Skip to content

Commit 6f017e2

Browse files
feat(plugin-sharing): list the objects a sharing rule can take effect on (#31207)
<!-- claude-projects-attribution: {"github_login":"marchtian"} --> _Requested by **田騏** · [project thread](https://claude.ai/code/project/chan_01VmuUhXU9SSUzqJYyXSmYSi?thread=cmsg_01VmuUhXU9SSUzqJYyXSmYSiFMQNAUTGWk1GeD14KvBfaa)_ Refs #30209 (row 5 B, server half; rows 1 and 2 measured below). The card stays open for row 1 and the console picker. Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - 改了什么:plugin-sharing 新增只读接口 `GET /api/v1/sharing/shareable-objects`,返回「共享规则能生效的对象」清单(业务对象在前、系统对象在后,各组按标签排序);判定直接调用保存时拒绝用的同一个判断(#30621 的 `sharingRuleObjectRefusal`),不另写一份;会话、账号、API 密钥、密钥等凭据类对象都不在清单里。 - 为什么改:新建共享规则时对象选择器列出 86 个对象,大多是 `sys_*`,业务对象被埋住,还能选到存凭据和会话的对象(保存才报错)。按裁决 6670358877 的第 5 行 B,服务端先给出清单,控制台下一张卡据此只列这些对象。 - 风险与回滚:纯新增、只读,不改存储、不改任何已有接口和元数据;只有共享规则管理员(`manage_sharing`,或旧的 `manage_platform_settings`,且有当前组织)能调用,其他人 403、匿名 401、带查询参数 400。回滚即撤销本 PR。 - 先看:`packages/plugins/plugin-sharing/src/shareable-objects.ts`(清单与排序,判定由调用方传入)、`src/sharing-rule-service.ts` 的 `listShareableObjects`(准入与判定接线)、`src/shareable-objects-routes.ts`(拒绝顺序:身份→受限登录→匿名→查询参数→管理员)。 - 两分钟验证:`pnpm --filter @objectstack/plugin-sharing exec vitest run src/shareable-objects.test.ts`,预期 16 个用例全过,其中「每个不在清单里的对象,defineRule 都以 SHARING_NOT_ENABLED 拒绝」逐个对象核对。 - 需要你做的:无,席位落地。 ## What changes - **A read-only list, mounted by plugin-sharing itself.** `registerShareableObjectsRoutes` (`src/shareable-objects-routes.ts`) mounts `GET /api/v1/sharing/shareable-objects` on `http.server` (alias `http-server`) at `kernel:ready`, the way the share-link routes are mounted. The rule administration routes (`/api/v1/sharing/rules*`) belong to `@objectstack/rest` over the `sharingRules` spec contract; a list there would need a new contract member, so this door reads the plugin's own `SharingRuleService` instead and touches neither `packages/rest` nor `packages/spec`. No HTTP server: one `warn` naming the route. - **What is listed** (`src/shareable-objects.ts`): every object in the engine's registry (`getConfigs()`) for which `SharingService.sharingRuleObjectRefusal(object)` answers `null`, the check both save doors (#30621) already ask, handed in as a function and never restated. Each entry is `{ name, label, system }`; `system` is `isSystem` or a `sys_` name. Business objects first, then platform objects, each group by label, then name. Response: `{ success: true, data: { objects } }`. - **Who may ask** (`SharingRuleService.listShareableObjects(context)`, new): the sharing-rule surface's own admission, `assertCanManageRules` (ADR-0111 D6: `manage_sharing` or the legacy `manage_platform_settings`, plus an active organization unless platform authority). The route's refusal order, each before the list is read: caller resolution through `resolveRequestExecutionContext` (session or API key; a permission-store outage and an API key lifecycle refusal relayed as declared), held sign-in `403` with the policy code, anonymous `401 UNAUTHENTICATED`, any query parameter `400 VALIDATION_ERROR` naming it (closed set: empty), then a non-administrator `403 PERMISSION_DENIED` with the surface's sentence. - **Never an empty list in place of an answer**: no rule service running (`enforce: false`) answers `501 NOT_IMPLEMENTED`, as the REST rule routes do; an engine that cannot enumerate its objects answers `500 INTERNAL_ERROR` with the error in the fault log. - **Ledger and guards**: `src/sharing-route-ledger.ts` (one `server-only` row) with its package-local conformance test; the live-mount parity dogfood test imports it, and `scripts/cross-package-test-inputs.mjs` / `turbo.json` declare it as that test's input; `scripts/check-route-envelope.mjs` declares the new door in `MODULES` at `{ responses: 0, ok: 0, err: 0 }` (every body goes through `sendOk` / `sendError` / `sendServerFault`). These four one-line wirings are the ones #31004's amendment named. - **One spelling of "platform object"**: `isPlatformObjectSchema` (`isSystem` or a `sys_` name) is extracted in `src/sharing-service.ts` from `effectiveSharingModel`'s own test, and the list reuses it for its `system` flag (no behaviour change; the system-context census keys that read to this file). - **Changeset**: `.changeset/30209-sharing-shareable-objects.md`, `@objectstack/plugin-sharing: patch`, both declaration lines at line start. ## Row 5's security note: credential and session objects Measured, then pinned. The platform's credential and session objects as their packages declare them (`sys_session`, `sys_account`, `sys_verification`, `sys_api_key`, `sys_two_factor`, `sys_oauth_application`, `sys_oauth_access_token`, `sys_oauth_consent`, `sys_jwks`, `sys_secret`) are none of them listed, and `defineRule` refuses a rule on each with `SHARING_NOT_ENABLED` / `422` (each is owner-less or public). So, after #30621, a rule on one of them is refused at save and never shares anything; none remains listable. A wider probe registered every object definition `@objectstack/platform-objects` (root, identity, security, audit) and plugin-sharing export, 65 objects: none was listable. ## Rows 1 and 2: readings (no code) **Row 2 is done on `main` `8ef4d309`** (PR #30496), both halves: - Condition in words: `describeFilterGroup` in `ui/packages/fields/src/widgets/FilterConditionField.tsx`, 2 hits; its pin `FilterConditionField.readWords-30209.test.tsx` (3 cases) is on main. - Recipient by name: the record page hands the record to the field face (`dependentValues={data}` in `ui/packages/plugin-detail/src/DetailSection.tsx`, 2 sites), so `recipient-picker` reads `recipient_type`; pinned by `recordFace.siblingValues-30209.test.tsx` (2 cases) on main. **Row 1: the cheapest honest shape, and which arm needs spec.** - The tab today is `record:related_list` over `sys_user_permission_set` (`packages/platform-objects/src/pages/sys-user.page.ts`), which can only list stored rows: sets from the default (`everyone`) binding and from positions are never rows, so no related-list source can show them. No existing page component shows effective access. - **The page component needs no spec change.** The spec's component `type` is an open string outside the namespaces its enum claims (`ai`, `app`, `element`, `global`, `nav`, `page`, `record`), and `mcp:authorized-assistants` on the same page is the precedent: a console widget whose zod arm lives in `ui/packages/types`, placed by the page with no `packages/spec` edit. A `security:effective-access` widget (any unclaimed namespace) follows it. The ui seat's reading (6659268469) that row 1 needs a spec vocabulary entry holds only for a type inside a claimed namespace such as `record:`. - **The data is the missing piece, and that arm is server.** `POST /api/v1/security/explain` answers one object and one operation; its `principal.permissionSets` lists the resolved names without their origin, and only its per-layer `contributors[].via` says how a set arrived (`baseline`, `everyone`, `position:NAME`), per object. `resolveUserAuthzGrants` (`@objectstack/core`) carries `permissions` and `directPermissions` but not which position or default binding brought each other set. So "effective sets with their origin (default set, position, direct)" needs a per-user read, for example `GET /api/v1/security/users/:id/effective-access`, built on the resolver plus the catalog's position bindings. If it is served by `@objectstack/rest` over a spec service contract, that contract member is the one spec arm; served by plugin-security on `IHttpServer` (this PR's shape), none. - Proposed split for the seat: (1) server card: the per-user effective-access read with origins; (2) ui card: the widget plus the one-line page edit in `sys-user.page.ts` (platform-objects). No spec card unless the read goes through the REST contract. ## Tests - `src/shareable-objects.test.ts` (new, 16 cases) on real `ObjectQL` + `SqlDriver` (better-sqlite3), with the real `SharingService` / `SharingRuleService` and the #30621 save guard bound: - the list: admitted objects only, business before platform, each by label; four admitted shapes (private, read, public-with-owner, a platform object under enforcement) and four refused (owner-less, the plugin's bypass `sys_user`, `controlled_by_parent`, `sys_sharing_rule` itself); - **one verdict**: for every registered object, "not listed" equals "`defineRule` refuses with `SHARING_NOT_ENABLED` 422"; - the credential and session objects above: absent, and each refused at `defineRule`; - admission: a member without `manage_sharing` and an organization-less `manage_sharing` holder are refused `PERMISSION_DENIED`; CONTROL the legacy `manage_platform_settings` is admitted; - the door, through the real platform resolver (a fake auth session, a grant store bound to a catalog whose set confers `manage_sharing`): an administrator is served the list and the service receives the resolved context; a member is refused `403 PERMISSION_DENIED` by the real service; anonymous `401`, held sign-in `403 PASSWORD_EXPIRED`, a query parameter `400` naming it, each asserting the envelope and that the list was never read; `501` when no rule service runs and `500` when the list cannot be read, neither with data. - `src/sharing-route-ledger.conformance.test.ts` (new): registrar enumeration against the ledger, both directions. ## Acceptance notes - **Deviation from the order: no widget hint on `sys_sharing_rule.object_name` in this PR.** The field already declares `widget: 'object-ref'`, which draws today's object picker. `FieldSchema.widget` is one free string and an unregistered value degrades to the text input, so naming a new widget here before the console registers it would turn the picker into a text box until the ui card lands. #31004's fields had no widget, so its hint degraded to what they already were; this field does not. The hint and the registration should land together, in the ui card (one line in `plugin-sharing`'s object file beside the `ui/` widget). Raised for the seat as an open question. - **Labels are the object's declared `label`**, not localized: the door withholds locale like its #31004 sibling. The console localizes object names by key as it does elsewhere. - The door is mounted unscoped only (`/api/v1/sharing/shareable-objects`), like the share-link and messaging doors on `IHttpServer`; the REST rule routes also have an environment-scoped twin, this one does not. - A public object with an owner column is listed: the save door admits it, because the reconcile still grants its external organization members (#25549). The list says what the save does, nothing narrower. ## Verification All readings on head ``59c76e787e` (`59c76e787e7b21dd493272fdd133042ed846a902`)` unless a line says otherwise. <details><summary>Package suite, typecheck, lint</summary> ``` pnpm --filter @objectstack/plugin-sharing test :: exit 0 (Test Files 70 passed, Tests 1464 passed) pnpm --filter @objectstack/plugin-sharing typecheck :: exit 0 (tsc, scripts tsc, check:test-typecheck OK, debt unchanged) pnpm --filter @objectstack/dogfood exec vitest run test/route-ledger-live-mount-parity.dogfood.test.ts :: exit 0 (8 passed; log: "shareable objects list mounted at GET /api/v1/sharing/shareable-objects") node node_modules/eslint/bin/eslint.js --no-inline-config <the 11 touched .ts/.mjs files> :: exit 0 ``` (The package has no `lint` script; the root eslint ran over the touched files.) </details> <details><summary>Negative-pin ablations (each through scripts/ablation-replace.mjs; anchor hit once; restored blob == HEAD, git diff HEAD empty)</summary> - Verdict filter deleted (`if (refusal(name) !== null) continue;`): 4 failed / 12 passed: the list case, the one-verdict case, the credential case and the door's administrator case. - Admission removed from `listShareableObjects` (`this.assertCanManageRules(context)` replaced by `void context`): 3 failed / 13 passed: member refused, organization-less refused, and the door's member `403`. (A first attempt replaced the call with text the anchor already contained; the tool refused it as a no-op (`replacement count moved 1 -> 1`) and restored; rerun with the replacement above.) - Anonymous branch replaced by `if (false as boolean) {`: 1 failed (`refuses an anonymous caller 401 UNAUTHENTICATED`). - Query refusal replaced by `if (false as boolean) {`: 1 failed (`refuses a query parameter 400 VALIDATION_ERROR naming it`). - Business-before-platform comparison deleted: 3 failed (the list order, the platform classification order, the door's list). </details> <details><summary>Derived gates</summary> ``` node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-adoption.mjs :: exit 0 node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-declaration-mirrors.mjs :: exit 0 node scripts/check-declaration-mirrors.mjs --self-test :: exit 0 node scripts/check-dts-emitted.mjs --self-test :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-issue-citations.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0 node scripts/check-self-test-wired.mjs :: exit 0 node scripts/check-self-test-wired.mjs --self-test :: exit 0 node scripts/check-self-test-workflow-commands.mjs :: exit 0 node scripts/check-self-test-workflow-commands.mjs --self-test :: exit 0 node scripts/check-tenant-audit-census.mjs :: exit 0 node scripts/check-tenant-audit-census.mjs --self-test :: exit 0 node scripts/check-whole-set-label-write.mjs :: exit 0 node scripts/check-whole-set-label-write.mjs --self-test :: exit 0 node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0 node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0 node scripts/ci/prune-turbo-cache.mjs --self-test :: exit 0 node scripts/ci/snapshot-next.mjs --self-test :: exit 0 node scripts/docs-audit/check-affected-docs.mjs :: exit 0 node scripts/docs-audit/check-drift-comment.mjs :: exit 0 node scripts/pm/bare-root-worklist.mjs --self-test :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm --filter @objectstack/spec run check:empty-state :: exit 0 pnpm --filter @objectstack/spec run check:liveness :: exit 0 pnpm --filter @objectstack/spec run check:strictness-ledger :: exit 0 pnpm --filter @objectstack/spec run check:variant-docs :: exit 0 pnpm check:agent-test-spelling :: exit 0 pnpm check:bash32-floor :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:ci-filter-parity :: exit 0 pnpm check:cli-command-ids :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:doc-authoring :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:dts-closure :: exit 0 pnpm check:dual-build-cjs-loads :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:entry-guard :: exit 0 pnpm check:error-status-conformance :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:i18n :: exit 0 pnpm check:i18n-stale-fill :: exit 0 pnpm check:issue-citations :: exit 0 pnpm check:keyed-text-bounds :: exit 0 pnpm check:lean-entry-closure :: exit 0 pnpm check:logger-receiver-detach :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:objectql-double-limit :: exit 0 pnpm check:org-identifier :: exit 0 pnpm check:page-declaration-shape :: exit 0 pnpm check:parse-guard :: exit 0 pnpm check:platform-object-tenancy-census :: exit 0 pnpm check:pm-dispatch-gates :: exit 0 pnpm check:pnpm-filter-targets :: exit 0 pnpm check:pnpm-flag-spellings :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:ratchet-remedy-authority :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:registry-log-declared :: exit 0 pnpm check:rest-log-spy-declared :: exit 0 pnpm check:route-envelope :: exit 0 pnpm check:scripts-symbol-anchors :: exit 0 pnpm check:slot-lookup :: exit 0 pnpm check:sourcemap-no-sources-content :: exit 0 pnpm check:system-context-census :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:turbo-task-graph :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:vouch-for-reference-callers :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 ``` Reconciliation: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran <the list above> :: exit 0` (89 derived, 89 run, 0 NOT-MEASURED, 0 UNRUN). Not derived, run as a check on the merged tree: `pnpm --filter @objectstack/spec run check:generated` exits 1 under the default 2 GB heap because `tsc -p tsconfig.test.json` aborts out of memory ("failed without diagnostics", exit 134), and exits 0 with `NODE_OPTIONS=--max-old-space-size=8192` (CI sets 4096). This diff touches no `packages/spec` file. </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_017ihL2f62c1GYk6XzbdtCa4 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 065bba7 commit 6f017e2

13 files changed

Lines changed: 950 additions & 4 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/plugin-sharing': patch
3+
---
4+
5+
feat(plugin-sharing): list the objects a sharing rule can take effect on
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: none
9+
10+
`SharingServicePlugin` mounts a read-only route, `GET /api/v1/sharing/shareable-objects`, for Setup's sharing-rule object picker. It answers `{ success: true, data: { objects } }`, each object `{ name, label, system }`, business objects first and then platform objects, each group by label.
11+
12+
An object is listed exactly when a sharing rule saved on it would take effect: the list asks the same check the save doors already ask (`SharingService.sharingRuleObjectRefusal`), so every object it leaves out is one a save refuses with `SHARING_NOT_ENABLED`. The platform's credential and session objects (sessions, linked accounts, API keys, verification tokens, two-factor rows, OAuth applications, tokens and consents, signing keys, secrets) are not listed.
13+
14+
Who may ask: the sharing-rule administrators, as `/api/v1/sharing/rules` admits them (`manage_sharing`, or the legacy `manage_platform_settings`, with an active organization). Anyone else is answered `403 PERMISSION_DENIED`, anonymous `401`. The route takes no query parameter and refuses one with `400 VALIDATION_ERROR`. With record-sharing enforcement off (`enforce: false`) it answers `501 NOT_IMPLEMENTED`.
15+
16+
`SharingRuleService` gains `listShareableObjects(context)`, the same list for in-process callers.
17+
18+
Nothing stored or authored changes; the `sys_sharing_rule` object field keeps its `object-ref` picker until the console's filtered picker lands.
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import {
4+
ANONYMOUS_DENY_CODE,
5+
ANONYMOUS_DENY_MESSAGE,
6+
ANONYMOUS_DENY_STATUS,
7+
evaluateAuthGate,
8+
isApiKeyLifecycleRefusalError,
9+
isAuthzStoreUnavailableError,
10+
resolveRequestExecutionContext,
11+
shouldDenyAnonymous,
12+
type RequestAuthService,
13+
type TenancyServiceResolver,
14+
} from '@objectstack/core';
15+
import type { ErrorCode } from '@objectstack/spec/api';
16+
import type { IHttpRequest, IHttpResponse, IHttpServer } from '@objectstack/spec/contracts';
17+
import type { ExecutionContext } from '@objectstack/spec/kernel';
18+
import { sendError, sendOk, sendServerFault } from '@objectstack/types';
19+
import { SharingRulesNotRunningError, type ShareableObject } from './shareable-objects.js';
20+
21+
/**
22+
* The shareable-objects door: `GET /api/v1/sharing/shareable-objects`. It
23+
* answers the objects a sharing rule can take effect on, for Setup's
24+
* sharing-rule object picker (`shareable-objects.ts` says which objects and in
25+
* what order). It reads and never writes.
26+
*
27+
* ## Why plugin-sharing mounts it
28+
*
29+
* The sharing-rule administration routes (`/api/v1/sharing/rules*`) are
30+
* `@objectstack/rest`'s, served over the `sharingRules` service through a spec
31+
* contract. A list there would need a new member on that contract; this door
32+
* is the plugin's own, read straight off the `SharingRuleService` the plugin
33+
* constructs, at a path no other package serves. It reaches for the
34+
* `http.server` service and registers on `IHttpServer`, as the share-link
35+
* family does; `sharing-route-ledger.ts` records it.
36+
*
37+
* ## Who may ask — the sharing-rule administrators
38+
*
39+
* In order, before anything is read:
40+
*
41+
* 1. **The caller**, resolved by the platform's one request-to-context
42+
* composition (`resolveRequestExecutionContext`) from the request's session
43+
* or API key. A permission store that cannot be read answers its own
44+
* `SERVICE_UNAVAILABLE` / `503`, and an API key its lifecycle refuses its
45+
* own `403` and code: both relayed as declared, never folded into a denial.
46+
* 2. **A held sign-in** (ADR-0069: an expired password, enforced MFA not yet
47+
* enrolled): `403` with the authentication policy's own code.
48+
* 3. **Anonymous** — the platform's one anonymous floor (ADR-0056 D2):
49+
* `UNAUTHENTICATED` / `401`.
50+
* 4. **The request**: the route takes no query parameter, and a request that
51+
* carries one is refused `VALIDATION_ERROR` / `400` naming it (AGENTS.md,
52+
* route rule 5: the closed set here is empty), never ignored.
53+
* 5. **The administrator**: the sharing-rule surface's own admission
54+
* (`SharingRuleService.listShareableObjects` asks the check every verb of
55+
* that surface asks, ADR-0111 D6). A caller it refuses is answered
56+
* `PERMISSION_DENIED` / `403` with its sentence, so the list names nothing
57+
* the caller could not author a rule on.
58+
*
59+
* ## What the door answers
60+
*
61+
* - **Success:** `200` with `{ objects }` (`ShareableObject[]`), business
62+
* objects first.
63+
* - **Sharing rules not running on this kernel** (`enforce: false`, or the
64+
* rule subsystem did not start): `501` / `NOT_IMPLEMENTED`, the answer the
65+
* REST sharing-rule routes give for the same state.
66+
* - **An engine that cannot enumerate its objects** is a fault: `500` /
67+
* `INTERNAL_ERROR` with this door's sentence and the error in the fault log.
68+
* An empty list would read as "no object can take a rule", so it is never
69+
* served in place of the answer.
70+
*/
71+
72+
/** The route the door is mounted at. */
73+
export const SHAREABLE_OBJECTS_ROUTE = '/api/v1/sharing/shareable-objects';
74+
75+
/** What the door is composed over — every service read when a request arrives. */
76+
export interface ShareableObjectsRoutesDeps {
77+
/** The data engine the caller's grants are read through; `undefined` when none is registered. */
78+
getEngine(): unknown;
79+
/** The auth service the caller's session is read through; `undefined` when none is registered. */
80+
getAuth(): RequestAuthService | undefined;
81+
/** How this door reaches the `tenancy` service; `undefined` on a host with no async service accessor. */
82+
tenancy: TenancyServiceResolver | undefined;
83+
/**
84+
* The list, admitted for `context` — `SharingRuleService.listShareableObjects`.
85+
* Throws `PERMISSION_DENIED: …` for a caller the sharing-rule surface refuses,
86+
* and {@link SharingRulesNotRunningError} when no rule service runs.
87+
*/
88+
listShareableObjects(context: ExecutionContext): ShareableObject[];
89+
}
90+
91+
/** Mount the door on `http`. Nothing on `deps` is called until a request arrives. */
92+
export function registerShareableObjectsRoutes(http: IHttpServer, deps: ShareableObjectsRoutesDeps): void {
93+
http.get(SHAREABLE_OBJECTS_ROUTE, (req, res) => handleShareableObjects(req, res, deps));
94+
}
95+
96+
/** The sharing-rule surface's refusal idiom (`PERMISSION_DENIED: <sentence>`), read once. */
97+
const PERMISSION_DENIED_PREFIX = /^PERMISSION_DENIED:\s*/;
98+
99+
async function handleShareableObjects(
100+
req: IHttpRequest,
101+
res: IHttpResponse,
102+
deps: ShareableObjectsRoutesDeps,
103+
): Promise<void> {
104+
// ① The caller.
105+
let context: ExecutionContext | undefined;
106+
try {
107+
context = await resolveCaller(req, deps);
108+
} catch (err) {
109+
if (isAuthzStoreUnavailableError(err) || isApiKeyLifecycleRefusalError(err)) {
110+
sendError(res, err.status, err.code, err.message);
111+
return;
112+
}
113+
sendServerFault(res, 500, 'INTERNAL_ERROR', 'The caller of this request could not be resolved.', err, {
114+
method: 'GET',
115+
path: SHAREABLE_OBJECTS_ROUTE,
116+
});
117+
return;
118+
}
119+
120+
// ② A held sign-in.
121+
const gate = evaluateAuthGate(context, '');
122+
if (gate) {
123+
sendError(res, 403, gate.code as ErrorCode, gate.message);
124+
return;
125+
}
126+
127+
// ③ Anonymous.
128+
if (!context || shouldDenyAnonymous({ userId: context.userId, method: req.method })) {
129+
sendError(res, ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE);
130+
return;
131+
}
132+
133+
// ④ The request: the closed query set is empty.
134+
const query = Object.keys(req.query ?? {});
135+
if (query.length > 0) {
136+
sendError(
137+
res,
138+
400,
139+
'VALIDATION_ERROR',
140+
`The shareable objects list takes no query parameter, and this request carries ${query.map((q) => `'${q}'`).join(', ')}. ` +
141+
'Ask for the list with no parameters: it always answers every object a sharing rule can take effect on.',
142+
);
143+
return;
144+
}
145+
146+
// ⑤ The administrator, then the list.
147+
let objects: ShareableObject[];
148+
try {
149+
objects = deps.listShareableObjects(context);
150+
} catch (err) {
151+
const message = err instanceof Error ? err.message : String(err);
152+
if (err instanceof SharingRulesNotRunningError) {
153+
sendError(res, 501, 'NOT_IMPLEMENTED', message);
154+
return;
155+
}
156+
if (PERMISSION_DENIED_PREFIX.test(message)) {
157+
sendError(res, 403, 'PERMISSION_DENIED', message.replace(PERMISSION_DENIED_PREFIX, ''));
158+
return;
159+
}
160+
sendServerFault(res, 500, 'INTERNAL_ERROR', 'The objects a sharing rule can take effect on could not be listed.', err, {
161+
method: 'GET',
162+
path: SHAREABLE_OBJECTS_ROUTE,
163+
});
164+
return;
165+
}
166+
sendOk(res, { objects });
167+
}
168+
169+
/**
170+
* Resolve the caller through the one composition. This door's differences, as
171+
* its named parameters: anonymous fails closed (the door answers its floor);
172+
* the held-sign-in posture is the one the resolver decided for the credential
173+
* that admitted the caller; an API key admits its owner with no session read;
174+
* no locale, bearer or OAuth token is carried — the list is the same in every
175+
* language, and nothing downstream acts as the caller.
176+
*/
177+
async function resolveCaller(req: IHttpRequest, deps: ShareableObjectsRoutesDeps): Promise<ExecutionContext | undefined> {
178+
const headers = new Headers();
179+
for (const [name, value] of Object.entries(req.headers ?? {})) {
180+
if (value == null) continue;
181+
headers.set(name, Array.isArray(value) ? value.join(',') : String(value));
182+
}
183+
return await resolveRequestExecutionContext({
184+
headers,
185+
ql: deps.getEngine(),
186+
authService: deps.getAuth(),
187+
tenancy: deps.tenancy,
188+
anonymous: 'fail-closed',
189+
authGate: 'resolved',
190+
credentials: 'api-key-and-session',
191+
sessionApi: 'optional',
192+
session: 'request',
193+
localization: 'withhold',
194+
requestLocale: undefined,
195+
accessToken: 'withhold',
196+
oauth: undefined,
197+
onAdmission: undefined,
198+
});
199+
}

0 commit comments

Comments
 (0)