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/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/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(); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 51cef72d056..fb4026d729f 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, @@ -10624,6 +10627,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. */ @@ -10631,6 +10725,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, 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); +}); 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)", 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. 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",