Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/public-sharing-enabled-canonical-predicate.md
Original file line number Diff line number Diff line change
@@ -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`.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

Expand Down
10 changes: 7 additions & 3 deletions packages/plugins/plugin-sharing/src/share-link-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
30 changes: 10 additions & 20 deletions packages/plugins/plugin-sharing/src/share-link-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
39 changes: 16 additions & 23 deletions packages/runtime/src/domains/share-links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions packages/spec/api-surface/data.json
Original file line number Diff line number Diff line change
Expand Up @@ -740,6 +740,7 @@
"isNowDefaultToken (function)",
"isOrganizationUnique (function)",
"isPlainRecord (function)",
"isPublicSharingEnabled (function)",
"isRuntimeDefaultToken (function)",
"isTenancyDisabled (function)",
"isTextFilterOperator (function)",
Expand Down
1 change: 1 addition & 0 deletions packages/spec/export-origins/data.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
71 changes: 70 additions & 1 deletion packages/spec/src/data/object.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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({
Expand Down
33 changes: 33 additions & 0 deletions packages/spec/src/data/object.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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'
Expand Down
Loading