Skip to content

Commit 528f5ca

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-19939-p4-s2-screen-trio
2 parents 02c8662 + b7cd1af commit 528f5ca

16 files changed

Lines changed: 902 additions & 73 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/runtime": minor
3+
"@objectstack/core": minor
4+
"@objectstack/platform-objects": minor
5+
"@objectstack/service-automation": minor
6+
---
7+
8+
The security catalog's activation door switches a position or permission set the environment authored, and a ledger write it refuses answers with the status its code means
9+
10+
Clause-②: yes (widening)
11+
12+
- **The door accepts environment-authored items** (`@objectstack/runtime`). `POST /api/v1/security/_activation/:type/:name` switched a position or permission set off and on only when a package shipped it. FROM: an item saved through the metadata door (`PUT /api/v1/meta/position/:name`, `PUT /api/v1/meta/permission/:name`) was refused `503` with `{ code: 'VALIDATION_FAILED', message: 'Package is required' }`, and nothing was written. TO: it is switched like a packaged one, `200`, and the resolver honours the row: a switched-off position stops granting its permission sets, and a switched-off set stops granting by every path. Who may call the door is unchanged (`manage_metadata`; the platform operator under a `group` or `isolated` posture), and so are its other refusals (an undeclared name `404`, a type with no switch or an audience anchor `400`, the last administrator `403`).
13+
- **A refused ledger write keeps its code and its status together** (`@objectstack/runtime`, ADR-0112). FROM: the door kept a thrown `code` but answered `503` whenever the error declared no status, so a record validation refusal was served as `503 VALIDATION_FAILED`. TO: the door answers through the dispatcher's one thrown-error rule: a record validation refusal is `400 VALIDATION_FAILED`, a hook's refusal keeps its own pair (the last-admin guard's `403 PERMISSION_DENIED`), and an error that declares no status is `503 SERVICE_UNAVAILABLE`.
14+
- **The ledger row of an item no package ships carries no package** (`@objectstack/platform-objects`, `@objectstack/core`). `sys_metadata_activation.package_id` is no longer required. It is empty exactly when no package ships the item. It is not part of the row identity (`metadata_type`, `name`), and no reader decides anything from it. The column was already nullable on disk (ADR-0113: `required` is a write-time check), so there is nothing to migrate.
15+
- **One spelling for "no package"** (`@objectstack/core`). `MetadataActivationRow.packageId` is optional. FROM: `packageId: string`, with `''` the only way to say "no package". TO: omit `packageId` for an item no package ships, and the store writes no value. `ObjectStoreMetadataActivationStore.setActive` refuses `packageId: ''` with a `TypeError` before it reads or writes anything, and `list()` returns `packageId` only when the row names a package. If your code passes `''`, omit the key instead.
16+
- **The flow row follows the shared row** (`@objectstack/service-automation`). `FlowActivationRow.packageId` is optional to match. Every flow row still names its package, because the flow toggle refuses a flow no package ships before it writes.
17+
- **The action activation door's answer for an action no package ships changes; the action is still refused.** `POST /api/v1/actions/_activation/:object/:action` still passes `''` as the package of such an action. The write is still refused and still writes nothing, but the store's `TypeError` refuses it now, with the store's sentence. FROM: `503 VALIDATION_FAILED`. TO: `503 SERVICE_UNAVAILABLE`. An environment-authored action could not be switched on `main` before this change and cannot after it.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
'@objectstack/core': minor
3+
---
4+
5+
fix(core)!: an import reads WHICH fields are references through the spec's arbiter, so a `user` field written without `reference` resolves against `sys_user` (#22785)
6+
7+
Clause-②: yes (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, exported declaration or stored shape is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite. What changes is the runtime answer of one field map, `buildFieldMetaMap`, for a `user` field written without `reference`: the import resolves its cell against `sys_user`, which refuses the id of a user the caller cannot see, and the export expands its column into the user's name. Both answers are the ones a `Field.user` field already gets; the author's metadata stays as written. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); no exported declaration is removed or narrowed, `ExportFieldMeta` and `buildFieldMetaMap` keep their signatures (not runtime-interface-only or type-surface-only). -->
10+
11+
**BREAKING** (an accept-set narrowing and a change to published output), shipped as `minor` under the launch-window convention for breaking changes. Two populations are affected, both only for a `user` field written without `reference`:
12+
13+
- an import row whose cell holds the id of a user the caller cannot see (a user outside the caller's organizations): stored before, `reference_not_found` now, as on a `Field.user` field;
14+
- a downstream reader of that column in `GET /api/v1/data/:object/export`: the cell held the stored user id and now holds the user's name, as a `Field.user` column already does.
15+
16+
A `user` field's target is fixed by its type: the spec's arbiter `referenceTargetOf` gives `{ type: 'user' }` the target `sys_user` whether or not `reference` is written (`Field.user()` writes it; hand-written JSON, a form or an AI author may not), and the data door's `$expand` already reads it that way. `buildFieldMetaMap`, the field map behind the import, export and import-template doors, counted a field as a reference only when it carried a `reference` string, so those doors disagreed with the arbiter for such a field. It now asks the arbiter both whether a field is a reference and what its target is.
17+
18+
**Door by door, before and after.** Measured on a real boot, as an administrator and as a member, on a fixture object holding a `user` field written without `reference` beside a `Field.user` one; the connector pull through its executor over a stub protocol.
19+
20+
- **Import: `POST /api/v1/data/:object/import`, `POST /api/v1/data/:object/import/jobs`, and a mapping's `connectorSource` pull (same runner).**
21+
- FROM: a cell holding a user's email or name was written as the field's id, so the row failed `reference_not_found` ("no sys_user record has id …") and nothing was stored.
22+
- TO: the cell is matched against `sys_user` exactly as on a `Field.user` field, so an email or a name stores the user's id. A pasted id of a user the caller can see still stores. A value matching no user still fails `reference_not_found`, now in the import's own words ("no record matches …"), and a name matching several users fails `reference_ambiguous`.
23+
- `sys_user` is asked its declared exposure first, as every lookup target is: where its `enable` declaration refuses `list`, the cell is refused.
24+
- **The one population that narrows, to `Field.user`'s answer.** A pasted id of a user the caller's row scope hides (a user outside the caller's organizations) used to store, since the write's own reference check runs elevated. It now fails `reference_not_found`, as it already did on a `Field.user` field. The data door's `POST /api/v1/data/:object` is unchanged and still accepts that id.
25+
- **Export: `GET /api/v1/data/:object/export`.** FROM: the column carried the stored user id (JSON and CSV). TO: it is expanded like a `Field.user` column and renders the user's name, so an exported file imports back through the name.
26+
- **Import template: `GET /api/v1/data/:object/export?template=true`.** FROM: the column's instructions named no target ("The name of a record, or its id."). TO: they name `sys_user` by its label ("The name of a User record, or its id.").
27+
- **Unchanged:**
28+
- `Field.user` and every field declaring `reference`.
29+
- A `lookup`, `master_detail` or `tree` field without `reference`: the arbiter names no target for it.
30+
- A non-reference type declaring `reference`: still no target.
31+
- A present `reference` that is not a string: still no target, and no throw.
32+
- plugin-auth's identity import, which reads `sys_user`'s own map. That map has no reference-typed field without `reference`, so it is identical.
33+
34+
**Fix, if a pipeline relied on the old answer:** none is needed for one that wrote visible user ids. A pipeline that imported ids of users outside the caller's organizations runs as a caller who can see them, as a `Field.user` field already required. A reader that needs the stored user id takes it from the record read (`GET /api/v1/data/:object`, without `expand`), not from the export.

‎content/docs/permissions/authorization.mdx‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -414,7 +414,14 @@ The ledger row is written through
414414
grant off, `true` back on). The caller needs `manage_metadata`; under a `group`
415415
or `isolated` tenancy posture the caller must also be the platform operator,
416416
because the row reaches every organization (ADR-0126 §5). The name must resolve
417-
in the security catalog, or the route answers `404`. It is the same authority
417+
in the security catalog, or the route answers `404`. That covers a position or
418+
permission set the environment authored through the metadata door as well as
419+
one a package ships: the ledger row records the package when there is one and
420+
leaves `package_id` empty when there is none, and the resolver honours both
421+
alike, because it keys the row by type and name. A write the ledger refuses
422+
answers with the status its code means: a record validation failure is `400`
423+
`VALIDATION_FAILED`, a hook's refusal keeps its own pair, and a store failure
424+
is `503` `SERVICE_UNAVAILABLE`. It is the same authority
418425
as the flow and action activation switches. The two audience anchors,
419426
`everyone` and `guest`, are refused with `400` in either direction: one row
420427
would switch the authenticated or anonymous baseline off for every principal.

‎packages/core/src/utils/import-field-meta.ts‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,12 @@ export interface ExportFieldMeta {
1818
label?: string;
1919
options?: Array<{ label?: string; value?: unknown; color?: string }>;
2020
/**
21-
* Target object of a field that declares a `reference` string, read through
22-
* the spec's one arbiter `referenceTargetOf` (so a non-reference type names
23-
* none). The import's reference resolution matches cells against it and asks
24-
* it its exposure first (#22739). Only a declared `reference` makes a field
25-
* one: a `user` field written without it keeps no target here, as before.
21+
* Target object of a reference field: WHICH fields are references, and their
22+
* target, are both the spec's one arbiter `referenceTargetOf`'s answer — so a
23+
* non-reference type names none, and a `user` field written without
24+
* `reference` names `sys_user`, as `$expand` reads it (#22785). The import's
25+
* reference resolution matches cells against it and asks it its exposure
26+
* first (#22739).
2627
*/
2728
reference?: string;
2829
/** Field on the referenced record to show as its label. */
@@ -87,7 +88,9 @@ export function buildFieldMetaMap(schema: unknown): Map<string, ExportFieldMeta>
8788
type: typeof f.type === 'string' ? f.type : undefined,
8889
label: typeof f.label === 'string' ? f.label : undefined,
8990
options: Array.isArray(f.options) ? f.options : undefined,
90-
reference: typeof f.reference === 'string' ? referenceTargetOf(f) : undefined,
91+
// A present non-string carrier stays out of the arbiter, which throws on
92+
// it: such a field names no target here, as before.
93+
reference: f.reference == null || typeof f.reference === 'string' ? referenceTargetOf(f) : undefined,
9194
displayField: typeof f.displayField === 'string' ? f.displayField : undefined,
9295
multiple: f.multiple === true,
9396
});

‎packages/core/src/utils/import-runner-reference-exposure.test.ts‎

Lines changed: 42 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -91,32 +91,51 @@ describe('[#22739] import reference resolution asks the target its declared expo
9191
});
9292

9393
/**
94-
* CONTROL — which fields the import treats as references is unchanged: a field
95-
* declaring a `reference` string. A `user` field written without one is NOT
96-
* resolved (its raw cell reaches the write, as on `main`), although the spec's
97-
* arbiter gives it `sys_user`; adopting that target widens the accept set and
98-
* is not this change. A non-reference type declaring `reference` names no target.
94+
* [#22785] WHICH fields are references is the spec arbiter's answer too: a
95+
* `user` field written without `reference` targets `sys_user`, as `$expand`
96+
* reads it, so its cell is resolved like a `Field.user` one. The widened
97+
* target is still asked its exposure first. A non-reference type declaring
98+
* `reference`, and a non-string carrier, name no target (and do not throw).
99+
* The real import and export doors: `import-user-field-target.dogfood.test.ts`.
99100
*/
100-
describe('[#22739] the reference field set stays the declared-reference set', () => {
101-
it('a user field without reference passes its cell through unresolved; one declaring it is resolved', async () => {
102-
const meta = buildFieldMetaMap({ fields: {
103-
owner: { type: 'user', label: 'Owner' },
104-
assignee: { type: 'user', label: 'Assignee', reference: 'sys_user' },
105-
amount: { type: 'number', label: 'Amount', reference: 'rx_target' },
106-
} });
107-
expect([meta.get('owner')!.reference, meta.get('assignee')!.reference, meta.get('amount')!.reference]).toEqual([undefined, 'sys_user', undefined]);
108-
109-
const findData = vi.fn(async (args: FindArgs) => (args.object === 'sys_user' && Object.values(args.query!.where!)[0] === 'Ann' ? [{ id: 'u_ann' }] : []));
110-
const getMetaItem = vi.fn(async ({ name }: { type: string; name: string }) => ({ type: 'object', name, item: { name } }));
101+
describe('[#22785] a user field without reference resolves through the arbiter target', () => {
102+
const EMAIL = 'ann@iuf.test';
103+
const fields = {
104+
owner: { type: 'user', label: 'Owner' },
105+
assignee: { type: 'user', label: 'Assignee', reference: 'sys_user' },
106+
amount: { type: 'number', label: 'Amount', reference: 'rx_target' },
107+
broken: { type: 'user', label: 'Broken', reference: { object: 'sys_user' } },
108+
};
109+
const userProtocol = (rows: Array<Record<string, unknown>>, enable?: unknown) => {
110+
const findData = vi.fn(async (args: FindArgs) => (args.object === 'sys_user' && (args.query!.where as Record<string, unknown>).email === EMAIL ? [{ id: 'u_ann' }] : []));
111+
const getMetaItem = vi.fn(async ({ name }: { type: string; name: string }) => ({ type: 'object', name, item: { name, ...(enable ? { enable } : {}) } }));
111112
const createData = vi.fn(async (a: { data: Record<string, unknown> }) => ({ id: 'new', ...a.data }));
112113
const p: ImportProtocolLike = { findData, getMetaItem, createData, updateData: vi.fn() };
113-
const summary = await runImport({
114-
p, objectName: 'rx_source', metaMap: meta, writeMode: 'insert', matchFields: [], dryRun: false,
115-
runAutomations: false, trimWhitespace: true, createMissingOptions: false, skipBlankMatchKey: false,
116-
rows: [{ owner: 'Ann', assignee: 'Ann' }],
114+
const summary = runImport({
115+
p, objectName: 'rx_source', metaMap: buildFieldMetaMap({ fields }), writeMode: 'insert', matchFields: [], dryRun: false,
116+
runAutomations: false, trimWhitespace: true, createMissingOptions: false, skipBlankMatchKey: false, rows,
117+
});
118+
return { summary, findData, getMetaItem, createData };
119+
};
120+
const OK = { ok: true, code: undefined, field: undefined };
121+
122+
it('names sys_user for both user fields, and no target for a non-reference type or a non-string carrier', () => {
123+
const meta = buildFieldMetaMap({ fields });
124+
expect(['owner', 'assignee', 'amount', 'broken'].map((f) => meta.get(f)!.reference)).toEqual(['sys_user', 'sys_user', undefined, undefined]);
125+
});
126+
127+
for (const field of ['owner', 'assignee'] as const) {
128+
it(`${field === 'owner' ? 'the field without reference' : 'CONTROL Field.user'}: an email stores the user id, sys_user asked once`, async () => {
129+
const { summary, getMetaItem, createData } = userProtocol([{ [field]: EMAIL }]);
130+
expect((await summary).results.map(verdict)).toEqual([OK]);
131+
expect(createData.mock.calls.map(([a]) => a.data)).toEqual([{ [field]: 'u_ann' }]);
132+
expect(getMetaItem.mock.calls.map(([r]) => r.name)).toEqual(['sys_user']);
117133
});
118-
expect(summary.results.map(verdict)).toEqual([{ ok: true, code: undefined, field: undefined }]);
119-
expect(createData.mock.calls[0]![0].data).toMatchObject({ owner: 'Ann', assignee: 'u_ann' });
120-
expect(getMetaItem.mock.calls.map(([r]) => r.name)).toEqual(['sys_user']);
134+
}
135+
136+
it('CONTROL — a sys_user the decision does not serve refuses both cells and is never read', async () => {
137+
const { summary, findData } = userProtocol([{ owner: EMAIL }, { assignee: EMAIL }], { apiEnabled: false });
138+
expect((await summary).results.map(verdict)).toEqual([{ ...NOT_FOUND, field: 'owner' }, { ...NOT_FOUND, field: 'assignee' }]);
139+
expect(findData).not.toHaveBeenCalled();
121140
});
122141
});

‎packages/core/src/utils/metadata-activation-store.test.ts‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,62 @@ describe('ObjectStoreMetadataActivationStore — the ADR-0126 §4 row semantics,
296296
});
297297
});
298298

299+
// [#22621 → A] The security catalog's activation door switches a position or
300+
// permission set the environment authored, and no package ships those. The row
301+
// says so with ONE spelling — no package value — and the store refuses the
302+
// other spelling a caller reaches for (`?? ''`), so a reader can never take an
303+
// environment item for a package named ''.
304+
describe('ObjectStoreMetadataActivationStore — an item no package ships', () => {
305+
it('INSERTS a row with no package when packageId is absent', async () => {
306+
const { engine, calls } = makeStoreEngine([]);
307+
308+
await new ObjectStoreMetadataActivationStore(engine, 'position').setActive({
309+
name: 'env_position', active: false,
310+
});
311+
312+
expect(calls.find((c) => c.op === 'insert')?.data).toEqual({
313+
metadata_type: 'position', name: 'env_position', package_id: null, active: false,
314+
});
315+
});
316+
317+
it('UPDATES its row with no package on the way back on', async () => {
318+
const { engine, calls } = makeStoreEngine([
319+
{ id: 'r1', metadata_type: 'permission', name: 'env_set', package_id: null, active: false },
320+
]);
321+
322+
await new ObjectStoreMetadataActivationStore(engine, 'permission').setActive({
323+
name: 'env_set', active: true,
324+
});
325+
326+
expect(calls.filter((c) => c.op === 'insert')).toHaveLength(0);
327+
expect(calls.find((c) => c.op === 'update')?.data).toEqual({ id: 'r1', active: true, package_id: null });
328+
});
329+
330+
it('REFUSES an empty-string package before reading or writing anything', async () => {
331+
const { engine, calls } = makeStoreEngine([]);
332+
333+
await expect(new ObjectStoreMetadataActivationStore(engine, 'position').setActive({
334+
name: 'env_position', packageId: '', active: false,
335+
})).rejects.toThrow(TypeError);
336+
expect(calls).toEqual([]);
337+
});
338+
339+
it('reads a package-less row back with packageId ABSENT, beside a packaged one that keeps its package', async () => {
340+
const { engine } = makeStoreEngine([
341+
{ id: 'r1', metadata_type: 'position', name: 'env_position', package_id: null, active: false },
342+
{ id: 'r2', metadata_type: 'position', name: 'sales_rep', package_id: 'crm', active: false },
343+
]);
344+
345+
const rows = await new ObjectStoreMetadataActivationStore(engine, 'position').list();
346+
347+
expect(rows).toEqual([
348+
{ name: 'env_position', active: false },
349+
{ name: 'sales_rep', packageId: 'crm', active: false },
350+
]);
351+
expect('packageId' in rows[0]).toBe(false);
352+
});
353+
});
354+
299355
describe('InMemoryMetadataActivationStore', () => {
300356
it('round-trips a row and reflects the latest flip', async () => {
301357
const store = new InMemoryMetadataActivationStore();

0 commit comments

Comments
 (0)