Skip to content

Commit 13e254d

Browse files
committed
test(rest,core): give the hand-over route its census rows; word a refused row as the /data door does
The transfer report's per-row failure now carries the sentence the /data write door answers for the same throw (a hook's refusal reads as the hook's sentence, a crash is withheld and logged), through an optional describer the core helper takes. The census pins gain the new route and registrar. Claude-Session: https://claude.ai/code/session_01Utp6CZavYYLh5k5r6jhgCF Co-authored-by: Claude <noreply@anthropic.com>
1 parent 417089c commit 13e254d

8 files changed

Lines changed: 81 additions & 9 deletions

‎packages/core/src/security/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -549,6 +549,7 @@ export {
549549
type UserWorkEngine,
550550
type UserAssignedWorkCount,
551551
type UserAssignedWorkSummary,
552+
type UserWorkFailureDescriber,
552553
type UserWorkTransferFailure,
553554
type UserWorkTransferOutcome,
554555
type UserWorkTransferReport,

‎packages/core/src/security/second-object-read-exposure.pin.test.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,14 @@ const CALLERS: Record<string, Classification> = {
264264
'the validation-predicate reads (`resolvePredicateRelated`: a pass/fail verdict, the value never served), and ' +
265265
"`resolveOneHopRelated`, the one-hop loader they and the formula fields read through, as the caller's own `find`",
266266
},
267+
// [#30205] A user's assigned work: which fields hold work for a user.
268+
'packages/core/src/security/user-assigned-work.ts': {
269+
kind: 'not-a-served-read',
270+
reason:
271+
'picks the fields whose target is the user object, then counts and updates rows of the object that DECLARES ' +
272+
'them, under the caller\'s own context; the only read of the target is the hand-over recipient\'s ban state ' +
273+
'(a fixed platform object), and no field of it is served',
274+
},
267275
'packages/objectql/src/integrity/dangling-reference-audit.ts': {
268276
kind: 'not-a-served-read',
269277
reason: 'an integrity audit: it probes whether a stored id resolves and reports ids, never a field of the target',

‎packages/core/src/security/user-assigned-work.test.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,17 @@ describe('[#30205] transferUserAssignedWork', () => {
196196
expect(engine.tables.get('account')![0].owner_id).toBe('u_gone');
197197
});
198198

199+
it('a serving door may word a refused row the way its own write door answers it', async () => {
200+
const engine = fakeEngine(seed(), { refuseUpdate: (object, id) => object === 'ticket' && id === 't2' });
201+
const seen: string[] = [];
202+
const report = await transferUserAssignedWork(engine, 'u_gone', 'u_next', CALLER, {
203+
objects: ['ticket'],
204+
describeFailure: (_err, object) => { seen.push(object); return { code: 'FORBIDDEN', message: 'You may not change this record' }; },
205+
});
206+
expect(report.objects[0].failures).toEqual([{ id: 't2', code: 'FORBIDDEN', message: 'You may not change this record' }]);
207+
expect(seen).toEqual(['ticket']);
208+
});
209+
199210
it('moves at most the row cap per call and answers what remains', async () => {
200211
const engine = fakeEngine(seed());
201212
const first = await transferUserAssignedWork(engine, 'u_gone', 'u_next', CALLER, { rowCap: 3 });

‎packages/core/src/security/user-assigned-work.ts‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,16 @@ export interface UserWorkTransferReport {
241241
remaining: number;
242242
}
243243

244-
function failureOf(id: unknown, err: unknown): UserWorkTransferFailure {
244+
/**
245+
* How a serving door words a refused row: the code and the caller-facing
246+
* sentence its own write door would answer for the same throw. `undefined`
247+
* keeps the raw reading below.
248+
*/
249+
export type UserWorkFailureDescriber = (err: unknown, object: string) => { code: string; message: string } | undefined;
250+
251+
function failureOf(id: unknown, err: unknown, object: string, describe?: UserWorkFailureDescriber): UserWorkTransferFailure {
252+
const described = describe?.(err, object);
253+
if (described) return { id: String(id), code: described.code, message: described.message.slice(0, 500) };
245254
const e = err as { code?: unknown; message?: unknown } | null;
246255
return {
247256
id: String(id),
@@ -263,7 +272,7 @@ export async function transferUserAssignedWork(
263272
fromUserId: string,
264273
toUserId: string,
265274
context: unknown,
266-
options: { objects?: readonly string[]; rowCap?: number } = {},
275+
options: { objects?: readonly string[]; rowCap?: number; describeFailure?: UserWorkFailureDescriber } = {},
267276
): Promise<UserWorkTransferReport> {
268277
if (fromUserId === toUserId) {
269278
throw new UserWorkTransferRefusal(400, 'VALIDATION_ERROR', 'toUserId must name a different user than the one whose work is transferred');
@@ -312,7 +321,7 @@ export async function transferUserAssignedWork(
312321
outcome.updated += 1;
313322
} catch (err) {
314323
outcome.failed += 1;
315-
if (outcome.failures.length < USER_WORK_FAILURE_SAMPLE) outcome.failures.push(failureOf(row.id, err));
324+
if (outcome.failures.length < USER_WORK_FAILURE_SAMPLE) outcome.failures.push(failureOf(row.id, err, ref.object, options.describeFailure));
316325
}
317326
}
318327
try {

‎packages/rest/src/objectql-slot-consumer-census.test.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,8 @@ const SLOT_FILES = [
153153
'batch-routes.ts',
154154
'meta-object-state-routes.ts',
155155
'meta-item-history-routes.ts',
156+
// [#30205] A user's assigned work: the counts and the hand-over (the SIXTH consumer).
157+
'user-work-routes.ts',
156158
] as const;
157159
const SOURCES = new Map<string, string>(SLOT_FILES.map((file) => [
158160
file, file === 'rest-server.ts' ? SOURCE : readFileSync(resolve(HERE, file), 'utf8'),
@@ -228,12 +230,13 @@ describe('[#18546] §1 the `objectQLProvider` consumer census is mechanical', ()
228230
// 5 UNCHANGED, over four files: 2 in `rest-server.ts` (the class head's
229231
// two), the batch door's 1, the state-machine read's 1 in
230232
// `meta-object-state-routes.ts` and `/published`'s 1 in
231-
// `meta-item-history-routes.ts`.
233+
// `meta-item-history-routes.ts`. [#30205] 6: a user's assigned work, its
234+
// one site in `user-work-routes.ts`, shared by both of its routes.
232235
const sites = censusInvocationSites('objectQLProvider');
233236
// The message carries the sites, so a failure here hands the next
234237
// reader the new census instead of a bare number.
235238
expect(sites.map((s) => `${s.file}:${s.line}: ${s.text}`).join('\n')).toBeTruthy();
236-
expect(sites).toHaveLength(5);
239+
expect(sites).toHaveLength(6);
237240
});
238241

239242
it('⭐ [#18559] ALL FIVE consumers reach the slot through `wiredEngineOrLoud` — none reads it directly', () => {
@@ -249,7 +252,7 @@ describe('[#18546] §1 the `objectQLProvider` consumer census is mechanical', ()
249252
// the site rather than a bare number.
250253
expect(direct.map((s) => `${s.file}:${s.line}: ${s.text}`).join('\n')).toBe('');
251254
expect(direct).toHaveLength(0);
252-
expect(viaSeam).toHaveLength(5);
255+
expect(viaSeam).toHaveLength(6);
253256

254257
// ⭐ CONTROL for the window predicate itself, on BOTH axes. It reads a
255258
// WINDOW of source text, so "3 of 3" is only a reading if the same

‎packages/rest/src/rest-write-response-internal-fields.tripwire.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,8 @@ const DISPOSITIONS: Record<string, Disposition> = {
397397
'POST /api/v1/security/suggested-bindings/:id/dismiss': { kind: 'no-record-echo', why: 'Dismissal receipt.' },
398398
'POST /api/v1/security/suggested-bindings/:id/reopen': { kind: 'no-record-echo', why: 'Re-open receipt.' },
399399
'POST /api/v1/security/explain': { kind: 'no-record-echo', why: 'Permission explanation verdict.' },
400+
// [#30205] The hand-over answers per-field counts and failed row ids, never a record.
401+
'POST /api/v1/security/users/:id/assigned-work/transfer': { kind: 'no-record-echo', why: 'Transfer report: per-field counts and failed row ids (id, code, refusal sentence), never a record body.' },
400402

401403
// Import: per-row RECEIPTS (row number, action, id, warnings/errors) plus
402404
// counts — measured, the written row is never spread into a result.

‎packages/rest/src/rest-write-route-hook-refusal-sentence.ledger.test.ts‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -154,13 +154,22 @@ function boot(error: () => Error, protocol: Record<string, Fn> = {}): Booted {
154154
}
155155
const service = (label: string) => async () => rejecting(label, calls, error);
156156
const caller = { userId: 'u1', systemPermissions: ['manage_metadata', 'manage_platform_settings'] };
157-
const engine = { transaction: async (fn: Fn) => fn({}) };
157+
// [#30205] The assigned-work hand-over reads and writes through the data
158+
// engine itself: one user field to move, an active target, and a row whose
159+
// `update` the refusal reaches.
160+
const engine = {
161+
transaction: async (fn: Fn) => fn({}),
162+
getConfigs: () => ({ [OBJECT]: { name: OBJECT, fields: { owner_id: { type: 'lookup', reference: 'sys_user' } } } }),
163+
find: async (object: string) => (object === 'sys_user' ? [{ id: 'u2', banned: false }] : [{ id: 'r1' }]),
164+
count: async () => 0,
165+
update: async () => { calls.push('engine.update'); throw error(); },
166+
};
158167
const rest = new RestServer(
159168
server,
160169
rejecting('protocol', calls, error, recorded),
161170
{ api: { requireAuth: false } } as any,
162171
undefined, undefined, undefined, undefined,
163-
async () => engine, // objectQLProvider (`POST /batch`)
172+
async () => engine, // objectQLProvider (`POST /batch`, the assigned-work hand-over)
164173
service('email'),
165174
service('sharing'),
166175
undefined, // retired slot
@@ -341,6 +350,12 @@ const ANSWERS_THE_SENTENCE: Record<string, Driver> = {
341350
},
342351
},
343352
'POST /api/v1/security/explain': { called: 'security.explain', body: { object: OBJECT, operation: 'read' } },
353+
// [#30205] The hand-over answers per row, inside its `200`: a refused row's
354+
// entry in `failures` carries the `/data` door's sentence for that throw.
355+
'POST /api/v1/security/users/:id/assigned-work/transfer': {
356+
called: 'engine.update', body: { toUserId: 'u2' },
357+
answer: (res) => res.body?.data?.objects?.[0]?.failures?.[0]?.message,
358+
},
344359
'POST /api/v1/data/:object/:id/shares': { called: 'sharing.grant', body: {} },
345360
'DELETE /api/v1/data/:object/:id/shares/:shareId': { called: 'sharing.revoke' },
346361
'POST /api/v1/batch': {

‎packages/rest/src/user-work-routes.ts‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
UserWorkTransferRefusal,
3838
type UserWorkEngine,
3939
} from '@objectstack/core';
40+
import { handleRouteError } from './error-response.js';
4041
import { refuseUnknownQueryParams } from './query-allowlist.js';
4142
import { wiredEngineOrLoud, type RestServer } from './rest-server.js';
4243

@@ -55,6 +56,25 @@ export const USER_WORK_QUERY_PARAMS: readonly string[] = [];
5556
/** The keys a transfer body may carry. */
5657
const TRANSFER_BODY_KEYS = new Set(['toUserId', 'objects']);
5758

59+
/**
60+
* A refused row, worded the way the `/data` write door answers the same throw:
61+
* the shared classification (`handleRouteError`) sent to a sink, so a hook's
62+
* refusal reads as the hook's sentence, a crash or a store fault has its words
63+
* withheld and is logged, and nothing here is a second rule.
64+
*/
65+
function rowFailureAnswer(err: unknown, object: string): { code: string; message: string } {
66+
const sink: { body?: Record<string, unknown>; status(code: number): typeof sink; json(body: Record<string, unknown>): typeof sink } = {
67+
status() { return sink; },
68+
json(body) { sink.body = body; return sink; },
69+
};
70+
handleRouteError(sink, err, object);
71+
const body = sink.body ?? {};
72+
return {
73+
code: typeof body.code === 'string' && body.code.length > 0 ? body.code : 'UPDATE_FAILED',
74+
message: typeof body.error === 'string' ? body.error : 'The record could not be updated',
75+
};
76+
}
77+
5878
/**
5979
* Register the assigned-work routes — `RestServer#registerUserWorkEndpoints`'
6080
* body, called by that method with the server as `this`.
@@ -127,7 +147,10 @@ export function registerUserWorkEndpoints(this: UserWorkRouteHost, basePath: str
127147
const engine = await resolveEngine(environmentId, req);
128148
if (!engine) return sendError(res, 501, 'NOT_IMPLEMENTED', 'No data engine is configured on this deployment');
129149
try {
130-
const report = await transferUserAssignedWork(engine, userId, toUserId, context, { objects });
150+
const report = await transferUserAssignedWork(engine, userId, toUserId, context, {
151+
objects,
152+
describeFailure: rowFailureAnswer,
153+
});
131154
return res.json({ success: true, data: report });
132155
} catch (err) {
133156
if (!(err instanceof UserWorkTransferRefusal)) throw err;

0 commit comments

Comments
 (0)