From 12e48a7d364423fa1bab8fde6d87ab1b2c3f4df9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 14:59:51 +0000 Subject: [PATCH 1/4] refactor(rest): move the record-sharing and sharing-rule registrar bodies into sharing-routes.ts (#23050 slice 4, move only) The bodies of RestServer#registerSharingEndpoints and #registerSharingRuleEndpoints move, unedited and re-indented one level, to packages/rest/src/sharing-routes.ts. The class keeps both private methods and their docblocks as a host literal plus .call; the registrar list gains the module. Readers are re-pointed in the following commits. Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU Co-authored-by: Claude --- .../rest/src/rest-server-registrar-files.ts | 2 + packages/rest/src/rest-server.ts | 690 +--------------- packages/rest/src/sharing-routes.ts | 766 ++++++++++++++++++ 3 files changed, 801 insertions(+), 657 deletions(-) create mode 100644 packages/rest/src/sharing-routes.ts diff --git a/packages/rest/src/rest-server-registrar-files.ts b/packages/rest/src/rest-server-registrar-files.ts index 47e9f68380b..baea6143d29 100644 --- a/packages/rest/src/rest-server-registrar-files.ts +++ b/packages/rest/src/rest-server-registrar-files.ts @@ -35,6 +35,8 @@ export const REST_SERVER_REGISTRAR_FILES = [ 'batch-routes.ts', // [#23050 slice 3] `registerApprovalsEndpoints`' body. 'approvals-routes.ts', + // [#23050 slice 4] `registerSharingEndpoints`' and `registerSharingRuleEndpoints`' bodies. + 'sharing-routes.ts', ] as const; /** One file on {@link REST_SERVER_REGISTRAR_FILES}. */ diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index da10d3841b4..5edd366aad5 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -108,9 +108,9 @@ import { refuseUnknownQueryParams } from './query-allowlist.js'; import type { DirectMountedRoute, MountedRouteSource } from './direct-mount.js'; import { RestServerConfig, RestApiConfigParsed } from '@objectstack/spec/api'; // [#11683] The catalog's own floor for "a required `code` and no more specific -// one" — see its use in `registerSharingEndpoints`, where the nested ADR-0112 -// envelope declares `code` REQUIRED while the flat classification it re-dresses -// legitimately carries none. +// one" — see its use in `registerSharingEndpoints` (`sharing-routes.ts` since +// #23050 slice 4), where the nested ADR-0112 envelope declares `code` REQUIRED +// while the flat classification it re-dresses legitimately carries none. import { standardErrorCodeForHttpStatus } from '@objectstack/spec/api'; // [#11637] The DECLARED contract for `config.api`, imported as a VALUE rather // than a type. Both hops into this package were casts, so this schema had @@ -318,6 +318,13 @@ import { registerDataActionEndpoints, type DataActionRouteHost, type ServerScope import { registerBatchEndpoints, type BatchRouteHost } from './batch-routes.js'; // [#23050 slice 3] The approval routes' body moved to its own module — see its header. import { registerApprovalsEndpoints, type ApprovalsRouteHost } from './approvals-routes.js'; +// [#23050 slice 4] The record-sharing and sharing-rule routes' bodies moved to one module — see its header. +import { + registerSharingEndpoints, + registerSharingRuleEndpoints, + type SharingRouteHost, + type SharingRuleRouteHost, +} from './sharing-routes.js'; // [#17551] The `POST …/analytics/dataset/query` door parse — the half of the // analytics family this route never had. See the module header for the // measurement that decides its shape. @@ -11014,449 +11021,19 @@ export class RestServer { * `@objectstack/plugin-sharing` plugin fails cleanly. */ private registerSharingEndpoints(basePath: string): void { - const { crud } = this.config; - const dataPath = `${basePath}${crud.dataPrefix}`; - const isScoped = basePath.includes('/environments/:environmentId'); - - const resolveService = async (environmentId?: string) => { - if (!this.sharingServiceProvider) return undefined; - try { return await this.sharingServiceProvider(environmentId); } - catch { return undefined; } - }; - /** - * [#8111] The ONE refusal emitter for the record-sharing family — the - * 501, all five mapped verdicts and all three 500s go through it, so - * "list, grant and revoke answer the same shape" is a property of the - * code rather than of nine literals that happen to agree. - * - * Before this, the family carried BOTH dialects ADR-0112 D5 retires: - * `respond501` was flat `{ code, message }` and every other arm was - * `{ code, error: '' }`, so `body.error.code` — the one - * position D5 declares — read `undefined` on all nine. #7035 - * (PR #7293) had already removed both from this file's `/meta` - * refusals, #7981 (PR #8071) from `registerSecurityEndpoints` and - * #8073 (PR #8174) from the `/security/explain` pair. - * - * Emitted through the SHARED builder (`sendError` from - * `@objectstack/types`, imported as `sendEnvelopeError` — see the note - * at the import). That is what makes this the reference shape by - * construction rather than a tenth local literal agreeing with the nine - * it replaced, and it types `code` to the closed ADR-0112 vocabulary - * for free. - * - * ⛔ Status codes are untouched and no code VALUE moves: only the - * POSITION of `code` and `message` changes. - */ - const respondError = ( - res: any, - status: number, - code: ErrorCode, - message: string, - // [#12510] The shared writer's OWN `extra` type, referenced rather - // than restated: this wrapper decides POSITION (the nested D5 - // envelope), never which channels exist. A local `{ declaredCode?: - // string }` would be a second, narrower declaration of a set - // `sendError` already owns — the shape that silently stops - // forwarding the next channel admitted there. - extra?: Parameters[4], - ): void => sendEnvelopeError(res, status, code, message, extra); - - const respond501 = (res: any) => respondError( - res, 501, 'NOT_IMPLEMENTED', - 'Sharing service is not configured on this deployment', - ); - /** - * [#12693] The `extra` this family's TWO NON-CLASSIFIED exits owe a - * producer that marked its refusal: the 500 fault terminal below, and - * the ADR-0111 prefix arm inside {@link respondSharingError}. - * - * Neither reaches {@link classifiedRefusalAnswer} — the 500 terminal - * because a declared server fault is deliberately not a refusal - * (that function's own docblock rules it back to "the catching route's - * own terminal", which is these three arms), the prefix arm because it - * runs precisely when the classification answered `undefined`. So - * neither holds a `refusal.body` to re-dress, and the one line the - * classified arm uses (`refusal.body.userMessage`, #12669) is NOT - * reusable here. What is reusable is the RULE, and - * {@link boundedDeclaredUserMessage} is that rule asked of the raw - * thrown error instead of the classification: `declaredUserMessage`'s - * presence answer with #5423's bound applied, one definition, shared - * with the `/data` door rather than copied beside it. - * - * Measured on `15bf9e859` before the repair, one producer per exit - * through the real routes on both doors: - * - * ```text - * throw { code: 'SHARE_STORE_DOWN', status: 503, userMessage: '…' } - * share door : 500 SHARES_LIST_FAILED — no mark - * /data door : 503 SERVICE_UNAVAILABLE — mark carried - * throw Error('NOT_FOUND: no such record …') + userMessage - * share door : 404 NOT_FOUND — no mark - * /data door : 500 INTERNAL_ERROR — mark carried - * ``` - * - * ⛔ The mark is the ONLY thing this adds, and the two doors' other - * disagreements visible in that measurement stay exactly as they are: - * the share family folds a declared 503 into its own 500 terminal, and - * it interpolates the caught message where `/data` withholds 5xx prose - * unconditionally (#5437). Both are deliberate and argued in - * {@link sharingFaultMessage} and the #11683 docblock below; the - * `/data` door's status for the prefix arm differs for a third - * deliberate reason — the prefix idiom is this service's local - * convention and no shared classifier can read it (ADR-0111). - * - * ⛔ Riding the mark across a FAULT terminal is not a re-opening of - * #5437 either, and the reason is `withDeclaredUserMessage`'s, not a - * second one: the withheld text is prose the producer never addressed - * to the caller, while this field exists only because an author wrote - * caller-facing text onto it. A genuine crash carries no mark and its - * envelope is byte-identical to before. - */ - const sharingDeclaredExtra = ( - error: any, - ): { userMessage: string } | undefined => { - const userMessage = boundedDeclaredUserMessage(error); - return userMessage === undefined ? undefined : { userMessage }; - }; - // [ADR-0111] The service enforces authorization (D1/D4/D5/D7) and - // signals the verdict via message prefixes, the plugin's established - // error idiom — this maps them onto HTTP. Returns true when handled. - // - // [#8111] The prefix is a SERVER-INTERNAL service→REST derivation. - // ⚠️ [#13095] This comment used to claim the MECHANISM guaranteed - // that: "it is stripped below and never reaches the wire". That was - // true of the prefix-idiom arm it was written about and FALSE for the - // classified limb #11683 added beside it, which re-dresses - // `resolveErrorResponse`'s answer — an answer that shipped the prefix - // inside the sentence until the 2026-08-31 ruling converged that arm - // onto the same declared-code-anchored strip. Even now the strip is - // ANCHORED (it removes only a prefix restating the declared code), so - // "never reaches the wire" is not a mechanism anyone may lean on. - // What holds instead is MEASURED, not guaranteed: no consumer - // branches on the prefix in the wire `error` text — censused at - // #8111's claim and re-censused 2026-09-01 (objectstack + objectui: - // zero wire readers; every `startsWith(CODE)` hit is this file's own - // route mappings or an in-process producer-side check; `cloud` was - // not reachable and is NOT measured). The prefix idiom itself stays - // exactly as it is; #8111 moved only the response SHAPE. - // - // [#11683] …and it stays exactly as it is here too. What moved is that - // the prefix read is no longer the FIRST question, and no longer the - // only one. It is the ADR-0111 idiom for producers that declare - // nothing else, and the census that made it safe to keep is still - // true: every throw site in `plugin-sharing/src/sharing-service.ts` - // (11 of them, re-censused at claim) is a bare `Error` carrying one of - // these prefixes and NO `code`, NO `status`. Backward compatibility is - // therefore not a courtesy — it is the only channel this service has. - // - // What it could never read is a refusal that DID declare itself, and - // two of those reach these three catches today: - // - // - `plugin-sharing`'s own write gate throws - // `{ code: 'FORBIDDEN', status: 403 }` (`sharing-plugin.ts`). - // `FORBIDDEN` is not one of the five, so a refusal that declared - // 403 twice over was answered `500 SHARE_*_FAILED`. - // - A sandboxed hook on the `sys_record_share` write arrives with - // the QuickJS debug wrapper on `.message` and the business text on - // `.innerMessage`. The wrapper IS the prefix, so nothing matched - // and the wrapper was interpolated verbatim into the 500 — #11588's - // leak on a branch #11588 did not reach. - // - // Both are asked FIRST now, through {@link classifiedRefusalAnswer} — - // the `/data` door's own classification, so this family cannot answer - // a refusal differently from every other face that catches it. A - // declaration outranking a message read is the whole point: this - // route's defect was that classification was a property of how the - // sentence happened to start. - // - // ⛔ The DIALECT does not move. #8111 converted these arms onto the - // nested ADR-0112 D5 envelope and the `check:route-envelope` ratchet - // only ticks down, so the classification is re-dressed through - // `respondError` rather than sent by `handleRouteError` (which speaks - // the flat dialect). Vocabulary and position stay two decisions. - const respondSharingError = (res: any, error: any): boolean => { - // A refusal the producer classified — answered exactly as `/data` - // answers it. `undefined` for everything else, which falls to the - // prefix idiom below and then to the caller's own 500 arm, both - // unchanged. - const refusal = classifiedRefusalAnswer(error); - if (refusal) { - // `code` is REQUIRED by the nested envelope, and the flat - // classification legitimately carries none for an undeclared - // sandbox refusal (ADR-0112 D4 governs the semantic-CODE - // channel: the producer names the condition on the CODE axis, - // so nothing is invented for the half it did not name; the ADR - // rules no HTTP status here, and the phrase is - // `error-response.ts`'s own prose rather than an ADR - // quotation). The - // catalog's own floor fills the required field — - // `standardErrorCodeForHttpStatus`, whose docblock exists for - // exactly this ("Total by construction: a producer can always - // fill a required `code`") and which is the same derivation - // `resolveThrownHttpError` applies at every other door. This - // is the one place the two dialects genuinely differ: the flat - // body may omit `code`, the nested one may not. - // - // [#12510] …and the producer's OWN spelling travels with it. - // An UNREGISTERED thrown code is demoted by the shared rule to - // a `declaredCode` sibling (ADR-0112 #9232) — the open, - // author-authored channel `ApiErrorSchema` has declared since - // #9106. This family used to drop it: the classification below - // was already holding the demoted string and only `code` and - // the message were re-dressed, so an app's spelling vanished - // here while the flat `/data` door carried it. Nothing invalid - // shipped — the closed `code` still carried the member the - // status derives — which is exactly what made the loss silent - // and one-directional: a consumer told by ADR-0112 to read - // `declaredCode` found nothing at this door. - // - // ⛔ The reason that used to stand here said `sendError`'s - // `extra` would not accept the field. That was true when it was - // written and false since #11719 / `db8c288` (PR #12403) added - // `declaredCode` to the writer's `Pick`. A stale sentence that - // discourages a repair costs more than one that misdescribes a - // mechanism, so it is recorded rather than merely deleted. - // - // ⛔ Read the CLASSIFICATION's field, never the resolver's raw - // `thrown.declaredCode`. Presence MEANS demotion - // (`ApiErrorSchema.declaredCode`'s documented invariant) and - // the raw field is set for a REGISTERED spelling too — - // measured: a producer throwing `{ code: 'RECORD_LOCKED', - // status: 409 }` resolves with `declaredCode: 'RECORD_LOCKED'` - // sitting beside an identical `code`, and forwarding that would - // put two spellings of one fact on every registered refusal. - // `refusal.body.declaredCode` is the answer AFTER - // `demotedDeclaredCode` (`error-response.ts`'s - // `thrownCodeFields`, the same one definition the dispatcher - // door reads), so the invariant arrives with the value. - // - // ⭐ Why re-dress rather than re-resolve: this door asks - // {@link classifiedRefusalAnswer} ONCE and re-dresses that one - // answer, exactly as it does for `status`, `code` and the - // message. Calling the resolver a second time here would be a - // second answer to a question already asked — the shape that - // let two `/api/v1/packages` doors drift apart (#12405). The - // pair is carried, not recomputed: `code` and `declaredCode` - // leave this door as the pair `thrownCodeFields` produced. - const code = typeof refusal.body.code === 'string' - ? refusal.body.code as ErrorCode - : standardErrorCodeForHttpStatus(refusal.status); - const declaredCode = typeof refusal.body.declaredCode === 'string' - ? refusal.body.declaredCode - : undefined; - // [#12669] …and so does the sentence the producer addressed to - // the CALLER. The flat `/data` door attaches it in - // `withDeclaredUserMessage` (`error-response.ts`, commit 79c46da90) and - // the one classification asked above is already holding the - // result; this family dropped it at the same re-dress, with the - // same one-directional silence — an author's own remedy text - // reaching `/data` and vanishing here. - // - // ⛔ `userMessage` and `declaredCode` are NOT symmetric, and - // the next reader will assume they are because they arrive on - // the same line. `declaredCode` is read from the CLASSIFICATION - // because presence there MEANS demotion — an invariant the raw - // thrown field does not carry, which is the whole subject of - // the paragraphs above (⛔ not restated here: #12510 owns that - // derivation and one reason with two copies is this lane's own - // recurring defect). `userMessage` has NO invariant left for a - // caller to re-derive. `declaredUserMessage` already decided - // PRESENCE — the field exists on an error only because its - // producer deliberately wrote end-user text with no host state - // onto it (an application hook, or a platform refusal carrying - // static guidance such as the packaged-permission-set lock's; - // platform and driver diagnostics never set it) — and - // `truncateClientMessage` already applied #5423's bound to the - // value. Reading `refusal.body.userMessage` IS the rule; there - // is no second function to run it through, and running one - // would be a second answer to a question the classification - // has already answered. - // - // The `typeof` guard below is therefore not that invariant. It - // is the same non-string floor `code` and `declaredCode` carry - // two lines up: a producer may put anything on a thrown object, - // and a number arriving in a string channel is the #3842 drift. - // - // ⛔ SCOPE: `userMessage` only. The producer's structured - // context — the flat body's top-level `issues` — is the other - // half of #12669 and is deliberately NOT forwarded here. The - // nested envelope's channel for it is `ApiError.details`, so - // mapping one onto the other is a SHAPE decision on a contract - // field rather than a rename, and #12669 is open on it. - const userMessage = typeof refusal.body.userMessage === 'string' - ? refusal.body.userMessage - : undefined; - respondError( - res, refusal.status, code, String(refusal.body.error ?? ''), - { - ...(declaredCode !== undefined ? { declaredCode } : {}), - ...(userMessage !== undefined ? { userMessage } : {}), - }, - ); - return true; - } - const msg = String(error?.message ?? error ?? ''); - const map: Array<[ErrorCode, number]> = [ - ['VALIDATION_FAILED', 400], - ['PERMISSION_DENIED', 403], - ['NOT_FOUND', 404], - ['CONFLICT', 409], - ['SHARING_NOT_ENABLED', 422], - ]; - for (const [code, status] of map) { - if (msg.startsWith(code)) { - // [#12693] …and the producer's own sentence to the caller - // rides this arm too. ⛔ Only the sentence: the PREFIX - // read, the status it decides and the stripping below are - // untouched — see {@link sharingDeclaredExtra}. - respondError( - res, status, code, - msg.replace(new RegExp(`^${code}:\\s*`), ''), - sharingDeclaredExtra(error), - ); - return true; - } - } - return false; - }; - /** - * [#11683] The text the three 500 arms may put on the wire. - * - * The arms interpolate the caught error's own message, and that is - * unchanged for every ordinary fault — `sharing-envelope.test.ts` pins - * a plain `Error('boom')` arriving as `boom` and it still does. - * - * The one shape it withholds is a SANDBOX error that reached a 500 at - * all, which after the classification above means one thing: a hook - * body that CRASHED rather than refused (`isScriptFaultMessage`, - * #7543). Its `.message` is the QuickJS debug wrapper and its - * `.innerMessage` is a `TypeError: …` — the wrapper the card measured - * leaking, wrapped around a runtime fault the caller must not read - * either. `/data` answers that case `INTERNAL_ERROR_MESSAGE` through - * {@link UNCLASSIFIED_FAULT}; this says the same sentence, so the leak - * is closed on this family unconditionally rather than only for the - * refusals the classification door catches. - * - * The full wrapper still reaches the operator: every caller logs the - * whole error object immediately above its `respondError`. - * - * [#22885] …and the sandbox read is the doors' own now. The runner's - * OWN fault (a denied capability, a missing `ctx.api` method, a - * budget) carries no `innerMessage`, so the read above let it through - * and these arms relayed the runner's diagnostic text. The sentence is - * {@link sandboxFaultAnswer}'s, the answer the other hand-built - * families give every sandbox fault, so a crash, a declared 5xx and an - * own fault read one withheld sentence here as there. ⛔ Only the - * SENTENCE: this family's own `500` and `SHARE_*_FAILED` code stay the - * recorded exception the write-route ledger names (#22764's - * `OWN_FAULT_TERMINAL`), a decision this does not take. The - * `innerMessage` read stays for any sandbox origin that answer declines. - */ - const sharingFaultMessage = (error: any): string => { - const fault = sandboxFaultAnswer(error); - if (fault !== undefined) return nestedRefusalEnvelope(fault).message; - return typeof error?.innerMessage === 'string' && error.innerMessage - ? INTERNAL_ERROR_MESSAGE - : String(error?.message ?? error).slice(0, 500); + // [#23050 slice 4] The body moved to `sharing-routes.ts`. This literal + // hands it every member it reads — the list IS its dependency on this + // class (see `SharingRouteHost`, and that module's header for why + // `sharingServiceProvider` is a getter). + const readSharingServiceProvider = () => this.sharingServiceProvider; + const host: SharingRouteHost = { + config: this.config, + routeManager: this.routeManager, + get sharingServiceProvider() { return readSharingServiceProvider(); }, + resolveExecCtx: (...a) => this.resolveExecCtx(...a), + enforceAuth: (...a) => this.enforceAuth(...a), }; - - // GET — list shares on a record. [ADR-0111 D5] Management-gated in the - // service: invisible record → 404, visible-but-not-manager → 403. - this.routeManager.register({ - method: 'GET', - path: `${dataPath}/:object/:id/shares`, - handler: async (req: any, res: any) => { - try { - const environmentId = isScoped ? req.params?.environmentId : undefined; - const context = await this.resolveExecCtx(environmentId, req); - if (this.enforceAuth(req, res, context)) return; - const svc = await resolveService(environmentId); - if (!svc) return respond501(res); - const rows = await svc.listShares(req.params.object, req.params.id, context ?? {}); - res.json({ data: rows }); - } catch (error: any) { - if (respondSharingError(res, error)) return; - logError('[REST] List shares error:', error); - // The 500 arms keep their 500-char cap: an unexpected - // fault's message is not a contract, and truncating it - // stays a sanitization step — only the position moves. - respondError( - res, 500, 'SHARES_LIST_FAILED', sharingFaultMessage(error), - sharingDeclaredExtra(error), - ); - } - }, - metadata: { summary: 'List per-record sharing grants', tags: ['sharing'] }, - }); - - // POST — grant access. [ADR-0111 D1/D7] Authorization + posture live - // in the service; this route only maps verdicts (403/404/422/400). - this.routeManager.register({ - method: 'POST', - path: `${dataPath}/:object/:id/shares`, - handler: async (req: any, res: any) => { - try { - const environmentId = isScoped ? req.params?.environmentId : undefined; - const context = await this.resolveExecCtx(environmentId, req); - if (this.enforceAuth(req, res, context)) return; - const svc = await resolveService(environmentId); - if (!svc) return respond501(res); - const body = req.body ?? {}; - const input = { - object: req.params.object, - recordId: req.params.id, - recipientType: body.recipientType ?? body.recipient_type, - recipientId: body.recipientId ?? body.recipient_id, - accessLevel: body.accessLevel ?? body.access_level, - source: body.source, - sourceId: body.sourceId ?? body.source_id, - reason: body.reason, - }; - const row = await svc.grant(input, context ?? {}); - res.status(201).json(row); - } catch (error: any) { - if (respondSharingError(res, error)) return; - logError('[REST] Grant share error:', error); - respondError( - res, 500, 'SHARE_GRANT_FAILED', sharingFaultMessage(error), - sharingDeclaredExtra(error), - ); - } - }, - metadata: { summary: 'Grant a per-record share to a principal', tags: ['sharing'] }, - }); - - // DELETE — revoke a share by id. [ADR-0111 D4] The URL's - // (object, id) is forwarded as the revoke scope so a share id can only - // be revoked through the record it belongs to; the service enforces - // management authority and the manual-source rule (409). - this.routeManager.register({ - method: 'DELETE', - path: `${dataPath}/:object/:id/shares/:shareId`, - handler: async (req: any, res: any) => { - try { - const environmentId = isScoped ? req.params?.environmentId : undefined; - const context = await this.resolveExecCtx(environmentId, req); - if (this.enforceAuth(req, res, context)) return; - const svc = await resolveService(environmentId); - if (!svc) return respond501(res); - await svc.revoke( - req.params.shareId, - context ?? {}, - { object: req.params.object, recordId: req.params.id }, - ); - res.status(204).end(); - } catch (error: any) { - if (respondSharingError(res, error)) return; - logError('[REST] Revoke share error:', error); - respondError( - res, 500, 'SHARE_REVOKE_FAILED', sharingFaultMessage(error), - sharingDeclaredExtra(error), - ); - } - }, - metadata: { summary: 'Revoke a per-record share by id', tags: ['sharing'] }, - }); + registerSharingEndpoints.call(host, basePath); } /** @@ -11472,219 +11049,18 @@ export class RestServer { * Returns 501 when no sharing-rule service is configured. */ private registerSharingRuleEndpoints(basePath: string): void { - // Sharing-rule routes live at the top of the API surface (e.g. - // `/api/v1/sharing/rules`) — they administer rules across the whole - // tenant rather than acting on a single CRUD object, so anchoring - // them on `basePath` keeps them out of the `/data/:object` namespace - // where greedy CRUD matchers would otherwise swallow them. - const dataPath = basePath; - const isScoped = basePath.includes('/environments/:environmentId'); - - const resolveService = async (environmentId?: string) => { - if (!this.sharingRulesServiceProvider) return undefined; - try { return await this.sharingRulesServiceProvider(environmentId); } - catch { return undefined; } - }; - const respond501 = (res: any) => res.status(501).json({ - code: 'NOT_IMPLEMENTED', - message: 'Sharing-rule service is not configured on this deployment', - }); - const handleError = (err: any, res: any, defaultCode: string) => { - const msg = String(err?.message ?? err ?? ''); - if (msg.startsWith('VALIDATION_FAILED')) { - return res.status(400).json({ code: 'VALIDATION_FAILED', error: msg.replace(/^VALIDATION_FAILED:\s*/, '') }); - } - // [ADR-0111 D6] The service gates every verb on `manage_sharing` - // (enforced there so non-REST callers are covered too) — map its - // verdict rather than burying it in a 500. - if (msg.startsWith('PERMISSION_DENIED')) { - return res.status(403).json({ code: 'PERMISSION_DENIED', error: msg.replace(/^PERMISSION_DENIED:\s*/, '') }); - } - if (msg.startsWith('RULE_NOT_FOUND')) { - return res.status(404).json({ code: 'RULE_NOT_FOUND', error: msg.replace(/^RULE_NOT_FOUND:?\s*/, '') }); - } - // [ADR-0111 D7 / #8207] `POST .../evaluate` reconciles through - // `SharingService.grant`, whose inertness guard now runs for the - // evaluator's system context too. A rule pointed at an object no - // sharing gate consults therefore refuses here instead of silently - // materialising rows nothing reads — and the admin who asked for - // the evaluation needs to be told WHICH object and WHY, not handed - // an opaque 500. Same code→status pair the per-record shares routes - // already publish (`respondSharingError`), so no new contract. - // - // ⚠️ Built through the SHARED `sendError` envelope, unlike the three - // arms above it. Those are the flat dialect — `code` beside `error` - // instead of inside it, so `body.error.code` reads `undefined` — - // held down by the `check:route-envelope` ratchet, which only ticks - // DOWN. A new arm copying its neighbours' shape is exactly what that - // ratchet exists to stop, so this one answers the envelope - // `BaseResponseSchema` declares. The asymmetry is the ratchet - // working; converting the other three is outstanding envelope- - // position work owned by that ratchet, not a rider on this card. - // - // ⚠️ Two card citations stood here and both were stale by the time - // anyone read them: #7035 closed 2026-08-10 (PR #7293, three `/meta` - // 501 handlers), and #8111 closed 2026-08-12 (PR #8212) having - // converged the record-sharing family it named. The ratchet's own - // baseline is the live owner of what is left; a closed card number - // is not. - if (msg.startsWith('SHARING_NOT_ENABLED')) { - return sendEnvelopeError( - res, 422, 'SHARING_NOT_ENABLED', - msg.replace(/^SHARING_NOT_ENABLED:\s*/, ''), - ); - } - // [#22719] A refusal none of the prefix arms above can read is asked - // of the `/data` door's own classification before the 500 below - // relays `.message`. For a sandboxed hook's refusal `.message` is - // the QuickJS debug wrapper, so `POST /rules`, `DELETE - // /rules/:idOrName` and `POST .../evaluate` answered `500` with - // `hook '' threw: Error: …` where every conforming write door - // answers the hook's own sentence. A refusal that DECLARED its - // envelope (`status`/`statusCode` + `code`) is answered at its own - // status too, as on every door that asks this. - // - // AFTER the prefix arms, deliberately: each of them answers a 4xx - // this family already publishes, and none can read a sandbox error - // (its `.message` opens with the wrapper, never a prefix), so asking - // here moves only what used to reach the 500. Emitted in the nested - // envelope like `SHARING_NOT_ENABLED` above, for that arm's reason. - const refusal = classifiedRefusalAnswer(err); - if (refusal) { - const nested = nestedRefusalEnvelope(refusal); - return sendEnvelopeError(res, nested.status, nested.code, nested.message, nested.extra); - } - logError(`[REST] sharing-rule ${defaultCode}:`, err); - // [#22764] …and a sandboxed body the doors answer as a FAULT — one - // that crashed, or a refusal that declared a 5xx — answers as the - // doors do, logged above and withheld from the caller. The 500 - // below relayed the QuickJS wrapper around the native error text. - // Nested, for the classified arm's reason. - const fault = sandboxFaultAnswer(err); - if (fault) { - const nested = nestedRefusalEnvelope(fault); - return sendEnvelopeError(res, nested.status, nested.code, nested.message, nested.extra); - } - return res.status(500).json({ code: defaultCode, error: msg.slice(0, 500) }); + // [#23050 slice 4] The body moved to `sharing-routes.ts`. This literal + // hands it every member it reads — the list IS its dependency on this + // class (see `SharingRuleRouteHost`, and that module's header for why + // `sharingRulesServiceProvider` is a getter). + const readSharingRulesServiceProvider = () => this.sharingRulesServiceProvider; + const host: SharingRuleRouteHost = { + routeManager: this.routeManager, + get sharingRulesServiceProvider() { return readSharingRulesServiceProvider(); }, + resolveExecCtx: (...a) => this.resolveExecCtx(...a), + enforceAuth: (...a) => this.enforceAuth(...a), }; - - // LIST - this.routeManager.register({ - method: 'GET', - path: `${dataPath}/sharing/rules`, - handler: async (req: any, res: any) => { - try { - const environmentId = isScoped ? req.params?.environmentId : undefined; - const context = await this.resolveExecCtx(environmentId, req); - if (this.enforceAuth(req, res, context)) return; - const svc = await resolveService(environmentId); - if (!svc) return respond501(res); - // [#6877] `object` reached `listRules` as an array; a - // repeated `?activeOnly=true` stopped matching and listed the - // INACTIVE rules too. - if (refuseRepeatedQueryParams(req, res, ['object', 'activeOnly'])) return; - const rows = await svc.listRules({ - object: req.query?.object, - activeOnly: req.query?.activeOnly === 'true' || req.query?.activeOnly === true, - }, context ?? {}); - res.json({ data: rows }); - } catch (err: any) { handleError(err, res, 'RULE_LIST_FAILED'); } - }, - metadata: { summary: 'List sharing rules', tags: ['sharing'] }, - }); - - // CREATE / UPSERT - this.routeManager.register({ - method: 'POST', - path: `${dataPath}/sharing/rules`, - handler: async (req: any, res: any) => { - try { - const environmentId = isScoped ? req.params?.environmentId : undefined; - const context = await this.resolveExecCtx(environmentId, req); - if (this.enforceAuth(req, res, context)) return; - const svc = await resolveService(environmentId); - if (!svc) return respond501(res); - const body = req.body ?? {}; - // Field-by-field pluck (not a schema parse): the authoring - // spec shape — CEL `condition` + `sharedWith{type,value}` — - // is not the runtime shape this endpoint takes, so unknown - // keys are dropped rather than rejected. [#3896] That made - // a typo (`criterias`) indistinguishable from "no criteria", - // which used to mean "share every record". `defineRule` now - // refuses a match-all criteria, so the typo surfaces as a - // 400 naming the field instead of a silent 201. - const input = { - name: body.name, - label: body.label, - description: body.description, - object: body.object ?? body.object_name, - criteria: body.criteria ?? body.criteria_json, - recipientType: body.recipientType ?? body.recipient_type, - recipientId: body.recipientId ?? body.recipient_id, - accessLevel: body.accessLevel ?? body.access_level, - active: body.active, - }; - const row = await svc.defineRule(input, context ?? {}); - res.status(201).json(row); - } catch (err: any) { handleError(err, res, 'RULE_DEFINE_FAILED'); } - }, - metadata: { summary: 'Create or upsert a sharing rule', tags: ['sharing'] }, - }); - - // GET - this.routeManager.register({ - method: 'GET', - path: `${dataPath}/sharing/rules/:idOrName`, - handler: async (req: any, res: any) => { - try { - const environmentId = isScoped ? req.params?.environmentId : undefined; - const context = await this.resolveExecCtx(environmentId, req); - if (this.enforceAuth(req, res, context)) return; - const svc = await resolveService(environmentId); - if (!svc) return respond501(res); - const row = await svc.getRule(req.params.idOrName, context ?? {}); - if (!row) return res.status(404).json({ code: 'RULE_NOT_FOUND' }); - res.json(row); - } catch (err: any) { handleError(err, res, 'RULE_GET_FAILED'); } - }, - metadata: { summary: 'Get a sharing rule by id or name', tags: ['sharing'] }, - }); - - // DELETE - this.routeManager.register({ - method: 'DELETE', - path: `${dataPath}/sharing/rules/:idOrName`, - handler: async (req: any, res: any) => { - try { - const environmentId = isScoped ? req.params?.environmentId : undefined; - const context = await this.resolveExecCtx(environmentId, req); - if (this.enforceAuth(req, res, context)) return; - const svc = await resolveService(environmentId); - if (!svc) return respond501(res); - await svc.deleteRule(req.params.idOrName, context ?? {}); - res.status(204).end(); - } catch (err: any) { handleError(err, res, 'RULE_DELETE_FAILED'); } - }, - metadata: { summary: 'Delete a sharing rule and its materialised grants', tags: ['sharing'] }, - }); - - // EVALUATE - this.routeManager.register({ - method: 'POST', - path: `${dataPath}/sharing/rules/:idOrName/evaluate`, - handler: async (req: any, res: any) => { - try { - const environmentId = isScoped ? req.params?.environmentId : undefined; - const context = await this.resolveExecCtx(environmentId, req); - if (this.enforceAuth(req, res, context)) return; - const svc = await resolveService(environmentId); - if (!svc) return respond501(res); - const result = await svc.evaluateRule(req.params.idOrName, context ?? {}); - res.json(result); - } catch (err: any) { handleError(err, res, 'RULE_EVALUATE_FAILED'); } - }, - metadata: { summary: 'Re-evaluate a sharing rule and reconcile grants', tags: ['sharing'] }, - }); + registerSharingRuleEndpoints.call(host, basePath); } /** diff --git a/packages/rest/src/sharing-routes.ts b/packages/rest/src/sharing-routes.ts new file mode 100644 index 00000000000..dfa1abd3df5 --- /dev/null +++ b/packages/rest/src/sharing-routes.ts @@ -0,0 +1,766 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#23050 slice 4] The record-sharing routes of `RestServer` — the per-record + * share family (`GET` / `POST {basePath}/data/:object/:id/shares` and + * `DELETE …/shares/:shareId`) and the sharing-rule family (list, create, read, + * delete and evaluate under `{basePath}/sharing/rules`) — moved out of + * `rest-server.ts` whole. + * + * A MOVE, not a redesign. The two functions below are the bodies of + * `RestServer#registerSharingEndpoints` and + * `RestServer#registerSharingRuleEndpoints` as they stood, each re-indented one + * level and otherwise unedited: the same routes, registered in the same order, + * by the same handlers, answering the same bodies. Each body's local helpers — + * the share family's `respondError`, `respondSharingError`, + * `sharingDeclaredExtra` and `sharingFaultMessage`, the rule family's + * `handleError` — moved with it, unchanged. The two registrars share ONE module + * because they are one capability (the rule family's own docblock calls itself + * a mirror of the share family); they still share no code, exactly as before. + * + * ## How the moved bodies reach the class + * + * The shape slice 1 set (`data-action-routes.ts`' header carries the full + * reasoning), with slice 2's refinement. The class keeps both private methods + * — the published `dist/*.d.ts` lists their names, docblocks and positions — + * and each body is one host literal ({@link SharingRouteHost}, + * {@link SharingRuleRouteHost}) plus `.call(host, basePath)`. A method is + * handed over as a closure that calls it on the server at call time, so a test + * double or spy set on the server is still the one the body reaches. + * + * Each field was checked on its own, not inherited from an earlier slice: + * + * - `config` and `routeManager` go BY VALUE. The share body reads `config` + * once, at its first line (`crud.dataPrefix`), and both bodies read + * `routeManager` only for their `register` calls, all of which run while + * registration runs. The class assigns `config` only in its constructor, and + * its one `routeManager` reassignment wraps `registerMetadataEndpointsInner`, + * never these registrars. + * - `sharingServiceProvider` and `sharingRulesServiceProvider` go as GETTERS. + * Each body's `resolveService` reads its provider at REQUEST time, and neither + * field is `readonly`, so a value copied at registration could part from it. + * The getter reads the field the handler read, when it read it. + * + * Module-level helpers stay where they were and are imported from the modules + * `rest-server.ts` imports them from; the bodies read nothing declared in + * `rest-server.ts` itself, so no export was added anywhere and the published + * surface does not move. + */ + +import { INTERNAL_ERROR_MESSAGE, sendError as sendEnvelopeError } from '@objectstack/types'; +// [#8073] The closed ADR-0112 error vocabulary: the share family's single +// refusal emitter types its `code` parameter as the vocabulary. +import type { ErrorCode } from '@objectstack/spec/api'; +// [#11683] The catalog's own floor for "a required `code` and no more specific +// one" — see `respondSharingError` below, where the nested ADR-0112 envelope +// declares `code` REQUIRED while the flat classification it re-dresses +// legitimately carries none. +import { standardErrorCodeForHttpStatus } from '@objectstack/spec/api'; +import { + boundedDeclaredUserMessage, + classifiedRefusalAnswer, + nestedRefusalEnvelope, + sandboxFaultAnswer, +} from './error-response.js'; +import { logError } from './log.js'; +import { refuseRepeatedQueryParams } from './query-multiplicity.js'; +import type { RestServer } from './rest-server.js'; + +/** + * The `RestServer` members {@link registerSharingEndpoints} reads — every one + * of them, and no other. Each is typed AS the class member (an indexed access + * on the class), so a change to a member's signature on the class is a change + * here too, and naming a member the class does not have is a compile error. + * See the module header for why `sharingServiceProvider` is a getter. + */ +export interface SharingRouteHost { + readonly config: RestServer['config']; + readonly routeManager: RestServer['routeManager']; + readonly sharingServiceProvider: RestServer['sharingServiceProvider']; + resolveExecCtx: RestServer['resolveExecCtx']; + enforceAuth: RestServer['enforceAuth']; +} + +/** + * The `RestServer` members {@link registerSharingRuleEndpoints} reads — every + * one of them, and no other, typed as {@link SharingRouteHost}'s are. See the + * module header for why `sharingRulesServiceProvider` is a getter. + */ +export interface SharingRuleRouteHost { + readonly routeManager: RestServer['routeManager']; + readonly sharingRulesServiceProvider: RestServer['sharingRulesServiceProvider']; + resolveExecCtx: RestServer['resolveExecCtx']; + enforceAuth: RestServer['enforceAuth']; +} + +/** + * Register record-level sharing endpoints — `RestServer#registerSharingEndpoints`' + * body, called by that method with the server as `this`. The method's own + * docblock, which the published declaration carries, names the routes. + */ +export function registerSharingEndpoints(this: SharingRouteHost, basePath: string): void { + const { crud } = this.config; + const dataPath = `${basePath}${crud.dataPrefix}`; + const isScoped = basePath.includes('/environments/:environmentId'); + + const resolveService = async (environmentId?: string) => { + if (!this.sharingServiceProvider) return undefined; + try { return await this.sharingServiceProvider(environmentId); } + catch { return undefined; } + }; + /** + * [#8111] The ONE refusal emitter for the record-sharing family — the + * 501, all five mapped verdicts and all three 500s go through it, so + * "list, grant and revoke answer the same shape" is a property of the + * code rather than of nine literals that happen to agree. + * + * Before this, the family carried BOTH dialects ADR-0112 D5 retires: + * `respond501` was flat `{ code, message }` and every other arm was + * `{ code, error: '' }`, so `body.error.code` — the one + * position D5 declares — read `undefined` on all nine. #7035 + * (PR #7293) had already removed both from this file's `/meta` + * refusals, #7981 (PR #8071) from `registerSecurityEndpoints` and + * #8073 (PR #8174) from the `/security/explain` pair. + * + * Emitted through the SHARED builder (`sendError` from + * `@objectstack/types`, imported as `sendEnvelopeError` — see the note + * at the import). That is what makes this the reference shape by + * construction rather than a tenth local literal agreeing with the nine + * it replaced, and it types `code` to the closed ADR-0112 vocabulary + * for free. + * + * ⛔ Status codes are untouched and no code VALUE moves: only the + * POSITION of `code` and `message` changes. + */ + const respondError = ( + res: any, + status: number, + code: ErrorCode, + message: string, + // [#12510] The shared writer's OWN `extra` type, referenced rather + // than restated: this wrapper decides POSITION (the nested D5 + // envelope), never which channels exist. A local `{ declaredCode?: + // string }` would be a second, narrower declaration of a set + // `sendError` already owns — the shape that silently stops + // forwarding the next channel admitted there. + extra?: Parameters[4], + ): void => sendEnvelopeError(res, status, code, message, extra); + + const respond501 = (res: any) => respondError( + res, 501, 'NOT_IMPLEMENTED', + 'Sharing service is not configured on this deployment', + ); + /** + * [#12693] The `extra` this family's TWO NON-CLASSIFIED exits owe a + * producer that marked its refusal: the 500 fault terminal below, and + * the ADR-0111 prefix arm inside {@link respondSharingError}. + * + * Neither reaches {@link classifiedRefusalAnswer} — the 500 terminal + * because a declared server fault is deliberately not a refusal + * (that function's own docblock rules it back to "the catching route's + * own terminal", which is these three arms), the prefix arm because it + * runs precisely when the classification answered `undefined`. So + * neither holds a `refusal.body` to re-dress, and the one line the + * classified arm uses (`refusal.body.userMessage`, #12669) is NOT + * reusable here. What is reusable is the RULE, and + * {@link boundedDeclaredUserMessage} is that rule asked of the raw + * thrown error instead of the classification: `declaredUserMessage`'s + * presence answer with #5423's bound applied, one definition, shared + * with the `/data` door rather than copied beside it. + * + * Measured on `15bf9e859` before the repair, one producer per exit + * through the real routes on both doors: + * + * ```text + * throw { code: 'SHARE_STORE_DOWN', status: 503, userMessage: '…' } + * share door : 500 SHARES_LIST_FAILED — no mark + * /data door : 503 SERVICE_UNAVAILABLE — mark carried + * throw Error('NOT_FOUND: no such record …') + userMessage + * share door : 404 NOT_FOUND — no mark + * /data door : 500 INTERNAL_ERROR — mark carried + * ``` + * + * ⛔ The mark is the ONLY thing this adds, and the two doors' other + * disagreements visible in that measurement stay exactly as they are: + * the share family folds a declared 503 into its own 500 terminal, and + * it interpolates the caught message where `/data` withholds 5xx prose + * unconditionally (#5437). Both are deliberate and argued in + * {@link sharingFaultMessage} and the #11683 docblock below; the + * `/data` door's status for the prefix arm differs for a third + * deliberate reason — the prefix idiom is this service's local + * convention and no shared classifier can read it (ADR-0111). + * + * ⛔ Riding the mark across a FAULT terminal is not a re-opening of + * #5437 either, and the reason is `withDeclaredUserMessage`'s, not a + * second one: the withheld text is prose the producer never addressed + * to the caller, while this field exists only because an author wrote + * caller-facing text onto it. A genuine crash carries no mark and its + * envelope is byte-identical to before. + */ + const sharingDeclaredExtra = ( + error: any, + ): { userMessage: string } | undefined => { + const userMessage = boundedDeclaredUserMessage(error); + return userMessage === undefined ? undefined : { userMessage }; + }; + // [ADR-0111] The service enforces authorization (D1/D4/D5/D7) and + // signals the verdict via message prefixes, the plugin's established + // error idiom — this maps them onto HTTP. Returns true when handled. + // + // [#8111] The prefix is a SERVER-INTERNAL service→REST derivation. + // ⚠️ [#13095] This comment used to claim the MECHANISM guaranteed + // that: "it is stripped below and never reaches the wire". That was + // true of the prefix-idiom arm it was written about and FALSE for the + // classified limb #11683 added beside it, which re-dresses + // `resolveErrorResponse`'s answer — an answer that shipped the prefix + // inside the sentence until the 2026-08-31 ruling converged that arm + // onto the same declared-code-anchored strip. Even now the strip is + // ANCHORED (it removes only a prefix restating the declared code), so + // "never reaches the wire" is not a mechanism anyone may lean on. + // What holds instead is MEASURED, not guaranteed: no consumer + // branches on the prefix in the wire `error` text — censused at + // #8111's claim and re-censused 2026-09-01 (objectstack + objectui: + // zero wire readers; every `startsWith(CODE)` hit is this file's own + // route mappings or an in-process producer-side check; `cloud` was + // not reachable and is NOT measured). The prefix idiom itself stays + // exactly as it is; #8111 moved only the response SHAPE. + // + // [#11683] …and it stays exactly as it is here too. What moved is that + // the prefix read is no longer the FIRST question, and no longer the + // only one. It is the ADR-0111 idiom for producers that declare + // nothing else, and the census that made it safe to keep is still + // true: every throw site in `plugin-sharing/src/sharing-service.ts` + // (11 of them, re-censused at claim) is a bare `Error` carrying one of + // these prefixes and NO `code`, NO `status`. Backward compatibility is + // therefore not a courtesy — it is the only channel this service has. + // + // What it could never read is a refusal that DID declare itself, and + // two of those reach these three catches today: + // + // - `plugin-sharing`'s own write gate throws + // `{ code: 'FORBIDDEN', status: 403 }` (`sharing-plugin.ts`). + // `FORBIDDEN` is not one of the five, so a refusal that declared + // 403 twice over was answered `500 SHARE_*_FAILED`. + // - A sandboxed hook on the `sys_record_share` write arrives with + // the QuickJS debug wrapper on `.message` and the business text on + // `.innerMessage`. The wrapper IS the prefix, so nothing matched + // and the wrapper was interpolated verbatim into the 500 — #11588's + // leak on a branch #11588 did not reach. + // + // Both are asked FIRST now, through {@link classifiedRefusalAnswer} — + // the `/data` door's own classification, so this family cannot answer + // a refusal differently from every other face that catches it. A + // declaration outranking a message read is the whole point: this + // route's defect was that classification was a property of how the + // sentence happened to start. + // + // ⛔ The DIALECT does not move. #8111 converted these arms onto the + // nested ADR-0112 D5 envelope and the `check:route-envelope` ratchet + // only ticks down, so the classification is re-dressed through + // `respondError` rather than sent by `handleRouteError` (which speaks + // the flat dialect). Vocabulary and position stay two decisions. + const respondSharingError = (res: any, error: any): boolean => { + // A refusal the producer classified — answered exactly as `/data` + // answers it. `undefined` for everything else, which falls to the + // prefix idiom below and then to the caller's own 500 arm, both + // unchanged. + const refusal = classifiedRefusalAnswer(error); + if (refusal) { + // `code` is REQUIRED by the nested envelope, and the flat + // classification legitimately carries none for an undeclared + // sandbox refusal (ADR-0112 D4 governs the semantic-CODE + // channel: the producer names the condition on the CODE axis, + // so nothing is invented for the half it did not name; the ADR + // rules no HTTP status here, and the phrase is + // `error-response.ts`'s own prose rather than an ADR + // quotation). The + // catalog's own floor fills the required field — + // `standardErrorCodeForHttpStatus`, whose docblock exists for + // exactly this ("Total by construction: a producer can always + // fill a required `code`") and which is the same derivation + // `resolveThrownHttpError` applies at every other door. This + // is the one place the two dialects genuinely differ: the flat + // body may omit `code`, the nested one may not. + // + // [#12510] …and the producer's OWN spelling travels with it. + // An UNREGISTERED thrown code is demoted by the shared rule to + // a `declaredCode` sibling (ADR-0112 #9232) — the open, + // author-authored channel `ApiErrorSchema` has declared since + // #9106. This family used to drop it: the classification below + // was already holding the demoted string and only `code` and + // the message were re-dressed, so an app's spelling vanished + // here while the flat `/data` door carried it. Nothing invalid + // shipped — the closed `code` still carried the member the + // status derives — which is exactly what made the loss silent + // and one-directional: a consumer told by ADR-0112 to read + // `declaredCode` found nothing at this door. + // + // ⛔ The reason that used to stand here said `sendError`'s + // `extra` would not accept the field. That was true when it was + // written and false since #11719 / `db8c288` (PR #12403) added + // `declaredCode` to the writer's `Pick`. A stale sentence that + // discourages a repair costs more than one that misdescribes a + // mechanism, so it is recorded rather than merely deleted. + // + // ⛔ Read the CLASSIFICATION's field, never the resolver's raw + // `thrown.declaredCode`. Presence MEANS demotion + // (`ApiErrorSchema.declaredCode`'s documented invariant) and + // the raw field is set for a REGISTERED spelling too — + // measured: a producer throwing `{ code: 'RECORD_LOCKED', + // status: 409 }` resolves with `declaredCode: 'RECORD_LOCKED'` + // sitting beside an identical `code`, and forwarding that would + // put two spellings of one fact on every registered refusal. + // `refusal.body.declaredCode` is the answer AFTER + // `demotedDeclaredCode` (`error-response.ts`'s + // `thrownCodeFields`, the same one definition the dispatcher + // door reads), so the invariant arrives with the value. + // + // ⭐ Why re-dress rather than re-resolve: this door asks + // {@link classifiedRefusalAnswer} ONCE and re-dresses that one + // answer, exactly as it does for `status`, `code` and the + // message. Calling the resolver a second time here would be a + // second answer to a question already asked — the shape that + // let two `/api/v1/packages` doors drift apart (#12405). The + // pair is carried, not recomputed: `code` and `declaredCode` + // leave this door as the pair `thrownCodeFields` produced. + const code = typeof refusal.body.code === 'string' + ? refusal.body.code as ErrorCode + : standardErrorCodeForHttpStatus(refusal.status); + const declaredCode = typeof refusal.body.declaredCode === 'string' + ? refusal.body.declaredCode + : undefined; + // [#12669] …and so does the sentence the producer addressed to + // the CALLER. The flat `/data` door attaches it in + // `withDeclaredUserMessage` (`error-response.ts`, commit 79c46da90) and + // the one classification asked above is already holding the + // result; this family dropped it at the same re-dress, with the + // same one-directional silence — an author's own remedy text + // reaching `/data` and vanishing here. + // + // ⛔ `userMessage` and `declaredCode` are NOT symmetric, and + // the next reader will assume they are because they arrive on + // the same line. `declaredCode` is read from the CLASSIFICATION + // because presence there MEANS demotion — an invariant the raw + // thrown field does not carry, which is the whole subject of + // the paragraphs above (⛔ not restated here: #12510 owns that + // derivation and one reason with two copies is this lane's own + // recurring defect). `userMessage` has NO invariant left for a + // caller to re-derive. `declaredUserMessage` already decided + // PRESENCE — the field exists on an error only because its + // producer deliberately wrote end-user text with no host state + // onto it (an application hook, or a platform refusal carrying + // static guidance such as the packaged-permission-set lock's; + // platform and driver diagnostics never set it) — and + // `truncateClientMessage` already applied #5423's bound to the + // value. Reading `refusal.body.userMessage` IS the rule; there + // is no second function to run it through, and running one + // would be a second answer to a question the classification + // has already answered. + // + // The `typeof` guard below is therefore not that invariant. It + // is the same non-string floor `code` and `declaredCode` carry + // two lines up: a producer may put anything on a thrown object, + // and a number arriving in a string channel is the #3842 drift. + // + // ⛔ SCOPE: `userMessage` only. The producer's structured + // context — the flat body's top-level `issues` — is the other + // half of #12669 and is deliberately NOT forwarded here. The + // nested envelope's channel for it is `ApiError.details`, so + // mapping one onto the other is a SHAPE decision on a contract + // field rather than a rename, and #12669 is open on it. + const userMessage = typeof refusal.body.userMessage === 'string' + ? refusal.body.userMessage + : undefined; + respondError( + res, refusal.status, code, String(refusal.body.error ?? ''), + { + ...(declaredCode !== undefined ? { declaredCode } : {}), + ...(userMessage !== undefined ? { userMessage } : {}), + }, + ); + return true; + } + const msg = String(error?.message ?? error ?? ''); + const map: Array<[ErrorCode, number]> = [ + ['VALIDATION_FAILED', 400], + ['PERMISSION_DENIED', 403], + ['NOT_FOUND', 404], + ['CONFLICT', 409], + ['SHARING_NOT_ENABLED', 422], + ]; + for (const [code, status] of map) { + if (msg.startsWith(code)) { + // [#12693] …and the producer's own sentence to the caller + // rides this arm too. ⛔ Only the sentence: the PREFIX + // read, the status it decides and the stripping below are + // untouched — see {@link sharingDeclaredExtra}. + respondError( + res, status, code, + msg.replace(new RegExp(`^${code}:\\s*`), ''), + sharingDeclaredExtra(error), + ); + return true; + } + } + return false; + }; + /** + * [#11683] The text the three 500 arms may put on the wire. + * + * The arms interpolate the caught error's own message, and that is + * unchanged for every ordinary fault — `sharing-envelope.test.ts` pins + * a plain `Error('boom')` arriving as `boom` and it still does. + * + * The one shape it withholds is a SANDBOX error that reached a 500 at + * all, which after the classification above means one thing: a hook + * body that CRASHED rather than refused (`isScriptFaultMessage`, + * #7543). Its `.message` is the QuickJS debug wrapper and its + * `.innerMessage` is a `TypeError: …` — the wrapper the card measured + * leaking, wrapped around a runtime fault the caller must not read + * either. `/data` answers that case `INTERNAL_ERROR_MESSAGE` through + * {@link UNCLASSIFIED_FAULT}; this says the same sentence, so the leak + * is closed on this family unconditionally rather than only for the + * refusals the classification door catches. + * + * The full wrapper still reaches the operator: every caller logs the + * whole error object immediately above its `respondError`. + * + * [#22885] …and the sandbox read is the doors' own now. The runner's + * OWN fault (a denied capability, a missing `ctx.api` method, a + * budget) carries no `innerMessage`, so the read above let it through + * and these arms relayed the runner's diagnostic text. The sentence is + * {@link sandboxFaultAnswer}'s, the answer the other hand-built + * families give every sandbox fault, so a crash, a declared 5xx and an + * own fault read one withheld sentence here as there. ⛔ Only the + * SENTENCE: this family's own `500` and `SHARE_*_FAILED` code stay the + * recorded exception the write-route ledger names (#22764's + * `OWN_FAULT_TERMINAL`), a decision this does not take. The + * `innerMessage` read stays for any sandbox origin that answer declines. + */ + const sharingFaultMessage = (error: any): string => { + const fault = sandboxFaultAnswer(error); + if (fault !== undefined) return nestedRefusalEnvelope(fault).message; + return typeof error?.innerMessage === 'string' && error.innerMessage + ? INTERNAL_ERROR_MESSAGE + : String(error?.message ?? error).slice(0, 500); + }; + + // GET — list shares on a record. [ADR-0111 D5] Management-gated in the + // service: invisible record → 404, visible-but-not-manager → 403. + this.routeManager.register({ + method: 'GET', + path: `${dataPath}/:object/:id/shares`, + handler: async (req: any, res: any) => { + try { + const environmentId = isScoped ? req.params?.environmentId : undefined; + const context = await this.resolveExecCtx(environmentId, req); + if (this.enforceAuth(req, res, context)) return; + const svc = await resolveService(environmentId); + if (!svc) return respond501(res); + const rows = await svc.listShares(req.params.object, req.params.id, context ?? {}); + res.json({ data: rows }); + } catch (error: any) { + if (respondSharingError(res, error)) return; + logError('[REST] List shares error:', error); + // The 500 arms keep their 500-char cap: an unexpected + // fault's message is not a contract, and truncating it + // stays a sanitization step — only the position moves. + respondError( + res, 500, 'SHARES_LIST_FAILED', sharingFaultMessage(error), + sharingDeclaredExtra(error), + ); + } + }, + metadata: { summary: 'List per-record sharing grants', tags: ['sharing'] }, + }); + + // POST — grant access. [ADR-0111 D1/D7] Authorization + posture live + // in the service; this route only maps verdicts (403/404/422/400). + this.routeManager.register({ + method: 'POST', + path: `${dataPath}/:object/:id/shares`, + handler: async (req: any, res: any) => { + try { + const environmentId = isScoped ? req.params?.environmentId : undefined; + const context = await this.resolveExecCtx(environmentId, req); + if (this.enforceAuth(req, res, context)) return; + const svc = await resolveService(environmentId); + if (!svc) return respond501(res); + const body = req.body ?? {}; + const input = { + object: req.params.object, + recordId: req.params.id, + recipientType: body.recipientType ?? body.recipient_type, + recipientId: body.recipientId ?? body.recipient_id, + accessLevel: body.accessLevel ?? body.access_level, + source: body.source, + sourceId: body.sourceId ?? body.source_id, + reason: body.reason, + }; + const row = await svc.grant(input, context ?? {}); + res.status(201).json(row); + } catch (error: any) { + if (respondSharingError(res, error)) return; + logError('[REST] Grant share error:', error); + respondError( + res, 500, 'SHARE_GRANT_FAILED', sharingFaultMessage(error), + sharingDeclaredExtra(error), + ); + } + }, + metadata: { summary: 'Grant a per-record share to a principal', tags: ['sharing'] }, + }); + + // DELETE — revoke a share by id. [ADR-0111 D4] The URL's + // (object, id) is forwarded as the revoke scope so a share id can only + // be revoked through the record it belongs to; the service enforces + // management authority and the manual-source rule (409). + this.routeManager.register({ + method: 'DELETE', + path: `${dataPath}/:object/:id/shares/:shareId`, + handler: async (req: any, res: any) => { + try { + const environmentId = isScoped ? req.params?.environmentId : undefined; + const context = await this.resolveExecCtx(environmentId, req); + if (this.enforceAuth(req, res, context)) return; + const svc = await resolveService(environmentId); + if (!svc) return respond501(res); + await svc.revoke( + req.params.shareId, + context ?? {}, + { object: req.params.object, recordId: req.params.id }, + ); + res.status(204).end(); + } catch (error: any) { + if (respondSharingError(res, error)) return; + logError('[REST] Revoke share error:', error); + respondError( + res, 500, 'SHARE_REVOKE_FAILED', sharingFaultMessage(error), + sharingDeclaredExtra(error), + ); + } + }, + metadata: { summary: 'Revoke a per-record share by id', tags: ['sharing'] }, + }); +} + +/** + * Register sharing-rule endpoints — `RestServer#registerSharingRuleEndpoints`' + * body, called by that method with the server as `this`. The method's own + * docblock, which the published declaration carries, names the routes. + */ +export function registerSharingRuleEndpoints(this: SharingRuleRouteHost, basePath: string): void { + // Sharing-rule routes live at the top of the API surface (e.g. + // `/api/v1/sharing/rules`) — they administer rules across the whole + // tenant rather than acting on a single CRUD object, so anchoring + // them on `basePath` keeps them out of the `/data/:object` namespace + // where greedy CRUD matchers would otherwise swallow them. + const dataPath = basePath; + const isScoped = basePath.includes('/environments/:environmentId'); + + const resolveService = async (environmentId?: string) => { + if (!this.sharingRulesServiceProvider) return undefined; + try { return await this.sharingRulesServiceProvider(environmentId); } + catch { return undefined; } + }; + const respond501 = (res: any) => res.status(501).json({ + code: 'NOT_IMPLEMENTED', + message: 'Sharing-rule service is not configured on this deployment', + }); + const handleError = (err: any, res: any, defaultCode: string) => { + const msg = String(err?.message ?? err ?? ''); + if (msg.startsWith('VALIDATION_FAILED')) { + return res.status(400).json({ code: 'VALIDATION_FAILED', error: msg.replace(/^VALIDATION_FAILED:\s*/, '') }); + } + // [ADR-0111 D6] The service gates every verb on `manage_sharing` + // (enforced there so non-REST callers are covered too) — map its + // verdict rather than burying it in a 500. + if (msg.startsWith('PERMISSION_DENIED')) { + return res.status(403).json({ code: 'PERMISSION_DENIED', error: msg.replace(/^PERMISSION_DENIED:\s*/, '') }); + } + if (msg.startsWith('RULE_NOT_FOUND')) { + return res.status(404).json({ code: 'RULE_NOT_FOUND', error: msg.replace(/^RULE_NOT_FOUND:?\s*/, '') }); + } + // [ADR-0111 D7 / #8207] `POST .../evaluate` reconciles through + // `SharingService.grant`, whose inertness guard now runs for the + // evaluator's system context too. A rule pointed at an object no + // sharing gate consults therefore refuses here instead of silently + // materialising rows nothing reads — and the admin who asked for + // the evaluation needs to be told WHICH object and WHY, not handed + // an opaque 500. Same code→status pair the per-record shares routes + // already publish (`respondSharingError`), so no new contract. + // + // ⚠️ Built through the SHARED `sendError` envelope, unlike the three + // arms above it. Those are the flat dialect — `code` beside `error` + // instead of inside it, so `body.error.code` reads `undefined` — + // held down by the `check:route-envelope` ratchet, which only ticks + // DOWN. A new arm copying its neighbours' shape is exactly what that + // ratchet exists to stop, so this one answers the envelope + // `BaseResponseSchema` declares. The asymmetry is the ratchet + // working; converting the other three is outstanding envelope- + // position work owned by that ratchet, not a rider on this card. + // + // ⚠️ Two card citations stood here and both were stale by the time + // anyone read them: #7035 closed 2026-08-10 (PR #7293, three `/meta` + // 501 handlers), and #8111 closed 2026-08-12 (PR #8212) having + // converged the record-sharing family it named. The ratchet's own + // baseline is the live owner of what is left; a closed card number + // is not. + if (msg.startsWith('SHARING_NOT_ENABLED')) { + return sendEnvelopeError( + res, 422, 'SHARING_NOT_ENABLED', + msg.replace(/^SHARING_NOT_ENABLED:\s*/, ''), + ); + } + // [#22719] A refusal none of the prefix arms above can read is asked + // of the `/data` door's own classification before the 500 below + // relays `.message`. For a sandboxed hook's refusal `.message` is + // the QuickJS debug wrapper, so `POST /rules`, `DELETE + // /rules/:idOrName` and `POST .../evaluate` answered `500` with + // `hook '' threw: Error: …` where every conforming write door + // answers the hook's own sentence. A refusal that DECLARED its + // envelope (`status`/`statusCode` + `code`) is answered at its own + // status too, as on every door that asks this. + // + // AFTER the prefix arms, deliberately: each of them answers a 4xx + // this family already publishes, and none can read a sandbox error + // (its `.message` opens with the wrapper, never a prefix), so asking + // here moves only what used to reach the 500. Emitted in the nested + // envelope like `SHARING_NOT_ENABLED` above, for that arm's reason. + const refusal = classifiedRefusalAnswer(err); + if (refusal) { + const nested = nestedRefusalEnvelope(refusal); + return sendEnvelopeError(res, nested.status, nested.code, nested.message, nested.extra); + } + logError(`[REST] sharing-rule ${defaultCode}:`, err); + // [#22764] …and a sandboxed body the doors answer as a FAULT — one + // that crashed, or a refusal that declared a 5xx — answers as the + // doors do, logged above and withheld from the caller. The 500 + // below relayed the QuickJS wrapper around the native error text. + // Nested, for the classified arm's reason. + const fault = sandboxFaultAnswer(err); + if (fault) { + const nested = nestedRefusalEnvelope(fault); + return sendEnvelopeError(res, nested.status, nested.code, nested.message, nested.extra); + } + return res.status(500).json({ code: defaultCode, error: msg.slice(0, 500) }); + }; + + // LIST + this.routeManager.register({ + method: 'GET', + path: `${dataPath}/sharing/rules`, + handler: async (req: any, res: any) => { + try { + const environmentId = isScoped ? req.params?.environmentId : undefined; + const context = await this.resolveExecCtx(environmentId, req); + if (this.enforceAuth(req, res, context)) return; + const svc = await resolveService(environmentId); + if (!svc) return respond501(res); + // [#6877] `object` reached `listRules` as an array; a + // repeated `?activeOnly=true` stopped matching and listed the + // INACTIVE rules too. + if (refuseRepeatedQueryParams(req, res, ['object', 'activeOnly'])) return; + const rows = await svc.listRules({ + object: req.query?.object, + activeOnly: req.query?.activeOnly === 'true' || req.query?.activeOnly === true, + }, context ?? {}); + res.json({ data: rows }); + } catch (err: any) { handleError(err, res, 'RULE_LIST_FAILED'); } + }, + metadata: { summary: 'List sharing rules', tags: ['sharing'] }, + }); + + // CREATE / UPSERT + this.routeManager.register({ + method: 'POST', + path: `${dataPath}/sharing/rules`, + handler: async (req: any, res: any) => { + try { + const environmentId = isScoped ? req.params?.environmentId : undefined; + const context = await this.resolveExecCtx(environmentId, req); + if (this.enforceAuth(req, res, context)) return; + const svc = await resolveService(environmentId); + if (!svc) return respond501(res); + const body = req.body ?? {}; + // Field-by-field pluck (not a schema parse): the authoring + // spec shape — CEL `condition` + `sharedWith{type,value}` — + // is not the runtime shape this endpoint takes, so unknown + // keys are dropped rather than rejected. [#3896] That made + // a typo (`criterias`) indistinguishable from "no criteria", + // which used to mean "share every record". `defineRule` now + // refuses a match-all criteria, so the typo surfaces as a + // 400 naming the field instead of a silent 201. + const input = { + name: body.name, + label: body.label, + description: body.description, + object: body.object ?? body.object_name, + criteria: body.criteria ?? body.criteria_json, + recipientType: body.recipientType ?? body.recipient_type, + recipientId: body.recipientId ?? body.recipient_id, + accessLevel: body.accessLevel ?? body.access_level, + active: body.active, + }; + const row = await svc.defineRule(input, context ?? {}); + res.status(201).json(row); + } catch (err: any) { handleError(err, res, 'RULE_DEFINE_FAILED'); } + }, + metadata: { summary: 'Create or upsert a sharing rule', tags: ['sharing'] }, + }); + + // GET + this.routeManager.register({ + method: 'GET', + path: `${dataPath}/sharing/rules/:idOrName`, + handler: async (req: any, res: any) => { + try { + const environmentId = isScoped ? req.params?.environmentId : undefined; + const context = await this.resolveExecCtx(environmentId, req); + if (this.enforceAuth(req, res, context)) return; + const svc = await resolveService(environmentId); + if (!svc) return respond501(res); + const row = await svc.getRule(req.params.idOrName, context ?? {}); + if (!row) return res.status(404).json({ code: 'RULE_NOT_FOUND' }); + res.json(row); + } catch (err: any) { handleError(err, res, 'RULE_GET_FAILED'); } + }, + metadata: { summary: 'Get a sharing rule by id or name', tags: ['sharing'] }, + }); + + // DELETE + this.routeManager.register({ + method: 'DELETE', + path: `${dataPath}/sharing/rules/:idOrName`, + handler: async (req: any, res: any) => { + try { + const environmentId = isScoped ? req.params?.environmentId : undefined; + const context = await this.resolveExecCtx(environmentId, req); + if (this.enforceAuth(req, res, context)) return; + const svc = await resolveService(environmentId); + if (!svc) return respond501(res); + await svc.deleteRule(req.params.idOrName, context ?? {}); + res.status(204).end(); + } catch (err: any) { handleError(err, res, 'RULE_DELETE_FAILED'); } + }, + metadata: { summary: 'Delete a sharing rule and its materialised grants', tags: ['sharing'] }, + }); + + // EVALUATE + this.routeManager.register({ + method: 'POST', + path: `${dataPath}/sharing/rules/:idOrName/evaluate`, + handler: async (req: any, res: any) => { + try { + const environmentId = isScoped ? req.params?.environmentId : undefined; + const context = await this.resolveExecCtx(environmentId, req); + if (this.enforceAuth(req, res, context)) return; + const svc = await resolveService(environmentId); + if (!svc) return respond501(res); + const result = await svc.evaluateRule(req.params.idOrName, context ?? {}); + res.json(result); + } catch (err: any) { handleError(err, res, 'RULE_EVALUATE_FAILED'); } + }, + metadata: { summary: 'Re-evaluate a sharing rule and reconcile grants', tags: ['sharing'] }, + }); +} From d325608f377609e14c35b372da9fdb9623f29457 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 15:12:29 +0000 Subject: [PATCH 2/4] refactor(rest): re-point the readers of the moved sharing registrar bodies (#23050 slice 4) The registrar list's nine length pins go 4 -> 5 with their sums unchanged; check-route-envelope declares sharing-routes.ts at the dialect counts that left rest-server.ts; the two platform-checklist anchors, two test headers and two error-response.ts pointers name sharing-routes.ts. Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU Co-authored-by: Claude --- .../areas/access-security.json | 4 +- .../qa/dogfood/test/authz-conformance.test.ts | 3 +- .../test/authz-probe-blind-spot.census.ts | 23 +++++++++-- packages/rest/src/error-response.ts | 10 +++-- .../rest/src/execctx-consumer-census.test.ts | 41 +++++++++++++++---- .../src/meta-draft-read-door-census.test.ts | 5 ++- .../rest-server-canonical-query-ast.test.ts | 10 ++++- .../src/rest-server-docblock-position.test.ts | 12 +++++- .../rest-server-query-number-census.test.ts | 6 ++- .../rest/src/rest-share-declared-code.test.ts | 3 +- .../rest/src/rest-share-user-message.test.ts | 3 +- ...ui-view-route-identity.measurement.test.ts | 6 ++- .../ui-view-route-tenancy.measurement.test.ts | 5 ++- scripts/check-route-envelope.mjs | 31 +++++++++++++- 14 files changed, 129 insertions(+), 33 deletions(-) diff --git a/docs/qa/platform-checklist/areas/access-security.json b/docs/qa/platform-checklist/areas/access-security.json index 455ca03171d..298f8136d1a 100644 --- a/docs/qa/platform-checklist/areas/access-security.json +++ b/docs/qa/platform-checklist/areas/access-security.json @@ -1189,7 +1189,7 @@ ], "source": [ "packages/rest/src/rest-route-ledger.ts#REST_ROUTE_LEDGER (record-shares family: GET/POST /data/:object/:id/shares, DELETE .../:shareId; sharing-rules family evaluate — re-pointed #18104: shareId is a route PATH PARAMETER, carried in this file only inside a route string)", - "packages/rest/src/rest-server.ts#registerSharingRuleEndpoints (the evaluate leg; the record-shares family GET/POST /data/:object/:id/shares + DELETE .../:shareId is registered inline, under no registrar of its own — #13788 measured that the registerRecordShareEndpoints this citation used to name is not in the file)", + "packages/rest/src/sharing-routes.ts#registerSharingRuleEndpoints (the evaluate leg; the record-shares family GET/POST /data/:object/:id/shares + DELETE .../:shareId is registered inline, under no registrar of its own — #13788 measured that the registerRecordShareEndpoints this citation used to name is not in the file)", "packages/plugins/plugin-sharing/src/objects/sys-record-share.object.ts#recipient_type (recipient_type/recipient_id/access_level/source fields)", "packages/plugins/plugin-sharing/src/sharing-rule-service.ts#evaluateRule (evaluateRule → SharingRuleEvaluationResult)", "examples/app-showcase/src/security/sharing-rules.ts#share_key_account_qualified_contacts_with_managers (share_key_account_qualified_contacts_with_managers seeded criteria rule)", @@ -2002,7 +2002,7 @@ "hydration-race" ], "source": [ - "packages/rest/src/rest-server.ts#registerSharingRuleEndpoints (list/create/get/delete)", + "packages/rest/src/sharing-routes.ts#registerSharingRuleEndpoints (list/create/get/delete)", "packages/spec/src/security/sharing.zod.ts (criteria rule authoring + match-all refusal #3896)", "packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts + sys-record-share.object.ts", "objectui packages/plugins/plugin-sharing/src/sharing-plugin.ts#nav_sharing_rules (nav_sharing_rules Setup nav → sys_sharing_rule)", diff --git a/packages/qa/dogfood/test/authz-conformance.test.ts b/packages/qa/dogfood/test/authz-conformance.test.ts index efcadb33958..250b400580d 100644 --- a/packages/qa/dogfood/test/authz-conformance.test.ts +++ b/packages/qa/dogfood/test/authz-conformance.test.ts @@ -1625,7 +1625,8 @@ describe('#9084 — the rest-server.ts transport tripwire keys off mechanics', ( // 0 over the list, whose length is pinned so a module added to it makes this // re-read. The control is that both probes really READ the list — their // text is every file on it, in order — so the zero is not a list ignored. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(4); + // [#23050 slice 4] Length 4 → 5 (`sharing-routes.ts`), still 0 over it. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(5); expect(REST_SERVER_REGISTRAR_PATHS[0]).toBe(REST_SERVER); const listed = REST_SERVER_REGISTRAR_PATHS.map((file) => readFileSync(join(REPO_ROOT, file), 'utf8')); for (const probe of [MECHANICS, ROUTES]) { diff --git a/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts b/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts index 9abe1f0d6b0..d462733a21a 100644 --- a/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts +++ b/packages/qa/dogfood/test/authz-probe-blind-spot.census.ts @@ -519,8 +519,9 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ 'indentation and the subtrahend is pinned at 1 by the `forwarder slice:` control, never inferred from a ' + 'terminator spelling), PLUS `registerPerItemRoute(` call sites; counted over this file AND every ' + 'module on the registrar list (`packages/rest/src/rest-server-registrar-files.ts`) — ' + - '`data-action-routes.ts`, `batch-routes.ts` and `approvals-routes.ts`, the modules the bodies of ' + - '`registerDataActionEndpoints`, `registerBatchEndpoints` and `registerApprovalsEndpoints` moved to; ' + + '`data-action-routes.ts`, `batch-routes.ts`, `approvals-routes.ts` and `sharing-routes.ts`, the modules the ' + + 'bodies of `registerDataActionEndpoints`, `registerBatchEndpoints`, `registerApprovalsEndpoints`, ' + + '`registerSharingEndpoints` and `registerSharingRuleEndpoints` moved to; ' + 'reachable = those inside registerMetadataEndpoints', // [#15542 / #15854] ⭐ THE POPULATION RULE LEARNED A SECOND SPELLING, and // the numbers it produces did NOT move: 80 / 19 / 61, exactly as before. @@ -677,13 +678,27 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ // readings; the four new occurrences are this slice's seam, the same two // spellings (the host literal's forwarder, two; the host interface's // member, two). No call site was added. + // [#23050 slice 4] 70 / 19 / 51 UNCHANGED, the same measurement again: + // `registerSharingEndpoints`' and `registerSharingRuleEndpoints`' bodies + // (eight registration sites, three and five, all outside + // `registerMetadataEndpoints`, so all in the blind spot) moved to ONE + // module, `sharing-routes.ts`, which joins the list (length 4 -> 5) with its + // own control. `this.routeManager.register(` stays 63 over the five files + // (34 here + 9 + 5 + 7 + 8), `private register*Endpoints(` stays 16 (both + // registrars are still declared here), and `enforceAuth` 72 -> 80 is + // RE-ANCHORED, not relaxed: the eight call sites moved with their routes + // and are counted in both readings; the eight new occurrences are this + // slice's seam, the same two spellings TWICE, because the module carries + // two registrars (two host literals' forwarders, four; two host + // interfaces' members, four). No call site was added. controls: { 'private register*Endpoints(': 16, 'this.routeManager.register(': 63, - 'registrar list length': 4, + 'registrar list length': 5, 'data-action-routes.ts: this.routeManager.register(': 9, 'batch-routes.ts: this.routeManager.register(': 5, 'approvals-routes.ts: this.routeManager.register(': 7, + 'sharing-routes.ts: this.routeManager.register(': 8, // Both halves of the new rule carry their own control, so neither can go // silently to zero: a helper deleted and its routes inlined back would // still read population 80, and only these two controls would notice the @@ -691,7 +706,7 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [ 'registerPerItemRoute(': 8, 'const registerPerItemRoute =': 1, 'forwarder slice: this.routeManager.register(': 1, - enforceAuth: 72, + enforceAuth: 80, }, note: 'The single non-tripwire probe names ONE registrar of 16. The other 15 can never mint a key: ' + diff --git a/packages/rest/src/error-response.ts b/packages/rest/src/error-response.ts index 1c91226dfd3..7fc2fd9c57e 100644 --- a/packages/rest/src/error-response.ts +++ b/packages/rest/src/error-response.ts @@ -605,8 +605,9 @@ function resolveErrorResponse(error: any, object?: string): { status: number; bo * ## Why this exists as an export rather than as a rule each route re-states * * Two route families build their error body by hand and share no branch with - * either door in this file: `/analytics/dataset/query` and the three - * record-share routes, both in `rest-server.ts`. Both re-derived + * either door in this file: `/analytics/dataset/query` (in `rest-server.ts`) + * and the three record-share routes (in `sharing-routes.ts` since #23050 + * slice 4, `rest-server.ts` before). Both re-derived * classification locally — analytics from an in-line `error.status` + * `error.code` read, the share family from `message.startsWith(CODE)` over * five literal prefixes — and both landed a *different* answer from `/data` @@ -770,8 +771,9 @@ export function sandboxFaultAnswer( * * ## The derivation is the record-share family's * - * `respondSharingError` in `rest-server.ts` re-dresses the same answer inline, - * and its comments carry the argument for each field; they are not restated: + * `respondSharingError` in `sharing-routes.ts` (moved out of `rest-server.ts` + * by #23050 slice 4) re-dresses the same answer inline, and its comments carry + * the argument for each field; they are not restated: * * - `code` is REQUIRED by the nested envelope and the flat classification * legitimately carries none for an undeclared sandbox refusal, so the diff --git a/packages/rest/src/execctx-consumer-census.test.ts b/packages/rest/src/execctx-consumer-census.test.ts index 9377e256848..4db6d8121a5 100644 --- a/packages/rest/src/execctx-consumer-census.test.ts +++ b/packages/rest/src/execctx-consumer-census.test.ts @@ -86,6 +86,10 @@ const SOURCE = readFileSync(resolve(HERE, 'rest-server.ts'), 'utf8'); * census reads every file on THE list of the files `RestServer`'s registrars * live in (`rest-server-registrar-files.ts`), so the slice that moves the next * body widens it by adding its module there. §2 pins the list's length. + * + * [#23050 slice 4] …and `registerSharingEndpoints`' and + * `registerSharingRuleEndpoints`' bodies to `sharing-routes.ts`, with their eight + * (three and five) — the first module on the list to carry TWO registrars. */ const SITE_FILES = REST_SERVER_REGISTRAR_FILES; const SOURCES = new Map(SITE_FILES.map((file) => [ @@ -96,17 +100,27 @@ const siteKey = (file: string, line: number) => `${file}:${line}`; /** * The frames in `rest-server.ts` that are NOT consumers: the host literal each * moved registrar (`registerDataActionEndpoints`, `registerBatchEndpoints`, - * `registerApprovalsEndpoints`) hands + * `registerApprovalsEndpoints`, `registerSharingEndpoints`, + * `registerSharingRuleEndpoints`) hands * its moved body forwards each member to the server with * `(...a) => this.(...a)`, so a moved site's stack shows that forwarder * above the site itself. The site is the first censused frame that is not one. - * Found by their spelling, and §2 pins ONE per moved module, so a respelt - * forwarder — or a module moved without joining `SITE_FILES` — is a control - * failure there rather than every moved site silently attributed to it. + * Found by their spelling, and §2 pins ONE per moved REGISTRAR — one per moved + * module, plus {@link EXTRA_MOVED_REGISTRARS} — so a respelt forwarder — or a + * module moved without joining `SITE_FILES` — is a control failure there rather + * than every moved site silently attributed to it. */ const FORWARDER_LINES = SOURCE.split('\n').flatMap((l, i) => l.includes('resolveExecCtx: (...a) => this.resolveExecCtx(...a),') ? [i + 1] : []); const FORWARDERS = new Set(FORWARDER_LINES.map((line) => siteKey('rest-server.ts', line))); +/** + * [#23050 slice 4] The moved registrars beyond one per moved module: + * `sharing-routes.ts` carries two registrars' bodies, each handed over by its + * own host literal and so through its own forwarder. + */ +const EXTRA_MOVED_REGISTRARS: Readonly>> = { + 'sharing-routes.ts': 1, +}; /** A stack frame in any censused file, as `::`. */ const SITE_FRAME = new RegExp(`(${SITE_FILES.map((file) => file.replace(/\./g, '\\.')).join('|')}):(\\d+):\\d+`, 'g'); @@ -565,11 +579,22 @@ describe('[#13160] §2 the consumer surface, counted from the tree', () => { // list, whose length is pinned here. The four new mentions are this // slice's seam, the same two spellings; three forwarders now, one per // moved module. - expect(SITE_FILES).toHaveLength(4); + // + // [#23050 slice 4] 62 sites UNCHANGED / 89 → 97 mentions — the same MOVE. + // `registerSharingEndpoints`' and `registerSharingRuleEndpoints`' bodies + // left for `sharing-routes.ts` with their eight BARE sites (three and + // five): 33 + 9 + 5 + 7 + 8 = 62 sites. The eight new mentions are this + // slice's seam, the same two spellings TWICE — two host literals' + // forwarders (four) and two host interfaces' members (four) — because + // the module carries two registrars. Five forwarders now: one per moved + // registrar, which is one per moved module plus `EXTRA_MOVED_REGISTRARS`. + expect(SITE_FILES).toHaveLength(5); expect(SITES.length).toBe(62); - expect([...SOURCES.values()].reduce((n, text) => n + text.split('resolveExecCtx').length - 1, 0)).toBe(89); - expect(FORWARDER_LINES, 'one host-literal resolveExecCtx forwarder per moved module: one was respelt or removed, or a module moved without joining SITE_FILES') - .toHaveLength(SITE_FILES.length - 1); + expect([...SOURCES.values()].reduce((n, text) => n + text.split('resolveExecCtx').length - 1, 0)).toBe(97); + const extraMovedRegistrars = Object.values(EXTRA_MOVED_REGISTRARS).reduce((n, k) => n + (k ?? 0), 0); + expect(extraMovedRegistrars).toBe(1); + expect(FORWARDER_LINES, 'one host-literal resolveExecCtx forwarder per moved registrar: one was respelt or removed, or a module moved without joining SITE_FILES') + .toHaveLength(SITE_FILES.length - 1 + extraMovedRegistrars); }); it('the split is 16 locally caught / 46 bare — NOT 12 / 46, which does not add to 62', () => { diff --git a/packages/rest/src/meta-draft-read-door-census.test.ts b/packages/rest/src/meta-draft-read-door-census.test.ts index 845a11d7bf7..b4c0b175652 100644 --- a/packages/rest/src/meta-draft-read-door-census.test.ts +++ b/packages/rest/src/meta-draft-read-door-census.test.ts @@ -240,8 +240,9 @@ describe('[#20338] census: every draft switch in rest-server.ts is ledgered', () // A module added to the list makes this re-read its number rather than // widening unnoticed. 8 switches, all in `rest-server.ts` (the `/meta` // and analytics doors); the data-action, batch and approval bodies read - // no draft. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(4); + // no draft. [#23050 slice 4] Nor do the two record-sharing bodies + // (`sharing-routes.ts`): 8 = 8 + 0 + 0 + 0 + 0, length 4 → 5. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(5); expect(SOURCE_FILES.map((file) => file.fileName)).toEqual([...REST_SERVER_REGISTRAR_FILES]); expect(SITES).toHaveLength(8); }); diff --git a/packages/rest/src/rest-server-canonical-query-ast.test.ts b/packages/rest/src/rest-server-canonical-query-ast.test.ts index b87ba836d33..d1835b4bf63 100644 --- a/packages/rest/src/rest-server-canonical-query-ast.test.ts +++ b/packages/rest/src/rest-server-canonical-query-ast.test.ts @@ -126,12 +126,18 @@ interface CensusEntry { * census reads it. The §1 controls pin the list's length and the census's sum * over it (4 = 1 + 3 + 0 + 0; the batch and approval bodies build no `query:` * literal). + * + * [#23050 slice 4] 4 = 1 + 3 + 0 + 0 + 0: the two record-sharing bodies + * (`sharing-routes.ts`) build no `query:` literal either — the rule list hands + * `listRules` an `object` / `activeOnly` pair read off `req.query`, never a + * `query:` slot. */ const REGISTRAR_QUERY_SLOT_FLOORS: Readonly> = { 'rest-server.ts': 1, 'data-action-routes.ts': 3, 'batch-routes.ts': 0, 'approvals-routes.ts': 0, + 'sharing-routes.ts': 0, }; const CENSUS: CensusEntry[] = [ @@ -319,8 +325,8 @@ describe('[#16638] §1 CONTROLS on the census instrument itself', () => { it('[#23050 slice 3] censuses every file on the registrar list — its length and the slot sum are pinned', () => { // A module added to the list makes this re-read its number rather than - // widening unnoticed. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(4); + // widening unnoticed. [#23050 slice 4] Length 4 → 5, the sum unchanged. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(5); expect(Object.keys(REGISTRAR_QUERY_SLOT_FLOORS).sort()).toEqual([...REST_SERVER_REGISTRAR_FILES].sort()); expect(CENSUS.filter(({ file }) => REGISTRAR_SOURCES.has(file)).map(({ file }) => file)) .toEqual([...REST_SERVER_REGISTRAR_FILES]); diff --git a/packages/rest/src/rest-server-docblock-position.test.ts b/packages/rest/src/rest-server-docblock-position.test.ts index 2793b4e1975..3295e911343 100644 --- a/packages/rest/src/rest-server-docblock-position.test.ts +++ b/packages/rest/src/rest-server-docblock-position.test.ts @@ -142,11 +142,18 @@ describe('rest-server.ts docblock position', () => { * carry three apiece (the module header, the host interface's block and the * function's) — and a module added to the list needs one here before it reads. * The case below the loop pins the list's length and the sum of the floors. + * + * [#23050 slice 4] `sharing-routes.ts` carries eight: the module header, two + * host interfaces' blocks and two functions' (it holds two registrars' bodies), + * plus the three blocks that moved with the record-share body (`respondError`'s, + * `sharingDeclaredExtra`'s and `sharingFaultMessage`'s), which `rest-server.ts` + * therefore no longer holds (168 → 165 there, above its floor). */ const MOVED_FILE_FLOORS: Readonly, number>> = { 'data-action-routes.ts': 4, 'batch-routes.ts': 3, 'approvals-routes.ts': 3, + 'sharing-routes.ts': 8, }; const MOVED_FILES: ReadonlyArray<{ file: string; minBlocks: number }> = REST_SERVER_REGISTRAR_FILES .filter((file): file is Exclude => file !== FILE) @@ -174,9 +181,10 @@ describe('[#23050 slice 3] the registrar list this pin reads', () => { it('reads every module on it, each against its own floor — the length and the floors\' sum are pinned', () => { // A module added to the list makes this re-read its numbers rather than // widening unnoticed: 4 + 3 + 3 = 10 blocks over the three modules. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(4); + // [#23050 slice 4] 4 + 3 + 3 + 8 = 18 over four, length 4 → 5. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(5); expect(Object.keys(MOVED_FILE_FLOORS).sort()) .toEqual(REST_SERVER_REGISTRAR_FILES.filter((file) => file !== FILE).sort()); - expect(MOVED_FILES.reduce((n, { minBlocks }) => n + minBlocks, 0)).toBe(10); + expect(MOVED_FILES.reduce((n, { minBlocks }) => n + minBlocks, 0)).toBe(18); }); }); diff --git a/packages/rest/src/rest-server-query-number-census.test.ts b/packages/rest/src/rest-server-query-number-census.test.ts index e6e770ca939..3817c7a7fe1 100644 --- a/packages/rest/src/rest-server-query-number-census.test.ts +++ b/packages/rest/src/rest-server-query-number-census.test.ts @@ -317,8 +317,10 @@ describe('#20139 census — every numeric coercion in rest-server.ts is classifi // A module added to the list makes this re-read its number rather than // widening unnoticed. 9 sites = 8 in `rest-server.ts` + 1 in // `data-action-routes.ts` (the export door's `page`); the batch and - // approval bodies carry none. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(4); + // approval bodies carry none. [#23050 slice 4] Nor do the two + // record-sharing bodies (`sharing-routes.ts`): 9 = 8 + 1 + 0 + 0 + 0, + // length 4 → 5. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(5); expect(SOURCES.map(([file]) => file)).toEqual([...REST_SERVER_REGISTRAR_FILES]); expect(sites).toHaveLength(9); }); diff --git a/packages/rest/src/rest-share-declared-code.test.ts b/packages/rest/src/rest-share-declared-code.test.ts index 27d2ef9606c..35b6317d108 100644 --- a/packages/rest/src/rest-share-declared-code.test.ts +++ b/packages/rest/src/rest-share-declared-code.test.ts @@ -7,7 +7,8 @@ * * ## The defect, reproduced before it was repaired * - * `respondSharingError` (`rest-server.ts`) asks {@link classifiedRefusalAnswer} + * `respondSharingError` (`sharing-routes.ts` since #23050 slice 4, `rest-server.ts` + * before) asks {@link classifiedRefusalAnswer} * — the `/data` door's own classification — and then re-dressed only `status`, * `code` and the message into the nested ADR-0112 D5 envelope (#8111). The * classification's body was ALREADY carrying the demoted spelling, computed by diff --git a/packages/rest/src/rest-share-user-message.test.ts b/packages/rest/src/rest-share-user-message.test.ts index 708337c68cd..108df957c69 100644 --- a/packages/rest/src/rest-share-user-message.test.ts +++ b/packages/rest/src/rest-share-user-message.test.ts @@ -7,7 +7,8 @@ * * ## The defect, reproduced before it was repaired * - * `respondSharingError` (`rest-server.ts`) asks {@link classifiedRefusalAnswer} + * `respondSharingError` (`sharing-routes.ts` since #23050 slice 4, `rest-server.ts` + * before) asks {@link classifiedRefusalAnswer} * — the flat `/data` door's own classification — and re-dressed `status`, * `code`, the message and (since #12510) `declaredCode` into the nested * ADR-0112 D5 envelope (#8111). The classification's body was ALREADY carrying diff --git a/packages/rest/src/ui-view-route-identity.measurement.test.ts b/packages/rest/src/ui-view-route-identity.measurement.test.ts index c00c85c43c8..81a1a789799 100644 --- a/packages/rest/src/ui-view-route-identity.measurement.test.ts +++ b/packages/rest/src/ui-view-route-identity.measurement.test.ts @@ -341,8 +341,10 @@ describe('[#13214] §1 the REST seam — absent context vs entitled context', () expect(body.includes('this.resolveExecCtx(')).toBe(true); // ⛔ Reverse-checked: a `true` from a substring test is only worth // something if the term is spelled the way the rest of the file spells - // it — 52 sibling sites say it is. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(4); + // it — 52 sibling sites say it is. [#23050 slice 4] The list's length + // 4 → 5 (`sharing-routes.ts`); over it the two terms read 52 and 67 + // (50 and 65 before, +1 each per new host-literal forwarder). + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(5); expect(REGISTRAR_SOURCES.split('this.enforceAuth(').length - 1).toBeGreaterThan(40); expect(REGISTRAR_SOURCES.split('this.resolveExecCtx(').length - 1).toBeGreaterThan(40); }); diff --git a/packages/rest/src/ui-view-route-tenancy.measurement.test.ts b/packages/rest/src/ui-view-route-tenancy.measurement.test.ts index d0857a030dc..390f7b9171c 100644 --- a/packages/rest/src/ui-view-route-tenancy.measurement.test.ts +++ b/packages/rest/src/ui-view-route-tenancy.measurement.test.ts @@ -1108,7 +1108,10 @@ describe('[#13214] §4 preconditions', () => { // ⛔ Reverse-checked: the same terms counted over the WHOLE file, so // these readings are about this registrar and not about a misspelling. - expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(4); + // [#23050 slice 4] The list's length 4 → 5 (`sharing-routes.ts`); over + // it the first two terms read 52 and 67 (50 and 65 before, +1 each per + // new host-literal forwarder) and the ownership guard stays at 1. + expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(5); expect(REGISTRAR_SOURCES.split('this.enforceAuth(').length - 1).toBeGreaterThan(40); expect(REGISTRAR_SOURCES.split('this.resolveExecCtx(').length - 1).toBeGreaterThan(40); // ...and the ownership guard is NOT sprayed across the file — it is a diff --git a/scripts/check-route-envelope.mjs b/scripts/check-route-envelope.mjs index 7332a6b8b8f..712dd40e3c7 100644 --- a/scripts/check-route-envelope.mjs +++ b/scripts/check-route-envelope.mjs @@ -339,6 +339,11 @@ const MODULES = { // string `error` body of `registerApprovalsEndpoints`' handlers left this // file with that body for `approvals-routes.ts`, declared below at the count // it had here; the sum is unchanged (17 + 13 + 8 + 1 = 39). + // + // 17 → 17 (#23050 slice 4): the record-sharing and sharing-rule bodies left + // this file for `sharing-routes.ts` carrying NO literal string `error` body + // (every arm there computes its message), so this count does not move and + // that module declares 0 below; the sum is unchanged (17 + 13 + 8 + 1 + 0 = 39). stringError: 17, // 77 → 75 (#7981): registerSecurityEndpoints' two `handleError` arms moved // off the `{ code, error }` sibling-code literal onto the shared @@ -390,7 +395,13 @@ const MODULES = { // above. Nine `{ code, error }` bodies left with the approvals body for // `approvals-routes.ts`, declared below; the sum is unchanged // (22 + 15 + 8 + 9 = 54). - siblingCode: 22, + // + // 22 → 18 (#23050 slice 4): NOT progress — the same MOVE as `stringError` + // above. The four `{ code, error }` bodies of the sharing-rule family's + // `handleError` (`VALIDATION_FAILED`, `PERMISSION_DENIED`, `RULE_NOT_FOUND` + // and its 500 terminal) left with that body for `sharing-routes.ts`, + // declared below; the sum is unchanged (18 + 15 + 8 + 9 + 4 = 54). + siblingCode: 18, }, // [#8850] The ADR-0112 error/fault-classification prologue, extracted from @@ -481,6 +492,24 @@ const MODULES = { stringError: 1, siblingCode: 9, }, + + // [#23050 slice 4] `RestServer#registerSharingEndpoints`' and + // `#registerSharingRuleEndpoints`' bodies — the per-record share family and + // the sharing-rule family — moved out of `rest-server.ts` whole, into ONE + // module, on exactly slice 1's terms above: a MOVE, no wire answer changed, + // so it inherits `dialectOnly` and the two dialect facts, and no write-site + // count. Both counts were measured on the moved file and equal what left + // `rest-server.ts` in the same PR (stringError 17 → 17, siblingCode 22 → 18), + // so each sum is unchanged. Both only ever tick down. Discovered by the + // `*-routes.ts` convention and declared in the same PR that created the + // module, so the gap never exists. + 'packages/rest/src/sharing-routes.ts': { + dialectOnly: + 'moved out of `rest-server.ts` by #23050 slice 4 and held to exactly the guarantee it had there — the two dialect facts, never a write-site count', + ratchet: '#9559 (option 1: convert onto the shared sendOk/sendError)', + stringError: 0, + siblingCode: 4, + }, }; /** Identifiers whose `.json()` READS a request rather than writing a response. */ From aa4ad8f41c0a96050c1037fe80b0f08c81bafc8e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 16:28:37 +0000 Subject: [PATCH 3/4] chore(adr-anchors): anchor sharing-routes.ts to ADR-0111 and ADR-0112 so the moved letter citations stay checked (#23050 slice 4) check-adr-anchors verifies ADR-NNNN Dk citations only on the anchored surface. The eight in the moved sharing bodies were on it only because rest-server.ts is anchored; the move dropped the reading 1756 -> 1748. Anchoring the new module restores it to 1756. Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU Co-authored-by: Claude --- .../packages__rest__src__sharing-routes.ts.json | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 scripts/adr-anchors/packages__rest__src__sharing-routes.ts.json diff --git a/scripts/adr-anchors/packages__rest__src__sharing-routes.ts.json b/scripts/adr-anchors/packages__rest__src__sharing-routes.ts.json new file mode 100644 index 00000000000..7ca250af7ee --- /dev/null +++ b/scripts/adr-anchors/packages__rest__src__sharing-routes.ts.json @@ -0,0 +1,8 @@ +{ + "file": "packages/rest/src/sharing-routes.ts", + "adrs": [ + "ADR-0111", + "ADR-0112" + ], + "invariant": "The record-sharing REST doors, moved here whole from `rest-server.ts` (#23050 slice 4): the per-record share family (`GET` / `POST {base}/data/:object/:id/shares`, `DELETE …/shares/:shareId`) and the sharing-rule family under `{base}/sharing/rules`. ADR-0111 — the doors decide no share authority of their own: the SERVICE enforces it (its D1/D2, and D6 for the rule surface's `manage_sharing` gate), and the doors only map its verdicts onto HTTP: a listing is management-gated (an invisible record answers 404, a visible record to a non-manager 403; its D5); a revoke forwards the URL's (object, id) as its scope, so a share id is revoked only through the record it belongs to (its D4); an inert grant or rule evaluation refuses 422 `SHARING_NOT_ENABLED` instead of materialising rows nothing reads (its D7). ADR-0112 — the per-record family answers every refusal through ONE emitter (`respondError`) at the nested position, where `error.code` carries the semantic code (its D5), typed as the closed `ErrorCode` vocabulary and filled from the catalog's own floor (`standardErrorCodeForHttpStatus`) when a classified refusal names none (its D4). A mapping arm dropped, a new one invented here, or a refusal answered in a second dialect is the drift both records forbid; the bodies' own comments carry each citation." +} From 7b101d0a640f7756a157da0eda72ac35332f1679 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 16:30:54 +0000 Subject: [PATCH 4/4] test(rest): state the docblock count rest-server.ts gave up without a base-bound number (#23050 slice 4) Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU Co-authored-by: Claude --- packages/rest/src/rest-server-docblock-position.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/rest/src/rest-server-docblock-position.test.ts b/packages/rest/src/rest-server-docblock-position.test.ts index 3295e911343..436c9edac28 100644 --- a/packages/rest/src/rest-server-docblock-position.test.ts +++ b/packages/rest/src/rest-server-docblock-position.test.ts @@ -147,7 +147,7 @@ describe('rest-server.ts docblock position', () => { * host interfaces' blocks and two functions' (it holds two registrars' bodies), * plus the three blocks that moved with the record-share body (`respondError`'s, * `sharingDeclaredExtra`'s and `sharingFaultMessage`'s), which `rest-server.ts` - * therefore no longer holds (168 → 165 there, above its floor). + * therefore no longer holds (three fewer there, still far above its floor). */ const MOVED_FILE_FLOORS: Readonly, number>> = { 'data-action-routes.ts': 4,