Skip to content

Commit fd4e073

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-24284-search-empty-set-refused
2 parents bead2f2 + 52ff308 commit fd4e073

22 files changed

Lines changed: 1263 additions & 854 deletions
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/rest': patch
3+
---
4+
5+
The `/meta` history family routes live in their own module, with no route, handler or answer changed
6+
7+
Clause-②: no (a move: the five routes per base, their place in the `/meta` registration order, every handler, every response and the anonymous-deny gate in front of them are unchanged, and the published declarations are byte-identical)
8+
9+
- `RestServer#registerMetadataEndpointsInner` keeps its name, docblock, place and head, its per-item registrar included. The five statements that register `GET {metaPath}/:type/:name/history` (the change log), `GET …/audit` (the audit trail), `POST …/publish` (the promotion of a pending draft), `POST …/rollback` (the restore of an earlier version) and `GET …/diff` (the comparison of two stored versions) moved, re-indented, to `packages/rest/src/meta-item-history-routes.ts`, and the method calls them at their old position, inside the umbrella's guarded registration. They still register through that per-item registrar, so the `metadata.endpoints.item` switch gates the same mounts.
10+
- Nothing to do for a consumer: no export of the package entry, no route, no configuration key and no response moved.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): register four organization and invitation-placement refusal codes under `@objectstack/plugin-auth`
6+
7+
Clause-②: yes (widening)
8+
9+
The error-code ledger (`ERROR_CODE_LEDGER`, and so `ErrorCode`) gains four `@objectstack/plugin-auth` codes. Each names a hook refusal that today answers a bare `403 FORBIDDEN` with a message and no `code`, so a client such as the console cannot tell it apart from any other refusal on the same endpoint:
10+
11+
- `ORGANIZATION_CREATION_DISABLED`: creating an organization is refused for every caller, because the deployment's effective tenancy posture enforces no organization wall (`/auth/config` reports `features.multiOrgEnabled: false`).
12+
- `ORGANIZATION_SLUG_LOCKED_BY_ACTIVE_ENVIRONMENTS`: an organization's slug cannot change while the organization has an environment whose status is neither archived nor failed.
13+
- `INVITE_PLACEMENT_UNSUPPORTED`: fail-closed. The invitation carries a placement (a business unit and positions), and this deployment has no delegated-administration runtime to authorize it.
14+
- `INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE`: the delegated-administration gate refused the invitation's placement, because the business unit or a position lies outside the issuer's delegated scope.
15+
16+
Registration only: `@objectstack/plugin-auth` still throws these refusals without a code until #24243 stamps them. The same change names a standard code, which needs no ledger row, for the plugin's other four code-less refusals: `RATE_LIMIT_EXCEEDED` for both phone-OTP send walls, which stay indistinguishable; `SERVICE_UNAVAILABLE` for the slug guard's read fault; and `VALIDATION_ERROR` for the reserved `__global__` organization id or slug. No existing code changes, and no standard-catalog member is shadowed (none needs a synonym waiver).

‎content/docs/permissions/system-context.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ One of the largest consumers of the flag;
181181
|:--|:---|:---|:---|:---|
182182
| 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes. The gate is `refuseUnexposedDataAction`, which `callData` asks first and the `/data` domain asks before its request gate; `callData` reads the same bypass once more for each nested-relation condition's target | `packages/runtime/src/action-execution.ts#callData`, `#refuseUnexposedDataAction` |
183183
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `packages/runtime/src/action-execution.ts#actionPermissionError` |
184-
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability (the permission-set clone door's save included) — and, because REST's save door and the stored-version read doors ask one save verdict, an app's full stored version on `/layers`, `?layers=true` and `/diff`, whatever its entry gates withhold | `packages/runtime/src/domains/meta.ts#handleMetadataRequest`, `packages/rest/src/rest-server.ts#registerMetadataEndpointsInner`, `packages/rest/src/meta-collection-routes.ts#registerMetaCollectionRoutes`, `packages/rest/src/meta-item-write-routes.ts#registerMetaItemWriteRoutes`, `packages/rest/src/rest-server.ts#metaSaveVerdict`, `packages/runtime/src/domains/permission-set-clone.ts#handlePermissionSetCloneWrite` |
184+
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability (the permission-set clone door's save included) — and, because REST's save door and the stored-version read doors ask one save verdict, an app's full stored version on `/layers`, `?layers=true` and `/diff`, whatever its entry gates withhold | `packages/runtime/src/domains/meta.ts#handleMetadataRequest`, `packages/rest/src/meta-collection-routes.ts#registerMetaCollectionRoutes`, `packages/rest/src/meta-item-write-routes.ts#registerMetaItemWriteRoutes`, `packages/rest/src/meta-item-history-routes.ts#registerMetaItemHistoryRoutes`, `packages/rest/src/rest-server.ts#metaSaveVerdict`, `packages/runtime/src/domains/permission-set-clone.ts#handlePermissionSetCloneWrite` |
185185
| 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `packages/metadata-core/src/meta-write-capability.ts#metaWriteCapabilityVerdict` |
186186
| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `packages/runtime/src/domains/actions.ts#handleActionsRequest`, `packages/runtime/src/domains/ai.ts#handleAIRequest`, `packages/runtime/src/domains/automation.ts#handleAutomationRequest`, `packages/runtime/src/domains/meta.ts#handleMetadataRequest`, `packages/runtime/src/domains/security.ts#handleSecurityRequest`, `packages/runtime/src/domains/packages.ts#handlePackagesRequest`, `packages/runtime/src/domains/analytics.ts#handleAnalyticsRequest`, `packages/runtime/src/domains/i18n.ts#handleI18nRequest`, `packages/rest/src/external-datasource-routes.ts#registerExternalDatasourceRoutes`, `packages/rest/src/package-routes.ts#refusePackageRequest` |
187187
| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `packages/runtime/src/domains/mcp.ts#handleMcpRequest` |

‎content/docs/references/api/contract.mdx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const result = ApiErrorSchema.parse(data);
2828

2929
| Property | Type | Required | Description |
3030
| :--- | :--- | :--- | :--- |
31-
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +335 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
31+
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +339 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
3232
| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) |
3333
| **message** | `string` | ✅ | Readable error message |
3434
| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. |
@@ -221,6 +221,8 @@ const result = ApiErrorSchema.parse(data);
221221
* `INVITE_EMAIL_FAILED`
222222
* `INVITE_ISSUER_MEMBER_ONLY`
223223
* `INVITE_ISSUER_UNVERIFIED`
224+
* `INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE`
225+
* `INVITE_PLACEMENT_UNSUPPORTED`
224226
* `INVITE_REQUIRES_EMAIL`
225227
* `INVITE_ROLE_ABOVE_ISSUER`
226228
* `INVITE_SMS_FAILED`
@@ -268,7 +270,9 @@ const result = ApiErrorSchema.parse(data);
268270
* `OBJECT_OWNERSHIP_CONFLICT`
269271
* `OBJECT_PACKAGE_DISABLED`
270272
* `OPENAPI_UNAVAILABLE`
273+
* `ORGANIZATION_CREATION_DISABLED`
271274
* `ORGANIZATION_OWNERSHIP_MIGRATION_REQUIRED`
275+
* `ORGANIZATION_SLUG_LOCKED_BY_ACTIVE_ENVIRONMENTS`
272276
* `OS_PROTOCOL_INCOMPATIBLE`
273277
* `PACKAGE_DELETE_FAILED`
274278
* `PACKAGE_DELETE_PARTIAL`

‎content/docs/references/api/error-code-ledger.mdx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,8 @@ const result = ErrorCode.parse(data);
388388
* `INVITE_EMAIL_FAILED`
389389
* `INVITE_ISSUER_MEMBER_ONLY`
390390
* `INVITE_ISSUER_UNVERIFIED`
391+
* `INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE`
392+
* `INVITE_PLACEMENT_UNSUPPORTED`
391393
* `INVITE_REQUIRES_EMAIL`
392394
* `INVITE_ROLE_ABOVE_ISSUER`
393395
* `INVITE_SMS_FAILED`
@@ -435,7 +437,9 @@ const result = ErrorCode.parse(data);
435437
* `OBJECT_OWNERSHIP_CONFLICT`
436438
* `OBJECT_PACKAGE_DISABLED`
437439
* `OPENAPI_UNAVAILABLE`
440+
* `ORGANIZATION_CREATION_DISABLED`
438441
* `ORGANIZATION_OWNERSHIP_MIGRATION_REQUIRED`
442+
* `ORGANIZATION_SLUG_LOCKED_BY_ACTIVE_ENVIRONMENTS`
439443
* `OS_PROTOCOL_INCOMPATIBLE`
440444
* `PACKAGE_DELETE_FAILED`
441445
* `PACKAGE_DELETE_PARTIAL`

‎docs/qa/platform-checklist/areas/studio-authoring.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -456,7 +456,7 @@
456456
"packages/spec/src/api/protocol.zod.ts#SaveMetaItemResponse (SaveMetaItemResponse: state draft|active, version as If-Match/409 OCC token, 'staged only — not served until published')",
457457
"packages/runtime/src/domains/packages.ts#packages (POST /packages/:id/publish-drafts, ADR-0033/ADR-0045 visibility flip + its failure warning)",
458458
"packages/objectql/src/protocol-publish-package-drafts.test.ts (atomic namespace gate; #4463 author-time rules gate the draft→active transition)",
459-
"packages/rest/src/rest-server.ts#toVersion (GET /meta/:type/:name/{history,audit,diff} + POST .../rollback: overlay-type history vs { events: [] } for non-overlay; rollback body { toVersion }, 400 on a missing/invalid toVersion; actor from resolveMetaWriteActor — the authenticated session identity ONLY, [#7941] — X-Actor is ignored outright, not read as a fallback either)",
459+
"packages/rest/src/meta-item-history-routes.ts#registerMetaItemHistoryRoutes (GET /meta/:type/:name/{history,audit,diff} + POST .../rollback: overlay-type history vs { events: [] } for non-overlay; rollback body { toVersion }, 400 on a missing/invalid toVersion; actor from resolveMetaWriteActor — the authenticated session identity ONLY, [#7941] — X-Actor is ignored outright, not read as a fallback either)",
460460
"packages/rest/src/meta-write-actor-identity.test.ts#Actor (unit pin: an explicit X-Actor does NOT outrank the authenticated identity, and is inert on the machine-write path too)",
461461
"packages/rest/src/rest-route-ledger.ts#getHistory (client bindings meta.getHistory / getAudit / diffItem / rollbackItem; the 'dispatcher /meta swallows /history as a compound name and 404s' note — routes hunter #12)",
462462
"docs/audits/2026-07-studio-package-create-ux-dogfood.md ('Unpublished draft' badge, 'Changes · n', 'one atomic release' toast)"

‎packages/qa/dogfood/test/authz-conformance.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1637,7 +1637,8 @@ describe('#9084 — the rest-server.ts transport tripwire keys off mechanics', (
16371637
// [#23050 slice 13a] Length 13 → 14 (`meta-collection-routes.ts`), still 0 over it.
16381638
// [#23050 slice 13b] Length 14 → 15 (`meta-item-read-routes.ts`), still 0 over it.
16391639
// [#23050 slice 13c] Length 15 → 16 (`meta-item-write-routes.ts`), still 0 over it.
1640-
expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(16);
1640+
// [#23050 slice 13d] Length 16 → 17 (`meta-item-history-routes.ts`), still 0 over it.
1641+
expect(REST_SERVER_REGISTRAR_FILES).toHaveLength(17);
16411642
expect(REST_SERVER_REGISTRAR_PATHS[0]).toBe(REST_SERVER);
16421643
const listed = REST_SERVER_REGISTRAR_PATHS.map((file) => readFileSync(join(REPO_ROOT, file), 'utf8'));
16431644
for (const probe of [MECHANICS, ROUTES]) {

‎packages/qa/dogfood/test/authz-probe-blind-spot.census.ts‎

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -541,17 +541,17 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [
541541
'`data-action-routes.ts`, `batch-routes.ts`, `approvals-routes.ts`, `sharing-routes.ts`, ' +
542542
'`analytics-routes.ts`, `security-routes.ts`, `discovery-routes.ts`, `email-routes.ts`, ' +
543543
'`search-routes.ts`, `ui-routes.ts`, `form-routes.ts`, `crud-routes.ts`, `meta-collection-routes.ts`, ' +
544-
'`meta-item-read-routes.ts` and `meta-item-write-routes.ts`, ' +
544+
'`meta-item-read-routes.ts`, `meta-item-write-routes.ts` and `meta-item-history-routes.ts`, ' +
545545
'the modules the bodies of ' +
546546
'`registerDataActionEndpoints`, `registerBatchEndpoints`, `registerApprovalsEndpoints`, ' +
547547
'`registerSharingEndpoints`, `registerSharingRuleEndpoints`, `registerAnalyticsEndpoints`, ' +
548548
'`registerSecurityExplainEndpoints`, `registerSecurityEndpoints`, `registerDiscoveryEndpoints`, ' +
549549
'`registerOpenApiEndpoints`, `registerEmailEndpoints`, `registerSearchEndpoints`, ' +
550-
'`registerUiEndpoints`, `registerFormEndpoints` and `registerCrudEndpoints`, and the first five `/meta` ' +
550+
'`registerUiEndpoints`, `registerFormEndpoints` and `registerCrudEndpoints`, and the first six `/meta` ' +
551551
'groups of `registerMetadataEndpointsInner`, moved to; ' +
552552
'reachable = those inside registerMetadataEndpoints, PLUS every site in the registrar list\'s named `/meta` ' +
553-
'subset (`REST_SERVER_META_ROUTE_FILES`: `meta-collection-routes.ts`, `meta-item-read-routes.ts` and ' +
554-
'`meta-item-write-routes.ts`), whose ' +
553+
'subset (`REST_SERVER_META_ROUTE_FILES`: `meta-collection-routes.ts`, `meta-item-read-routes.ts`, ' +
554+
'`meta-item-write-routes.ts` and `meta-item-history-routes.ts`), whose ' +
555555
'registrations the inner method ' +
556556
'runs inside the umbrella\'s swap',
557557
// [#15542 / #15854] ⭐ THE POPULATION RULE LEARNED A SECOND SPELLING, and
@@ -891,10 +891,29 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [
891891
// spells it nowhere), `private register*Endpoints(` stays 16,
892892
// `const registerPerItemRoute =` stays 1 and `enforceAuth` stays 112: the
893893
// moved group spells none of them, and neither does the seam.
894+
// [#23050 slice 13d] 70 / 19 / 51 UNCHANGED, `reachable` held by 13a's rule
895+
// and every control keyed as 13c keyed them, with no rule change. The sixth
896+
// `/meta` group, the history family (`/history`, `/audit`, `/publish`,
897+
// `/rollback` and `/diff`, five helper-routed registration sites, REACHABLE
898+
// through the same umbrella key), moved to `meta-item-history-routes.ts`,
899+
// which joins the list (length 16 -> 17) and the named `/meta` subset
900+
// (3 -> 4). Like the per-item writes' module it has NO direct site, so its
901+
// own control counts `registerPerItemRoute(` (5), and the whole-population
902+
// `registerPerItemRoute(` control stays 8 over this file plus every module on
903+
// the list (1 here, the published read's, + 2 + 5); over this file alone it
904+
// would now read 1. The subset's length and the module calls inside the
905+
// inner method move together (both 4). Measured at the move commit, before
906+
// the list learned the module: population 65, the five sites out of reach,
907+
// and the `.call(` control at 4 against a subset of 3.
908+
// `this.routeManager.register(` stays 63 over the seventeen files (the module
909+
// spells it nowhere), `private register*Endpoints(` stays 16,
910+
// `const registerPerItemRoute =` stays 1, `forwarder slice:` stays 1 and
911+
// `enforceAuth` stays 112: the moved group spells none of them, and neither
912+
// does the seam.
894913
controls: {
895914
'private register*Endpoints(': 16,
896915
'this.routeManager.register(': 63,
897-
'registrar list length': 16,
916+
'registrar list length': 17,
898917
'data-action-routes.ts: this.routeManager.register(': 9,
899918
'batch-routes.ts: this.routeManager.register(': 5,
900919
'approvals-routes.ts: this.routeManager.register(': 7,
@@ -912,6 +931,8 @@ export const PROBE_FILE_CENSUS: readonly ProbeFileReading[] = [
912931
// [#23050 slice 13c] Controlled on the helper's call spelling: this module
913932
// has no direct site (see above).
914933
'meta-item-write-routes.ts: registerPerItemRoute(': 2,
934+
// [#23050 slice 13d] The same: the history family's module has no direct site.
935+
'meta-item-history-routes.ts: registerPerItemRoute(': 5,
915936
// [#23050 slice 13a] Recorded AS the subset's length, not as a literal: the
916937
// measured side is the module calls inside the inner method, so the two
917938
// can only move together.

‎packages/rest/src/execctx-consumer-census.test.ts‎

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,13 @@ const SOURCE = readFileSync(resolve(HERE, 'rest-server.ts'), 'utf8');
133133
* [#23050 slice 13c] …and the fifth, the per-item writes, to
134134
* `meta-item-write-routes.ts`, with their two: the item save's and the reset's,
135135
* each locally caught on its invocation line, as before.
136+
*
137+
* [#23050 slice 13d] …and the sixth, the history family, to
138+
* `meta-item-history-routes.ts`, with its five, each locally caught as before:
139+
* the audit trail's, the publish's and the rollback's on their invocation
140+
* lines, the change log's and the comparison's on their continuation lines —
141+
* two of §7's and §8's four continuation sites, which those sections find there
142+
* because they read every file here.
136143
*/
137144
const SITE_FILES = REST_SERVER_REGISTRAR_FILES;
138145
const SOURCES = new Map<string, string>(SITE_FILES.map((file) => [
@@ -150,7 +157,7 @@ const siteKey = (file: string, line: number) => `${file}:${line}`;
150157
* `registerSearchEndpoints`, `registerUiEndpoints`,
151158
* `registerCrudEndpoints`) hands its moved body — and each one
152159
* `registerMetadataEndpointsInner` builds for a moved `/meta` group's module
153-
* (#23050 slices 13a, 13b and 13c), counted as that module's registrar —
160+
* (#23050 slices 13a, 13b, 13c and 13d), counted as that module's registrar —
154161
* forwards each member to the server with `(...a) => this.<member>(...a)`, so
155162
* a moved site's stack shows
156163
* that forwarder above the site itself. The site is the first censused frame
@@ -782,9 +789,22 @@ describe('[#13160] §2 the consumer surface, counted from the tree', () => {
782789
// one forwarder's two mentions in). Sixteen forwarders now: (16 - 1) + 3 - 2,
783790
// the module carrying one host literal that reads the seam, so neither map
784791
// gains an entry.
785-
expect(SITE_FILES).toHaveLength(16);
792+
//
793+
// [#23050 slice 13d] 62 sites UNCHANGED / 141 → 145 mentions — the same MOVE.
794+
// The sixth `/meta` group, the history family, left for
795+
// `meta-item-history-routes.ts` with its five LOCALLY CAUGHT sites (the audit
796+
// trail's, the publish's and the rollback's on their invocation lines, the
797+
// change log's and the comparison's on their continuation lines, as before):
798+
// 5 + 9 + 5 + 7 + 8 + 1 + 5 + 2 + 1 + 1 + 1 + 0 + 6 + 3 + 1 + 2 + 5 = 62 sites.
799+
// The four new mentions are this slice's seam, the two spellings once (the
800+
// fourth host literal the inner method builds, its forwarder, two; the host
801+
// interface's member, two): `rest-server.ts` goes 55 → 52 (the five sites
802+
// out, one forwarder's two mentions in). Seventeen forwarders now:
803+
// (17 - 1) + 3 - 2, the module carrying one host literal that reads the seam,
804+
// so neither map gains an entry.
805+
expect(SITE_FILES).toHaveLength(17);
786806
expect(SITES.length).toBe(62);
787-
expect([...SOURCES.values()].reduce((n, text) => n + text.split('resolveExecCtx').length - 1, 0)).toBe(141);
807+
expect([...SOURCES.values()].reduce((n, text) => n + text.split('resolveExecCtx').length - 1, 0)).toBe(145);
788808
const extraMovedRegistrars = Object.values(EXTRA_MOVED_REGISTRARS).reduce((n, k) => n + (k ?? 0), 0);
789809
expect(extraMovedRegistrars).toBe(3);
790810
const registrarsReadingNoExecCtx = Object.values(MOVED_REGISTRARS_READING_NO_EXECCTX).reduce((n, k) => n + (k ?? 0), 0);

0 commit comments

Comments
 (0)