Skip to content
21 changes: 21 additions & 0 deletions .changeset/23183-app-item-read-by-package-id.md
Original file line number Diff line number Diff line change
@@ -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/<packageId>` 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/<packageId>`. 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=<id>`) 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/<key>/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')`.
2 changes: 1 addition & 1 deletion content/docs/references/api/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
217 changes: 217 additions & 0 deletions packages/metadata-protocol/src/protocol.app-package-id-key.test.ts
Original file line number Diff line number Diff line change
@@ -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/<packageId>`. 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 (`<object>.<viewKey>`), 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<Record<string, unknown> & { name: string }> = [CRM_APP, ...SUITE_APPS, VIEW_ITEM, SHADOW_APP];
const typeOf = (item: Record<string, unknown>) => (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<string, unknown>) => {
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<string, unknown>)[k] === v),
);
};
const engine: any = {
async find(table: string, opts?: { where?: Record<string, unknown>; 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<string, unknown> }) {
// `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();
});
});
Loading
Loading