diff --git a/.changeset/public-sharing-enabled-canonical-predicate.md b/.changeset/public-sharing-enabled-canonical-predicate.md new file mode 100644 index 0000000000..17b3856580 --- /dev/null +++ b/.changeset/public-sharing-enabled-canonical-predicate.md @@ -0,0 +1,13 @@ +--- +"@objectstack/spec": minor +"@objectstack/plugin-sharing": patch +"@objectstack/runtime": patch +--- + +`publicSharing.enabled` now has one canonical predicate, exported from the package that declares the key. + +`isPublicSharingEnabled(schema)` is a new export of `@objectstack/spec/data`, declared in `src/data/object.zod.ts` beside the `publicSharing` block itself — the same shape as the neighbouring `isTenancyDisabled`. It is additive: nothing was removed or narrowed from the spec's public API. + +Until now the same policy read existed in two spellings. `@objectstack/plugin-sharing` defined it (for the share-link service's redemption gate and the route probe above it), and `@objectstack/runtime` carried a documented private mirror for its `/share-links` dispatcher domain — copied rather than imported because the plugin is only a **dev** dependency of the runtime. That reasoning was true of that one home and not of the question: both packages already depend on `@objectstack/spec`, so a shared home existed all along and the de-duplication adds no dependency edge. Both surfaces now consume the exported predicate and the runtime copy is deleted. + +Behaviour is unchanged, fail-closed included: an absent `publicSharing` block, an absent schema, and an engine that cannot answer `getSchema` at all remain **one** answer, `false`, and only the boolean `true` enables. The two pins that held the copies equal — `share-link-eligibility.test.ts` in the plugin and `share-links-enforcement-context.test.ts` in the runtime, which assert the same observable answer on both surfaces rather than trusting the copy — are unchanged and still green; they are what proves the merge did not move behaviour. The predicate's own contract, which those tests can only observe indirectly, is now pinned directly in `packages/spec/src/data/object.test.ts`. diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 2e6ca492b0..25dae0637f 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -135,7 +135,7 @@ The largest single consumer — **17 of the 105 sites**. | 34 | `revoke()` deletes directly, **before** the non-manual-source guard | Get: the evaluator can revoke its own grants. Lose: the `CONFLICT` guard that warns a rule-materialised grant will be silently re-granted on the next reconcile | `plugin-sharing/src/sharing-service.ts:1476` (guard at `:1501`) | | 35 | `listShares()` skips the management gate | Get: full enumeration of who can see a record | `plugin-sharing/src/sharing-service.ts:1528` | | 36 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `sharing-plugin.ts:1189` | -| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link **creation** while the policy is off — resolution is **not** bypassed since #14033 (`publicSharing.enabled` is a standing policy held at every redemption): a link minted this way does not resolve until the block is enabled | `plugin-sharing/src/share-link-service.ts:469`, `:523`, `:527`, `:600`, `:630` | +| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link **creation** while the policy is off — resolution is **not** bypassed since #14033 (`publicSharing.enabled` is a standing policy held at every redemption): a link minted this way does not resolve until the block is enabled | `plugin-sharing/src/share-link-service.ts:459`, `:513`, `:517`, `:590`, `:620` | | 38 | Sharing-rule provenance stamp skipped | Lose: the row is not marked as an admin customization — seeder / `defineRule` / boot reconcilers are "the package door" | `sharing-rule-provenance.ts:47` | | 39 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, and the platform-global-rule delete guard | `sharing-rule-service.ts:202`, `:427` | diff --git a/packages/plugins/plugin-sharing/src/share-link-routes.ts b/packages/plugins/plugin-sharing/src/share-link-routes.ts index 03632ed773..7110833ad6 100644 --- a/packages/plugins/plugin-sharing/src/share-link-routes.ts +++ b/packages/plugins/plugin-sharing/src/share-link-routes.ts @@ -35,11 +35,15 @@ import type { IHttpServer, IHttpRequest, RouteHandler } from '@objectstack/spec/ import { sendOk, sendError } from '@objectstack/types'; import type { ShareLinkExecutionContext } from '@objectstack/spec/contracts'; import type { ExecutionContext } from '@objectstack/spec/kernel'; -// [#14637] `isPublicSharingEnabled` is the service's OWN reading of the +// [#14637 -> #14935] `isPublicSharingEnabled` is the CANONICAL reading of the // standing switch, imported rather than restated here. A second spelling of // `publicSharing.enabled` at this layer is how the probe below came to -// contradict the gate inside `resolveToken` in the first place. -import { isPublicSharingEnabled, type ShareLinkService } from './share-link-service.js'; +// contradict the gate inside `resolveToken` in the first place. It now comes +// from the package that DECLARES the key, which is the same predicate +// `share-link-service.ts` gates redemption with — one definition, not a +// service-local one this layer re-exports. +import { isPublicSharingEnabled } from '@objectstack/spec/data'; +import { type ShareLinkService } from './share-link-service.js'; import type { SharingEngine } from './sharing-service.js'; const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; diff --git a/packages/plugins/plugin-sharing/src/share-link-service.ts b/packages/plugins/plugin-sharing/src/share-link-service.ts index 6fbe885c6f..ef7ca45a69 100644 --- a/packages/plugins/plugin-sharing/src/share-link-service.ts +++ b/packages/plugins/plugin-sharing/src/share-link-service.ts @@ -32,6 +32,16 @@ import { ExpressionEngine } from '@objectstack/formula'; // keep a copy. `declared-fields.ts`'s doc comment is the canonical statement of // the rule; this seam defers to it instead of restating it. import { materializeDeclaredFields } from '@objectstack/objectql/core'; +// [#14935] The ONE reading of `publicSharing.enabled`, imported from the +// package that DECLARES the key rather than spelled out again here. This file +// exported its own copy (#14637) and `@objectstack/runtime` kept a documented +// mirror of it, because `@objectstack/plugin-sharing` is only a DEV dependency +// of that package — but both packages already depend on `@objectstack/spec`, +// so the shared home the copy was justified by existed all along. The +// definition is unchanged, fail-closed included: an absent block, an absent +// 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 { SharingEngine } from './sharing-service.js'; import { deleteRowsForDeletedRecords, @@ -85,26 +95,6 @@ function generateToken(length: number = TOKEN_LENGTH): string { return out; } -/** - * [#14637] Is `publicSharing` switched ON for this object schema? - * - * The ONE reading of the standing switch, exported so the HTTP probe that sits - * ABOVE `resolveToken` asks the same question the gate INSIDE it asks. It was - * a private expression here while the route layer answered from the token row - * with no knowledge of the object's block, which re-opened the existence - * oracle this service's redemption gate closes (maintainer ruling 2026-09-03, - * decision batch #17 item 1, verbatim 「同意」 — option A). - * - * An absent block, an absent schema, and an engine that cannot answer - * `getSchema` at all are one answer: `false`. `enabled` defaults to off, so a - * caller that cannot read the policy must refuse rather than answer from the - * row — the same definition {@link getPolicy} has always used. - */ -export function isPublicSharingEnabled(schema: unknown): boolean { - return (schema as { publicSharing?: { enabled?: unknown } } | null | undefined) - ?.publicSharing?.enabled === true; -} - /** Internal helper — extract publicSharing policy from an object schema. */ function getPolicy(schema: any): { enabled: boolean; diff --git a/packages/runtime/src/domains/share-links.ts b/packages/runtime/src/domains/share-links.ts index 6b14a2e197..b94e7a28d3 100644 --- a/packages/runtime/src/domains/share-links.ts +++ b/packages/runtime/src/domains/share-links.ts @@ -36,33 +36,26 @@ */ import { SHARE_LINK_SERVICE } from '@objectstack/spec/contracts'; +// [#14637 -> #14935] The standing `publicSharing.enabled` switch, read through +// the ONE predicate the package that DECLARES the key exports. This file used +// to carry a documented MIRROR of `isPublicSharingEnabled` from +// `plugin-sharing/src/share-link-service.ts`, copied rather than imported +// because `@objectstack/plugin-sharing` is a **dev** dependency here and +// importing it would invert the dependency direction. That reasoning held only +// for that home: `@objectstack/spec` is a runtime dependency of this package +// AND of the plugin, so moving the predicate beside the schema removes the copy +// without adding an edge. Behaviour is unchanged, fail-closed included — an +// absent block, an absent schema, and an engine that cannot answer `getSchema` +// remain one answer, `false` — and the pins that held the two spellings equal +// (`share-links-enforcement-context.test.ts` here, +// `share-link-eligibility.test.ts` on the other side) are unchanged too: they +// assert the same observable answer on both surfaces, which is what proves the +// de-duplication did not move the behaviour. +import { isPublicSharingEnabled } from '@objectstack/spec/data'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js'; -/** - * [#14637] Is `publicSharing` switched ON for this object schema? - * - * A deliberate MIRROR of `isPublicSharingEnabled` in - * `plugin-sharing/src/share-link-service.ts`, which is the canonical - * definition and the one `resolveToken`'s own gate reads. It is copied rather - * than imported because `@objectstack/plugin-sharing` is a **dev** dependency - * of this package: importing it here would invert the dependency direction to - * make one boolean read shared. The two spellings are held equal by the pins - * in `share-links-enforcement-context.test.ts` on this side and - * `share-link-eligibility.test.ts` on the other, which assert the SAME - * observable answer on both surfaces rather than trusting the copy. - * - * An absent block, an absent schema, and an engine that cannot answer - * `getSchema` at all are one answer: `false`. `enabled` defaults to off, so a - * surface that cannot read the policy must refuse rather than answer from the - * token row. - */ -function isPublicSharingEnabled(schema: unknown): boolean { - return (schema as { publicSharing?: { enabled?: unknown } } | null | undefined) - ?.publicSharing?.enabled === true; -} - export function createShareLinksDomain(deps: DomainHandlerDeps): DomainRoute { return { prefix: '/share-links', diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index 639140b0e0..b817cdf3e3 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -740,6 +740,7 @@ "isNowDefaultToken (function)", "isOrganizationUnique (function)", "isPlainRecord (function)", + "isPublicSharingEnabled (function)", "isRuntimeDefaultToken (function)", "isTenancyDisabled (function)", "isTextFilterOperator (function)", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index 51fa0bb664..624ffba6fa 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -740,6 +740,7 @@ "isNowDefaultToken": "src/data/default-value-tokens.ts#isNowDefaultToken (function)", "isOrganizationUnique": "src/data/field.zod.ts#isOrganizationUnique (function)", "isPlainRecord": "src/data/authoring-key-lint.ts#isPlainRecord (function)", + "isPublicSharingEnabled": "src/data/object.zod.ts#isPublicSharingEnabled (function)", "isRuntimeDefaultToken": "src/data/default-value-tokens.ts#isRuntimeDefaultToken (function)", "isTenancyDisabled": "src/data/object.zod.ts#isTenancyDisabled (function)", "isTextFilterOperator": "src/data/filter-text-operator-declared-type.ts#isTextFilterOperator (function)", diff --git a/packages/spec/src/data/object.test.ts b/packages/spec/src/data/object.test.ts index 334debc015..82cf719a93 100644 --- a/packages/spec/src/data/object.test.ts +++ b/packages/spec/src/data/object.test.ts @@ -6,7 +6,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; // `activities`, … and the annotation stops being a contract check at all. This // only became visible when tsconfig.test.json put these files in front of tsc // (#5286). -import { ObjectSchema, ObjectCapabilities, IndexSchema, ObjectFieldGroupSchema, ObjectExternalBindingSchema, ObjectAccessConfigSchema, LifecycleSchema, TenancyConfigSchema, isTenancyDisabled, resolveCrudAffordances, type ServiceObject } from './object.zod'; +import { ObjectSchema, ObjectCapabilities, IndexSchema, ObjectFieldGroupSchema, ObjectExternalBindingSchema, ObjectAccessConfigSchema, LifecycleSchema, TenancyConfigSchema, isTenancyDisabled, isPublicSharingEnabled, resolveCrudAffordances, type ServiceObject } from './object.zod'; import { resolveInjectedSystemColumns } from './injected-system-columns'; import { Field } from './field.zod'; import type { StateMachineValidation } from './validation.zod'; @@ -2101,6 +2101,75 @@ describe('isTenancyDisabled — platform-global posture predicate (#3249, ADR-00 }); }); +/** + * [#14935] `isPublicSharingEnabled` — the canonical read of the standing + * share-link switch, exported beside the `publicSharing` declaration. + * + * It replaces two spellings: this predicate was private to + * `plugin-sharing/src/share-link-service.ts` (#14637) and `@objectstack/runtime` + * carried a documented MIRROR of it for its `/share-links` dispatcher domain. + * Those two surfaces keep their own behavioural pins — `share-link-eligibility` + * and `share-links-enforcement-context`, which assert the same observable + * answer on both surfaces. What is pinned HERE is the predicate's own contract, + * which those tests can only observe indirectly: fail-CLOSED, with the three + * unreadable cases collapsing to ONE answer. + */ +describe('isPublicSharingEnabled — standing share-link policy predicate (#14935, #14637)', () => { + it('is true only for an explicit publicSharing.enabled === true', () => { + expect(isPublicSharingEnabled({ name: 'article', publicSharing: { enabled: true } })).toBe(true); + expect(isPublicSharingEnabled({ name: 'article', publicSharing: { enabled: false } })).toBe(false); + }); + + it('is false when the block, or the key, is absent — `enabled` defaults to OFF', () => { + expect(isPublicSharingEnabled({ name: 'article', fields: { title: { type: 'text' } } })).toBe(false); + expect(isPublicSharingEnabled({ name: 'article', publicSharing: {} })).toBe(false); + expect(isPublicSharingEnabled({ name: 'article', publicSharing: { allowedAudiences: ['link_only'] } })).toBe(false); + }); + + it('collapses the three unreadable cases to ONE answer, false', () => { + // An absent block, an absent schema, and an engine that cannot answer + // `getSchema` at all (`engine.getSchema?.(name)` -> undefined). A surface + // that cannot read the policy must refuse rather than answer from the + // share-link row: a distinguishable "sharing is off for this object" is an + // existence oracle for a caller holding nothing but a token. + const unreadable = [{ name: 'article' }, undefined, null]; + for (const schema of unreadable) expect(isPublicSharingEnabled(schema)).toBe(false); + expect(new Set(unreadable.map(isPublicSharingEnabled)).size).toBe(1); + }); + + it('refuses a truthy non-boolean — only the boolean true publishes', () => { + // Nothing that reaches this predicate is guaranteed to have been through + // `ObjectSchema`: the runtime probe reads whatever the engine's schema + // registry holds. `=== true` is what keeps a stored `'true'` from + // publishing records. + for (const enabled of ['true', 1, {}, [], 'yes'] as unknown[]) { + expect(isPublicSharingEnabled({ name: 'article', publicSharing: { enabled } })).toBe(false); + } + }); + + it('tolerates null/undefined/non-object schemas', () => { + expect(isPublicSharingEnabled(undefined)).toBe(false); + expect(isPublicSharingEnabled(null)).toBe(false); + expect(isPublicSharingEnabled('article')).toBe(false); + expect(isPublicSharingEnabled(42)).toBe(false); + }); + + it('agrees with the schema it reads — the parsed default is OFF', () => { + const parsed = ObjectSchema.parse({ + name: 'article', + fields: { title: { type: 'text' } }, + publicSharing: { allowedAudiences: ['link_only'] }, + }); + expect(parsed.publicSharing?.enabled).toBe(false); + expect(isPublicSharingEnabled(parsed)).toBe(false); + expect(isPublicSharingEnabled(ObjectSchema.parse({ + name: 'article', + fields: { title: { type: 'text' } }, + publicSharing: { enabled: true }, + }))).toBe(true); + }); +}); + describe('userActions row predicates + resolveCrudAffordances (objectui#2614)', () => { it('accepts the plain boolean form unchanged (back-compat)', () => { const obj = ObjectSchema.parse({ diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 2d83dd0f5c..b13580052b 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -2204,6 +2204,8 @@ const ObjectSchemaBase = strictObject( * provided, the plugin allows `link_only` audience + `view` permission * (the safest combination — caller still needs the URL to access). * + * @see {@link isPublicSharingEnabled} — the ONE reading of `enabled`, + * exported below beside this declaration. * @see packages/plugins/plugin-sharing/src/share-link-service.ts */ publicSharing: strictObject({ @@ -2323,6 +2325,37 @@ const ObjectSchemaBase = strictObject( ...MetadataProtectionFields, }); +/** + * [#14935] Is `publicSharing` switched ON for this object schema? + * + * The ONE reading of the standing switch declared in the `publicSharing` block + * above, exported here beside the declaration so that every surface gating on + * it asks the same question. Two packages read it today — the share-link + * service and the route probe above it (`@objectstack/plugin-sharing`), and the + * `/share-links` dispatcher domain (`@objectstack/runtime`) — and the second + * carried a documented copy of this expression, because the plugin is only a + * DEV dependency of the runtime. That copy was never structurally forced: both + * packages already depend on THIS one, so the shared home existed all along. + * One policy read spelled twice, held equal by a comment and by two pins, is a + * contract defect even while the two spellings agree. + * + * Fail-CLOSED, and the three unreadable cases are ONE answer, `false`: an absent + * `publicSharing` block, an absent schema, and an engine that cannot answer + * `getSchema` at all. `enabled` defaults to off, so a surface that cannot read + * the policy must refuse rather than answer from the share-link row — a + * distinguishable "sharing is off for this object" is an existence oracle for a + * caller holding nothing but a token. Only the boolean `true` enables: the + * strict comparison is deliberate, so a truthy `'true'` or `1` that never went + * through this schema does not publish records. + * + * The same shape as {@link isTenancyDisabled} — an object posture the spec owns + * precisely because more than one package must not re-derive it independently. + */ +export function isPublicSharingEnabled(schema: unknown): boolean { + return (schema as { publicSharing?: { enabled?: unknown } } | null | undefined) + ?.publicSharing?.enabled === true; +} + /** * Converts a snake_case name to a human-readable Title Case label. * @example snakeCaseToLabel('project_task') → 'Project Task'