Repository navigation
fix(plugin-sharing): a plain member's own share-link list is self-scoped (ADR-0111) - #21403
Conversation
Route-level pin through the real runtime composition: a plain member lists exactly their own links, foreign creators stay absent, the admin path and the anonymous 401 are unchanged. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
listLinks read sys_share_link under the caller's context, so the self-scoped list ADR-0111 rules (both doors force createdBy to the caller) demanded an object-level grant the member baseline does not carry, and every plain member's list was refused. The caller's own list (a non-empty user identity, and the creator filter equal to it) is now read under the system context, constrained server-side to that identity, and every row must pass the creator rule before it leaves. One helper, isLinkCreator, is that rule for both listLinks and revokeLink. Every other shape keeps the caller's context. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…redicate alone Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…coped list on the contract The elevation predicate no longer reads isSystem: a system caller asking for its own links gets the same rows either way, and listLinks keeps the single isSystem read site it had. The IShareLinkService.listLinks doc comment said every listing is read under context; it now states the self-scoped own list. Adds the persona pin to the route test and the changeset. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
The route's list is always the caller's own, which is now the self-scoped system read, so the envelope pin reads what the route hands listLinks (every resolver key) instead of the context of a read that no longer carries it. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 138 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 37ca7de54a5c4369209615168125af7fe83f9184 && git checkout 37ca7de54a5c4369209615168125af7fe83f9184
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 85986144c2ef6f379955137677c5cbfb00e194d2 8682b7b24115599b49aaba02fd05777ecc0c5a0d && git checkout -B drift-repro 85986144c2ef6f379955137677c5cbfb00e194d2 && git merge --no-ff 8682b7b24115599b49aaba02fd05777ecc0c5a0d
node scripts/docs-audit/affected-docs.mjs --json 85986144c2ef6f379955137677c5cbfb00e194d2
|
…tusCode, so a share-link permission refusal answers 403 at both doors (objectstack-ai#21429) Fixes objectstack-ai#21405 Clause-②: no ## What was wrong `PermissionDeniedError` (`plugin-security/src/errors.ts`) declared `statusCode = 403` and no `status`. It was the only error class in that module that did. A door that reads `status` alone derived no status from it. `plugin-sharing`'s share-link route door reads `err?.status ?? 500`, and that door serves `/api/v1/share-links` on the standalone server. Measured on a showcase boot (`@objectstack/verify`, with the app's own default profile), as a plain member, at `main` 6d67ad5: | request | plugin route door | runtime dispatcher `/share-links` domain | |---|---|---| | `POST /share-links` on a `showcase_client_brief` record the member cannot read | 500 `PERMISSION_DENIED` | 403 `PERMISSION_DENIED` | | `GET /share-links` | 500 `PERMISSION_DENIED` | 403 `PERMISSION_DENIED` | The dispatcher door was driven in-process, the way `@objectstack/verify`'s own handle drives it: an `HttpDispatcher` over the same booted kernel, with the same bearer token. ## The change (the triage ruling on the card) - `PermissionDeniedError` carries `readonly status = 403` beside `statusCode = 403`. The code, the message, `details`, `developerMessage` and `statusCode` are unchanged. No door is edited, and no status helper is added anywhere. - The module's "Why each carries BOTH `status` and `statusCode`" note now names the readers as they stand today. The share-link route door and the sandbox boundary's passthrough read `status` alone. `errorFromThrown` and `mapDataError` read both spellings. The note used to say `mapDataError` reads `status` alone, which stopped being true when it learned both spellings. ## Pins - **Enumeration** (`plugin-security/src/errors.test.ts`). Every `Error` subclass that `errors.ts` exports is constructed, and each must carry a numeric `status` and `statusCode` with equal values. The population is read from the module's exports, so a class added later is checked without being listed. A floor names the eight classes exported today, so the enumeration cannot pass over nothing. - **One refusal, both doors** (`runtime/src/domains/share-links-enforcement-context.test.ts`, the new `[objectstack-ai#21405]` block). The harness has one engine double with the whole `SecurityPlugin` middleware booted on it, one `ShareLinkService` and one envelope. It drives both production entries: `registerShareLinkRoutes` mounted on a route recorder, and `handleShareLinksRequest` over the dispatcher's own `errorFromThrown`. A create by a caller with no `allowRead` on the object answers 403 `PERMISSION_DENIED` through both doors, under the `single` and the `group` posture, and writes no link. - **Create only.** PR objectstack-ai#21403 (for objectstack-ai#21328, merged into this branch) made a member's own list a self-scoped read. Measured on the showcase boot after that merge: `GET /share-links` answers 200 through both doors, with no filter, with the Share dialog's object and record filter, and with `includeRevoked`. No list request reaches the refusal any more, so no list case is pinned. Before the merge, a list case was written, and it went red under the ablation below. - The existing `[objectstack-ai#6649]` shared-catch case drove the production class to reach the dispatcher catch's `statusCode` channel. The class now carries `status` as well, so the case adds a `statusCode`-only throw beside it, and that channel stays pinned. ## Tests (head 01bb1de unless noted) - Runtime: `share-links-enforcement-context`, `data-permission-denied-envelope`, `permission-denied-error-parity` and `share-links-internal-hash-probe` give 4 files, 39 passed. - `pnpm --filter @objectstack/runtime typecheck` is OK. Its test layer holds 27 files / 190 errors / 68 signatures in its ledger, unchanged. - `pnpm --filter @objectstack/plugin-security typecheck` is OK. `errors.test.ts` is in the `tsconfig.test.json` program (`--listFiles`: 1 hit). - `pnpm --filter @objectstack/plugin-security test`: 161 files, 3502 passed, 33 skipped. `pnpm --filter @objectstack/plugin-sharing test`: 38 files, 928 passed. Both ran at 46b09ea. Since then, the only change in either package is a comment in `errors.test.ts`. - Importers whose answer could move: the 13 `rest` test files that import `@objectstack/plugin-security` give 209 passed. The dogfood share-link files (`share-links-self-list`, `showcase-client-liaison-fixtures`, `audit-log-internal-fields`) pass. Both ran at 46b09ea. - Gates: `dispatch-gates --commands` at 01bb1de derives 67 commands, and all 67 exit 0. The `--ran` reconciliation reports 67 derived, 67 run and 0 NOT-MEASURED, derived from the recorded exit codes. At an earlier head, `check:dual-build-cjs-loads` first exited 3 (`PREREQUISITE NOT MET`: 8 unbuilt packages). Those were built, and every later run exits 0. - Lint (narrowed): `eslint --no-inline-config --format json` over the 3 changed TS files reports 3 files, 0 errors and 0 warnings, and the config resolves for each file. `eslint.config.mjs` enables no type-aware linting (its own note: no `parserOptions.project`, no typed rules), so this diff cannot move a verdict on an untouched file. The full `pnpm lint` is CI's. ## Ablations Each leg ran from a committed head through `scripts/ablation-replace.mjs`: the anchor hit, the blob changed, and the restore was proven by an empty `git diff HEAD`. `plugin-security` resolves from `dist/` in the runtime and dogfood suites, so each of those legs rebuilt it, and `scripts/ablation-dist-preflight.mjs` proved the marker in `dist/` (4 files) and then absent (all 6 files, tree clean). | mutation in `errors.ts` | suite | red | green | |---|---|---|---| | `status` renamed off `PermissionDeniedError` (`status_ablated_21405`) | runtime `share-links-enforcement-context` (head d9f9aab) | 2: both `[objectstack-ai#21405]` postures, plugin door `expected 500 to be 403` | 18, every `[objectstack-ai#6649]` dispatcher case included (that door reads `statusCode`) | | same | `errors.test.ts` | 2: the enumeration (`PermissionDeniedError: status=undefined statusCode=403`) and the 403 case | 1 (the floor) | | same, at b7fdf64 | the showcase dogfood pin then on this branch (create and list, both doors) | 2: create 500 vs 403, list 500 vs 403 | 1 (persona) | | a scratch `export class AblatedStatusCodeOnlyError extends Error { readonly statusCode = 418; }` | `errors.test.ts` | 1: `AblatedStatusCodeOnlyError: status=undefined statusCode=418 — declare both` | 2 | | `status = 404` on `PermissionDeniedError` | `errors.test.ts` | 2: `status 404 !== statusCode 403` and the 403 case | 1 | The restore legs pass: runtime 20/20 and `errors.test.ts` 3/3. The first attempt at the planted-class leg did not run. `ablation-replace` refused it before any test, because its replacement contained the anchor, so the anchor count did not fall. It was redone with an anchor the replacement does not contain. ## Docs `content/docs/**` (outside `releases/`) and `skills/**` hold no sentence this change makes false. The status table in `protocol/kernel/error-handling.mdx` gives 403 for insufficient permissions, and that is now true at the share-link door. `permissions/field-level-security.mdx` shows a partial dump of the thrown error without `status`. The dump states nothing false, so it is left alone (an edit was made on this branch and reverted). ## Acceptance notes - **Pin location.** The claim put the route pin in `packages/qa/dogfood/test/` or beside the plugin. A dogfood version came first: a showcase boot, both doors, create and list. It passed, and it went red under ablation. It reached the dispatcher door by importing `runtime/src/http-dispatcher.ts`, and `check:test-source-alias` refused that (exit 1): four new unaliased artifact imports into dogfood (`metadata-protocol`, `observability`, `rest`, `service-datasource`). Fixing that means aliasing them in dogfood's vitest config, which changes every dogfood boot. The plugin packages cannot import runtime, because of the dependency direction. The runtime package already imports both doors, so the pin moved there, beside the dispatcher's own `[objectstack-ai#6649]` block. - **Doors this fixes, named and not edited.** Each reads `status` alone: - `plugin-sharing/src/share-link-routes.ts`, five catches (create, list, revoke, resolve, messages). Create and list are measured above. Resolve and messages read under the system context, so they never meet this refusal. Revoke is not measured. - The sandbox boundary. `SANDBOX_ERROR_PASSTHROUGH` in `runtime/src/sandbox/quickjs-runner.ts` carries `code`, `fields`, `status` and `userMessage`, but not `statusCode`. A `PermissionDeniedError` from a host call inside a sandboxed body now crosses with its 403. Not measured. - `metadata-protocol/src/protocol.ts`, the `deleteMetaItem` catch (`e.status = err?.status ?? 500`). Not measured. - `service-analytics/src/analytics-service.ts`, `hasDeclaredErrorEnvelope` (a numeric `status` plus a `code`). A `PermissionDeniedError` now counts as declared and is re-thrown before the missing-source heuristic. Not measured. - `runtime/src/domains/actions.ts`, the `setActionActive` catch (`status`, else 503). Not measured, and this refusal is unlikely there. - **Readers whose wire answer does not move.** - `rest`'s `resolveErrorResponse` passthrough reads `status` alone. A `PermissionDeniedError` used to fall through to `mapDataError`, which answered 403 `PERMISSION_DENIED` with the message and `object`. It now takes the passthrough arm, with the same status, code and `object`. The message passes the 500-character client bound and the declared-code-prefix strip, and neither changes a message inside those limits. This is read from the code; the 13 `rest` test files above pass. - `rest-server`'s analytics envelope reader ① now answers this refusal where ①b answered it, with the same status and code. Read from the code, not measured. - `plugin-auth`'s `createOAuthClient` catch reads `status` alone, but it only meets better-auth errors. - **A stale comment in a door file, not edited.** `runtime/src/domains/share-links.ts` (the catch's docblock) says the enforcement refusals "carry `statusCode`, not `status`". That is no longer true of `PermissionDeniedError`. Carrier: whoever next touches that file; no carrier is named. - `plugin-security/src/packaged-permission-set-lock.ts` holds two more 403 classes outside `errors.ts`. Both already carry both spellings. As ruled, the enumeration covers `errors.ts` exports only. - objectstack-ai#21329 (`createLink` refusals) is not addressed here. Triage orders it after this card, and its pins can now read either door. --- _Generated by [Claude Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #21328
Clause-②: no
What was wrong
GET /api/v1/share-linksrefused every plain member: a caller holdingmember_defaultand nosys_share_linkgrant was refused with no filter, with an object filter, and with any record. The console's Share dialog loads this list when it opens, so it failed for that whole class of user on every record. Both share-link doors already forcecreatedByto the caller, because ADR-0111's surface table rules the list self-scoped. ButShareLinkService.listLinksreadsys_share_linkunder the caller's context, and that read needs an object-level grant the member baseline does not carry. An admin's list answered.Measured through this PR's route pin (showcase boot via
@objectstack/verify). At the merge baseee75aae1a, and again with the elevation ablated at8682b7b24, the plain member's six list cases answered 500 with codePERMISSION_DENIED. The admin's list answered 200 with exactly the admin's own link. The Acceptance notes explain why this composition answers 500 where the card's hosted composition answered 403.The change (the triage direction quoted on the card)
isLinkCreator(row, context)inshare-link-service.tsholds when the caller has a non-empty user identity and the row'screated_byequals it.revokeLinkandlistLinksboth read this rule, and there is no second creator test.listLinks. The read runs under the system context only when the creator filter passes that rule, which means the filter names the caller. In that case thecreated_byconstraint is the caller's identity, written server-side, and each returned row must passisLinkCreatorbefore it leaves. Every other shape keeps the caller's context: no creator filter, another user as creator, an empty or absent identity, and an admin listing someone else's links. A system caller keeps its bypass.listLinkskeeps its singleisSystemread, so the system-context census stays at 114.internalcolumns under any context. The token comes back through the existing privileged accessor, and the password hash never does (route pin[secret]).sys_share_linkgrant (route pins[persona]and[no-grant]).What this deliberately changes beyond the list
revokeLink's creator check now refuses a caller with no user identity. Before,undefined === undefinedlet an identity-less, non-system caller revoke a link whosecreated_bythe driver omitted. Neither HTTP door reaches that case, because both answer 401 first. The new behaviour is pinned fail-closed. The creator, non-creator and ADR-0111 D8 record-manager paths are unchanged, and their existing pins pass untouched.share-link-enforcement-context.test.ts. The pinlistLinks reads under the same whole enveloperead the envelope off thesys_share_linkread. The route's list is always the caller's own, and that list is now the system read, so the pin now observes the envelope at thelistLinkscall. It checks that every key the resolver produced arrives, using the same comparison as thecreateLinkpin, and asserts that the read is the self-scoped one. The property the file pins is unchanged: the route hands enforcement the whole envelope (ruling A, commit 8e13ca8).@objectstack/spec. TheIShareLinkService.listLinksdoc comment said every listing is read undercontext. It now describes the self-scoped own list. This is a doc comment only.Tests (head
8682b7b24)Route pin, real composition.
packages/qa/dogfood/test/share-links-self-list.dogfood.test.tshas 11 cases, and all 11 pass. A plain member lists exactly their own links, both with no filter and with the Share dialog's object and record filter. Member B's links and the admin's sit on the same record and stay absent. AcreatedByquery naming B is ignored. A plain member with no links gets[]. The row carries its token and nopassword_hash. The admin's list is unchanged, and an anonymous caller gets 401UNAUTHENTICATED.Service pins.
share-link-service.test.tsandshare-link-enforcement-context.test.tspass 52 of 52. The service pins cover:PERMISSION_DENIED/ 403;created_bypredicate, which still leaks no foreign row;Package runs.
pnpm --filter @objectstack/plugin-sharing testpasses 928 tests in 38 files, and itstypecheckis OK.@objectstack/specpassestypecheck, and itstestpasses 17529 tests in 598 files.@objectstack/dogfoodpassestypecheck, and its tsc program includes the new test.Ablations, service level. Each leg ran from the committed head through
scripts/ablation-replace.mjs, and each restore was proven to leave the blob equal to HEAD withgit diff HEADempty.isLinkCreatorwhereand creator post-filter)whereonly removedAblation, route level. The elevation was removed and plugin-sharing rebuilt.
ablation-dist-preflightfound the marker in 2 dist files. Result: 6 red (the own, empty, secret and foreign cases, at 500PERMISSION_DENIED) and 5 green (persona, no-grant, fixture, admin, anonymous, which the test header says sit still). For the restore leg, plugin-sharing was rebuilt, the marker was absent from dist, and all 11 passed. The first route mutation (afalse &&guard) was constant-folded out of dist. The preflight caught that before any test ran, so it measured nothing, and the leg was redone with a marker the bundler keeps.Gates.
dispatch-gates --commandsat this head derives 89 commands, and all exit 0.check:dual-build-cjs-loadsfirst exited 3 (PREREQUISITE NOT MET, because unrelated packages had not been built). Those packages were built from the turbo cache, and the rerun exited 0. The--ranreconciliation reports 89 derived, 89 run and 0 NOT MEASURED, a figure derived from the recorded exit codes.Lint (narrowed). eslint
--no-inline-configover the 5 changed TS files reports 0 errors and 0 warnings in all 5 files, and the config resolves for each. The narrowing is safe because the config enables no type-aware rules, and its local plugins read only their baselines. So this diff cannot move the verdict on any untouched file.Docs
content/docs/**(outsidereleases/andreferences/) andskills/**hold no sentence this change makes false. It makes true ADR-0111's surface-table entry for share links, "list is self-scoped", for every signed-in caller.Acceptance notes
registerShareLinkRoutesserves/api/v1/share-links, not the runtime/share-linksdomain. The error status shows it: that door answers 500 where the dispatcher's mapper answers 403. The runtime domain forwardscreatedByas the caller's id and the full envelope to the samelistLinks, so the service fix covers both doors. The runtime door has no separate route pin here.PERMISSION_DENIEDrefusal (which carriesstatusCode403 and nostatus) to HTTP 500. It is still reachable after this PR:POST /api/v1/share-linksby a plain member, on an object they cannot read, answered 500 with codePERMISSION_DENIEDon the showcase boot.revokeLink's creator read already does. Share-link rows are inserted under the system context withorganization_idnull (measured on the showcase boot). A caller's own list therefore spans organizations in the environment's database, the same reachrevokeLinkalready gives the creator. This follows the ruled direction. It is an observation only, with no wrong answer measured.scripts/adr-anchorsentry forshare-link-service.ts. The ADR-0111 id stays in the code. An anchor would be outside the claimed surface, and no one is set to carry it.origin/mainhas moved since the merge base. None of the upstream changes touch this PR's six files.Generated by Claude Code