Skip to content

Commit 1f294f9

Browse files
zhuangjianguoclaude
andcommitted
refactor(spec,plugin-sharing,runtime): one canonical publicSharing.enabled predicate
`isPublicSharingEnabled` is now exported from `@objectstack/spec/data`, declared beside the `publicSharing` block in `src/data/object.zod.ts` (the same shape as the neighbouring `isTenancyDisabled`). `plugin-sharing`'s service and route probe consume it, and `packages/runtime`'s documented private mirror in `src/domains/share-links.ts` is deleted. The mirror's stated justification — importing the plugin would invert the dependency direction, it being a dev dependency of the runtime — held only for that home: both packages already depend on `@objectstack/spec`, so the shared home needed no new edge. Behaviour unchanged, fail-closed included: an absent block, an absent schema and an engine that cannot answer `getSchema` remain one answer, `false`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
1 parent f7db8f4 commit 1f294f9

8 files changed

Lines changed: 151 additions & 47 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/plugin-sharing": patch
4+
"@objectstack/runtime": patch
5+
---
6+
7+
`publicSharing.enabled` now has one canonical predicate, exported from the package that declares the key.
8+
9+
`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.
10+
11+
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.
12+
13+
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`.

packages/plugins/plugin-sharing/src/share-link-routes.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,15 @@ import type { IHttpServer, IHttpRequest, RouteHandler } from '@objectstack/spec/
3535
import { sendOk, sendError } from '@objectstack/types';
3636
import type { ShareLinkExecutionContext } from '@objectstack/spec/contracts';
3737
import type { ExecutionContext } from '@objectstack/spec/kernel';
38-
// [#14637] `isPublicSharingEnabled` is the service's OWN reading of the
38+
// [#14637 -> #14935] `isPublicSharingEnabled` is the CANONICAL reading of the
3939
// standing switch, imported rather than restated here. A second spelling of
4040
// `publicSharing.enabled` at this layer is how the probe below came to
41-
// contradict the gate inside `resolveToken` in the first place.
42-
import { isPublicSharingEnabled, type ShareLinkService } from './share-link-service.js';
41+
// contradict the gate inside `resolveToken` in the first place. It now comes
42+
// from the package that DECLARES the key, which is the same predicate
43+
// `share-link-service.ts` gates redemption with — one definition, not a
44+
// service-local one this layer re-exports.
45+
import { isPublicSharingEnabled } from '@objectstack/spec/data';
46+
import { type ShareLinkService } from './share-link-service.js';
4347
import type { SharingEngine } from './sharing-service.js';
4448

4549
const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const;

packages/plugins/plugin-sharing/src/share-link-service.ts

Lines changed: 10 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,16 @@ import { ExpressionEngine } from '@objectstack/formula';
3232
// keep a copy. `declared-fields.ts`'s doc comment is the canonical statement of
3333
// the rule; this seam defers to it instead of restating it.
3434
import { materializeDeclaredFields } from '@objectstack/objectql/core';
35+
// [#14935] The ONE reading of `publicSharing.enabled`, imported from the
36+
// package that DECLARES the key rather than spelled out again here. This file
37+
// exported its own copy (#14637) and `@objectstack/runtime` kept a documented
38+
// mirror of it, because `@objectstack/plugin-sharing` is only a DEV dependency
39+
// of that package — but both packages already depend on `@objectstack/spec`,
40+
// so the shared home the copy was justified by existed all along. The
41+
// definition is unchanged, fail-closed included: an absent block, an absent
42+
// schema and an engine that cannot answer `getSchema` are one answer, `false`
43+
// — the same definition `getPolicy` below has always used.
44+
import { isPublicSharingEnabled } from '@objectstack/spec/data';
3545
import type { SharingEngine } from './sharing-service.js';
3646
import {
3747
deleteRowsForDeletedRecords,
@@ -85,26 +95,6 @@ function generateToken(length: number = TOKEN_LENGTH): string {
8595
return out;
8696
}
8797

88-
/**
89-
* [#14637] Is `publicSharing` switched ON for this object schema?
90-
*
91-
* The ONE reading of the standing switch, exported so the HTTP probe that sits
92-
* ABOVE `resolveToken` asks the same question the gate INSIDE it asks. It was
93-
* a private expression here while the route layer answered from the token row
94-
* with no knowledge of the object's block, which re-opened the existence
95-
* oracle this service's redemption gate closes (maintainer ruling 2026-09-03,
96-
* decision batch #17 item 1, verbatim 「同意」 — option A).
97-
*
98-
* An absent block, an absent schema, and an engine that cannot answer
99-
* `getSchema` at all are one answer: `false`. `enabled` defaults to off, so a
100-
* caller that cannot read the policy must refuse rather than answer from the
101-
* row — the same definition {@link getPolicy} has always used.
102-
*/
103-
export function isPublicSharingEnabled(schema: unknown): boolean {
104-
return (schema as { publicSharing?: { enabled?: unknown } } | null | undefined)
105-
?.publicSharing?.enabled === true;
106-
}
107-
10898
/** Internal helper — extract publicSharing policy from an object schema. */
10999
function getPolicy(schema: any): {
110100
enabled: boolean;

packages/runtime/src/domains/share-links.ts

Lines changed: 16 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -36,33 +36,26 @@
3636
*/
3737

3838
import { SHARE_LINK_SERVICE } from '@objectstack/spec/contracts';
39+
// [#14637 -> #14935] The standing `publicSharing.enabled` switch, read through
40+
// the ONE predicate the package that DECLARES the key exports. This file used
41+
// to carry a documented MIRROR of `isPublicSharingEnabled` from
42+
// `plugin-sharing/src/share-link-service.ts`, copied rather than imported
43+
// because `@objectstack/plugin-sharing` is a **dev** dependency here and
44+
// importing it would invert the dependency direction. That reasoning held only
45+
// for that home: `@objectstack/spec` is a runtime dependency of this package
46+
// AND of the plugin, so moving the predicate beside the schema removes the copy
47+
// without adding an edge. Behaviour is unchanged, fail-closed included — an
48+
// absent block, an absent schema, and an engine that cannot answer `getSchema`
49+
// remain one answer, `false` — and the pins that held the two spellings equal
50+
// (`share-links-enforcement-context.test.ts` here,
51+
// `share-link-eligibility.test.ts` on the other side) are unchanged too: they
52+
// assert the same observable answer on both surfaces, which is what proves the
53+
// de-duplication did not move the behaviour.
54+
import { isPublicSharingEnabled } from '@objectstack/spec/data';
3955

4056
import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js';
4157
import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js';
4258

43-
/**
44-
* [#14637] Is `publicSharing` switched ON for this object schema?
45-
*
46-
* A deliberate MIRROR of `isPublicSharingEnabled` in
47-
* `plugin-sharing/src/share-link-service.ts`, which is the canonical
48-
* definition and the one `resolveToken`'s own gate reads. It is copied rather
49-
* than imported because `@objectstack/plugin-sharing` is a **dev** dependency
50-
* of this package: importing it here would invert the dependency direction to
51-
* make one boolean read shared. The two spellings are held equal by the pins
52-
* in `share-links-enforcement-context.test.ts` on this side and
53-
* `share-link-eligibility.test.ts` on the other, which assert the SAME
54-
* observable answer on both surfaces rather than trusting the copy.
55-
*
56-
* An absent block, an absent schema, and an engine that cannot answer
57-
* `getSchema` at all are one answer: `false`. `enabled` defaults to off, so a
58-
* surface that cannot read the policy must refuse rather than answer from the
59-
* token row.
60-
*/
61-
function isPublicSharingEnabled(schema: unknown): boolean {
62-
return (schema as { publicSharing?: { enabled?: unknown } } | null | undefined)
63-
?.publicSharing?.enabled === true;
64-
}
65-
6659
export function createShareLinksDomain(deps: DomainHandlerDeps): DomainRoute {
6760
return {
6861
prefix: '/share-links',

packages/spec/api-surface/data.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -740,6 +740,7 @@
740740
"isNowDefaultToken (function)",
741741
"isOrganizationUnique (function)",
742742
"isPlainRecord (function)",
743+
"isPublicSharingEnabled (function)",
743744
"isRuntimeDefaultToken (function)",
744745
"isTenancyDisabled (function)",
745746
"isTextFilterOperator (function)",

packages/spec/export-origins/data.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -740,6 +740,7 @@
740740
"isNowDefaultToken": "src/data/default-value-tokens.ts#isNowDefaultToken (function)",
741741
"isOrganizationUnique": "src/data/field.zod.ts#isOrganizationUnique (function)",
742742
"isPlainRecord": "src/data/authoring-key-lint.ts#isPlainRecord (function)",
743+
"isPublicSharingEnabled": "src/data/object.zod.ts#isPublicSharingEnabled (function)",
743744
"isRuntimeDefaultToken": "src/data/default-value-tokens.ts#isRuntimeDefaultToken (function)",
744745
"isTenancyDisabled": "src/data/object.zod.ts#isTenancyDisabled (function)",
745746
"isTextFilterOperator": "src/data/filter-text-operator-declared-type.ts#isTextFilterOperator (function)",

packages/spec/src/data/object.test.ts

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest';
66
// `activities`, … and the annotation stops being a contract check at all. This
77
// only became visible when tsconfig.test.json put these files in front of tsc
88
// (#5286).
9-
import { ObjectSchema, ObjectCapabilities, IndexSchema, ObjectFieldGroupSchema, ObjectExternalBindingSchema, ObjectAccessConfigSchema, LifecycleSchema, TenancyConfigSchema, isTenancyDisabled, resolveCrudAffordances, type ServiceObject } from './object.zod';
9+
import { ObjectSchema, ObjectCapabilities, IndexSchema, ObjectFieldGroupSchema, ObjectExternalBindingSchema, ObjectAccessConfigSchema, LifecycleSchema, TenancyConfigSchema, isTenancyDisabled, isPublicSharingEnabled, resolveCrudAffordances, type ServiceObject } from './object.zod';
1010
import { resolveInjectedSystemColumns } from './injected-system-columns';
1111
import { Field } from './field.zod';
1212
import type { StateMachineValidation } from './validation.zod';
@@ -2101,6 +2101,75 @@ describe('isTenancyDisabled — platform-global posture predicate (#3249, ADR-00
21012101
});
21022102
});
21032103

2104+
/**
2105+
* [#14935] `isPublicSharingEnabled` — the canonical read of the standing
2106+
* share-link switch, exported beside the `publicSharing` declaration.
2107+
*
2108+
* It replaces two spellings: this predicate was private to
2109+
* `plugin-sharing/src/share-link-service.ts` (#14637) and `@objectstack/runtime`
2110+
* carried a documented MIRROR of it for its `/share-links` dispatcher domain.
2111+
* Those two surfaces keep their own behavioural pins — `share-link-eligibility`
2112+
* and `share-links-enforcement-context`, which assert the same observable
2113+
* answer on both surfaces. What is pinned HERE is the predicate's own contract,
2114+
* which those tests can only observe indirectly: fail-CLOSED, with the three
2115+
* unreadable cases collapsing to ONE answer.
2116+
*/
2117+
describe('isPublicSharingEnabled — standing share-link policy predicate (#14935, #14637)', () => {
2118+
it('is true only for an explicit publicSharing.enabled === true', () => {
2119+
expect(isPublicSharingEnabled({ name: 'article', publicSharing: { enabled: true } })).toBe(true);
2120+
expect(isPublicSharingEnabled({ name: 'article', publicSharing: { enabled: false } })).toBe(false);
2121+
});
2122+
2123+
it('is false when the block, or the key, is absent — `enabled` defaults to OFF', () => {
2124+
expect(isPublicSharingEnabled({ name: 'article', fields: { title: { type: 'text' } } })).toBe(false);
2125+
expect(isPublicSharingEnabled({ name: 'article', publicSharing: {} })).toBe(false);
2126+
expect(isPublicSharingEnabled({ name: 'article', publicSharing: { allowedAudiences: ['link_only'] } })).toBe(false);
2127+
});
2128+
2129+
it('collapses the three unreadable cases to ONE answer, false', () => {
2130+
// An absent block, an absent schema, and an engine that cannot answer
2131+
// `getSchema` at all (`engine.getSchema?.(name)` -> undefined). A surface
2132+
// that cannot read the policy must refuse rather than answer from the
2133+
// share-link row: a distinguishable "sharing is off for this object" is an
2134+
// existence oracle for a caller holding nothing but a token.
2135+
const unreadable = [{ name: 'article' }, undefined, null];
2136+
for (const schema of unreadable) expect(isPublicSharingEnabled(schema)).toBe(false);
2137+
expect(new Set(unreadable.map(isPublicSharingEnabled)).size).toBe(1);
2138+
});
2139+
2140+
it('refuses a truthy non-boolean — only the boolean true publishes', () => {
2141+
// Nothing that reaches this predicate is guaranteed to have been through
2142+
// `ObjectSchema`: the runtime probe reads whatever the engine's schema
2143+
// registry holds. `=== true` is what keeps a stored `'true'` from
2144+
// publishing records.
2145+
for (const enabled of ['true', 1, {}, [], 'yes'] as unknown[]) {
2146+
expect(isPublicSharingEnabled({ name: 'article', publicSharing: { enabled } })).toBe(false);
2147+
}
2148+
});
2149+
2150+
it('tolerates null/undefined/non-object schemas', () => {
2151+
expect(isPublicSharingEnabled(undefined)).toBe(false);
2152+
expect(isPublicSharingEnabled(null)).toBe(false);
2153+
expect(isPublicSharingEnabled('article')).toBe(false);
2154+
expect(isPublicSharingEnabled(42)).toBe(false);
2155+
});
2156+
2157+
it('agrees with the schema it reads — the parsed default is OFF', () => {
2158+
const parsed = ObjectSchema.parse({
2159+
name: 'article',
2160+
fields: { title: { type: 'text' } },
2161+
publicSharing: { allowedAudiences: ['link_only'] },
2162+
});
2163+
expect(parsed.publicSharing?.enabled).toBe(false);
2164+
expect(isPublicSharingEnabled(parsed)).toBe(false);
2165+
expect(isPublicSharingEnabled(ObjectSchema.parse({
2166+
name: 'article',
2167+
fields: { title: { type: 'text' } },
2168+
publicSharing: { enabled: true },
2169+
}))).toBe(true);
2170+
});
2171+
});
2172+
21042173
describe('userActions row predicates + resolveCrudAffordances (objectui#2614)', () => {
21052174
it('accepts the plain boolean form unchanged (back-compat)', () => {
21062175
const obj = ObjectSchema.parse({

packages/spec/src/data/object.zod.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2204,6 +2204,8 @@ const ObjectSchemaBase = strictObject(
22042204
* provided, the plugin allows `link_only` audience + `view` permission
22052205
* (the safest combination — caller still needs the URL to access).
22062206
*
2207+
* @see {@link isPublicSharingEnabled} — the ONE reading of `enabled`,
2208+
* exported below beside this declaration.
22072209
* @see packages/plugins/plugin-sharing/src/share-link-service.ts
22082210
*/
22092211
publicSharing: strictObject({
@@ -2323,6 +2325,37 @@ const ObjectSchemaBase = strictObject(
23232325
...MetadataProtectionFields,
23242326
});
23252327

2328+
/**
2329+
* [#14935] Is `publicSharing` switched ON for this object schema?
2330+
*
2331+
* The ONE reading of the standing switch declared in the `publicSharing` block
2332+
* above, exported here beside the declaration so that every surface gating on
2333+
* it asks the same question. Two packages read it today — the share-link
2334+
* service and the route probe above it (`@objectstack/plugin-sharing`), and the
2335+
* `/share-links` dispatcher domain (`@objectstack/runtime`) — and the second
2336+
* carried a documented copy of this expression, because the plugin is only a
2337+
* DEV dependency of the runtime. That copy was never structurally forced: both
2338+
* packages already depend on THIS one, so the shared home existed all along.
2339+
* One policy read spelled twice, held equal by a comment and by two pins, is a
2340+
* contract defect even while the two spellings agree.
2341+
*
2342+
* Fail-CLOSED, and the three unreadable cases are ONE answer, `false`: an absent
2343+
* `publicSharing` block, an absent schema, and an engine that cannot answer
2344+
* `getSchema` at all. `enabled` defaults to off, so a surface that cannot read
2345+
* the policy must refuse rather than answer from the share-link row — a
2346+
* distinguishable "sharing is off for this object" is an existence oracle for a
2347+
* caller holding nothing but a token. Only the boolean `true` enables: the
2348+
* strict comparison is deliberate, so a truthy `'true'` or `1` that never went
2349+
* through this schema does not publish records.
2350+
*
2351+
* The same shape as {@link isTenancyDisabled} — an object posture the spec owns
2352+
* precisely because more than one package must not re-derive it independently.
2353+
*/
2354+
export function isPublicSharingEnabled(schema: unknown): boolean {
2355+
return (schema as { publicSharing?: { enabled?: unknown } } | null | undefined)
2356+
?.publicSharing?.enabled === true;
2357+
}
2358+
23262359
/**
23272360
* Converts a snake_case name to a human-readable Title Case label.
23282361
* @example snakeCaseToLabel('project_task') → 'Project Task'

0 commit comments

Comments
 (0)