From 43842683f7649a5711b08b5c0cd43eed73d23421 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 03:39:18 +0000 Subject: [PATCH 1/5] wip(plugin-sharing): stamp the minted share link's organization (ADR-0131 D9) Claude-Session: https://claude.ai/code/session_01DhtBNRSSLKsbimJ37KcfZy Co-authored-by: Claude --- .../plugin-sharing/src/share-link-service.ts | 129 +++++++++++++++++- 1 file changed, 128 insertions(+), 1 deletion(-) diff --git a/packages/plugins/plugin-sharing/src/share-link-service.ts b/packages/plugins/plugin-sharing/src/share-link-service.ts index f05d80a21da..89b3b8f1095 100644 --- a/packages/plugins/plugin-sharing/src/share-link-service.ts +++ b/packages/plugins/plugin-sharing/src/share-link-service.ts @@ -44,6 +44,17 @@ import { materializeDeclaredFields, readInternalColumn } from '@objectstack/obje // schema and an engine that cannot answer `getSchema` are one answer, `false` // — the same definition `getPolicy` below has always used. import { isPublicSharingEnabled } from '@objectstack/spec/data'; +import type { TenancyPosture } from '@objectstack/spec/security'; +// [ADR-0131 D9] The one "which organization does an organization-less write +// resolve to?" decision (`@objectstack/core`'s, re-exported unchanged), and the +// engine's refusal for it — see +// `ShareLinkService.resolveMintOrganization`. +import { + resolveSystemWriteOrganization, + resolveTenantFieldName, + SystemWriteOrganizationRequiredError, +} from '@objectstack/objectql'; +import { resolveTenancyPosture } from '@objectstack/types'; import type { SharingEngine } from './sharing-service.js'; import { hashShareLinkPassword, @@ -60,6 +71,9 @@ import { /** Service-elevated context for the plugin's own queries / mutations. */ const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; +/** The organization object the ADR-0131 D9 decision counts on a `single` install. */ +const ORGANIZATION_OBJECT = 'sys_organization'; + /** * [#5190] The table whose orphans this service owns. `sys_share_link` is * `managedBy: 'engine-owned'` and its object doc states every write flows @@ -662,11 +676,124 @@ export class ShareLinkService implements IShareLinkService { use_count: 0, }; - await this.engine.insert('sys_share_link', row, { context: SYSTEM_CTX }); + // [ADR-0131 D9] The organization the link belongs to rides the stored row + // and the write context — the `{ isSystem, tenantId }` shape #8844's + // refusal prescribes for a system write. The mint response keeps the + // contract's shape (`ShareLink` declares no organization column). + const organizationId = await this.resolveMintOrganization(input, context); + const stored = organizationId === undefined ? row : { ...row, organization_id: organizationId }; + await this.engine.insert('sys_share_link', stored, { + context: organizationId === undefined ? SYSTEM_CTX : { ...SYSTEM_CTX, tenantId: organizationId }, + }); // [#21839] The row as stored carries the hash; the mint response does not. return withoutPasswordHash(row); } + /** + * [ADR-0131 D9, C8 stage 8b-0h] The organization a minted link is stamped + * with — never a NULL row on a tenant-columned `sys_share_link`: + * + * 1. the LINKED RECORD's organization, read under the system context off the + * column the object is walled by, so field-level masking cannot hide it + * from the decision. It wins over the session's: under `group` a member + * active in one organization may mint on a record of another it can see, + * and the link exposes that record, so it belongs where the record does — + * the direct-grant rule `SharingService.resolveDirectGrantOrganization` + * follows for `sys_record_share`; + * 2. the acting context's organization (`tenantId`), when the record names + * none — an object with no tenant column, or an organization-less row; + * 3. otherwise `@objectstack/core`'s one D9 predicate: the Default + * Organization under `single` when exactly one exists, and a loud + * `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` refusal under a wall (and on a + * `single` install holding none or several) — nothing is written. + * + * The refusal is the engine's own: the envelope its system-insert rule + * raises for this same write once the platform-namespace exemption retires, + * so the mint answers alike before and after that. + * + * The posture is the REQUESTED one (`resolveTenancyPosture()`), the source + * and fail-stricter direction `resolveAuthzContext` and the `tenancy` + * service's `defaultOrgId()` read: a deployment that asked for a wall is + * walled here even while it runs degraded. + * + * `undefined` means there is nothing to stamp: `sys_share_link` carries no + * tenant column in this composition, or the composition registers no + * organization object at all. + * + * A failing record read propagates: the mint fails and nothing is written, + * rather than stamping the session's organization on a link whose record's + * organization is unknown. + */ + private async resolveMintOrganization( + input: CreateShareLinkInput, + context: ExecutionContext, + ): Promise { + if (!this.tenantFieldOf('sys_share_link')) return undefined; + + const recordTenantField = this.tenantFieldOf(input.object); + if (recordTenantField) { + const rows = await this.engine.find(input.object, { + where: { id: input.recordId }, + fields: ['id', recordTenantField], + limit: 1, + context: SYSTEM_CTX, + } as any); + const value = Array.isArray(rows) + ? (rows[0] as Record | undefined)?.[recordTenantField] + : undefined; + if (typeof value === 'string' && value.trim() !== '') return value; + } + + const acting = context.tenantId; + if (typeof acting === 'string' && acting.trim() !== '') return acting; + + let posture: TenancyPosture; + try { + posture = resolveTenancyPosture(); + } catch { + // An unreadable posture is the boot's to refuse; here it reads walled. + posture = 'isolated'; + } + const decision = await resolveSystemWriteOrganization({ + posture, + probeOrganizations: () => this.probeOrganizations(), + organizationObjectRegistered: !!this.engine.getSchema?.(ORGANIZATION_OBJECT), + }); + if (decision.kind === 'derived') return decision.organizationId; + if (decision.kind === 'no-organization-object') return undefined; + throw new SystemWriteOrganizationRequiredError( + 'sys_share_link', + posture, + decision.reason, + decision.organizationCount, + ); + } + + /** The install's organization ids, capped at two — only "0 / 1 / several" decides. */ + private async probeOrganizations(): Promise { + const rows = await this.engine.find(ORGANIZATION_OBJECT, { + fields: ['id'], + limit: 2, + context: SYSTEM_CTX, + } as any); + return (Array.isArray(rows) ? rows : []) + .map((r: any) => r?.id) + .filter((id: unknown) => id != null && String(id) !== '') + .map((id: unknown) => String(id)); + } + + /** The tenant column of `object`, or `null` when it has none / the engine cannot say. */ + private tenantFieldOf(object: string): string | null { + if (typeof this.engine.getSchema !== 'function') return null; + let schema: unknown; + try { + schema = this.engine.getSchema(object); + } catch { + return null; + } + return schema ? resolveTenantFieldName(schema) : null; + } + async revokeLink(idOrToken: string, context: ExecutionContext): Promise { if (!idOrToken) throw makeError(400, 'VALIDATION_FAILED', 'id or token is required'); const filter = idOrToken.startsWith('shl_') ? { id: idOrToken } : { token: idOrToken }; From 36706e5e606afb01e96105f7e2d8615bcc3f6e4b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 03:44:21 +0000 Subject: [PATCH 2/5] test(plugin-sharing): pin the share-link mint's organization stamp on a real engine Claude-Session: https://claude.ai/code/session_01DhtBNRSSLKsbimJ37KcfZy Co-authored-by: Claude --- .../src/share-link-organization-stamp.test.ts | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 packages/plugins/plugin-sharing/src/share-link-organization-stamp.test.ts diff --git a/packages/plugins/plugin-sharing/src/share-link-organization-stamp.test.ts b/packages/plugins/plugin-sharing/src/share-link-organization-stamp.test.ts new file mode 100644 index 00000000000..590ab9bb58e --- /dev/null +++ b/packages/plugins/plugin-sharing/src/share-link-organization-stamp.test.ts @@ -0,0 +1,135 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D9, C8 stage 8b-0h] Every minted `sys_share_link` row carries + * `organization_id` — on a REAL engine over a REAL driver, with the REAL + * `sys_share_link` object, so the column under test is the one the registry + * injects and the row is read back off the driver, never off the response. + * + * ## Why the service decides, not the engine + * + * `sys_share_link` is `unclassified` in the #13491 ledger, so + * `Engine.resolveSystemInsertOrganization` lets an organization-less system + * insert on it through today. The mint used to rely on that and landed NULL on + * every link. The organization is derivable at the mint — the linked record + * and the caller are both in hand — so the service stamps it, and refuses + * loudly where nothing names one, before and after that exemption retires. + * + * ## The four pins + * + * 1. walled: the row carries the RECORD's organization — even when the caller + * acts in another one (the link exposes the record, so it belongs where + * the record does); + * 2. walled: an organization-less record takes the caller's organization; + * 3. `single`: neither names one — the Default Organization (the only one); + * 4. walled: neither names one — refused with the engine's ADR-0112 envelope, + * and no row is written. + * + * The posture is the REQUESTED one, read from `OS_TENANCY_POSTURE`, so each + * case stubs the environment rather than injecting an engine provider. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { ObjectQL, SYSTEM_WRITE_ORGANIZATION_REQUIRED_CODE } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; + +import { ShareLinkService } from './share-link-service.js'; +import { SysShareLink } from './objects/index.js'; + +const OBJECT = 'os15212_brief'; +const ORG_A = 'org_a'; +const ORG_B = 'org_b'; +const DEFAULT_ORG = 'org_default'; + +const BRIEF_FIELDS: Record> = { + id: { type: 'text', name: 'id', label: 'Id', primary: true }, + name: { type: 'text', name: 'name', label: 'Name' }, + organization_id: { type: 'text', name: 'organization_id', label: 'Org' }, +}; + +const ORG_FIELDS: Record> = { + id: { type: 'text', name: 'id', label: 'Id', primary: true }, + name: { type: 'text', name: 'name', label: 'Name' }, +}; + +const open: SqlDriver[] = []; + +afterEach(async () => { + vi.unstubAllEnvs(); + while (open.length) await open.pop()?.disconnect?.(); +}); + +/** + * brief_a ORG_A + * brief_p (null) <- organization-less; what pins 2-4 mint on + */ +async function boot(posture: 'single' | 'isolated', organizations: readonly string[]) { + vi.stubEnv('OS_TENANCY_POSTURE', posture); + const driver = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + open.push(driver); + + const ql = new ObjectQL(); + ql.registerDriver(driver as never, true); + await ql.init(); + ql.registerObject({ name: OBJECT, label: 'Brief', fields: BRIEF_FIELDS } as never); + ql.registerObject(SysShareLink as never); + ql.registerObject({ name: 'sys_organization', label: 'Organization', isSystem: true, fields: ORG_FIELDS } as never); + await driver.initObjects( + [OBJECT, 'sys_share_link', 'sys_organization'].map((name) => ({ name, fields: (ql.getSchema(name) as any).fields }) as never), + ); + for (const id of organizations) await driver.create('sys_organization', { id, name: id } as never); + await driver.create(OBJECT, { id: 'brief_a', name: 'A', organization_id: ORG_A } as never); + await driver.create(OBJECT, { id: 'brief_p', name: 'P' } as never); + + const service = new ShareLinkService({ engine: ql as never, permissive: true, logger: { warn: () => {} } }); + const links = async () => + (await driver.find('sys_share_link', {} as never)).map((r: any) => ({ + record_id: String(r.record_id), + organization_id: r.organization_id ?? null, + })); + return { ql, service, links }; +} + +const caller = (tenantId?: string) => + ({ userId: 'u_1', positions: [], permissions: [], ...(tenantId ? { tenantId } : {}) }) as unknown as ExecutionContext; + +describe('[ADR-0131 D9] a minted share link carries an organization', () => { + it("the real sys_share_link object carries the tenant column the stamp writes", async () => { + const { ql } = await boot('isolated', [ORG_A, ORG_B]); + expect(Object.keys((ql.getSchema('sys_share_link') as any).fields)).toContain('organization_id'); + }); + + it("walled: the RECORD's organization, even when the caller acts in another", async () => { + const { service, links } = await boot('isolated', [ORG_A, ORG_B]); + await service.createLink({ object: OBJECT, recordId: 'brief_a' }, { ...caller(ORG_B), isSystem: true } as never); + expect(await links()).toEqual([{ record_id: 'brief_a', organization_id: ORG_A }]); + }); + + it("walled: an organization-less record takes the caller's organization", async () => { + const { service, links } = await boot('isolated', [ORG_A, ORG_B]); + await service.createLink({ object: OBJECT, recordId: 'brief_p' }, caller(ORG_B)); + expect(await links()).toEqual([{ record_id: 'brief_p', organization_id: ORG_B }]); + }); + + it('single: neither names one — the Default Organization, the only one there is', async () => { + const { service, links } = await boot('single', [DEFAULT_ORG]); + const link = await service.createLink({ object: OBJECT, recordId: 'brief_p' }, caller()); + expect(await links()).toEqual([{ record_id: 'brief_p', organization_id: DEFAULT_ORG }]); + // The mint response keeps the contract's shape. + expect(link).not.toHaveProperty('organization_id'); + }); + + it('walled: neither names one — refused with the ADR-0112 envelope, and no row is written', async () => { + const { service, links } = await boot('isolated', [ORG_A, ORG_B]); + const refusal = await service + .createLink({ object: OBJECT, recordId: 'brief_p' }, caller()) + .then(() => undefined, (err: unknown) => err); + expect(refusal).toMatchObject({ code: SYSTEM_WRITE_ORGANIZATION_REQUIRED_CODE, status: 500 }); + expect(await links()).toEqual([]); + }); +}); From 434345d94e3c88442fd7f1fac1451fcbad00643a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 03:50:48 +0000 Subject: [PATCH 3/5] fix(plugin-sharing): the organization probe answers [] where no organization object is registered Claude-Session: https://claude.ai/code/session_01DhtBNRSSLKsbimJ37KcfZy Co-authored-by: Claude --- packages/plugins/plugin-sharing/src/share-link-service.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/plugins/plugin-sharing/src/share-link-service.ts b/packages/plugins/plugin-sharing/src/share-link-service.ts index 89b3b8f1095..a85075c834b 100644 --- a/packages/plugins/plugin-sharing/src/share-link-service.ts +++ b/packages/plugins/plugin-sharing/src/share-link-service.ts @@ -769,8 +769,14 @@ export class ShareLinkService implements IShareLinkService { ); } - /** The install's organization ids, capped at two — only "0 / 1 / several" decides. */ + /** + * The install's organization ids, capped at two — only "0 / 1 / several" + * decides. A composition that registers no organization object answers `[]` + * without a read, as the engine's own probe does: the data door refuses an + * unregistered name. + */ private async probeOrganizations(): Promise { + if (!this.engine.getSchema?.(ORGANIZATION_OBJECT)) return []; const rows = await this.engine.find(ORGANIZATION_OBJECT, { fields: ['id'], limit: 2, From 3d89674faa4f582477adca88106d5cc7d3653f8b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 03:58:11 +0000 Subject: [PATCH 4/5] chore(changeset): share-link mint stamps its organization (ADR-0131 D9) Claude-Session: https://claude.ai/code/session_01DhtBNRSSLKsbimJ37KcfZy Co-authored-by: Claude --- .changeset/15212-8b0h-share-link-stamped.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .changeset/15212-8b0h-share-link-stamped.md diff --git a/.changeset/15212-8b0h-share-link-stamped.md b/.changeset/15212-8b0h-share-link-stamped.md new file mode 100644 index 00000000000..49c07b8cab5 --- /dev/null +++ b/.changeset/15212-8b0h-share-link-stamped.md @@ -0,0 +1,12 @@ +--- +"@objectstack/plugin-sharing": patch +--- + +fix(plugin-sharing): a minted share link carries an organization, never a NULL one (ADR-0131 D9) + +Clause-②: no + +- **What changed.** `ShareLinkService.createLink` stamps `sys_share_link.organization_id`. It takes the organization of the linked record, read under the system context. When the record names none, it takes the acting context's organization (`tenantId`). When neither names one, it applies the system-write rule: under `single`, the Default Organization (the only one there is); under a wall (`group` or `isolated`), the mint is refused with `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` (status 500) and no link is written. The same refusal answers a `single` install that holds no organization or several. Before, every link was written with `organization_id` NULL. +- **A link on another organization's record.** Under `group`, a member active in one organization can mint on a visible record of another. The link carries the record's organization, not the session's. +- **What stays the same.** Under `single` nothing a user sees changes. The mint response keeps the `ShareLink` shape and carries no organization field. A composition that registers no `sys_organization` object writes links as before. +- **Who can meet the refusal.** No in-repo caller reaches it. Both share-link routes need a signed-in caller. Under a wall, an organization-less API key is refused at admission, and an organization-less session cannot see a record that has a tenant column, so its mint is refused before the stamp. The refusal is reached by a walled, organization-less caller minting on an object with no tenant column, or by a system caller that threads no `tenantId`. The fix is to pass the organization on the context (`{ isSystem: true, tenantId }`). From a117f81c431314b0dbba46c865b7393b876f6a38 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 04:28:10 +0000 Subject: [PATCH 5/5] fix(plugin-sharing): the mint's write context and reads keep the census-decidable, typed spellings Claude-Session: https://claude.ai/code/session_01DhtBNRSSLKsbimJ37KcfZy Co-authored-by: Claude --- packages/plugins/plugin-sharing/src/share-link-service.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/plugins/plugin-sharing/src/share-link-service.ts b/packages/plugins/plugin-sharing/src/share-link-service.ts index a85075c834b..a8e4f2e177f 100644 --- a/packages/plugins/plugin-sharing/src/share-link-service.ts +++ b/packages/plugins/plugin-sharing/src/share-link-service.ts @@ -683,7 +683,7 @@ export class ShareLinkService implements IShareLinkService { const organizationId = await this.resolveMintOrganization(input, context); const stored = organizationId === undefined ? row : { ...row, organization_id: organizationId }; await this.engine.insert('sys_share_link', stored, { - context: organizationId === undefined ? SYSTEM_CTX : { ...SYSTEM_CTX, tenantId: organizationId }, + context: { ...SYSTEM_CTX, tenantId: organizationId }, }); // [#21839] The row as stored carries the hash; the mint response does not. return withoutPasswordHash(row); @@ -737,7 +737,7 @@ export class ShareLinkService implements IShareLinkService { fields: ['id', recordTenantField], limit: 1, context: SYSTEM_CTX, - } as any); + }); const value = Array.isArray(rows) ? (rows[0] as Record | undefined)?.[recordTenantField] : undefined; @@ -781,7 +781,7 @@ export class ShareLinkService implements IShareLinkService { fields: ['id'], limit: 2, context: SYSTEM_CTX, - } as any); + }); return (Array.isArray(rows) ? rows : []) .map((r: any) => r?.id) .filter((id: unknown) => id != null && String(id) !== '')