diff --git a/.changeset/30209-sharing-shareable-objects.md b/.changeset/30209-sharing-shareable-objects.md new file mode 100644 index 00000000000..71acdd873e5 --- /dev/null +++ b/.changeset/30209-sharing-shareable-objects.md @@ -0,0 +1,18 @@ +--- +'@objectstack/plugin-sharing': patch +--- + +feat(plugin-sharing): list the objects a sharing rule can take effect on + +Clause-②: yes (widening) +Metadata-protocol: none + +`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. + +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. + +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`. + +`SharingRuleService` gains `listShareableObjects(context)`, the same list for in-process callers. + +Nothing stored or authored changes; the `sys_sharing_rule` object field keeps its `object-ref` picker until the console's filtered picker lands. diff --git a/packages/plugins/plugin-sharing/src/shareable-objects-routes.ts b/packages/plugins/plugin-sharing/src/shareable-objects-routes.ts new file mode 100644 index 00000000000..c1c32db6c9f --- /dev/null +++ b/packages/plugins/plugin-sharing/src/shareable-objects-routes.ts @@ -0,0 +1,199 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { + ANONYMOUS_DENY_CODE, + ANONYMOUS_DENY_MESSAGE, + ANONYMOUS_DENY_STATUS, + evaluateAuthGate, + isApiKeyLifecycleRefusalError, + isAuthzStoreUnavailableError, + resolveRequestExecutionContext, + shouldDenyAnonymous, + type RequestAuthService, + type TenancyServiceResolver, +} from '@objectstack/core'; +import type { ErrorCode } from '@objectstack/spec/api'; +import type { IHttpRequest, IHttpResponse, IHttpServer } from '@objectstack/spec/contracts'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { sendError, sendOk, sendServerFault } from '@objectstack/types'; +import { SharingRulesNotRunningError, type ShareableObject } from './shareable-objects.js'; + +/** + * The shareable-objects door: `GET /api/v1/sharing/shareable-objects`. It + * answers the objects a sharing rule can take effect on, for Setup's + * sharing-rule object picker (`shareable-objects.ts` says which objects and in + * what order). It reads and never writes. + * + * ## Why plugin-sharing mounts it + * + * The sharing-rule administration routes (`/api/v1/sharing/rules*`) are + * `@objectstack/rest`'s, served over the `sharingRules` service through a spec + * contract. A list there would need a new member on that contract; this door + * is the plugin's own, read straight off the `SharingRuleService` the plugin + * constructs, at a path no other package serves. It reaches for the + * `http.server` service and registers on `IHttpServer`, as the share-link + * family does; `sharing-route-ledger.ts` records it. + * + * ## Who may ask — the sharing-rule administrators + * + * In order, before anything is read: + * + * 1. **The caller**, resolved by the platform's one request-to-context + * composition (`resolveRequestExecutionContext`) from the request's session + * or API key. A permission store that cannot be read answers its own + * `SERVICE_UNAVAILABLE` / `503`, and an API key its lifecycle refuses its + * own `403` and code: both relayed as declared, never folded into a denial. + * 2. **A held sign-in** (ADR-0069: an expired password, enforced MFA not yet + * enrolled): `403` with the authentication policy's own code. + * 3. **Anonymous** — the platform's one anonymous floor (ADR-0056 D2): + * `UNAUTHENTICATED` / `401`. + * 4. **The request**: the route takes no query parameter, and a request that + * carries one is refused `VALIDATION_ERROR` / `400` naming it (AGENTS.md, + * route rule 5: the closed set here is empty), never ignored. + * 5. **The administrator**: the sharing-rule surface's own admission + * (`SharingRuleService.listShareableObjects` asks the check every verb of + * that surface asks, ADR-0111 D6). A caller it refuses is answered + * `PERMISSION_DENIED` / `403` with its sentence, so the list names nothing + * the caller could not author a rule on. + * + * ## What the door answers + * + * - **Success:** `200` with `{ objects }` (`ShareableObject[]`), business + * objects first. + * - **Sharing rules not running on this kernel** (`enforce: false`, or the + * rule subsystem did not start): `501` / `NOT_IMPLEMENTED`, the answer the + * REST sharing-rule routes give for the same state. + * - **An engine that cannot enumerate its objects** is a fault: `500` / + * `INTERNAL_ERROR` with this door's sentence and the error in the fault log. + * An empty list would read as "no object can take a rule", so it is never + * served in place of the answer. + */ + +/** The route the door is mounted at. */ +export const SHAREABLE_OBJECTS_ROUTE = '/api/v1/sharing/shareable-objects'; + +/** What the door is composed over — every service read when a request arrives. */ +export interface ShareableObjectsRoutesDeps { + /** The data engine the caller's grants are read through; `undefined` when none is registered. */ + getEngine(): unknown; + /** The auth service the caller's session is read through; `undefined` when none is registered. */ + getAuth(): RequestAuthService | undefined; + /** How this door reaches the `tenancy` service; `undefined` on a host with no async service accessor. */ + tenancy: TenancyServiceResolver | undefined; + /** + * The list, admitted for `context` — `SharingRuleService.listShareableObjects`. + * Throws `PERMISSION_DENIED: …` for a caller the sharing-rule surface refuses, + * and {@link SharingRulesNotRunningError} when no rule service runs. + */ + listShareableObjects(context: ExecutionContext): ShareableObject[]; +} + +/** Mount the door on `http`. Nothing on `deps` is called until a request arrives. */ +export function registerShareableObjectsRoutes(http: IHttpServer, deps: ShareableObjectsRoutesDeps): void { + http.get(SHAREABLE_OBJECTS_ROUTE, (req, res) => handleShareableObjects(req, res, deps)); +} + +/** The sharing-rule surface's refusal idiom (`PERMISSION_DENIED: `), read once. */ +const PERMISSION_DENIED_PREFIX = /^PERMISSION_DENIED:\s*/; + +async function handleShareableObjects( + req: IHttpRequest, + res: IHttpResponse, + deps: ShareableObjectsRoutesDeps, +): Promise { + // ① The caller. + let context: ExecutionContext | undefined; + try { + context = await resolveCaller(req, deps); + } catch (err) { + if (isAuthzStoreUnavailableError(err) || isApiKeyLifecycleRefusalError(err)) { + sendError(res, err.status, err.code, err.message); + return; + } + sendServerFault(res, 500, 'INTERNAL_ERROR', 'The caller of this request could not be resolved.', err, { + method: 'GET', + path: SHAREABLE_OBJECTS_ROUTE, + }); + return; + } + + // ② A held sign-in. + const gate = evaluateAuthGate(context, ''); + if (gate) { + sendError(res, 403, gate.code as ErrorCode, gate.message); + return; + } + + // ③ Anonymous. + if (!context || shouldDenyAnonymous({ userId: context.userId, method: req.method })) { + sendError(res, ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE); + return; + } + + // ④ The request: the closed query set is empty. + const query = Object.keys(req.query ?? {}); + if (query.length > 0) { + sendError( + res, + 400, + 'VALIDATION_ERROR', + `The shareable objects list takes no query parameter, and this request carries ${query.map((q) => `'${q}'`).join(', ')}. ` + + 'Ask for the list with no parameters: it always answers every object a sharing rule can take effect on.', + ); + return; + } + + // ⑤ The administrator, then the list. + let objects: ShareableObject[]; + try { + objects = deps.listShareableObjects(context); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (err instanceof SharingRulesNotRunningError) { + sendError(res, 501, 'NOT_IMPLEMENTED', message); + return; + } + if (PERMISSION_DENIED_PREFIX.test(message)) { + sendError(res, 403, 'PERMISSION_DENIED', message.replace(PERMISSION_DENIED_PREFIX, '')); + return; + } + sendServerFault(res, 500, 'INTERNAL_ERROR', 'The objects a sharing rule can take effect on could not be listed.', err, { + method: 'GET', + path: SHAREABLE_OBJECTS_ROUTE, + }); + return; + } + sendOk(res, { objects }); +} + +/** + * Resolve the caller through the one composition. This door's differences, as + * its named parameters: anonymous fails closed (the door answers its floor); + * the held-sign-in posture is the one the resolver decided for the credential + * that admitted the caller; an API key admits its owner with no session read; + * no locale, bearer or OAuth token is carried — the list is the same in every + * language, and nothing downstream acts as the caller. + */ +async function resolveCaller(req: IHttpRequest, deps: ShareableObjectsRoutesDeps): Promise { + const headers = new Headers(); + for (const [name, value] of Object.entries(req.headers ?? {})) { + if (value == null) continue; + headers.set(name, Array.isArray(value) ? value.join(',') : String(value)); + } + return await resolveRequestExecutionContext({ + headers, + ql: deps.getEngine(), + authService: deps.getAuth(), + tenancy: deps.tenancy, + anonymous: 'fail-closed', + authGate: 'resolved', + credentials: 'api-key-and-session', + sessionApi: 'optional', + session: 'request', + localization: 'withhold', + requestLocale: undefined, + accessToken: 'withhold', + oauth: undefined, + onAdmission: undefined, + }); +} diff --git a/packages/plugins/plugin-sharing/src/shareable-objects.test.ts b/packages/plugins/plugin-sharing/src/shareable-objects.test.ts new file mode 100644 index 00000000000..96fc17ab0f6 --- /dev/null +++ b/packages/plugins/plugin-sharing/src/shareable-objects.test.ts @@ -0,0 +1,410 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#30209 row 5 B] The objects a sharing rule can take effect on, and the door + * that serves them, `GET /api/v1/sharing/shareable-objects`. + * + * Real `ObjectQL` + `SqlDriver` (better-sqlite3 `:memory:`) holding the object + * shapes the save-time refusal distinguishes, with the real `SharingService` + * and `SharingRuleService`, and the save doors' guard bound — so "listed" is + * compared with what a save actually does, not with a restatement of it. + * + * - the list holds exactly the objects the save doors admit a rule on; + * every object it leaves out is one `defineRule` refuses with + * `SHARING_NOT_ENABLED` (the one-verdict pin); + * - business objects come before platform objects, each group by label; + * - the platform's credential and session objects (as their packages declare + * them) are not listed, and a rule on any of them is refused at save; + * - the sharing-rule surface's admission decides who may ask; + * - the door: anonymous, a held sign-in and a query parameter are refused by + * envelope before the list is read; a member without the admin surface is + * refused `PERMISSION_DENIED` through the real resolver and the real + * service; an administrator resolved the same way is served the list. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { bindSecurityCatalogReader, type SecurityCatalogReader } from '@objectstack/core'; +import { SysSecret } from '@objectstack/platform-objects'; +import { + SysAccount, SysApiKey, SysJwks, SysOauthAccessToken, SysOauthApplication, SysOauthConsent, SysSession, + SysTwoFactor, SysVerification, +} from '@objectstack/platform-objects/identity'; +import { ApiErrorSchema, envelopeViolations } from '@objectstack/spec/api'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; + +import { SharingService } from './sharing-service.js'; +import { SharingRuleService } from './sharing-rule-service.js'; +import { bindRuleObjectEnforcementGuard } from './rule-hooks.js'; +import { listShareableObjects, ShareableObjectsUnavailableError, SharingRulesNotRunningError } from './shareable-objects.js'; +import { SHAREABLE_OBJECTS_ROUTE, registerShareableObjectsRoutes } from './shareable-objects-routes.js'; + +const ORG = 'org_30209'; + +const ADMIN = { + userId: 'usr_admin', organizationId: ORG, tenantId: ORG, + positions: [], permissions: [], systemPermissions: ['manage_sharing'], +} as unknown as ExecutionContext; +const LEGACY_ADMIN = { ...ADMIN, systemPermissions: ['manage_platform_settings'] } as unknown as ExecutionContext; +const MEMBER = { ...ADMIN, userId: 'usr_member', systemPermissions: [] } as unknown as ExecutionContext; +/** Holds the org-scoped capability but resolved no organization (ADR-0111 D6 / #8158). */ +const ORGLESS_ADMIN = { + userId: 'usr_orgless', positions: [], permissions: [], systemPermissions: ['manage_sharing'], +} as unknown as ExecutionContext; + +const text = (name: string) => ({ type: 'text', name, label: name }); +const silent: any = { debug() {}, info() {}, warn() {}, error() {}, fatal() {}, child() { return silent; } }; + +/** Every object the boot registers, by the verdict the save doors give a rule on it. */ +const ADMITTED = { + /** private model + owner: the ordinary enforced business object. */ + deal: 'os30209_deal', + /** a second business object, to pin the label order (label "Account" < "Deal"). */ + account: 'os30209_account', + /** public model WITH an owner column: admitted (its external members are still granted). */ + notice: 'os30209_notice', + /** a platform object under enforcement: listed, after the business objects. */ + sysCase: 'sys_os30209_case', +} as const; +const REFUSED = { + /** the `sys_session` shape: no owner, no model. */ + session: 'os30209_session', + /** a platform object that bypasses record sharing (plugin default bypass list). */ + sysUser: 'sys_user', + /** a detail under its master. */ + line: 'os30209_line', + /** the sharing-rule store itself: `isSystem` with no model (public) and no owner. */ + rule: 'sys_sharing_rule', +} as const; + +const RULE_FIELDS = Object.fromEntries( + ['organization_id', 'name', 'label', 'description', 'object_name', 'criteria_json', 'recipient_type', + 'recipient_id', 'access_level', 'managed_by', 'created_at', 'updated_at'].map((f) => [f, text(f)]), +); + +const open: SqlDriver[] = []; +afterEach(async () => { + while (open.length) await open.pop()!.disconnect().catch(() => undefined); + vi.restoreAllMocks(); +}); + +async function boot() { + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + open.push(driver); + const ql = new ObjectQL({ logger: silent }); + ql.registerDriver(driver as never, true); + await ql.init(); + ql.setTenancyPostureProvider(() => 'single'); + const recordFields = { id: { ...text('id'), primary: true }, stage: text('stage'), organization_id: text('organization_id') }; + const owned = { ...recordFields, owner_id: text('owner_id') }; + ql.registerObject({ name: ADMITTED.deal, label: 'Deal', sharingModel: 'private', fields: owned } as never); + ql.registerObject({ name: ADMITTED.account, label: 'Account', sharingModel: 'read', fields: owned } as never); + ql.registerObject({ name: ADMITTED.notice, label: 'Notice', sharingModel: 'public_read_write', fields: owned } as never); + ql.registerObject({ name: ADMITTED.sysCase, label: 'Case', isSystem: true, sharingModel: 'private', fields: owned } as never); + ql.registerObject({ name: REFUSED.session, label: 'Session', ownership: 'none', fields: recordFields } as never); + ql.registerObject({ name: REFUSED.sysUser, label: 'User', isSystem: true, sharingModel: 'private', fields: owned } as never); + ql.registerObject({ + name: REFUSED.line, label: 'Line', sharingModel: 'controlled_by_parent', fields: owned, + } as never); + ql.registerObject({ + name: REFUSED.rule, label: 'Sharing Rule', isSystem: true, + fields: { id: { ...text('id'), primary: true }, ...RULE_FIELDS, active: { type: 'boolean', name: 'active', label: 'Active' } }, + } as never); + await driver.initObjects([REFUSED.rule].map((name) => ({ name, fields: (ql as any).getSchema(name).fields }) as never)); + const sharing = new SharingService({ engine: ql as never }); + const rules = new SharingRuleService({ engine: ql as never, sharing }); + bindRuleObjectEnforcementGuard(ql as never, sharing); + return { ql, rules, sharing }; +} + +const names = (list: Array<{ name: string }>) => list.map((o) => o.name); + +describe('[#30209] the objects a sharing rule can take effect on — the list', () => { + it('lists the admitted objects, business first and each group by label, and none of the refused ones', async () => { + const { rules } = await boot(); + const listed = rules.listShareableObjects(ADMIN); + expect(listed).toEqual([ + { name: ADMITTED.account, label: 'Account', system: false }, + { name: ADMITTED.deal, label: 'Deal', system: false }, + { name: ADMITTED.notice, label: 'Notice', system: false }, + { name: ADMITTED.sysCase, label: 'Case', system: true }, + ]); + for (const refused of Object.values(REFUSED)) expect(names(listed)).not.toContain(refused); + }); + + it('one verdict: every object left out is one defineRule refuses with SHARING_NOT_ENABLED, every listed one it does not', async () => { + const { ql, rules } = await boot(); + const listed = new Set(names(rules.listShareableObjects(ADMIN))); + const registered = Object.keys((ql as any).getConfigs()); + expect(registered.length).toBeGreaterThanOrEqual(8); + for (const object of registered) { + const err = await rules.defineRule({ + name: `r_${object}`, label: object, object, recipientType: 'position', recipientId: 'sales_rep', + criteria: { stage: 'won' }, + } as never, ADMIN).then(() => undefined, (x: unknown) => x as Error & { code?: string; status?: number }); + const refusedForObject = err?.code === 'SHARING_NOT_ENABLED' && err?.status === 422; + expect(refusedForObject, `${object}: listed=${listed.has(object)} defineRule=${err?.message ?? 'saved'}`) + .toBe(!listed.has(object)); + } + }); + + it('a platform object is one the registry marks isSystem or one named sys_', () => { + const configs = { + zeta: { name: 'zeta', label: 'Zeta' }, + sys_alpha: { name: 'sys_alpha', label: 'Alpha' }, + flagged: { name: 'flagged', label: 'Beta', isSystem: true }, + bare: { name: 'bare' }, + }; + expect(listShareableObjects({ getConfigs: () => configs }, () => null)).toEqual([ + { name: 'bare', label: 'bare', system: false }, + { name: 'zeta', label: 'Zeta', system: false }, + { name: 'sys_alpha', label: 'Alpha', system: true }, + { name: 'flagged', label: 'Beta', system: true }, + ]); + }); + + it('an engine that cannot enumerate its objects is told so, never answered with an empty list', () => { + expect(() => listShareableObjects({}, () => null)).toThrow(ShareableObjectsUnavailableError); + }); +}); + +describe('[#30209] objects holding credentials and sessions are not listed, and a rule on them is refused', () => { + /** The platform's credential and session objects, as their packages declare them. */ + const CREDENTIAL_OBJECTS = [ + SysSession, SysAccount, SysVerification, SysApiKey, SysTwoFactor, + SysOauthApplication, SysOauthAccessToken, SysOauthConsent, SysJwks, SysSecret, + ]; + + it('none of them is listed, and defineRule refuses each with SHARING_NOT_ENABLED 422', async () => { + const { ql, rules } = await boot(); + for (const def of CREDENTIAL_OBJECTS) ql.registerObject(def as never); + const credentialNames = CREDENTIAL_OBJECTS.map((d) => (d as { name: string }).name); + expect(credentialNames).toEqual(expect.arrayContaining(['sys_session', 'sys_account', 'sys_api_key', 'sys_secret'])); + for (const name of credentialNames) expect((ql as any).getSchema(name), `${name} registered`).toBeTruthy(); + + const listed = names(rules.listShareableObjects(ADMIN)); + expect(listed.filter((n) => credentialNames.includes(n))).toEqual([]); + // CONTROL: the same list still serves the enforced objects beside them. + expect(listed).toContain(ADMITTED.deal); + + for (const object of credentialNames) { + const err = await rules.defineRule({ + name: `r_${object}`, label: object, object, recipientType: 'position', recipientId: 'sales_rep', + criteria: { id: 'x' }, + } as never, ADMIN).then(() => undefined, (x: unknown) => x as Error & { code?: string; status?: number }); + expect([err?.code, err?.status], `${object}: ${err?.message ?? 'saved'}`).toEqual(['SHARING_NOT_ENABLED', 422]); + } + }); +}); + +describe('[#30209] the list is admitted as the sharing-rule surface admits', () => { + it('a member without manage_sharing is refused PERMISSION_DENIED', async () => { + const { rules } = await boot(); + expect(() => rules.listShareableObjects(MEMBER)).toThrow(/^PERMISSION_DENIED: .*manage_sharing/); + }); + + it('a manage_sharing holder with no organization resolved is refused PERMISSION_DENIED', async () => { + const { rules } = await boot(); + expect(() => rules.listShareableObjects(ORGLESS_ADMIN)).toThrow(/^PERMISSION_DENIED: .*active organization/); + }); + + it('CONTROL: the legacy manage_platform_settings gate is admitted, as on every verb of the surface', async () => { + const { rules } = await boot(); + expect(names(rules.listShareableObjects(LEGACY_ADMIN))).toContain(ADMITTED.deal); + }); +}); + +// ── the door ──────────────────────────────────────────────────────────────── + +/** The sessions the fake auth service knows, by bearer token. */ +const SESSIONS: Record = { + admin: { userId: 'usr_admin' }, + member: { userId: 'usr_member' }, + held: { userId: 'usr_admin', authGate: { code: 'PASSWORD_EXPIRED', message: 'Your password has expired.' } }, +}; + +/** The one permission set this rig's catalog serves: it confers the sharing-rule admin capability. */ +const SHARING_ADMIN_SET = 'os30209_sharing_admin'; + +function catalogServing(): SecurityCatalogReader { + const entry = { type: 'permission', name: SHARING_ADMIN_SET, definition: { name: SHARING_ADMIN_SET, systemPermissions: ['manage_sharing'] }, source: 'registry' }; + return { + async resolve(type: string, name: string) { + return type === 'permission' && name === SHARING_ADMIN_SET ? entry : undefined; + }, + async list(type: string) { + return type === 'permission' ? [entry] : []; + }, + } as unknown as SecurityCatalogReader; +} + +/** + * The grant store the platform resolver reads the caller's grants from: the + * administrator holds {@link SHARING_ADMIN_SET} in {@link ORG}, the member + * holds nothing; both are members of {@link ORG}. + */ +function grantStore() { + const rows: Record>> = { + sys_user_permission_set: [{ id: 'ups_admin', user_id: 'usr_admin', permission_set: SHARING_ADMIN_SET, organization_id: ORG }], + sys_member: [ + { id: 'm_admin', user_id: 'usr_admin', organization_id: ORG, role: 'member' }, + { id: 'm_member', user_id: 'usr_member', organization_id: ORG, role: 'member' }, + ], + }; + const matches = (row: Record, where: unknown): boolean => { + if (!where || typeof where !== 'object') return true; + return Object.entries(where as Record).every(([k, v]) => { + if (k === '$and' && Array.isArray(v)) return v.every((w) => matches(row, w)); + if (k === '$or' && Array.isArray(v)) return v.some((w) => matches(row, w)); + if (v && typeof v === 'object' && !Array.isArray(v)) { + const op = v as Record; + if ('$in' in op && Array.isArray(op.$in)) return op.$in.includes(row[k]); + if ('$eq' in op) return row[k] === op.$eq; + return true; + } + return row[k] === v; + }); + }; + const store = { + find: async (object: string, opts?: { where?: unknown; filter?: unknown; limit?: number }) => { + const matched = (rows[object] ?? []).filter((r) => matches(r, opts?.where ?? opts?.filter)); + return typeof opts?.limit === 'number' ? matched.slice(0, opts.limit) : matched; + }, + findOne: async (object: string, opts?: { where?: unknown; filter?: unknown }) => + (rows[object] ?? []).find((r) => matches(r, opts?.where ?? opts?.filter)) ?? null, + }; + bindSecurityCatalogReader(store, catalogServing()); + return store; +} + +function door(list: (context: ExecutionContext) => ReturnType) { + const handlers = new Map Promise>(); + const http = { + get: vi.fn((path: string, handler: (req: unknown, res: unknown) => Promise) => handlers.set(`GET ${path}`, handler)), + post: vi.fn(), put: vi.fn(), patch: vi.fn(), delete: vi.fn(), use: vi.fn(), + }; + const auth = { + api: { + getSession: vi.fn(async (input: { headers: Headers }) => { + const bearer = input.headers.get('authorization')?.replace(/^Bearer /, ''); + const s = bearer ? SESSIONS[bearer] : undefined; + if (!s) return null; + return { + user: { id: s.userId, email: `${s.userId}@example.test`, ...(s.authGate ? { authGate: s.authGate } : {}) }, + session: { id: `ses_${bearer}`, userId: s.userId, activeOrganizationId: ORG }, + }; + }), + }, + }; + const listSpy = vi.fn(list); + const store = grantStore(); + registerShareableObjectsRoutes(http as never, { + getEngine: () => store, + getAuth: () => auth as never, + tenancy: undefined, + listShareableObjects: listSpy, + }); + + async function call(opts: { bearer?: string; query?: Record } = {}) { + const handler = handlers.get(`GET ${SHAREABLE_OBJECTS_ROUTE}`); + if (!handler) throw new Error('the door mounted no GET handler at its route'); + const answer: { status: number; body: any } = { status: 0, body: undefined }; + const res = { + status: (code: number) => { answer.status = code; return res; }, + json: (body: unknown) => { answer.body = body; }, + header: () => res, + send: () => undefined, + }; + await handler( + { + params: {}, + query: opts.query ?? {}, + headers: opts.bearer ? { authorization: `Bearer ${opts.bearer}` } : {}, + method: 'GET', + path: SHAREABLE_OBJECTS_ROUTE, + }, + res, + ); + expect(envelopeViolations(answer.body), JSON.stringify(answer.body)).toEqual([]); + return answer; + } + + return { call, listSpy }; +} + +/** The refusal envelope's `code` and `status`, after checking the error parses as the declared `ApiError`. */ +function envelope(answer: { status: number; body: any }): [unknown, number] { + expect(answer.body?.success).toBe(false); + expect(ApiErrorSchema.safeParse(answer.body.error).success, JSON.stringify(answer.body.error)).toBe(true); + return [answer.body.error.code, answer.status]; +} + +describe('[#30209] the shareable-objects door — mounted', () => { + it('registers exactly one route: GET /api/v1/sharing/shareable-objects', () => { + const http = { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn(), delete: vi.fn(), use: vi.fn() }; + registerShareableObjectsRoutes(http as never, {} as never); + expect(http.get.mock.calls.map((c) => c[0])).toEqual(['/api/v1/sharing/shareable-objects']); + for (const verb of ['post', 'put', 'patch', 'delete', 'use'] as const) expect(http[verb]).not.toHaveBeenCalled(); + }); +}); + +describe('[#30209] the shareable-objects door — answers', () => { + it('serves an administrator the list, through the platform resolver and the real service', async () => { + const { rules } = await boot(); + const d = door((context) => rules.listShareableObjects(context)); + const answer = await d.call({ bearer: 'admin' }); + expect(answer.status, JSON.stringify(answer.body)).toBe(200); + expect(answer.body.success).toBe(true); + expect(names(answer.body.data.objects)).toEqual([ADMITTED.account, ADMITTED.deal, ADMITTED.notice, ADMITTED.sysCase]); + // The context the service received is the resolved administrator, organization included. + const [context] = d.listSpy.mock.calls[0]!; + expect(context.userId).toBe('usr_admin'); + expect(context.tenantId).toBe(ORG); + expect(context.systemPermissions).toContain('manage_sharing'); + }); + + it('refuses a member without the sharing-rule admin surface 403 PERMISSION_DENIED, and lists nothing', async () => { + const { rules } = await boot(); + const d = door((context) => rules.listShareableObjects(context)); + const answer = await d.call({ bearer: 'member' }); + expect(envelope(answer)).toEqual(['PERMISSION_DENIED', 403]); + expect(answer.body.error.message).toMatch(/manage_sharing/); + expect(answer.body.data).toBeUndefined(); + }); + + it('refuses an anonymous caller 401 UNAUTHENTICATED before the list is read', async () => { + const d = door(() => { throw new Error('must not be read'); }); + expect(envelope(await d.call())).toEqual(['UNAUTHENTICATED', 401]); + expect(d.listSpy).not.toHaveBeenCalled(); + }); + + it('refuses a held sign-in 403 with the policy code before the list is read', async () => { + const d = door(() => { throw new Error('must not be read'); }); + expect(envelope(await d.call({ bearer: 'held' }))).toEqual(['PASSWORD_EXPIRED', 403]); + expect(d.listSpy).not.toHaveBeenCalled(); + }); + + it('refuses a query parameter 400 VALIDATION_ERROR naming it, never ignoring it', async () => { + const d = door(() => { throw new Error('must not be read'); }); + const answer = await d.call({ bearer: 'admin', query: { object: 'os30209_deal' } }); + expect(envelope(answer)).toEqual(['VALIDATION_ERROR', 400]); + expect(answer.body.error.message).toContain("'object'"); + expect(d.listSpy).not.toHaveBeenCalled(); + }); + + it('answers 501 NOT_IMPLEMENTED when no sharing-rule service runs, never an empty list', async () => { + const d = door(() => { throw new SharingRulesNotRunningError(); }); + const answer = await d.call({ bearer: 'admin' }); + expect(envelope(answer)).toEqual(['NOT_IMPLEMENTED', 501]); + expect(answer.body.data).toBeUndefined(); + }); + + it('answers a list that cannot be read 500 INTERNAL_ERROR, never an empty list', async () => { + vi.spyOn(console, 'error').mockImplementation(() => undefined); + const d = door(() => { throw new ShareableObjectsUnavailableError(); }); + const answer = await d.call({ bearer: 'admin' }); + expect(envelope(answer)).toEqual(['INTERNAL_ERROR', 500]); + expect(answer.body.data).toBeUndefined(); + }); +}); diff --git a/packages/plugins/plugin-sharing/src/shareable-objects.ts b/packages/plugins/plugin-sharing/src/shareable-objects.ts new file mode 100644 index 00000000000..33c1f051f7d --- /dev/null +++ b/packages/plugins/plugin-sharing/src/shareable-objects.ts @@ -0,0 +1,112 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The objects a sharing rule can take effect on — the list behind Setup's + * sharing-rule object picker (the read half of the row-5 ruling on #30209: + * a read-only "objects under record sharing" list, and a picker that offers + * only those). + * + * ## One verdict, never a second copy + * + * An object is listed exactly when the save doors would ACCEPT a rule on it: + * `SharingService.sharingRuleObjectRefusal(object)` answers `null`. That is the + * predicate both save doors already ask (the `sys_sharing_rule` hook guard on + * the `/data` door, and `SharingRuleService.defineRule`), and it reads the + * reconcile's own ADR-0111 D7 inert-grant verdict. So the picker, the save + * doors and the reconcile cannot disagree about which objects a rule works on: + * an object this list leaves out is one a save would refuse with + * `SHARING_NOT_ENABLED`, and an object it lists is one a save admits. The + * predicate is handed in, never re-derived here. + * + * ## Order + * + * Business objects first, then platform (system) objects; inside each group + * by label, then by machine name. A platform object is the one test + * `effectiveSharingModel` already asks (`isPlatformObjectSchema`): the registry + * marks it `isSystem`, or its name carries the platform's `sys_` prefix. + */ + +import { isPlatformObjectSchema } from './sharing-service.js'; + +/** One object a sharing rule can take effect on. */ +export interface ShareableObject { + /** The object's machine name — the value `sys_sharing_rule.object_name` stores. */ + name: string; + /** The object's declared label, or its name when it declares none. */ + label: string; + /** `true` for a platform object (`isSystem`, or a `sys_` name); business objects are `false`. */ + system: boolean; +} + +/** The slice of the data engine this list reads: the registry's object configs. */ +export interface ShareableObjectsEngine { + getConfigs?(): Record | undefined; +} + +/** + * Raised when the engine cannot enumerate its objects. An empty list would + * read as "no object can take a sharing rule", which is a different and wrong + * answer, so the caller is told the list could not be read. + */ +export class ShareableObjectsUnavailableError extends Error { + constructor() { + super('The data engine cannot enumerate its objects, so the objects a sharing rule can take effect on cannot be listed.'); + this.name = 'ShareableObjectsUnavailableError'; + } +} + +/** + * Raised when the sharing-rule subsystem is not running on this kernel (record + * sharing configured with `enforce: false`, or the subsystem failed to start). + * No object can take a rule there, and the door says so rather than answering + * an empty list or an unmounted path. + */ +export class SharingRulesNotRunningError extends Error { + constructor() { + super('Sharing rules are not running on this deployment (record-sharing enforcement is off or did not start), so no object can take one.'); + this.name = 'SharingRulesNotRunningError'; + } +} + +interface ObjectConfigShape { + name?: unknown; + label?: unknown; +} + +function labelOf(name: string, config: ObjectConfigShape): string { + return typeof config.label === 'string' && config.label.trim().length > 0 ? config.label : name; +} + +function isPlatformObject(name: string, config: ObjectConfigShape): boolean { + return isPlatformObjectSchema(config) || name.startsWith('sys_'); +} + +function byGroupThenLabel(a: ShareableObject, b: ShareableObject): number { + if (a.system !== b.system) return a.system ? 1 : -1; + const byLabel = a.label.localeCompare(b.label, 'en', { sensitivity: 'base' }); + if (byLabel !== 0) return byLabel; + return a.name < b.name ? -1 : a.name > b.name ? 1 : 0; +} + +/** + * Every registered object a sharing rule can take effect on, ordered business + * first. `refusal` is the save doors' own verdict + * (`SharingService.sharingRuleObjectRefusal`): an object is listed when it + * answers `null`. + */ +export function listShareableObjects( + engine: ShareableObjectsEngine, + refusal: (object: string) => string | null, +): ShareableObject[] { + if (typeof engine.getConfigs !== 'function') throw new ShareableObjectsUnavailableError(); + const configs = engine.getConfigs(); + if (!configs || typeof configs !== 'object') throw new ShareableObjectsUnavailableError(); + const listed: ShareableObject[] = []; + for (const [key, raw] of Object.entries(configs)) { + const config: ObjectConfigShape = raw && typeof raw === 'object' ? (raw as ObjectConfigShape) : {}; + const name = typeof config.name === 'string' && config.name.length > 0 ? config.name : key; + if (refusal(name) !== null) continue; + listed.push({ name, label: labelOf(name, config), system: isPlatformObject(name, config) }); + } + return listed.sort(byGroupThenLabel); +} diff --git a/packages/plugins/plugin-sharing/src/sharing-plugin.ts b/packages/plugins/plugin-sharing/src/sharing-plugin.ts index 1ee65c2b3b4..5044a86b071 100644 --- a/packages/plugins/plugin-sharing/src/sharing-plugin.ts +++ b/packages/plugins/plugin-sharing/src/sharing-plugin.ts @@ -21,6 +21,7 @@ import { // [#23367] The API-key lifecycle policy source, and its branded refusal. apiKeyLifecyclePolicySourceOf, isApiKeyLifecycleRefusalError, + type RequestAuthService, } from '@objectstack/core'; import type { EngineMiddleware, OperationContext } from '@objectstack/objectql'; import type { @@ -73,6 +74,8 @@ import { import { SharingRuleService, bindDeclaredRuleNameGuard, bindSharingRuleActivationReconcile } from './sharing-rule-service.js'; import { ShareLinkService } from './share-link-service.js'; import { registerShareLinkRoutes } from './share-link-routes.js'; +import { SHAREABLE_OBJECTS_ROUTE, registerShareableObjectsRoutes } from './shareable-objects-routes.js'; +import { SharingRulesNotRunningError } from './shareable-objects.js'; import { bindRuleHooks, unbindAllRuleHooks, @@ -639,6 +642,47 @@ export class SharingServicePlugin implements Plugin { ); }; + /** + * [#30209 row 5 B] Mount `GET /api/v1/sharing/shareable-objects` + * (`shareable-objects-routes.ts`, ledger `sharing-route-ledger.ts`). The + * list is `SharingRuleService.listShareableObjects`, read at request time, + * so a rule service that never started answers 501 there instead of the + * path going unmounted. Canonical `http.server` first, the deprecated + * `http-server` alias second, as the share-link mount reads them. + */ + private mountShareableObjectsRoute(ctx: PluginContext): void { + const readServer = (name: string): IHttpServer | null => { + try { return ctx.getService(name); } catch { return null; } + }; + const http: IHttpServer | null = readServer('http.server') ?? readServer('http-server'); + if (!http) { + ctx.logger.warn( + 'SharingServicePlugin: no HTTP server — GET ' + SHAREABLE_OBJECTS_ROUTE + ' (the sharing-rule object ' + + 'picker list) not registered. SharingRuleService.listShareableObjects is still reachable via ' + + 'kernel.getService("sharingRules").', + ); + return; + } + const optional = (name: string): T | undefined => { + try { return ctx.getService(name); } catch { return undefined; } + }; + const kernel = ctx.getKernel?.() as + | { getServiceAsync?: (name: string) => Promise } + | undefined; + registerShareableObjectsRoutes(http, { + getEngine: () => optional('objectql'), + getAuth: () => optional('auth'), + tenancy: kernel && typeof kernel.getServiceAsync === 'function' + ? () => kernel.getServiceAsync!('tenancy') + : undefined, + listShareableObjects: (context) => { + if (!this.ruleService) throw new SharingRulesNotRunningError(); + return this.ruleService.listShareableObjects(context); + }, + }); + ctx.logger.info(`SharingServicePlugin: shareable objects list mounted at GET ${SHAREABLE_OBJECTS_ROUTE}`); + } + async init(ctx: PluginContext): Promise { // Register sys_record_share via the manifest service. ctx.getService<{ register(m: any): void }>('manifest').register({ @@ -1182,6 +1226,12 @@ export class SharingServicePlugin implements Plugin { } catch (err: any) { ctx.logger.warn('SharingServicePlugin: share-link subsystem not started', { error: err?.message }); } + + // [#30209 row 5 B] The objects a sharing rule can take effect on, for + // Setup's sharing-rule object picker. Mounted whenever an HTTP server is + // present; with no rule service running it answers 501 rather than an + // unmounted path (see `shareable-objects-routes.ts`). + this.mountShareableObjectsRoute(ctx); }); // [#2926 ③] Materialize sharing grants for rows already present at boot. diff --git a/packages/plugins/plugin-sharing/src/sharing-route-ledger.conformance.test.ts b/packages/plugins/plugin-sharing/src/sharing-route-ledger.conformance.test.ts new file mode 100644 index 00000000000..cd50519edbf --- /dev/null +++ b/packages/plugins/plugin-sharing/src/sharing-route-ledger.conformance.test.ts @@ -0,0 +1,61 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Sharing route-ledger conformance — the package-local guard every + * `*-route-ledger.ts` pairs with. `registerShareableObjectsRoutes` is a + * standalone, synchronous function that calls `http.get` and touches neither + * argument before a request arrives, so a capturing mock `IHttpServer` + * enumerates its real surface (the seam the messaging, print, storage and + * settings ledgers use), and the two directions fail here by name on every + * `pnpm test` of this package. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { registerShareableObjectsRoutes } from './shareable-objects-routes.js'; +import { SHARING_ROUTE_LEDGER } from './sharing-route-ledger.js'; + +function createMockServer() { + return { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + patch: vi.fn(), + use: vi.fn(), + listen: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + }; +} + +function enumerateRoutes(): Set { + const server = createMockServer(); + registerShareableObjectsRoutes(server as any, {} as any); + const keys = new Set(); + for (const verb of ['get', 'post', 'put', 'patch', 'delete'] as const) { + for (const call of server[verb].mock.calls) keys.add(`${verb.toUpperCase()} ${call[0]}`); + } + return keys; +} + +const ledgerKeys = (): Set => new Set(SHARING_ROUTE_LEDGER.map((e) => e.route)); + +describe('sharing route ledger ↔ registerShareableObjectsRoutes enumeration', () => { + it('every mounted route has a ledger entry', () => { + const ledger = ledgerKeys(); + const missing = [...enumerateRoutes()].filter((k) => !ledger.has(k)); + expect(missing, `sharing routes with no ledger entry: ${missing.join(', ')}`).toEqual([]); + }); + + it('every ledger entry is really mounted by the registrar', () => { + const live = enumerateRoutes(); + const stale = [...ledgerKeys()].filter((k) => !live.has(k)); + expect(stale, `sharing ledger entries the registrar no longer mounts: ${stale.join(', ')}`).toEqual([]); + }); + + it('the ledger is compared against a real enumeration, not an empty one', () => { + const live = enumerateRoutes(); + expect(live.size).toBeGreaterThan(0); + expect(live.size).toBe(ledgerKeys().size); + expect(SHARING_ROUTE_LEDGER.every((e) => e.disposition === 'sdk' || (e.note ?? '').length > 0)).toBe(true); + }); +}); diff --git a/packages/plugins/plugin-sharing/src/sharing-route-ledger.ts b/packages/plugins/plugin-sharing/src/sharing-route-ledger.ts new file mode 100644 index 00000000000..03ef797db60 --- /dev/null +++ b/packages/plugins/plugin-sharing/src/sharing-route-ledger.ts @@ -0,0 +1,55 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Sharing route ledger — the audited disposition of every HTTP route + * `registerShareableObjectsRoutes` (`shareable-objects-routes.ts`) mounts. + * + * WHY THIS EXISTS. `SharingServicePlugin` reaches for the `http.server` + * service and registers straight on `IHttpServer`, so the REST ledger does not + * see this route, the same shape as the messaging, print, storage and settings + * families. `sharing-route-ledger.conformance.test.ts` fails when the registrar + * mounts a route with no row here, and when a row names a route it no longer + * mounts; the dogfood live-mount parity gate reads this ledger beside the + * others on a real boot. The sharing-rule administration routes + * (`/api/v1/sharing/rules*`) are not here: `@objectstack/rest` serves them, and + * they are rows of its ledger. The share-link routes are rows of the + * dispatcher ledger (`/share-links`). + * + * NOT SDK SURFACE. `@objectstack/client` expresses no method for it: the + * consumer is the console's sharing-rule object picker, over plain HTTP. That + * is a `server-only` disposition. + * + * This module is package-internal (not exported from the index): it is the + * guard's data, not public API. It stays import-free, so a guard in another + * package can read it as a relative SOURCE file. + */ + +/** Disposition of a single sharing route. Same vocabulary as the REST ledger. */ +export type SharingRouteDisposition = + /** Expressed by the SDK — `client` names the method (dotted path). */ + | 'sdk' + /** Should be in the SDK and is not — an open, acknowledged gap. */ + | 'gap' + /** Deliberately not SDK surface. */ + | 'server-only' + /** Public, unauthenticated browser-facing route. */ + | 'public' + /** Server and client disagree on the shape — needs reconciliation. */ + | 'mismatch'; + +export interface SharingRouteLedgerEntry { + /** `VERB /api/v1/sharing/...` — the full wire path. */ + route: string; + /** Registrar family, for grouping and diff messages. */ + family: string; + disposition: SharingRouteDisposition; + /** Dotted method path on `ObjectStackClient` — required when disposition is `sdk`. */ + client?: string; + /** One-line rationale. Required for every non-`sdk` disposition. */ + note?: string; +} + +export const SHARING_ROUTE_LEDGER: readonly SharingRouteLedgerEntry[] = [ + { route: 'GET /api/v1/sharing/shareable-objects', family: 'sharing-shareable-objects', disposition: 'server-only', + note: "the objects a sharing rule can take effect on, for the console's sharing-rule object picker; sharing-rule administrators only, read-only" }, +]; diff --git a/packages/plugins/plugin-sharing/src/sharing-rule-service.ts b/packages/plugins/plugin-sharing/src/sharing-rule-service.ts index e2df976916d..29f936f0e00 100644 --- a/packages/plugins/plugin-sharing/src/sharing-rule-service.ts +++ b/packages/plugins/plugin-sharing/src/sharing-rule-service.ts @@ -52,6 +52,7 @@ import { TeamGraphService, teamHasMember } from './team-graph.js'; import { PositionGraphService } from './position-graph.js'; import { BusinessUnitGraphService } from './business-unit-graph.js'; import type { DeclaredSharingRule, DeclaredSharingRuleSource } from './declared-sharing-rules.js'; +import { listShareableObjects, type ShareableObject, type ShareableObjectsEngine } from './shareable-objects.js'; /** * System-elevated context for the rule evaluator's own reconcile writes. @@ -950,6 +951,27 @@ export class SharingRuleService implements ISharingRuleService { return { ...where, organization_id: orgId }; } + /** + * [#30209 row 5 B] The objects a sharing rule can take effect on, for + * Setup's object picker (`GET /api/v1/sharing/shareable-objects`). + * + * Admitted as every other verb of this surface admits + * ({@link assertCanManageRules}: `manage_sharing`, or the legacy + * `manage_platform_settings`, with a resolvable organization), so the list + * names nothing the caller could not author a rule on. Listed by the save + * doors' own verdict ({@link SharingService.sharingRuleObjectRefusal}) — + * handed to {@link listShareableObjects}, never restated — so an object + * listed here is one `defineRule` and the `/data` door admit, and one left + * out is one they refuse with `SHARING_NOT_ENABLED`. + */ + listShareableObjects(context: ExecutionContext): ShareableObject[] { + this.assertCanManageRules(context); // [ADR-0111 D6] + return listShareableObjects( + this.engine as SharingEngine & ShareableObjectsEngine, + (object) => this.sharing.sharingRuleObjectRefusal(object), + ); + } + async listRules( filter: { object?: string; activeOnly?: boolean }, context: ExecutionContext, diff --git a/packages/plugins/plugin-sharing/src/sharing-service.ts b/packages/plugins/plugin-sharing/src/sharing-service.ts index 28e392bc439..7e3c306f309 100644 --- a/packages/plugins/plugin-sharing/src/sharing-service.ts +++ b/packages/plugins/plugin-sharing/src/sharing-service.ts @@ -220,13 +220,20 @@ export function effectiveSharingModel(schema: any): 'private' | 'read' | 'public if (m === 'private') return 'private'; if (m === 'public_read') return 'read'; if (m === 'public_read_write' || m === 'controlled_by_parent') return 'public'; - if (m == null) { - const isSystem = schema?.isSystem === true || String(schema?.name ?? '').startsWith('sys_'); - return isSystem ? 'public' : 'private'; - } + if (m == null) return isPlatformObjectSchema(schema) ? 'public' : 'private'; return 'private'; } +/** + * A platform (system) object: one the registry marks `isSystem`, or one named + * with the platform's `sys_` prefix. The one spelling of that test, read by + * {@link effectiveSharingModel}'s default and by the sharing-rule object + * picker's ordering (`shareable-objects.ts`). + */ +export function isPlatformObjectSchema(schema: any): boolean { + return schema?.isSystem === true || String(schema?.name ?? '').startsWith('sys_'); +} + /** * [Ruling of record 6355206139, letter B] The org-wide default THIS principal * meets on `object`: {@link effectiveSharingModel}, floored to owner_only for diff --git a/packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts b/packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts index 2d99efb315e..cffc46b5665 100644 --- a/packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts +++ b/packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts @@ -74,6 +74,7 @@ import { SETTINGS_ROUTE_LEDGER } from '../../../services/service-settings/src/se import { PRINT_ROUTE_LEDGER } from '../../../services/service-print/src/print-route-ledger.js'; import { MESSAGING_ROUTE_LEDGER } from '../../../services/service-messaging/src/messaging-route-ledger.js'; import { PERSONAL_VIEW_ROUTE_LEDGER } from '../../../plugins/plugin-auth/src/personal-view-route-ledger.js'; +import { SHARING_ROUTE_LEDGER } from '../../../plugins/plugin-sharing/src/sharing-route-ledger.js'; /** One ledger row, normalized to a wire pattern this gate can probe. */ interface LedgerRow { @@ -114,6 +115,8 @@ function collectLedgerRows(): LedgerRow[] { // [#29443] AuthPlugin's own-row delete door for a personal view, mounted on // the IHttpServer port (unlike its raw-app `/auth/*` catch-all above). for (const r of PERSONAL_VIEW_ROUTE_LEDGER) add('plugin-auth/personal-view-route-ledger.ts', r.route, ''); + // [#30209] SharingServicePlugin's shareable-objects list, on the IHttpServer port. + for (const r of SHARING_ROUTE_LEDGER) add('plugin-sharing/sharing-route-ledger.ts', r.route, ''); return rows; } diff --git a/scripts/check-route-envelope.mjs b/scripts/check-route-envelope.mjs index 3b4537ef130..285fa3bf982 100644 --- a/scripts/check-route-envelope.mjs +++ b/scripts/check-route-envelope.mjs @@ -282,6 +282,12 @@ const MODULES = { // `sendServerFault`), so it builds none itself. 'packages/services/service-messaging/src/preference-options-routes.ts': { responses: 0, ok: 0, err: 0 }, + // [#30209] The shareable-objects door (`GET /api/v1/sharing/shareable-objects`), + // mounted by `SharingServicePlugin` straight on `IHttpServer`. Every body it + // answers is written by the shared pair (`sendOk` / `sendError` / + // `sendServerFault`), so it builds none itself. + 'packages/plugins/plugin-sharing/src/shareable-objects-routes.ts': { responses: 0, ok: 0, err: 0 }, + // The unreact door (`DELETE /api/v1/comments/reactions/:id`), mounted by // `AuditPlugin` straight on `IHttpServer`. It supplies its part of the shared // own-row delete door below and answers nothing itself, so it builds no body. diff --git a/scripts/cross-package-test-inputs.mjs b/scripts/cross-package-test-inputs.mjs index 5583bde16e1..2855526892d 100644 --- a/scripts/cross-package-test-inputs.mjs +++ b/scripts/cross-package-test-inputs.mjs @@ -1317,6 +1317,8 @@ export const CROSS_PACKAGE_TEST_INPUTS = { 'packages/services/service-messaging/src/messaging-route-ledger.ts', // [#29443] The personal-view door's ledger, imported the same way. 'packages/plugins/plugin-auth/src/personal-view-route-ledger.ts', + // [#30209] The shareable-objects door's ledger, imported the same way. + 'packages/plugins/plugin-sharing/src/sharing-route-ledger.ts', // flow-trigger / validation conformance pin spec's zod schemas. 'packages/spec/src/automation/**', 'packages/spec/src/data/**', diff --git a/turbo.json b/turbo.json index 9d17f62350b..ab5e69f72d2 100644 --- a/turbo.json +++ b/turbo.json @@ -616,6 +616,7 @@ "$TURBO_ROOT$/packages/services/service-settings/src/settings-route-ledger.ts", "$TURBO_ROOT$/packages/services/service-messaging/src/messaging-route-ledger.ts", "$TURBO_ROOT$/packages/plugins/plugin-auth/src/personal-view-route-ledger.ts", + "$TURBO_ROOT$/packages/plugins/plugin-sharing/src/sharing-route-ledger.ts", "$TURBO_ROOT$/packages/spec/src/automation/**", "$TURBO_ROOT$/packages/spec/src/data/**", "$TURBO_ROOT$/examples/app-showcase/**",