Skip to content

Commit b5cd9e7

Browse files
committed
Merge origin/main into claude/issue-22398-plugin-auth-session-redispatch
Claude-Session: https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ Co-authored-by: Claude <noreply@anthropic.com>
2 parents feea8a8 + 3ca71b6 commit b5cd9e7

7 files changed

Lines changed: 349 additions & 145 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
---
4+
5+
fix(metadata-protocol)!: one reader of `OS_METADATA_WRITABLE` — the legacy `OBJECTSTACK_METADATA_WRITABLE`, removed in 11.0, no longer opens the hatch at the type listing either
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) nothing authorable is removed, renamed or narrowed: no spec key, no metadata spelling, no export and no stored row changes shape, so there is nothing for `os migrate meta` to rewrite and no ledger entry to make. The operator action below is a deployment-environment rename, which the ADR-0087 ledger does not carry; 11.0 already published it, and this change makes the last reader honour it. -->
10+
11+
**BREAKING**, graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N`.
12+
13+
This finishes 11.0's removal at the reader that kept it. The 11.0 release removed ObjectStack's own legacy environment-variable names, `OBJECTSTACK_METADATA_WRITABLE` among them. That change edited one of the two readers of `OS_METADATA_WRITABLE` (the metadata repository's write gate) and missed the other (the protocol's, which feeds `GET /api/v1/meta/types` and the protocol's write gates). So with only the legacy spelling set, the type listing reported a named type as writable (`allowOrgOverride: true`, `overrideSource: 'env'`), while creating a new item of that type answered `403 NOT_CREATABLE`. One deployment gave two answers to one question.
14+
15+
**What changes.** The protocol now reads the setting through the repository's reader, so there is one reader and it reads `OS_METADATA_WRITABLE` only. With only `OBJECTSTACK_METADATA_WRITABLE` set, the hatch is shut everywhere: the listing reports no env override, and every write the hatch would open is refused as it is with nothing set. No deprecation warning is printed for the legacy name any more, because nothing reads it.
16+
17+
**What does not change.** `OS_METADATA_WRITABLE` behaves exactly as before.
18+
19+
**Operator action.** A deployment that still sets `OBJECTSTACK_METADATA_WRITABLE` sets `OS_METADATA_WRITABLE` in its place, with the same comma-separated type list. This is the rename 11.0 already published; nothing else changes.

‎content/docs/deployment/environment-variables.mdx‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -374,7 +374,7 @@ the hosted ObjectOS Cloud control plane.
374374

375375
Some env vars accept a legacy alias for compatibility. **Ecosystem-standard names** (e.g. `DATABASE_URL`, `AUTH_SECRET`, `BETTER_AUTH_*`, `PORT`, `CORS_*`, `MCP_SERVER_*`) are permanently accepted and no longer warn. ObjectStack's own former names are deprecated — prefer the canonical `OS_*`.
376376

377-
> **Removed in 11** (rename required): `OS_MULTI_TENANT` → `OS_MULTI_ORG_ENABLED`, `AUTH_BASE_URL`/`OS_AUTH_BASE_URL` → `OS_AUTH_URL`.
377+
> **Removed in 11** (rename required): `OS_MULTI_TENANT` → `OS_MULTI_ORG_ENABLED`, `OBJECTSTACK_METADATA_WRITABLE` → `OS_METADATA_WRITABLE`, `AUTH_BASE_URL`/`OS_AUTH_BASE_URL` → `OS_AUTH_URL`.
378378
379379
| Canonical | Legacy |
380380
|:---|:---|
@@ -391,6 +391,5 @@ Some env vars accept a legacy alias for compatibility. **Ecosystem-standard name
391391
| `OS_MCP_SERVER_ENABLED` | `MCP_SERVER_ENABLED` |
392392
| `OS_MCP_SERVER_NAME` | `MCP_SERVER_NAME` |
393393
| `OS_MCP_SERVER_TRANSPORT` | `MCP_SERVER_TRANSPORT` |
394-
| `OS_METADATA_WRITABLE` | `OBJECTSTACK_METADATA_WRITABLE` |
395394
| `OS_HOME` | `OBJECTSTACK_HOME` |
396395
| `OS_DEV_CRYPTO_KEY` | `OBJECTSTACK_DEV_CRYPTO_KEY` |

‎packages/metadata-protocol/src/protocol.packaged-base-refusal.test.ts‎

Lines changed: 82 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,13 @@
1515
* deliberately `null` — a name no package ships, a Regime O overlay type,
1616
* and the #6960 delete carve-out. [ADR-0131 D6] The operator hatch is no
1717
* longer one of them: managed content is sealed, so the verdict on an item
18-
* a managed package ships is the same with `OS_METADATA_WRITABLE` (either
19-
* spelling) set or not.
18+
* a managed package ships is the same with `OS_METADATA_WRITABLE` set or
19+
* not. [#22411] The legacy spelling `OBJECTSTACK_METADATA_WRITABLE`, which
20+
* 11.0 removed, is not read at all: the hatch is shut under it.
2021
*
21-
* The registry double serves only `getArtifactItem`, which is all the verdict
22-
* reads; what it returns is what the real `SchemaRegistry` returns for an
22+
* The registry double serves `getArtifactItem`, which is all the verdict
23+
* reads, and `getRegisteredTypes`, which the type listing reads to show the
24+
* hatch shut under the removed spelling; what `getArtifactItem` returns is what the real `SchemaRegistry` returns for an
2325
* artifact a code package registered (`_packageId` stamped, package
2426
* provenance). `@objectstack/objectql` cannot be imported here: it depends on
2527
* this package.
@@ -46,7 +48,10 @@ const ARTIFACTS = new Map<string, Map<string, unknown>>([
4648
]);
4749

4850
function protocolOn(environmentId: string | undefined): ObjectStackProtocolImplementation {
49-
const registry = { getArtifactItem: (type: string, name: string) => ARTIFACTS.get(type)?.get(name) };
51+
const registry = {
52+
getArtifactItem: (type: string, name: string) => ARTIFACTS.get(type)?.get(name),
53+
getRegisteredTypes: () => Array.from(ARTIFACTS.keys()),
54+
};
5055
return new ObjectStackProtocolImplementation({ registry } as never, () => new Map(), environmentId);
5156
}
5257

@@ -120,37 +125,78 @@ describe('packagedBaseRefusal — the /meta door\'s locked-base verdict, handed
120125
.toThrow('registry unreadable');
121126
});
122127

123-
// [ADR-0131 D6] Managed content is sealed: the hatch, under either spelling
124-
// its reader honours, opens neither verb on an item a managed package
125-
// ships — the verdict is the one it gives with the hatch shut, sentence
126-
// included. Every door that asks this verdict (the `/automation` doors, the
127-
// read envelope) inherits that.
128-
for (const variable of ['OS_METADATA_WRITABLE', 'OBJECTSTACK_METADATA_WRITABLE'] as const) {
129-
for (const environmentId of [undefined, 'env_1']) {
130-
it(`${variable}=flow,page does not open a managed item (${environmentId ? 'environment' : 'host-config'} kernel)`, () => {
131-
const shut = protocolOn(environmentId);
132-
const sealed = {
133-
save: shut.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'save' }) as any,
134-
delete: shut.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'delete' }) as any,
135-
page: shut.packagedBaseRefusal({ type: 'page', name: 'pkg_page', operation: 'save' }) as any,
136-
};
137-
process.env[variable] = 'flow,page';
138-
ObjectStackProtocolImplementation.resetEnvWritableCache();
139-
const p = protocolOn(environmentId);
140-
const open = {
141-
save: p.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'save' }) as any,
142-
delete: p.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'delete' }) as any,
143-
page: p.packagedBaseRefusal({ type: 'page', name: 'pkg_page', operation: 'save' }) as any,
144-
};
145-
for (const verb of ['save', 'delete', 'page'] as const) {
146-
expect(shape(open[verb]), verb).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 });
147-
expect(open[verb].message, verb).toBe(sealed[verb].message);
148-
}
149-
// The #6960 carve-out and the regime-O overlay keep their `null`.
150-
expect(p.packagedBaseRefusal({ type: 'page', name: 'pkg_page', operation: 'delete' })).toBeNull();
151-
expect(p.packagedBaseRefusal({ type: 'view', name: 'pkg_view', operation: 'save' })).toBeNull();
152-
});
153-
}
128+
// [ADR-0131 D6] Managed content is sealed: the hatch opens neither verb on
129+
// an item a managed package ships — the verdict is the one it gives with
130+
// the hatch shut, sentence included. Every door that asks this verdict (the
131+
// `/automation` doors, the read envelope) inherits that.
132+
for (const environmentId of [undefined, 'env_1']) {
133+
it(`OS_METADATA_WRITABLE=flow,page does not open a managed item (${environmentId ? 'environment' : 'host-config'} kernel)`, () => {
134+
const shut = protocolOn(environmentId);
135+
const sealed = {
136+
save: shut.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'save' }) as any,
137+
delete: shut.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'delete' }) as any,
138+
page: shut.packagedBaseRefusal({ type: 'page', name: 'pkg_page', operation: 'save' }) as any,
139+
};
140+
process.env.OS_METADATA_WRITABLE = 'flow,page';
141+
ObjectStackProtocolImplementation.resetEnvWritableCache();
142+
const p = protocolOn(environmentId);
143+
const open = {
144+
save: p.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'save' }) as any,
145+
delete: p.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'delete' }) as any,
146+
page: p.packagedBaseRefusal({ type: 'page', name: 'pkg_page', operation: 'save' }) as any,
147+
};
148+
for (const verb of ['save', 'delete', 'page'] as const) {
149+
expect(shape(open[verb]), verb).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 });
150+
expect(open[verb].message, verb).toBe(sealed[verb].message);
151+
}
152+
// The #6960 carve-out and the regime-O overlay keep their `null`.
153+
expect(p.packagedBaseRefusal({ type: 'page', name: 'pkg_page', operation: 'delete' })).toBeNull();
154+
expect(p.packagedBaseRefusal({ type: 'view', name: 'pkg_view', operation: 'save' })).toBeNull();
155+
});
156+
}
157+
158+
// [#22411] The legacy spelling `OBJECTSTACK_METADATA_WRITABLE` was removed in
159+
// 11.0, and the protocol now reads the setting through the repository's one
160+
// reader, which reads the preferred spelling only. Under the legacy
161+
// spelling alone the hatch is SHUT: the type listing reports no env
162+
// override, and every verdict is the one the shut hatch gives. Control: the
163+
// preferred spelling still opens the listing.
164+
for (const environmentId of [undefined, 'env_1']) {
165+
it(`OBJECTSTACK_METADATA_WRITABLE=flow,page is not read — the hatch is shut (${environmentId ? 'environment' : 'host-config'} kernel)`, async () => {
166+
const shut = protocolOn(environmentId);
167+
const sealed = {
168+
save: shut.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'save' }) as any,
169+
delete: shut.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: 'delete' }) as any,
170+
page: shut.packagedBaseRefusal({ type: 'page', name: 'pkg_page', operation: 'save' }) as any,
171+
};
172+
const hatchOf = async (p: ObjectStackProtocolImplementation) => {
173+
const { entries } = await (p as any).getMetaTypes();
174+
return Object.fromEntries(['flow', 'page'].map((t) => {
175+
const e = entries.find((x: any) => x.type === t);
176+
return [t, { allowOrgOverride: e?.allowOrgOverride, overrideSource: e?.overrideSource }];
177+
}));
178+
};
179+
const SHUT = { allowOrgOverride: false, overrideSource: 'registry' };
180+
expect(await hatchOf(shut)).toEqual({ flow: SHUT, page: SHUT });
181+
182+
process.env.OBJECTSTACK_METADATA_WRITABLE = 'flow,page';
183+
ObjectStackProtocolImplementation.resetEnvWritableCache();
184+
const legacy = protocolOn(environmentId);
185+
expect(await hatchOf(legacy)).toEqual({ flow: SHUT, page: SHUT });
186+
for (const verb of ['save', 'delete', 'page'] as const) {
187+
const verdict: any = verb === 'page'
188+
? legacy.packagedBaseRefusal({ type: 'page', name: 'pkg_page', operation: 'save' })
189+
: legacy.packagedBaseRefusal({ type: 'flow', name: 'pkg_flow', operation: verb });
190+
expect(shape(verdict), verb).toEqual({ code: 'NOT_OVERRIDABLE', status: 403 });
191+
expect(verdict.message, verb).toBe(sealed[verb].message);
192+
}
193+
194+
// Control: the preferred spelling is read, so the same names open the listing.
195+
process.env.OS_METADATA_WRITABLE = 'flow,page';
196+
ObjectStackProtocolImplementation.resetEnvWritableCache();
197+
const OPEN = { allowOrgOverride: true, overrideSource: 'env' };
198+
expect(await hatchOf(protocolOn(environmentId))).toEqual({ flow: OPEN, page: OPEN });
199+
});
154200
}
155201
});
156202

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 23 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type {
44
DataProtocol, MetadataProtocol, PackageProtocol,
55
} from '@objectstack/spec/api';
66
import { IDataEngine, engineCanRollBack, objectNotFoundError, recordNotFoundError } from '@objectstack/core';
7-
import { declaredUserMessage, readEnvWithDeprecation, resolveTenancyPosture, resolveThrownHttpError } from '@objectstack/types';
7+
import { declaredUserMessage, resolveTenancyPosture, resolveThrownHttpError } from '@objectstack/types';
88
// [#6285] ADR-0105 D1's authority on "does this deployment wall organizations?".
99
// `resolveMultiOrgEnabled()` is DEMOTED and its own doc comment says answering
1010
// this question with it is a bug (cloud#1020, #5233) — so the posture, and only
@@ -43,7 +43,14 @@ import type { RuntimeAuthoringIssue } from './runtime-authoring-gate.js';
4343
// ADR-0120 D4 reporting that replaced this file's empty `catch` blocks.
4444
import { ensureMetadataOverlayIndexes } from './migrations/overlay-index.js';
4545
import { driverCanRunSql, resolveDriverExec } from './migrations/driver-exec.js';
46-
import { DraftConflictError, SysMetadataRepository, packageScopedRowWhere, type SysMetadataEngine } from './sys-metadata-repository.js';
46+
import {
47+
DraftConflictError,
48+
SysMetadataRepository,
49+
envWritableMetadataTypes,
50+
packageScopedRowWhere,
51+
resetEnvWritableMetadataTypes,
52+
type SysMetadataEngine,
53+
} from './sys-metadata-repository.js';
4754
import { isOriginGatedType, managedItemSealedSentence } from './packaged-base-regime.js';
4855
import {
4956
resolveArtifactLockLayer,
@@ -15860,29 +15867,24 @@ export class ObjectStackProtocolImplementation implements
1586015867
* use to enable Studio-side editing of types whose protocol-level flag
1586115868
* is still false (object, field, permission, …).
1586215869
*
15863-
* Memoised at first call. Tests can override by clearing the cache via
15870+
* [#22411] Delegates to the repository's {@link envWritableMetadataTypes},
15871+
* the ONE reader of the setting, so the listing and the save door read the
15872+
* same set. This used to be a second copy of the parse. The 11.0 removal of
15873+
* the legacy `OBJECTSTACK_METADATA_WRITABLE` spelling edited the other
15874+
* copy and missed this one, so the listing advertised a hatch that the
15875+
* repository then refused. ⛔ Never parse the variable here again.
15876+
*
15877+
* Memoised at first call (by the shared reader). Tests can override by
15878+
* clearing the cache via
1586415879
* {@link ObjectStackProtocolImplementation.resetEnvWritableCache}.
1586515880
*/
15866-
private static _envWritableTypes: Set<string> | null = null;
1586715881
private static envWritableTypes(): ReadonlySet<string> {
15868-
if (this._envWritableTypes !== null) return this._envWritableTypes;
15869-
const raw = readEnvWithDeprecation('OS_METADATA_WRITABLE', 'OBJECTSTACK_METADATA_WRITABLE') || '';
15870-
const set = new Set<string>();
15871-
for (const tok of raw.split(',')) {
15872-
const t = tok.trim();
15873-
if (!t) continue;
15874-
const singular = PLURAL_TO_SINGULAR[t] ?? t;
15875-
set.add(singular);
15876-
const plural = SINGULAR_TO_PLURAL[singular];
15877-
if (plural) set.add(plural);
15878-
}
15879-
this._envWritableTypes = set;
15880-
return set;
15881-
}
15882-
15883-
/** Test hook — clear the memoised env-writable cache. */
15882+
return envWritableMetadataTypes();
15883+
}
15884+
15885+
/** Test hook — clear the memoised env-writable cache (the shared reader's one cache). */
1588415886
static resetEnvWritableCache(): void {
15885-
this._envWritableTypes = null;
15887+
resetEnvWritableMetadataTypes();
1588615888
}
1588715889

1588815890
/**

‎packages/metadata-protocol/src/sys-metadata-repository.ts‎

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -359,13 +359,22 @@ const OVERLAY_CAPABLE_TYPES: ReadonlySet<string> = new Set(
359359
/**
360360
* Phase 3a-env-writable: parse `OS_METADATA_WRITABLE` (comma-
361361
* separated singular type names). Memoised; tests can reset via
362-
* {@link resetEnvWritableMetadataTypes}. Mirrors the same helper in
363-
* ObjectStackProtocolImplementation — both gates must consult the same
364-
* elevated set so the env-var escape hatch is applied consistently
365-
* regardless of which write path a caller takes.
362+
* {@link resetEnvWritableMetadataTypes}.
363+
*
364+
* [#22411] The ONE reader of the setting. The repository's write gate and
365+
* every protocol consumer of the hatch — `getMetaTypes()`'s listing,
366+
* `isOverlayAllowed()` — ask this function, so the listing cannot advertise a
367+
* hatch the save door then refuses. There used to be a second, hand-copied
368+
* reader in `ObjectStackProtocolImplementation`. The 11.0 removal of
369+
* ObjectStack's own legacy env names (`OBJECTSTACK_METADATA_WRITABLE` →
370+
* `OS_METADATA_WRITABLE`, published as a breaking change) edited this copy and
371+
* missed that one, which went on honouring the removed spelling: the listing
372+
* said "writable", the save answered `403 NOT_CREATABLE`. ⛔ Do not add a
373+
* second reader, and do not give this one a legacy alias back — the spelling
374+
* is removed, and `[]` is that decision.
366375
*/
367376
let _envWritableMetadataTypes: Set<string> | null = null;
368-
function envWritableMetadataTypes(): ReadonlySet<string> {
377+
export function envWritableMetadataTypes(): ReadonlySet<string> {
369378
if (_envWritableMetadataTypes !== null) return _envWritableMetadataTypes;
370379
const raw = readEnvWithDeprecation('OS_METADATA_WRITABLE', []) || '';
371380
const set = new Set<string>();
@@ -381,7 +390,11 @@ function envWritableMetadataTypes(): ReadonlySet<string> {
381390
return set;
382391
}
383392

384-
/** Test hook — clear the memoised env-writable cache. */
393+
/**
394+
* Test hook — clear the memoised env-writable cache. The one cache
395+
* {@link envWritableMetadataTypes} keeps, so this and
396+
* `ObjectStackProtocolImplementation.resetEnvWritableCache()` clear the same set.
397+
*/
385398
export function resetEnvWritableMetadataTypes(): void {
386399
_envWritableMetadataTypes = null;
387400
}

0 commit comments

Comments
 (0)