Skip to content

Commit 4db3c61

Browse files
claude[bot]zhuangjianguoclaude
authored
refactor(spec,plugin-sharing,runtime): one canonical predicate for publicSharing.enabled (#16029)
* 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 * docs(permissions): re-anchor row 37's five citations after the -10 line shift `check-system-context-census` red on `1f294f94a`: this PR's edit to `plugin-sharing/src/share-link-service.ts` is +10 above line 45 and -20 above line 108, so every line below shifts by exactly -10, and row 37 of `content/docs/permissions/system-context.mdx` cites five of them by number. All five are PURE LINE ROT, checked per anchor with the file open rather than assumed: the text now at 459 / 513 / 517 / 590 / 620 is byte-identical to the text that was at 469 / 523 / 527 / 600 / 630 at the merge base, and the diff's two hunks both end above line 108 — no line at or below the first anchor was touched at all. Row 37's claim ("share-link policy `enabled` check bypassed; system callers re-enter under a system context") is unchanged and still described by the same five `context.isSystem` reads, so no ledger row with a needle is owed here. Repaired with `node scripts/check-system-context-census.mjs --fix`, which rewrote exactly those five numbers and no prose. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ * chore: regenerate the system-context census from the merged tree `content/docs/permissions/system-context.mdx` is an os-regen artifact and both sides moved it since `f7db8f4fd`: this branch re-anchored row 37 after its own -10 line shift, and `origin/main` carried the census rows that #15996's share-link admission seam moved. The driver merges such a path with exit 0 while silently keeping ONE side, so it is regenerated from the merged tree rather than resolved by picking a side or by hand. `pnpm gen:system-context-census` re-derives BOTH sides at once, which is visible in the result: row 37 keeps this branch's anchors (`share-link-service.ts:459`, `:513`, `:517`, `:590`, `:620`) while main's shifts land beside them (`sharing-plugin.ts:1088` becomes `:1189`, `rest-server.ts:1553`/`:1582`/`:1585` become `:1565`/`:1594`/`:1597`, `domains/packages.ts:422` becomes `:535`, and row 50's five `rest-server.ts` anchors move). No prose changed — only anchors. Landed through `scripts/pm/os-regen-merge.sh`: merge first, then regenerate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --------- Co-authored-by: Claude <zhuangjianguo@steedos.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 281bf0d commit 4db3c61

9 files changed

Lines changed: 152 additions & 48 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`.

content/docs/permissions/system-context.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ The largest single consumer — **17 of the 105 sites**.
135135
| 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`) |
136136
| 35 | `listShares()` skips the management gate | Get: full enumeration of who can see a record | `plugin-sharing/src/sharing-service.ts:1528` |
137137
| 36 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `sharing-plugin.ts:1189` |
138-
| 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` |
138+
| 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` |
139139
| 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` |
140140
| 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:278`, `:503` |
141141

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
@@ -2243,6 +2243,8 @@ const ObjectSchemaBase = strictObject(
22432243
* provided, the plugin allows `link_only` audience + `view` permission
22442244
* (the safest combination — caller still needs the URL to access).
22452245
*
2246+
* @see {@link isPublicSharingEnabled} — the ONE reading of `enabled`,
2247+
* exported below beside this declaration.
22462248
* @see packages/plugins/plugin-sharing/src/share-link-service.ts
22472249
*/
22482250
publicSharing: strictObject({
@@ -2368,6 +2370,37 @@ const ObjectSchemaBase = strictObject(
23682370
refuseForeignTreeReference(object.name, object.fields, ctx);
23692371
});
23702372

2373+
/**
2374+
* [#14935] Is `publicSharing` switched ON for this object schema?
2375+
*
2376+
* The ONE reading of the standing switch declared in the `publicSharing` block
2377+
* above, exported here beside the declaration so that every surface gating on
2378+
* it asks the same question. Two packages read it today — the share-link
2379+
* service and the route probe above it (`@objectstack/plugin-sharing`), and the
2380+
* `/share-links` dispatcher domain (`@objectstack/runtime`) — and the second
2381+
* carried a documented copy of this expression, because the plugin is only a
2382+
* DEV dependency of the runtime. That copy was never structurally forced: both
2383+
* packages already depend on THIS one, so the shared home existed all along.
2384+
* One policy read spelled twice, held equal by a comment and by two pins, is a
2385+
* contract defect even while the two spellings agree.
2386+
*
2387+
* Fail-CLOSED, and the three unreadable cases are ONE answer, `false`: an absent
2388+
* `publicSharing` block, an absent schema, and an engine that cannot answer
2389+
* `getSchema` at all. `enabled` defaults to off, so a surface that cannot read
2390+
* the policy must refuse rather than answer from the share-link row — a
2391+
* distinguishable "sharing is off for this object" is an existence oracle for a
2392+
* caller holding nothing but a token. Only the boolean `true` enables: the
2393+
* strict comparison is deliberate, so a truthy `'true'` or `1` that never went
2394+
* through this schema does not publish records.
2395+
*
2396+
* The same shape as {@link isTenancyDisabled} — an object posture the spec owns
2397+
* precisely because more than one package must not re-derive it independently.
2398+
*/
2399+
export function isPublicSharingEnabled(schema: unknown): boolean {
2400+
return (schema as { publicSharing?: { enabled?: unknown } } | null | undefined)
2401+
?.publicSharing?.enabled === true;
2402+
}
2403+
23712404
/**
23722405
* Converts a snake_case name to a human-readable Title Case label.
23732406
* @example snakeCaseToLabel('project_task') → 'Project Task'

0 commit comments

Comments
 (0)