From 4ee85a99f5648dee10ff24d37267f031a9b64802 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 08:34:19 +0000 Subject: [PATCH 1/6] spec(api): declare the app item read's package-id key on GetMetaItemRequestSchema.name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0048 §3.1 makes the package id the app's route key under one app per package. The request's name describe now declares the arm for type app, and isAppPackageIdKey is its one spelling (type app + MANIFEST_ID_PATTERN). Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude --- packages/spec/src/api/protocol.zod.ts | 35 ++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index af9b56ceab9..c0c927fdffc 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -38,6 +38,9 @@ import { MetadataProvenanceSchema, } from '../kernel/metadata-protection.zod'; import { MetadataValidationResultSchema } from '../kernel/metadata-plugin.zod'; +// [#23183] The package-id grammar the app item read's package-id key is +// recognised by ({@link isAppPackageIdKey}) — its one declaration. +import { MANIFEST_ID_PATTERN } from '../kernel/manifest.zod'; // [#10235] The per-column sortability projection the object read serves on its // envelope — computed at serve time, never authorable; see sortability.zod.ts. import { ObjectSortabilitySchema } from './sortability.zod'; @@ -350,7 +353,19 @@ const BUNDLE_MEMBER_SELECTOR_DESCRIPTION = */ export const GetMetaItemRequestSchema = lazySchema(() => z.object({ type: z.string().describe('Metadata type name'), - name: z.string().describe('Item name (snake_case identifier)'), + name: z.string().describe( + 'Item name: the item\'s own `name`. For `type: \'app\'` only, it may instead be the id of the ' + + 'package that ships the app (`manifest.id`, reverse-domain, e.g. `com.acme.crm`): with one app ' + + 'per package the app IS the package container, and the package id is its route key (ADR-0048 ' + + '§3.1). The two grammars are disjoint (an app name is snake_case with no dot; a package id ' + + 'matches `MANIFEST_ID_PATTERN`, with at least one dot), so an app key in the package-id grammar ' + + 'always names the package (`isAppPackageIdKey`). That read answers the package\'s one app as the ' + + 'by-name read answers it, under the app\'s own `name`, and the per-caller read gate judges it as ' + + 'it judges that app read by name (an app withheld from the caller answers 403 ' + + '`PERMISSION_DENIED`). A package with no app, or with more than one, answers as an absent item. ' + + 'A `packageId` naming a different package is refused with 400 `INVALID_REQUEST`. Every other ' + + 'type, and every other key, is read by name.', + ), packageId: z.string().optional().describe('Optional package ID to filter items by'), member: z.string().optional().describe(BUNDLE_MEMBER_SELECTOR_DESCRIPTION), state: z.enum(['active', 'draft']).optional().describe( @@ -374,6 +389,24 @@ export const GetMetaItemRequestSchema = lazySchema(() => z.object({ ), })); +/** + * [#23183] Does `name` address the app item read by PACKAGE ID — the arm + * {@link GetMetaItemRequestSchema}'s `name` declares (ADR-0048 §3.1)? True + * exactly when `type` is `app` and `name` is in the package-id grammar + * ({@link MANIFEST_ID_PATTERN}), which no app name can be: `AppSchema.name` is + * snake_case with no dot, and a package id carries at least one. So the arm is + * unambiguous, and a by-name read of every other key is unchanged. + * + * The one spelling of the rule: the producer that resolves the key + * (`getMetaItem` in `@objectstack/metadata-protocol`) asks it, and so does any + * door that must tell the arm from a by-name read — ⛔ never a second regex or + * type test beside it. `type` is the canonical SINGULAR type: a door folds + * `apps` once, at its boundary, before asking. + */ +export function isAppPackageIdKey(type: string, name: string): boolean { + return type === 'app' && MANIFEST_ID_PATTERN.test(name); +} + /** * ADR-0010 read-side protection envelope — the flags a metadata READ publishes * alongside the document, all derived in one place. From 7e62280baf12e34d6421de4b84e22fb4f1db554f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 08:38:13 +0000 Subject: [PATCH 2/6] metadata-protocol: getMetaItem answers the app read keyed by package id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0048 §3.1, as GetMetaItemRequestSchema.name declares: a type-app key in the package-id grammar resolves to the package's one app (the package-scoped list's membership), read by its own name in that package. No app, or more than one, reads the key as asked and answers absence; a packageId naming another package is refused 400 INVALID_REQUEST. Both transports' item reads reach it through the protocol with no code of their own. rest: fetchCurrentMetaDocument keeps /history, /audit and /diff by name, so the arm is the plain read's alone. Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 100 +++++++++++++++++++++ packages/rest/src/rest-server.ts | 13 +++ 2 files changed, 113 insertions(+) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index f52ee8d4739..96dc9e6ca90 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -151,6 +151,9 @@ import { planUnmanagedImport, unmanagedInstallPostureRefusal } from './package-u import type { MetadataCacheRequest, MetadataCacheResponse, ServiceInfo, ApiRoutes, WellKnownCapabilities, CapabilityDescriptor } from '@objectstack/spec/api'; import type { ApiError, BatchOperationResult } from '@objectstack/spec/api'; import { readServiceSelfInfo, readChannelRoute, readAuthFamilies, isSubscribableChannel, CHANNEL_SURFACE_SLOTS, ErrorCode, standardErrorCodeForHttpStatus, resolveDiscoveryEnvironment } from '@objectstack/spec/api'; +// [#23183] The app item read's package-id key — the one spelling of the arm +// `GetMetaItemRequestSchema.name` declares ({@link resolveAppPackageIdKey}). +import { isAppPackageIdKey } from '@objectstack/spec/api'; import { parseFilterAST, isFilterAST, VALID_AST_OPERATORS, REFERENCE_VALUE_TYPES, referenceTargetOf, AggregationFunction, DateGranularity, resolveSearchFieldResolution, @@ -10625,6 +10628,97 @@ export class ObjectStackProtocolImplementation implements }, options); } + /** + * [#23183] The packages {@link resolveAppPackageIdKey} has reported as + * holding more than one app, keyed by package and app set, so the operator + * is told once per state rather than once per read. + */ + private readonly reportedMultiAppPackages = new Set(); + + /** + * [#23183] ADR-0048 §3.1: "The route/container coordinate for an installed + * package's UI is its package id … With the current one-app-per-package + * invariant, the app *is* the package container." So the app item read + * answers for a package id as it answers for the app's own name, as + * `GetMetaItemRequestSchema.name` declares. This is the one place the key is + * resolved: both transports' item reads hand {@link getMetaItem} the key + * they were asked for, and neither resolves it itself. + * + * Recognised by `isAppPackageIdKey` (`@objectstack/spec/api`): `type: 'app'` + * and a key in the package-id grammar, which no app name is in. Every other + * request is returned untouched, so every by-name read of every type is + * unchanged. + * + * "The package's app" is what the package-scoped list serves + * ({@link getMetaItems} with `packageId`, the read behind + * `GET /meta/app?package=`): the apps the package ships, its stored rows + * included, none for a disabled package. Its drafts are in view exactly + * when this read reads drafts (`state: 'draft'` or `previewDrafts`), so the + * key resolves to the app the read itself would serve. + * + * - ONE app: the request becomes that app's by-name read, scoped to the + * package (`packageId`, ADR-0048 prefer-local). The envelope names the + * app by its own `name`, the address a save of it uses and the item its + * `version` token belongs to. + * - NONE, or MORE THAN ONE (a package breaking ADR-0019 D3's at-most-one + * rule, which the save door does not enforce): the request is returned + * as asked, and its by-name read answers absence, because no legal app + * name is in the package-id grammar. Absence, and no answer of its own: + * the doors' read gate answers an unpublished app as absent (ADR-0045 + * §3), and a separate "two apps" answer would let a caller see an app + * being built in the package before it is published. The operator is + * told instead, in the log. + * + * A `packageId` naming a different package is refused `400 INVALID_REQUEST`: + * the key already names the package, and a prefer-local scope from a + * second package would read that package's same-name app, not this one's. + * + * A failed list read propagates: a store outage is never answered as "no + * such package" (#5532). + */ + private async resolveAppPackageIdKey(request: R): Promise { + if (!isAppPackageIdKey(request.type, request.name)) return request; + const packageId = request.name; + if (request.packageId !== undefined && request.packageId !== packageId) { + const err: any = new Error( + `The app read's key '${packageId}' is a package id, and it names a different package ` + + `than packageId '${request.packageId}'. Name the package once: read the app by its ` + + `package id alone, or by its own app name with packageId as its scope.`, + ); + err.code = 'INVALID_REQUEST'; + err.status = 400; + throw err; + } + const readsDrafts = request.state === 'draft' || request.previewDrafts === true; + const listed = await this.getMetaItems({ + type: 'app', + packageId, + ...(readsDrafts ? { previewDrafts: true } : {}), + }); + const names = [...new Set( + ((listed as { items?: unknown[] } | undefined)?.items ?? []) + .map((app) => (app as { name?: unknown } | null)?.name) + .filter((name): name is string => typeof name === 'string' && name.length > 0), + )]; + if (names.length === 1) return { ...request, name: names[0], packageId }; + if (names.length > 1) { + const key = `${packageId}|${[...names].sort().join(',')}`; + if (!this.reportedMultiAppPackages.has(key)) { + this.reportedMultiAppPackages.add(key); + console.warn( + `[metadata-protocol] Package '${packageId}' holds ${names.length} apps ` + + `(${[...names].sort().join(', ')}), so reading the app by its package id answers ` + + `"not found": with one app per package the app is the package container ` + + `(ADR-0048 §3.1), and a package may define at most one app (ADR-0019 D3). ` + + `Fold them into one app, or move all but one into packages of their own.`, + ); + } + } + return request; + } + async getMetaItem(request: { type: string, name: string, packageId?: string, state?: 'active' | 'draft', previewDrafts?: boolean, /** [#22877] The bundle member to read — {@link resolveBundleMemberSelector}; omitted, the canonical one. */ @@ -10632,6 +10726,12 @@ export class ObjectStackProtocolImplementation implements }) { // #4432 — CANONICAL TYPE KEY. See {@link canonicalMetaType}. request = canonicalizeMetaRequestType(request); + // [#23183] ADR-0048 §3.1: the app read keyed by PACKAGE ID, resolved + // ONCE, here, to the package's one app read by its own name in that + // package, so every step below (the stored row, the lock, the version + // token) reads the app exactly as its by-name read does. See + // {@link resolveAppPackageIdKey}. + request = await this.resolveAppPackageIdKey(request); // [#22877] Ruling 6116165299 (Q2 i): ONE member of a bundled type is // addressed by name, the selector's or else the canonical one. Every // layer below answers that member or nothing — the stored row, the diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index b59453d43cd..7d2ecdfa1f2 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -133,6 +133,9 @@ import { HistoryMetaItemRequestSchema, AuditMetaItemRequestSchema, } from '@objectstack/spec/api'; +// [#23183] The app item read's package-id key, spelled once in the spec — see +// `fetchCurrentMetaDocument`, whose doors stay by-name. +import { isAppPackageIdKey } from '@objectstack/spec/api'; // [commit 2a29caa53] Declared request shapes for the meta-read doors below — imported so // each door's request literal is compiled against the spec contract instead of // being smuggled past it with `as any` (see `TransportScopedMetaRequest`). @@ -3505,11 +3508,21 @@ export class RestServer { * serves no document of its own (`/history` and `/audit` serve events, * `/diff` a comparison) and so judges the item the plain read would judge. * `undefined` when nothing is behind the name. + * + * [#23183] …by NAME. These doors read their own data (events, the diff) + * under the literal `:name`, so the document they judge is that name's. + * The app item read's package-id key (ADR-0048 §3.1, + * `GetMetaItemRequestSchema.name`) is the plain read's arm, not theirs: the + * protocol would answer it with the package's app under the APP's name, + * which is not the item these doors read. Such a key is answered as no + * current document, exactly as it was before the arm existed: no app name + * is in the package-id grammar, so nothing is stored under it. */ private async fetchCurrentMetaDocument( req: any, p: RestProtocol, ): Promise { + if (isAppPackageIdKey(RestServer.metaTypeSingular(req.params.type), req.params.name)) return undefined; const currentRequest: GetMetaItemRequest = { type: req.params.type, name: req.params.name, From 245be87d94cfd2c716494d9dba9ab8f69f5c2cd7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 08:46:39 +0000 Subject: [PATCH 3/6] metadata-protocol: pin getMetaItem's package-id key for app One app, the by-name read, no app / unknown / two apps (absence, one operator report), a packageId naming another package (400 INVALID_REQUEST), the grammar on another type, and drafts. Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude --- .../src/protocol.app-package-id-key.test.ts | 217 ++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 packages/metadata-protocol/src/protocol.app-package-id-key.test.ts diff --git a/packages/metadata-protocol/src/protocol.app-package-id-key.test.ts b/packages/metadata-protocol/src/protocol.app-package-id-key.test.ts new file mode 100644 index 00000000000..1506853188a --- /dev/null +++ b/packages/metadata-protocol/src/protocol.app-package-id-key.test.ts @@ -0,0 +1,217 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#23183, ADR-0048 §3.1] `getMetaItem` answers the app read keyed by PACKAGE + * ID, as `GetMetaItemRequestSchema.name` declares. + * + * ADR-0048 §3.1 makes the package id the app's route key ("With the current + * one-app-per-package invariant, the app *is* the package container"), so the + * console addresses an app at `/apps/`. No declared read answered an + * app by that key: `/meta/app/com.acme.crm` asked for an app NAMED + * `com.acme.crm` and was answered absence whether the package's app existed, + * was withheld, or was missing. + * + * The producer resolves the key once (`resolveAppPackageIdKey`), so both + * transports' item reads reach it with no code of their own; their wire + * answers are pinned in `@objectstack/runtime`'s + * `meta-app-package-id-key.test.ts`. This file pins the producer: + * + * 1. one app in the package: the package's app, under its own name, the same + * item the by-name read serves (the plural type spelling included); + * 2. the by-name read is unchanged; + * 3. no app, an unknown package, and more than one app: absence (the last + * told to the operator once, never answered differently); + * 4. a `packageId` naming a different package: `400 INVALID_REQUEST`; + * 5. a key in the package-id grammar on another type: read by name; + * 6. drafts are in view exactly when the read reads drafts. + * + * The engine double is `protocol.list-slot-prefer-local.test.ts`'s: `find` / + * `findOne` over `sys_metadata` rows, and a registry answering a + * package-scoped lookup with that package's items only. + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { assertEngineFindOnePredicate, isCodeArtifactBody } from '@objectstack/metadata-core'; +import { ObjectStackProtocolImplementation } from './protocol.js'; + +const CRM_PKG = 'com.acme.crm'; +const CRM_APP = { name: 'crm', label: 'CRM', navigation: [], _packageId: CRM_PKG, _provenance: 'package' }; +/** A package that breaks ADR-0019 D3's at-most-one rule. */ +const SUITE_PKG = 'com.acme.suite'; +const SUITE_APPS = [ + { name: 'suite_sales', label: 'Sales', navigation: [], _packageId: SUITE_PKG, _provenance: 'package' }, + { name: 'suite_service', label: 'Service', navigation: [], _packageId: SUITE_PKG, _provenance: 'package' }, +]; +/** A package that ships no app. */ +const LIB_PKG = 'com.acme.lib'; +/** A package whose only app is a pending draft row — authored, never published. */ +const DRAFT_PKG = 'com.acme.drafted'; +const DRAFT_APP_NAME = 'drafted'; +/** + * A `view` whose name is in the package-id grammar (`.`), and + * a package of that very id shipping an app: if the arm fired on `view`, the + * read would resolve that package and miss the view. + */ +const VIEW_KEY = 'account.pipeline'; +const VIEW_ITEM = { name: VIEW_KEY, label: 'Pipeline', object: 'account', viewKind: 'list', _packageId: 'com.acme.views', _provenance: 'package' }; +const SHADOW_APP = { name: 'pipeline_app', label: 'Pipeline app', navigation: [], _packageId: VIEW_KEY, _provenance: 'package' }; + +interface StoredRow { + id: string; + type: string; + name: string; + organization_id: string | null; + package_id: string | null; + state: string; + metadata: string; +} + +const STORED: StoredRow[] = [{ + id: 'r_drafted_draft', + type: 'app', + name: DRAFT_APP_NAME, + organization_id: null, + package_id: DRAFT_PKG, + state: 'draft', + metadata: JSON.stringify({ name: DRAFT_APP_NAME, label: 'Drafted', navigation: [] }), +}]; + +function harness() { + const items: Array & { name: string }> = [CRM_APP, ...SUITE_APPS, VIEW_ITEM, SHADOW_APP]; + const typeOf = (item: Record) => (item === VIEW_ITEM ? 'view' : 'app'); + const ofType = (asked: string, packageId?: string) => + items.filter((i) => typeOf(i) === asked && (!packageId || i._packageId === packageId)); + const registry = { + getArtifactItem(asked: string, name: string, packageId?: string) { + const hit = ofType(asked, packageId).find((i) => i.name === name); + return hit && isCodeArtifactBody(hit) ? hit : undefined; + }, + getItem(asked: string, name: string, packageId?: string) { + return ofType(asked, packageId).find((i) => i.name === name); + }, + listItems(asked: string, packageId?: string) { + return ofType(asked, packageId); + }, + getObject: () => undefined, + registerObject: () => undefined, + getPackage: () => undefined, + isPackageDisabled: () => false, + applyNavContributions: (app: unknown) => app, + }; + const matching = (where: Record) => { + for (const k of Object.keys(where)) { + if (k.startsWith('$')) throw new Error(`[test double] unsupported WHERE combinator '${k}'`); + } + return STORED.filter((r) => + Object.entries(where).every(([k, v]) => v === undefined || (r as unknown as Record)[k] === v), + ); + }; + const engine: any = { + async find(table: string, opts?: { where?: Record; limit?: number }) { + if (table !== 'sys_metadata') return []; + const matched = matching(opts?.where ?? {}); + // `check:objectql-double-limit` — the caller's bound, applied after the filter. + return opts?.limit === undefined ? matched : matched.slice(0, opts.limit); + }, + async findOne(table: string, opts?: { where?: Record }) { + // `check:engine-double-contract` — refuses what the real engine refuses. + assertEngineFindOnePredicate(table, opts); + if (table !== 'sys_metadata') return null; + return matching(opts?.where ?? {})[0] ?? null; + }, + async insert() { + return {}; + }, + registry, + }; + return new ObjectStackProtocolImplementation(engine, () => new Map(), 'env_1'); +} + +/** The envelope's item, with nothing but its identity read off it. */ +const itemName = (envelope: any): unknown => envelope?.item?.name; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('[#23183] getMetaItem answers an app keyed by its package id (ADR-0048 §3.1)', () => { + it('one app in the package: that app, under its own name — the item the by-name read serves', async () => { + const protocol = harness(); + const byPackage: any = await protocol.getMetaItem({ type: 'app', name: CRM_PKG }); + const byName: any = await protocol.getMetaItem({ type: 'app', name: 'crm' }); + + expect(byPackage.type).toBe('app'); + // The envelope names the app by its own name: the address a save of it + // uses, never the key it was asked by. + expect(byPackage.name).toBe('crm'); + expect(itemName(byPackage)).toBe('crm'); + expect(byPackage.item._packageId).toBe(CRM_PKG); + expect(byPackage.item).toEqual(byName.item); + }); + + it('the plural type spelling is folded first, so `apps` reaches the arm too', async () => { + const protocol = harness(); + const envelope: any = await protocol.getMetaItem({ type: 'apps', name: CRM_PKG }); + expect(envelope.type).toBe('app'); + expect(itemName(envelope)).toBe('crm'); + }); + + it('the by-name read is unchanged: an app name is never in the package-id grammar', async () => { + const protocol = harness(); + const envelope: any = await protocol.getMetaItem({ type: 'app', name: 'crm' }); + expect(envelope.name).toBe('crm'); + expect(itemName(envelope)).toBe('crm'); + }); + + it('a package that ships no app, and a package nobody installed: absence', async () => { + const protocol = harness(); + for (const key of [LIB_PKG, 'com.acme.missing']) { + const envelope: any = await protocol.getMetaItem({ type: 'app', name: key }); + expect(envelope.item, key).toBeUndefined(); + } + }); + + it('a package holding more than one app: absence, and the operator is told once, naming the package and its apps', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const protocol = harness(); + + const first: any = await protocol.getMetaItem({ type: 'app', name: SUITE_PKG }); + const second: any = await protocol.getMetaItem({ type: 'app', name: SUITE_PKG }); + expect(first.item).toBeUndefined(); + expect(second.item).toBeUndefined(); + + const reports = warn.mock.calls.map((args) => String(args[0])).filter((line) => line.includes(SUITE_PKG)); + expect(reports).toHaveLength(1); + for (const app of SUITE_APPS) expect(reports[0]).toContain(app.name); + // Each app stays readable by its own name. + for (const app of SUITE_APPS) { + expect(itemName(await protocol.getMetaItem({ type: 'app', name: app.name }))).toBe(app.name); + } + }); + + it('a packageId naming a different package is refused 400 INVALID_REQUEST; the same package is the same read', async () => { + const protocol = harness(); + await expect(protocol.getMetaItem({ type: 'app', name: CRM_PKG, packageId: SUITE_PKG })) + .rejects.toMatchObject({ code: 'INVALID_REQUEST', status: 400 }); + + const same: any = await protocol.getMetaItem({ type: 'app', name: CRM_PKG, packageId: CRM_PKG }); + expect(itemName(same)).toBe('crm'); + }); + + it('a key in the package-id grammar on another type is read by name, even where a package of that id ships an app', async () => { + const protocol = harness(); + const envelope: any = await protocol.getMetaItem({ type: 'view', name: VIEW_KEY }); + expect(envelope.name).toBe(VIEW_KEY); + expect(itemName(envelope)).toBe(VIEW_KEY); + }); + + it('drafts are in view exactly when the read reads drafts: a draft-only app resolves on the draft read, not on the plain one', async () => { + const protocol = harness(); + + const draft: any = await protocol.getMetaItem({ type: 'app', name: DRAFT_PKG, state: 'draft' }); + expect(draft.name).toBe(DRAFT_APP_NAME); + expect(itemName(draft)).toBe(DRAFT_APP_NAME); + + const plain: any = await protocol.getMetaItem({ type: 'app', name: DRAFT_PKG }); + expect(plain.item).toBeUndefined(); + }); +}); From 6cb23994798a67e5b60df692d10765f05a12d7fb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 09:01:20 +0000 Subject: [PATCH 4/6] runtime: pin the app read by package id on both hosts; changeset RestServer and the hono catch-all over one real store each: a member on a withheld app's package id gets the by-name 403, a holder the by-name item, an unknown or unpublished package the absence answer, and a dotted key on another type its own item. RestServer's /history, /audit and /diff stay by name. Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude --- .../23183-app-item-read-by-package-id.md | 21 ++ .../domains/meta-app-package-id-key.test.ts | 306 ++++++++++++++++++ 2 files changed, 327 insertions(+) create mode 100644 .changeset/23183-app-item-read-by-package-id.md create mode 100644 packages/runtime/src/domains/meta-app-package-id-key.test.ts diff --git a/.changeset/23183-app-item-read-by-package-id.md b/.changeset/23183-app-item-read-by-package-id.md new file mode 100644 index 00000000000..a7bcf0d62ea --- /dev/null +++ b/.changeset/23183-app-item-read-by-package-id.md @@ -0,0 +1,21 @@ +--- +'@objectstack/spec': minor +'@objectstack/metadata-protocol': minor +'@objectstack/rest': patch +--- + +feat(spec): the app item read answers by package id. `GET /meta/app/` returns the package's app, or the same 403 / 404 the read by name gives (ADR-0048 §3.1) + +Clause-②: yes (widening) + +ADR-0048 §3.1 makes an installed package's id the route key of its UI. With one app per package the app is the package container, so the console opens an app at `/apps/`. The app item read now accepts that key too. + +- **`GetMetaItemRequestSchema.name`** declares it. For `type: 'app'` only, a key in the package-id grammar (`MANIFEST_ID_PATTERN`, at least one dot) names the package. App names are snake_case with no dot, so the two grammars never overlap and every read by name is unchanged. The new export `isAppPackageIdKey(type, name)` is the one test for the arm. +- **`getMetaItem`** (`@objectstack/metadata-protocol`) resolves it to the package's one app, as the package-scoped list (`GET /meta/app?package=`) serves it, read by its own name in that package. The response's `name` is the app's name. Both hosts' item reads (`RestServer` and the `@objectstack/hono` catch-all) answer it with no change of their own, and the per-caller read gate judges the app exactly as it does by name: + - a caller the app is withheld from gets `403 PERMISSION_DENIED`; + - an unknown package, a package with no app, a package whose app is unpublished, and a package holding more than one app all get the `404` absence answer, byte-identical to the one for an unknown app name. A package holding more than one app (ADR-0019 D3 allows at most one) is also reported once in the server log, naming its apps; + - a caller who holds the app is served it. +- A `packageId` (`?package=`) naming a different package than the key is refused with `400 INVALID_REQUEST`. +- `/meta/app//history`, `/audit` and `/diff` stay by name (`@objectstack/rest`): the package-id key belongs to the plain item read only. + +From a client: `client.meta.getItem('app', 'com.acme.crm')`. diff --git a/packages/runtime/src/domains/meta-app-package-id-key.test.ts b/packages/runtime/src/domains/meta-app-package-id-key.test.ts new file mode 100644 index 00000000000..1e014390d73 --- /dev/null +++ b/packages/runtime/src/domains/meta-app-package-id-key.test.ts @@ -0,0 +1,306 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#23183, ADR-0048 §3.1] The app access verdict is answerable by PACKAGE ID, + * on both hosts: `RestServer`'s `GET /meta/:type/:name` and the runtime + * dispatcher's `/meta` item read (the only answer on a host that mounts just + * `@objectstack/hono`'s `${prefix}/*` catch-all). + * + * ## What was missing + * + * ADR-0048 §3.1 makes the package id the app's route key, so the console opens + * an app at `/apps/`, and when that app is not in the caller's list + * it must tell a withheld app (403) from a missing one (404). By name the read + * already answers that (`/meta/app/payroll` → `403 PERMISSION_DENIED`). By + * package id nothing did: `/meta/app/com.acme.payroll` asked for an app NAMED + * `com.acme.payroll` and answered 404 whatever the package held, and the list + * read (`?package=`) drops a withheld app, so it reads as a missing one. + * + * ## The fix, and why neither door carries it + * + * `GetMetaItemRequestSchema.name` declares the arm (`type: 'app'`, a key in + * the package-id grammar, `isAppPackageIdKey`), and its producer resolves it: + * `getMetaItem` in `@objectstack/metadata-protocol` reads the package's one + * app by its own name. Both doors hand the protocol the key they were asked + * for, so each gets the arm with no code of its own, and the per-caller read + * gate judges the resolved app exactly as it judges the by-name read. These + * rows drive both doors over one real store each and hold the two equal, and + * equal to the by-name read. + * + * ## The rig + * + * `meta-package-all-fold.test.ts`'s: per door, a REAL store (better-sqlite3 + * `:memory:`, the real `sys_metadata*` objects, a real + * `ObjectStackProtocolImplementation`), with the apps registered the way a + * code package registers them (`registry.registerApp(app, packageId)`), and + * the caller supplied at each door's identity seam. + * + * `RestServer` also serves three doors the dispatcher does not, `/history`, + * `/audit` and `/diff`, which judge "the item the plain read would judge" but + * read their own data by the literal `:name`. The arm is the plain read's, not + * theirs: the last rows pin them by name, as before the arm. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { SysMetadata, SysMetadataHistoryObject, SysMetadataAuditObject } from '@objectstack/platform-objects/metadata'; +import { RestServer } from '@objectstack/rest'; +import { HttpDispatcher } from '../http-dispatcher.js'; + +const PREFIX = '/api/v1'; +const META = `${PREFIX}/meta`; +/** `registry.registerObject` requires a package id (see the sibling real-stack tests). */ +const TEST_PACKAGE_ID = 'objectstack-test'; + +/** A package whose app the member does not hold (`requiredPermissions`). */ +const PAYROLL_PKG = 'com.acme.payroll'; +const PAYROLL_APP = { + name: 'payroll', + label: 'Payroll', + requiredPermissions: ['payroll.access'], + navigation: [{ id: 'nav_payroll_runs', type: 'page', label: 'Runs', pageName: 'payroll_runs' }], +}; +/** A package whose app is mid-build (ADR-0045 §3: externally unobservable). */ +const BUILD_PKG = 'com.acme.build'; +const BUILD_APP = { + name: 'production_management', + label: 'Production', + _unpublished: true, + navigation: [{ id: 'nav_lines', type: 'page', label: 'Lines', pageName: 'lines' }], +}; +/** No package of this id is installed. */ +const MISSING_PKG = 'com.acme.missing'; +/** + * A `view` whose name is in the package-id grammar (`.`), and + * a package of that very id shipping an app: were the arm to fire on `view`, + * the read would resolve that package and miss the view. + */ +const VIEW_KEY = 'task.grid'; +const SHADOW_APP = { name: 'grid_app', label: 'Grid app', navigation: [] }; + +const TASK = { + name: 'task', + label: 'Task', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true, label: 'ID' }, + name: { name: 'name', type: 'text' as const, label: 'Name' }, + }, +}; + +const CALLERS = { + member: { userId: 'u_member', isSystem: false, systemPermissions: [] as string[] }, + holder: { userId: 'u_holder', isSystem: false, systemPermissions: ['payroll.access'] }, + /** + * `/history`, `/audit` and `/diff` admit an authoring capability first, so + * their rows need callers past that door: one who does not hold the app… + */ + builder: { userId: 'u_builder', isSystem: false, systemPermissions: ['studio.access'] }, + /** …and one who does. */ + builderHolder: { userId: 'u_builder_holder', isSystem: false, systemPermissions: ['studio.access', 'payroll.access'] }, +} as const; +type CallerName = keyof typeof CALLERS; + +const clone = (v: T): T => JSON.parse(JSON.stringify(v)); + +const liveEngines: ObjectQL[] = []; +afterEach(async () => { + while (liveEngines.length) { + try { await liveEngines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +/** One real store: the apps registered as their code packages register them, and one stored view. */ +async function bootStore() { + const engine = new ObjectQL(); + liveEngines.push(engine); + engine.registerDriver(new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }), true); + await engine.init(); + engine.registry.registerObject(TASK as any, TEST_PACKAGE_ID); + engine.registry.registerObject(SysMetadata as any, TEST_PACKAGE_ID); + engine.registry.registerObject(SysMetadataHistoryObject as any, TEST_PACKAGE_ID); + engine.registry.registerObject(SysMetadataAuditObject as any, TEST_PACKAGE_ID); + await engine.syncSchemas(); + engine.registry.registerApp(clone(PAYROLL_APP), PAYROLL_PKG); + engine.registry.registerApp(clone(BUILD_APP), BUILD_PKG); + engine.registry.registerApp(clone(SHADOW_APP), VIEW_KEY); + const protocol = new ObjectStackProtocolImplementation(engine as any); + await protocol.saveMetaItem({ + type: 'view', + name: VIEW_KEY, + item: { name: VIEW_KEY, label: 'Grid', object: 'task', viewKind: 'list', columns: [{ field: 'name', label: 'Name' }] }, + } as any); + return { protocol }; +} + +// ── The two doors ───────────────────────────────────────────────────────────── + +interface Answer { status: number; code?: string; body: any; envelope?: any } +interface Door { read(who: CallerName, path: string): Promise } + +/** The refusal's `code` in either door's envelope: nested under `error`, or beside a string `error`. */ +const codeOf = (body: any): string | undefined => + body?.error && typeof body.error === 'object' ? body.error.code : body?.code; + +function makeRes() { + const res: any = { statusCode: 200, body: undefined, headers: {} as Record }; + res.status = (code: number) => { res.statusCode = code; return res; }; + res.json = (body: any) => { res.body = body; return res; }; + res.send = () => res; + res.end = () => res; + res.header = (k: string, v: string) => { res.headers[k] = v; return res; }; + res.setHeader = () => {}; + res.write = () => true; + return res; +} + +/** `RestServer`'s route for a path below `/meta`, and the params the adapter would hand it. */ +function restRouteOf(path: string): { route: string; params: Record } { + const s = path.replace(/^\/meta\//, '').split('/'); + if (s.length === 3) return { route: `${META}/:type/:name/${s[2]}`, params: { type: s[0], name: s[1] } }; + if (s.length === 2) return { route: `${META}/:type/:name`, params: { type: s[0], name: s[1] } }; + throw new Error(`no RestServer route for ${path}`); +} + +async function bootRestServer(): Promise { + const { protocol } = await bootStore(); + const noop = () => {}; + const server = { get: noop, post: noop, put: noop, delete: noop, patch: noop, use: noop, listen: async () => {}, close: async () => {} }; + const rest: any = new RestServer(server as any, protocol as any, { api: { requireAuth: false } } as any); + let caller: CallerName = 'member'; + rest.resolveExecCtx = async () => clone(CALLERS[caller]); + rest.registerRoutes(); + return { + read: async (who, path) => { + const { route, params } = restRouteOf(path); + const entry = rest.getRoutes().find((r: any) => r.method === 'GET' && r.path === route); + if (!entry) throw new Error(`GET ${route} is not registered`); + caller = who; + const res = makeRes(); + await entry.handler({ method: 'GET', path: `${PREFIX}${path}`, params, query: {}, headers: {}, body: undefined }, res); + return { + status: res.statusCode, + code: codeOf(res.body), + body: res.body, + envelope: res.statusCode === 200 ? res.body : undefined, + }; + }, + }; +} + +async function bootCatchAll(): Promise { + const { protocol } = await bootStore(); + const get = (n: string) => (n === 'protocol' ? protocol : null); + const kernel: any = { context: { getService: get }, getService: get, getServiceAsync: async (n: string) => get(n) }; + const dispatcher = new HttpDispatcher(kernel); + let caller: CallerName = 'member'; + // The seam `dispatch()` resolves identity through (the sibling + // dispatch-level pins supply the principal the same way). + (dispatcher as any).timedResolveExecutionContext = async () => clone(CALLERS[caller]); + return { + read: async (who, path) => { + caller = who; + // What `createHonoApp`'s catch-all hands `dispatch()`: the path below + // the prefix, the query, and the raw Fetch `Request`. + const raw = new Request(new URL(`http://localhost${PREFIX}${path}`), { method: 'GET' }); + const res = await dispatcher.dispatch('GET', path, undefined, {}, { request: raw } as any, PREFIX); + const status = res.response?.status ?? 0; + const body = res.response?.body; + return { status, code: codeOf(body), body, envelope: status === 200 ? body?.data : undefined }; + }, + }; +} + +const DOORS: ReadonlyArray Promise]> = [ + ['RestServer', bootRestServer], + ['the @objectstack/hono catch-all', bootCatchAll], +]; + +const text = (a: Answer): string => JSON.stringify(a.body ?? null); + +describe.each(DOORS)('[#23183] %s: the app item read answers by package id', (_door, boot) => { + it('a member on a withheld app\'s package id: 403 PERMISSION_DENIED, the by-name read\'s verdict', async () => { + const door = await boot(); + const byPackage = await door.read('member', `/meta/app/${PAYROLL_PKG}`); + const byName = await door.read('member', '/meta/app/payroll'); + + expect(byName.status, text(byName)).toBe(403); + expect(byName.code).toBe('PERMISSION_DENIED'); + expect(byPackage.status, text(byPackage)).toBe(403); + expect(byPackage.code).toBe('PERMISSION_DENIED'); + // A refusal, never the document. + expect(text(byPackage)).not.toContain('nav_payroll_runs'); + }, 60_000); + + it('a holder is served the app, under its own name — the item the by-name read serves', async () => { + const door = await boot(); + const byPackage = await door.read('holder', `/meta/app/${PAYROLL_PKG}`); + const byName = await door.read('holder', '/meta/app/payroll'); + + expect(byName.status, text(byName)).toBe(200); + expect(byPackage.status, text(byPackage)).toBe(200); + expect(byPackage.envelope?.name).toBe('payroll'); + expect(byPackage.envelope?.item?.name).toBe('payroll'); + expect(byPackage.envelope?.item).toEqual(byName.envelope?.item); + }, 60_000); + + it('an unknown package id: 404, the door\'s own answer for an app that is not there', async () => { + const door = await boot(); + const byPackage = await door.read('holder', `/meta/app/${MISSING_PKG}`); + const byName = await door.read('holder', '/meta/app/no_such_app'); + + expect(byPackage.status, text(byPackage)).toBe(404); + expect(byName.status, text(byName)).toBe(404); + expect(text(byPackage)).toBe(text(byName)); + }, 60_000); + + it('a non-holder learns nothing more than by name: a package whose app is unpublished answers exactly as an unknown package', async () => { + const door = await boot(); + const unpublished = await door.read('member', `/meta/app/${BUILD_PKG}`); + const unknown = await door.read('member', `/meta/app/${MISSING_PKG}`); + + expect(unpublished.status, text(unpublished)).toBe(404); + expect(text(unpublished)).toBe(text(unknown)); + }, 60_000); + + it('a key in the package-id grammar on another type is read by name, even where a package of that id ships an app', async () => { + const door = await boot(); + const answer = await door.read('holder', `/meta/view/${VIEW_KEY}`); + + expect(answer.status, text(answer)).toBe(200); + expect(answer.envelope?.item?.name).toBe(VIEW_KEY); + }, 60_000); +}); + +describe('[#23183] RestServer: /history, /audit and /diff stay by name — the arm is the plain read\'s', () => { + it('a builder who does not hold the app reads a package id\'s history and audit as an unknown key\'s, never the plain read\'s 403', async () => { + const door = await bootRestServer(); + // The control: the plain read refuses this caller the app by package id. + const plain = await door.read('builder', `/meta/app/${PAYROLL_PKG}`); + expect(plain.status, text(plain)).toBe(403); + for (const sub of ['history', 'audit']) { + const byPackage = await door.read('builder', `/meta/app/${PAYROLL_PKG}/${sub}`); + const unknown = await door.read('builder', `/meta/app/${MISSING_PKG}/${sub}`); + expect(unknown.status, `${sub} ${text(unknown)}`).toBe(200); + expect(byPackage.status, `${sub} ${text(byPackage)}`).toBe(200); + // The same answer, but for the key each echoes. + expect(text(byPackage).split(PAYROLL_PKG).join('KEY'), sub) + .toBe(text(unknown).split(MISSING_PKG).join('KEY')); + } + }, 60_000); + + it('a holder\'s /diff of a package id is the absence answer, as for an unknown key — never an empty diff of the app', async () => { + const door = await bootRestServer(); + const byPackage = await door.read('builderHolder', `/meta/app/${PAYROLL_PKG}/diff`); + const unknown = await door.read('builderHolder', `/meta/app/${MISSING_PKG}/diff`); + + expect(byPackage.status, text(byPackage)).toBe(404); + expect(byPackage.code).toBe('RESOURCE_NOT_FOUND'); + expect(text(byPackage)).toBe(text(unknown)); + }, 60_000); +}); From 8c9e60b19c81fce2029c1ea56cebf650daad5088 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 09:20:23 +0000 Subject: [PATCH 5/6] spec: regenerate api-surface, export-origins and the protocol reference The three artifacts check:generated proved stale: isAppPackageIdKey is added (0 breaking, 1 added), and GetMetaItemRequest's name describe moves in the reference page. Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude --- content/docs/references/api/protocol.mdx | 2 +- packages/spec/api-surface/api.json | 1 + packages/spec/export-origins/api.json | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index c83fd2fc8bb..2b46feda9ad 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1424,7 +1424,7 @@ This schema accepts one of the following structures: | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **type** | `string` | ✅ | Metadata type name | -| **name** | `string` | ✅ | Item name (snake_case identifier) | +| **name** | `string` | ✅ | Item name: the item's own `name`. For `type: 'app'` only, it may instead be the id of the package that ships the app (`manifest.id`, reverse-domain, e.g. `com.acme.crm`): with one app per package the app IS the package container, and the package id is its route key (ADR-0048 §3.1). The two grammars are disjoint (an app name is snake_case with no dot; a package id matches `MANIFEST_ID_PATTERN`, with at least one dot), so an app key in the package-id grammar always names the package (`isAppPackageIdKey`). That read answers the package's one app as the by-name read answers it, under the app's own `name`, and the per-caller read gate judges it as it judges that app read by name (an app withheld from the caller answers 403 `PERMISSION_DENIED`). A package with no app, or with more than one, answers as an absent item. A `packageId` naming a different package is refused with 400 `INVALID_REQUEST`. Every other type, and every other key, is read by name. | | **packageId** | `string` | optional | Optional package ID to filter items by | | **member** | `string` | optional | Bundle member selector, for a metadata type whose identity is a pair (today only `email_template`, identified by `(name, locale)`): the member this request addresses, spelled as its discriminator value (`zh-CN`). Absent, the canonical member (`en-US` for `email_template`), which is what a bare-name read answers; naming the canonical value is the same request. It is not the response translation locale, which `?locale=` and `Accept-Language` choose. Refused with 400 `VALIDATION_ERROR` on a type that has no members, and for a blank value or one longer than a stored member can be (100 characters). A well-formed member with nothing stored or shipped answers as that door answers an absent item. | | **state** | `Enum<'active' \| 'draft'>` | optional | Draft-visibility switch — which lifecycle row to read (strict mode): `'draft'` opens the pending draft buffer (Studio's editor read) and fails when no draft exists; absent or `'active'` reads the live published row. Distinct from `previewDrafts`, which FALLS BACK to the active row when no draft exists. Declaration ≠ authorization: this member only selects which stored row is read — ADR-0106 masking is unaffected, and draft access is gated upstream, not by this schema. | diff --git a/packages/spec/api-surface/api.json b/packages/spec/api-surface/api.json index 26368c465fb..09a5349ce4a 100644 --- a/packages/spec/api-surface/api.json +++ b/packages/spec/api-surface/api.json @@ -1098,6 +1098,7 @@ "getAuthEndpointUrl (function)", "getDefaultRouteRegistrations (function)", "identityFreeEndpointGateFailure (function)", + "isAppPackageIdKey (function)", "isSubscribableChannel (function)", "makeApiErrorSchema (function)", "normalizeEndpointPath (function)", diff --git a/packages/spec/export-origins/api.json b/packages/spec/export-origins/api.json index 6c44350c154..5f27a330a1a 100644 --- a/packages/spec/export-origins/api.json +++ b/packages/spec/export-origins/api.json @@ -1051,6 +1051,7 @@ "getAuthEndpointUrl": "src/api/auth-endpoints.zod.ts#getAuthEndpointUrl (function)", "getDefaultRouteRegistrations": "src/api/plugin-rest-api.zod.ts#getDefaultRouteRegistrations (function)", "identityFreeEndpointGateFailure": "src/api/endpoint-publish-gate.ts#identityFreeEndpointGateFailure (function)", + "isAppPackageIdKey": "src/api/protocol.zod.ts#isAppPackageIdKey (function)", "isSubscribableChannel": "src/api/discovery.zod.ts#isSubscribableChannel (function)", "makeApiErrorSchema": "src/api/contract.zod.ts#makeApiErrorSchema (function)", "normalizeEndpointPath": "src/api/endpoint.zod.ts#normalizeEndpointPath (function)", From 7b8b2dfc8fd7d6c3996b767e6390fabda13c9c6b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 09:54:04 +0000 Subject: [PATCH 6/6] scripts: record the new pinned findOne double in the engine-double ledger check:engine-double-contract RETAINED verdict for protocol.app-package-id-key.test.ts: one new pinned findOne double, recorded with --write (no seam lost). Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude --- scripts/engine-double-contract.pinned.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 5f2231c8da2..a298069e707 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -261,6 +261,11 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/metadata-protocol/src/protocol.app-package-id-key.test.ts", + "verb": "findOne", + "pinned": 1 + }, { "file": "packages/metadata-protocol/src/protocol.audit-field-governance.test.ts", "verb": "delete",