From a24d5df8878a3338d02f92b1425e6462e1a541c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 02:43:09 +0000 Subject: [PATCH 1/6] fix(core): an import's reference resolution asks the lookup target its declared exposure before matching a cell (#22739) Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T Co-authored-by: Claude --- packages/core/src/utils/import-field-meta.ts | 11 ++- .../import-runner-reference-exposure.test.ts | 91 +++++++++++++++++++ packages/core/src/utils/import-runner.ts | 53 ++++++++++- 3 files changed, 150 insertions(+), 5 deletions(-) create mode 100644 packages/core/src/utils/import-runner-reference-exposure.test.ts diff --git a/packages/core/src/utils/import-field-meta.ts b/packages/core/src/utils/import-field-meta.ts index c166e91c74b..45fca264c8e 100644 --- a/packages/core/src/utils/import-field-meta.ts +++ b/packages/core/src/utils/import-field-meta.ts @@ -10,12 +10,19 @@ * needs. The export renderers stay in `rest`. */ +import { referenceTargetOf } from '@objectstack/spec/data'; + export interface ExportFieldMeta { name: string; type?: string; label?: string; options?: Array<{ label?: string; value?: unknown; color?: string }>; - /** Target object for lookup / master_detail / user fields. */ + /** + * Target object of a reference-typed field, as the spec's one arbiter + * `referenceTargetOf` answers it (a `user` field targets `sys_user` with no + * `reference` written). The import's reference resolution matches cells + * against it and asks it its exposure first (#22739). + */ reference?: string; /** Field on the referenced record to show as its label. */ displayField?: string; @@ -79,7 +86,7 @@ export function buildFieldMetaMap(schema: unknown): Map type: typeof f.type === 'string' ? f.type : undefined, label: typeof f.label === 'string' ? f.label : undefined, options: Array.isArray(f.options) ? f.options : undefined, - reference: typeof f.reference === 'string' ? f.reference : undefined, + reference: referenceTargetOf(f), displayField: typeof f.displayField === 'string' ? f.displayField : undefined, multiple: f.multiple === true, }); diff --git a/packages/core/src/utils/import-runner-reference-exposure.test.ts b/packages/core/src/utils/import-runner-reference-exposure.test.ts new file mode 100644 index 00000000000..edf0636e4e3 --- /dev/null +++ b/packages/core/src/utils/import-runner-reference-exposure.test.ts @@ -0,0 +1,91 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#22739] An import cell resolved against a lookup TARGET whose declared + * exposure does not serve `list` answers as a target the caller cannot read: + * `reference_not_found`, for a cell that matches and a cell that does not + * alike, and the target is never read. + * + * The decision is the spec's one (`canServeApiOperation`), asked of the + * target's `enable` block as the protocol's `getMetaItem` serves it. It takes + * no caller, so a member and an administrator are answered the same. The real + * doors (`POST /data/:object/import` and `/import/jobs`, both personas) are + * pinned in `packages/qa/dogfood/test/import-reference-exposure.dogfood.test.ts`. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { runImport, type ImportProtocolLike } from './import-runner'; +import type { ExportFieldMeta } from './import-field-meta.js'; + +type FindArgs = Parameters[0]; + +const TARGET = 'rx_target'; +const RECORD = { id: 'rx_1', name: 'Existing' }; + +const metaMap = new Map([ + ['name', { name: 'name', type: 'text' }], + ['ref', { name: 'ref', type: 'lookup', reference: TARGET }], +]); + +/** A protocol over one target record, whose declaration is `enable` (or a throw). */ +function protocol(enable: unknown, opts: { metaThrows?: boolean } = {}) { + const findData = vi.fn(async (args: FindArgs) => { + if (args.object !== TARGET) return []; + const [[field, value]] = Object.entries(args.query!.where!); + return (RECORD as Record)[field] === value ? [RECORD] : []; + }); + const getMetaItem = vi.fn(async ({ name }: { type: string; name: string }) => { + if (opts.metaThrows) throw new Error('metadata store unavailable'); + return { type: 'object', name, item: { name, ...(enable === undefined ? {} : { enable }) } }; + }); + const p: ImportProtocolLike = { findData, getMetaItem, createData: vi.fn(async (a) => ({ id: 'new', ...a.data })), updateData: vi.fn() }; + return { p, findData, getMetaItem }; +} + +const run = (p: ImportProtocolLike, context: Record) => + runImport({ + p, context, objectName: 'rx_source', metaMap, writeMode: 'insert', matchFields: [], dryRun: true, + runAutomations: false, trimWhitespace: true, createMissingOptions: false, skipBlankMatchKey: false, + rows: [{ name: 'a', ref: 'Existing' }, { name: 'b', ref: 'Missing' }, { name: 'c', ref: 'rx_1' }], + }); + +const verdict = (r: { ok: boolean; code?: string; field?: string }) => ({ ok: r.ok, code: r.code, field: r.field }); +const NOT_FOUND = { ok: false, code: 'reference_not_found', field: 'ref' }; +const PERSONAS = { member: { userId: 'u_member', roles: ['member'] }, admin: { userId: 'u_admin', roles: ['admin'] } }; + +describe('[#22739] import reference resolution asks the target its declared exposure, for list', () => { + for (const [persona, context] of Object.entries(PERSONAS)) { + for (const [label, enable] of [ + ['apiEnabled: false', { apiEnabled: false }], + ['the deny-all whitelist', { apiMethods: [] }], + ['a whitelist granting get but not list', { apiMethods: ['get'] }], + ['a whitelist granting no read', { apiMethods: ['create'] }], + ] as const) { + it(`${persona}: ${label} answers a match, a miss and an id alike, and reads nothing`, async () => { + const { p, findData } = protocol(enable); + const summary = await run(p, context); + expect(summary.results.map(verdict)).toEqual([NOT_FOUND, NOT_FOUND, NOT_FOUND]); + expect(findData).not.toHaveBeenCalled(); + }); + } + + for (const [label, enable] of [ + ['no enable block', undefined], + ['a whitelist granting list', { apiMethods: ['list'] }], + ] as const) { + it(`CONTROL ${persona}: ${label} resolves a match and an id, and misses a miss`, async () => { + const { p } = protocol(enable); + const summary = await run(p, context); + expect(summary.results.map(verdict)).toEqual([{ ok: true, code: undefined, field: undefined }, NOT_FOUND, { ok: true, code: undefined, field: undefined }]); + }); + } + } + + it('a declaration that cannot be read withholds the target (fail-closed), asked once per import', async () => { + const { p, findData, getMetaItem } = protocol(undefined, { metaThrows: true }); + const summary = await run(p, PERSONAS.member); + expect(summary.results.map(verdict)).toEqual([NOT_FOUND, NOT_FOUND, NOT_FOUND]); + expect(findData).not.toHaveBeenCalled(); + expect(getMetaItem).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/core/src/utils/import-runner.ts b/packages/core/src/utils/import-runner.ts index 8e511983ac2..e8b4eb660e0 100644 --- a/packages/core/src/utils/import-runner.ts +++ b/packages/core/src/utils/import-runner.ts @@ -4,9 +4,9 @@ import { randomUUID } from 'node:crypto'; import { coerceRow, type RefResolver, type RefMatch } from './import-coerce.js'; import type { ExportFieldMeta } from './import-field-meta.js'; import type { ValidationMessageTranslator } from '@objectstack/spec/system'; -import type { DroppedFieldsEvent } from '@objectstack/spec/data'; +import { canServeApiOperation, type DroppedFieldsEvent, type EnableLike } from '@objectstack/spec/data'; import type { ExecutionContext } from '@objectstack/spec/kernel'; -import type { CreateDataRequest, FindDataRequest, UpdateDataRequest, ValidateDataIssue, ValidateDataRequest, ValidateDataResponse } from '@objectstack/spec/api'; +import type { CreateDataRequest, FindDataRequest, GetMetaItemRequest, UpdateDataRequest, ValidateDataIssue, ValidateDataRequest, ValidateDataResponse } from '@objectstack/spec/api'; import { bulkWrite, withTransientRetry, defaultIsTransientError, type BulkWriteRowResult } from './bulk-write.js'; import { isUniqueViolationError, uniqueViolationColumn, isEngineDuplicateRecordEnvelope, mapDataError, sandboxBusinessMessage, isSandboxOrigin } from '@objectstack/types'; @@ -207,6 +207,15 @@ export interface ImportProtocolLike { * Such a dry run reports coercion + create/update/skip resolution only. */ validateData?(args: ImportProtocolRequest): Promise; + /** + * [#22739] The object-declaration read, the one the door's import template + * and the connector pull already make. The reference resolution reads a + * lookup TARGET's `enable` block through it ({@link servesReferenceTarget}). + * A protocol without it (plugin-auth's identity import, whose reads run + * under the system identity) has no declaration to judge, and none is + * fabricated for it. + */ + getMetaItem?(request: GetMetaItemRequest): Promise; } export interface RunImportOptions { @@ -551,6 +560,39 @@ function responseDrops(res: unknown): readonly DroppedFieldsEvent[] | undefined return (res as { droppedFields?: DroppedFieldsEvent[] } | null | undefined)?.droppedFields; } +/** + * [#22739] The operation a reference cell's TARGET object is judged as, by the + * spec's one exposure decision: `list`. Matching a cell is a predicate read + * over the target (`findData` with `where: { : }`), the runtime + * `find` the spec maps to `list` (`DATA_ACTION_TO_API_OPERATION`), and the read + * `GET /data/{target}?=` performs. `get` would serve a name match + * on an object that refuses exactly that read. + */ +const REFERENCE_TARGET_OPERATION = 'list'; + +/** + * [#22739] May the reference resolution read `target` to match a cell? + * + * The import door judges the ADDRESSED object; matching a lookup cell reads a + * SECOND object nobody addressed, so a match stored the target's id and a miss + * answered `reference_not_found`, even for a target every data route refuses: + * a presence oracle. Asked of the target's own `enable` block, it takes no + * caller. A refused target answers as a target the caller cannot read, which + * is measured: every cell, matching or not, `reference_not_found`, and nothing + * is read. A declaration that cannot be read withholds too (fail-closed). + */ +async function servesReferenceTarget(p: ImportProtocolLike, target: string): Promise { + if (typeof p.getMetaItem !== 'function') return true; + let enable: EnableLike | null | undefined; + try { + const found = (await p.getMetaItem({ type: 'object', name: target })) as { item?: { enable?: EnableLike | null } } | undefined; + enable = found?.item?.enable; + } catch { + return false; + } + return canServeApiOperation(enable, REFERENCE_TARGET_OPERATION); +} + /** Upper bound on rows in one createManyData batch (framework#2678 suggests 100-500). */ const MAX_CREATE_BATCH_SIZE = 200; @@ -620,9 +662,14 @@ export function runImport(opts: RunImportOptions): Promise { }); // Reference resolver: name/email/id → referenced record id. Cached per - // (object, display) so a name repeated across rows costs one query. + // (object, display) so a name repeated across rows costs one query. The + // target's exposure is asked first, once per target per import (#22739). const refCache = new Map(); + const servedTargets = new Map>(); const resolveRef: RefResolver = async (referenceObject, display, meta) => { + let served = servedTargets.get(referenceObject); + if (!served) servedTargets.set(referenceObject, served = servesReferenceTarget(p, referenceObject)); + if (!(await served)) return {}; const cacheKey = `${referenceObject}::${display}`; const cached = refCache.get(cacheKey); if (cached) return cached; From 22bfc2770bd4805829ca171928cbcf10be5110ef Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 02:50:01 +0000 Subject: [PATCH 2/6] test(core,dogfood): pin the import reference resolution's target exposure on both doors; classify it in the enumeration pin (#22739) Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T Co-authored-by: Claude --- .../22739-import-reference-target-exposure.md | 27 ++++ .../second-object-read-exposure.pin.test.ts | 32 +++-- .../import-reference-exposure.dogfood.test.ts | 126 ++++++++++++++++++ 3 files changed, 171 insertions(+), 14 deletions(-) create mode 100644 .changeset/22739-import-reference-target-exposure.md create mode 100644 packages/qa/dogfood/test/import-reference-exposure.dogfood.test.ts diff --git a/.changeset/22739-import-reference-target-exposure.md b/.changeset/22739-import-reference-target-exposure.md new file mode 100644 index 00000000000..58ed397ceaf --- /dev/null +++ b/.changeset/22739-import-reference-target-exposure.md @@ -0,0 +1,27 @@ +--- +'@objectstack/core': minor +--- + +fix(core)!: an import's reference resolution asks the lookup TARGET its declared exposure before matching a cell (#22739) + +Clause-②: no (narrowing) + + + +**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +`POST /api/v1/data/:object/import` and the async `POST /api/v1/data/:object/import/jobs` judge the ADDRESSED object's `enable` block. A lookup cell is resolved by matching its text against the lookup's TARGET object — an object nobody addressed. + +**FROM.** For an administrator and a member alike, a cell was matched against a target whose declaration refuses the read on every data route (`enable.apiEnabled: false`, the deny-all `apiMethods: []`, or a whitelist without `list`): a cell naming an existing record stored its id, and a cell naming none answered `reference_not_found` — so the row report told the two apart. + +**TO.** The runner asks the spec's one decision (`canServeApiOperation`) of the TARGET's `enable` block, for `list` — matching a cell is a predicate read over the target, the runtime `find` the spec maps to `list`. A target the decision does not serve answers every cell, naming a record or not (a pasted id included), `reference_not_found` — the answer a target the caller cannot read (no read permission, or a record its row scope hides) already gets. Nothing of the target is read. A target with no `enable` block, or a whitelist that grants `list`, resolves exactly as before. A declaration that cannot be read withholds the target too (fail-closed). + +**Bindings.** + +- **Every caller of `runImport` whose protocol reads object declarations.** The decision takes no user, so an administrator, a member and a system context (the connector pull) are answered the same. The target's declaration is read through the protocol's `getMetaItem`, a new optional member of `ImportProtocolLike`; a protocol without it (plugin-auth's identity import, whose reads run under the system identity) is not judged. +- **No new error code, no status change.** The refusal is the per-row `reference_not_found` the resolver already answers. +- **The target is read through `referenceTargetOf`.** `buildFieldMetaMap` now takes a reference field's target from the spec's arbiter instead of the raw `reference` key, so a `user` field written without `reference` targets `sys_user` on the import, export and import-template doors, as it already did on the data door's `$expand`. A non-string `reference` carrier, which `ObjectSchema` refuses, now throws there instead of reading as "no target". + +**Fix, if an import relied on the old answer:** the target's own declaration decides — grant `list` in its `enable.apiMethods`, or drop `enable.apiEnabled: false`, if the object is meant to be matched by import. + +**Measured producers.** Read on `origin/main` `bf515e724d` over every non-test `.ts` source under `packages/` (spec and qa excluded) and `examples/`: twelve objects refuse `list` by declaration, and exactly one lookup points into any of them, from an object that is itself `apiEnabled: false` (its own import door already answers 404); the control count of lookups into `sys_user` is 75. So no shipped object's import answer changes. Deployed and cloud-held object definitions were NOT MEASURED. diff --git a/packages/core/src/security/second-object-read-exposure.pin.test.ts b/packages/core/src/security/second-object-read-exposure.pin.test.ts index f6ffaeb5e13..a8f0e1112b9 100644 --- a/packages/core/src/security/second-object-read-exposure.pin.test.ts +++ b/packages/core/src/security/second-object-read-exposure.pin.test.ts @@ -10,9 +10,10 @@ * * The data routes judge the ADDRESSED object. A read that follows a lookup * reaches an object nobody addressed: the data door's `$expand` served the row - * fields of a target every data route refuses, and the dataset door's label - * passes rendered its display names. Both now ask the target (behaviour-pinned - * in their own packages and on a real stack in `@objectstack/dogfood`). This + * fields of a target every data route refuses, the dataset door's label + * passes rendered its display names, and the import door matched cells against + * it (#22739). All three now ask the target (behaviour-pinned in their own + * packages and on a real stack in `@objectstack/dogfood`). This * file is the ENUMERATION: it names the census of such reads, holds each * decided one to the decision, and goes red when a new one appears without * being classified here. @@ -36,15 +37,10 @@ * `origin/main` at eae3368a, those are the analytics relationship hop * (`service-analytics/src/hop-object.ts` — its target comes from the host's * relationship resolver; the analytics door already judges every hop's object - * for `aggregate` over `queryObjects`); the import door's reference resolution - * (`core/src/utils/import-runner.ts` `resolveRef`, whose target - * `import-field-meta.ts` reads off the raw `reference` carrier — measured: a - * cell's display text is matched against an unexposed target and the matched - * id is stored, a miss is reported per row; open, carried by a follow-up of - * #22661); and the reads whose target is a FIXED platform object rather than - * an authored one (a file field's `sys_file` hydration, the `sys_user` display - * names the approvals and audit surfaces resolve). They are named here, not - * held. + * for `aggregate` over `queryObjects`); and the reads whose target is a FIXED + * platform object rather than an authored one (a file field's `sys_file` + * hydration, the `sys_user` display names the approvals and audit surfaces + * resolve). They are named here, not held. * * The scan surface and its prefilter follow `row-serving-door-exposure.pin.test.ts` * in this directory: git's authored-file list, never a directory crawl, and `.ts` @@ -79,7 +75,7 @@ type Classification = /** The read, in words. */ read: string; /** The operation the target is judged as. */ - operation: 'get'; + operation: 'get' | 'list'; /** The function that asks the decision, and the file it lives in. */ decision: { file: string; fn: string }; /** Where the read takes its answer from that function: a call site that must exist. */ @@ -132,6 +128,14 @@ const CALLERS: Record = { wiring: { file: 'packages/services/service-analytics/src/analytics-service.ts', call: 'servesLabelTarget(' }, pin: 'packages/services/service-analytics/src/__tests__/dimension-label-exposure.test.ts', }, + 'packages/core/src/utils/import-field-meta.ts': { + kind: 'decided', + read: "the import door's reference resolution (`resolveRef` matches a cell against the target; the export and template doors read the same map, the export's expansion decided in protocol.ts)", + operation: 'list', + decision: { file: 'packages/core/src/utils/import-runner.ts', fn: 'servesReferenceTarget' }, + wiring: { file: 'packages/core/src/utils/import-runner.ts', call: 'servesReferenceTarget(p, referenceObject' }, + pin: 'packages/core/src/utils/import-runner-reference-exposure.test.ts', + }, 'packages/objectql/src/relation-filter-lowering.ts': { kind: 'open', read: "the data door's nested-relation filter condition (`ObjectQL.lowerRelationConditions` reads the related object to lower it)", @@ -280,7 +284,7 @@ describe('[#22661] every read that reaches a second object asks that object its const decided = Object.values(CALLERS).filter( (c): c is Extract => c.kind === 'decided', ); - expect(decided.map((d) => d.decision.fn).sort()).toEqual(['servesExpansionTarget', 'servesLabelTarget']); + expect(decided.map((d) => d.decision.fn).sort()).toEqual(['servesExpansionTarget', 'servesLabelTarget', 'servesReferenceTarget']); for (const d of decided) { const body = functionBody(d.decision.file, d.decision.fn); diff --git a/packages/qa/dogfood/test/import-reference-exposure.dogfood.test.ts b/packages/qa/dogfood/test/import-reference-exposure.dogfood.test.ts new file mode 100644 index 00000000000..ad57e83875e --- /dev/null +++ b/packages/qa/dogfood/test/import-reference-exposure.dogfood.test.ts @@ -0,0 +1,126 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#22739] An import cell matched against a lookup TARGET asks that target's +// declared exposure first (`enable.apiEnabled` / `enable.apiMethods`, the +// spec's one decision, for `list`), on a real boot: the real SecurityPlugin, +// ObjectQL, SQL driver and REST layers, over the #22661 fixture. +// +// A target the decision does not serve answers every cell, naming a record or +// not, as `reference_not_found`: the answer a target the caller cannot read +// already gets (the precedent leg). Both doors — the synchronous +// `POST /data/:object/import` and the async `/import/jobs` — and both personas. +// +// ## Armed before anything is believed +// +// Each target's own `list` is read off the data door first (404 for the off +// switch, 405 for a whitelist without `list`, 200 otherwise), and the served +// targets are the controls: a door that refused every target fails them. +// +// Fixtures are synthetic. `@objectstack/core` (the import runner) resolves +// through its BUILT output here, so a verdict on a change to it is a verdict +// on its last build. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { PermissionSetSchema } from '@objectstack/spec/security'; +import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; +import { secondObjectExposureStack, soxMemberSet } from './fixtures/second-object-exposure-fixture.js'; + +const SYS = { context: { isSystem: true } } as const; +/** Each source lookup and the data door's `list` answer for the object it points at. */ +const LIST_STATUS = { hidden: 404, closed: 405, getonly: 405, listonly: 200, open: 200 } as const; +type Rel = keyof typeof LIST_STATUS; +const WITHHELD: Rel[] = ['hidden', 'closed', 'getonly']; +const SERVED: Rel[] = ['listonly', 'open']; +const MARK = (rel: string) => `IMPX-${rel.toUpperCase()}-81`; +const NOT_FOUND = (rel: string) => ({ ok: false, code: 'reference_not_found', field: rel }); + +/** The #22661 member, plus a read on its own import jobs so the async door's report can be read back. */ +const member = PermissionSetSchema.parse({ + ...soxMemberSet, + name: 'impx_fixture_member', + objects: { ...soxMemberSet.objects, sys_import_job: { allowRead: true } }, +}); + +type Row = { ok: boolean; code?: string; field?: string; id?: string }; + +describe('[#22739] an import matches a lookup cell only against a target whose declared exposure serves list', () => { + let stack: VerifyStack; + const token: Record<'admin' | 'member', string> = { admin: '', member: '' }; + const ids: Record = {}; + + beforeAll(async () => { + stack = await bootStack(secondObjectExposureStack as never, { + security: new SecurityPlugin({ defaultPermissionSets: [...securityDefaultPermissionSets, member], fallbackPermissionSet: member.name }), + }); + token.admin = await stack.signIn(); + token.member = await stack.signUp('impx-member@verify.test'); + const ql = (await stack.kernel.getServiceAsync('objectql')) as any; + for (const rel of [...WITHHELD, ...SERVED, 'private']) { + ids[rel] = String((await ql.insert(`sox_${rel}`, { name: MARK(rel) }, SYS)).id); + } + }, 180_000); + + afterAll(async () => { + await stack?.stop?.(); + }); + + /** A cell naming the target's record, then a cell naming none, per target. */ + const rowsFor = (rels: readonly string[], tag: string) => + rels.flatMap((rel) => [{ name: `${tag}-${rel}-m`, [rel]: MARK(rel) }, { name: `${tag}-${rel}-x`, [rel]: `IMPX-NONE-${rel}` }]); + + const doors = { + sync: async (who: string, rows: object[]): Promise => { + const res = await stack.apiAs(who, 'POST', '/data/sox_source/import', { format: 'json', rows, runAutomations: false }); + expect(res.status, await res.clone().text()).toBe(200); + return ((await res.json()) as { results: Row[] }).results; + }, + jobs: async (who: string, rows: object[]): Promise => { + const res = await stack.apiAs(who, 'POST', '/data/sox_source/import/jobs', { format: 'json', rows, runAutomations: false }); + expect(res.status, await res.clone().text()).toBe(201); + const { jobId } = (await res.json()) as { jobId: string }; + for (let i = 0; i < 200; i++) { + const body: any = await (await stack.apiAs(who, 'GET', `/data/import/jobs/${jobId}/results`)).json(); + if (body.status === 'succeeded') return body.results; + expect(['pending', 'running'], JSON.stringify(body)).toContain(body.status); + await new Promise((r) => setTimeout(r, 25)); + } + throw new Error(`import job ${jobId} did not finish`); + }, + }; + + for (const persona of ['admin', 'member'] as const) { + describe(persona, () => { + it("ARMED — the data door answers each target's own list as its declaration says", async () => { + for (const rel of [...WITHHELD, ...SERVED]) { + const res = await stack.apiAs(token[persona], 'GET', `/data/sox_${rel}?name=${MARK(rel)}`); + expect(res.status, rel).toBe(LIST_STATUS[rel]); + } + }); + + for (const door of ['sync', 'jobs'] as const) { + it(`${door}: a withheld target answers a cell naming its record as one naming none`, async () => { + const results = await doors[door](token[persona], rowsFor(WITHHELD, `${persona}-${door}`)); + const want = WITHHELD.flatMap((rel) => [NOT_FOUND(rel), NOT_FOUND(rel)]); + expect(results.map(({ ok, code, field }) => ({ ok, code, field }))).toEqual(want); + }); + + it(`CONTROL ${door}: a served target stores the matched id and misses a miss`, async () => { + const results = await doors[door](token[persona], rowsFor(SERVED, `${persona}-${door}-c`)); + const ql = (await stack.kernel.getServiceAsync('objectql')) as any; + for (const [i, rel] of SERVED.entries()) { + const [hit, miss] = [results[2 * i]!, results[2 * i + 1]!]; + expect(hit.ok, JSON.stringify(hit)).toBe(true); + expect((await ql.find('sox_source', { where: { id: hit.id }, ...SYS }))[0]?.[rel], rel).toBe(ids[rel]); + expect({ ok: miss.ok, code: miss.code, field: miss.field }).toEqual(NOT_FOUND(rel)); + } + }); + } + }); + } + + it('PRECEDENT — a target the member may not read answers a cell naming its record as one naming none, unchanged', async () => { + const results = await doors.sync(token.member, rowsFor(['private'], 'precedent')); + expect(results.map(({ ok, code, field }) => ({ ok, code, field }))).toEqual([NOT_FOUND('private'), NOT_FOUND('private')]); + }); +}); From 074a7203fe1122acf9d695c07a3dc2813fa4f31a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 03:04:15 +0000 Subject: [PATCH 3/6] docs(changeset): the measured user-field reading on the import door (#22739) Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T Co-authored-by: Claude --- .changeset/22739-import-reference-target-exposure.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/22739-import-reference-target-exposure.md b/.changeset/22739-import-reference-target-exposure.md index 58ed397ceaf..37755ee60c0 100644 --- a/.changeset/22739-import-reference-target-exposure.md +++ b/.changeset/22739-import-reference-target-exposure.md @@ -20,7 +20,7 @@ Clause-②: no (narrowing) - **Every caller of `runImport` whose protocol reads object declarations.** The decision takes no user, so an administrator, a member and a system context (the connector pull) are answered the same. The target's declaration is read through the protocol's `getMetaItem`, a new optional member of `ImportProtocolLike`; a protocol without it (plugin-auth's identity import, whose reads run under the system identity) is not judged. - **No new error code, no status change.** The refusal is the per-row `reference_not_found` the resolver already answers. -- **The target is read through `referenceTargetOf`.** `buildFieldMetaMap` now takes a reference field's target from the spec's arbiter instead of the raw `reference` key, so a `user` field written without `reference` targets `sys_user` on the import, export and import-template doors, as it already did on the data door's `$expand`. A non-string `reference` carrier, which `ObjectSchema` refuses, now throws there instead of reading as "no target". +- **The target is read through `referenceTargetOf`.** `buildFieldMetaMap` now takes a reference field's target from the spec's arbiter instead of the raw `reference` key, so a `user` field written without `reference` (fully specified: the type fixes the target) targets `sys_user` on the import, export and import-template doors, as it already did on the data door's `$expand`. Measured on the import door: a cell holding a user's email used to fail `reference_not_found` (the raw text reached the write as an id); it now resolves to that user. A non-string `reference` carrier, which `ObjectSchema` refuses, now throws there instead of reading as "no target". **Fix, if an import relied on the old answer:** the target's own declaration decides — grant `list` in its `enable.apiMethods`, or drop `enable.apiEnabled: false`, if the object is meant to be matched by import. From eed1cbf9ed2d2cd735d2836f28072be371171ee1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 03:38:50 +0000 Subject: [PATCH 4/6] test(rest): the presentation-keys fixture declares a reference type, since the target is read through referenceTargetOf (#22739) Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T Co-authored-by: Claude --- packages/rest/src/export-format.test.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/rest/src/export-format.test.ts b/packages/rest/src/export-format.test.ts index 468cc15fc8e..a0544530be5 100644 --- a/packages/rest/src/export-format.test.ts +++ b/packages/rest/src/export-format.test.ts @@ -247,10 +247,15 @@ describe('buildFieldMetaMap — presentation keys only (#6536)', () => { 'displayField', 'label', 'multiple', 'name', 'options', 'reference', 'type', ]; - /** One field declaring every retired constraint key alongside the presentation ones. */ + /** + * One field declaring every retired constraint key alongside the presentation + * ones. A reference type, because `reference` is read through the spec's + * arbiter `referenceTargetOf` (#22739), which answers no target for a + * non-reference type. + */ const FIELD = { name: 'amount', - type: 'number', + type: 'lookup', label: '金额', reference: 'contracts', displayField: 'title', @@ -282,7 +287,7 @@ describe('buildFieldMetaMap — presentation keys only (#6536)', () => { const meta = buildFieldMetaMap({ fields: { amount: FIELD } }).get('amount')!; expect(meta).toEqual({ name: 'amount', - type: 'number', + type: 'lookup', label: '金额', options: undefined, reference: 'contracts', From f06912e44263541d893fc2bc9b1ce6189eb6253d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 05:07:32 +0000 Subject: [PATCH 5/6] fix(core): the import treats as a reference only a field that declares one, reading its target through referenceTargetOf; pin the user-field control (#22739) Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T Co-authored-by: Claude --- packages/core/src/utils/import-field-meta.ts | 11 ++++--- .../import-runner-reference-exposure.test.ts | 33 ++++++++++++++++++- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/packages/core/src/utils/import-field-meta.ts b/packages/core/src/utils/import-field-meta.ts index 45fca264c8e..fd25270d28b 100644 --- a/packages/core/src/utils/import-field-meta.ts +++ b/packages/core/src/utils/import-field-meta.ts @@ -18,10 +18,11 @@ export interface ExportFieldMeta { label?: string; options?: Array<{ label?: string; value?: unknown; color?: string }>; /** - * Target object of a reference-typed field, as the spec's one arbiter - * `referenceTargetOf` answers it (a `user` field targets `sys_user` with no - * `reference` written). The import's reference resolution matches cells - * against it and asks it its exposure first (#22739). + * Target object of a field that declares a `reference` string, read through + * the spec's one arbiter `referenceTargetOf` (so a non-reference type names + * none). The import's reference resolution matches cells against it and asks + * it its exposure first (#22739). Only a declared `reference` makes a field + * one: a `user` field written without it keeps no target here, as before. */ reference?: string; /** Field on the referenced record to show as its label. */ @@ -86,7 +87,7 @@ export function buildFieldMetaMap(schema: unknown): Map type: typeof f.type === 'string' ? f.type : undefined, label: typeof f.label === 'string' ? f.label : undefined, options: Array.isArray(f.options) ? f.options : undefined, - reference: referenceTargetOf(f), + reference: typeof f.reference === 'string' ? referenceTargetOf(f) : undefined, displayField: typeof f.displayField === 'string' ? f.displayField : undefined, multiple: f.multiple === true, }); diff --git a/packages/core/src/utils/import-runner-reference-exposure.test.ts b/packages/core/src/utils/import-runner-reference-exposure.test.ts index edf0636e4e3..d9d39fc6363 100644 --- a/packages/core/src/utils/import-runner-reference-exposure.test.ts +++ b/packages/core/src/utils/import-runner-reference-exposure.test.ts @@ -15,7 +15,7 @@ import { describe, it, expect, vi } from 'vitest'; import { runImport, type ImportProtocolLike } from './import-runner'; -import type { ExportFieldMeta } from './import-field-meta.js'; +import { buildFieldMetaMap, type ExportFieldMeta } from './import-field-meta.js'; type FindArgs = Parameters[0]; @@ -89,3 +89,34 @@ describe('[#22739] import reference resolution asks the target its declared expo expect(getMetaItem).toHaveBeenCalledTimes(1); }); }); + +/** + * CONTROL — which fields the import treats as references is unchanged: a field + * declaring a `reference` string. A `user` field written without one is NOT + * resolved (its raw cell reaches the write, as on `main`), although the spec's + * arbiter gives it `sys_user`; adopting that target widens the accept set and + * is not this change. A non-reference type declaring `reference` names no target. + */ +describe('[#22739] the reference field set stays the declared-reference set', () => { + it('a user field without reference passes its cell through unresolved; one declaring it is resolved', async () => { + const meta = buildFieldMetaMap({ fields: { + owner: { type: 'user', label: 'Owner' }, + assignee: { type: 'user', label: 'Assignee', reference: 'sys_user' }, + amount: { type: 'number', label: 'Amount', reference: 'rx_target' }, + } }); + expect([meta.get('owner')!.reference, meta.get('assignee')!.reference, meta.get('amount')!.reference]).toEqual([undefined, 'sys_user', undefined]); + + const findData = vi.fn(async (args: FindArgs) => (args.object === 'sys_user' && Object.values(args.query!.where!)[0] === 'Ann' ? [{ id: 'u_ann' }] : [])); + const getMetaItem = vi.fn(async ({ name }: { type: string; name: string }) => ({ type: 'object', name, item: { name } })); + const createData = vi.fn(async (a: { data: Record }) => ({ id: 'new', ...a.data })); + const p: ImportProtocolLike = { findData, getMetaItem, createData, updateData: vi.fn() }; + const summary = await runImport({ + p, objectName: 'rx_source', metaMap: meta, writeMode: 'insert', matchFields: [], dryRun: false, + runAutomations: false, trimWhitespace: true, createMissingOptions: false, skipBlankMatchKey: false, + rows: [{ owner: 'Ann', assignee: 'Ann' }], + }); + expect(summary.results.map(verdict)).toEqual([{ ok: true, code: undefined, field: undefined }]); + expect(createData.mock.calls[0]![0].data).toMatchObject({ owner: 'Ann', assignee: 'u_ann' }); + expect(getMetaItem.mock.calls.map(([r]) => r.name)).toEqual(['sys_user']); + }); +}); From 7610e850fb615603def2d0bebb3243dd4c67504a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 05:17:38 +0000 Subject: [PATCH 6/6] docs(changeset): the field set does not widen; the residual narrowing and its producer census (#22739) Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T Co-authored-by: Claude --- .../22739-import-reference-target-exposure.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/.changeset/22739-import-reference-target-exposure.md b/.changeset/22739-import-reference-target-exposure.md index 37755ee60c0..f01f64334ec 100644 --- a/.changeset/22739-import-reference-target-exposure.md +++ b/.changeset/22739-import-reference-target-exposure.md @@ -6,21 +6,27 @@ fix(core)!: an import's reference resolution asks the lookup TARGET its declared Clause-②: no (narrowing) - + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. `POST /api/v1/data/:object/import` and the async `POST /api/v1/data/:object/import/jobs` judge the ADDRESSED object's `enable` block. A lookup cell is resolved by matching its text against the lookup's TARGET object — an object nobody addressed. -**FROM.** For an administrator and a member alike, a cell was matched against a target whose declaration refuses the read on every data route (`enable.apiEnabled: false`, the deny-all `apiMethods: []`, or a whitelist without `list`): a cell naming an existing record stored its id, and a cell naming none answered `reference_not_found` — so the row report told the two apart. +**FROM.** -**TO.** The runner asks the spec's one decision (`canServeApiOperation`) of the TARGET's `enable` block, for `list` — matching a cell is a predicate read over the target, the runtime `find` the spec maps to `list`. A target the decision does not serve answers every cell, naming a record or not (a pasted id included), `reference_not_found` — the answer a target the caller cannot read (no read permission, or a record its row scope hides) already gets. Nothing of the target is read. A target with no `enable` block, or a whitelist that grants `list`, resolves exactly as before. A declaration that cannot be read withholds the target too (fail-closed). +- For an administrator and a member alike, a cell was matched against a target whose declaration refuses the read on every data route (`enable.apiEnabled: false`, the deny-all `apiMethods: []`, or a whitelist without `list`): a cell naming an existing record stored its id, and a cell naming none answered `reference_not_found` — so the row report told the two apart. +- A field of the legacy type spelling `'reference'` (not a `FieldType`, so `ObjectSchema` refuses it, but still listed in the import, export and import-template doors' own type tables) took its target from the raw `reference` key: import matched its cells against that object and export expanded it. + +**TO.** + +- The runner asks the spec's one decision (`canServeApiOperation`) of the TARGET's `enable` block, for `list` — matching a cell is a predicate read over the target, the runtime `find` the spec maps to `list`. A target the decision does not serve answers every cell, naming a record or not (a pasted id included), `reference_not_found` — the answer a target the caller cannot read (no read permission, or a record its row scope hides) already gets. Nothing of the target is read. A target with no `enable` block, or a whitelist that grants `list`, resolves exactly as before. A declaration that cannot be read withholds the target too (fail-closed). +- A field is a reference on those doors exactly when it declares a `reference` string, as before, and its target is read through the spec's arbiter `referenceTargetOf`, which names none for a type outside `lookup` / `master_detail` / `user` / `tree`: such a `'reference'`-typed field's cell is stored as written, and export leaves its stored id. Every other non-reference type was never resolved by these doors and is unchanged. Measured over every git-tracked non-test source under `packages/` and `examples/` (`.ts`, `.js`, `.json`; fixtures excluded) at `0d326bfb12`: no shipped field declares `reference` on a non-reference type (0 object literals, 0 `Field.*` helper calls; the one literal pairing `type` with `reference` is a key-classification table, not a field), against a control of 30 reference-typed literals and 80 `Field.lookup` / `masterDetail` / `user` / `tree` calls; a planted literal and a planted helper call were both found. **Bindings.** - **Every caller of `runImport` whose protocol reads object declarations.** The decision takes no user, so an administrator, a member and a system context (the connector pull) are answered the same. The target's declaration is read through the protocol's `getMetaItem`, a new optional member of `ImportProtocolLike`; a protocol without it (plugin-auth's identity import, whose reads run under the system identity) is not judged. - **No new error code, no status change.** The refusal is the per-row `reference_not_found` the resolver already answers. -- **The target is read through `referenceTargetOf`.** `buildFieldMetaMap` now takes a reference field's target from the spec's arbiter instead of the raw `reference` key, so a `user` field written without `reference` (fully specified: the type fixes the target) targets `sys_user` on the import, export and import-template doors, as it already did on the data door's `$expand`. Measured on the import door: a cell holding a user's email used to fail `reference_not_found` (the raw text reached the write as an id); it now resolves to that user. A non-string `reference` carrier, which `ObjectSchema` refuses, now throws there instead of reading as "no target". +- **Which fields count as references does not widen.** A `user` field written without `reference` still has no target on these doors (its cell reaches the write as written), although the arbiter gives it `sys_user`; adopting that target would widen what the import accepts and is not this change. **Fix, if an import relied on the old answer:** the target's own declaration decides — grant `list` in its `enable.apiMethods`, or drop `enable.apiEnabled: false`, if the object is meant to be matched by import.