From 8049dd0fda562d9e491e375613e2378fc93a63a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 20:23:24 +0000 Subject: [PATCH 01/11] feat(core): app installs and migration by an installed app An app that holds its own key could not get its types into a stack: defining a type, registering its _app card and granting it types were separate owner calls with nothing tying them to what the app asked for. planInstall() reports what applying an app's manifest would change, and installApp() applies exactly that plan. The approval is stored as an _install@1 Record, one per appId, that claims the type families it defines and links by association to the _app cards and _grant Records it produced. Its version history is the upgrade log; uninstallApp() revokes the linked grants and soft-deletes it. _install is ungrantable and only the owner acting alone writes one. An installed app may commit migrations within the families its install claims, to versions the owner approved, when it holds update-any on them by direct grant and acts as its own key. migrateAll() gains { sweep: 'listed' } so such an app can sweep what it can enumerate; the rest migrate lazily through commitMigration(). Refs #359 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LeWBt7FirCqK6pCrRwaVCE --- .changeset/app-installs.md | 5 + README.md | 7 + docs/spec.md | 1 + docs/spec/access-control.md | 6 +- docs/spec/apps.md | 110 +++++++ docs/spec/data-model.md | 6 +- docs/spec/identity.md | 2 +- docs/spec/versioning.md | 2 +- docs/spec/wire-format.md | 4 +- packages/core/src/grants.ts | 4 +- packages/core/src/identity-bindings.ts | 3 + packages/core/src/index.ts | 4 + packages/core/src/install.ts | 210 ++++++++++++++ packages/core/src/scoped-stack.ts | 86 +++++- packages/core/src/stack.ts | 358 ++++++++++++++++++++++- packages/core/src/types.ts | 30 ++ packages/core/tests/install.test.ts | 383 +++++++++++++++++++++++++ 17 files changed, 1198 insertions(+), 23 deletions(-) create mode 100644 .changeset/app-installs.md create mode 100644 docs/spec/apps.md create mode 100644 packages/core/src/install.ts create mode 100644 packages/core/tests/install.test.ts diff --git a/.changeset/app-installs.md b/.changeset/app-installs.md new file mode 100644 index 00000000..7e8c4e1b --- /dev/null +++ b/.changeset/app-installs.md @@ -0,0 +1,5 @@ +--- +'@haverstack/core': minor +--- + +App installs: `Stack.planInstall()`, `installApp()` and `uninstallApp()` apply an app's manifest (its types and the grants it requests) as one reviewable act, stored as a new `_install@1` system type that cannot be granted and only the owner acting alone may write. An installed app may commit migrations within the type families its install claims, to versions the owner approved, when it holds `update-any` on them. `migrateAll()` takes `{ sweep: 'listed' }` for a non-owner and returns `{ migrated, skipped }`. diff --git a/README.md b/README.md index 20c7ca1a..99618df2 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,13 @@ await stack.grantType('com.example.myapp/note', { }); ``` +An app can instead ship those steps as a manifest — its types and the grants it asks for — which the owner reviews and applies in one call. The stack keeps the approval as an `_install` record, so the grants it made can be listed, upgraded and withdrawn together, and the app can migrate its own types without the owner running its code. See [App installs](./docs/spec/apps.md). + +```ts +const plan = await stack.planInstall(manifest, { did: notesAppDid }); // show this to the owner +await stack.installApp(plan); +``` + The containment is the **type list**, not the `-own` suffix. When a delegated app acts for someone, `-own` is read as the bare verb and the subject decides which records are in reach — so in a personal stack, where nearly everything is owner-authored, `read-own` is close to `read-any`. Grant an app the types it needs and no more. On the app's side, connecting is the keypair plus a URL. `APIAdapter` performs the challenge–response handshake on open and re-runs it whenever the token expires, so there is no token to obtain, store, or refresh by hand: diff --git a/docs/spec.md b/docs/spec.md index 66d13f2d..5b590c4d 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -13,6 +13,7 @@ The spec is split into focused documents: | [Data model](./spec/data-model.md) | Records, IDs, associations, types, schemas, migrations, queries | | [Identity](./spec/identity.md) | DIDs, entities, apps, groups, authentication, key rotation | | [Access control](./spec/access-control.md) | Record-level permissions, type-level grants, `ScopedStack` enforcement | +| [App installs](./spec/apps.md) | Manifests, the `_install` record, and migration by an installed app | | [Unlisted records](./spec/unlisted.md) | Withholding a Record from enumeration, orthogonal to who may read it | | [Refusals & disclosure](./spec/disclosure.md) | Which refusal a Record answers with, and what a refusal is allowed to reveal | | [Versioning & deletion](./spec/versioning.md) | Version history, restore, optimistic concurrency, soft delete/purge | diff --git a/docs/spec/access-control.md b/docs/spec/access-control.md index 8bbc9268..35b9b09f 100644 --- a/docs/spec/access-control.md +++ b/docs/spec/access-control.md @@ -215,9 +215,9 @@ await stack.revokeType('com.example/comment', { ### What a grant covers - **No wildcard `baseId`**: there is no `*` or catch-all. Every grant is opt-in per type. Adding a new type never implicitly inherits existing grants — it starts default-deny. -- **Some system types can't be granted at all**: `grantType()` refuses `_grant`, `_config`, and `_app`. Each would hand the grantee the machinery the model rests on — minting their own grants, rewriting stack ownership, or registering an app card claiming a DID that isn't theirs (see [App](./identity.md#app)). Other reserved types (`_attachment`, `_entity`, `_group`) stay grantable. The refusal is enforced [again at evaluation](#refused-at-the-write-and-again-at-evaluation): a `_grant` Record naming one of these families confers nothing, however it came to exist. -- **A `_grant` Record is only writable by the owner acting alone.** Refusing grants _on_ `_grant` closes one route to authority; record-level `write` on a grant Record is another, reaching the same escalation by editing what an existing grant confers — its `actions`, `baseId`, or `grantee` — rather than by minting a fresh one. So `ScopedStack` refuses `mutate()`, `patchContent()`, `associate()`, `dissociate()`, `grantAccess()`, `revokeAccess()`, `delete()`, `undelete()` and `restoreVersion()` on any `_grant` Record with `StackPermissionError`, whatever the Record's own `permissions` say, and delegation does not carry it (see [Delegation](#delegation-principal-and-subject)). Nothing legitimate is lost: `grantType()` and `revokeType()` live on `Stack`, never on `StackClient`, so a scoped caller has no business writing one. -- **`commitMigration()` is owner-acting-alone, and no grant substitutes for it.** Moving a Record between type families is not something record-level `write` or an `update` grant confers, in any combination: `ScopedStack.commitMigration()` refuses every requester but the owner acting alone, delegation included. This mirrors the bulk path — `migrateAll()` lives on `Stack` and is absent from `StackClient` for the same reason `grantType()`/`revokeType()` are — so the per-record verb carries the restriction the family-wide one already had, instead of introducing a grant model beside it. +- **Some system types can't be granted at all**: `grantType()` refuses `_grant`, `_config`, `_app` and `_install`. Each would hand the grantee the machinery the model rests on — minting their own grants, rewriting stack ownership, registering an app card claiming a DID that isn't theirs (see [App](./identity.md#app)), or approving an app's install (see [App installs](./apps.md#the-_install-record)). Other reserved types (`_attachment`, `_entity`, `_group`) stay grantable. The refusal is enforced [again at evaluation](#refused-at-the-write-and-again-at-evaluation): a `_grant` Record naming one of these families confers nothing, however it came to exist. +- **A `_grant` Record is only writable by the owner acting alone.** Refusing grants _on_ `_grant` closes one route to authority; record-level `write` on a grant Record is another, reaching the same escalation by editing what an existing grant confers — its `actions`, `baseId`, or `grantee` — rather than by minting a fresh one. So `ScopedStack` refuses `mutate()`, `patchContent()`, `associate()`, `dissociate()`, `grantAccess()`, `revokeAccess()`, `delete()`, `undelete()` and `restoreVersion()` on any `_grant` Record with `StackPermissionError`, whatever the Record's own `permissions` say, and delegation does not carry it (see [Delegation](#delegation-principal-and-subject)). Nothing legitimate is lost: `grantType()` and `revokeType()` live on `Stack`, never on `StackClient`, so a scoped caller has no business writing one. An `_install` Record is fenced on the same terms, since it decides which grants exist ([App installs § The `_install` record](./apps.md#the-_install-record)). +- **`commitMigration()` is owner-acting-alone, and no grant substitutes for it.** Moving a Record between type families is not something record-level `write` or an `update` grant confers, in any combination: `ScopedStack.commitMigration()` refuses every requester but the owner acting alone, delegation included. The one exception is an installed app migrating within the families its install claims, to a version the owner approved — see [App installs § Migrating an installed app's types](./apps.md#migrating-an-installed-apps-types). This mirrors the bulk path — `migrateAll()` lives on `Stack` and is absent from `StackClient` for the same reason `grantType()`/`revokeType()` are — so the per-record verb carries the restriction the family-wide one already had, instead of introducing a grant model beside it. The restriction is what makes the verb safe to expose. `commitMigration()` replaces `content` and `typeId` wholesale, so it is create-shaped at the destination and update-shaped over the Record as it stands: a grant-based version would have to re-derive every gate `create()` applies _and_ every gate `mutate()` applies, and would reopen each one it missed. The sharpest is the non-owner `_attachment@1` refusal (see [Attachments](./attachments.md#creating-_attachment1-records-directly)) — a requester holding a create grant on `_attachment@1` and write access to any Record they authored could otherwise migrate that Record into the family naming any `fileId`, then read the bytes through the uploader clause. Ordinary write access to a Record is not consent to move it between families. diff --git a/docs/spec/apps.md b/docs/spec/apps.md new file mode 100644 index 00000000..eb7a4d49 --- /dev/null +++ b/docs/spec/apps.md @@ -0,0 +1,110 @@ +# App installs + +An app the owner does not run — one that holds its own key and reaches the stack through `APIAdapter` (see [Identity § Two ways an app reaches a stack](./identity.md#two-ways-an-app-reaches-a-stack)) — knows its schema but cannot define it, since [defining a Type is owner-acting-alone](./access-control.md#what-a-grant-covers). Every such app needs the same owner-side steps: define its types, register its key on an `_app` card, grant that key the types it needs. An **install** is those steps as one reviewable act, and a Record that remembers what was approved. + +The app ships a **manifest** — a request. What the stack stores is the owner's **approval** of it, as an `_install` Record. The owner reviews a plan of what applying the manifest would change, then applies exactly that plan. + +```ts +type AppManifest = { + appId: AppId; // reverse-DNS, as on an _app card + name: string; + version?: string; + types: DefineTypeOptions[]; // the types the app defines + requests: InstallRequest[]; // the grants its keys hold +}; + +type InstallRequest = { baseId: BaseId; actions: GrantAction[] }; + +const plan = await stack.planInstall(manifest, { did: appDid }); +// …show the plan to the owner… +await stack.installApp(plan); +``` + +Migration functions are not part of a manifest. They are app code, registered at every startup with `registerMigration()` (see [Data model § Type migrations](./data-model.md#type-migrations)), and the app runs them itself — see [Migrating an installed app's types](#migrating-an-installed-apps-types). + +`planInstall()`, `installApp()` and `uninstallApp()` live on `Stack` and are absent from `StackClient`, like `grantType()` and `defineType()`: everything they write is the owner's to write. There is no wire endpoint for them; a server offering an install flow builds its approval step on these calls. + +## The `_install` record + +```ts +type InstallContent = { + appId: AppId; // a binding: immutable, unique among installs + name: string; + version?: string; + defines: TypeId[]; // every version the owner has approved + requests: InstallRequest[]; // the grants each of the app's keys holds +}; +``` + +`_install@1` is a system type, pre-seeded with the others ([Data model § System types](./data-model.md#system-types)). It is integrity-bearing in the way `_grant` is — it decides which grants exist and who may commit migrations — and is protected the same way: + +- **It cannot be granted.** `grantType()` refuses `_install` beside `_grant`, `_config` and `_app` (see [Access control § What a grant covers](./access-control.md#what-a-grant-covers)), and a `request` naming any of the four is refused at the write. +- **Only the owner acting alone writes one.** `ScopedStack` refuses every write to an `_install` Record on the same terms as a `_grant` Record, whatever the Record's own `permissions` say. +- **`appId` is a binding**, immutable and unique on the terms [Identity § DID bindings](./identity.md#did-bindings) sets out: one install answers for each app, and an existing install cannot be relabelled to answer for another. +- **A family is claimed by one install.** The families named by an install's `defines` are its own, and a write that would have a second install claim one is refused with `StackConflictError`, on create, patch, migration and restore alike. A soft-deleted install keeps its claims, for the reason a soft-deleted card keeps its DID: it can be undeleted. No install can claim a system family. + +Every `defines` entry must be a versioned TypeId, and every `request` must name a family and actions from the grant vocabulary; `StackValidationError` otherwise. + +**An install and an `_app` card answer different questions.** A card is one per _key_: an app on two devices, or after a key change, has two cards sharing an `appId`. An install is one per _software_. A card says who a key is; an install says what the owner agreed to let that software do. They are linked rather than merged so neither has to answer the other's question. + +**Associations hold the links.** An install carries a `relationship` labelled `install.app` to each `_app` card it was installed for, and one labelled `install.grant` to each `_grant` Record it produced. That is what tells a grant the install made from one the owner wrote by hand, and it is what uninstalling withdraws. + +**Version history is the upgrade log.** Applying a changed manifest patches the install, so what was approved at any earlier point is a [version](./versioning.md#version-history) of it. + +## Plan, then apply + +`planInstall(manifest, { did })` writes nothing. It reports what applying the manifest for that key would change: + +```ts +type InstallPlan = { + manifest: AppManifest; + did: EntityId; + existing: (StackRecord & { content: InstallContent }) | null; + newFamilies: BaseId[]; // families the install would claim for the first time + newVersions: TypeId[]; // versions not yet in `defines` + requestsAdded: InstallRequest[]; + requestsRemoved: InstallRequest[]; + foreignRequests: (InstallRequest & { owner: AppId | 'system' | null })[]; + newKey: boolean; // whether `did` is not yet linked to this install +}; +``` + +`foreignRequests` are the requests on families the install does not define, each naming who owns the family: another install's `appId`, `'system'`, or `null` when nothing claims it. They are the requests an approval most needs to show — an app asking to read another app's records, or `_entity`, is asking for reach beyond its own data. + +It refuses what no approval could make valid: a type in a system family or in a family another install claims (`StackConflictError`), a request [`grantType()` would refuse](./access-control.md#type-level-grants) (`StackValidationError`), and a `did` whose `_app` card names a different `appId` (`StackConflictError`). + +`installApp(plan)` applies the plan: + +1. Defines each of the manifest's types, with [schema drift](./data-model.md#schema-drift-detection) applying as it does to any `defineType()`. +2. Registers the key on an `_app` card when it has none, undeleting a soft-deleted one. An existing card's `name` is left alone: it is the owner's label. +3. Creates the install, or patches it — undeleting it first if it was uninstalled. `defines` gains the manifest's versions and never loses any, since a Type once defined stays defined; `requests` becomes the manifest's. +4. Brings the grants of **every** key linked to the install to exactly `requests`: a grant no longer requested is revoked, a missing one is written, and the links follow. + +**Nothing is applied that was not approved.** `installApp()` plans the same manifest again and refuses with `StackConflictError` when the result differs from the plan it was handed — another install claiming a family, the install changing, the key being linked. The remedy is to plan again and show the owner the new plan. Re-applying a manifest whose plan is empty changes nothing. + +## Migrating an installed app's types + +[Migration is owner-acting-alone](./access-control.md#what-a-grant-covers), and an installed app's migration functions are its own code, which the owner should not have to run with owner authority. An install closes the gap: a contained app may commit migrations within its own families. `ScopedStack.commitMigration()` — and so `migrateAll()` run by the app over `APIAdapter`, which commits through `POST /records/:id/migrate` — admits a request that is not the owner acting alone when **all** of these hold: + +1. The source and target families are both claimed by one live install, and by no other. +2. The target TypeId is in that install's `defines` — a version the owner approved. +3. The requester holds `update-any` on each family through a grant naming its DID directly. Default and group grants do not count, on the terms they do not count for [a principal](./access-control.md#who-a-grant-reaches). The manifest has to request it, so the plan shows it. +4. The requester is acting alone — not delegated — as the DID of an `_app` card linked to the install. + +The reasons migration is otherwise owner-only do not reach this case. A migration that crosses into `_attachment` or moves a DID binding needs a system family at one end, and no install can claim one. Ordinary write access is not consent to move a Record between versions; the owner's approval of the version is. Every migration still snapshots the Record's prior content and type to [version history](./versioning.md#version-history), so it stays recoverable like any other write. + +Approving a new version is therefore also approving its migration. Until the owner approves it, the app reads records at the older version through [`presentAt: 'latest'`](./data-model.md#type-migrations). + +**The app sweeps what it can see.** `migrateAll()` ordinarily sweeps every record of the family, deleted and unlisted included. A non-owner can do neither: `includeUnlisted` is [owner-only](./unlisted.md#includeunlisted-is-owner-only), and a soft-deleted record reaches it as a [tombstone](./versioning.md#the-tombstone-is-literal) with no content to migrate. So the app passes `sweep: 'listed'`: + +```ts +const { migrated, skipped } = await stack.migrateAll('com.example.notes/note', { sweep: 'listed' }); +``` + +which migrates live, listed records and counts the soft-deleted ones it passed over in `skipped`. Unlisted records are not enumerable to it, so they are not counted. Both kinds stay at their version, still found by a query on that `typeId`, and the app commits each one with `commitMigration()` when it next reaches it — after an undelete, or reading an unlisted record by ID. + +## Uninstalling + +`uninstallApp(appId)` revokes every grant linked to the install and soft-deletes it. The app's records stay: they are the owner's data, and purging them is a separate, deliberate act. Its `_app` cards stay too, since they are what its records' attribution resolves through (see [Identity § Attribution and what can be trusted](./identity.md#attribution-and-what-can-be-trusted)). A deleted install confers no migration authority. + +Installing the same `appId` again undeletes the install and grants its requests afresh. Its families stay claimed in between, so no other app can take them over while it is uninstalled. diff --git a/docs/spec/data-model.md b/docs/spec/data-model.md index e6ab7954..3e3bd5f9 100644 --- a/docs/spec/data-model.md +++ b/docs/spec/data-model.md @@ -358,7 +358,7 @@ Apps that care about semantics filter by exact `typeId`; apps that want flexibil ### System types -Reserved, library-defined types: `_config@1` ([Stack initialization](../spec.md#stack-initialization)), `_entity@1`, `_app@1`, `_group@1` ([Identity](./identity.md)), `_grant@1` ([Access control](./access-control.md#type-level-grants)), and `_attachment@1` ([Attachments](./attachments.md)). System types follow the same versioned ID format as user-defined types and can evolve using the same migration mechanism. All six are pre-seeded when a Stack is opened with `Stack.open()` — always available without any setup by the caller. +Reserved, library-defined types: `_config@1` ([Stack initialization](../spec.md#stack-initialization)), `_entity@1`, `_app@1`, `_group@1` ([Identity](./identity.md)), `_grant@1` ([Access control](./access-control.md#type-level-grants)), `_attachment@1` ([Attachments](./attachments.md)), and `_install@1` ([App installs](./apps.md#the-_install-record)). System types follow the same versioned ID format as user-defined types and can evolve using the same migration mechanism. All seven are pre-seeded when a Stack is opened with `Stack.open()` — always available without any setup by the caller. ### Type migrations @@ -388,8 +388,8 @@ The migration registry is **per-stack-instance** and lives in memory — differe - **`presentAt: 'latest'`** — an explicit opt-in on both `get()` and `query()` that applies the registered migration chain in memory before returning. Nothing is written to disk; this is a read-time convenience, never a persistence mechanism. It is a property of the app instance that registered the chain, so it never travels: a server [rejects a request carrying `presentAt`](./wire-format.md#records) rather than dropping it. Throws `StackMigrationError` when a matched Record's version can't be reconciled with what this app instance has registered (see stale-writer behavior below). - **A content patch never migrates.** `mutate()` validates the merged content against the Record's _own current_ stored Type — never the latest — and writes back at the same `typeId`. An unrelated content edit can never fold an invisible schema rewrite into the same version-history entry. This is also why content read through `presentAt: 'latest'` is not writable back wholesale, and why [the content key is a patch](#mutations). - **Path composition** — migrations between adjacent versions are automatically chained (v1→v2→v3), so apps only ever register one step at a time. -- **`migrateAll("com.example.myapp/note")`** eagerly commits all pending migrations for a type family in one deliberate pass, taking each Record to the end of the registered chain rather than to a version the caller names — which is why it takes a `BaseId` and refuses a versioned `TypeId` — call it at app startup after registering migrations, or after a schema change. It sweeps soft-deleted and unlisted Records unconditionally (`includeDeleted`/`includeUnlisted` are not caller options in either direction — see [Deletion](./versioning.md#deletion) and [Unlisted records](./unlisted.md)), validates each migrated result against the target Type's schema before writing, and aborts immediately on the first validation failure (a buggy migration function is a bug to surface, not to paper over by skipping the offending records) — anything already committed earlier in the pass stays committed. Previous content is snapshotted to version history before each write. -- **`commitMigration(id, toTypeId, content)`** is the single-record counterpart, changing one Record's `typeId` and `content` together in one step. Unlike `migrateAll()`, `content` here is supplied by the caller rather than produced by a registered `Migration` function — the client-side app that owns `toTypeId` computes it, and the library validates it against `toTypeId`'s schema exactly as `create()`/`mutate()` validate against a schema. This is what backs the wire's `POST /records/:id/migrate` (see [Wire format](./wire-format.md#records)). Under `ScopedStack` it is **owner-acting-alone**, matching `migrateAll()`'s own absence from `StackClient` — no grant or record-level `write` substitutes for it (see [Access control](./access-control.md#type-level-grants)). Previous content and `typeId` are snapshotted to version history first, same as `migrateAll()`. +- **`migrateAll("com.example.myapp/note")`** eagerly commits all pending migrations for a type family in one deliberate pass, taking each Record to the end of the registered chain rather than to a version the caller names — which is why it takes a `BaseId` and refuses a versioned `TypeId` — call it at app startup after registering migrations, or after a schema change. It sweeps soft-deleted and unlisted Records (`includeDeleted`/`includeUnlisted` are not caller options in either direction — see [Deletion](./versioning.md#deletion) and [Unlisted records](./unlisted.md)); the one narrower sweep is `{ sweep: 'listed' }`, for an installed app that can reach neither (see [App installs § Migrating an installed app's types](./apps.md#migrating-an-installed-apps-types)). It validates each migrated result against the target Type's schema before writing, and aborts immediately on the first validation failure (a buggy migration function is a bug to surface, not to paper over by skipping the offending records) — anything already committed earlier in the pass stays committed. Previous content is snapshotted to version history before each write. +- **`commitMigration(id, toTypeId, content)`** is the single-record counterpart, changing one Record's `typeId` and `content` together in one step. Unlike `migrateAll()`, `content` here is supplied by the caller rather than produced by a registered `Migration` function — the client-side app that owns `toTypeId` computes it, and the library validates it against `toTypeId`'s schema exactly as `create()`/`mutate()` validate against a schema. This is what backs the wire's `POST /records/:id/migrate` (see [Wire format](./wire-format.md#records)). Under `ScopedStack` it is **owner-acting-alone**, matching `migrateAll()`'s own absence from `StackClient` — no grant or record-level `write` substitutes for it (see [Access control](./access-control.md#type-level-grants)) — except for an installed app within its own families (see [App installs](./apps.md#migrating-an-installed-apps-types)). Previous content and `typeId` are snapshotted to version history first, same as `migrateAll()`. Because `content` is a full replacement written under a new `typeId`, a migration commit is create-shaped at the destination and update-shaped over the Record as it stands, and owes both sets of integrity checks. DID bindings are held to immutability across the union of the two families' binding fields — a card can neither shed its `did` by migrating out of `_entity`/`_app` nor pick one up on the way in — and to uniqueness in the destination family (see [Identity § DID bindings](./identity.md#did-bindings)). An `_attachment@1` Record's `fileId`, `mimeType` and `size` stay immutable, and a Record arriving from outside that family is held to the same mimeType-establishment check `create()` applies. Migrating _into_ `_group` is refused outright: a group's `admin` roster entry is stamped at creation and a migration cannot stamp one, so it would produce a group nobody but the owner can manage — version-to-version migration within `_group` stays open and carries the existing roster with it. diff --git a/docs/spec/identity.md b/docs/spec/identity.md index ba963613..0e4109f7 100644 --- a/docs/spec/identity.md +++ b/docs/spec/identity.md @@ -84,7 +84,7 @@ The check costs a lookup over the `_app` family per delegated write that carries **Per-edit app attribution does not exist**, and version history is not a way around that: a [snapshot](./versioning.md#version-history) carries `content`, the `typeId` it was read under, the author's `createdBy` and the `updatedBy` of that one change, never `appId`. So a Record whose creator is a verified delegated app says nothing about the software behind any later edit, and nothing records it. An app that needs edits attributable individually should model them as Records of its own rather than reading attribution off the edited one. -Linking the two is the owner's job, not the library's: an `_app` Record with a `did` is the owner's card for a piece of software, the same way an `_entity` Record is their card for a person. Nothing creates one automatically — `grantType()` writes a `_grant` Record and nothing else, since naming an app is a display decision the library has no truthful answer for. +Linking the two is the owner's job, not the library's: an `_app` Record with a `did` is the owner's card for a piece of software, the same way an `_entity` Record is their card for a person. `grantType()` writes a `_grant` Record and nothing else, since naming an app is a display decision the library has no truthful answer for. The one call that writes a card is `installApp()`, applying a manifest the owner approved, and it takes the name from that manifest — see [App installs](./apps.md). **The `_app` registry is integrity-bearing.** It is the only thing standing between "this DID authenticated" and "this is My Notes App", and a lookup is worth no more than the registry behind it. Two protections apply, and they close different routes to the same end — a card bearing a name the owner trusts, pointing at a key someone else holds: diff --git a/docs/spec/versioning.md b/docs/spec/versioning.md index 7baef6ef..3320e64b 100644 --- a/docs/spec/versioning.md +++ b/docs/spec/versioning.md @@ -156,7 +156,7 @@ A soft-deleted Record has no current state to edit, so `mutate()`, `patchContent The refusal is asked **after** the authority decision, never before: `ScopedStack` authorizes first and only then calls into `Stack`. It names a state, so a requester who may not read the Record must still hear what a missing ID sounds like — otherwise "exists but deleted" becomes a probe a stranger can run against guessed IDs, the same [information-exposure rule](./disclosure.md) that governs every other refusal. -`commitMigration()` is exempt: migration deliberately sweeps soft-deleted Records so one can come back current on undelete (see [Undelete](#undelete)), and it is owner-acting-alone only. +`commitMigration()` is exempt: migration deliberately sweeps soft-deleted Records so one can come back current on undelete (see [Undelete](#undelete)), and it is owner-acting-alone, save for an [installed app within its own families](./apps.md#migrating-an-installed-apps-types). ### Purge diff --git a/docs/spec/wire-format.md b/docs/spec/wire-format.md index 6fe75606..48dcf06e 100644 --- a/docs/spec/wire-format.md +++ b/docs/spec/wire-format.md @@ -386,7 +386,7 @@ For `typeId: "_attachment@1"`, a non-owner requester gets `403` regardless of gr ### Migration commit -`POST /records/:id/migrate` is the only way a record's `typeId` changes after creation. Body: `{ "toTypeId": "...", "content": {...} }` — the full post-migration content, computed client-side by the type's owning app (migration functions are app code, not server code) and validated by the server against `toTypeId`'s schema before writing. This is what an app uses to commit a pending lazy migration alongside new content (a change set carries no `typeId`, so `PATCH` cannot), and what `stack.migrateAll()` uses for each record in a batch pass. `Stack.commitMigration()`/`ScopedStack.commitMigration()` is the client-side entry point that backs this endpoint for a single record — see [Type migrations](./data-model.md#type-migrations). A server built on `ScopedStack` serves this endpoint to the **stack owner** and answers `403` otherwise: migration is owner-driven, and no grant confers it (see [Access control](./access-control.md#type-level-grants)). Like every other endpoint that bumps a record's version, it accepts `If-Match` — a migration commit replaces content wholesale, so it is precisely the write a caller most needs to be able to fence. `stack.migrateAll()` sends none, since a batch pass doesn't know each record's version going in; a single `commitMigration()` passes whatever `ifVersion` its caller supplied. +`POST /records/:id/migrate` is the only way a record's `typeId` changes after creation. Body: `{ "toTypeId": "...", "content": {...} }` — the full post-migration content, computed client-side by the type's owning app (migration functions are app code, not server code) and validated by the server against `toTypeId`'s schema before writing. This is what an app uses to commit a pending lazy migration alongside new content (a change set carries no `typeId`, so `PATCH` cannot), and what `stack.migrateAll()` uses for each record in a batch pass. `Stack.commitMigration()`/`ScopedStack.commitMigration()` is the client-side entry point that backs this endpoint for a single record — see [Type migrations](./data-model.md#type-migrations). A server built on `ScopedStack` serves this endpoint to the **stack owner**, and to an installed app migrating within the families its install claims (see [App installs § Migrating an installed app's types](./apps.md#migrating-an-installed-apps-types)), and answers `403` otherwise: migration is owner-driven, and no grant alone confers it (see [Access control](./access-control.md#type-level-grants)). Like every other endpoint that bumps a record's version, it accepts `If-Match` — a migration commit replaces content wholesale, so it is precisely the write a caller most needs to be able to fence. `stack.migrateAll()` sends none, since a batch pass doesn't know each record's version going in; a single `commitMigration()` passes whatever `ifVersion` its caller supplied. ### Response envelope @@ -605,7 +605,7 @@ GET /types/:id — get one type definition (id is URL-encoded) POST /types — register a type, or evolve an existing one in place ``` -**`POST /types` is served to the stack owner acting alone** and answers `403` otherwise, delegation included — the same rule as [`POST /records/:id/migrate`](#migration-commit). A Type is stack-wide: every app reading the family validates against it, and no grant confers defining one (see [Access control § Type-level grants](./access-control.md#type-level-grants)). `ScopedStack` has no `defineType()`, so a server serves this endpoint through an unscoped `Stack` after checking `isOwnerActingAlone()`. `GET /types` and `GET /types/:id` stay open to any authenticated requester, since a client needs a schema to validate and render the records it can reach. +**`POST /types` is served to the stack owner acting alone** and answers `403` otherwise, delegation included — the rule [`POST /records/:id/migrate`](#migration-commit) applies to everyone but an installed app. An app that needs its types defined ships them in its [manifest](./apps.md) for the owner to install. A Type is stack-wide: every app reading the family validates against it, and no grant confers defining one (see [Access control § Type-level grants](./access-control.md#type-level-grants)). `ScopedStack` has no `defineType()`, so a server serves this endpoint through an unscoped `Stack` after checking `isOwnerActingAlone()`. `GET /types` and `GET /types/:id` stay open to any authenticated requester, since a client needs a schema to validate and render the records it can reach. **The body is a whole Type**, as `GET /types/:id` returns one. `id`, `name` and `schema` are read, as is `migratesFrom` when present; `baseId`, `version`, `schemaHash` and `createdAt` are accepted and ignored, since `defineType()` derives or stamps each of them. Any other key is refused (see [Unrecognized input](#unrecognized-input)). diff --git a/packages/core/src/grants.ts b/packages/core/src/grants.ts index 16d80382..c3d075a3 100644 --- a/packages/core/src/grants.ts +++ b/packages/core/src/grants.ts @@ -306,12 +306,14 @@ async function resolveGroupRoleMemoized( * System type families grantType() refuses to target: a grant on any of them * would let the grantee mint their own grants, touch stack config, or * register an app card claiming a DID that isn't theirs — the last of which - * is what verified app attribution rests on. + * is what verified app attribution rests on — or approve an install, which + * decides grants and migration authority. */ export const UNGRANTABLE_SYSTEM_TYPES: ReadonlySet = new Set([ SYSTEM_TYPES.GRANT, SYSTEM_TYPES.CONFIG, SYSTEM_TYPES.APP, + SYSTEM_TYPES.INSTALL, ]); /** diff --git a/packages/core/src/identity-bindings.ts b/packages/core/src/identity-bindings.ts index d801bbfa..78d0f9d0 100644 --- a/packages/core/src/identity-bindings.ts +++ b/packages/core/src/identity-bindings.ts @@ -19,6 +19,7 @@ import { SYSTEM_TYPES } from './types.js'; const BINDING_FIELDS: ReadonlyMap = new Map([ [SYSTEM_TYPES.APP, ['did', 'appId'] as const], [SYSTEM_TYPES.ENTITY, ['did'] as const], + [SYSTEM_TYPES.INSTALL, ['appId'] as const], ]); /** @@ -39,6 +40,8 @@ const BINDING_FIELDS: ReadonlyMap = new Ma const UNIQUE_BINDING_FIELDS: ReadonlyMap = new Map([ [SYSTEM_TYPES.APP, ['did'] as const], [SYSTEM_TYPES.ENTITY, ['did'] as const], + // One install answers for each app; see docs/spec/apps.md § The `_install` record. + [SYSTEM_TYPES.INSTALL, ['appId'] as const], ]); export const bindingFieldsOf = (family: string): readonly ('did' | 'appId')[] => diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e96b7b64..a5248ce6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -33,7 +33,9 @@ export type { DeleteAndReturnResult, CollectAttachmentGarbageOptions, CollectAttachmentGarbageResult, + MigrateAllOptions, } from './stack.js'; +export type { AppManifest, InstallPlan, ForeignRequest } from './install.js'; // Type handles export { typeHandle } from './type-handle.js'; @@ -137,6 +139,8 @@ export type { GrantAction, GrantContent, GrantGrantee, + InstallContent, + InstallRequest, TypeGrant, PutAttachmentOptions, ConfigContent, diff --git a/packages/core/src/install.ts b/packages/core/src/install.ts new file mode 100644 index 00000000..a453568b --- /dev/null +++ b/packages/core/src/install.ts @@ -0,0 +1,210 @@ +/** + * App installs + * ------------------------------------------------------- + * An app the owner does not run ships a manifest — the types it defines + * and the type-level grants it asks for — and the owner applies it with + * `Stack.planInstall()` then `Stack.installApp()`. What the stack keeps is + * not the manifest but the owner's approval of it: an `_install` Record, + * one per `appId`, linked by association to the `_app` cards it was + * installed for and the `_grant` Records it produced. Its version history + * is the upgrade log, and soft-deleting it is uninstalling. + * + * An install claims the families it defines, one install per family. That + * claim is what lets a contained app commit migrations within its own + * families without the owner running its code — see + * ScopedStack.commitMigration(). + * + * This module holds the parts that read an install as data: the write-time + * shape rules, the family claim, and the plan diff. The verbs that write + * live on `Stack`. See docs/spec/apps.md. + */ + +import { baseIdOf, familyIdProblem, parseTypeId } from './schema.js'; +import { GRANT_ACTION_SET, UNGRANTABLE_SYSTEM_TYPES } from './grants.js'; +import { SYSTEM_TYPES } from './types.js'; +import type { + AppId, + BaseId, + EntityId, + GrantAction, + GrantContent, + InstallContent, + InstallRequest, + RecordId, + RelationshipAssociation, + StackRecord, + TypeId, +} from './types.js'; +import type { DefineTypeOptions } from './stack.js'; +import type { ValidationError } from './validate.js'; + +/** What an app ships: the types it defines and the grants it asks for. */ +export type AppManifest = { + appId: AppId; + name: string; + version?: string; + types: DefineTypeOptions[]; + requests: InstallRequest[]; +}; + +/** A request on a family this install does not define, and who owns that family. */ +export type ForeignRequest = InstallRequest & { + /** The `appId` of the install claiming the family, `'system'`, or null when nothing claims it. */ + owner: AppId | 'system' | null; +}; + +/** + * What applying a manifest would change, for the owner to approve. + * `installApp()` applies a plan only while it is still what planning the + * same manifest would produce. See docs/spec/apps.md § Plan, then apply. + */ +export type InstallPlan = { + manifest: AppManifest; + /** The key being installed. */ + did: EntityId; + /** The install as it stood when planned — null for a first install. */ + existing: (StackRecord & { content: InstallContent }) | null; + /** Families this install would claim that it does not claim yet. */ + newFamilies: BaseId[]; + /** Type versions not yet in the install's `defines`. */ + newVersions: TypeId[]; + requestsAdded: InstallRequest[]; + requestsRemoved: InstallRequest[]; + /** Requests on families the manifest does not define — the ones an approval most needs to show. */ + foreignRequests: ForeignRequest[]; + /** Whether `did` is a key this install is not yet linked to. */ + newKey: boolean; +}; + +/** Relationship label from an install to an `_app` card it was installed for. */ +export const INSTALL_APP_LABEL = 'install.app'; +/** Relationship label from an install to a `_grant` it produced. */ +export const INSTALL_GRANT_LABEL = 'install.grant'; + +export const installAppLink = (recordId: RecordId): RelationshipAssociation => ({ + kind: 'relationship', + label: INSTALL_APP_LABEL, + target: { kind: 'record', recordId }, +}); + +export const installGrantLink = (recordId: RecordId): RelationshipAssociation => ({ + kind: 'relationship', + label: INSTALL_GRANT_LABEL, + target: { kind: 'record', recordId }, +}); + +/** Record ids an install links to under `label`. */ +export function linkedIds(record: StackRecord, label: string): RecordId[] { + const ids: RecordId[] = []; + for (const a of record.associations ?? []) { + if (a.kind === 'relationship' && a.label === label && a.target.kind === 'record') { + ids.push(a.target.recordId); + } + } + return ids; +} + +const SYSTEM_FAMILIES: ReadonlySet = new Set(Object.values(SYSTEM_TYPES)); + +export const isSystemFamily = (baseId: BaseId): boolean => SYSTEM_FAMILIES.has(baseId); + +/** + * The families an install claims, read as data: entries that are not + * well-formed TypeIds claim nothing. + */ +export function claimedFamilies(content: unknown): Set { + const defines = (content as { defines?: unknown } | null)?.defines; + const families = new Set(); + if (!Array.isArray(defines)) return families; + for (const id of defines) { + if (typeof id !== 'string') continue; + const parsed = parseTypeId(id); + if (parsed) families.add(parsed.baseId); + } + return families; +} + +/** + * An `_install`'s `defines` and `requests`, asked of its content on every + * write. A schema can say both are lists; only this can say what their + * entries must name. See docs/spec/apps.md § The `_install` record. + */ +export function validateInstall(typeId: TypeId, content: unknown): ValidationError[] { + if (baseIdOf(typeId) !== SYSTEM_TYPES.INSTALL) return []; + const c = content as Partial> | null; + const errors: ValidationError[] = []; + if (Array.isArray(c?.defines)) { + c.defines.forEach((id, i) => { + const parsed = typeof id === 'string' ? parseTypeId(id) : null; + if (!parsed) { + errors.push({ path: `defines[${i}]`, message: 'Expected a versioned TypeId' }); + } else if (isSystemFamily(parsed.baseId)) { + errors.push({ + path: `defines[${i}]`, + message: `"${parsed.baseId}" is a system type; no install can define it`, + }); + } + }); + } + if (Array.isArray(c?.requests)) { + c.requests.forEach((r, i) => { + const req = r as Partial> | null; + const problem = familyIdProblem(req?.baseId, `requests[${i}].baseId`); + if (problem) { + errors.push({ path: `requests[${i}].baseId`, message: problem }); + } else if (UNGRANTABLE_SYSTEM_TYPES.has(req!.baseId as string)) { + errors.push({ + path: `requests[${i}].baseId`, + message: `"${String(req!.baseId)}" cannot be granted, so no install can request it`, + }); + } + if (Array.isArray(req?.actions)) { + req.actions.forEach((a, j) => { + if (!GRANT_ACTION_SET.has(a as GrantAction)) { + errors.push({ + path: `requests[${i}].actions[${j}]`, + message: `Unknown grant action "${String(a)}"`, + }); + } + }); + } + }); + } + return errors; +} + +/** Whether two requests ask for the same family and exactly the same actions. */ +export function sameRequest(a: InstallRequest, b: InstallRequest): boolean { + if (a.baseId !== b.baseId || a.actions.length !== b.actions.length) return false; + const actions = new Set(a.actions); + return b.actions.every((x) => actions.has(x)); +} + +/** Whether a stored grant is exactly `request`, made out to `did`. */ +export function grantIsRequest( + grant: GrantContent, + request: InstallRequest, + did: EntityId, +): boolean { + if (grant.grantee?.kind !== 'entity' || grant.grantee.entityId !== did) return false; + if (!Array.isArray(grant.actions)) return false; + return sameRequest({ baseId: grant.baseId, actions: grant.actions }, request); +} + +/** + * The parts of a plan that depend on the stack's state — what + * `installApp()` compares to refuse a stale one. + */ +export function planFingerprint(plan: InstallPlan): string { + return JSON.stringify([ + plan.existing?.id ?? null, + plan.existing?.version ?? null, + plan.existing?.deletedAt ? true : false, + plan.newFamilies, + plan.newVersions, + plan.requestsAdded, + plan.requestsRemoved, + plan.foreignRequests, + plan.newKey, + ]); +} diff --git a/packages/core/src/scoped-stack.ts b/packages/core/src/scoped-stack.ts index e43d3315..ab69c06c 100644 --- a/packages/core/src/scoped-stack.ts +++ b/packages/core/src/scoped-stack.ts @@ -35,6 +35,7 @@ import type { ActorOptions, AppContent, AppId, + InstallContent, PutAttachmentOptions, Association, AssociationEdit, @@ -88,6 +89,7 @@ import { UNGRANTABLE_SYSTEM_TYPES, } from './grants.js'; import { bindingFieldsOf } from './identity-bindings.js'; +import { claimedFamilies, linkedIds, INSTALL_APP_LABEL } from './install.js'; import { assertAttachmentSize } from './limits.js'; import { validateIdTimestampSkew, validateRecordId } from './record-id.js'; import { @@ -1327,15 +1329,17 @@ export class ScopedStack implements StackClient { /** * A `_grant` Record *is* authority, so rewriting one is the escalation * UNGRANTABLE_SYSTEM_TYPES refuses at evaluation, reached by editing an - * existing grant rather than minting a fresh one. `grantType()` and `revokeType()` - * live on `Stack`, never `StackClient`, so no scoped write is lost. Writes - * only: reading a grant Record and its history stays on the ordinary gate. - * See docs/spec/access-control.md § Type-level grants. + * existing grant rather than minting a fresh one. An `_install` decides + * grants and migration authority, so it is fenced the same way. The verbs + * that write either live on `Stack`, never `StackClient`, so no scoped + * write is lost. Writes only: reading one and its history stays on the + * ordinary gate. See docs/spec/access-control.md § Type-level grants. */ private async requireOwnerForGrantRecord(record: StackRecord): Promise { - if (baseIdOf(record.typeId) !== SYSTEM_TYPES.GRANT) return; + const family = baseIdOf(record.typeId); + if (family !== SYSTEM_TYPES.GRANT && family !== SYSTEM_TYPES.INSTALL) return; if (this.ownerActingAlone) return; - throw await this.denialFor(record, 'Only the stack owner may write a _grant record'); + throw await this.denialFor(record, `Only the stack owner may write a ${family} record`); } /** @@ -1523,8 +1527,10 @@ export class ScopedStack implements StackClient { } /** - * Commit a per-record migration — **the owner acting alone, only**, the - * same restriction the bulk `migrateAll()` carries by living on `Stack`. + * Commit a per-record migration — **the owner acting alone**, the same + * restriction the bulk `migrateAll()` carries by living on `Stack`, with + * one exception: an installed app migrating within its own families (see + * installMayMigrate()). * * Migrate replaces `content` and `typeId` wholesale, so a grant-based * version would have to re-derive every gate `create()` applies at the @@ -1542,10 +1548,72 @@ export class ScopedStack implements StackClient { content: Record, opts: IfVersionOptions = {}, ): Promise { - this.requireOwnerActingAlone('Only the stack owner may commit a migration'); + if (!this.ownerActingAlone && !(await this.installMayMigrate(id, toTypeId))) { + throw new StackPermissionError( + 'Only the stack owner, or an installed app within the type families it defines, may commit a migration', + ); + } return this.stack.commitMigration(id, toTypeId, content, { ...opts, ...this.actor }); } + /** + * Whether this request is an installed app migrating a record within its + * own families: the app acting as itself, its key linked to the one live + * install claiming both families, `toTypeId` a version the owner + * approved, and an `update-any` grant on each family made out to the key + * directly. Read as data, so a family two installs claim — however that + * came to be — confers nothing. + * + * The reasons migration is otherwise owner-only do not reach this case: + * no install can claim a system family, so neither an `_attachment` nor + * a DID binding is in reach, and the owner's approval of the version is + * the consent. See docs/spec/apps.md § Migrating an installed app's types. + */ + private async installMayMigrate(id: RecordId, toTypeId: TypeId): Promise { + const principal = this.#principalId; + if (!principal || this.delegated) return false; + const record = await this.stack.get(id, { includeDeleted: true }); + if (!record) return false; + const families = new Set([baseIdOf(record.typeId), baseIdOf(toTypeId)]); + + const installs = (await queryAllPages((q) => this.stack.query(q), { + filter: { baseId: SYSTEM_TYPES.INSTALL, includeDeleted: true, includeUnlisted: true }, + })) as (StackRecord & { content: InstallContent })[]; + let install: (StackRecord & { content: InstallContent }) | undefined; + for (const family of families) { + const claimants = installs.filter((r) => claimedFamilies(r.content).has(family)); + if (claimants.length !== 1) return false; + if (install && install.id !== claimants[0]!.id) return false; + install = claimants[0]; + } + if (!install || install.deletedAt) return false; + if (!Array.isArray(install.content.defines) || !install.content.defines.includes(toTypeId)) { + return false; + } + + let linked = false; + for (const cardId of linkedIds(install, INSTALL_APP_LABEL)) { + const card = await this.stack.get(cardId); + if (card && baseIdOf(card.typeId) === SYSTEM_TYPES.APP) { + if ((card.content as AppContent).did === principal) linked = true; + } + } + if (!linked) return false; + + const grants = await this.loadGrants(); + for (const family of families) { + const held = await this.hasGrant(`${family}@1`, ['update-any'], { + grantee: principal, + prefetchedGrants: grants, + matchOwn: false, + allowDefault: false, + allowGroup: false, + }); + if (!held) return false; + } + return true; + } + /** * Store bytes and create an _attachment@1 metadata record (create grant * on `_attachment@1` required; anonymous denied), returning that record. diff --git a/packages/core/src/stack.ts b/packages/core/src/stack.ts index c66ab48b..d7b045c1 100644 --- a/packages/core/src/stack.ts +++ b/packages/core/src/stack.ts @@ -68,7 +68,10 @@ import type { ConfigContent, EntityId, EntityContent, + AppContent, AppId, + InstallContent, + InstallRequest, RecordId, Actor, ActorOptions, @@ -122,6 +125,20 @@ import { } from './grants.js'; import type { GrantQuery } from './grants.js'; import { bindingFieldsOf, uniqueBindingFieldsOf } from './identity-bindings.js'; +import { + claimedFamilies, + grantIsRequest, + installAppLink, + installGrantLink, + isSystemFamily, + linkedIds, + planFingerprint, + sameRequest, + validateInstall, + INSTALL_APP_LABEL, + INSTALL_GRANT_LABEL, +} from './install.js'; +import type { AppManifest, ForeignRequest, InstallPlan } from './install.js'; import { assertAttachmentSize, assertContentSize } from './limits.js'; import { validateParentId, @@ -365,6 +382,17 @@ export type DeleteAndReturnResult = DeleteResult & { record: StackRecord | null; }; +/** Options for Stack.migrateAll(). */ +export type MigrateAllOptions = { + /** + * `'all'` (the default) sweeps every record of the family, deleted and + * unlisted included. `'listed'` sweeps only what a non-owner can + * enumerate and read whole. See docs/spec/apps.md § Migrating an + * installed app's types. + */ + sweep?: 'all' | 'listed'; +}; + /** The argument to Stack.defineType(). */ export type DefineTypeOptions = { id: TypeId; @@ -878,8 +906,16 @@ export class Stack implements StackClient { * the only way disk state changes version. Sweeps soft-deleted records * too, validates each result before writing, and aborts on the first * validation failure. See docs/spec/data-model.md § Type migrations. + * + * `sweep: 'listed'` is for a contained app over `APIAdapter`, which can + * neither enumerate unlisted records nor read a deleted one's content: + * it migrates live, listed records and counts the deleted ones it passed + * over in `skipped`. See docs/spec/apps.md § Migrating an installed app's types. */ - async migrateAll(baseId: BaseId): Promise<{ migrated: number }> { + async migrateAll( + baseId: BaseId, + opts: MigrateAllOptions = {}, + ): Promise<{ migrated: number; skipped: number }> { this.assertOpen(); const problem = familyIdProblem(baseId, 'migrateAll'); if (problem) throw new StackValidationError([{ path: 'baseId', message: problem }]); @@ -890,7 +926,9 @@ export class Stack implements StackClient { throw new StackMigrationError(`migrateAll: no registered types found for baseId "${baseId}"`); } + const listedOnly = opts.sweep === 'listed'; let migrated = 0; + let skipped = 0; for (const typeId of familyTypeIds) { const latestId = this.latestTypeId(typeId); @@ -907,12 +945,16 @@ export class Stack implements StackClient { let cursor: string | undefined; do { const result: QueryResult = await this.adapter.queryRecords({ - filter: { typeId, includeDeleted: true, includeUnlisted: true }, + filter: { typeId, includeDeleted: true, includeUnlisted: !listedOnly }, limit: 100, cursor, }); for (const record of result.records) { + if (listedOnly && record.deletedAt) { + skipped++; + continue; + } // Same checked path commitMigration() takes — a migration // function is no more entitled to move a DID binding or repoint // an attachment than a request body is. No ifVersion: a batch @@ -925,7 +967,7 @@ export class Stack implements StackClient { } while (cursor); } - return { migrated }; + return { migrated, skipped }; } // ------------------------------------------------------- @@ -993,6 +1035,7 @@ export class Stack implements StackClient { ...validateContent(content, type.schema), ...validateGrantee(typeId, content), ...validateGrantBaseId(typeId, content), + ...validateInstall(typeId, content), ...validateAssociations(opts.permissions, 'permissions'), ...validatePermissions(opts.permissions), ...validateAssociations(opts.associations), @@ -1018,6 +1061,7 @@ export class Stack implements StackClient { await this.checkAttachmentAssociationPointers(opts.associations); await this.checkBindingsOnCreate(typeId, content as Record); + await this.checkInstallClaims(typeId, content); if (opts.id !== undefined) { validateRecordId(opts.id); @@ -1340,6 +1384,7 @@ export class Stack implements StackClient { ...validateContent(merged, type.schema), ...validateGrantee(existing.typeId, merged), ...validateGrantBaseId(existing.typeId, merged), + ...validateInstall(existing.typeId, merged), ]; if (contentErrors.length > 0) throw new StackValidationError(contentErrors); @@ -1357,6 +1402,7 @@ export class Stack implements StackClient { } await this.checkBindingsOnUpdate(existing.typeId, id, contentPatch, existing.content, merged); + if ('defines' in contentPatch) await this.checkInstallClaims(existing.typeId, merged, id); if (id === SYSTEM_TYPES.CONFIG) { this.checkConfigEntityIdUnchanged( @@ -1886,6 +1932,7 @@ export class Stack implements StackClient { ...validateContent(target.content, type.schema), ...validateGrantee(target.typeId, target.content), ...validateGrantBaseId(target.typeId, target.content), + ...validateInstall(target.typeId, target.content), ]; if (errors.length > 0) { throw new StackValidationError(errors); @@ -1912,6 +1959,10 @@ export class Stack implements StackClient { ); } + // Unlike a DID, a family can have been claimed by another install + // since this snapshot was taken. + await this.checkInstallClaims(target.typeId, target.content, id); + // A restore adds no containment edge and takes none away, so there is // no cycle for it to close and nothing for the destination checks to // gate. See docs/spec/versioning.md § Restore semantics. @@ -1989,6 +2040,7 @@ export class Stack implements StackClient { ...validateContent(content, type.schema), ...validateGrantee(toTypeId, content), ...validateGrantBaseId(toTypeId, content), + ...validateInstall(toTypeId, content), ]; if (errors.length > 0) { throw new StackValidationError(errors); @@ -2029,6 +2081,7 @@ export class Stack implements StackClient { } await this.checkBindingsOnMigrate(existing.typeId, toTypeId, id, existingContent, content); + await this.checkInstallClaims(toTypeId, content, id); if (id === SYSTEM_TYPES.CONFIG) { this.checkConfigEntityIdUnchanged( @@ -2793,6 +2846,284 @@ export class Stack implements StackClient { return matches; } + // ------------------------------------------------------- + // App installs + // ------------------------------------------------------- + + /** + * What applying `manifest` for the key `did` would change, for the owner + * to review before installApp(). Writes nothing. Refuses outright what no + * approval could make valid: a type in a system family or in a family + * another install claims, a request the grant rules refuse, or a `did` + * already registered to a different app. See docs/spec/apps.md § Plan, then apply. + */ + async planInstall(manifest: AppManifest, opts: { did: EntityId }): Promise { + this.assertOpen(); + const { did } = opts; + this.checkManifest(manifest, did); + + const installs = await this.loadInstalls(); + const existing = installs.find((r) => r.content.appId === manifest.appId) ?? null; + const owners = new Map(); + for (const r of installs) { + for (const family of claimedFamilies(r.content)) owners.set(family, r.content.appId); + } + + const manifestFamilies = new Set(manifest.types.map((t) => baseIdOf(t.id))); + for (const family of manifestFamilies) { + const owner = owners.get(family); + if (owner !== undefined && owner !== manifest.appId) { + throw new StackConflictError( + `Type family "${family}" is claimed by the install for "${owner}"`, + ); + } + } + + const card = await this.findAppCard(did); + if (card && (card.content as AppContent).appId !== manifest.appId) { + throw new StackConflictError( + `${did} is registered to "${(card.content as AppContent).appId}", not "${manifest.appId}"`, + ); + } + + const claimed = existing ? claimedFamilies(existing.content) : new Set(); + const owned = new Set([...claimed, ...manifestFamilies]); + const defined = new Set(existing?.content.defines ?? []); + const prior = existing?.content.requests ?? []; + const foreignRequests: ForeignRequest[] = manifest.requests + .filter((r) => !owned.has(r.baseId)) + .map((r) => ({ + ...r, + owner: isSystemFamily(r.baseId) ? 'system' : (owners.get(r.baseId) ?? null), + })); + + return { + manifest, + did, + existing, + newFamilies: [...manifestFamilies].filter((f) => !claimed.has(f)), + newVersions: [...new Set(manifest.types.map((t) => t.id))].filter((id) => !defined.has(id)), + requestsAdded: manifest.requests.filter((r) => !prior.some((p) => sameRequest(p, r))), + requestsRemoved: prior.filter((p) => !manifest.requests.some((r) => sameRequest(p, r))), + foreignRequests, + newKey: !existing || !card || !linkedIds(existing, INSTALL_APP_LABEL).includes(card.id), + }; + } + + /** + * Apply an approved plan: define the manifest's types, register the + * key's `_app` card if it has none, write the `_install` Record, and + * bring every linked key's grants to exactly what the manifest requests. + * Refuses a plan the stack has moved on from with StackConflictError, so + * what is applied is what was approved. Reinstalls a soft-deleted + * install. See docs/spec/apps.md § Plan, then apply. + */ + async installApp(plan: InstallPlan): Promise { + this.assertOpen(); + const fresh = await this.planInstall(plan.manifest, { did: plan.did }); + if (planFingerprint(fresh) !== planFingerprint(plan)) { + throw new StackConflictError( + `The stack changed since the install of "${plan.manifest.appId}" was planned; plan it again`, + ); + } + const { manifest, did, existing } = fresh; + + for (const type of manifest.types) await this.defineType(type); + const card = await this.ensureAppCard(manifest, did); + + const defines = [ + ...new Set([...(existing?.content.defines ?? []), ...manifest.types.map((t) => t.id)]), + ]; + const requests: InstallRequest[] = manifest.requests.map((r) => ({ + baseId: r.baseId, + actions: [...r.actions], + })); + + let install: StackRecord; + if (!existing) { + install = await this.create( + `${SYSTEM_TYPES.INSTALL}@1`, + { + appId: manifest.appId, + name: manifest.name, + ...(manifest.version !== undefined && { version: manifest.version }), + defines, + requests, + }, + { associations: [installAppLink(card.id)] }, + ); + } else { + const current = existing.deletedAt ? await this.undelete(existing.id) : existing; + install = await this.patchContent( + existing.id, + { name: manifest.name, version: manifest.version ?? null, defines, requests }, + { ifVersion: current.version }, + ); + if (!linkedIds(install, INSTALL_APP_LABEL).includes(card.id)) { + install = await this.associate(install.id, [installAppLink(card.id)]); + } + } + return (await this.reconcileInstallGrants(install)) as StackRecord & { + content: InstallContent; + }; + } + + /** + * Withdraw every grant an install produced and soft-delete it. The app's + * records and `_app` cards stay: the data is the owner's, and the cards + * are what its records' attribution resolves through. Returns the + * install's tombstone. See docs/spec/apps.md § Uninstalling. + */ + async uninstallApp(appId: AppId): Promise { + this.assertOpen(); + const install = (await this.loadInstalls()).find( + (r) => r.content.appId === appId && !r.deletedAt, + ); + if (!install) throw new StackNotFoundError(`No install for "${appId}"`); + const edits: AssociationEdit[] = []; + for (const id of linkedIds(install, INSTALL_GRANT_LABEL)) { + if (await this.get(id)) await this.delete(id); + edits.push({ op: 'remove', association: installGrantLink(id) }); + } + if (edits.length > 0) await this.amendAssociations(install.id, edits); + const { record } = await this.deleteAndReturn(install.id); + return record as StackRecord & { content: InstallContent }; + } + + /** A manifest's own shape, and every request held to grantType()'s rules. */ + private checkManifest(manifest: AppManifest, did: EntityId): void { + const errors: ValidationError[] = []; + if (typeof did !== 'string' || !did.startsWith('did:')) { + errors.push({ path: 'did', message: 'Expected a DID' }); + } + if (typeof manifest.appId !== 'string' || manifest.appId === '') { + errors.push({ path: 'appId', message: 'Expected a non-empty appId' }); + } + if (typeof manifest.name !== 'string' || manifest.name === '') { + errors.push({ path: 'name', message: 'Expected a non-empty name' }); + } + manifest.types.forEach((t, i) => { + const parsed = parseTypeId(t.id); + if (!parsed) { + errors.push({ path: `types[${i}].id`, message: 'Expected a versioned TypeId' }); + } else if (isSystemFamily(parsed.baseId)) { + errors.push({ + path: `types[${i}].id`, + message: `"${parsed.baseId}" is a system type; no app can define it`, + }); + } + }); + if (errors.length > 0) throw new StackValidationError(errors); + for (const r of manifest.requests) this.checkGrantValid(r.baseId, r.actions); + } + + /** Every `_install` Record, deleted and unlisted included — a deleted install keeps its claims. */ + private async loadInstalls(): Promise<(StackRecord & { content: InstallContent })[]> { + const records = await queryAllPages((q) => this.query(q), { + filter: { baseId: SYSTEM_TYPES.INSTALL, includeDeleted: true, includeUnlisted: true }, + }); + return records as (StackRecord & { content: InstallContent })[]; + } + + /** + * One install per family. A soft-deleted install keeps its claim for the + * reason a soft-deleted card keeps its DID: it can be undeleted. + * See docs/spec/apps.md § The `_install` record. + */ + private async checkInstallClaims( + typeId: TypeId, + content: unknown, + excludeId?: RecordId, + ): Promise { + if (baseIdOf(typeId) !== SYSTEM_TYPES.INSTALL) return; + const claimed = claimedFamilies(content); + if (claimed.size === 0) return; + for (const other of await this.loadInstalls()) { + if (other.id === excludeId) continue; + for (const family of claimedFamilies(other.content)) { + if (claimed.has(family)) { + throw new StackConflictError( + `Type family "${family}" is claimed by the install for "${other.content.appId}"`, + ); + } + } + } + } + + /** The `_app` card claiming `did`, deleted and unlisted included. */ + private findAppCard(did: EntityId): Promise { + return findFirstMatch( + (q) => this.query(q), + { + filter: { + baseId: SYSTEM_TYPES.APP, + includeDeleted: true, + includeUnlisted: true, + ...(filtersContent(this.capabilities) && { content: { did } }), + }, + }, + (r) => (r.content as AppContent).did === did, + ); + } + + /** The key's `_app` card, created or undeleted as needed. */ + private async ensureAppCard(manifest: AppManifest, did: EntityId): Promise { + const card = await this.findAppCard(did); + if (!card) { + return this.create(`${SYSTEM_TYPES.APP}@1`, { + appId: manifest.appId, + name: manifest.name, + ...(manifest.version !== undefined && { version: manifest.version }), + did, + }); + } + return card.deletedAt ? this.undelete(card.id) : card; + } + + /** + * Make the grants linked to `install` exactly its `requests`, once per + * live linked key: withdraw any that no longer match, write any missing, + * and drop links to grants that are gone. + */ + private async reconcileInstallGrants(install: StackRecord): Promise { + const { requests } = install.content as InstallContent; + const dids: EntityId[] = []; + for (const id of linkedIds(install, INSTALL_APP_LABEL)) { + const did = ((await this.get(id))?.content as AppContent | undefined)?.did; + if (typeof did === 'string') dids.push(did); + } + + const edits: AssociationEdit[] = []; + const held: GrantContent[] = []; + for (const id of linkedIds(install, INSTALL_GRANT_LABEL)) { + const grant = await this.get(id); + const content = grant?.content as GrantContent | undefined; + const wanted = + content !== undefined && + baseIdOf(grant!.typeId) === SYSTEM_TYPES.GRANT && + dids.some((did) => requests.some((r) => grantIsRequest(content, r, did))); + if (wanted) { + held.push(content); + continue; + } + if (grant) await this.delete(id); + edits.push({ op: 'remove', association: installGrantLink(id) }); + } + + for (const did of dids) { + for (const r of requests) { + if (held.some((g) => grantIsRequest(g, r, did))) continue; + const grant = await this.grantType(r.baseId, { + actions: r.actions, + grantee: { kind: 'entity', entityId: did }, + }); + held.push(grant.content); + edits.push({ op: 'add', association: installGrantLink(grant.id) }); + } + } + return edits.length > 0 ? this.amendAssociations(install.id, edits) : install; + } + // ------------------------------------------------------- // Private helpers // ------------------------------------------------------- @@ -2908,6 +3239,27 @@ export class Stack implements StackClient { filename: { kind: 'string' }, }, }); + await this.defineType({ + id: `${SYSTEM_TYPES.INSTALL}@1`, + name: 'Install', + schema: { + appId: { kind: 'string', required: true }, + name: { kind: 'string', required: true }, + version: { kind: 'string' }, + defines: { kind: 'array', items: { kind: 'string' }, required: true }, + requests: { + kind: 'array', + required: true, + items: { + kind: 'object', + properties: { + baseId: { kind: 'string', required: true }, + actions: { kind: 'array', items: { kind: 'string' }, required: true }, + }, + }, + }, + }, + }); } /** diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index b989678b..50ec8be9 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -494,6 +494,34 @@ export type GrantContent = { grantee: GrantGrantee; }; +/** One type-level grant an app's manifest asks for. */ +export type InstallRequest = { + /** The type family, as `grantType()` takes it. */ + baseId: BaseId; + actions: GrantAction[]; +}; + +/** + * Content for _install records: what the owner approved of an app's + * manifest, one live record per `appId`. See docs/spec/apps.md. + */ +export type InstallContent = { + /** + * The software this install is for. A binding: immutable, and unique + * among installs, so one install answers for each app. + */ + appId: AppId; + name: string; + version?: string; + /** + * Every type version the owner has approved for this app. The families + * they name are claimed by this install and by no other. + */ + defines: TypeId[]; + /** The grants each of the app's keys holds. */ + requests: InstallRequest[]; +}; + /** The metadata an upload's `_attachment@1` record carries beside its bytes. */ export type PutAttachmentOptions = { mimeType: string; @@ -540,6 +568,8 @@ export const SYSTEM_TYPES = { ATTACHMENT: '_attachment', /** Stack-level configuration singleton. See ConfigContent. */ CONFIG: '_config', + /** An owner's approval of an app's manifest. See InstallContent. */ + INSTALL: '_install', } as const; // ------------------------------------------------------- diff --git a/packages/core/tests/install.test.ts b/packages/core/tests/install.test.ts new file mode 100644 index 00000000..50b79eaf --- /dev/null +++ b/packages/core/tests/install.test.ts @@ -0,0 +1,383 @@ +import { describe, test, expect, beforeEach } from 'vitest'; +import { Stack } from '../src/stack.js'; +import { + StackConflictError, + StackNotFoundError, + StackPermissionError, + StackValidationError, +} from '../src/errors.js'; +import { MemoryAdapter } from '../src/testing.js'; +import type { AppManifest } from '../src/install.js'; +import type { AppContent, GrantContent, InstallContent, StackRecord } from '../src/types.js'; + +const OWNER = 'did:key:owner'; +const APP_DID = 'did:key:notes-app'; +const OTHER_DID = 'did:key:other-app'; +const PERSON = 'did:key:person'; + +const NOTE_1 = 'com.example.notes/note@1'; +const NOTE_2 = 'com.example.notes/note@2'; +const TAG_1 = 'com.example.notes/tag@1'; + +const manifest = (overrides: Partial = {}): AppManifest => ({ + appId: 'com.example.notes', + name: 'Notes', + version: '1.0.0', + types: [{ id: NOTE_1, name: 'Note', schema: { text: { kind: 'text' } } }], + requests: [{ baseId: 'com.example.notes/note', actions: ['create', 'read-any'] }], + ...overrides, +}); + +const NOTE_2_TYPE = { + id: NOTE_2, + name: 'Note', + schema: { text: { kind: 'text' }, pinned: { kind: 'boolean' } }, + migratesFrom: NOTE_1, +} as const; + +const MIGRATING = [ + { baseId: 'com.example.notes/note', actions: ['read-any', 'update-any'] }, +] as AppManifest['requests']; + +let stack: Stack; + +async function install(m: AppManifest, did = APP_DID) { + return stack.installApp(await stack.planInstall(m, { did })); +} + +async function linkedGrants(record: StackRecord): Promise { + const out: GrantContent[] = []; + for (const a of record.associations ?? []) { + if (a.kind !== 'relationship' || a.label !== 'install.grant' || a.target.kind !== 'record') { + continue; + } + const grant = await stack.get(a.target.recordId); + if (grant) out.push(grant.content as GrantContent); + } + return out; +} + +beforeEach(async () => { + stack = await Stack.open(await MemoryAdapter.open({ ownerEntityId: OWNER })); +}); + +describe('installApp()', () => { + test('defines the types, registers the key and grants exactly the requests', async () => { + const record = await install(manifest()); + + expect(await stack.getType(NOTE_1)).not.toBeNull(); + expect(record.typeId).toBe('_install@1'); + expect(record.content).toEqual({ + appId: 'com.example.notes', + name: 'Notes', + version: '1.0.0', + defines: [NOTE_1], + requests: [{ baseId: 'com.example.notes/note', actions: ['create', 'read-any'] }], + }); + + const card = (await stack.query({ filter: { baseId: '_app' } })).records[0]!; + expect(card.content).toMatchObject({ appId: 'com.example.notes', did: APP_DID }); + + expect(await linkedGrants(record)).toEqual([ + { + baseId: 'com.example.notes/note', + actions: ['create', 'read-any'], + grantee: { kind: 'entity', entityId: APP_DID }, + }, + ]); + expect(await stack.listTypeGrants()).toHaveLength(1); + }); + + test('re-applying the same manifest changes nothing', async () => { + const first = await install(manifest()); + const plan = await stack.planInstall(manifest(), { did: APP_DID }); + expect(plan).toMatchObject({ + newFamilies: [], + newVersions: [], + requestsAdded: [], + requestsRemoved: [], + newKey: false, + }); + const second = await stack.installApp(plan); + expect(second.id).toBe(first.id); + expect(await stack.listTypeGrants()).toHaveLength(1); + }); + + test('an upgrade adds approved versions and brings grants to the new requests', async () => { + const first = await install(manifest()); + const next = manifest({ version: '2.0.0', types: [NOTE_2_TYPE], requests: MIGRATING }); + const plan = await stack.planInstall(next, { did: APP_DID }); + expect(plan.newVersions).toEqual([NOTE_2]); + expect(plan.newFamilies).toEqual([]); + expect(plan.requestsAdded).toEqual(MIGRATING); + expect(plan.requestsRemoved).toEqual(manifest().requests); + + const upgraded = await stack.installApp(plan); + expect(upgraded.content.defines).toEqual([NOTE_1, NOTE_2]); + expect((await linkedGrants(upgraded)).map((g) => g.actions)).toEqual([ + ['read-any', 'update-any'], + ]); + expect(await stack.listTypeGrants()).toHaveLength(1); + expect((await stack.getVersions(first.id)).length).toBeGreaterThan(0); + }); + + test('a second key gets the same grants, and an upgrade reaches every linked key', async () => { + await install(manifest()); + const plan = await stack.planInstall(manifest(), { did: OTHER_DID }); + expect(plan.newKey).toBe(true); + await stack.installApp(plan); + expect( + (await stack.listTypeGrants()).map( + (g) => (g.content.grantee as { entityId: string }).entityId, + ), + ).toEqual(expect.arrayContaining([APP_DID, OTHER_DID])); + + await install(manifest({ requests: [] })); + expect(await stack.listTypeGrants()).toHaveLength(0); + }); + + test('refuses a plan the stack has moved on from', async () => { + const plan = await stack.planInstall(manifest(), { did: APP_DID }); + await install(manifest()); + await expect(stack.installApp(plan)).rejects.toThrow(StackConflictError); + }); + + test('a key registered to another app is refused', async () => { + await stack.create('_app@1', { appId: 'com.example.other', name: 'Other', did: APP_DID }); + await expect(stack.planInstall(manifest(), { did: APP_DID })).rejects.toThrow( + StackConflictError, + ); + }); + + test('system types can be neither defined nor requested when ungrantable', async () => { + await expect( + stack.planInstall(manifest({ types: [{ id: '_entity@2', name: 'Entity', schema: {} }] }), { + did: APP_DID, + }), + ).rejects.toThrow(StackValidationError); + await expect( + stack.planInstall(manifest({ requests: [{ baseId: '_install', actions: ['create'] }] }), { + did: APP_DID, + }), + ).rejects.toThrow(StackValidationError); + }); + + test('requests outside the families the manifest defines name their owner', async () => { + await install( + manifest({ + appId: 'com.example.tags', + name: 'Tags', + types: [{ id: TAG_1, name: 'Tag', schema: { label: { kind: 'string' } } }], + requests: [], + }), + OTHER_DID, + ); + const plan = await stack.planInstall( + manifest({ + requests: [ + { baseId: 'com.example.notes/note', actions: ['create'] }, + { baseId: 'com.example.notes/tag', actions: ['read-any'] }, + { baseId: '_entity', actions: ['read-any'] }, + { baseId: 'org.example/bookmark', actions: ['read-any'] }, + ], + }), + { did: APP_DID }, + ); + expect(plan.foreignRequests).toEqual([ + { baseId: 'com.example.notes/tag', actions: ['read-any'], owner: 'com.example.tags' }, + { baseId: '_entity', actions: ['read-any'], owner: 'system' }, + { baseId: 'org.example/bookmark', actions: ['read-any'], owner: null }, + ]); + }); +}); + +describe('the _install record', () => { + test('a family is claimed by one install only', async () => { + await install(manifest()); + const rival = manifest({ appId: 'com.example.rival', name: 'Rival' }); + await expect(stack.planInstall(rival, { did: OTHER_DID })).rejects.toThrow(StackConflictError); + await expect( + stack.create('_install@1', { + appId: 'com.example.rival', + name: 'Rival', + defines: [NOTE_2], + requests: [], + }), + ).rejects.toThrow(StackConflictError); + }); + + test('one install answers for each appId, and appId is immutable', async () => { + const record = await install(manifest()); + await expect( + stack.create('_install@1', { + appId: 'com.example.notes', + name: 'Notes again', + defines: [], + requests: [], + }), + ).rejects.toThrow(StackConflictError); + await expect(stack.patchContent(record.id, { appId: 'com.example.other' })).rejects.toThrow( + StackValidationError, + ); + }); + + test('defines cannot name a system family', async () => { + await expect( + stack.create('_install@1', { + appId: 'com.example.x', + name: 'X', + defines: ['_grant@1'], + requests: [], + }), + ).rejects.toThrow(StackValidationError); + }); + + test('cannot be granted, and only the owner acting alone writes one', async () => { + await expect( + stack.grantType('_install', { + actions: ['create'], + grantee: { kind: 'entity', entityId: PERSON }, + }), + ).rejects.toThrow(StackValidationError); + + const record = await install(manifest()); + await stack.grantAccess( + record.id, + (['read', 'write'] as const).map((label) => ({ + kind: 'permission' as const, + label, + grantee: { kind: 'entity' as const, entityId: PERSON }, + })), + ); + await expect( + stack.asEntity(PERSON).patchContent(record.id, { name: 'Renamed' }), + ).rejects.toThrow(StackPermissionError); + }); +}); + +describe('uninstallApp()', () => { + test('withdraws the grants, keeps the card, and a later install restores it', async () => { + const record = await install(manifest()); + const removed = await stack.uninstallApp('com.example.notes'); + expect(removed.deletedAt).toBeDefined(); + expect(await stack.listTypeGrants()).toHaveLength(0); + expect((await stack.query({ filter: { baseId: '_app' } })).records).toHaveLength(1); + + const again = await install(manifest()); + expect(again.id).toBe(record.id); + expect(again.deletedAt).toBeUndefined(); + expect(await stack.listTypeGrants()).toHaveLength(1); + }); + + test('an unknown appId is not found', async () => { + await expect(stack.uninstallApp('com.example.none')).rejects.toThrow(StackNotFoundError); + }); +}); + +describe('commitMigration() for an installed app', () => { + async function installForMigration(requests = MIGRATING) { + await install(manifest({ types: [manifest().types[0]!, NOTE_2_TYPE], requests })); + return stack.create(NOTE_1, { text: 'hello' }); + } + + test('the app may migrate within its own families to an approved version', async () => { + const note = await installForMigration(); + const migrated = await stack + .asEntity(APP_DID) + .commitMigration(note.id, NOTE_2, { text: 'hello', pinned: false }); + expect(migrated.typeId).toBe(NOTE_2); + expect(migrated.updatedBy).toEqual({ subjectId: APP_DID }); + }); + + test('update-any has to be requested', async () => { + const note = await installForMigration([ + { baseId: 'com.example.notes/note', actions: ['read-any', 'update-own'] }, + ]); + await expect( + stack.asEntity(APP_DID).commitMigration(note.id, NOTE_2, { text: 'hello' }), + ).rejects.toThrow(StackPermissionError); + }); + + test('a version the owner has not approved is refused', async () => { + const note = await installForMigration(); + await stack.defineType({ id: 'com.example.notes/note@3', name: 'Note', schema: {} }); + await expect( + stack.asEntity(APP_DID).commitMigration(note.id, 'com.example.notes/note@3', {}), + ).rejects.toThrow(StackPermissionError); + }); + + test('a family the install does not claim is refused', async () => { + const note = await installForMigration(); + await stack.defineType({ id: 'org.example/other@1', name: 'Other', schema: {} }); + await stack.grantType('org.example/other', { + actions: ['read-any', 'update-any'], + grantee: { kind: 'entity', entityId: APP_DID }, + }); + await expect( + stack.asEntity(APP_DID).commitMigration(note.id, 'org.example/other@1', {}), + ).rejects.toThrow(StackPermissionError); + }); + + test('the app acting for someone else, or a key not linked to the install, is refused', async () => { + const note = await installForMigration(); + await expect( + stack + .asActor({ principalId: APP_DID, subjectId: PERSON }) + .commitMigration(note.id, NOTE_2, { text: 'hello' }), + ).rejects.toThrow(StackPermissionError); + await stack.grantType('com.example.notes/note', { + actions: ['read-any', 'update-any'], + grantee: { kind: 'entity', entityId: OTHER_DID }, + }); + await expect( + stack.asEntity(OTHER_DID).commitMigration(note.id, NOTE_2, { text: 'hello' }), + ).rejects.toThrow(StackPermissionError); + }); + + test('an uninstalled app may no longer migrate', async () => { + const note = await installForMigration(); + await stack.uninstallApp('com.example.notes'); + await stack.grantType('com.example.notes/note', { + actions: ['read-any', 'update-any'], + grantee: { kind: 'entity', entityId: APP_DID }, + }); + await expect( + stack.asEntity(APP_DID).commitMigration(note.id, NOTE_2, { text: 'hello' }), + ).rejects.toThrow(StackPermissionError); + }); +}); + +describe("migrateAll({ sweep: 'listed' })", () => { + test('migrates live, listed records and counts the deleted ones it passed over', async () => { + await stack.defineType(manifest().types[0]!); + await stack.defineType(NOTE_2_TYPE); + stack.registerMigration({ + from: NOTE_1, + to: NOTE_2, + migrate: (c) => ({ ...c, pinned: false }), + }); + const live = await stack.create(NOTE_1, { text: 'a' }); + const deleted = await stack.create(NOTE_1, { text: 'b' }); + await stack.delete(deleted.id); + const unlisted = await stack.create(NOTE_1, { text: 'c' }, { unlisted: true }); + + expect(await stack.migrateAll('com.example.notes/note', { sweep: 'listed' })).toEqual({ + migrated: 1, + skipped: 1, + }); + expect((await stack.get(live.id))!.typeId).toBe(NOTE_2); + expect((await stack.get(deleted.id, { includeDeleted: true }))!.typeId).toBe(NOTE_1); + expect((await stack.get(unlisted.id))!.typeId).toBe(NOTE_1); + + expect(await stack.migrateAll('com.example.notes/note')).toEqual({ migrated: 2, skipped: 0 }); + }); +}); + +describe('the _app card an install registers', () => { + test('is the owner’s, carrying the manifest’s name', async () => { + await install(manifest()); + const card = (await stack.query({ filter: { baseId: '_app' } })).records[0]!; + expect((card.content as AppContent).name).toBe('Notes'); + expect(card.createdBy).toBeUndefined(); + }); +}); From d4ed32e7a984689f28a75f572d1f00b511be8343 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 20:59:59 +0000 Subject: [PATCH 02/11] fix(core): an install claims only families in its own namespace Claims were first-come: on a stack where an app was never installed, the first manifest listing its family took it over, migration rights included, and nothing stopped a manifest claiming a commons family. A family now belongs to the app whose appId is its namespace. A manifest may list commons types so they get defined, but defining one claims nothing and no app may migrate a commons family. A type in another app's namespace is refused in favour of a request, which the plan reports with the family's owner. Claim uniqueness follows from appId uniqueness, so the separate claim check goes away. Refs #359 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LeWBt7FirCqK6pCrRwaVCE --- docs/spec/access-control.md | 2 +- docs/spec/apps.md | 28 ++++--- packages/core/src/install.ts | 46 +++++++++++- packages/core/src/scoped-stack.ts | 5 +- packages/core/src/stack.ts | 101 +++++++++---------------- packages/core/tests/install.test.ts | 111 ++++++++++++++++++---------- 6 files changed, 173 insertions(+), 120 deletions(-) diff --git a/docs/spec/access-control.md b/docs/spec/access-control.md index 35b9b09f..dc454846 100644 --- a/docs/spec/access-control.md +++ b/docs/spec/access-control.md @@ -392,7 +392,7 @@ Per key, over a Record in no system family: | `parentId` | the same, plus read access to the destination | | `permissions`, `unlisted` | owner or the Record's creator, on both sides of a delegation — and never a delegated principal, as at [create time](#delegation-principal-and-subject) | -The family fences apply to every key alike, whatever the table says: a `_group` Record is writable only by an admin or the owner, a `_grant` Record only by the owner acting alone, and an `_app` card's `did`/`appId` only by the owner acting alone (see [Identity § DID bindings](./identity.md#did-bindings)). +The family fences apply to every key alike, whatever the table says: a `_group` Record is writable only by an admin or the owner, a `_grant` or `_install` Record only by the owner acting alone, and an `_app` card's `did`/`appId` only by the owner acting alone (see [Identity § DID bindings](./identity.md#did-bindings)). **A single coarse gate over the whole verb is deliberately not the rule.** Requiring the strictest of them — the owner acting alone — for any multi-key call would be simpler to state, and would leave every collaborator back at one call per aspect, which is the cost the verb exists to remove. Requiring the loosest would hand a write-holder the reshare the [`write` bit](#the-write-bit-a-recoverability-trust-model) is defined not to carry. Per-key composition is the only one that changes nobody's reach. diff --git a/docs/spec/apps.md b/docs/spec/apps.md index eb7a4d49..3c6abe05 100644 --- a/docs/spec/apps.md +++ b/docs/spec/apps.md @@ -41,9 +41,9 @@ type InstallContent = { - **It cannot be granted.** `grantType()` refuses `_install` beside `_grant`, `_config` and `_app` (see [Access control § What a grant covers](./access-control.md#what-a-grant-covers)), and a `request` naming any of the four is refused at the write. - **Only the owner acting alone writes one.** `ScopedStack` refuses every write to an `_install` Record on the same terms as a `_grant` Record, whatever the Record's own `permissions` say. - **`appId` is a binding**, immutable and unique on the terms [Identity § DID bindings](./identity.md#did-bindings) sets out: one install answers for each app, and an existing install cannot be relabelled to answer for another. -- **A family is claimed by one install.** The families named by an install's `defines` are its own, and a write that would have a second install claim one is refused with `StackConflictError`, on create, patch, migration and restore alike. A soft-deleted install keeps its claims, for the reason a soft-deleted card keeps its DID: it can be undeleted. No install can claim a system family. +- **It claims only its own families.** Every `defines` entry must be a versioned TypeId in the install's own namespace (see [Who owns a family](#who-owns-a-family)); anything else is refused with `StackValidationError` on create, patch, migration and restore alike. `appId` being unique is what makes each family's owner single. -Every `defines` entry must be a versioned TypeId, and every `request` must name a family and actions from the grant vocabulary; `StackValidationError` otherwise. +Every `request` must name a family and actions from the grant vocabulary; `StackValidationError` otherwise. **An install and an `_app` card answer different questions.** A card is one per _key_: an app on two devices, or after a key change, has two cards sharing an `appId`. An install is one per _software_. A card says who a key is; an install says what the owner agreed to let that software do. They are linked rather than merged so neither has to answer the other's question. @@ -51,6 +51,16 @@ Every `defines` entry must be a versioned TypeId, and every `request` must name **Version history is the upgrade log.** Applying a changed manifest patches the install, so what was approved at any earlier point is a [version](./versioning.md#version-history) of it. +## Who owns a family + +**A family belongs to the app whose `appId` is its namespace** — the part of the `BaseId` before the `/`. `com.example.notes/note` is `com.example.notes`'s, whether or not that app is installed, so ownership never depends on which app reached a stack first. A Type's namespace is already how its author is named ([Data model § Types](./data-model.md#types)); an install only makes that enforceable. + +Two kinds of family belong to no app. **System families** (`_entity`, `_grant`, …) are the library's. **Commons families** (`org.haverstack/…`) are governed in the open by the [Schema Commons](../commons/README.md), precisely so that no single app controls a shape every app reads. A manifest may list commons types so that installing it defines them, but defining one claims nothing, and no app may migrate a commons family. + +**Using a family is a request; owning one is control of its schema.** Any app may ask for grants on any grantable family — another app's, a commons one, `_entity` — and the owner sees each such request, with the family's owner, in the plan. Only the owner of a family defines its versions and migrates its records, so two apps can never publish rival versions of the same family. An app that wants to add to records it does not own defines a family of its own and links its records to them with `relationship` associations; the shared family is untouched. + +**Residual, stated rather than fixed:** `appId` is the app's own claim. On a stack where the real `com.example.notes` is not installed, another app can present a manifest under that `appId` and, if approved, own its families. The plan names the `appId` asking, so the owner is the check; closing the gap needs signed manifests. + ## Plan, then apply `planInstall(manifest, { did })` writes nothing. It reports what applying the manifest for that key would change: @@ -64,14 +74,14 @@ type InstallPlan = { newVersions: TypeId[]; // versions not yet in `defines` requestsAdded: InstallRequest[]; requestsRemoved: InstallRequest[]; - foreignRequests: (InstallRequest & { owner: AppId | 'system' | null })[]; + foreignRequests: (InstallRequest & { owner: AppId | 'commons' | 'system' | null })[]; newKey: boolean; // whether `did` is not yet linked to this install }; ``` -`foreignRequests` are the requests on families the install does not define, each naming who owns the family: another install's `appId`, `'system'`, or `null` when nothing claims it. They are the requests an approval most needs to show — an app asking to read another app's records, or `_entity`, is asking for reach beyond its own data. +`foreignRequests` are the requests on families outside the app's own namespace, each naming the family's [owner](#who-owns-a-family): another app's `appId`, `'commons'`, `'system'`, or `null` for a family with no namespace. They are the requests an approval most needs to show — an app asking to read another app's records, or `_entity`, is asking for reach beyond its own data. -It refuses what no approval could make valid: a type in a system family or in a family another install claims (`StackConflictError`), a request [`grantType()` would refuse](./access-control.md#type-level-grants) (`StackValidationError`), and a `did` whose `_app` card names a different `appId` (`StackConflictError`). +It refuses what no approval could make valid: a type outside the app's own namespace and the commons (`StackValidationError` — use a request instead), a request [`grantType()` would refuse](./access-control.md#type-level-grants) (`StackValidationError`), and a `did` whose `_app` card names a different `appId` (`StackConflictError`). `installApp(plan)` applies the plan: @@ -80,18 +90,18 @@ It refuses what no approval could make valid: a type in a system family or in a 3. Creates the install, or patches it — undeleting it first if it was uninstalled. `defines` gains the manifest's versions and never loses any, since a Type once defined stays defined; `requests` becomes the manifest's. 4. Brings the grants of **every** key linked to the install to exactly `requests`: a grant no longer requested is revoked, a missing one is written, and the links follow. -**Nothing is applied that was not approved.** `installApp()` plans the same manifest again and refuses with `StackConflictError` when the result differs from the plan it was handed — another install claiming a family, the install changing, the key being linked. The remedy is to plan again and show the owner the new plan. Re-applying a manifest whose plan is empty changes nothing. +**Nothing is applied that was not approved.** `installApp()` plans the same manifest again and refuses with `StackConflictError` when the result differs from the plan it was handed — the install changing, or the key being linked, since it was planned. The remedy is to plan again and show the owner the new plan. Re-applying a manifest whose plan is empty changes nothing. ## Migrating an installed app's types [Migration is owner-acting-alone](./access-control.md#what-a-grant-covers), and an installed app's migration functions are its own code, which the owner should not have to run with owner authority. An install closes the gap: a contained app may commit migrations within its own families. `ScopedStack.commitMigration()` — and so `migrateAll()` run by the app over `APIAdapter`, which commits through `POST /records/:id/migrate` — admits a request that is not the owner acting alone when **all** of these hold: -1. The source and target families are both claimed by one live install, and by no other. +1. The source and target families are both in the namespace of one live install, and both in its `defines`. 2. The target TypeId is in that install's `defines` — a version the owner approved. 3. The requester holds `update-any` on each family through a grant naming its DID directly. Default and group grants do not count, on the terms they do not count for [a principal](./access-control.md#who-a-grant-reaches). The manifest has to request it, so the plan shows it. 4. The requester is acting alone — not delegated — as the DID of an `_app` card linked to the install. -The reasons migration is otherwise owner-only do not reach this case. A migration that crosses into `_attachment` or moves a DID binding needs a system family at one end, and no install can claim one. Ordinary write access is not consent to move a Record between versions; the owner's approval of the version is. Every migration still snapshots the Record's prior content and type to [version history](./versioning.md#version-history), so it stays recoverable like any other write. +The reasons migration is otherwise owner-only do not reach this case. A migration that crosses into `_attachment` or moves a DID binding needs a system family at one end, and no install can claim one; nor can it claim a commons family, which every app reads. Ordinary write access is not consent to move a Record between versions; the owner's approval of the version is. Every migration still snapshots the Record's prior content and type to [version history](./versioning.md#version-history), so it stays recoverable like any other write. Approving a new version is therefore also approving its migration. Until the owner approves it, the app reads records at the older version through [`presentAt: 'latest'`](./data-model.md#type-migrations). @@ -107,4 +117,4 @@ which migrates live, listed records and counts the soft-deleted ones it passed o `uninstallApp(appId)` revokes every grant linked to the install and soft-deletes it. The app's records stay: they are the owner's data, and purging them is a separate, deliberate act. Its `_app` cards stay too, since they are what its records' attribution resolves through (see [Identity § Attribution and what can be trusted](./identity.md#attribution-and-what-can-be-trusted)). A deleted install confers no migration authority. -Installing the same `appId` again undeletes the install and grants its requests afresh. Its families stay claimed in between, so no other app can take them over while it is uninstalled. +Installing the same `appId` again undeletes the install and grants its requests afresh. diff --git a/packages/core/src/install.ts b/packages/core/src/install.ts index a453568b..701b0a9e 100644 --- a/packages/core/src/install.ts +++ b/packages/core/src/install.ts @@ -49,8 +49,12 @@ export type AppManifest = { /** A request on a family this install does not define, and who owns that family. */ export type ForeignRequest = InstallRequest & { - /** The `appId` of the install claiming the family, `'system'`, or null when nothing claims it. */ - owner: AppId | 'system' | null; + /** + * The app whose namespace the family is in, whether or not it is + * installed; `'commons'` or `'system'` for families no app owns; null + * for a family with no namespace. + */ + owner: AppId | 'commons' | 'system' | null; }; /** @@ -108,6 +112,32 @@ const SYSTEM_FAMILIES: ReadonlySet = new Set(Object.values(SYSTEM_TYPES) export const isSystemFamily = (baseId: BaseId): boolean => SYSTEM_FAMILIES.has(baseId); +/** The Schema Commons namespace: families no app owns. See docs/commons/README.md. */ +export const COMMONS_NAMESPACE = 'org.haverstack'; + +/** The part of a family before its `/` — `com.example.notes` for `com.example.notes/note`. */ +export const namespaceOf = (baseId: BaseId): string | null => { + const slash = baseId.indexOf('/'); + return slash > 0 ? baseId.slice(0, slash) : null; +}; + +/** + * How a family stands toward the app `appId`: its own (the family's + * namespace is the `appId`), a commons or system family nobody owns, or + * another app's. Only an app's own families can be claimed, so which app + * owns a family never depends on which was installed first. + * See docs/spec/apps.md § Who owns a family. + */ +export function familyStanding( + baseId: BaseId, + appId: AppId, +): 'own' | 'commons' | 'system' | 'foreign' { + if (isSystemFamily(baseId)) return 'system'; + const namespace = namespaceOf(baseId); + if (namespace === COMMONS_NAMESPACE) return 'commons'; + return namespace === appId ? 'own' : 'foreign'; +} + /** * The families an install claims, read as data: entries that are not * well-formed TypeIds claim nothing. @@ -138,10 +168,10 @@ export function validateInstall(typeId: TypeId, content: unknown): ValidationErr const parsed = typeof id === 'string' ? parseTypeId(id) : null; if (!parsed) { errors.push({ path: `defines[${i}]`, message: 'Expected a versioned TypeId' }); - } else if (isSystemFamily(parsed.baseId)) { + } else if (typeof c?.appId === 'string' && familyStanding(parsed.baseId, c.appId) !== 'own') { errors.push({ path: `defines[${i}]`, - message: `"${parsed.baseId}" is a system type; no install can define it`, + message: `"${parsed.baseId}" is outside the namespace "${c.appId}"; an install claims only its own families`, }); } }); @@ -173,6 +203,14 @@ export function validateInstall(typeId: TypeId, content: unknown): ValidationErr return errors; } +/** The manifest's types in its own namespace — the versions an install of it defines. */ +export function ownTypeIds(manifest: AppManifest): TypeId[] { + const ids = manifest.types + .map((t) => t.id) + .filter((id) => familyStanding(baseIdOf(id), manifest.appId) === 'own'); + return [...new Set(ids)]; +} + /** Whether two requests ask for the same family and exactly the same actions. */ export function sameRequest(a: InstallRequest, b: InstallRequest): boolean { if (a.baseId !== b.baseId || a.actions.length !== b.actions.length) return false; diff --git a/packages/core/src/scoped-stack.ts b/packages/core/src/scoped-stack.ts index ab69c06c..bd6c0f92 100644 --- a/packages/core/src/scoped-stack.ts +++ b/packages/core/src/scoped-stack.ts @@ -89,7 +89,7 @@ import { UNGRANTABLE_SYSTEM_TYPES, } from './grants.js'; import { bindingFieldsOf } from './identity-bindings.js'; -import { claimedFamilies, linkedIds, INSTALL_APP_LABEL } from './install.js'; +import { claimedFamilies, familyStanding, linkedIds, INSTALL_APP_LABEL } from './install.js'; import { assertAttachmentSize } from './limits.js'; import { validateIdTimestampSkew, validateRecordId } from './record-id.js'; import { @@ -1559,7 +1559,7 @@ export class ScopedStack implements StackClient { /** * Whether this request is an installed app migrating a record within its * own families: the app acting as itself, its key linked to the one live - * install claiming both families, `toTypeId` a version the owner + * install claiming both families in its own namespace, `toTypeId` a version the owner * approved, and an `update-any` grant on each family made out to the key * directly. Read as data, so a family two installs claim — however that * came to be — confers nothing. @@ -1583,6 +1583,7 @@ export class ScopedStack implements StackClient { for (const family of families) { const claimants = installs.filter((r) => claimedFamilies(r.content).has(family)); if (claimants.length !== 1) return false; + if (familyStanding(family, claimants[0]!.content.appId) !== 'own') return false; if (install && install.id !== claimants[0]!.id) return false; install = claimants[0]; } diff --git a/packages/core/src/stack.ts b/packages/core/src/stack.ts index d7b045c1..d553060c 100644 --- a/packages/core/src/stack.ts +++ b/packages/core/src/stack.ts @@ -127,11 +127,13 @@ import type { GrantQuery } from './grants.js'; import { bindingFieldsOf, uniqueBindingFieldsOf } from './identity-bindings.js'; import { claimedFamilies, + familyStanding, grantIsRequest, installAppLink, installGrantLink, - isSystemFamily, linkedIds, + namespaceOf, + ownTypeIds, planFingerprint, sameRequest, validateInstall, @@ -1061,7 +1063,6 @@ export class Stack implements StackClient { await this.checkAttachmentAssociationPointers(opts.associations); await this.checkBindingsOnCreate(typeId, content as Record); - await this.checkInstallClaims(typeId, content); if (opts.id !== undefined) { validateRecordId(opts.id); @@ -1402,7 +1403,6 @@ export class Stack implements StackClient { } await this.checkBindingsOnUpdate(existing.typeId, id, contentPatch, existing.content, merged); - if ('defines' in contentPatch) await this.checkInstallClaims(existing.typeId, merged, id); if (id === SYSTEM_TYPES.CONFIG) { this.checkConfigEntityIdUnchanged( @@ -1959,10 +1959,6 @@ export class Stack implements StackClient { ); } - // Unlike a DID, a family can have been claimed by another install - // since this snapshot was taken. - await this.checkInstallClaims(target.typeId, target.content, id); - // A restore adds no containment edge and takes none away, so there is // no cycle for it to close and nothing for the destination checks to // gate. See docs/spec/versioning.md § Restore semantics. @@ -2081,7 +2077,6 @@ export class Stack implements StackClient { } await this.checkBindingsOnMigrate(existing.typeId, toTypeId, id, existingContent, content); - await this.checkInstallClaims(toTypeId, content, id); if (id === SYSTEM_TYPES.CONFIG) { this.checkConfigEntityIdUnchanged( @@ -2853,9 +2848,9 @@ export class Stack implements StackClient { /** * What applying `manifest` for the key `did` would change, for the owner * to review before installApp(). Writes nothing. Refuses outright what no - * approval could make valid: a type in a system family or in a family - * another install claims, a request the grant rules refuse, or a `did` - * already registered to a different app. See docs/spec/apps.md § Plan, then apply. + * approval could make valid: a type outside the app's own namespace and + * the commons, a request the grant rules refuse, or a `did` already + * registered to a different app. See docs/spec/apps.md § Plan, then apply. */ async planInstall(manifest: AppManifest, opts: { did: EntityId }): Promise { this.assertOpen(); @@ -2864,20 +2859,8 @@ export class Stack implements StackClient { const installs = await this.loadInstalls(); const existing = installs.find((r) => r.content.appId === manifest.appId) ?? null; - const owners = new Map(); - for (const r of installs) { - for (const family of claimedFamilies(r.content)) owners.set(family, r.content.appId); - } - - const manifestFamilies = new Set(manifest.types.map((t) => baseIdOf(t.id))); - for (const family of manifestFamilies) { - const owner = owners.get(family); - if (owner !== undefined && owner !== manifest.appId) { - throw new StackConflictError( - `Type family "${family}" is claimed by the install for "${owner}"`, - ); - } - } + const ownVersions = ownTypeIds(manifest); + const ownFamilies = new Set(ownVersions.map(baseIdOf)); const card = await this.findAppCard(did); if (card && (card.content as AppContent).appId !== manifest.appId) { @@ -2887,22 +2870,27 @@ export class Stack implements StackClient { } const claimed = existing ? claimedFamilies(existing.content) : new Set(); - const owned = new Set([...claimed, ...manifestFamilies]); const defined = new Set(existing?.content.defines ?? []); const prior = existing?.content.requests ?? []; - const foreignRequests: ForeignRequest[] = manifest.requests - .filter((r) => !owned.has(r.baseId)) - .map((r) => ({ - ...r, - owner: isSystemFamily(r.baseId) ? 'system' : (owners.get(r.baseId) ?? null), - })); + const foreignRequests: ForeignRequest[] = []; + for (const r of manifest.requests) { + const standing = familyStanding(r.baseId, manifest.appId); + if (standing === 'own') continue; + const owner = + standing === 'foreign' + ? namespaceOf(r.baseId) + : standing === 'commons' + ? 'commons' + : 'system'; + foreignRequests.push({ ...r, owner }); + } return { manifest, did, existing, - newFamilies: [...manifestFamilies].filter((f) => !claimed.has(f)), - newVersions: [...new Set(manifest.types.map((t) => t.id))].filter((id) => !defined.has(id)), + newFamilies: [...ownFamilies].filter((f) => !claimed.has(f)), + newVersions: ownVersions.filter((id) => !defined.has(id)), requestsAdded: manifest.requests.filter((r) => !prior.some((p) => sameRequest(p, r))), requestsRemoved: prior.filter((p) => !manifest.requests.some((r) => sameRequest(p, r))), foreignRequests, @@ -2931,9 +2919,7 @@ export class Stack implements StackClient { for (const type of manifest.types) await this.defineType(type); const card = await this.ensureAppCard(manifest, did); - const defines = [ - ...new Set([...(existing?.content.defines ?? []), ...manifest.types.map((t) => t.id)]), - ]; + const defines = [...new Set([...(existing?.content.defines ?? []), ...ownTypeIds(manifest)])]; const requests: InstallRequest[] = manifest.requests.map((r) => ({ baseId: r.baseId, actions: [...r.actions], @@ -3006,11 +2992,17 @@ export class Stack implements StackClient { const parsed = parseTypeId(t.id); if (!parsed) { errors.push({ path: `types[${i}].id`, message: 'Expected a versioned TypeId' }); - } else if (isSystemFamily(parsed.baseId)) { - errors.push({ - path: `types[${i}].id`, - message: `"${parsed.baseId}" is a system type; no app can define it`, - }); + } else { + const standing = familyStanding(parsed.baseId, manifest.appId); + if (standing === 'system' || standing === 'foreign') { + errors.push({ + path: `types[${i}].id`, + message: + standing === 'system' + ? `"${parsed.baseId}" is a system type; no app can define it` + : `"${parsed.baseId}" is outside the namespace "${manifest.appId}"; request access to it instead of defining it`, + }); + } } }); if (errors.length > 0) throw new StackValidationError(errors); @@ -3025,31 +3017,6 @@ export class Stack implements StackClient { return records as (StackRecord & { content: InstallContent })[]; } - /** - * One install per family. A soft-deleted install keeps its claim for the - * reason a soft-deleted card keeps its DID: it can be undeleted. - * See docs/spec/apps.md § The `_install` record. - */ - private async checkInstallClaims( - typeId: TypeId, - content: unknown, - excludeId?: RecordId, - ): Promise { - if (baseIdOf(typeId) !== SYSTEM_TYPES.INSTALL) return; - const claimed = claimedFamilies(content); - if (claimed.size === 0) return; - for (const other of await this.loadInstalls()) { - if (other.id === excludeId) continue; - for (const family of claimedFamilies(other.content)) { - if (claimed.has(family)) { - throw new StackConflictError( - `Type family "${family}" is claimed by the install for "${other.content.appId}"`, - ); - } - } - } - } - /** The `_app` card claiming `did`, deleted and unlisted included. */ private findAppCard(did: EntityId): Promise { return findFirstMatch( diff --git a/packages/core/tests/install.test.ts b/packages/core/tests/install.test.ts index 50b79eaf..2bfe941a 100644 --- a/packages/core/tests/install.test.ts +++ b/packages/core/tests/install.test.ts @@ -17,7 +17,8 @@ const PERSON = 'did:key:person'; const NOTE_1 = 'com.example.notes/note@1'; const NOTE_2 = 'com.example.notes/note@2'; -const TAG_1 = 'com.example.notes/tag@1'; +const TAG_1 = 'com.example.tags/tag@1'; +const COMMONS_NOTE = 'org.haverstack/note@1'; const manifest = (overrides: Partial = {}): AppManifest => ({ appId: 'com.example.notes', @@ -162,7 +163,26 @@ describe('installApp()', () => { ).rejects.toThrow(StackValidationError); }); - test('requests outside the families the manifest defines name their owner', async () => { + test('requests outside the app’s own namespace name the family’s owner', async () => { + const plan = await stack.planInstall( + manifest({ + requests: [ + { baseId: 'com.example.notes/note', actions: ['create'] }, + { baseId: 'com.example.tags/tag', actions: ['read-any'] }, + { baseId: 'org.haverstack/note', actions: ['read-any'] }, + { baseId: '_entity', actions: ['read-any'] }, + ], + }), + { did: APP_DID }, + ); + expect(plan.foreignRequests).toEqual([ + { baseId: 'com.example.tags/tag', actions: ['read-any'], owner: 'com.example.tags' }, + { baseId: 'org.haverstack/note', actions: ['read-any'], owner: 'commons' }, + { baseId: '_entity', actions: ['read-any'], owner: 'system' }, + ]); + }); + + test('another app’s family can be used through a request, never defined', async () => { await install( manifest({ appId: 'com.example.tags', @@ -172,38 +192,50 @@ describe('installApp()', () => { }), OTHER_DID, ); - const plan = await stack.planInstall( + await expect( + stack.planInstall(manifest({ types: [{ id: TAG_1, name: 'Tag', schema: {} }] }), { + did: APP_DID, + }), + ).rejects.toThrow(StackValidationError); + + const record = await install( + manifest({ requests: [{ baseId: 'com.example.tags/tag', actions: ['read-any'] }] }), + ); + expect(await linkedGrants(record)).toEqual([ + { + baseId: 'com.example.tags/tag', + actions: ['read-any'], + grantee: { kind: 'entity', entityId: APP_DID }, + }, + ]); + }); + + test('commons types are defined by an install but never claimed', async () => { + const record = await install( manifest({ - requests: [ - { baseId: 'com.example.notes/note', actions: ['create'] }, - { baseId: 'com.example.notes/tag', actions: ['read-any'] }, - { baseId: '_entity', actions: ['read-any'] }, - { baseId: 'org.example/bookmark', actions: ['read-any'] }, + types: [ + ...manifest().types, + { id: COMMONS_NOTE, name: 'Note', schema: { body: { kind: 'text' } } }, ], }), - { did: APP_DID }, ); - expect(plan.foreignRequests).toEqual([ - { baseId: 'com.example.notes/tag', actions: ['read-any'], owner: 'com.example.tags' }, - { baseId: '_entity', actions: ['read-any'], owner: 'system' }, - { baseId: 'org.example/bookmark', actions: ['read-any'], owner: null }, - ]); + expect(await stack.getType(COMMONS_NOTE)).not.toBeNull(); + expect(record.content.defines).toEqual([NOTE_1]); }); }); describe('the _install record', () => { - test('a family is claimed by one install only', async () => { - await install(manifest()); - const rival = manifest({ appId: 'com.example.rival', name: 'Rival' }); - await expect(stack.planInstall(rival, { did: OTHER_DID })).rejects.toThrow(StackConflictError); - await expect( - stack.create('_install@1', { - appId: 'com.example.rival', - name: 'Rival', - defines: [NOTE_2], - requests: [], - }), - ).rejects.toThrow(StackConflictError); + test('defines names only families in the install’s own namespace', async () => { + for (const id of [NOTE_2, COMMONS_NOTE, '_grant@1']) { + await expect( + stack.create('_install@1', { + appId: 'com.example.rival', + name: 'Rival', + defines: [id], + requests: [], + }), + ).rejects.toThrow(StackValidationError); + } }); test('one install answers for each appId, and appId is immutable', async () => { @@ -221,17 +253,6 @@ describe('the _install record', () => { ); }); - test('defines cannot name a system family', async () => { - await expect( - stack.create('_install@1', { - appId: 'com.example.x', - name: 'X', - defines: ['_grant@1'], - requests: [], - }), - ).rejects.toThrow(StackValidationError); - }); - test('cannot be granted, and only the owner acting alone writes one', async () => { await expect( stack.grantType('_install', { @@ -318,6 +339,22 @@ describe('commitMigration() for an installed app', () => { ).rejects.toThrow(StackPermissionError); }); + test('a commons family is never the app’s to migrate', async () => { + await install( + manifest({ + types: [ + { id: COMMONS_NOTE, name: 'Note', schema: { body: { kind: 'text' } } }, + { id: 'org.haverstack/note@2', name: 'Note', schema: { body: { kind: 'text' } } }, + ], + requests: [{ baseId: 'org.haverstack/note', actions: ['read-any', 'update-any'] }], + }), + ); + const note = await stack.create(COMMONS_NOTE, { body: 'hi' }); + await expect( + stack.asEntity(APP_DID).commitMigration(note.id, 'org.haverstack/note@2', { body: 'hi' }), + ).rejects.toThrow(StackPermissionError); + }); + test('the app acting for someone else, or a key not linked to the install, is refused', async () => { const note = await installForMigration(); await expect( From 6782ac9a5c4e9211d979011f6a5eb46068768760 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 21:07:03 +0000 Subject: [PATCH 03/11] feat: POST /installs, so an app installs itself the same way on any server Without a pinned endpoint every server would invent its own install request and every app would special-case every server. The app's half of an install is now on the wire; the owner's approval stays the server's. POST /installs takes { manifest } from a key acting as itself. The key is the session's, never the body's, so no app can ask on behalf of a key it does not hold. It answers 202 pending while applying the manifest would change something (the server queues it and writes nothing to the stack) and 200 with the _install record once the plan is empty. installApp() gives each linked key read on its own install so the app can see what was approved. Discovery advertises the endpoint, and APIAdapter.requestInstall() refuses locally when it is absent. Refs #359 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LeWBt7FirCqK6pCrRwaVCE --- .changeset/app-install-requests.md | 8 + docs/spec/apps.md | 18 +- docs/spec/wire-format.md | 25 ++- packages/adapter-api/src/index.ts | 48 +++++- .../adapter-api/tests/conformance.test.ts | 55 ++++++- packages/conformance-fixtures/src/index.ts | 154 ++++++++++++++++++ packages/core/src/index.ts | 1 + packages/core/src/install.ts | 18 ++ packages/core/src/stack.ts | 26 ++- packages/core/src/wire-body.ts | 68 +++++++- packages/core/src/wire-entry.ts | 1 + packages/core/tests/install.test.ts | 24 +++ packages/core/tests/wire-body.test.ts | 37 +++++ packages/wire-types/src/index.ts | 33 ++++ 14 files changed, 506 insertions(+), 10 deletions(-) create mode 100644 .changeset/app-install-requests.md diff --git a/.changeset/app-install-requests.md b/.changeset/app-install-requests.md new file mode 100644 index 00000000..6e30f99b --- /dev/null +++ b/.changeset/app-install-requests.md @@ -0,0 +1,8 @@ +--- +'@haverstack/core': minor +'@haverstack/wire-types': minor +'@haverstack/conformance-fixtures': minor +'@haverstack/adapter-api': minor +--- + +`POST /installs` lets an app present its manifest for the owner to approve, as the key its session authenticated with: `202 { status: 'pending' }` until approved, `200 { status: 'installed', install }` once applying it would change nothing. Discovery advertises it with `installs: { requests: true }`. `APIAdapter.requestInstall()` sends it, `parseInstallBody()` and `isPlanEmpty()` serve it, and `installApp()` gives each linked key read on its own `_install` record. A manifest may define only types in its own namespace (the family's namespace is the `appId`) and commons types, which it defines without claiming. diff --git a/docs/spec/apps.md b/docs/spec/apps.md index 3c6abe05..8f05cc23 100644 --- a/docs/spec/apps.md +++ b/docs/spec/apps.md @@ -22,7 +22,7 @@ await stack.installApp(plan); Migration functions are not part of a manifest. They are app code, registered at every startup with `registerMigration()` (see [Data model § Type migrations](./data-model.md#type-migrations)), and the app runs them itself — see [Migrating an installed app's types](#migrating-an-installed-apps-types). -`planInstall()`, `installApp()` and `uninstallApp()` live on `Stack` and are absent from `StackClient`, like `grantType()` and `defineType()`: everything they write is the owner's to write. There is no wire endpoint for them; a server offering an install flow builds its approval step on these calls. +`planInstall()`, `installApp()` and `uninstallApp()` live on `Stack` and are absent from `StackClient`, like `grantType()` and `defineType()`: everything they write is the owner's to write. An app reaches them only through the request it can make over the wire — see [Over the wire](#over-the-wire). ## The `_install` record @@ -118,3 +118,19 @@ which migrates live, listed records and counts the soft-deleted ones it passed o `uninstallApp(appId)` revokes every grant linked to the install and soft-deletes it. The app's records stay: they are the owner's data, and purging them is a separate, deliberate act. Its `_app` cards stay too, since they are what its records' attribution resolves through (see [Identity § Attribution and what can be trusted](./identity.md#attribution-and-what-can-be-trusted)). A deleted install confers no migration authority. Installing the same `appId` again undeletes the install and grants its requests afresh. + +## Over the wire + +Install has two halves, and only one of them is the same on every server. **The app's half** — present a manifest, learn whether it was approved — is pinned by [`POST /installs`](./wire-format.md#installs), so an app installs itself the same way on any stack. **The owner's half** — review the plan, approve it — is a person deciding, through whatever the server offers (an admin page, a notification); it is not on the wire, and underneath it is `planInstall()` then `installApp()`. + +```ts +const result = await adapter.requestInstall(manifest); +// { status: 'pending' } until the owner approves this manifest for this key, +// then { status: 'installed', install } once applying it would change nothing +``` + +**The key is the session's.** The request names no DID: the handshake already proved which key is asking, so no app can ask for an install on behalf of a key it does not hold. + +**A request is not an approval.** A pending request lives with the server, not in the stack, so a key that merely authenticated writes nothing into the owner's data. The stack holds only what the owner approved. + +**An app reads its own install.** `installApp()` gives every linked key record-level `read` on the install, so the app sees which versions and grants were approved with an ordinary read — by id, or `query({ filter: { baseId: '_install' } })`, which returns only installs it can read. Upgrading is the same request with the new manifest: `pending` until the owner approves, with the app reading at the older version through `presentAt: 'latest'` meanwhile. diff --git a/docs/spec/wire-format.md b/docs/spec/wire-format.md index 48dcf06e..7b8e1bc3 100644 --- a/docs/spec/wire-format.md +++ b/docs/spec/wire-format.md @@ -25,7 +25,7 @@ GET /.well-known/stack } ``` -`auth` is optional and describes how a token can be earned here — see [Authentication § Advertising it](#advertising-it). `changes` is optional and describes the [change feed](./change-feed.md); its absence means the server offers none. +`auth` is optional and describes how a token can be earned here — see [Authentication § Advertising it](#advertising-it). `changes` is optional and describes the [change feed](./change-feed.md); its absence means the server offers none. `installs` is optional, and `{ "requests": true }` says the server takes [install requests](#installs); `@haverstack/wire-types` exports `supportsInstallRequests()` to read it. ### Version negotiation @@ -697,6 +697,29 @@ Owner only. Returns `409 Conflict` if any record in the stack still references t Attachment permissions are governed by the Record(s) that reference them, not the attachment itself. If any Record referencing a `fileId` is accessible to the requester, the attachment is accessible. A non-owner requester can also access a file if they own an `_attachment@1` record for it, enabling access in the window between upload and record association. +## Installs + +``` +POST /installs — present an app's manifest for the owner to approve +``` + +How an app holding its own key asks to be installed (see [App installs § Over the wire](./apps.md#over-the-wire)). Only the asking is specified here; the owner approves through whatever the server offers, with `Stack.planInstall()` and `installApp()`. + +**The body is `{ "manifest": { … } }`**, an `AppManifest`: `appId`, `name`, optional `version`, `types` (each read as a [`POST /types`](#types) body is) and `requests` (each `{ baseId, actions }`). `parseInstallBody()` from `@haverstack/core/wire` reads it, refusing an unknown key at either level with **400** like any other [unrecognized input](#unrecognized-input). **The key being installed is never in the body**: it is the session's principal. + +**The request must come from the key acting as itself.** A delegated session names someone else as the subject, and an install is for the key that authenticated, so it answers **403** (code `permission`). + +The server plans the manifest for the session's key and answers with the result: + +| Status | Body | When | +| ------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| `202` | `{ "status": "pending" }` | Applying the manifest would change something. The server queues it for the owner and writes nothing to the stack. | +| `200` | `{ "status": "installed", "install": … }` | The plan is empty — `isPlanEmpty()` from `@haverstack/core` decides — and `install` is the `_install` record, which the key can read. | +| `422` | `validation` | The manifest defines a type outside the app's [own namespace](./apps.md#who-owns-a-family) and the commons, or a request breaks the grant rules. | +| `409` | `conflict` | The key's `_app` card names a different `appId`. | + +Re-sending the same manifest is how an app checks: it answers `202` until the owner approves and `200` after. A manifest that changes anything an approved one said — a new version, a changed request — is `202` again, so an upgrade takes the same path as a first install. A client refuses `requestInstall()` locally when discovery does not advertise `installs`, rather than learning it as a `404`. + ## Entity ``` diff --git a/packages/adapter-api/src/index.ts b/packages/adapter-api/src/index.ts index cbafdee8..80dcf7af 100644 --- a/packages/adapter-api/src/index.ts +++ b/packages/adapter-api/src/index.ts @@ -50,6 +50,8 @@ import type { RecordChangeSet, StackCapabilities, MissingCapability, + AppManifest, + InstallContent, } from '@haverstack/core'; import type { StackAdapter, SubscribeChangesOptions } from '@haverstack/core/adapter'; import { @@ -82,6 +84,7 @@ import type { AuthChallengeResponse, AuthTokenResponse, WireAuthErrorCode, + WireInstallResponse, } from '@haverstack/wire-types'; import { isWireError, @@ -92,6 +95,7 @@ import { isRetryableAuthError, isValidCursor, supportsChangeFeed, + supportsInstallRequests, WIRE_ERROR_STATUS, supportsDidChallenge, CHANGE_FRAME_READY, @@ -113,6 +117,11 @@ import { */ export type { MissingCapability } from '@haverstack/core'; +/** What `APIAdapter.requestInstall()` resolves to. */ +export type InstallRequestResult = + | { status: 'pending' } + | { status: 'installed'; install: StackRecord & { content: InstallContent } }; + export type APIAdapterOpenOptions = { /** Base URL of the stack server e.g. "https://example.com". Trailing slash is stripped. */ url: string; @@ -179,10 +188,10 @@ export class APIAdapterConnectionError extends APIAdapterError { export class APIAdapterCapabilityError extends APIAdapterError { constructor( /** - * `'changes'` names the feed, which discovery advertises beside - * `capabilities` rather than in it. + * `'changes'` names the feed and `'installs'` the install endpoint, + * which discovery advertises beside `capabilities` rather than in it. */ - public readonly capability: MissingCapability | 'changes', + public readonly capability: MissingCapability | 'changes' | 'installs', message: string, ) { super(message); @@ -809,6 +818,8 @@ export class APIAdapter implements StackAdapter { capabilities: StackCapabilities, /** The feed discovery advertised, if any. Absent means the server offers none. */ private readonly changeFeed: DiscoveryChanges | undefined, + /** Whether discovery advertised `POST /installs`. */ + private readonly installRequests: boolean, ) { this.capabilities = capabilities; this.ownerEntityId = ownerEntityId; @@ -901,6 +912,7 @@ export class APIAdapter implements StackAdapter { // are what subscribeChanges() promises its caller, and a client that // forgot them would assume both. supportsChangeFeed(discovery) ? discovery.changes : undefined, + supportsInstallRequests(discovery), ); } @@ -1064,6 +1076,36 @@ export class APIAdapter implements StackAdapter { return requireRecordBody(raw, `PATCH /records/${id}`); } + /** + * Present this app's manifest for the owner to approve, as the key this + * adapter authenticated with. `pending` until the owner approves this + * manifest for this key; `installed`, with the `_install` record, once + * applying it would change nothing. Refused locally when the server does + * not advertise install requests. See docs/spec/wire-format.md § Installs. + */ + async requestInstall(manifest: AppManifest): Promise { + if (!this.installRequests) { + throw new APIAdapterCapabilityError( + 'installs', + `Server at "${this.baseUrl}" does not take install requests; ask its owner to install ` + + 'the app another way.', + ); + } + const raw = await this.request('POST', '/installs', { + manifest, + }); + if (raw?.status === 'pending') return { status: 'pending' }; + if (raw?.status === 'installed' && raw.install) { + return { + status: 'installed', + install: parseRecord(raw.install) as StackRecord & { content: InstallContent }, + }; + } + throw new APIAdapterError( + 'POST /installs answered with neither a pending nor an installed body', + ); + } + async commitMigration( id: RecordId, toTypeId: TypeId, diff --git a/packages/adapter-api/tests/conformance.test.ts b/packages/adapter-api/tests/conformance.test.ts index 78e6735f..d456a9fa 100644 --- a/packages/adapter-api/tests/conformance.test.ts +++ b/packages/adapter-api/tests/conformance.test.ts @@ -7,7 +7,12 @@ * the documented response. See that package for the fixture data itself. */ import { describe, test, expect } from 'vitest'; -import { APIAdapter, APIAdapterAuthError, APIAdapterHandshakeError } from '../src/index.js'; +import { + APIAdapter, + APIAdapterAuthError, + APIAdapterCapabilityError, + APIAdapterHandshakeError, +} from '../src/index.js'; import { BASE_URL, DISCOVERY, @@ -33,6 +38,7 @@ import { restoreVersionFixtures, getJournalFixtures, commitMigrationFixtures, + installRequestFixtures, discoveryFixtures, errorResponseFixtures, attachmentUploadFixtures, @@ -715,6 +721,53 @@ describe('commitMigration fixtures', () => { } }); +// ------------------------------------------------------- +// Install requests — the manifest travels as given, and the key never +// travels at all: it is the session's. +// ------------------------------------------------------- + +describe('install request fixtures', () => { + const openWithInstalls = (): Promise => + openDiscovered({ ...DISCOVERY, installs: { requests: true } }, { token: undefined }); + + for (const fixture of installRequestFixtures) { + test(fixture.name, async () => { + const adapter = await openWithInstalls(); + mockFetch.mockResolvedValueOnce(jsonResponse(fixture.responseBody, fixture.responseStatus)); + + const attempt = adapter.requestInstall(fixture.requestBody!.manifest); + const body = fixture.responseBody!; + if ('error' in body) { + await expect(attempt).rejects.toBeInstanceOf( + ERROR_CLASS_FOR_CODE[body.error.code as never], + ); + } else if (body.status === 'installed') { + const result = await attempt; + expect(result.status).toBe('installed'); + expect(result.status === 'installed' && result.install.content).toEqual( + body.install.content, + ); + } else { + expect(await attempt).toEqual({ status: 'pending' }); + } + + const [url, init] = mockFetch.mock.lastCall as [string, RequestInit]; + expect(url).toBe(`${BASE_URL}${fixture.path}`); + expect(init.method).toBe(fixture.method); + expect(JSON.parse(init.body as string)).toEqual(fixture.requestBody); + }); + } + + test('a server advertising no install requests is refused locally', async () => { + const adapter = await openAdapter(); + const calls = mockFetch.mock.calls.length; + await expect( + adapter.requestInstall(installRequestFixtures[0]!.requestBody!.manifest), + ).rejects.toBeInstanceOf(APIAdapterCapabilityError); + expect(mockFetch.mock.calls.length).toBe(calls); + }); +}); + // ------------------------------------------------------- // Error responses — pins that APIAdapter reconstructs the documented // core error class from each fixture's wire error body. diff --git a/packages/conformance-fixtures/src/index.ts b/packages/conformance-fixtures/src/index.ts index 84342724..c93d0d1f 100644 --- a/packages/conformance-fixtures/src/index.ts +++ b/packages/conformance-fixtures/src/index.ts @@ -39,6 +39,8 @@ import type { AuthTokenRequest, AuthTokenResponse, WireAuthError, + WireInstallRequest, + WireInstallResponse, } from '@haverstack/wire-types'; import { WIRE_PROTOCOL_VERSION } from '@haverstack/wire-types'; @@ -122,6 +124,26 @@ export const discoveryFixtures: ConformanceFixture }, }, }, + { + name: 'discovery-advertises-install-requests', + description: + 'A server that takes install requests says so with installs.requests: true. Absent ' + + 'means it does not, and a client refuses requestInstall() locally rather than learning ' + + 'it as a 404 — see docs/spec/wire-format.md § Installs.', + method: 'GET', + path: '/.well-known/stack', + responseStatus: 200, + responseBody: { + version: WIRE_PROTOCOL_VERSION, + entityId: 'did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK', + capabilities: { + filter: { content: 'none', contentPresent: false, search: false }, + sort: { fields: ['createdAt', 'updatedAt', 'version'], contentField: false }, + limits: { attachmentBytes: null, contentBytes: null }, + }, + installs: { requests: true }, + }, + }, { name: 'discovery-omits-absent-timezone', description: @@ -2137,6 +2159,137 @@ export const commitMigrationFixtures: ConformanceFixture< }, ]; +// ------------------------------------------------------- +// Install requests +// ------------------------------------------------------- +// +// POST /installs is how an app presents its manifest to a stack it holds +// its own key for (docs/spec/wire-format.md § Installs). The owner +// approves out of band; these pin only what the app sees. The key being +// installed is always the session's — the body never names one. + +const INSTALL_MANIFEST: WireInstallRequest['manifest'] = { + appId: 'com.example.notes', + name: 'Notes', + version: '1.0.0', + types: [{ id: 'com.example.notes/note@1', name: 'Note', schema: { text: { kind: 'text' } } }], + requests: [{ baseId: 'com.example.notes/note', actions: ['create', 'read-any'] }], +}; + +export const installRequestFixtures: ConformanceFixture< + WireInstallRequest, + WireInstallResponse | WireError +>[] = [ + { + name: 'install-request-pending', + description: + 'POST /installs with a manifest the owner has not approved for this key answers 202 with ' + + '{ status: "pending" }. The server queues it for the owner and writes nothing to the ' + + 'stack: a request is not an approval. Sending the same manifest again answers the same ' + + 'way until the owner acts, so re-sending is how an app checks. An upgrade — a manifest ' + + 'adding a version or changing a request — is pending again in the same way.', + method: 'POST', + path: '/installs', + requestBody: { manifest: INSTALL_MANIFEST }, + responseStatus: 202, + responseBody: { status: 'pending' }, + }, + { + name: 'install-request-already-installed', + description: + 'POST /installs with a manifest whose plan for this key is empty — the install is live, ' + + 'the key is linked, and nothing would be added or removed — answers 200 with the ' + + '_install record. Each linked key holds read on its own install, so the app can fetch ' + + 'it again by id, or find it with a query on the _install family. Assumes the owner ' + + "approved exactly this manifest for the session's key.", + method: 'POST', + path: '/installs', + requestBody: { manifest: INSTALL_MANIFEST }, + responseStatus: 200, + responseBody: { + status: 'installed', + install: { + id: '1hk153x00009', + typeId: '_install@1', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + content: { + appId: 'com.example.notes', + name: 'Notes', + version: '1.0.0', + defines: ['com.example.notes/note@1'], + requests: [{ baseId: 'com.example.notes/note', actions: ['create', 'read-any'] }], + }, + version: 1, + }, + }, + }, + { + name: 'install-request-type-outside-namespace', + description: + "POST /installs whose manifest defines a type outside the app's own namespace answers " + + '422 with code "validation", naming the type. A family belongs to the app whose appId ' + + "is its namespace; an app that wants to use another app's family lists it in requests " + + 'instead. See docs/spec/apps.md § Who owns a family.', + method: 'POST', + path: '/installs', + requestBody: { + manifest: { + ...INSTALL_MANIFEST, + types: [{ id: 'com.example.tags/tag@1', name: 'Tag', schema: {} }], + }, + }, + responseStatus: 422, + responseBody: { + error: { + code: 'validation', + message: 'Content validation failed', + details: [ + { + path: 'types[0].id', + message: + '"com.example.tags/tag" is outside the namespace "com.example.notes"; request ' + + 'access to it instead of defining it', + }, + ], + }, + }, + }, + { + name: 'install-request-key-registered-to-another-app', + description: + 'POST /installs from a key whose _app card names a different appId answers 409 with ' + + 'code "conflict". One key speaks for one app; a card\'s appId is immutable once set. ' + + 'Assumes the session\'s DID is registered to "com.example.other".', + method: 'POST', + path: '/installs', + requestBody: { manifest: INSTALL_MANIFEST }, + responseStatus: 409, + responseBody: { + error: { + code: 'conflict', + message: + 'did:key:z6Mkfsz9oK6i2355mvEwtDYdAmqCN6kmQETThJtARfj9iGum is registered to ' + + '"com.example.other", not "com.example.notes"', + }, + }, + }, + { + name: 'install-request-delegated-session', + description: + 'POST /installs from a delegated session — a principal acting for a subject — answers ' + + '403 with code "permission". An install is for the key that authenticated, acting as ' + + 'itself; a delegated token names someone else as the subject.', + method: 'POST', + path: '/installs', + requestBody: { manifest: INSTALL_MANIFEST }, + responseStatus: 403, + responseBody: { + error: { code: 'permission', message: 'An install request must come from the key itself' }, + }, + }, +]; + // ------------------------------------------------------- // Error responses // ------------------------------------------------------- @@ -5190,5 +5343,6 @@ export const allConformanceFixtures: ConformanceFixture[] = [ ...restoreVersionFixtures, ...getJournalFixtures, ...commitMigrationFixtures, + ...installRequestFixtures, ...errorResponseFixtures, ]; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a5248ce6..9cc43b81 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -36,6 +36,7 @@ export type { MigrateAllOptions, } from './stack.js'; export type { AppManifest, InstallPlan, ForeignRequest } from './install.js'; +export { isPlanEmpty } from './install.js'; // Type handles export { typeHandle } from './type-handle.js'; diff --git a/packages/core/src/install.ts b/packages/core/src/install.ts index 701b0a9e..e2cf82a6 100644 --- a/packages/core/src/install.ts +++ b/packages/core/src/install.ts @@ -229,6 +229,24 @@ export function grantIsRequest( return sameRequest({ baseId: grant.baseId, actions: grant.actions }, request); } +/** + * Whether applying `plan` would change nothing: the install is live, this + * key is linked to it, and the manifest adds and removes nothing. A server + * answers such a request as already installed rather than queuing it. + * See docs/spec/wire-format.md § Installs. + */ +export function isPlanEmpty(plan: InstallPlan): boolean { + return ( + plan.existing !== null && + !plan.existing.deletedAt && + !plan.newKey && + plan.newFamilies.length === 0 && + plan.newVersions.length === 0 && + plan.requestsAdded.length === 0 && + plan.requestsRemoved.length === 0 + ); +} + /** * The parts of a plan that depend on the stack's state — what * `installApp()` compares to refuse a stale one. diff --git a/packages/core/src/stack.ts b/packages/core/src/stack.ts index d553060c..4265e48c 100644 --- a/packages/core/src/stack.ts +++ b/packages/core/src/stack.ts @@ -3088,7 +3088,31 @@ export class Stack implements StackClient { edits.push({ op: 'add', association: installGrantLink(grant.id) }); } } - return edits.length > 0 ? this.amendAssociations(install.id, edits) : install; + let result = edits.length > 0 ? await this.amendAssociations(install.id, edits) : install; + + // Each key may read its own install, which is how an app learns what + // was approved. See docs/spec/apps.md § Over the wire. + const readers = dids.filter( + (did) => + !(result.permissions ?? []).some( + (p) => + p.kind === 'permission' && + p.label === 'read' && + p.grantee.kind === 'entity' && + p.grantee.entityId === did, + ), + ); + if (readers.length > 0) { + result = await this.grantAccess( + install.id, + readers.map((entityId) => ({ + kind: 'permission' as const, + label: 'read' as const, + grantee: { kind: 'entity' as const, entityId }, + })), + ); + } + return result; } // ------------------------------------------------------- diff --git a/packages/core/src/wire-body.ts b/packages/core/src/wire-body.ts index 48c21226..f3ba89c2 100644 --- a/packages/core/src/wire-body.ts +++ b/packages/core/src/wire-body.ts @@ -2,8 +2,8 @@ * Stack — Wire Request Bodies * ------------------------------------------------------- * The JSON bodies of the endpoints core specifies but runs no server for: - * the auth handshake, `PATCH /entity`, `POST /types` and - * `POST /records/:id/migrate`. Each parser names every key its endpoint + * the auth handshake, `PATCH /entity`, `POST /types`, + * `POST /records/:id/migrate` and `POST /installs`. Each parser names every key its endpoint * defines, so a server refuses the rest without keeping its own copy of * the list — a copy that drifts the first time core adds a field. * @@ -20,7 +20,8 @@ import { StackBadRequestError, StackValidationError } from './errors.js'; import type { DefineTypeOptions } from './stack.js'; import { assertKnownKeys, validateAssociation } from './query-validation.js'; -import type { AssociationEdit, StackType, TypeId, TypeSchema } from './types.js'; +import type { AppManifest } from './install.js'; +import type { AssociationEdit, GrantAction, StackType, TypeId, TypeSchema } from './types.js'; export function requireBody(body: unknown, label: string): Record { if (typeof body !== 'object' || body === null || Array.isArray(body)) @@ -219,3 +220,64 @@ export function parseMigrationBody(body: unknown): WireMigrationRequest { content: requiredObject(b, 'content', label), }; } + +// ------------------------------------------------------- +// POST /installs +// ------------------------------------------------------- + +/** + * Parse a `POST /installs` body, `{ manifest }`, into the manifest + * `planInstall()` takes. Each type is read as a `POST /types` body is. + * Which families a manifest may define, and which requests the grant rules + * allow, are `planInstall()`'s to judge. See docs/spec/wire-format.md § Installs. + */ +export function parseInstallBody(body: unknown): AppManifest { + const b = requireKnownBody(body, ['manifest'], 'install body'); + const m = requireKnownBody( + requiredObject(b, 'manifest', 'install body'), + ['appId', 'name', 'version', 'types', 'requests'], + 'manifest', + ); + const manifest: AppManifest = { + appId: nestedString(m, 'manifest', 'appId'), + name: nestedString(m, 'manifest', 'name'), + types: nestedArray(m, 'manifest', 'types').map((t, i) => { + if (typeof t !== 'object' || t === null || Array.isArray(t)) + fieldError(`manifest.types[${i}]`, 'a type must be an object'); + return parseTypeBody(t); + }), + requests: nestedArray(m, 'manifest', 'requests').map((r, i) => { + const path = `manifest.requests[${i}]`; + if (typeof r !== 'object' || r === null || Array.isArray(r)) + fieldError(path, 'a request must be an object'); + const req = requireKnownBody(r, ['baseId', 'actions'], path); + const actions = nestedArray(req, path, 'actions'); + actions.forEach((a, j) => { + if (typeof a !== 'string') + fieldError(`${path}.actions[${j}]`, 'an action must be a string'); + }); + return { baseId: nestedString(req, path, 'baseId'), actions: actions as GrantAction[] }; + }), + }; + if (m.version !== undefined) { + if (typeof m.version !== 'string') fieldError('manifest.version', 'version must be a string'); + manifest.version = m.version; + } + return manifest; +} + +/** A required string inside a nested object, its 422 naming the full path. */ +function nestedString(obj: Record, at: string, key: string): string { + const value = obj[key]; + if (value === undefined) throw new StackBadRequestError(`Invalid ${at}: ${key} is required`); + if (typeof value !== 'string') fieldError(`${at}.${key}`, `${key} must be a string`); + return value; +} + +/** A required array inside a nested object, its 422 naming the full path. */ +function nestedArray(obj: Record, at: string, key: string): unknown[] { + const value = obj[key]; + if (value === undefined) throw new StackBadRequestError(`Invalid ${at}: ${key} is required`); + if (!Array.isArray(value)) fieldError(`${at}.${key}`, `${key} must be an array`); + return value; +} diff --git a/packages/core/src/wire-entry.ts b/packages/core/src/wire-entry.ts index 0bce50f0..ead53959 100644 --- a/packages/core/src/wire-entry.ts +++ b/packages/core/src/wire-entry.ts @@ -102,6 +102,7 @@ export { parseEntityPatchBody, parseTypeBody, parseMigrationBody, + parseInstallBody, } from './wire-body.js'; export type { WireAuthChallengeRequest, diff --git a/packages/core/tests/install.test.ts b/packages/core/tests/install.test.ts index 2bfe941a..feae4daf 100644 --- a/packages/core/tests/install.test.ts +++ b/packages/core/tests/install.test.ts @@ -7,6 +7,7 @@ import { StackValidationError, } from '../src/errors.js'; import { MemoryAdapter } from '../src/testing.js'; +import { isPlanEmpty } from '../src/install.js'; import type { AppManifest } from '../src/install.js'; import type { AppContent, GrantContent, InstallContent, StackRecord } from '../src/types.js'; @@ -224,6 +225,29 @@ describe('installApp()', () => { }); }); +describe('what an installed app sees', () => { + test('each linked key can read its own install, and no one else can', async () => { + const record = await install(manifest()); + expect((await stack.asEntity(APP_DID).get(record.id))?.content).toEqual(record.content); + expect( + (await stack.asEntity(APP_DID).query({ filter: { baseId: '_install' } })).records, + ).toHaveLength(1); + expect(await stack.asEntity(PERSON).get(record.id)).toBeNull(); + }); + + test('a plan is empty only once its key is installed and nothing would change', async () => { + expect(isPlanEmpty(await stack.planInstall(manifest(), { did: APP_DID }))).toBe(false); + await install(manifest()); + expect(isPlanEmpty(await stack.planInstall(manifest(), { did: APP_DID }))).toBe(true); + expect(isPlanEmpty(await stack.planInstall(manifest(), { did: OTHER_DID }))).toBe(false); + expect(isPlanEmpty(await stack.planInstall(manifest({ requests: [] }), { did: APP_DID }))).toBe( + false, + ); + await stack.uninstallApp('com.example.notes'); + expect(isPlanEmpty(await stack.planInstall(manifest(), { did: APP_DID }))).toBe(false); + }); +}); + describe('the _install record', () => { test('defines names only families in the install’s own namespace', async () => { for (const id of [NOTE_2, COMMONS_NOTE, '_grant@1']) { diff --git a/packages/core/tests/wire-body.test.ts b/packages/core/tests/wire-body.test.ts index 990d2b0d..acee25b4 100644 --- a/packages/core/tests/wire-body.test.ts +++ b/packages/core/tests/wire-body.test.ts @@ -6,6 +6,7 @@ import { parseEntityPatchBody, parseTypeBody, parseMigrationBody, + parseInstallBody, } from '../src/wire-entry.js'; import { Stack } from '../src/stack.js'; import { StackBadRequestError, StackValidationError } from '../src/errors.js'; @@ -207,3 +208,39 @@ describe('parseAssociationEditsBody', () => { ).toBe('changes[0].association.label'); }); }); + +describe('parseInstallBody', () => { + const manifest = { + appId: 'com.example.notes', + name: 'Notes', + version: '1.0.0', + types: [{ id: 'com.example.notes/note@1', name: 'Note', schema: { text: { kind: 'text' } } }], + requests: [{ baseId: 'com.example.notes/note', actions: ['create', 'read-any'] }], + }; + + test('reads a manifest into what planInstall() takes', () => { + expect(parseInstallBody({ manifest })).toEqual(manifest); + }); + + test('an unknown key at either level, or a missing field, is not this request', () => { + expect(() => parseInstallBody({ manifest, did: DID })).toThrow(StackBadRequestError); + expect(() => parseInstallBody({ manifest: { ...manifest, did: DID } })).toThrow( + StackBadRequestError, + ); + const { requests: _, ...noRequests } = manifest; + expect(() => parseInstallBody({ manifest: noRequests })).toThrow(StackBadRequestError); + }); + + test('a wrongly typed field names its path', () => { + expect(pathOf(() => parseInstallBody({ manifest: { ...manifest, types: {} } }))).toBe( + 'manifest.types', + ); + expect( + pathOf(() => + parseInstallBody({ + manifest: { ...manifest, requests: [{ baseId: 'com.example.notes/note', actions: [1] }] }, + }), + ), + ).toBe('manifest.requests[0].actions[0]'); + }); +}); diff --git a/packages/wire-types/src/index.ts b/packages/wire-types/src/index.ts index ca98ca17..87590488 100644 --- a/packages/wire-types/src/index.ts +++ b/packages/wire-types/src/index.ts @@ -13,6 +13,7 @@ import { StackTimeoutError, } from '@haverstack/core'; import type { + AppManifest, NativeSortField, StackRecord, StackType, @@ -445,6 +446,7 @@ export type DiscoveryResponse = { capabilities?: DiscoveryCapabilities; auth?: DiscoveryAuth; changes?: DiscoveryChanges; + installs?: DiscoveryInstalls; }; /** @@ -784,3 +786,34 @@ export function isProtocolCompatible(version: string, against = WIRE_PROTOCOL_VE * list, applied as one write. See docs/spec/wire-format.md § Associations. */ export type WireAssociationEditsRequest = { changes: AssociationEdit[] }; + +// ------------------------------------------------------- +// Installs +// ------------------------------------------------------- + +/** + * Whether a server takes install requests at `POST /installs`. Absent means + * it does not, and a client says so locally rather than learning it as a + * 404. An object for the same reason `changes` is one. + * See docs/spec/wire-format.md § Installs. + */ +export type DiscoveryInstalls = { + requests: boolean; +}; + +/** Whether a server advertises `POST /installs`. */ +export function supportsInstallRequests(discovery: DiscoveryResponse): boolean { + return discovery.installs?.requests === true; +} + +/** POST /installs. The key being installed is the session's, never named here. */ +export type WireInstallRequest = { manifest: AppManifest }; + +/** + * POST /installs answers `pending` (202) while the owner has not approved + * this manifest for this key, and `installed` (200) once applying it would + * change nothing. + */ +export type WireInstallResponse = + | { status: 'pending' } + | { status: 'installed'; install: WireRecord }; From 1cbdc36f3aa697d3d662e516f30b0fa477519224 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 21:20:18 +0000 Subject: [PATCH 04/11] fix(core): show linked keys and type writes in an install plan; refuse an app migrating a tombstone Security review of the install flow found three gaps: - A key presenting an existing app's appId joins that install as the same app: it inherits its grants and migration rights, and its requests replace every linked key's. The plan only said `newKey`. It now lists `linkedKeys`, and the spec states the residual plainly. - Commons types in a manifest were defined by installApp() but appeared nowhere in the plan, so an app could fix a commons version's shape unseen. Name changes and schema widening on approved versions were invisible too. The plan now carries `typeChanges` for every type it would write. - An installed app could commit a migration to a soft-deleted record it cannot read. That is now refused with StackConflictError until the record is undeleted, as apps.md already described. Both new plan fields are in the stale-plan fingerprint. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qy4cJmqNvyGei7gFMz9Nx9 --- docs/spec/apps.md | 12 +++++-- packages/core/src/index.ts | 2 +- packages/core/src/install.ts | 21 +++++++++++ packages/core/src/scoped-stack.ts | 9 +++++ packages/core/src/stack.ts | 19 +++++++++- packages/core/tests/install.test.ts | 54 +++++++++++++++++++++++++++++ 6 files changed, 113 insertions(+), 4 deletions(-) diff --git a/docs/spec/apps.md b/docs/spec/apps.md index 8f05cc23..0b8e421a 100644 --- a/docs/spec/apps.md +++ b/docs/spec/apps.md @@ -59,7 +59,7 @@ Two kinds of family belong to no app. **System families** (`_entity`, `_grant`, **Using a family is a request; owning one is control of its schema.** Any app may ask for grants on any grantable family — another app's, a commons one, `_entity` — and the owner sees each such request, with the family's owner, in the plan. Only the owner of a family defines its versions and migrates its records, so two apps can never publish rival versions of the same family. An app that wants to add to records it does not own defines a family of its own and links its records to them with `relationship` associations; the shared family is untouched. -**Residual, stated rather than fixed:** `appId` is the app's own claim. On a stack where the real `com.example.notes` is not installed, another app can present a manifest under that `appId` and, if approved, own its families. The plan names the `appId` asking, so the owner is the check; closing the gap needs signed manifests. +**Residual, stated rather than fixed:** `appId` is the app's own claim. Any key can present a manifest under `com.example.notes`. Where that app is not installed, approving it gives the key the app's families. Where it is, approving it links the key to the existing install: the key is registered as that app, holds its grants and may migrate its types, and the manifest's `requests` replace those of every key already linked. The plan names the `appId` asking and the keys already linked, so the owner is the check; closing the gap needs signed manifests. ## Plan, then apply @@ -75,10 +75,16 @@ type InstallPlan = { requestsAdded: InstallRequest[]; requestsRemoved: InstallRequest[]; foreignRequests: (InstallRequest & { owner: AppId | 'commons' | 'system' | null })[]; + typeChanges: { id: TypeId; change: 'new' | 'schema' | 'name' }[]; // types installApp() would write newKey: boolean; // whether `did` is not yet linked to this install + linkedKeys: EntityId[]; // keys already linked, whose grants the plan also sets }; ``` +`typeChanges` lists every manifest type whose definition would write — not yet defined, or defined with a different schema or name — commons types included. Defining a commons type claims nothing, but the first definition of a version fixes its shape for every app that reads it, so the owner sees it like any other. + +`linkedKeys` matters most beside `newKey`: a new key on an existing install joins those keys as the same app (see [Who owns a family](#who-owns-a-family)), and `requestsAdded` and `requestsRemoved` apply to all of them. + `foreignRequests` are the requests on families outside the app's own namespace, each naming the family's [owner](#who-owns-a-family): another app's `appId`, `'commons'`, `'system'`, or `null` for a family with no namespace. They are the requests an approval most needs to show — an app asking to read another app's records, or `_entity`, is asking for reach beyond its own data. It refuses what no approval could make valid: a type outside the app's own namespace and the commons (`StackValidationError` — use a request instead), a request [`grantType()` would refuse](./access-control.md#type-level-grants) (`StackValidationError`), and a `did` whose `_app` card names a different `appId` (`StackConflictError`). @@ -90,7 +96,7 @@ It refuses what no approval could make valid: a type outside the app's own names 3. Creates the install, or patches it — undeleting it first if it was uninstalled. `defines` gains the manifest's versions and never loses any, since a Type once defined stays defined; `requests` becomes the manifest's. 4. Brings the grants of **every** key linked to the install to exactly `requests`: a grant no longer requested is revoked, a missing one is written, and the links follow. -**Nothing is applied that was not approved.** `installApp()` plans the same manifest again and refuses with `StackConflictError` when the result differs from the plan it was handed — the install changing, or the key being linked, since it was planned. The remedy is to plan again and show the owner the new plan. Re-applying a manifest whose plan is empty changes nothing. +**Nothing is applied that was not approved.** `installApp()` plans the same manifest again and refuses with `StackConflictError` when the result differs from the plan it was handed — the install changing, a type being defined, or a key being linked, since it was planned. The remedy is to plan again and show the owner the new plan. Re-applying a manifest whose plan is empty changes nothing. ## Migrating an installed app's types @@ -101,6 +107,8 @@ It refuses what no approval could make valid: a type outside the app's own names 3. The requester holds `update-any` on each family through a grant naming its DID directly. Default and group grants do not count, on the terms they do not count for [a principal](./access-control.md#who-a-grant-reaches). The manifest has to request it, so the plan shows it. 4. The requester is acting alone — not delegated — as the DID of an `_app` card linked to the install. +The Record must also be live. The owner may migrate a soft-deleted Record, but an app cannot read one, so its migration is refused with `StackConflictError` until the Record is undeleted. + The reasons migration is otherwise owner-only do not reach this case. A migration that crosses into `_attachment` or moves a DID binding needs a system family at one end, and no install can claim one; nor can it claim a commons family, which every app reads. Ordinary write access is not consent to move a Record between versions; the owner's approval of the version is. Every migration still snapshots the Record's prior content and type to [version history](./versioning.md#version-history), so it stays recoverable like any other write. Approving a new version is therefore also approving its migration. Until the owner approves it, the app reads records at the older version through [`presentAt: 'latest'`](./data-model.md#type-migrations). diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9cc43b81..d7fd3f76 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -35,7 +35,7 @@ export type { CollectAttachmentGarbageResult, MigrateAllOptions, } from './stack.js'; -export type { AppManifest, InstallPlan, ForeignRequest } from './install.js'; +export type { AppManifest, InstallPlan, ForeignRequest, TypeChange } from './install.js'; export { isPlanEmpty } from './install.js'; // Type handles diff --git a/packages/core/src/install.ts b/packages/core/src/install.ts index e2cf82a6..4aab0a61 100644 --- a/packages/core/src/install.ts +++ b/packages/core/src/install.ts @@ -57,6 +57,16 @@ export type ForeignRequest = InstallRequest & { owner: AppId | 'commons' | 'system' | null; }; +/** + * A manifest type whose definition would write: one not yet defined, or + * defined with a different schema or name. Commons types included, since + * defining one claims nothing but still fixes its shape for every app. + */ +export type TypeChange = { + id: TypeId; + change: 'new' | 'schema' | 'name'; +}; + /** * What applying a manifest would change, for the owner to approve. * `installApp()` applies a plan only while it is still what planning the @@ -76,8 +86,16 @@ export type InstallPlan = { requestsRemoved: InstallRequest[]; /** Requests on families the manifest does not define — the ones an approval most needs to show. */ foreignRequests: ForeignRequest[]; + /** Every manifest type `installApp()` would define or redefine. */ + typeChanges: TypeChange[]; /** Whether `did` is a key this install is not yet linked to. */ newKey: boolean; + /** + * The keys already linked to the install. Each holds `requests`, so a + * plan that changes them changes every one — and a new key joins them + * as the same app. See docs/spec/apps.md § Plan, then apply. + */ + linkedKeys: EntityId[]; }; /** Relationship label from an install to an `_app` card it was installed for. */ @@ -242,6 +260,7 @@ export function isPlanEmpty(plan: InstallPlan): boolean { !plan.newKey && plan.newFamilies.length === 0 && plan.newVersions.length === 0 && + plan.typeChanges.length === 0 && plan.requestsAdded.length === 0 && plan.requestsRemoved.length === 0 ); @@ -261,6 +280,8 @@ export function planFingerprint(plan: InstallPlan): string { plan.requestsAdded, plan.requestsRemoved, plan.foreignRequests, + plan.typeChanges, plan.newKey, + plan.linkedKeys, ]); } diff --git a/packages/core/src/scoped-stack.ts b/packages/core/src/scoped-stack.ts index bd6c0f92..9677d398 100644 --- a/packages/core/src/scoped-stack.ts +++ b/packages/core/src/scoped-stack.ts @@ -66,6 +66,7 @@ import type { } from './types.js'; import { StackError, + StackConflictError, StackNotFoundError, StackPermissionError, RelayScopeError, @@ -1612,6 +1613,14 @@ export class ScopedStack implements StackClient { }); if (!held) return false; } + // Asked only once authority is settled, as every mutating verb asks it. + // The owner may migrate a tombstone; an app sees it without content. + // See docs/spec/apps.md § Migrating an installed app's types. + if (record.deletedAt) { + throw new StackConflictError( + `Record "${id}" is soft-deleted; undelete it before migrating it.`, + ); + } return true; } diff --git a/packages/core/src/stack.ts b/packages/core/src/stack.ts index 4265e48c..c197bde0 100644 --- a/packages/core/src/stack.ts +++ b/packages/core/src/stack.ts @@ -140,7 +140,7 @@ import { INSTALL_APP_LABEL, INSTALL_GRANT_LABEL, } from './install.js'; -import type { AppManifest, ForeignRequest, InstallPlan } from './install.js'; +import type { AppManifest, ForeignRequest, InstallPlan, TypeChange } from './install.js'; import { assertAttachmentSize, assertContentSize } from './limits.js'; import { validateParentId, @@ -2885,6 +2885,21 @@ export class Stack implements StackClient { foreignRequests.push({ ...r, owner }); } + const typeChanges: TypeChange[] = []; + for (const t of manifest.types) { + const current = await this.getTypeCached(t.id); + if (!current) typeChanges.push({ id: t.id, change: 'new' }); + else if (current.schemaHash !== (await hashSchema(t.schema as TypeSchema))) { + typeChanges.push({ id: t.id, change: 'schema' }); + } else if (current.name !== t.name) typeChanges.push({ id: t.id, change: 'name' }); + } + + const linkedKeys: EntityId[] = []; + for (const id of existing ? linkedIds(existing, INSTALL_APP_LABEL) : []) { + const key = ((await this.get(id))?.content as AppContent | undefined)?.did; + if (typeof key === 'string') linkedKeys.push(key); + } + return { manifest, did, @@ -2894,7 +2909,9 @@ export class Stack implements StackClient { requestsAdded: manifest.requests.filter((r) => !prior.some((p) => sameRequest(p, r))), requestsRemoved: prior.filter((p) => !manifest.requests.some((r) => sameRequest(p, r))), foreignRequests, + typeChanges, newKey: !existing || !card || !linkedIds(existing, INSTALL_APP_LABEL).includes(card.id), + linkedKeys, }; } diff --git a/packages/core/tests/install.test.ts b/packages/core/tests/install.test.ts index feae4daf..533d68ff 100644 --- a/packages/core/tests/install.test.ts +++ b/packages/core/tests/install.test.ts @@ -144,6 +144,49 @@ describe('installApp()', () => { await expect(stack.installApp(plan)).rejects.toThrow(StackConflictError); }); + test('the plan lists every type it would write, commons types included', async () => { + const commons = { id: COMMONS_NOTE, name: 'Note', schema: { body: { kind: 'text' } } } as const; + const plan = await stack.planInstall(manifest({ types: [manifest().types[0]!, commons] }), { + did: APP_DID, + }); + expect(plan.typeChanges).toEqual([ + { id: NOTE_1, change: 'new' }, + { id: COMMONS_NOTE, change: 'new' }, + ]); + await stack.installApp(plan); + + const renamed = await stack.planInstall( + manifest({ types: [{ ...manifest().types[0]!, name: 'Memo' }] }), + { did: APP_DID }, + ); + expect(renamed.typeChanges).toEqual([{ id: NOTE_1, change: 'name' }]); + expect(isPlanEmpty(renamed)).toBe(false); + + const widened = await stack.planInstall( + manifest({ + types: [ + { ...manifest().types[0]!, schema: { text: { kind: 'text' }, x: { kind: 'string' } } }, + ], + }), + { did: APP_DID }, + ); + expect(widened.typeChanges).toEqual([{ id: NOTE_1, change: 'schema' }]); + }); + + test('the plan names the keys already linked, whose grants it also sets', async () => { + expect((await stack.planInstall(manifest(), { did: APP_DID })).linkedKeys).toEqual([]); + await install(manifest()); + const plan = await stack.planInstall(manifest({ requests: MIGRATING }), { did: OTHER_DID }); + expect(plan.newKey).toBe(true); + expect(plan.linkedKeys).toEqual([APP_DID]); + }); + + test('a plan made before a type was defined is refused', async () => { + const plan = await stack.planInstall(manifest(), { did: APP_DID }); + await stack.defineType(manifest().types[0]!); + await expect(stack.installApp(plan)).rejects.toThrow(StackConflictError); + }); + test('a key registered to another app is refused', async () => { await stack.create('_app@1', { appId: 'com.example.other', name: 'Other', did: APP_DID }); await expect(stack.planInstall(manifest(), { did: APP_DID })).rejects.toThrow( @@ -334,6 +377,17 @@ describe('commitMigration() for an installed app', () => { expect(migrated.updatedBy).toEqual({ subjectId: APP_DID }); }); + test('a soft-deleted record waits for an undelete', async () => { + const note = await installForMigration(); + await stack.delete(note.id); + await expect( + stack.asEntity(APP_DID).commitMigration(note.id, NOTE_2, { text: 'overwritten' }), + ).rejects.toThrow(StackConflictError); + const tombstone = (await stack.get(note.id, { includeDeleted: true }))!; + expect(tombstone.typeId).toBe(NOTE_1); + expect(tombstone.content).toEqual({ text: 'hello' }); + }); + test('update-any has to be requested', async () => { const note = await installForMigration([ { baseId: 'com.example.notes/note', actions: ['read-any', 'update-own'] }, From 9b6b7d5127cfbc1060e8634a7e5ffcfe7e278d5f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 21:27:30 +0000 Subject: [PATCH 05/11] fix(core): withdraw install read from unlinked keys; freeze the planned manifest - installApp() now removes record-level `read` on the install from any key of the app that is no longer linked (its `_app` card deleted, say), while leaving readers the owner added by hand. - planInstall() keeps a deep-frozen copy of the manifest, so the plan applies what was reviewed even if the caller's object is mutated between planning and applying. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qy4cJmqNvyGei7gFMz9Nx9 --- docs/spec/apps.md | 4 +-- packages/core/src/install.ts | 24 +++++++++++++ packages/core/src/stack.ts | 52 +++++++++++++++++------------ packages/core/tests/install.test.ts | 28 ++++++++++++++++ 4 files changed, 84 insertions(+), 24 deletions(-) diff --git a/docs/spec/apps.md b/docs/spec/apps.md index 0b8e421a..2220be21 100644 --- a/docs/spec/apps.md +++ b/docs/spec/apps.md @@ -96,7 +96,7 @@ It refuses what no approval could make valid: a type outside the app's own names 3. Creates the install, or patches it — undeleting it first if it was uninstalled. `defines` gains the manifest's versions and never loses any, since a Type once defined stays defined; `requests` becomes the manifest's. 4. Brings the grants of **every** key linked to the install to exactly `requests`: a grant no longer requested is revoked, a missing one is written, and the links follow. -**Nothing is applied that was not approved.** `installApp()` plans the same manifest again and refuses with `StackConflictError` when the result differs from the plan it was handed — the install changing, a type being defined, or a key being linked, since it was planned. The remedy is to plan again and show the owner the new plan. Re-applying a manifest whose plan is empty changes nothing. +**Nothing is applied that was not approved.** `installApp()` plans the same manifest again and refuses with `StackConflictError` when the result differs from the plan it was handed — the install changing, a type being defined, or a key being linked, since it was planned. The remedy is to plan again and show the owner the new plan. A plan holds a frozen copy of the manifest it was made from, so changing the caller's object afterwards changes nothing that is applied. Re-applying a manifest whose plan is empty changes nothing. ## Migrating an installed app's types @@ -141,4 +141,4 @@ const result = await adapter.requestInstall(manifest); **A request is not an approval.** A pending request lives with the server, not in the stack, so a key that merely authenticated writes nothing into the owner's data. The stack holds only what the owner approved. -**An app reads its own install.** `installApp()` gives every linked key record-level `read` on the install, so the app sees which versions and grants were approved with an ordinary read — by id, or `query({ filter: { baseId: '_install' } })`, which returns only installs it can read. Upgrading is the same request with the new manifest: `pending` until the owner approves, with the app reading at the older version through `presentAt: 'latest'` meanwhile. +**An app reads its own install.** `installApp()` gives every linked key record-level `read` on the install, and withdraws it from any key of the app no longer linked — one whose `_app` card was deleted, say — leaving other readers the owner added alone, so the app sees which versions and grants were approved with an ordinary read — by id, or `query({ filter: { baseId: '_install' } })`, which returns only installs it can read. Upgrading is the same request with the new manifest: `pending` until the owner approves, with the app reading at the older version through `presentAt: 'latest'` meanwhile. diff --git a/packages/core/src/install.ts b/packages/core/src/install.ts index 4aab0a61..7b25169c 100644 --- a/packages/core/src/install.ts +++ b/packages/core/src/install.ts @@ -24,6 +24,7 @@ import { GRANT_ACTION_SET, UNGRANTABLE_SYSTEM_TYPES } from './grants.js'; import { SYSTEM_TYPES } from './types.js'; import type { AppId, + AuthorityAssociation, BaseId, EntityId, GrantAction, @@ -115,6 +116,13 @@ export const installGrantLink = (recordId: RecordId): RelationshipAssociation => target: { kind: 'record', recordId }, }); +/** Record-level `read` on an install for one of its keys. */ +export const installReader = (entityId: EntityId): AuthorityAssociation => ({ + kind: 'permission', + label: 'read', + grantee: { kind: 'entity', entityId }, +}); + /** Record ids an install links to under `label`. */ export function linkedIds(record: StackRecord, label: string): RecordId[] { const ids: RecordId[] = []; @@ -229,6 +237,22 @@ export function ownTypeIds(manifest: AppManifest): TypeId[] { return [...new Set(ids)]; } +/** + * A deep-frozen copy of `manifest`, so the plan that holds it applies what + * was reviewed even if the caller's object changes afterwards. + */ +export function snapshotManifest(manifest: AppManifest): AppManifest { + return deepFreeze(structuredClone(manifest)); +} + +function deepFreeze(value: T): T { + if (typeof value === 'object' && value !== null && !Object.isFrozen(value)) { + Object.freeze(value); + for (const v of Object.values(value)) deepFreeze(v); + } + return value; +} + /** Whether two requests ask for the same family and exactly the same actions. */ export function sameRequest(a: InstallRequest, b: InstallRequest): boolean { if (a.baseId !== b.baseId || a.actions.length !== b.actions.length) return false; diff --git a/packages/core/src/stack.ts b/packages/core/src/stack.ts index c197bde0..a3743999 100644 --- a/packages/core/src/stack.ts +++ b/packages/core/src/stack.ts @@ -131,11 +131,13 @@ import { grantIsRequest, installAppLink, installGrantLink, + installReader, linkedIds, namespaceOf, ownTypeIds, planFingerprint, sameRequest, + snapshotManifest, validateInstall, INSTALL_APP_LABEL, INSTALL_GRANT_LABEL, @@ -2852,9 +2854,10 @@ export class Stack implements StackClient { * the commons, a request the grant rules refuse, or a `did` already * registered to a different app. See docs/spec/apps.md § Plan, then apply. */ - async planInstall(manifest: AppManifest, opts: { did: EntityId }): Promise { + async planInstall(submitted: AppManifest, opts: { did: EntityId }): Promise { this.assertOpen(); const { did } = opts; + const manifest = snapshotManifest(submitted); this.checkManifest(manifest, did); const installs = await this.loadInstalls(); @@ -3107,28 +3110,33 @@ export class Stack implements StackClient { } let result = edits.length > 0 ? await this.amendAssociations(install.id, edits) : install; - // Each key may read its own install, which is how an app learns what - // was approved. See docs/spec/apps.md § Over the wire. - const readers = dids.filter( - (did) => - !(result.permissions ?? []).some( - (p) => - p.kind === 'permission' && - p.label === 'read' && - p.grantee.kind === 'entity' && - p.grantee.entityId === did, - ), + // Each linked key may read its own install, which is how an app learns + // what was approved; a key of this app no longer linked may not. + // See docs/spec/apps.md § Over the wire. + const appKeys = new Set( + ( + await queryAllPages((q) => this.query(q), { + filter: { baseId: SYSTEM_TYPES.APP, includeDeleted: true, includeUnlisted: true }, + }) + ) + .map((r) => r.content as AppContent) + .filter((c) => c.appId === (install.content as InstallContent).appId) + .map((c) => c.did), ); - if (readers.length > 0) { - result = await this.grantAccess( - install.id, - readers.map((entityId) => ({ - kind: 'permission' as const, - label: 'read' as const, - grantee: { kind: 'entity' as const, entityId }, - })), - ); - } + const readerOf = (p: AuthorityAssociation): EntityId | null => + p.kind === 'permission' && p.label === 'read' && p.grantee.kind === 'entity' + ? p.grantee.entityId + : null; + const current = (result.permissions ?? []).map(readerOf); + const access: AssociationEdit[] = [ + ...dids + .filter((did) => !current.includes(did)) + .map((entityId) => ({ op: 'add' as const, association: installReader(entityId) })), + ...current + .filter((did): did is EntityId => did !== null && appKeys.has(did) && !dids.includes(did)) + .map((entityId) => ({ op: 'remove' as const, association: installReader(entityId) })), + ]; + if (access.length > 0) result = await this.amendAccess(install.id, access); return result; } diff --git a/packages/core/tests/install.test.ts b/packages/core/tests/install.test.ts index 533d68ff..0d0d23b6 100644 --- a/packages/core/tests/install.test.ts +++ b/packages/core/tests/install.test.ts @@ -278,6 +278,34 @@ describe('what an installed app sees', () => { expect(await stack.asEntity(PERSON).get(record.id)).toBeNull(); }); + test('a key no longer linked loses read on the install; other readers keep it', async () => { + await install(manifest()); + const record = await install(manifest(), OTHER_DID); + await stack.grantAccess(record.id, [ + { kind: 'permission', label: 'read', grantee: { kind: 'entity', entityId: PERSON } }, + ]); + const otherCard = (await stack.query({ filter: { baseId: '_app' } })).records.find( + (r) => (r.content as AppContent).did === OTHER_DID, + )!; + await stack.delete(otherCard.id); + + await install(manifest()); + expect(await stack.asEntity(OTHER_DID).get(record.id)).toBeNull(); + expect(await stack.asEntity(APP_DID).get(record.id)).not.toBeNull(); + expect(await stack.asEntity(PERSON).get(record.id)).not.toBeNull(); + }); + + test('a plan applies the manifest as planned, whatever happens to the caller’s object', async () => { + const m = manifest(); + const plan = await stack.planInstall(m, { did: APP_DID }); + m.requests.push({ baseId: 'com.example.notes/note', actions: ['delete-any'] }); + expect(() => { + (plan.manifest.requests as unknown[]).push({ baseId: '_entity', actions: ['read-any'] }); + }).toThrow(TypeError); + const record = await stack.installApp(plan); + expect(record.content.requests).toEqual(manifest().requests); + }); + test('a plan is empty only once its key is installed and nothing would change', async () => { expect(isPlanEmpty(await stack.planInstall(manifest(), { did: APP_DID }))).toBe(false); await install(manifest()); From 07ad8e8f754b27de62695948b2b5f98b2ef9f0fb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 21:42:53 +0000 Subject: [PATCH 06/11] feat(core): signed manifests that pin an install to its publisher An appId was the manifest's own claim: any key could present a manifest under com.example.notes, take its families where the app was not installed, or join the existing install as the same app where it was. A manifest now names a publisher DID and travels signed over manifestPayload(), canonical JSON under a versioned label. planInstall() refuses one its publisher did not sign. The first install pins the publisher as a binding on _install, so a manifest under the same appId signed by anyone else is refused whichever key presents it. did:key publishers verify from the DID; any other method needs a verifyPublisher callback, and a did:web publisher whose host reversed is the appId marks the plan namespaceVerified. POST /installs, APIAdapter.requestInstall() and the fixtures carry { manifest, signature }; the fixture signatures are real so a server can verify them. Refs #359 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LeWBt7FirCqK6pCrRwaVCE --- .changeset/app-install-publishers.md | 8 ++ README.md | 4 +- docs/spec/apps.md | 34 ++++- docs/spec/wire-format.md | 14 +- packages/adapter-api/src/index.ts | 11 +- .../adapter-api/tests/conformance.test.ts | 25 +++- packages/conformance-fixtures/src/index.ts | 76 ++++++++++- packages/core/src/identity-bindings.ts | 14 +- packages/core/src/index.ts | 11 +- packages/core/src/install.ts | 121 ++++++++++++++++- packages/core/src/scoped-stack.ts | 3 +- packages/core/src/stack.ts | 35 ++++- packages/core/src/types.ts | 5 + packages/core/src/wire-body.ts | 16 ++- packages/core/tests/install.test.ts | 127 ++++++++++++++---- packages/core/tests/wire-body.test.ts | 24 ++-- packages/wire-types/src/index.ts | 9 +- 17 files changed, 447 insertions(+), 90 deletions(-) create mode 100644 .changeset/app-install-publishers.md diff --git a/.changeset/app-install-publishers.md b/.changeset/app-install-publishers.md new file mode 100644 index 00000000..50046907 --- /dev/null +++ b/.changeset/app-install-publishers.md @@ -0,0 +1,8 @@ +--- +'@haverstack/core': minor +'@haverstack/wire-types': minor +'@haverstack/conformance-fixtures': minor +'@haverstack/adapter-api': minor +--- + +App manifests name a `publisher` DID and travel signed: `planInstall()`, `installApp()`, `POST /installs` and `APIAdapter.requestInstall()` take `{ manifest, signature }`, made with `signManifest()` over `manifestPayload()`. A `did:key` publisher verifies with no lookup; any other method needs a `verifyPublisher` callback. The first install pins its publisher on `_install.publisher`, so a manifest under the same `appId` signed by anyone else is refused, whichever key presents it. A plan's `namespaceVerified` is true when a `did:web` publisher's domain, reversed, is the `appId`. diff --git a/README.md b/README.md index 28bb2252..048aebc9 100644 --- a/README.md +++ b/README.md @@ -41,10 +41,10 @@ await stack.grantType('com.example.myapp/note', { }); ``` -An app can instead ship those steps as a manifest — its types and the grants it asks for — which the owner reviews and applies in one call. The stack keeps the approval as an `_install` record, so the grants it made can be listed, upgraded and withdrawn together, and the app can migrate its own types without the owner running its code. See [App installs](./docs/spec/apps.md). +An app can instead ship those steps as a manifest — its types and the grants it asks for, signed by its publisher — which the owner reviews and applies in one call. The stack keeps the approval as an `_install` record, so the grants it made can be listed, upgraded and withdrawn together, and the app can migrate its own types without the owner running its code. See [App installs](./docs/spec/apps.md). ```ts -const plan = await stack.planInstall(manifest, { did: notesAppDid }); // show this to the owner +const plan = await stack.planInstall(signedManifest, { did: notesAppDid }); // show this to the owner await stack.installApp(plan); ``` diff --git a/docs/spec/apps.md b/docs/spec/apps.md index 2220be21..44866eaa 100644 --- a/docs/spec/apps.md +++ b/docs/spec/apps.md @@ -9,13 +9,16 @@ type AppManifest = { appId: AppId; // reverse-DNS, as on an _app card name: string; version?: string; + publisher: string; // the DID that signs the manifest — see Who publishes an app types: DefineTypeOptions[]; // the types the app defines requests: InstallRequest[]; // the grants its keys hold }; type InstallRequest = { baseId: BaseId; actions: GrantAction[] }; +type SignedManifest = { manifest: AppManifest; signature: string }; // base64url Ed25519 -const plan = await stack.planInstall(manifest, { did: appDid }); +const signed = await signManifest(manifest, publisherPrivateKey); // the app author, once per release +const plan = await stack.planInstall(signed, { did: appDid }); // …show the plan to the owner… await stack.installApp(plan); ``` @@ -31,6 +34,7 @@ type InstallContent = { appId: AppId; // a binding: immutable, unique among installs name: string; version?: string; + publisher: string; // a binding: immutable once set defines: TypeId[]; // every version the owner has approved requests: InstallRequest[]; // the grants each of the app's keys holds }; @@ -41,6 +45,7 @@ type InstallContent = { - **It cannot be granted.** `grantType()` refuses `_install` beside `_grant`, `_config` and `_app` (see [Access control § What a grant covers](./access-control.md#what-a-grant-covers)), and a `request` naming any of the four is refused at the write. - **Only the owner acting alone writes one.** `ScopedStack` refuses every write to an `_install` Record on the same terms as a `_grant` Record, whatever the Record's own `permissions` say. - **`appId` is a binding**, immutable and unique on the terms [Identity § DID bindings](./identity.md#did-bindings) sets out: one install answers for each app, and an existing install cannot be relabelled to answer for another. +- **`publisher` is a binding too**, immutable but not unique — one publisher may ship many apps. It is what [pins the install](#who-publishes-an-app) to whoever signed its first manifest. - **It claims only its own families.** Every `defines` entry must be a versioned TypeId in the install's own namespace (see [Who owns a family](#who-owns-a-family)); anything else is refused with `StackValidationError` on create, patch, migration and restore alike. `appId` being unique is what makes each family's owner single. Every `request` must name a family and actions from the grant vocabulary; `StackValidationError` otherwise. @@ -59,16 +64,31 @@ Two kinds of family belong to no app. **System families** (`_entity`, `_grant`, **Using a family is a request; owning one is control of its schema.** Any app may ask for grants on any grantable family — another app's, a commons one, `_entity` — and the owner sees each such request, with the family's owner, in the plan. Only the owner of a family defines its versions and migrates its records, so two apps can never publish rival versions of the same family. An app that wants to add to records it does not own defines a family of its own and links its records to them with `relationship` associations; the shared family is untouched. -**Residual, stated rather than fixed:** `appId` is the app's own claim. Any key can present a manifest under `com.example.notes`. Where that app is not installed, approving it gives the key the app's families. Where it is, approving it links the key to the existing install: the key is registered as that app, holds its grants and may migrate its types, and the manifest's `requests` replace those of every key already linked. The plan names the `appId` asking and the keys already linked, so the owner is the check; closing the gap needs signed manifests. +An `appId` is a claim the manifest makes, and [Who publishes an app](#who-publishes-an-app) is what makes it more than one. + +## Who publishes an app + +Every manifest names a **publisher** — a DID belonging to the app's author, distinct from the per-device keys an install links — and carries the publisher's signature over `manifestPayload(manifest)`: the label `haverstack-manifest-v1`, a newline, and the manifest as canonical JSON (keys sorted at every depth, no whitespace), so the same manifest signs the same way however it was serialized. `signManifest(manifest, privateKey)` produces one. `planInstall()` refuses a manifest whose signature does not verify with `StackValidationError` at `signature`, before it looks at anything else the stack holds. + +**The first install pins its publisher.** `_install.publisher` is a binding, so once a stack has installed `com.example.notes` from one publisher, a manifest under that `appId` signed by anyone else is refused with `StackConflictError` — whichever key presents it, so another key cannot join the install as the same app, and no one else can upgrade it. A publisher that loses its key is in the position [key rotation](./identity.md#deferred-key-rotation) describes: a new key is a new identity, and the owner uninstalls and reinstalls to accept it. + +**How strongly a publisher is known depends on its DID method**, following the [method table](./identity.md): + +- **`did:key`** is verified from the DID itself, with no lookup. That proves the same key signed every manifest an install accepts — trust on first use. It does not prove who holds the key: on a stack the real app never reached, the first manifest under its `appId` is pinned, whoever signed it. The plan names the publisher, so an owner can compare it with the one the app's author publishes. +- **`did:web`** is verified by a `PublisherVerifier` the caller passes as `verifyPublisher` to `planInstall()` and `installApp()`, which resolves the DID document from its domain; core resolves no method but `did:key`, and refuses any other publisher without a verifier rather than taking it on trust. When the `did:web` host, reversed, is the `appId` — `did:web:notes.example.com` for `com.example.notes` — the plan's `namespaceVerified` is true: the domain the namespace names vouches for the manifest. That is the one case where an `appId` is proven rather than claimed, and it inherits a domain's limits: whoever controls the domain controls the app. + +Nothing requires a domain. An app with only a key gets pinning; an app that wants its namespace proven publishes from `did:web`. ## Plan, then apply -`planInstall(manifest, { did })` writes nothing. It reports what applying the manifest for that key would change: +`planInstall(signed, { did, verifyPublisher? })` writes nothing. It reports what applying the manifest for that key would change: ```ts type InstallPlan = { manifest: AppManifest; did: EntityId; + signature: string; // carried so installApp() verifies it again + namespaceVerified: boolean; // a did:web publisher whose domain is the appId reversed existing: (StackRecord & { content: InstallContent }) | null; newFamilies: BaseId[]; // families the install would claim for the first time newVersions: TypeId[]; // versions not yet in `defines` @@ -87,9 +107,9 @@ type InstallPlan = { `foreignRequests` are the requests on families outside the app's own namespace, each naming the family's [owner](#who-owns-a-family): another app's `appId`, `'commons'`, `'system'`, or `null` for a family with no namespace. They are the requests an approval most needs to show — an app asking to read another app's records, or `_entity`, is asking for reach beyond its own data. -It refuses what no approval could make valid: a type outside the app's own namespace and the commons (`StackValidationError` — use a request instead), a request [`grantType()` would refuse](./access-control.md#type-level-grants) (`StackValidationError`), and a `did` whose `_app` card names a different `appId` (`StackConflictError`). +It refuses what no approval could make valid: a type outside the app's own namespace and the commons (`StackValidationError` — use a request instead), a request [`grantType()` would refuse](./access-control.md#type-level-grants) (`StackValidationError`), a manifest its publisher did not sign (`StackValidationError`), one under an `appId` already installed from another publisher, and a `did` whose `_app` card names a different `appId` (both `StackConflictError`). -`installApp(plan)` applies the plan: +`installApp(plan, { verifyPublisher? })` applies the plan: 1. Defines each of the manifest's types, with [schema drift](./data-model.md#schema-drift-detection) applying as it does to any `defineType()`. 2. Registers the key on an `_app` card when it has none, undeleting a soft-deleted one. An existing card's `name` is left alone: it is the owner's label. @@ -132,12 +152,12 @@ Installing the same `appId` again undeletes the install and grants its requests Install has two halves, and only one of them is the same on every server. **The app's half** — present a manifest, learn whether it was approved — is pinned by [`POST /installs`](./wire-format.md#installs), so an app installs itself the same way on any stack. **The owner's half** — review the plan, approve it — is a person deciding, through whatever the server offers (an admin page, a notification); it is not on the wire, and underneath it is `planInstall()` then `installApp()`. ```ts -const result = await adapter.requestInstall(manifest); +const result = await adapter.requestInstall(signed); // { status: 'pending' } until the owner approves this manifest for this key, // then { status: 'installed', install } once applying it would change nothing ``` -**The key is the session's.** The request names no DID: the handshake already proved which key is asking, so no app can ask for an install on behalf of a key it does not hold. +**The key is the session's; the publisher is the signature's.** The request names no installing DID: the handshake already proved which key is asking, so no app can ask for an install on behalf of a key it does not hold. The manifest's signature proves who published it, so no key can present a manifest its publisher did not sign. **A request is not an approval.** A pending request lives with the server, not in the stack, so a key that merely authenticated writes nothing into the owner's data. The stack holds only what the owner approved. diff --git a/docs/spec/wire-format.md b/docs/spec/wire-format.md index a455b20a..4aa055bc 100644 --- a/docs/spec/wire-format.md +++ b/docs/spec/wire-format.md @@ -707,18 +707,18 @@ POST /installs — present an app's manifest for the owner to approve How an app holding its own key asks to be installed (see [App installs § Over the wire](./apps.md#over-the-wire)). Only the asking is specified here; the owner approves through whatever the server offers, with `Stack.planInstall()` and `installApp()`. -**The body is `{ "manifest": { … } }`**, an `AppManifest`: `appId`, `name`, optional `version`, `types` (each read as a [`POST /types`](#types) body is) and `requests` (each `{ baseId, actions }`). `parseInstallBody()` from `@haverstack/core/wire` reads it, refusing an unknown key at either level with **400** like any other [unrecognized input](#unrecognized-input). **The key being installed is never in the body**: it is the session's principal. +**The body is `{ "manifest": { … }, "signature": "…" }`**, a signed `AppManifest`: `appId`, `name`, optional `version`, `publisher`, `types` (each read as a [`POST /types`](#types) body is) and `requests` (each `{ baseId, actions }`), with the publisher's base64url signature over it (see [App installs § Who publishes an app](./apps.md#who-publishes-an-app)). `parseInstallBody()` from `@haverstack/core/wire` reads it, refusing an unknown key at either level with **400** like any other [unrecognized input](#unrecognized-input). **The key being installed is never in the body**: it is the session's principal. **The request must come from the key acting as itself.** A delegated session names someone else as the subject, and an install is for the key that authenticated, so it answers **403** (code `permission`). The server plans the manifest for the session's key and answers with the result: -| Status | Body | When | -| ------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | -| `202` | `{ "status": "pending" }` | Applying the manifest would change something. The server queues it for the owner and writes nothing to the stack. | -| `200` | `{ "status": "installed", "install": … }` | The plan is empty — `isPlanEmpty()` from `@haverstack/core` decides — and `install` is the `_install` record, which the key can read. | -| `422` | `validation` | The manifest defines a type outside the app's [own namespace](./apps.md#who-owns-a-family) and the commons, or a request breaks the grant rules. | -| `409` | `conflict` | The key's `_app` card names a different `appId`. | +| Status | Body | When | +| ------ | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `202` | `{ "status": "pending" }` | Applying the manifest would change something. The server queues it for the owner and writes nothing to the stack. | +| `200` | `{ "status": "installed", "install": … }` | The plan is empty — `isPlanEmpty()` from `@haverstack/core` decides — and `install` is the `_install` record, which the key can read. | +| `422` | `validation` | The signature is not the publisher's over this manifest, the manifest defines a type outside the app's [own namespace](./apps.md#who-owns-a-family) and the commons, or a request breaks the grant rules. | +| `409` | `conflict` | The `appId` is installed from another publisher, or the key's `_app` card names a different `appId`. | Re-sending the same manifest is how an app checks: it answers `202` until the owner approves and `200` after. A manifest that changes anything an approved one said — a new version, a changed request — is `202` again, so an upgrade takes the same path as a first install. A client refuses `requestInstall()` locally when discovery does not advertise `installs`, rather than learning it as a `404`. diff --git a/packages/adapter-api/src/index.ts b/packages/adapter-api/src/index.ts index 5eb47dcd..92fd8bc7 100644 --- a/packages/adapter-api/src/index.ts +++ b/packages/adapter-api/src/index.ts @@ -50,7 +50,7 @@ import type { RecordChangeSet, StackCapabilities, MissingCapability, - AppManifest, + SignedManifest, InstallContent, } from '@haverstack/core'; import type { StackAdapter, SubscribeChangesOptions } from '@haverstack/core/adapter'; @@ -1095,13 +1095,13 @@ export class APIAdapter implements StackAdapter { } /** - * Present this app's manifest for the owner to approve, as the key this - * adapter authenticated with. `pending` until the owner approves this + * Present this app's signed manifest for the owner to approve, as the + * key this adapter authenticated with. `pending` until the owner approves this * manifest for this key; `installed`, with the `_install` record, once * applying it would change nothing. Refused locally when the server does * not advertise install requests. See docs/spec/wire-format.md § Installs. */ - async requestInstall(manifest: AppManifest): Promise { + async requestInstall(signed: SignedManifest): Promise { if (!this.installRequests) { throw new APIAdapterCapabilityError( 'installs', @@ -1110,7 +1110,8 @@ export class APIAdapter implements StackAdapter { ); } const raw = await this.request('POST', '/installs', { - manifest, + manifest: signed.manifest, + signature: signed.signature, }); if (raw?.status === 'pending') return { status: 'pending' }; if (raw?.status === 'installed' && raw.install) { diff --git a/packages/adapter-api/tests/conformance.test.ts b/packages/adapter-api/tests/conformance.test.ts index d456a9fa..a594effa 100644 --- a/packages/adapter-api/tests/conformance.test.ts +++ b/packages/adapter-api/tests/conformance.test.ts @@ -39,6 +39,7 @@ import { getJournalFixtures, commitMigrationFixtures, installRequestFixtures, + INSTALL_FIXTURE_PUBLISHER, discoveryFixtures, errorResponseFixtures, attachmentUploadFixtures, @@ -65,6 +66,7 @@ import { StackSchemaDriftError, StackPayloadTooLargeError, StackTimeoutError, + manifestPayload, } from '@haverstack/core'; import { buildAuthChallengePayload, @@ -72,7 +74,7 @@ import { base64urlDecode, didCredentialFromKeypair, } from '@haverstack/core/wire'; -import { generateDidKeypair } from '@haverstack/core/did'; +import { generateDidKeypair, verifyDidSignature } from '@haverstack/core/did'; useFetchMock(); @@ -735,7 +737,7 @@ describe('install request fixtures', () => { const adapter = await openWithInstalls(); mockFetch.mockResolvedValueOnce(jsonResponse(fixture.responseBody, fixture.responseStatus)); - const attempt = adapter.requestInstall(fixture.requestBody!.manifest); + const attempt = adapter.requestInstall(fixture.requestBody!); const body = fixture.responseBody!; if ('error' in body) { await expect(attempt).rejects.toBeInstanceOf( @@ -758,11 +760,28 @@ describe('install request fixtures', () => { }); } + // The signatures are real, so a server can verify them rather than trust + // its own derivation of the signed bytes. One fixture is forged on purpose. + test('every fixture signature but the forged one is the publisher’s', async () => { + for (const fixture of installRequestFixtures) { + const { manifest, signature } = fixture.requestBody!; + expect(manifest.publisher).toBe(INSTALL_FIXTURE_PUBLISHER); + const valid = await verifyDidSignature( + manifest.publisher, + base64urlDecode(signature), + manifestPayload(manifest), + ); + expect(valid, fixture.name).toBe( + fixture.name !== 'install-request-signature-not-the-publishers', + ); + } + }); + test('a server advertising no install requests is refused locally', async () => { const adapter = await openAdapter(); const calls = mockFetch.mock.calls.length; await expect( - adapter.requestInstall(installRequestFixtures[0]!.requestBody!.manifest), + adapter.requestInstall(installRequestFixtures[0]!.requestBody!), ).rejects.toBeInstanceOf(APIAdapterCapabilityError); expect(mockFetch.mock.calls.length).toBe(calls); }); diff --git a/packages/conformance-fixtures/src/index.ts b/packages/conformance-fixtures/src/index.ts index c93d0d1f..87834558 100644 --- a/packages/conformance-fixtures/src/index.ts +++ b/packages/conformance-fixtures/src/index.ts @@ -2167,15 +2167,29 @@ export const commitMigrationFixtures: ConformanceFixture< // its own key for (docs/spec/wire-format.md § Installs). The owner // approves out of band; these pin only what the app sees. The key being // installed is always the session's — the body never names one. +// +// Every manifest carries a real Ed25519 signature by INSTALL_FIXTURE_PUBLISHER +// over manifestPayload() from @haverstack/core, so a server can verify one +// rather than trusting its own derivation of the signed bytes. + +/** The publisher whose key signed the install fixtures' manifests. */ +export const INSTALL_FIXTURE_PUBLISHER = 'did:key:z6Mkj5oEgHSkFTYqW9dZhhLxZdPSX6NSMxfbg9QYAERTALgf'; const INSTALL_MANIFEST: WireInstallRequest['manifest'] = { appId: 'com.example.notes', name: 'Notes', version: '1.0.0', + publisher: INSTALL_FIXTURE_PUBLISHER, types: [{ id: 'com.example.notes/note@1', name: 'Note', schema: { text: { kind: 'text' } } }], requests: [{ baseId: 'com.example.notes/note', actions: ['create', 'read-any'] }], }; +const SIGNED_INSTALL: WireInstallRequest = { + manifest: INSTALL_MANIFEST, + signature: + 'xQ7hCjIXyXJ9eNpadlOT2HPQzgnAUn-bxDuMQ2PQoVihasedwgmqw4VissRUgf8PhZ0gdHVBogL4fRLp14vTBA', +}; + export const installRequestFixtures: ConformanceFixture< WireInstallRequest, WireInstallResponse | WireError @@ -2190,7 +2204,7 @@ export const installRequestFixtures: ConformanceFixture< 'adding a version or changing a request — is pending again in the same way.', method: 'POST', path: '/installs', - requestBody: { manifest: INSTALL_MANIFEST }, + requestBody: SIGNED_INSTALL, responseStatus: 202, responseBody: { status: 'pending' }, }, @@ -2204,7 +2218,7 @@ export const installRequestFixtures: ConformanceFixture< "approved exactly this manifest for the session's key.", method: 'POST', path: '/installs', - requestBody: { manifest: INSTALL_MANIFEST }, + requestBody: SIGNED_INSTALL, responseStatus: 200, responseBody: { status: 'installed', @@ -2217,6 +2231,7 @@ export const installRequestFixtures: ConformanceFixture< appId: 'com.example.notes', name: 'Notes', version: '1.0.0', + publisher: INSTALL_FIXTURE_PUBLISHER, defines: ['com.example.notes/note@1'], requests: [{ baseId: 'com.example.notes/note', actions: ['create', 'read-any'] }], }, @@ -2238,6 +2253,8 @@ export const installRequestFixtures: ConformanceFixture< ...INSTALL_MANIFEST, types: [{ id: 'com.example.tags/tag@1', name: 'Tag', schema: {} }], }, + signature: + 'RtkahKa-mw9f9DoQCNHJOKu-_W-5PyFVoW06oBJLVFC9PlSgc6qE_dz82C7vmUSK3bHcUgVLjAKrBOEdRS-DCw', }, responseStatus: 422, responseBody: { @@ -2255,6 +2272,57 @@ export const installRequestFixtures: ConformanceFixture< }, }, }, + { + name: 'install-request-signature-not-the-publishers', + description: + "POST /installs whose signature is not the publisher's over this manifest — here a " + + 'request widened after signing — answers 422 with code "validation" at "signature". ' + + "The publisher's signature is what lets an install refuse anyone else's upgrade, so a " + + 'manifest that does not carry one is not considered at all. See docs/spec/apps.md § Who ' + + 'publishes an app.', + method: 'POST', + path: '/installs', + requestBody: { + manifest: { + ...INSTALL_MANIFEST, + requests: [{ baseId: 'com.example.notes/note', actions: ['read-any', 'update-any'] }], + }, + signature: SIGNED_INSTALL.signature, + }, + responseStatus: 422, + responseBody: { + error: { + code: 'validation', + message: 'Content validation failed', + details: [ + { + path: 'signature', + message: 'The signature is not the publisher’s over this manifest', + }, + ], + }, + }, + }, + { + name: 'install-request-publisher-pinned', + description: + 'POST /installs for an appId already installed from another publisher answers 409 with ' + + 'code "conflict", whichever key asks. The first install pins its publisher, so only a ' + + 'manifest the same publisher signed can upgrade it or link another key to it. Assumes ' + + '"com.example.notes" is installed from did:web:notes.example.com.', + method: 'POST', + path: '/installs', + requestBody: SIGNED_INSTALL, + responseStatus: 409, + responseBody: { + error: { + code: 'conflict', + message: + '"com.example.notes" is installed from did:web:notes.example.com; this manifest is ' + + `signed by ${INSTALL_FIXTURE_PUBLISHER}`, + }, + }, + }, { name: 'install-request-key-registered-to-another-app', description: @@ -2263,7 +2331,7 @@ export const installRequestFixtures: ConformanceFixture< 'Assumes the session\'s DID is registered to "com.example.other".', method: 'POST', path: '/installs', - requestBody: { manifest: INSTALL_MANIFEST }, + requestBody: SIGNED_INSTALL, responseStatus: 409, responseBody: { error: { @@ -2282,7 +2350,7 @@ export const installRequestFixtures: ConformanceFixture< 'itself; a delegated token names someone else as the subject.', method: 'POST', path: '/installs', - requestBody: { manifest: INSTALL_MANIFEST }, + requestBody: SIGNED_INSTALL, responseStatus: 403, responseBody: { error: { code: 'permission', message: 'An install request must come from the key itself' }, diff --git a/packages/core/src/identity-bindings.ts b/packages/core/src/identity-bindings.ts index 78d0f9d0..687eead2 100644 --- a/packages/core/src/identity-bindings.ts +++ b/packages/core/src/identity-bindings.ts @@ -16,10 +16,14 @@ import { SYSTEM_TYPES } from './types.js'; * claims one, and something later resolves through it. Every one of them is * immutable once set. See docs/spec/identity.md § DID bindings. */ -const BINDING_FIELDS: ReadonlyMap = new Map([ +/** A content field something resolves through or pins. */ +export type BindingField = 'did' | 'appId' | 'publisher'; + +const BINDING_FIELDS: ReadonlyMap = new Map([ [SYSTEM_TYPES.APP, ['did', 'appId'] as const], [SYSTEM_TYPES.ENTITY, ['did'] as const], - [SYSTEM_TYPES.INSTALL, ['appId'] as const], + // `publisher` pins who may upgrade the install; see docs/spec/apps.md § Who publishes an app. + [SYSTEM_TYPES.INSTALL, ['appId', 'publisher'] as const], ]); /** @@ -37,15 +41,15 @@ const BINDING_FIELDS: ReadonlyMap = new Ma * card onto another's `appId` is what immutability already refuses. * See docs/spec/identity.md § DID bindings. */ -const UNIQUE_BINDING_FIELDS: ReadonlyMap = new Map([ +const UNIQUE_BINDING_FIELDS: ReadonlyMap = new Map([ [SYSTEM_TYPES.APP, ['did'] as const], [SYSTEM_TYPES.ENTITY, ['did'] as const], // One install answers for each app; see docs/spec/apps.md § The `_install` record. [SYSTEM_TYPES.INSTALL, ['appId'] as const], ]); -export const bindingFieldsOf = (family: string): readonly ('did' | 'appId')[] => +export const bindingFieldsOf = (family: string): readonly BindingField[] => BINDING_FIELDS.get(family) ?? []; -export const uniqueBindingFieldsOf = (family: string): readonly ('did' | 'appId')[] => +export const uniqueBindingFieldsOf = (family: string): readonly BindingField[] => UNIQUE_BINDING_FIELDS.get(family) ?? []; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d7fd3f76..b8c2a1cf 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -35,8 +35,15 @@ export type { CollectAttachmentGarbageResult, MigrateAllOptions, } from './stack.js'; -export type { AppManifest, InstallPlan, ForeignRequest, TypeChange } from './install.js'; -export { isPlanEmpty } from './install.js'; +export type { + AppManifest, + InstallPlan, + ForeignRequest, + TypeChange, + SignedManifest, + PublisherVerifier, +} from './install.js'; +export { isPlanEmpty, signManifest, manifestPayload, appIdVouchedBy } from './install.js'; // Type handles export { typeHandle } from './type-handle.js'; diff --git a/packages/core/src/install.ts b/packages/core/src/install.ts index 7b25169c..5498c015 100644 --- a/packages/core/src/install.ts +++ b/packages/core/src/install.ts @@ -14,14 +14,21 @@ * families without the owner running its code — see * ScopedStack.commitMigration(). * + * A manifest is signed by its publisher's key, and the install pins that + * publisher: only a manifest the same publisher signed can upgrade it, or + * link another key to it. + * * This module holds the parts that read an install as data: the write-time - * shape rules, the family claim, and the plan diff. The verbs that write + * shape rules, the family claim, manifest signing, and the plan diff. The verbs that write * live on `Stack`. See docs/spec/apps.md. */ import { baseIdOf, familyIdProblem, parseTypeId } from './schema.js'; import { GRANT_ACTION_SET, UNGRANTABLE_SYSTEM_TYPES } from './grants.js'; import { SYSTEM_TYPES } from './types.js'; +import { isValidDidKey, signWithDid, verifyDidSignature } from './did.js'; +import { base64urlDecode, base64urlEncode } from './auth.js'; +import { StackValidationError } from './errors.js'; import type { AppId, AuthorityAssociation, @@ -44,10 +51,33 @@ export type AppManifest = { appId: AppId; name: string; version?: string; + /** + * The DID of whoever publishes the app — a key of the author's, not one + * of the per-device keys being installed. See docs/spec/apps.md + * § Who publishes an app. + */ + publisher: string; types: DefineTypeOptions[]; requests: InstallRequest[]; }; +/** A manifest with its publisher's signature over manifestPayload(). */ +export type SignedManifest = { + manifest: AppManifest; + /** base64url Ed25519 signature. */ + signature: string; +}; + +/** + * Verifies a signature by a publisher whose DID method core does not + * resolve — `did:web`, say. `did:key` needs none: its public key is the DID. + */ +export type PublisherVerifier = ( + publisher: string, + signature: Uint8Array, + payload: Uint8Array, +) => Promise; + /** A request on a family this install does not define, and who owns that family. */ export type ForeignRequest = InstallRequest & { /** @@ -77,6 +107,14 @@ export type InstallPlan = { manifest: AppManifest; /** The key being installed. */ did: EntityId; + /** The publisher's signature over `manifest`, carried so the plan can be verified again. */ + signature: string; + /** + * Whether the publisher's DID is a `did:web` whose domain is the + * `appId` reversed — the domain vouching for the namespace, beyond the + * key alone. See docs/spec/apps.md § Who publishes an app. + */ + namespaceVerified: boolean; /** The install as it stood when planned — null for a first install. */ existing: (StackRecord & { content: InstallContent }) | null; /** Families this install would claim that it does not claim yet. */ @@ -253,6 +291,86 @@ function deepFreeze(value: T): T { return value; } +const MANIFEST_PAYLOAD_LABEL = 'haverstack-manifest-v1'; + +/** + * The bytes a publisher signs: a label, then the manifest as canonical + * JSON — keys sorted at every depth, no whitespace — so the same manifest + * signs the same way however it was serialized. + */ +export function manifestPayload(manifest: AppManifest): Uint8Array { + return new TextEncoder().encode(`${MANIFEST_PAYLOAD_LABEL}\n${canonicalJson(manifest)}`); +} + +function canonicalJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`; + if (typeof value === 'object' && value !== null) { + const entries = Object.keys(value) + .filter((k) => (value as Record)[k] !== undefined) + .sort() + .map((k) => `${JSON.stringify(k)}:${canonicalJson((value as Record)[k])}`); + return `{${entries.join(',')}}`; + } + return JSON.stringify(value); +} + +/** Sign `manifest` with the private key behind its `publisher`. */ +export async function signManifest( + manifest: AppManifest, + privateKey: CryptoKey, +): Promise { + const signature = await signWithDid(privateKey, manifestPayload(manifest)); + return { manifest, signature: base64urlEncode(signature) }; +} + +/** + * Refuse a manifest its publisher did not sign. A `did:key` publisher is + * verified from the DID itself; any other method needs `verifier`, and is + * refused without one rather than taken on trust. + */ +export async function assertManifestSigned( + signed: SignedManifest, + verifier?: PublisherVerifier, +): Promise { + const { publisher } = signed.manifest; + const fail = (path: string, message: string): never => { + throw new StackValidationError([{ path, message }]); + }; + if (typeof publisher !== 'string' || !publisher.startsWith('did:')) { + fail('manifest.publisher', 'Expected the publisher’s DID'); + } + let signature: Uint8Array; + try { + signature = base64urlDecode(signed.signature); + } catch { + return fail('signature', 'Expected a base64url signature'); + } + const payload = manifestPayload(signed.manifest); + let valid: boolean; + if (isValidDidKey(publisher)) { + valid = await verifyDidSignature(publisher, signature, payload).catch(() => false); + } else if (verifier) { + valid = await verifier(publisher, signature, payload); + } else { + return fail( + 'manifest.publisher', + `Cannot verify a ${publisher.split(':')[1]} publisher without a verifier for that DID method`, + ); + } + if (!valid) fail('signature', 'The signature is not the publisher’s over this manifest'); +} + +/** + * The `appId` a `did:web` publisher's domain vouches for: its host + * reversed, so `did:web:notes.example.com` vouches for `com.example.notes`. + * Null for any other DID, or a `did:web` with a path or port. + */ +export function appIdVouchedBy(publisher: string): AppId | null { + const match = /^did:web:([a-z0-9.-]+)$/i.exec(publisher); + if (!match) return null; + return match[1]!.toLowerCase().split('.').reverse().join('.'); +} + /** Whether two requests ask for the same family and exactly the same actions. */ export function sameRequest(a: InstallRequest, b: InstallRequest): boolean { if (a.baseId !== b.baseId || a.actions.length !== b.actions.length) return false; @@ -307,5 +425,6 @@ export function planFingerprint(plan: InstallPlan): string { plan.typeChanges, plan.newKey, plan.linkedKeys, + plan.namespaceVerified, ]); } diff --git a/packages/core/src/scoped-stack.ts b/packages/core/src/scoped-stack.ts index 9677d398..e17d7523 100644 --- a/packages/core/src/scoped-stack.ts +++ b/packages/core/src/scoped-stack.ts @@ -90,6 +90,7 @@ import { UNGRANTABLE_SYSTEM_TYPES, } from './grants.js'; import { bindingFieldsOf } from './identity-bindings.js'; +import type { BindingField } from './identity-bindings.js'; import { claimedFamilies, familyStanding, linkedIds, INSTALL_APP_LABEL } from './install.js'; import { assertAttachmentSize } from './limits.js'; import { validateIdTimestampSkew, validateRecordId } from './record-id.js'; @@ -1275,7 +1276,7 @@ export class ScopedStack implements StackClient { */ private requireOwnerForAppIdentity( typeId: TypeId, - touches: (field: 'did' | 'appId') => boolean, + touches: (field: BindingField) => boolean, ): void { if (baseIdOf(typeId) !== SYSTEM_TYPES.APP) return; if (!bindingFieldsOf(SYSTEM_TYPES.APP).some(touches)) return; diff --git a/packages/core/src/stack.ts b/packages/core/src/stack.ts index a3743999..93741b8b 100644 --- a/packages/core/src/stack.ts +++ b/packages/core/src/stack.ts @@ -125,7 +125,10 @@ import { } from './grants.js'; import type { GrantQuery } from './grants.js'; import { bindingFieldsOf, uniqueBindingFieldsOf } from './identity-bindings.js'; +import type { BindingField } from './identity-bindings.js'; import { + appIdVouchedBy, + assertManifestSigned, claimedFamilies, familyStanding, grantIsRequest, @@ -142,6 +145,7 @@ import { INSTALL_APP_LABEL, INSTALL_GRANT_LABEL, } from './install.js'; +import type { PublisherVerifier, SignedManifest } from './install.js'; import type { AppManifest, ForeignRequest, InstallPlan, TypeChange } from './install.js'; import { assertAttachmentSize, assertContentSize } from './limits.js'; import { @@ -2182,7 +2186,7 @@ export class Stack implements StackClient { */ private async checkBindingUnique( family: string, - field: 'did' | 'appId', + field: BindingField, value: unknown, excludeId?: RecordId, ): Promise { @@ -2218,7 +2222,7 @@ export class Stack implements StackClient { */ private checkBindingImmutable( family: string, - field: 'did' | 'appId', + field: BindingField, existing: unknown, next: unknown, ): void { @@ -2854,14 +2858,23 @@ export class Stack implements StackClient { * the commons, a request the grant rules refuse, or a `did` already * registered to a different app. See docs/spec/apps.md § Plan, then apply. */ - async planInstall(submitted: AppManifest, opts: { did: EntityId }): Promise { + async planInstall( + signed: SignedManifest, + opts: { did: EntityId; verifyPublisher?: PublisherVerifier }, + ): Promise { this.assertOpen(); const { did } = opts; - const manifest = snapshotManifest(submitted); + const manifest = snapshotManifest(signed.manifest); this.checkManifest(manifest, did); + await assertManifestSigned({ manifest, signature: signed.signature }, opts.verifyPublisher); const installs = await this.loadInstalls(); const existing = installs.find((r) => r.content.appId === manifest.appId) ?? null; + if (existing && existing.content.publisher !== manifest.publisher) { + throw new StackConflictError( + `"${manifest.appId}" is installed from ${existing.content.publisher}; this manifest is signed by ${manifest.publisher}`, + ); + } const ownVersions = ownTypeIds(manifest); const ownFamilies = new Set(ownVersions.map(baseIdOf)); @@ -2906,6 +2919,8 @@ export class Stack implements StackClient { return { manifest, did, + signature: signed.signature, + namespaceVerified: appIdVouchedBy(manifest.publisher) === manifest.appId, existing, newFamilies: [...ownFamilies].filter((f) => !claimed.has(f)), newVersions: ownVersions.filter((id) => !defined.has(id)), @@ -2926,9 +2941,15 @@ export class Stack implements StackClient { * what is applied is what was approved. Reinstalls a soft-deleted * install. See docs/spec/apps.md § Plan, then apply. */ - async installApp(plan: InstallPlan): Promise { + async installApp( + plan: InstallPlan, + opts: { verifyPublisher?: PublisherVerifier } = {}, + ): Promise { this.assertOpen(); - const fresh = await this.planInstall(plan.manifest, { did: plan.did }); + const fresh = await this.planInstall( + { manifest: plan.manifest, signature: plan.signature }, + { did: plan.did, verifyPublisher: opts.verifyPublisher }, + ); if (planFingerprint(fresh) !== planFingerprint(plan)) { throw new StackConflictError( `The stack changed since the install of "${plan.manifest.appId}" was planned; plan it again`, @@ -2953,6 +2974,7 @@ export class Stack implements StackClient { appId: manifest.appId, name: manifest.name, ...(manifest.version !== undefined && { version: manifest.version }), + publisher: manifest.publisher, defines, requests, }, @@ -3262,6 +3284,7 @@ export class Stack implements StackClient { appId: { kind: 'string', required: true }, name: { kind: 'string', required: true }, version: { kind: 'string' }, + publisher: { kind: 'string', required: true }, defines: { kind: 'array', items: { kind: 'string' }, required: true }, requests: { kind: 'array', diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 50ec8be9..17b40a17 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -513,6 +513,11 @@ export type InstallContent = { appId: AppId; name: string; version?: string; + /** + * The DID that signed the approved manifest. A binding: only a manifest + * the same publisher signed can change this install. + */ + publisher: string; /** * Every type version the owner has approved for this app. The families * they name are claimed by this install and by no other. diff --git a/packages/core/src/wire-body.ts b/packages/core/src/wire-body.ts index f3ba89c2..17fb1d46 100644 --- a/packages/core/src/wire-body.ts +++ b/packages/core/src/wire-body.ts @@ -20,7 +20,7 @@ import { StackBadRequestError, StackValidationError } from './errors.js'; import type { DefineTypeOptions } from './stack.js'; import { assertKnownKeys, validateAssociation } from './query-validation.js'; -import type { AppManifest } from './install.js'; +import type { AppManifest, SignedManifest } from './install.js'; import type { AssociationEdit, GrantAction, StackType, TypeId, TypeSchema } from './types.js'; export function requireBody(body: unknown, label: string): Record { @@ -226,21 +226,23 @@ export function parseMigrationBody(body: unknown): WireMigrationRequest { // ------------------------------------------------------- /** - * Parse a `POST /installs` body, `{ manifest }`, into the manifest - * `planInstall()` takes. Each type is read as a `POST /types` body is. + * Parse a `POST /installs` body, `{ manifest, signature }`, into the + * signed manifest `planInstall()` takes. Each type is read as a `POST /types` body is. * Which families a manifest may define, and which requests the grant rules * allow, are `planInstall()`'s to judge. See docs/spec/wire-format.md § Installs. */ -export function parseInstallBody(body: unknown): AppManifest { - const b = requireKnownBody(body, ['manifest'], 'install body'); +export function parseInstallBody(body: unknown): SignedManifest { + const b = requireKnownBody(body, ['manifest', 'signature'], 'install body'); + const signature = requiredString(b, 'signature', 'install body'); const m = requireKnownBody( requiredObject(b, 'manifest', 'install body'), - ['appId', 'name', 'version', 'types', 'requests'], + ['appId', 'name', 'version', 'publisher', 'types', 'requests'], 'manifest', ); const manifest: AppManifest = { appId: nestedString(m, 'manifest', 'appId'), name: nestedString(m, 'manifest', 'name'), + publisher: nestedString(m, 'manifest', 'publisher'), types: nestedArray(m, 'manifest', 'types').map((t, i) => { if (typeof t !== 'object' || t === null || Array.isArray(t)) fieldError(`manifest.types[${i}]`, 'a type must be an object'); @@ -263,7 +265,7 @@ export function parseInstallBody(body: unknown): AppManifest { if (typeof m.version !== 'string') fieldError('manifest.version', 'version must be a string'); manifest.version = m.version; } - return manifest; + return { manifest, signature }; } /** A required string inside a nested object, its 422 naming the full path. */ diff --git a/packages/core/tests/install.test.ts b/packages/core/tests/install.test.ts index 0d0d23b6..12db1130 100644 --- a/packages/core/tests/install.test.ts +++ b/packages/core/tests/install.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect, beforeEach } from 'vitest'; +import { describe, test, expect, beforeAll, beforeEach } from 'vitest'; import { Stack } from '../src/stack.js'; import { StackConflictError, @@ -7,8 +7,10 @@ import { StackValidationError, } from '../src/errors.js'; import { MemoryAdapter } from '../src/testing.js'; -import { isPlanEmpty } from '../src/install.js'; +import { appIdVouchedBy, isPlanEmpty, signManifest } from '../src/install.js'; import type { AppManifest } from '../src/install.js'; +import { generateDidKeypair } from '../src/did.js'; +import type { DidKeypair } from '../src/did.js'; import type { AppContent, GrantContent, InstallContent, StackRecord } from '../src/types.js'; const OWNER = 'did:key:owner'; @@ -25,6 +27,7 @@ const manifest = (overrides: Partial = {}): AppManifest => ({ appId: 'com.example.notes', name: 'Notes', version: '1.0.0', + publisher: publisherKey.did, types: [{ id: NOTE_1, name: 'Note', schema: { text: { kind: 'text' } } }], requests: [{ baseId: 'com.example.notes/note', actions: ['create', 'read-any'] }], ...overrides, @@ -42,9 +45,19 @@ const MIGRATING = [ ] as AppManifest['requests']; let stack: Stack; +let publisherKey: DidKeypair; + +beforeAll(async () => { + publisherKey = await generateDidKeypair(); +}); + +/** planInstall() for a manifest signed by `key`, the publisher's own by default. */ +async function planSigned(m: AppManifest, opts: { did: string }, key = publisherKey) { + return stack.planInstall(await signManifest(m, key.privateKey), opts); +} async function install(m: AppManifest, did = APP_DID) { - return stack.installApp(await stack.planInstall(m, { did })); + return stack.installApp(await planSigned(m, { did })); } async function linkedGrants(record: StackRecord): Promise { @@ -73,6 +86,7 @@ describe('installApp()', () => { appId: 'com.example.notes', name: 'Notes', version: '1.0.0', + publisher: publisherKey.did, defines: [NOTE_1], requests: [{ baseId: 'com.example.notes/note', actions: ['create', 'read-any'] }], }); @@ -92,7 +106,7 @@ describe('installApp()', () => { test('re-applying the same manifest changes nothing', async () => { const first = await install(manifest()); - const plan = await stack.planInstall(manifest(), { did: APP_DID }); + const plan = await planSigned(manifest(), { did: APP_DID }); expect(plan).toMatchObject({ newFamilies: [], newVersions: [], @@ -108,7 +122,7 @@ describe('installApp()', () => { test('an upgrade adds approved versions and brings grants to the new requests', async () => { const first = await install(manifest()); const next = manifest({ version: '2.0.0', types: [NOTE_2_TYPE], requests: MIGRATING }); - const plan = await stack.planInstall(next, { did: APP_DID }); + const plan = await planSigned(next, { did: APP_DID }); expect(plan.newVersions).toEqual([NOTE_2]); expect(plan.newFamilies).toEqual([]); expect(plan.requestsAdded).toEqual(MIGRATING); @@ -125,7 +139,7 @@ describe('installApp()', () => { test('a second key gets the same grants, and an upgrade reaches every linked key', async () => { await install(manifest()); - const plan = await stack.planInstall(manifest(), { did: OTHER_DID }); + const plan = await planSigned(manifest(), { did: OTHER_DID }); expect(plan.newKey).toBe(true); await stack.installApp(plan); expect( @@ -139,14 +153,14 @@ describe('installApp()', () => { }); test('refuses a plan the stack has moved on from', async () => { - const plan = await stack.planInstall(manifest(), { did: APP_DID }); + const plan = await planSigned(manifest(), { did: APP_DID }); await install(manifest()); await expect(stack.installApp(plan)).rejects.toThrow(StackConflictError); }); test('the plan lists every type it would write, commons types included', async () => { const commons = { id: COMMONS_NOTE, name: 'Note', schema: { body: { kind: 'text' } } } as const; - const plan = await stack.planInstall(manifest({ types: [manifest().types[0]!, commons] }), { + const plan = await planSigned(manifest({ types: [manifest().types[0]!, commons] }), { did: APP_DID, }); expect(plan.typeChanges).toEqual([ @@ -155,14 +169,14 @@ describe('installApp()', () => { ]); await stack.installApp(plan); - const renamed = await stack.planInstall( + const renamed = await planSigned( manifest({ types: [{ ...manifest().types[0]!, name: 'Memo' }] }), { did: APP_DID }, ); expect(renamed.typeChanges).toEqual([{ id: NOTE_1, change: 'name' }]); expect(isPlanEmpty(renamed)).toBe(false); - const widened = await stack.planInstall( + const widened = await planSigned( manifest({ types: [ { ...manifest().types[0]!, schema: { text: { kind: 'text' }, x: { kind: 'string' } } }, @@ -174,41 +188,39 @@ describe('installApp()', () => { }); test('the plan names the keys already linked, whose grants it also sets', async () => { - expect((await stack.planInstall(manifest(), { did: APP_DID })).linkedKeys).toEqual([]); + expect((await planSigned(manifest(), { did: APP_DID })).linkedKeys).toEqual([]); await install(manifest()); - const plan = await stack.planInstall(manifest({ requests: MIGRATING }), { did: OTHER_DID }); + const plan = await planSigned(manifest({ requests: MIGRATING }), { did: OTHER_DID }); expect(plan.newKey).toBe(true); expect(plan.linkedKeys).toEqual([APP_DID]); }); test('a plan made before a type was defined is refused', async () => { - const plan = await stack.planInstall(manifest(), { did: APP_DID }); + const plan = await planSigned(manifest(), { did: APP_DID }); await stack.defineType(manifest().types[0]!); await expect(stack.installApp(plan)).rejects.toThrow(StackConflictError); }); test('a key registered to another app is refused', async () => { await stack.create('_app@1', { appId: 'com.example.other', name: 'Other', did: APP_DID }); - await expect(stack.planInstall(manifest(), { did: APP_DID })).rejects.toThrow( - StackConflictError, - ); + await expect(planSigned(manifest(), { did: APP_DID })).rejects.toThrow(StackConflictError); }); test('system types can be neither defined nor requested when ungrantable', async () => { await expect( - stack.planInstall(manifest({ types: [{ id: '_entity@2', name: 'Entity', schema: {} }] }), { + planSigned(manifest({ types: [{ id: '_entity@2', name: 'Entity', schema: {} }] }), { did: APP_DID, }), ).rejects.toThrow(StackValidationError); await expect( - stack.planInstall(manifest({ requests: [{ baseId: '_install', actions: ['create'] }] }), { + planSigned(manifest({ requests: [{ baseId: '_install', actions: ['create'] }] }), { did: APP_DID, }), ).rejects.toThrow(StackValidationError); }); test('requests outside the app’s own namespace name the family’s owner', async () => { - const plan = await stack.planInstall( + const plan = await planSigned( manifest({ requests: [ { baseId: 'com.example.notes/note', actions: ['create'] }, @@ -237,7 +249,7 @@ describe('installApp()', () => { OTHER_DID, ); await expect( - stack.planInstall(manifest({ types: [{ id: TAG_1, name: 'Tag', schema: {} }] }), { + planSigned(manifest({ types: [{ id: TAG_1, name: 'Tag', schema: {} }] }), { did: APP_DID, }), ).rejects.toThrow(StackValidationError); @@ -297,7 +309,7 @@ describe('what an installed app sees', () => { test('a plan applies the manifest as planned, whatever happens to the caller’s object', async () => { const m = manifest(); - const plan = await stack.planInstall(m, { did: APP_DID }); + const plan = await planSigned(m, { did: APP_DID }); m.requests.push({ baseId: 'com.example.notes/note', actions: ['delete-any'] }); expect(() => { (plan.manifest.requests as unknown[]).push({ baseId: '_entity', actions: ['read-any'] }); @@ -307,15 +319,13 @@ describe('what an installed app sees', () => { }); test('a plan is empty only once its key is installed and nothing would change', async () => { - expect(isPlanEmpty(await stack.planInstall(manifest(), { did: APP_DID }))).toBe(false); + expect(isPlanEmpty(await planSigned(manifest(), { did: APP_DID }))).toBe(false); await install(manifest()); - expect(isPlanEmpty(await stack.planInstall(manifest(), { did: APP_DID }))).toBe(true); - expect(isPlanEmpty(await stack.planInstall(manifest(), { did: OTHER_DID }))).toBe(false); - expect(isPlanEmpty(await stack.planInstall(manifest({ requests: [] }), { did: APP_DID }))).toBe( - false, - ); + expect(isPlanEmpty(await planSigned(manifest(), { did: APP_DID }))).toBe(true); + expect(isPlanEmpty(await planSigned(manifest(), { did: OTHER_DID }))).toBe(false); + expect(isPlanEmpty(await planSigned(manifest({ requests: [] }), { did: APP_DID }))).toBe(false); await stack.uninstallApp('com.example.notes'); - expect(isPlanEmpty(await stack.planInstall(manifest(), { did: APP_DID }))).toBe(false); + expect(isPlanEmpty(await planSigned(manifest(), { did: APP_DID }))).toBe(false); }); }); @@ -326,6 +336,7 @@ describe('the _install record', () => { stack.create('_install@1', { appId: 'com.example.rival', name: 'Rival', + publisher: publisherKey.did, defines: [id], requests: [], }), @@ -339,6 +350,7 @@ describe('the _install record', () => { stack.create('_install@1', { appId: 'com.example.notes', name: 'Notes again', + publisher: publisherKey.did, defines: [], requests: [], }), @@ -524,3 +536,62 @@ describe('the _app card an install registers', () => { expect(card.createdBy).toBeUndefined(); }); }); + +describe('the publisher', () => { + test('a manifest its publisher did not sign is refused', async () => { + const stranger = await generateDidKeypair(); + await expect(planSigned(manifest(), { did: APP_DID }, stranger)).rejects.toThrow( + StackValidationError, + ); + + const signed = await signManifest(manifest(), publisherKey.privateKey); + const tampered = { ...signed, manifest: { ...signed.manifest, requests: MIGRATING } }; + await expect(stack.planInstall(tampered, { did: APP_DID })).rejects.toThrow( + StackValidationError, + ); + }); + + test('is pinned: no other publisher can upgrade the install or link a key to it', async () => { + const record = await install(manifest()); + const impostor = await generateDidKeypair(); + const theirs = manifest({ publisher: impostor.did, requests: MIGRATING }); + for (const did of [APP_DID, OTHER_DID]) { + await expect(planSigned(theirs, { did }, impostor)).rejects.toThrow(StackConflictError); + } + await expect(stack.patchContent(record.id, { publisher: impostor.did })).rejects.toThrow( + StackValidationError, + ); + }); + + test('a did:web publisher needs a verifier, and vouches for the appId its domain reverses', async () => { + const web = manifest({ publisher: 'did:web:notes.example.com' }); + const signed = await signManifest(web, publisherKey.privateKey); + await expect(stack.planInstall(signed, { did: APP_DID })).rejects.toThrow(StackValidationError); + + const verifyPublisher = async () => true; + const plan = await stack.planInstall(signed, { did: APP_DID, verifyPublisher }); + expect(plan.namespaceVerified).toBe(true); + expect((await stack.installApp(plan, { verifyPublisher })).content.publisher).toBe( + 'did:web:notes.example.com', + ); + + const elsewhere = await signManifest( + manifest({ appId: 'com.example.tags', types: [], publisher: 'did:web:notes.example.com' }), + publisherKey.privateKey, + ); + expect( + (await stack.planInstall(elsewhere, { did: OTHER_DID, verifyPublisher })).namespaceVerified, + ).toBe(false); + expect( + (await planSigned(manifest({ appId: 'com.example.keyed', types: [] }), { did: PERSON })) + .namespaceVerified, + ).toBe(false); + }); + + test('appIdVouchedBy() reverses a bare did:web host and nothing else', () => { + expect(appIdVouchedBy('did:web:notes.example.com')).toBe('com.example.notes'); + expect(appIdVouchedBy('did:web:example.com:apps:notes')).toBeNull(); + expect(appIdVouchedBy('did:web:example.com%3A8443')).toBeNull(); + expect(appIdVouchedBy(publisherKey.did)).toBeNull(); + }); +}); diff --git a/packages/core/tests/wire-body.test.ts b/packages/core/tests/wire-body.test.ts index acee25b4..406e495b 100644 --- a/packages/core/tests/wire-body.test.ts +++ b/packages/core/tests/wire-body.test.ts @@ -214,31 +214,37 @@ describe('parseInstallBody', () => { appId: 'com.example.notes', name: 'Notes', version: '1.0.0', + publisher: DID, types: [{ id: 'com.example.notes/note@1', name: 'Note', schema: { text: { kind: 'text' } } }], requests: [{ baseId: 'com.example.notes/note', actions: ['create', 'read-any'] }], }; + const signature = 'c2lnbmF0dXJl'; - test('reads a manifest into what planInstall() takes', () => { - expect(parseInstallBody({ manifest })).toEqual(manifest); + test('reads a signed manifest into what planInstall() takes', () => { + expect(parseInstallBody({ manifest, signature })).toEqual({ manifest, signature }); }); test('an unknown key at either level, or a missing field, is not this request', () => { - expect(() => parseInstallBody({ manifest, did: DID })).toThrow(StackBadRequestError); - expect(() => parseInstallBody({ manifest: { ...manifest, did: DID } })).toThrow( + expect(() => parseInstallBody({ manifest, signature, did: DID })).toThrow(StackBadRequestError); + expect(() => parseInstallBody({ manifest: { ...manifest, did: DID }, signature })).toThrow( + StackBadRequestError, + ); + expect(() => parseInstallBody({ manifest })).toThrow(StackBadRequestError); + const { publisher: _, ...unpublished } = manifest; + expect(() => parseInstallBody({ manifest: unpublished, signature })).toThrow( StackBadRequestError, ); - const { requests: _, ...noRequests } = manifest; - expect(() => parseInstallBody({ manifest: noRequests })).toThrow(StackBadRequestError); }); test('a wrongly typed field names its path', () => { - expect(pathOf(() => parseInstallBody({ manifest: { ...manifest, types: {} } }))).toBe( - 'manifest.types', - ); + expect( + pathOf(() => parseInstallBody({ manifest: { ...manifest, types: {} }, signature })), + ).toBe('manifest.types'); expect( pathOf(() => parseInstallBody({ manifest: { ...manifest, requests: [{ baseId: 'com.example.notes/note', actions: [1] }] }, + signature, }), ), ).toBe('manifest.requests[0].actions[0]'); diff --git a/packages/wire-types/src/index.ts b/packages/wire-types/src/index.ts index 87590488..71d3721c 100644 --- a/packages/wire-types/src/index.ts +++ b/packages/wire-types/src/index.ts @@ -13,7 +13,7 @@ import { StackTimeoutError, } from '@haverstack/core'; import type { - AppManifest, + SignedManifest, NativeSortField, StackRecord, StackType, @@ -806,8 +806,11 @@ export function supportsInstallRequests(discovery: DiscoveryResponse): boolean { return discovery.installs?.requests === true; } -/** POST /installs. The key being installed is the session's, never named here. */ -export type WireInstallRequest = { manifest: AppManifest }; +/** + * POST /installs: a manifest and its publisher's signature. The key being + * installed is the session's, never named here. + */ +export type WireInstallRequest = SignedManifest; /** * POST /installs answers `pending` (202) while the owner has not approved From e1232a79b3f153fb9ea58343fa4870434134898e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 22:30:46 +0000 Subject: [PATCH 07/11] feat(core): certified app keys, forward-only releases, and publisher rotation after uninstall A signed manifest proves who published it but not which key presents it: manifests ship with their apps, so any key could copy one and join an install as the same app. - A publisher may certify a key with certifyKey() over keyCertificatePayload({ appId, did }). The app sends it as `keyCertificate` on POST /installs; the plan reports `keyCertified`. It is optional, since an app on users' devices can only get one from a publisher service whose issuing decides what it proves. A certificate that is presented and wrong is refused, never read as absent. - Manifests carry a positive integer `release`, stored on the install. planInstall() refuses an older one, so a signed manifest can't be replayed over a newer install. - The publisher pin holds while the install is live. After uninstalling, a new publisher may take the install up (`publisherChanged`), which unlinks the old publisher's keys. `publisher` is no longer a binding, so the install can be patched to the new one. Fixtures are re-signed under a new fixture publisher key, and gain certified, misdirected-certificate and older-release cases. Refs #359 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qy4cJmqNvyGei7gFMz9Nx9 --- .changeset/app-install-publishers.md | 2 +- docs/spec/apps.md | 36 ++++-- docs/spec/wire-format.md | 14 +- packages/adapter-api/src/index.ts | 11 +- .../adapter-api/tests/conformance.test.ts | 17 +++ packages/conformance-fixtures/src/index.ts | 87 ++++++++++++- packages/core/src/identity-bindings.ts | 7 +- packages/core/src/index.ts | 10 +- packages/core/src/install.ts | 121 +++++++++++++++--- packages/core/src/stack.ts | 68 ++++++++-- packages/core/src/types.ts | 6 +- packages/core/src/wire-body.ts | 31 ++++- packages/core/tests/install.test.ts | 77 ++++++++++- packages/core/tests/wire-body.test.ts | 14 ++ packages/wire-types/src/index.ts | 9 +- 15 files changed, 431 insertions(+), 79 deletions(-) diff --git a/.changeset/app-install-publishers.md b/.changeset/app-install-publishers.md index 50046907..2d84a140 100644 --- a/.changeset/app-install-publishers.md +++ b/.changeset/app-install-publishers.md @@ -5,4 +5,4 @@ '@haverstack/adapter-api': minor --- -App manifests name a `publisher` DID and travel signed: `planInstall()`, `installApp()`, `POST /installs` and `APIAdapter.requestInstall()` take `{ manifest, signature }`, made with `signManifest()` over `manifestPayload()`. A `did:key` publisher verifies with no lookup; any other method needs a `verifyPublisher` callback. The first install pins its publisher on `_install.publisher`, so a manifest under the same `appId` signed by anyone else is refused, whichever key presents it. A plan's `namespaceVerified` is true when a `did:web` publisher's domain, reversed, is the `appId`. +App manifests name a `publisher` DID and travel signed: `planInstall()`, `installApp()`, `POST /installs` and `APIAdapter.requestInstall()` take `{ manifest, signature }`, made with `signManifest()` over `manifestPayload()`. A `did:key` publisher verifies with no lookup; any other method needs a `verifyPublisher` callback. A live install pins its publisher on `_install.publisher`, so a manifest under the same `appId` signed by anyone else is refused, whichever key presents it; once uninstalled, a new publisher may take it up, unlinking the old keys. Manifests carry a `release` that may not go backwards. A publisher may certify each key with `certifyKey()`; the app sends the result as `keyCertificate`, and the plan reports `keyCertified`. A plan's `namespaceVerified` is true when a `did:web` publisher's domain, reversed, is the `appId`. diff --git a/docs/spec/apps.md b/docs/spec/apps.md index 44866eaa..b77c87ca 100644 --- a/docs/spec/apps.md +++ b/docs/spec/apps.md @@ -10,12 +10,14 @@ type AppManifest = { name: string; version?: string; publisher: string; // the DID that signs the manifest — see Who publishes an app + release: number; // a positive integer the publisher raises with every manifest it signs types: DefineTypeOptions[]; // the types the app defines requests: InstallRequest[]; // the grants its keys hold }; type InstallRequest = { baseId: BaseId; actions: GrantAction[] }; type SignedManifest = { manifest: AppManifest; signature: string }; // base64url Ed25519 +type InstallSubmission = SignedManifest & { keyCertificate?: string }; // see Certified keys const signed = await signManifest(manifest, publisherPrivateKey); // the app author, once per release const plan = await stack.planInstall(signed, { did: appDid }); @@ -34,7 +36,8 @@ type InstallContent = { appId: AppId; // a binding: immutable, unique among installs name: string; version?: string; - publisher: string; // a binding: immutable once set + publisher: string; // pinned while the install is live + release: number; // the approved manifest's release defines: TypeId[]; // every version the owner has approved requests: InstallRequest[]; // the grants each of the app's keys holds }; @@ -45,7 +48,7 @@ type InstallContent = { - **It cannot be granted.** `grantType()` refuses `_install` beside `_grant`, `_config` and `_app` (see [Access control § What a grant covers](./access-control.md#what-a-grant-covers)), and a `request` naming any of the four is refused at the write. - **Only the owner acting alone writes one.** `ScopedStack` refuses every write to an `_install` Record on the same terms as a `_grant` Record, whatever the Record's own `permissions` say. - **`appId` is a binding**, immutable and unique on the terms [Identity § DID bindings](./identity.md#did-bindings) sets out: one install answers for each app, and an existing install cannot be relabelled to answer for another. -- **`publisher` is a binding too**, immutable but not unique — one publisher may ship many apps. It is what [pins the install](#who-publishes-an-app) to whoever signed its first manifest. +- **`publisher` is pinned while the install is live**: `planInstall()` refuses a manifest from any other publisher until the owner uninstalls (see [Who publishes an app](#who-publishes-an-app)). It is not unique — one publisher may ship many apps. - **It claims only its own families.** Every `defines` entry must be a versioned TypeId in the install's own namespace (see [Who owns a family](#who-owns-a-family)); anything else is refused with `StackValidationError` on create, patch, migration and restore alike. `appId` being unique is what makes each family's owner single. Every `request` must name a family and actions from the grant vocabulary; `StackValidationError` otherwise. @@ -70,7 +73,11 @@ An `appId` is a claim the manifest makes, and [Who publishes an app](#who-publis Every manifest names a **publisher** — a DID belonging to the app's author, distinct from the per-device keys an install links — and carries the publisher's signature over `manifestPayload(manifest)`: the label `haverstack-manifest-v1`, a newline, and the manifest as canonical JSON (keys sorted at every depth, no whitespace), so the same manifest signs the same way however it was serialized. `signManifest(manifest, privateKey)` produces one. `planInstall()` refuses a manifest whose signature does not verify with `StackValidationError` at `signature`, before it looks at anything else the stack holds. -**The first install pins its publisher.** `_install.publisher` is a binding, so once a stack has installed `com.example.notes` from one publisher, a manifest under that `appId` signed by anyone else is refused with `StackConflictError` — whichever key presents it, so another key cannot join the install as the same app, and no one else can upgrade it. A publisher that loses its key is in the position [key rotation](./identity.md#deferred-key-rotation) describes: a new key is a new identity, and the owner uninstalls and reinstalls to accept it. +**A live install is pinned to its publisher.** Once a stack has installed `com.example.notes` from one publisher, a manifest under that `appId` signed by anyone else is refused with `StackConflictError`, whichever key presents it, so no one else can upgrade the install. A publisher that loses its key is in the position [key rotation](./identity.md#deferred-key-rotation) describes: a new key is a new identity. The owner accepts it by uninstalling, after which a manifest from the new publisher may take the install up. The plan says so with `publisherChanged`, and applying it unlinks every key the old publisher's app had linked — each must be installed again — while `defines` and the install's version history carry over. + +**Releases only go forward.** A signature never expires, so `release` is what keeps an older manifest from being replayed over a newer install: `planInstall()` refuses a manifest whose `release` is below the installed one with `StackConflictError`. An equal release is accepted, which is how a second key installs the manifest the first already has. The check does not cross a publisher change, since a new publisher numbers its own releases. + +**The pin is on the publisher, not on the key presenting the manifest.** A signed manifest ships with its app, so any key can copy one and present it as its own. That is what [Certified keys](#certified-keys) are for. **How strongly a publisher is known depends on its DID method**, following the [method table](./identity.md): @@ -79,15 +86,26 @@ Every manifest names a **publisher** — a DID belonging to the app's author, di Nothing requires a domain. An app with only a key gets pinning; an app that wants its namespace proven publishes from `did:web`. +## Certified keys + +A publisher may vouch for a key as one of its app's by signing `keyCertificatePayload({ appId, did })` — the label `haverstack-app-key-v1`, a newline, and `{ appId, did }` as canonical JSON — with `certifyKey()`. The app presents the result as `keyCertificate` beside its signed manifest. `planInstall()` verifies it as it verifies the manifest's signature, and the plan's `keyCertified` says whether the key came with one. + +**It is optional.** An uncertified key may still be approved; the owner is then the only check on whether it is really the app. A certificate that is presented and does not verify for this `appId` and key is refused with `StackValidationError` at `keyCertificate` rather than treated as absent: it is a forgery or a copy. + +**It proves what the publisher's issuing does.** For an app the publisher runs on its own servers, the publisher certifies the keys it holds, and a certified key is the app. For an app running on its users' devices, the publisher's private key cannot ship with it, so a device asks the publisher for a certificate — and the certificate then means only what the publisher checked before issuing it: a platform attestation, an account login, or nothing. The owner sees that a publisher vouches for the key; how much that is worth is the publisher's to earn. + ## Plan, then apply -`planInstall(signed, { did, verifyPublisher? })` writes nothing. It reports what applying the manifest for that key would change: +`planInstall(submission, { did, verifyPublisher? })` writes nothing. It reports what applying the manifest for that key would change: ```ts type InstallPlan = { manifest: AppManifest; did: EntityId; signature: string; // carried so installApp() verifies it again + keyCertificate?: string; // likewise, when one was presented + keyCertified: boolean; // the publisher certified `did` — see Certified keys + publisherChanged: boolean; // an uninstalled install taken up by a new publisher namespaceVerified: boolean; // a did:web publisher whose domain is the appId reversed existing: (StackRecord & { content: InstallContent }) | null; newFamilies: BaseId[]; // families the install would claim for the first time @@ -103,17 +121,17 @@ type InstallPlan = { `typeChanges` lists every manifest type whose definition would write — not yet defined, or defined with a different schema or name — commons types included. Defining a commons type claims nothing, but the first definition of a version fixes its shape for every app that reads it, so the owner sees it like any other. -`linkedKeys` matters most beside `newKey`: a new key on an existing install joins those keys as the same app (see [Who owns a family](#who-owns-a-family)), and `requestsAdded` and `requestsRemoved` apply to all of them. +`linkedKeys` matters most beside `newKey`: a new key on an existing install joins those keys as the same app, and `requestsAdded` and `requestsRemoved` apply to all of them. `keyCertified` is what says whether the publisher vouches for that key (see [Certified keys](#certified-keys)). `foreignRequests` are the requests on families outside the app's own namespace, each naming the family's [owner](#who-owns-a-family): another app's `appId`, `'commons'`, `'system'`, or `null` for a family with no namespace. They are the requests an approval most needs to show — an app asking to read another app's records, or `_entity`, is asking for reach beyond its own data. -It refuses what no approval could make valid: a type outside the app's own namespace and the commons (`StackValidationError` — use a request instead), a request [`grantType()` would refuse](./access-control.md#type-level-grants) (`StackValidationError`), a manifest its publisher did not sign (`StackValidationError`), one under an `appId` already installed from another publisher, and a `did` whose `_app` card names a different `appId` (both `StackConflictError`). +It refuses what no approval could make valid: a type outside the app's own namespace and the commons (`StackValidationError` — use a request instead), a request [`grantType()` would refuse](./access-control.md#type-level-grants) (`StackValidationError`), a manifest its publisher did not sign or a key certificate that does not verify (`StackValidationError`), and a manifest under an `appId` live from another publisher, one older than the installed release, or a `did` whose `_app` card names a different `appId` (`StackConflictError`). `installApp(plan, { verifyPublisher? })` applies the plan: 1. Defines each of the manifest's types, with [schema drift](./data-model.md#schema-drift-detection) applying as it does to any `defineType()`. 2. Registers the key on an `_app` card when it has none, undeleting a soft-deleted one. An existing card's `name` is left alone: it is the owner's label. -3. Creates the install, or patches it — undeleting it first if it was uninstalled. `defines` gains the manifest's versions and never loses any, since a Type once defined stays defined; `requests` becomes the manifest's. +3. Creates the install, or patches it — undeleting it first if it was uninstalled, and unlinking the old keys if its publisher changed. `defines` gains the manifest's versions and never loses any, since a Type once defined stays defined; `requests` becomes the manifest's. 4. Brings the grants of **every** key linked to the install to exactly `requests`: a grant no longer requested is revoked, a missing one is written, and the links follow. **Nothing is applied that was not approved.** `installApp()` plans the same manifest again and refuses with `StackConflictError` when the result differs from the plan it was handed — the install changing, a type being defined, or a key being linked, since it was planned. The remedy is to plan again and show the owner the new plan. A plan holds a frozen copy of the manifest it was made from, so changing the caller's object afterwards changes nothing that is applied. Re-applying a manifest whose plan is empty changes nothing. @@ -152,12 +170,12 @@ Installing the same `appId` again undeletes the install and grants its requests Install has two halves, and only one of them is the same on every server. **The app's half** — present a manifest, learn whether it was approved — is pinned by [`POST /installs`](./wire-format.md#installs), so an app installs itself the same way on any stack. **The owner's half** — review the plan, approve it — is a person deciding, through whatever the server offers (an admin page, a notification); it is not on the wire, and underneath it is `planInstall()` then `installApp()`. ```ts -const result = await adapter.requestInstall(signed); +const result = await adapter.requestInstall({ ...signed, keyCertificate }); // keyCertificate optional // { status: 'pending' } until the owner approves this manifest for this key, // then { status: 'installed', install } once applying it would change nothing ``` -**The key is the session's; the publisher is the signature's.** The request names no installing DID: the handshake already proved which key is asking, so no app can ask for an install on behalf of a key it does not hold. The manifest's signature proves who published it, so no key can present a manifest its publisher did not sign. +**The key is the session's; the publisher is the signature's.** The request names no installing DID: the handshake already proved which key is asking, so no app can ask for an install on behalf of a key it does not hold. The manifest's signature proves who published it, so no key can present a manifest its publisher did not sign; a key certificate, when present, proves the publisher vouches for the key presenting it. **A request is not an approval.** A pending request lives with the server, not in the stack, so a key that merely authenticated writes nothing into the owner's data. The stack holds only what the owner approved. diff --git a/docs/spec/wire-format.md b/docs/spec/wire-format.md index 4aa055bc..c0cb12d6 100644 --- a/docs/spec/wire-format.md +++ b/docs/spec/wire-format.md @@ -707,18 +707,18 @@ POST /installs — present an app's manifest for the owner to approve How an app holding its own key asks to be installed (see [App installs § Over the wire](./apps.md#over-the-wire)). Only the asking is specified here; the owner approves through whatever the server offers, with `Stack.planInstall()` and `installApp()`. -**The body is `{ "manifest": { … }, "signature": "…" }`**, a signed `AppManifest`: `appId`, `name`, optional `version`, `publisher`, `types` (each read as a [`POST /types`](#types) body is) and `requests` (each `{ baseId, actions }`), with the publisher's base64url signature over it (see [App installs § Who publishes an app](./apps.md#who-publishes-an-app)). `parseInstallBody()` from `@haverstack/core/wire` reads it, refusing an unknown key at either level with **400** like any other [unrecognized input](#unrecognized-input). **The key being installed is never in the body**: it is the session's principal. +**The body is `{ "manifest": { … }, "signature": "…", "keyCertificate"?: "…" }`**, a signed `AppManifest`: `appId`, `name`, optional `version`, `publisher`, `release` (a positive integer), `types` (each read as a [`POST /types`](#types) body is) and `requests` (each `{ baseId, actions }`), with the publisher's base64url signature over it (see [App installs § Who publishes an app](./apps.md#who-publishes-an-app)), and optionally the publisher's base64url certificate for the session's key (see [App installs § Certified keys](./apps.md#certified-keys)). `parseInstallBody()` from `@haverstack/core/wire` reads it, refusing an unknown key at either level with **400** like any other [unrecognized input](#unrecognized-input). **The key being installed is never in the body**: it is the session's principal. **The request must come from the key acting as itself.** A delegated session names someone else as the subject, and an install is for the key that authenticated, so it answers **403** (code `permission`). The server plans the manifest for the session's key and answers with the result: -| Status | Body | When | -| ------ | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `202` | `{ "status": "pending" }` | Applying the manifest would change something. The server queues it for the owner and writes nothing to the stack. | -| `200` | `{ "status": "installed", "install": … }` | The plan is empty — `isPlanEmpty()` from `@haverstack/core` decides — and `install` is the `_install` record, which the key can read. | -| `422` | `validation` | The signature is not the publisher's over this manifest, the manifest defines a type outside the app's [own namespace](./apps.md#who-owns-a-family) and the commons, or a request breaks the grant rules. | -| `409` | `conflict` | The `appId` is installed from another publisher, or the key's `_app` card names a different `appId`. | +| Status | Body | When | +| ------ | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `202` | `{ "status": "pending" }` | Applying the manifest would change something. The server queues it for the owner and writes nothing to the stack. | +| `200` | `{ "status": "installed", "install": … }` | The plan is empty — `isPlanEmpty()` from `@haverstack/core` decides — and `install` is the `_install` record, which the key can read. | +| `422` | `validation` | The signature is not the publisher's over this manifest, a `keyCertificate` is not the publisher's for this key, the manifest defines a type outside the app's [own namespace](./apps.md#who-owns-a-family) and the commons, or a request breaks the grant rules. | +| `409` | `conflict` | The `appId` is live from another publisher, the manifest's `release` is older than the installed one, or the key's `_app` card names a different `appId`. | Re-sending the same manifest is how an app checks: it answers `202` until the owner approves and `200` after. A manifest that changes anything an approved one said — a new version, a changed request — is `202` again, so an upgrade takes the same path as a first install. A client refuses `requestInstall()` locally when discovery does not advertise `installs`, rather than learning it as a `404`. diff --git a/packages/adapter-api/src/index.ts b/packages/adapter-api/src/index.ts index 92fd8bc7..985a8073 100644 --- a/packages/adapter-api/src/index.ts +++ b/packages/adapter-api/src/index.ts @@ -50,7 +50,7 @@ import type { RecordChangeSet, StackCapabilities, MissingCapability, - SignedManifest, + InstallSubmission, InstallContent, } from '@haverstack/core'; import type { StackAdapter, SubscribeChangesOptions } from '@haverstack/core/adapter'; @@ -1101,7 +1101,7 @@ export class APIAdapter implements StackAdapter { * applying it would change nothing. Refused locally when the server does * not advertise install requests. See docs/spec/wire-format.md § Installs. */ - async requestInstall(signed: SignedManifest): Promise { + async requestInstall(submission: InstallSubmission): Promise { if (!this.installRequests) { throw new APIAdapterCapabilityError( 'installs', @@ -1110,8 +1110,11 @@ export class APIAdapter implements StackAdapter { ); } const raw = await this.request('POST', '/installs', { - manifest: signed.manifest, - signature: signed.signature, + manifest: submission.manifest, + signature: submission.signature, + ...(submission.keyCertificate !== undefined && { + keyCertificate: submission.keyCertificate, + }), }); if (raw?.status === 'pending') return { status: 'pending' }; if (raw?.status === 'installed' && raw.install) { diff --git a/packages/adapter-api/tests/conformance.test.ts b/packages/adapter-api/tests/conformance.test.ts index a594effa..7dc30c39 100644 --- a/packages/adapter-api/tests/conformance.test.ts +++ b/packages/adapter-api/tests/conformance.test.ts @@ -39,6 +39,7 @@ import { getJournalFixtures, commitMigrationFixtures, installRequestFixtures, + INSTALL_FIXTURE_KEY, INSTALL_FIXTURE_PUBLISHER, discoveryFixtures, errorResponseFixtures, @@ -67,6 +68,7 @@ import { StackPayloadTooLargeError, StackTimeoutError, manifestPayload, + keyCertificatePayload, } from '@haverstack/core'; import { buildAuthChallengePayload, @@ -777,6 +779,21 @@ describe('install request fixtures', () => { } }); + test('every fixture key certificate but the misdirected one is the publisher’s', async () => { + for (const fixture of installRequestFixtures) { + const { manifest, keyCertificate } = fixture.requestBody!; + if (keyCertificate === undefined) continue; + const valid = await verifyDidSignature( + manifest.publisher, + base64urlDecode(keyCertificate), + keyCertificatePayload({ appId: manifest.appId, did: INSTALL_FIXTURE_KEY }), + ); + expect(valid, fixture.name).toBe( + fixture.name !== 'install-request-certificate-not-for-this-key', + ); + } + }); + test('a server advertising no install requests is refused locally', async () => { const adapter = await openAdapter(); const calls = mockFetch.mock.calls.length; diff --git a/packages/conformance-fixtures/src/index.ts b/packages/conformance-fixtures/src/index.ts index 87834558..26d97498 100644 --- a/packages/conformance-fixtures/src/index.ts +++ b/packages/conformance-fixtures/src/index.ts @@ -2169,17 +2169,22 @@ export const commitMigrationFixtures: ConformanceFixture< // installed is always the session's — the body never names one. // // Every manifest carries a real Ed25519 signature by INSTALL_FIXTURE_PUBLISHER -// over manifestPayload() from @haverstack/core, so a server can verify one -// rather than trusting its own derivation of the signed bytes. +// over manifestPayload() from @haverstack/core, and every key certificate +// one over keyCertificatePayload(), so a server can verify one rather than +// trusting its own derivation of the signed bytes. /** The publisher whose key signed the install fixtures' manifests. */ -export const INSTALL_FIXTURE_PUBLISHER = 'did:key:z6Mkj5oEgHSkFTYqW9dZhhLxZdPSX6NSMxfbg9QYAERTALgf'; +export const INSTALL_FIXTURE_PUBLISHER = 'did:key:z6Mkf2zQmvB1cfYsgtiWAJu9F9axVsp95LFGw8TVhkf6BpBw'; + +/** The session key the certified fixtures are certified for. */ +export const INSTALL_FIXTURE_KEY = 'did:key:z6Mkfsz9oK6i2355mvEwtDYdAmqCN6kmQETThJtARfj9iGum'; const INSTALL_MANIFEST: WireInstallRequest['manifest'] = { appId: 'com.example.notes', name: 'Notes', version: '1.0.0', publisher: INSTALL_FIXTURE_PUBLISHER, + release: 1, types: [{ id: 'com.example.notes/note@1', name: 'Note', schema: { text: { kind: 'text' } } }], requests: [{ baseId: 'com.example.notes/note', actions: ['create', 'read-any'] }], }; @@ -2187,7 +2192,13 @@ const INSTALL_MANIFEST: WireInstallRequest['manifest'] = { const SIGNED_INSTALL: WireInstallRequest = { manifest: INSTALL_MANIFEST, signature: - 'xQ7hCjIXyXJ9eNpadlOT2HPQzgnAUn-bxDuMQ2PQoVihasedwgmqw4VissRUgf8PhZ0gdHVBogL4fRLp14vTBA', + '9YCWMoIrzmJQUcQCO30ijsrYtAg4Nu6fxCF0k-qFHU2p-dHgvtOEeeP1yeNcT-8qByvLO7-Qe7hMZEr3q0_zBA', +}; + +const CERTIFIED_INSTALL: WireInstallRequest = { + ...SIGNED_INSTALL, + keyCertificate: + '0DUpaK8BYrGUWLQYQCmNrMEo81zsdNHV913q_Oa5L-FV2yoHBQEQdExRDE93pFF3rCr3Z1QHBQKReuV6ZSXtDA', }; export const installRequestFixtures: ConformanceFixture< @@ -2208,6 +2219,67 @@ export const installRequestFixtures: ConformanceFixture< responseStatus: 202, responseBody: { status: 'pending' }, }, + { + name: 'install-request-certified-key-pending', + description: + "POST /installs carrying keyCertificate — the publisher's signature over " + + "keyCertificatePayload({ appId, did }) for the session's key — answers 202 pending like " + + 'any other request; the certificate lets the owner see the publisher vouches for this ' + + `key. Assumes the session's DID is ${INSTALL_FIXTURE_KEY}. See docs/spec/apps.md ` + + '§ Certified keys.', + method: 'POST', + path: '/installs', + requestBody: CERTIFIED_INSTALL, + responseStatus: 202, + responseBody: { status: 'pending' }, + }, + { + name: 'install-request-certificate-not-for-this-key', + description: + "POST /installs whose keyCertificate does not verify for the session's key — here one " + + 'the publisher issued to a different key — answers 422 with code "validation" at ' + + '"keyCertificate". A certificate that is presented and wrong is refused, never treated ' + + "as an uncertified key: it is a forgery or a copied certificate. Assumes the session's " + + `DID is ${INSTALL_FIXTURE_KEY}.`, + method: 'POST', + path: '/installs', + requestBody: { + ...SIGNED_INSTALL, + keyCertificate: + 'Zqqu77XqEmDFiKDOKbHgESPoPivm7BAHq8CrS98qK53YruMbkEuVoRkwocNzt_v7sta-cLYjYi1Wzj7xxR2sDA', + }, + responseStatus: 422, + responseBody: { + error: { + code: 'validation', + message: 'Content validation failed', + details: [ + { + path: 'keyCertificate', + message: 'The signature is not the publisher’s over this key', + }, + ], + }, + }, + }, + { + name: 'install-request-older-release', + description: + 'POST /installs with a manifest whose release is lower than the installed one answers ' + + '409 with code "conflict". A signature never expires, so the release is what keeps an ' + + 'older signed manifest from being replayed over a newer install. Assumes ' + + '"com.example.notes" is installed from the same publisher at release 2.', + method: 'POST', + path: '/installs', + requestBody: SIGNED_INSTALL, + responseStatus: 409, + responseBody: { + error: { + code: 'conflict', + message: '"com.example.notes" is installed at release 2; this manifest is release 1', + }, + }, + }, { name: 'install-request-already-installed', description: @@ -2232,6 +2304,7 @@ export const installRequestFixtures: ConformanceFixture< name: 'Notes', version: '1.0.0', publisher: INSTALL_FIXTURE_PUBLISHER, + release: 1, defines: ['com.example.notes/note@1'], requests: [{ baseId: 'com.example.notes/note', actions: ['create', 'read-any'] }], }, @@ -2254,7 +2327,7 @@ export const installRequestFixtures: ConformanceFixture< types: [{ id: 'com.example.tags/tag@1', name: 'Tag', schema: {} }], }, signature: - 'RtkahKa-mw9f9DoQCNHJOKu-_W-5PyFVoW06oBJLVFC9PlSgc6qE_dz82C7vmUSK3bHcUgVLjAKrBOEdRS-DCw', + 'BgnK_89lwtE_kwWEVDibw_UEug8Z0DCwzenqNZorq0RDDnKWzPISdzWyji2HOknIFWTUokw5SjfkDZKJf-MHDA', }, responseStatus: 422, responseBody: { @@ -2307,8 +2380,8 @@ export const installRequestFixtures: ConformanceFixture< name: 'install-request-publisher-pinned', description: 'POST /installs for an appId already installed from another publisher answers 409 with ' + - 'code "conflict", whichever key asks. The first install pins its publisher, so only a ' + - 'manifest the same publisher signed can upgrade it or link another key to it. Assumes ' + + 'code "conflict", whichever key asks. A live install is pinned to its publisher, so only ' + + 'a manifest the same publisher signed can upgrade it or link another key to it. Assumes ' + '"com.example.notes" is installed from did:web:notes.example.com.', method: 'POST', path: '/installs', diff --git a/packages/core/src/identity-bindings.ts b/packages/core/src/identity-bindings.ts index 687eead2..a45b8bc9 100644 --- a/packages/core/src/identity-bindings.ts +++ b/packages/core/src/identity-bindings.ts @@ -16,14 +16,13 @@ import { SYSTEM_TYPES } from './types.js'; * claims one, and something later resolves through it. Every one of them is * immutable once set. See docs/spec/identity.md § DID bindings. */ -/** A content field something resolves through or pins. */ -export type BindingField = 'did' | 'appId' | 'publisher'; +/** A content field something resolves through. */ +export type BindingField = 'did' | 'appId'; const BINDING_FIELDS: ReadonlyMap = new Map([ [SYSTEM_TYPES.APP, ['did', 'appId'] as const], [SYSTEM_TYPES.ENTITY, ['did'] as const], - // `publisher` pins who may upgrade the install; see docs/spec/apps.md § Who publishes an app. - [SYSTEM_TYPES.INSTALL, ['appId', 'publisher'] as const], + [SYSTEM_TYPES.INSTALL, ['appId'] as const], ]); /** diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b8c2a1cf..6da74cba 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -41,9 +41,17 @@ export type { ForeignRequest, TypeChange, SignedManifest, + InstallSubmission, PublisherVerifier, } from './install.js'; -export { isPlanEmpty, signManifest, manifestPayload, appIdVouchedBy } from './install.js'; +export { + isPlanEmpty, + signManifest, + manifestPayload, + appIdVouchedBy, + certifyKey, + keyCertificatePayload, +} from './install.js'; // Type handles export { typeHandle } from './type-handle.js'; diff --git a/packages/core/src/install.ts b/packages/core/src/install.ts index 5498c015..dec9b89a 100644 --- a/packages/core/src/install.ts +++ b/packages/core/src/install.ts @@ -14,12 +14,13 @@ * families without the owner running its code — see * ScopedStack.commitMigration(). * - * A manifest is signed by its publisher's key, and the install pins that - * publisher: only a manifest the same publisher signed can upgrade it, or - * link another key to it. + * A manifest is signed by its publisher's key and numbered by release. + * A live install pins that publisher and refuses an older release, so only + * a newer manifest the same publisher signed can upgrade it. A publisher + * may also certify each key of its app, which the plan reports. * * This module holds the parts that read an install as data: the write-time - * shape rules, the family claim, manifest signing, and the plan diff. The verbs that write + * shape rules, the family claim, manifest and key signing, and the plan diff. The verbs that write * live on `Stack`. See docs/spec/apps.md. */ @@ -57,6 +58,12 @@ export type AppManifest = { * § Who publishes an app. */ publisher: string; + /** + * A positive integer the publisher raises with every manifest it signs, + * so an older one cannot be replayed over a newer install. + * See docs/spec/apps.md § Who publishes an app. + */ + release: number; types: DefineTypeOptions[]; requests: InstallRequest[]; }; @@ -68,6 +75,16 @@ export type SignedManifest = { signature: string; }; +/** + * What an install request carries: the signed manifest and, optionally, + * the publisher's certificate for the key being installed. + * See docs/spec/apps.md § Certified keys. + */ +export type InstallSubmission = SignedManifest & { + /** base64url Ed25519 signature by the publisher over keyCertificatePayload(). */ + keyCertificate?: string; +}; + /** * Verifies a signature by a publisher whose DID method core does not * resolve — `did:web`, say. `did:key` needs none: its public key is the DID. @@ -109,6 +126,18 @@ export type InstallPlan = { did: EntityId; /** The publisher's signature over `manifest`, carried so the plan can be verified again. */ signature: string; + /** The publisher's certificate for `did`, when one was presented. */ + keyCertificate?: string; + /** + * Whether the publisher certified `did` as a key of this app. An + * uncertified key may still be approved; the owner is then the only check. + */ + keyCertified: boolean; + /** + * Whether an uninstalled install is being taken up by a different + * publisher. Its keys are unlinked, so each must be installed again. + */ + publisherChanged: boolean; /** * Whether the publisher's DID is a `did:web` whose domain is the * `appId` reversed — the domain vouching for the namespace, beyond the @@ -324,28 +353,30 @@ export async function signManifest( } /** - * Refuse a manifest its publisher did not sign. A `did:key` publisher is - * verified from the DID itself; any other method needs `verifier`, and is - * refused without one rather than taken on trust. + * Whether `publisher` signed `payload`. A `did:key` publisher is verified + * from the DID itself; any other method needs `verifier`, and is refused + * without one rather than taken on trust. */ -export async function assertManifestSigned( - signed: SignedManifest, +async function assertPublisherSigned( + publisher: string, + encoded: string, + payload: Uint8Array, + path: string, + what: string, verifier?: PublisherVerifier, ): Promise { - const { publisher } = signed.manifest; - const fail = (path: string, message: string): never => { - throw new StackValidationError([{ path, message }]); + const fail = (at: string, message: string): never => { + throw new StackValidationError([{ path: at, message }]); }; if (typeof publisher !== 'string' || !publisher.startsWith('did:')) { fail('manifest.publisher', 'Expected the publisher’s DID'); } let signature: Uint8Array; try { - signature = base64urlDecode(signed.signature); + signature = base64urlDecode(encoded); } catch { - return fail('signature', 'Expected a base64url signature'); + return fail(path, 'Expected a base64url signature'); } - const payload = manifestPayload(signed.manifest); let valid: boolean; if (isValidDidKey(publisher)) { valid = await verifyDidSignature(publisher, signature, payload).catch(() => false); @@ -357,7 +388,63 @@ export async function assertManifestSigned( `Cannot verify a ${publisher.split(':')[1]} publisher without a verifier for that DID method`, ); } - if (!valid) fail('signature', 'The signature is not the publisher’s over this manifest'); + if (!valid) fail(path, `The signature is not the publisher’s over this ${what}`); +} + +/** Refuse a manifest its publisher did not sign. */ +export async function assertManifestSigned( + signed: SignedManifest, + verifier?: PublisherVerifier, +): Promise { + await assertPublisherSigned( + signed.manifest.publisher, + signed.signature, + manifestPayload(signed.manifest), + 'signature', + 'manifest', + verifier, + ); +} + +const KEY_CERTIFICATE_LABEL = 'haverstack-app-key-v1'; + +/** + * The bytes a publisher signs to certify `did` as a key of `appId`. The + * label keeps a certificate from ever verifying as a manifest, or the + * reverse. + */ +export function keyCertificatePayload(cert: { appId: AppId; did: EntityId }): Uint8Array { + return new TextEncoder().encode( + `${KEY_CERTIFICATE_LABEL}\n${canonicalJson({ appId: cert.appId, did: cert.did })}`, + ); +} + +/** Certify `did` as a key of `appId`, with the private key behind the app's publisher. */ +export async function certifyKey( + cert: { appId: AppId; did: EntityId }, + privateKey: CryptoKey, +): Promise { + return base64urlEncode(await signWithDid(privateKey, keyCertificatePayload(cert))); +} + +/** + * Refuse a certificate that does not verify: one presented and wrong is a + * forgery or a mistake, not an uncertified key. + */ +export async function assertKeyCertified( + manifest: AppManifest, + did: EntityId, + keyCertificate: string, + verifier?: PublisherVerifier, +): Promise { + await assertPublisherSigned( + manifest.publisher, + keyCertificate, + keyCertificatePayload({ appId: manifest.appId, did }), + 'keyCertificate', + 'key', + verifier, + ); } /** @@ -426,5 +513,7 @@ export function planFingerprint(plan: InstallPlan): string { plan.newKey, plan.linkedKeys, plan.namespaceVerified, + plan.keyCertified, + plan.publisherChanged, ]); } diff --git a/packages/core/src/stack.ts b/packages/core/src/stack.ts index 93741b8b..257200c7 100644 --- a/packages/core/src/stack.ts +++ b/packages/core/src/stack.ts @@ -128,6 +128,7 @@ import { bindingFieldsOf, uniqueBindingFieldsOf } from './identity-bindings.js'; import type { BindingField } from './identity-bindings.js'; import { appIdVouchedBy, + assertKeyCertified, assertManifestSigned, claimedFamilies, familyStanding, @@ -145,7 +146,7 @@ import { INSTALL_APP_LABEL, INSTALL_GRANT_LABEL, } from './install.js'; -import type { PublisherVerifier, SignedManifest } from './install.js'; +import type { InstallSubmission, PublisherVerifier } from './install.js'; import type { AppManifest, ForeignRequest, InstallPlan, TypeChange } from './install.js'; import { assertAttachmentSize, assertContentSize } from './limits.js'; import { @@ -2859,22 +2860,35 @@ export class Stack implements StackClient { * registered to a different app. See docs/spec/apps.md § Plan, then apply. */ async planInstall( - signed: SignedManifest, + submission: InstallSubmission, opts: { did: EntityId; verifyPublisher?: PublisherVerifier }, ): Promise { this.assertOpen(); - const { did } = opts; - const manifest = snapshotManifest(signed.manifest); + const { did, verifyPublisher } = opts; + const manifest = snapshotManifest(submission.manifest); this.checkManifest(manifest, did); - await assertManifestSigned({ manifest, signature: signed.signature }, opts.verifyPublisher); + await assertManifestSigned({ manifest, signature: submission.signature }, verifyPublisher); + const { keyCertificate } = submission; + if (keyCertificate !== undefined) { + await assertKeyCertified(manifest, did, keyCertificate, verifyPublisher); + } const installs = await this.loadInstalls(); const existing = installs.find((r) => r.content.appId === manifest.appId) ?? null; - if (existing && existing.content.publisher !== manifest.publisher) { + // A live install is pinned to its publisher; an uninstalled one may be + // taken up by another, which is how an owner accepts a rotated key. + // See docs/spec/apps.md § Who publishes an app. + const publisherChanged = !!existing && existing.content.publisher !== manifest.publisher; + if (existing && publisherChanged && !existing.deletedAt) { throw new StackConflictError( `"${manifest.appId}" is installed from ${existing.content.publisher}; this manifest is signed by ${manifest.publisher}`, ); } + if (existing && !publisherChanged && manifest.release < existing.content.release) { + throw new StackConflictError( + `"${manifest.appId}" is installed at release ${existing.content.release}; this manifest is release ${manifest.release}`, + ); + } const ownVersions = ownTypeIds(manifest); const ownFamilies = new Set(ownVersions.map(baseIdOf)); @@ -2911,7 +2925,8 @@ export class Stack implements StackClient { } const linkedKeys: EntityId[] = []; - for (const id of existing ? linkedIds(existing, INSTALL_APP_LABEL) : []) { + const keptLinks = existing && !publisherChanged ? linkedIds(existing, INSTALL_APP_LABEL) : []; + for (const id of keptLinks) { const key = ((await this.get(id))?.content as AppContent | undefined)?.did; if (typeof key === 'string') linkedKeys.push(key); } @@ -2919,7 +2934,10 @@ export class Stack implements StackClient { return { manifest, did, - signature: signed.signature, + signature: submission.signature, + ...(keyCertificate !== undefined && { keyCertificate }), + keyCertified: keyCertificate !== undefined, + publisherChanged, namespaceVerified: appIdVouchedBy(manifest.publisher) === manifest.appId, existing, newFamilies: [...ownFamilies].filter((f) => !claimed.has(f)), @@ -2928,7 +2946,7 @@ export class Stack implements StackClient { requestsRemoved: prior.filter((p) => !manifest.requests.some((r) => sameRequest(p, r))), foreignRequests, typeChanges, - newKey: !existing || !card || !linkedIds(existing, INSTALL_APP_LABEL).includes(card.id), + newKey: !card || !keptLinks.includes(card.id), linkedKeys, }; } @@ -2947,7 +2965,11 @@ export class Stack implements StackClient { ): Promise { this.assertOpen(); const fresh = await this.planInstall( - { manifest: plan.manifest, signature: plan.signature }, + { + manifest: plan.manifest, + signature: plan.signature, + ...(plan.keyCertificate !== undefined && { keyCertificate: plan.keyCertificate }), + }, { did: plan.did, verifyPublisher: opts.verifyPublisher }, ); if (planFingerprint(fresh) !== planFingerprint(plan)) { @@ -2955,7 +2977,7 @@ export class Stack implements StackClient { `The stack changed since the install of "${plan.manifest.appId}" was planned; plan it again`, ); } - const { manifest, did, existing } = fresh; + const { manifest, did, existing, publisherChanged } = fresh; for (const type of manifest.types) await this.defineType(type); const card = await this.ensureAppCard(manifest, did); @@ -2975,16 +2997,32 @@ export class Stack implements StackClient { name: manifest.name, ...(manifest.version !== undefined && { version: manifest.version }), publisher: manifest.publisher, + release: manifest.release, defines, requests, }, { associations: [installAppLink(card.id)] }, ); } else { - const current = existing.deletedAt ? await this.undelete(existing.id) : existing; + let current = existing.deletedAt ? await this.undelete(existing.id) : existing; + const oldKeys = publisherChanged ? linkedIds(current, INSTALL_APP_LABEL) : []; + if (oldKeys.length > 0) { + // The keys the old publisher's app linked are not this publisher's. + current = await this.amendAssociations( + current.id, + oldKeys.map((id) => ({ op: 'remove' as const, association: installAppLink(id) })), + ); + } install = await this.patchContent( existing.id, - { name: manifest.name, version: manifest.version ?? null, defines, requests }, + { + name: manifest.name, + version: manifest.version ?? null, + publisher: manifest.publisher, + release: manifest.release, + defines, + requests, + }, { ifVersion: current.version }, ); if (!linkedIds(install, INSTALL_APP_LABEL).includes(card.id)) { @@ -3030,6 +3068,9 @@ export class Stack implements StackClient { if (typeof manifest.name !== 'string' || manifest.name === '') { errors.push({ path: 'name', message: 'Expected a non-empty name' }); } + if (!Number.isSafeInteger(manifest.release) || manifest.release < 1) { + errors.push({ path: 'release', message: 'Expected a positive integer release' }); + } manifest.types.forEach((t, i) => { const parsed = parseTypeId(t.id); if (!parsed) { @@ -3285,6 +3326,7 @@ export class Stack implements StackClient { name: { kind: 'string', required: true }, version: { kind: 'string' }, publisher: { kind: 'string', required: true }, + release: { kind: 'number', required: true }, defines: { kind: 'array', items: { kind: 'string' }, required: true }, requests: { kind: 'array', diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 17b40a17..2d62ef86 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -514,10 +514,12 @@ export type InstallContent = { name: string; version?: string; /** - * The DID that signed the approved manifest. A binding: only a manifest - * the same publisher signed can change this install. + * The DID that signed the approved manifest. While the install is live, + * only a manifest the same publisher signed can change it. */ publisher: string; + /** The approved manifest's release; an older one is refused. */ + release: number; /** * Every type version the owner has approved for this app. The families * they name are claimed by this install and by no other. diff --git a/packages/core/src/wire-body.ts b/packages/core/src/wire-body.ts index 17fb1d46..b0391f81 100644 --- a/packages/core/src/wire-body.ts +++ b/packages/core/src/wire-body.ts @@ -20,7 +20,7 @@ import { StackBadRequestError, StackValidationError } from './errors.js'; import type { DefineTypeOptions } from './stack.js'; import { assertKnownKeys, validateAssociation } from './query-validation.js'; -import type { AppManifest, SignedManifest } from './install.js'; +import type { AppManifest, InstallSubmission } from './install.js'; import type { AssociationEdit, GrantAction, StackType, TypeId, TypeSchema } from './types.js'; export function requireBody(body: unknown, label: string): Record { @@ -226,23 +226,27 @@ export function parseMigrationBody(body: unknown): WireMigrationRequest { // ------------------------------------------------------- /** - * Parse a `POST /installs` body, `{ manifest, signature }`, into the - * signed manifest `planInstall()` takes. Each type is read as a `POST /types` body is. + * Parse a `POST /installs` body, `{ manifest, signature, keyCertificate? }`, + * into the submission `planInstall()` takes. Each type is read as a `POST /types` body is. * Which families a manifest may define, and which requests the grant rules * allow, are `planInstall()`'s to judge. See docs/spec/wire-format.md § Installs. */ -export function parseInstallBody(body: unknown): SignedManifest { - const b = requireKnownBody(body, ['manifest', 'signature'], 'install body'); +export function parseInstallBody(body: unknown): InstallSubmission { + const b = requireKnownBody(body, ['manifest', 'signature', 'keyCertificate'], 'install body'); const signature = requiredString(b, 'signature', 'install body'); + if (b.keyCertificate !== undefined && typeof b.keyCertificate !== 'string') { + fieldError('keyCertificate', 'keyCertificate must be a string'); + } const m = requireKnownBody( requiredObject(b, 'manifest', 'install body'), - ['appId', 'name', 'version', 'publisher', 'types', 'requests'], + ['appId', 'name', 'version', 'publisher', 'release', 'types', 'requests'], 'manifest', ); const manifest: AppManifest = { appId: nestedString(m, 'manifest', 'appId'), name: nestedString(m, 'manifest', 'name'), publisher: nestedString(m, 'manifest', 'publisher'), + release: nestedRelease(m), types: nestedArray(m, 'manifest', 'types').map((t, i) => { if (typeof t !== 'object' || t === null || Array.isArray(t)) fieldError(`manifest.types[${i}]`, 'a type must be an object'); @@ -265,7 +269,20 @@ export function parseInstallBody(body: unknown): SignedManifest { if (typeof m.version !== 'string') fieldError('manifest.version', 'version must be a string'); manifest.version = m.version; } - return { manifest, signature }; + return { + manifest, + signature, + ...(b.keyCertificate !== undefined && { keyCertificate: b.keyCertificate as string }), + }; +} + +/** `manifest.release`, a positive integer. */ +function nestedRelease(m: Record): number { + const value = m.release; + if (value === undefined) throw new StackBadRequestError('Invalid manifest: release is required'); + if (!Number.isSafeInteger(value) || (value as number) < 1) + fieldError('manifest.release', 'release must be a positive integer'); + return value as number; } /** A required string inside a nested object, its 422 naming the full path. */ diff --git a/packages/core/tests/install.test.ts b/packages/core/tests/install.test.ts index 12db1130..7cadc0c7 100644 --- a/packages/core/tests/install.test.ts +++ b/packages/core/tests/install.test.ts @@ -7,7 +7,7 @@ import { StackValidationError, } from '../src/errors.js'; import { MemoryAdapter } from '../src/testing.js'; -import { appIdVouchedBy, isPlanEmpty, signManifest } from '../src/install.js'; +import { appIdVouchedBy, certifyKey, isPlanEmpty, signManifest } from '../src/install.js'; import type { AppManifest } from '../src/install.js'; import { generateDidKeypair } from '../src/did.js'; import type { DidKeypair } from '../src/did.js'; @@ -28,6 +28,7 @@ const manifest = (overrides: Partial = {}): AppManifest => ({ name: 'Notes', version: '1.0.0', publisher: publisherKey.did, + release: 1, types: [{ id: NOTE_1, name: 'Note', schema: { text: { kind: 'text' } } }], requests: [{ baseId: 'com.example.notes/note', actions: ['create', 'read-any'] }], ...overrides, @@ -87,6 +88,7 @@ describe('installApp()', () => { name: 'Notes', version: '1.0.0', publisher: publisherKey.did, + release: 1, defines: [NOTE_1], requests: [{ baseId: 'com.example.notes/note', actions: ['create', 'read-any'] }], }); @@ -337,6 +339,7 @@ describe('the _install record', () => { appId: 'com.example.rival', name: 'Rival', publisher: publisherKey.did, + release: 1, defines: [id], requests: [], }), @@ -351,6 +354,7 @@ describe('the _install record', () => { appId: 'com.example.notes', name: 'Notes again', publisher: publisherKey.did, + release: 1, defines: [], requests: [], }), @@ -551,14 +555,46 @@ describe('the publisher', () => { ); }); - test('is pinned: no other publisher can upgrade the install or link a key to it', async () => { - const record = await install(manifest()); + test('is pinned: no other publisher can upgrade a live install or link a key to it', async () => { + await install(manifest()); const impostor = await generateDidKeypair(); const theirs = manifest({ publisher: impostor.did, requests: MIGRATING }); for (const did of [APP_DID, OTHER_DID]) { await expect(planSigned(theirs, { did }, impostor)).rejects.toThrow(StackConflictError); } - await expect(stack.patchContent(record.id, { publisher: impostor.did })).rejects.toThrow( + }); + + test('an uninstalled install may be taken up by a new publisher, unlinking the old keys', async () => { + await install(manifest()); + await install(manifest(), OTHER_DID); + await stack.uninstallApp('com.example.notes'); + + const rotated = await generateDidKeypair(); + const next = manifest({ publisher: rotated.did, release: 1 }); + const plan = await planSigned(next, { did: APP_DID }, rotated); + expect(plan.publisherChanged).toBe(true); + expect(plan.newKey).toBe(true); + expect(plan.linkedKeys).toEqual([]); + + const record = await stack.installApp(plan); + expect(record.deletedAt).toBeUndefined(); + expect(record.content.publisher).toBe(rotated.did); + expect( + (await stack.listTypeGrants()).map( + (g) => (g.content.grantee as { entityId: string }).entityId, + ), + ).toEqual([APP_DID]); + expect(await stack.asEntity(OTHER_DID).get(record.id)).toBeNull(); + await expect(planSigned(manifest(), { did: APP_DID })).rejects.toThrow(StackConflictError); + }); + + test('a release older than the installed one is refused', async () => { + await install(manifest({ release: 2, requests: [] })); + await expect( + planSigned(manifest({ release: 1, requests: MIGRATING }), { did: APP_DID }), + ).rejects.toThrow(StackConflictError); + expect((await planSigned(manifest({ release: 2 }), { did: OTHER_DID })).newKey).toBe(true); + await expect(planSigned(manifest({ release: 0 }), { did: APP_DID })).rejects.toThrow( StackValidationError, ); }); @@ -588,6 +624,39 @@ describe('the publisher', () => { ).toBe(false); }); + test('a key the publisher certified is marked so; a wrong certificate is refused', async () => { + const signed = await signManifest(manifest(), publisherKey.privateKey); + expect((await stack.planInstall(signed, { did: APP_DID })).keyCertified).toBe(false); + + const keyCertificate = await certifyKey( + { appId: 'com.example.notes', did: APP_DID }, + publisherKey.privateKey, + ); + const plan = await stack.planInstall({ ...signed, keyCertificate }, { did: APP_DID }); + expect(plan.keyCertified).toBe(true); + await stack.installApp(plan); + + for (const [cert, did] of [ + [keyCertificate, OTHER_DID], + [ + await certifyKey({ appId: 'com.example.tags', did: OTHER_DID }, publisherKey.privateKey), + OTHER_DID, + ], + [ + await certifyKey( + { appId: 'com.example.notes', did: OTHER_DID }, + (await generateDidKeypair()).privateKey, + ), + OTHER_DID, + ], + [signed.signature, APP_DID], + ] as const) { + await expect(stack.planInstall({ ...signed, keyCertificate: cert }, { did })).rejects.toThrow( + StackValidationError, + ); + } + }); + test('appIdVouchedBy() reverses a bare did:web host and nothing else', () => { expect(appIdVouchedBy('did:web:notes.example.com')).toBe('com.example.notes'); expect(appIdVouchedBy('did:web:example.com:apps:notes')).toBeNull(); diff --git a/packages/core/tests/wire-body.test.ts b/packages/core/tests/wire-body.test.ts index 406e495b..e92c0ede 100644 --- a/packages/core/tests/wire-body.test.ts +++ b/packages/core/tests/wire-body.test.ts @@ -215,6 +215,7 @@ describe('parseInstallBody', () => { name: 'Notes', version: '1.0.0', publisher: DID, + release: 1, types: [{ id: 'com.example.notes/note@1', name: 'Note', schema: { text: { kind: 'text' } } }], requests: [{ baseId: 'com.example.notes/note', actions: ['create', 'read-any'] }], }; @@ -222,6 +223,11 @@ describe('parseInstallBody', () => { test('reads a signed manifest into what planInstall() takes', () => { expect(parseInstallBody({ manifest, signature })).toEqual({ manifest, signature }); + expect(parseInstallBody({ manifest, signature, keyCertificate: signature })).toEqual({ + manifest, + signature, + keyCertificate: signature, + }); }); test('an unknown key at either level, or a missing field, is not this request', () => { @@ -248,5 +254,13 @@ describe('parseInstallBody', () => { }), ), ).toBe('manifest.requests[0].actions[0]'); + for (const release of [0, 1.5, '1']) { + expect( + pathOf(() => parseInstallBody({ manifest: { ...manifest, release }, signature })), + ).toBe('manifest.release'); + } + expect(pathOf(() => parseInstallBody({ manifest, signature, keyCertificate: 1 }))).toBe( + 'keyCertificate', + ); }); }); diff --git a/packages/wire-types/src/index.ts b/packages/wire-types/src/index.ts index 71d3721c..5ad58d16 100644 --- a/packages/wire-types/src/index.ts +++ b/packages/wire-types/src/index.ts @@ -13,7 +13,7 @@ import { StackTimeoutError, } from '@haverstack/core'; import type { - SignedManifest, + InstallSubmission, NativeSortField, StackRecord, StackType, @@ -807,10 +807,11 @@ export function supportsInstallRequests(discovery: DiscoveryResponse): boolean { } /** - * POST /installs: a manifest and its publisher's signature. The key being - * installed is the session's, never named here. + * POST /installs: a manifest, its publisher's signature, and optionally the + * publisher's certificate for the session's key. The key being installed + * is the session's, never named here. */ -export type WireInstallRequest = SignedManifest; +export type WireInstallRequest = InstallSubmission; /** * POST /installs answers `pending` (202) while the owner has not approved From de4e587155f36cf077f47311b2677905211ee062 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 00:35:49 +0000 Subject: [PATCH 08/11] fix(core): close remaining install review findings - An installed app's commitMigration() applies the file-reference gate to the new content, so a migration cannot point a record at an attachment the app cannot read and then download it. - sameRequest() compares action sets, so padding a request with a repeated action can no longer hide a dropped grant from the plan. - Once a key joins with the publisher's certificate, the install sets keysCertified and refuses a new key that presents none, so a copied manifest can't join an app whose keys are certified. A new publisher taking up an uninstalled install starts without it. - parseInstallBody() refuses derived Type keys in a manifest type rather than dropping them, which made genuine signatures fail to verify. - isPlanEmpty() counts a name, version or release change, so such an upgrade reaches the owner and is recorded. Refs #359 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017VerGQ4c2eMbvTM9szXEC7 --- .changeset/app-install-review-fixes.md | 6 ++ docs/spec/apps.md | 13 ++- docs/spec/wire-format.md | 4 +- .../adapter-api/tests/conformance.test.ts | 14 ++- packages/conformance-fixtures/src/index.ts | 40 ++++++++ packages/core/src/install.ts | 19 +++- packages/core/src/scoped-stack.ts | 4 + packages/core/src/stack.ts | 18 +++- packages/core/src/types.ts | 5 + packages/core/src/wire-body.ts | 15 ++- packages/core/tests/install.test.ts | 96 +++++++++++++++++++ packages/core/tests/wire-body.test.ts | 9 ++ 12 files changed, 226 insertions(+), 17 deletions(-) create mode 100644 .changeset/app-install-review-fixes.md diff --git a/.changeset/app-install-review-fixes.md b/.changeset/app-install-review-fixes.md new file mode 100644 index 00000000..c70c11dc --- /dev/null +++ b/.changeset/app-install-review-fixes.md @@ -0,0 +1,6 @@ +--- +'@haverstack/core': minor +'@haverstack/conformance-fixtures': minor +--- + +Once a key joins an install with the publisher's `keyCertificate`, `_install.keysCertified` is set and a new key presenting none is refused. An installed app's `commitMigration()` is held to the file-reference gate. Requests compare as sets of actions, a plan that changes only `name`, `version` or `release` is no longer empty, and a manifest type carrying a derived Type key is refused on the wire instead of dropped. diff --git a/docs/spec/apps.md b/docs/spec/apps.md index b77c87ca..b78db001 100644 --- a/docs/spec/apps.md +++ b/docs/spec/apps.md @@ -38,6 +38,7 @@ type InstallContent = { version?: string; publisher: string; // pinned while the install is live release: number; // the approved manifest's release + keysCertified?: boolean; // set once a key joins with a certificate — see Certified keys defines: TypeId[]; // every version the owner has approved requests: InstallRequest[]; // the grants each of the app's keys holds }; @@ -92,6 +93,8 @@ A publisher may vouch for a key as one of its app's by signing `keyCertificatePa **It is optional.** An uncertified key may still be approved; the owner is then the only check on whether it is really the app. A certificate that is presented and does not verify for this `appId` and key is refused with `StackValidationError` at `keyCertificate` rather than treated as absent: it is a forgery or a copy. +**Once one key is certified, every new key must be.** Applying a plan whose key came with a certificate sets `_install.keysCertified`, and from then on `planInstall()` refuses a key not yet linked to the install that presents none, with `StackConflictError`. Without that, a key holding only a copy of the signed manifest could still ask to join an app whose real keys are all certified, and the owner would be the only check. Keys already linked are unaffected; the flag survives uninstalling, and clears only when a [new publisher](#who-publishes-an-app) takes the install up, since the old publisher's certificates say nothing about the new one's keys. + **It proves what the publisher's issuing does.** For an app the publisher runs on its own servers, the publisher certifies the keys it holds, and a certified key is the app. For an app running on its users' devices, the publisher's private key cannot ship with it, so a device asks the publisher for a certificate — and the certificate then means only what the publisher checked before issuing it: a platform attestation, an account login, or nothing. The owner sees that a publisher vouches for the key; how much that is worth is the publisher's to earn. ## Plan, then apply @@ -125,16 +128,18 @@ type InstallPlan = { `foreignRequests` are the requests on families outside the app's own namespace, each naming the family's [owner](#who-owns-a-family): another app's `appId`, `'commons'`, `'system'`, or `null` for a family with no namespace. They are the requests an approval most needs to show — an app asking to read another app's records, or `_entity`, is asking for reach beyond its own data. -It refuses what no approval could make valid: a type outside the app's own namespace and the commons (`StackValidationError` — use a request instead), a request [`grantType()` would refuse](./access-control.md#type-level-grants) (`StackValidationError`), a manifest its publisher did not sign or a key certificate that does not verify (`StackValidationError`), and a manifest under an `appId` live from another publisher, one older than the installed release, or a `did` whose `_app` card names a different `appId` (`StackConflictError`). +It refuses what no approval could make valid: a type outside the app's own namespace and the commons (`StackValidationError` — use a request instead), a request [`grantType()` would refuse](./access-control.md#type-level-grants) (`StackValidationError`), a manifest its publisher did not sign or a key certificate that does not verify (`StackValidationError`), and a manifest under an `appId` live from another publisher, one older than the installed release, a new key with no certificate on an install whose `keysCertified` is set, or a `did` whose `_app` card names a different `appId` (`StackConflictError`). + +A request is compared to the one the install holds by family and the _set_ of its actions, so repeating an action changes nothing. `installApp(plan, { verifyPublisher? })` applies the plan: 1. Defines each of the manifest's types, with [schema drift](./data-model.md#schema-drift-detection) applying as it does to any `defineType()`. 2. Registers the key on an `_app` card when it has none, undeleting a soft-deleted one. An existing card's `name` is left alone: it is the owner's label. -3. Creates the install, or patches it — undeleting it first if it was uninstalled, and unlinking the old keys if its publisher changed. `defines` gains the manifest's versions and never loses any, since a Type once defined stays defined; `requests` becomes the manifest's. +3. Creates the install, or patches it — undeleting it first if it was uninstalled, and unlinking the old keys if its publisher changed. `defines` gains the manifest's versions and never loses any, since a Type once defined stays defined; `requests`, `name`, `version` and `release` become the manifest's, and `keysCertified` is set when the key came with a certificate. 4. Brings the grants of **every** key linked to the install to exactly `requests`: a grant no longer requested is revoked, a missing one is written, and the links follow. -**Nothing is applied that was not approved.** `installApp()` plans the same manifest again and refuses with `StackConflictError` when the result differs from the plan it was handed — the install changing, a type being defined, or a key being linked, since it was planned. The remedy is to plan again and show the owner the new plan. A plan holds a frozen copy of the manifest it was made from, so changing the caller's object afterwards changes nothing that is applied. Re-applying a manifest whose plan is empty changes nothing. +**Nothing is applied that was not approved.** `installApp()` plans the same manifest again and refuses with `StackConflictError` when the result differs from the plan it was handed — the install changing, a type being defined, or a key being linked, since it was planned. The remedy is to plan again and show the owner the new plan. A plan holds a frozen copy of the manifest it was made from, so changing the caller's object afterwards changes nothing that is applied. Re-applying a manifest whose plan is empty changes nothing. A plan is empty only when the manifest's `name`, `version` and `release` are also the install's, so an upgrade that changes only those still reaches the owner. ## Migrating an installed app's types @@ -145,6 +150,8 @@ It refuses what no approval could make valid: a type outside the app's own names 3. The requester holds `update-any` on each family through a grant naming its DID directly. Default and group grants do not count, on the terms they do not count for [a principal](./access-control.md#who-a-grant-reaches). The manifest has to request it, so the plan shows it. 4. The requester is acting alone — not delegated — as the DID of an `_app` card linked to the install. +The new content is held to the [file-reference gate](./access-control.md#reference-creation-gating) a create applies: every `file-ref` field must name a file the app can already read, so a migration cannot point a Record at someone else's attachment. + The Record must also be live. The owner may migrate a soft-deleted Record, but an app cannot read one, so its migration is refused with `StackConflictError` until the Record is undeleted. The reasons migration is otherwise owner-only do not reach this case. A migration that crosses into `_attachment` or moves a DID binding needs a system family at one end, and no install can claim one; nor can it claim a commons family, which every app reads. Ordinary write access is not consent to move a Record between versions; the owner's approval of the version is. Every migration still snapshots the Record's prior content and type to [version history](./versioning.md#version-history), so it stays recoverable like any other write. diff --git a/docs/spec/wire-format.md b/docs/spec/wire-format.md index c0cb12d6..debd0594 100644 --- a/docs/spec/wire-format.md +++ b/docs/spec/wire-format.md @@ -707,7 +707,7 @@ POST /installs — present an app's manifest for the owner to approve How an app holding its own key asks to be installed (see [App installs § Over the wire](./apps.md#over-the-wire)). Only the asking is specified here; the owner approves through whatever the server offers, with `Stack.planInstall()` and `installApp()`. -**The body is `{ "manifest": { … }, "signature": "…", "keyCertificate"?: "…" }`**, a signed `AppManifest`: `appId`, `name`, optional `version`, `publisher`, `release` (a positive integer), `types` (each read as a [`POST /types`](#types) body is) and `requests` (each `{ baseId, actions }`), with the publisher's base64url signature over it (see [App installs § Who publishes an app](./apps.md#who-publishes-an-app)), and optionally the publisher's base64url certificate for the session's key (see [App installs § Certified keys](./apps.md#certified-keys)). `parseInstallBody()` from `@haverstack/core/wire` reads it, refusing an unknown key at either level with **400** like any other [unrecognized input](#unrecognized-input). **The key being installed is never in the body**: it is the session's principal. +**The body is `{ "manifest": { … }, "signature": "…", "keyCertificate"?: "…" }`**, a signed `AppManifest`: `appId`, `name`, optional `version`, `publisher`, `release` (a positive integer), `types` (each read as a [`POST /types`](#types) body is, carrying only `id`, `name`, `schema` and `migratesFrom`: a key a Type derives is refused rather than dropped, since dropping a signed key would fail the signature) and `requests` (each `{ baseId, actions }`), with the publisher's base64url signature over it (see [App installs § Who publishes an app](./apps.md#who-publishes-an-app)), and optionally the publisher's base64url certificate for the session's key (see [App installs § Certified keys](./apps.md#certified-keys)). `parseInstallBody()` from `@haverstack/core/wire` reads it, refusing an unknown key at either level with **400** like any other [unrecognized input](#unrecognized-input). **The key being installed is never in the body**: it is the session's principal. **The request must come from the key acting as itself.** A delegated session names someone else as the subject, and an install is for the key that authenticated, so it answers **403** (code `permission`). @@ -718,7 +718,7 @@ The server plans the manifest for the session's key and answers with the result: | `202` | `{ "status": "pending" }` | Applying the manifest would change something. The server queues it for the owner and writes nothing to the stack. | | `200` | `{ "status": "installed", "install": … }` | The plan is empty — `isPlanEmpty()` from `@haverstack/core` decides — and `install` is the `_install` record, which the key can read. | | `422` | `validation` | The signature is not the publisher's over this manifest, a `keyCertificate` is not the publisher's for this key, the manifest defines a type outside the app's [own namespace](./apps.md#who-owns-a-family) and the commons, or a request breaks the grant rules. | -| `409` | `conflict` | The `appId` is live from another publisher, the manifest's `release` is older than the installed one, or the key's `_app` card names a different `appId`. | +| `409` | `conflict` | The `appId` is live from another publisher, the manifest's `release` is older than the installed one, the install takes new keys only with a certificate and none came, or the key's `_app` card names a different `appId`. | Re-sending the same manifest is how an app checks: it answers `202` until the owner approves and `200` after. A manifest that changes anything an approved one said — a new version, a changed request — is `202` again, so an upgrade takes the same path as a first install. A client refuses `requestInstall()` locally when discovery does not advertise `installs`, rather than learning it as a `404`. diff --git a/packages/adapter-api/tests/conformance.test.ts b/packages/adapter-api/tests/conformance.test.ts index 7dc30c39..15aa12cc 100644 --- a/packages/adapter-api/tests/conformance.test.ts +++ b/packages/adapter-api/tests/conformance.test.ts @@ -730,6 +730,12 @@ describe('commitMigration fixtures', () => { // travels at all: it is the session's. // ------------------------------------------------------- +// Forged on purpose, or refused before the signature is read. +const UNSIGNED_INSTALL_FIXTURES = new Set([ + 'install-request-signature-not-the-publishers', + 'install-request-manifest-type-derived-key', +]); + describe('install request fixtures', () => { const openWithInstalls = (): Promise => openDiscovered({ ...DISCOVERY, installs: { requests: true } }, { token: undefined }); @@ -763,8 +769,8 @@ describe('install request fixtures', () => { } // The signatures are real, so a server can verify them rather than trust - // its own derivation of the signed bytes. One fixture is forged on purpose. - test('every fixture signature but the forged one is the publisher’s', async () => { + // its own derivation of the signed bytes, except in UNSIGNED_INSTALL_FIXTURES. + test('every fixture signature but the unsigned ones is the publisher’s', async () => { for (const fixture of installRequestFixtures) { const { manifest, signature } = fixture.requestBody!; expect(manifest.publisher).toBe(INSTALL_FIXTURE_PUBLISHER); @@ -773,9 +779,7 @@ describe('install request fixtures', () => { base64urlDecode(signature), manifestPayload(manifest), ); - expect(valid, fixture.name).toBe( - fixture.name !== 'install-request-signature-not-the-publishers', - ); + expect(valid, fixture.name).toBe(!UNSIGNED_INSTALL_FIXTURES.has(fixture.name)); } }); diff --git a/packages/conformance-fixtures/src/index.ts b/packages/conformance-fixtures/src/index.ts index 26d97498..cd89f0df 100644 --- a/packages/conformance-fixtures/src/index.ts +++ b/packages/conformance-fixtures/src/index.ts @@ -2429,6 +2429,46 @@ export const installRequestFixtures: ConformanceFixture< error: { code: 'permission', message: 'An install request must come from the key itself' }, }, }, + { + name: 'install-request-key-not-certified', + description: + 'POST /installs from a key not yet linked to an install whose keysCertified is set, ' + + 'presenting no keyCertificate, answers 409 with code "conflict". Once a key has joined ' + + "with the publisher's certificate, every new key needs one, so a copy of the signed " + + 'manifest is not enough to join. Assumes "com.example.notes" is installed from ' + + "INSTALL_FIXTURE_PUBLISHER with keysCertified set, and the session's key is not linked to it.", + method: 'POST', + path: '/installs', + requestBody: SIGNED_INSTALL, + responseStatus: 409, + responseBody: { + error: { + code: 'conflict', + message: `"com.example.notes" takes new keys only with a keyCertificate from ${INSTALL_FIXTURE_PUBLISHER}`, + }, + }, + }, + { + name: 'install-request-manifest-type-derived-key', + description: + 'POST /installs whose manifest type carries a key a Type derives — here schemaHash — ' + + 'answers 400 with code "bad_request", before any signature is checked. A manifest type ' + + 'is id, name, schema and migratesFrom; a derived key is refused rather than dropped, ' + + 'since dropping a signed key would fail the signature.', + method: 'POST', + path: '/installs', + requestBody: { + manifest: { + ...INSTALL_MANIFEST, + types: [{ ...INSTALL_MANIFEST.types[0]!, schemaHash: 'sha256:0' } as never], + }, + signature: SIGNED_INSTALL.signature, + }, + responseStatus: 400, + responseBody: { + error: { code: 'bad_request', message: 'Unknown key in manifest.types[0]: schemaHash' }, + }, + }, ]; // ------------------------------------------------------- diff --git a/packages/core/src/install.ts b/packages/core/src/install.ts index dec9b89a..d02f5934 100644 --- a/packages/core/src/install.ts +++ b/packages/core/src/install.ts @@ -458,11 +458,16 @@ export function appIdVouchedBy(publisher: string): AppId | null { return match[1]!.toLowerCase().split('.').reverse().join('.'); } -/** Whether two requests ask for the same family and exactly the same actions. */ +/** + * Whether two requests ask for the same family and the same set of + * actions. Compared as sets, so a repeated action can't pad one list to + * the other's length. + */ export function sameRequest(a: InstallRequest, b: InstallRequest): boolean { - if (a.baseId !== b.baseId || a.actions.length !== b.actions.length) return false; - const actions = new Set(a.actions); - return b.actions.every((x) => actions.has(x)); + if (a.baseId !== b.baseId) return false; + const as = new Set(a.actions); + const bs = new Set(b.actions); + return as.size === bs.size && [...bs].every((x) => as.has(x)); } /** Whether a stored grant is exactly `request`, made out to `did`. */ @@ -478,7 +483,8 @@ export function grantIsRequest( /** * Whether applying `plan` would change nothing: the install is live, this - * key is linked to it, and the manifest adds and removes nothing. A server + * key is linked to it, the manifest adds and removes nothing, and its + * `name`, `version` and `release` are the ones the install holds. A server * answers such a request as already installed rather than queuing it. * See docs/spec/wire-format.md § Installs. */ @@ -487,6 +493,9 @@ export function isPlanEmpty(plan: InstallPlan): boolean { plan.existing !== null && !plan.existing.deletedAt && !plan.newKey && + plan.manifest.name === plan.existing.content.name && + (plan.manifest.version ?? null) === (plan.existing.content.version ?? null) && + plan.manifest.release === plan.existing.content.release && plan.newFamilies.length === 0 && plan.newVersions.length === 0 && plan.typeChanges.length === 0 && diff --git a/packages/core/src/scoped-stack.ts b/packages/core/src/scoped-stack.ts index e17d7523..2f551bd1 100644 --- a/packages/core/src/scoped-stack.ts +++ b/packages/core/src/scoped-stack.ts @@ -1555,6 +1555,10 @@ export class ScopedStack implements StackClient { 'Only the stack owner, or an installed app within the type families it defines, may commit a migration', ); } + // The new content is a fresh set of file references, so an app is held + // to the gate create() applies; otherwise a migration could point one of + // its records at any attachment and read the bytes through it. + if (!this.ownerActingAlone) await this.requireFileRefAccess(toTypeId, content); return this.stack.commitMigration(id, toTypeId, content, { ...opts, ...this.actor }); } diff --git a/packages/core/src/stack.ts b/packages/core/src/stack.ts index 257200c7..92a8800f 100644 --- a/packages/core/src/stack.ts +++ b/packages/core/src/stack.ts @@ -2926,6 +2926,15 @@ export class Stack implements StackClient { const linkedKeys: EntityId[] = []; const keptLinks = existing && !publisherChanged ? linkedIds(existing, INSTALL_APP_LABEL) : []; + const newKey = !card || !keptLinks.includes(card.id); + // Once the publisher has certified a key, an uncertified one joining is + // a downgrade only a copied manifest needs. See docs/spec/apps.md § Certified keys. + const certifiesKeys = !publisherChanged && existing?.content.keysCertified === true; + if (newKey && keyCertificate === undefined && certifiesKeys) { + throw new StackConflictError( + `"${manifest.appId}" takes new keys only with a keyCertificate from ${manifest.publisher}`, + ); + } for (const id of keptLinks) { const key = ((await this.get(id))?.content as AppContent | undefined)?.did; if (typeof key === 'string') linkedKeys.push(key); @@ -2946,7 +2955,7 @@ export class Stack implements StackClient { requestsRemoved: prior.filter((p) => !manifest.requests.some((r) => sameRequest(p, r))), foreignRequests, typeChanges, - newKey: !card || !keptLinks.includes(card.id), + newKey, linkedKeys, }; } @@ -2998,6 +3007,7 @@ export class Stack implements StackClient { ...(manifest.version !== undefined && { version: manifest.version }), publisher: manifest.publisher, release: manifest.release, + ...(fresh.keyCertified && { keysCertified: true }), defines, requests, }, @@ -3020,6 +3030,11 @@ export class Stack implements StackClient { version: manifest.version ?? null, publisher: manifest.publisher, release: manifest.release, + // A new publisher's certificates start afresh; the old one's bound nothing it signs. + keysCertified: + fresh.keyCertified || (!publisherChanged && existing.content.keysCertified) + ? true + : null, defines, requests, }, @@ -3327,6 +3342,7 @@ export class Stack implements StackClient { version: { kind: 'string' }, publisher: { kind: 'string', required: true }, release: { kind: 'number', required: true }, + keysCertified: { kind: 'boolean' }, defines: { kind: 'array', items: { kind: 'string' }, required: true }, requests: { kind: 'array', diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 2d62ef86..8ee9d12c 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -520,6 +520,11 @@ export type InstallContent = { publisher: string; /** The approved manifest's release; an older one is refused. */ release: number; + /** + * Set once a key joins with the publisher's certificate. From then on a + * new key joins only with one. See docs/spec/apps.md § Certified keys. + */ + keysCertified?: boolean; /** * Every type version the owner has approved for this app. The families * they name are claimed by this install and by no other. diff --git a/packages/core/src/wire-body.ts b/packages/core/src/wire-body.ts index b0391f81..fa3c3848 100644 --- a/packages/core/src/wire-body.ts +++ b/packages/core/src/wire-body.ts @@ -225,9 +225,18 @@ export function parseMigrationBody(body: unknown): WireMigrationRequest { // POST /installs // ------------------------------------------------------- +/** Every key a manifest type carries — a `DefineTypeOptions`'s. */ +const MANIFEST_TYPE_KEYS: readonly string[] = Object.keys({ + id: true, + name: true, + schema: true, + migratesFrom: true, +} satisfies Record); + /** * Parse a `POST /installs` body, `{ manifest, signature, keyCertificate? }`, - * into the submission `planInstall()` takes. Each type is read as a `POST /types` body is. + * into the submission `planInstall()` takes. Each type is read as a + * `POST /types` body is, less the keys `defineType()` derives. * Which families a manifest may define, and which requests the grant rules * allow, are `planInstall()`'s to judge. See docs/spec/wire-format.md § Installs. */ @@ -250,6 +259,10 @@ export function parseInstallBody(body: unknown): InstallSubmission { types: nestedArray(m, 'manifest', 'types').map((t, i) => { if (typeof t !== 'object' || t === null || Array.isArray(t)) fieldError(`manifest.types[${i}]`, 'a type must be an object'); + // Only what a manifest type carries: a key parseTypeBody() would + // drop is one the signature covers, and the stripped manifest would + // then fail to verify. + requireKnownBody(t, MANIFEST_TYPE_KEYS, `manifest.types[${i}]`); return parseTypeBody(t); }), requests: nestedArray(m, 'manifest', 'requests').map((r, i) => { diff --git a/packages/core/tests/install.test.ts b/packages/core/tests/install.test.ts index 7cadc0c7..54527d77 100644 --- a/packages/core/tests/install.test.ts +++ b/packages/core/tests/install.test.ts @@ -329,6 +329,39 @@ describe('what an installed app sees', () => { await stack.uninstallApp('com.example.notes'); expect(isPlanEmpty(await planSigned(manifest(), { did: APP_DID }))).toBe(false); }); + + test('a manifest that changes only name, version or release is not an empty plan', async () => { + await install(manifest()); + for (const change of [{ name: 'Notes+' }, { version: '1.0.1' }, { release: 2 }]) { + expect( + isPlanEmpty(await planSigned(manifest(change), { did: APP_DID })), + JSON.stringify(change), + ).toBe(false); + } + const bumped = await planSigned(manifest({ version: '1.0.1', release: 2 }), { did: APP_DID }); + const record = await stack.installApp(bumped); + expect(record.content).toMatchObject({ version: '1.0.1', release: 2 }); + }); + + test('requests compare as sets of actions, so a repeated action cannot keep one dropped', async () => { + await install(manifest({ requests: MIGRATING })); + const padded = await planSigned( + manifest({ + requests: [{ baseId: 'com.example.notes/note', actions: ['read-any', 'read-any'] }], + }), + { did: APP_DID }, + ); + expect(padded.requestsRemoved).toEqual(MIGRATING); + const record = await stack.installApp(padded); + expect((await linkedGrants(record)).flatMap((g) => g.actions)).not.toContain('update-any'); + + const reordered = (actions: ('create' | 'read-any')[]) => + manifest({ requests: [{ baseId: 'com.example.notes/note', actions }] }); + await install(reordered(['read-any', 'create'])); + expect(isPlanEmpty(await planSigned(reordered(['create', 'read-any']), { did: APP_DID }))).toBe( + true, + ); + }); }); describe('the _install record', () => { @@ -421,6 +454,22 @@ describe('commitMigration() for an installed app', () => { expect(migrated.updatedBy).toEqual({ subjectId: APP_DID }); }); + test('new content may reference only files the app can already read', async () => { + const withPhoto = { + ...NOTE_2_TYPE, + schema: { text: { kind: 'text' }, photo: { kind: 'file-ref' } }, + } as const; + await install(manifest({ types: [manifest().types[0]!, withPhoto], requests: MIGRATING })); + const note = await stack.create(NOTE_1, { text: 'hello' }); + const owners = await stack.putAttachment(new Uint8Array([1, 2, 3]), { mimeType: 'image/png' }); + await expect( + stack + .asEntity(APP_DID) + .commitMigration(note.id, NOTE_2, { text: 'hello', photo: owners.content.fileId }), + ).rejects.toThrow(StackPermissionError); + expect((await stack.get(note.id))!.typeId).toBe(NOTE_1); + }); + test('a soft-deleted record waits for an undelete', async () => { const note = await installForMigration(); await stack.delete(note.id); @@ -657,6 +706,53 @@ describe('the publisher', () => { } }); + test('once a key is certified, a new key needs a certificate too', async () => { + const certified = async (did: string) => + stack.planInstall( + { + ...(await signManifest(manifest(), publisherKey.privateKey)), + keyCertificate: await certifyKey( + { appId: 'com.example.notes', did }, + publisherKey.privateKey, + ), + }, + { did }, + ); + await install(manifest()); + expect((await planSigned(manifest(), { did: OTHER_DID })).newKey).toBe(true); + + const record = await stack.installApp(await certified(APP_DID)); + expect(record.content.keysCertified).toBe(true); + await expect(planSigned(manifest(), { did: OTHER_DID })).rejects.toThrow(StackConflictError); + await stack.installApp(await certified(OTHER_DID)); + // A key already linked is not new, so it needs none. + expect(isPlanEmpty(await planSigned(manifest(), { did: APP_DID }))).toBe(true); + + await stack.uninstallApp('com.example.notes'); + await expect(planSigned(manifest(), { did: PERSON })).rejects.toThrow(StackConflictError); + }); + + test('a new publisher taking up an install starts without certified keys', async () => { + const plan = await stack.planInstall( + { + ...(await signManifest(manifest(), publisherKey.privateKey)), + keyCertificate: await certifyKey( + { appId: 'com.example.notes', did: APP_DID }, + publisherKey.privateKey, + ), + }, + { did: APP_DID }, + ); + await stack.installApp(plan); + await stack.uninstallApp('com.example.notes'); + + const rotated = await generateDidKeypair(); + const record = await stack.installApp( + await planSigned(manifest({ publisher: rotated.did }), { did: OTHER_DID }, rotated), + ); + expect(record.content.keysCertified).toBeUndefined(); + }); + test('appIdVouchedBy() reverses a bare did:web host and nothing else', () => { expect(appIdVouchedBy('did:web:notes.example.com')).toBe('com.example.notes'); expect(appIdVouchedBy('did:web:example.com:apps:notes')).toBeNull(); diff --git a/packages/core/tests/wire-body.test.ts b/packages/core/tests/wire-body.test.ts index e92c0ede..6611b04e 100644 --- a/packages/core/tests/wire-body.test.ts +++ b/packages/core/tests/wire-body.test.ts @@ -230,6 +230,15 @@ describe('parseInstallBody', () => { }); }); + test('a manifest type carries no key a Type derives, since dropping one would void the signature', () => { + for (const key of ['baseId', 'version', 'schemaHash', 'createdAt']) { + const types = [{ ...manifest.types[0]!, [key]: 'x' }]; + expect(() => parseInstallBody({ manifest: { ...manifest, types }, signature })).toThrow( + StackBadRequestError, + ); + } + }); + test('an unknown key at either level, or a missing field, is not this request', () => { expect(() => parseInstallBody({ manifest, signature, did: DID })).toThrow(StackBadRequestError); expect(() => parseInstallBody({ manifest: { ...manifest, did: DID }, signature })).toThrow( From 382bedf7bf28318e49d9d3949fad91683260c9d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 01:31:11 +0000 Subject: [PATCH 09/11] fix(core): refuse invalid manifest schemas in planInstall() installApp() defines a manifest's types one at a time before writing anything else, so a type defineType() refuses left the earlier ones written with no _install record. A malformed schema on an already defined type also crashed hashSchema() with a TypeError (a 500 over POST /installs). planInstall() now refuses up front what defineType() would: a malformed schema or field name (StackValidationError), a type listed twice, and a non-additive change to a defined type (StackSchemaDriftError). A manifest's types are now written all or none. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WUE2XGg8HhX21XHfPCevLD --- docs/spec/apps.md | 4 +-- packages/core/src/stack.ts | 19 +++++++++++ packages/core/tests/install.test.ts | 52 +++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/docs/spec/apps.md b/docs/spec/apps.md index b78db001..07943b45 100644 --- a/docs/spec/apps.md +++ b/docs/spec/apps.md @@ -128,13 +128,13 @@ type InstallPlan = { `foreignRequests` are the requests on families outside the app's own namespace, each naming the family's [owner](#who-owns-a-family): another app's `appId`, `'commons'`, `'system'`, or `null` for a family with no namespace. They are the requests an approval most needs to show — an app asking to read another app's records, or `_entity`, is asking for reach beyond its own data. -It refuses what no approval could make valid: a type outside the app's own namespace and the commons (`StackValidationError` — use a request instead), a request [`grantType()` would refuse](./access-control.md#type-level-grants) (`StackValidationError`), a manifest its publisher did not sign or a key certificate that does not verify (`StackValidationError`), and a manifest under an `appId` live from another publisher, one older than the installed release, a new key with no certificate on an install whose `keysCertified` is set, or a `did` whose `_app` card names a different `appId` (`StackConflictError`). +It refuses what no approval could make valid: a type outside the app's own namespace and the commons (`StackValidationError` — use a request instead), a type whose schema [`defineType()` would refuse](./data-model.md#types) or that is listed twice (`StackValidationError`), a type already defined whose new schema is not [additive](./data-model.md#schema-drift-detection) (`StackSchemaDriftError`), a request [`grantType()` would refuse](./access-control.md#type-level-grants) (`StackValidationError`), a manifest its publisher did not sign or a key certificate that does not verify (`StackValidationError`), and a manifest under an `appId` live from another publisher, one older than the installed release, a new key with no certificate on an install whose `keysCertified` is set, or a `did` whose `_app` card names a different `appId` (`StackConflictError`). A request is compared to the one the install holds by family and the _set_ of its actions, so repeating an action changes nothing. `installApp(plan, { verifyPublisher? })` applies the plan: -1. Defines each of the manifest's types, with [schema drift](./data-model.md#schema-drift-detection) applying as it does to any `defineType()`. +1. Defines each of the manifest's types. The plan has already refused a schema `defineType()` would, so a manifest's types are written all or none. 2. Registers the key on an `_app` card when it has none, undeleting a soft-deleted one. An existing card's `name` is left alone: it is the owner's label. 3. Creates the install, or patches it — undeleting it first if it was uninstalled, and unlinking the old keys if its publisher changed. `defines` gains the manifest's versions and never loses any, since a Type once defined stays defined; `requests`, `name`, `version` and `release` become the manifest's, and `keysCertified` is set when the key came with a certificate. 4. Brings the grants of **every** key linked to the install to exactly `requests`: a grant no longer requested is revoked, a missing one is written, and the links follow. diff --git a/packages/core/src/stack.ts b/packages/core/src/stack.ts index 92a8800f..28ee90cf 100644 --- a/packages/core/src/stack.ts +++ b/packages/core/src/stack.ts @@ -2920,6 +2920,10 @@ export class Stack implements StackClient { const current = await this.getTypeCached(t.id); if (!current) typeChanges.push({ id: t.id, change: 'new' }); else if (current.schemaHash !== (await hashSchema(t.schema as TypeSchema))) { + // Refused here, not left to defineType(): installApp() defines types one + // by one, so a drift found there leaves the earlier ones written. + const violations = diffSchemas(current.schema, t.schema as TypeSchema); + if (violations.length > 0) throw new StackSchemaDriftError(t.id, violations); typeChanges.push({ id: t.id, change: 'schema' }); } else if (current.name !== t.name) typeChanges.push({ id: t.id, change: 'name' }); } @@ -3086,7 +3090,22 @@ export class Stack implements StackClient { if (!Number.isSafeInteger(manifest.release) || manifest.release < 1) { errors.push({ path: 'release', message: 'Expected a positive integer release' }); } + const seen = new Set(); manifest.types.forEach((t, i) => { + if (seen.has(t.id)) { + errors.push({ path: `types[${i}].id`, message: `"${t.id}" is listed more than once` }); + } + seen.add(t.id); + const schemaPath = `types[${i}].schema`; + const shapeErrors = validateSchemaShape(t.schema, schemaPath); + errors.push(...shapeErrors); + if (shapeErrors.length === 0) { + const schema = t.schema as TypeSchema; + for (const e of validateSchemaReservedNames(schema)) { + errors.push({ ...e, path: `${schemaPath}.${e.path}` }); + } + validateSchemaFieldNames(schema, schemaPath, errors); + } const parsed = parseTypeId(t.id); if (!parsed) { errors.push({ path: `types[${i}].id`, message: 'Expected a versioned TypeId' }); diff --git a/packages/core/tests/install.test.ts b/packages/core/tests/install.test.ts index 54527d77..0fd9b1e7 100644 --- a/packages/core/tests/install.test.ts +++ b/packages/core/tests/install.test.ts @@ -4,6 +4,7 @@ import { StackConflictError, StackNotFoundError, StackPermissionError, + StackSchemaDriftError, StackValidationError, } from '../src/errors.js'; import { MemoryAdapter } from '../src/testing.js'; @@ -282,6 +283,57 @@ describe('installApp()', () => { }); }); +describe("a manifest's schemas", () => { + const NEW_1 = 'com.example.notes/new@1'; + const withNew = (schema: unknown): AppManifest => + manifest({ + types: [ + { id: NEW_1, name: 'New', schema: { body: { kind: 'text' } } }, + { id: NOTE_1, name: 'Note', schema: schema as AppManifest['types'][number]['schema'] }, + ], + }); + + test('a non-additive change to a defined type is refused at plan time', async () => { + await install(manifest()); + await expect( + planSigned(withNew({ text: { kind: 'number' } }), { did: APP_DID }), + ).rejects.toThrow(StackSchemaDriftError); + }); + + test('a malformed schema is refused at plan time, defined type or not', async () => { + for (const schema of [null, { text: { kind: 'object' } }]) { + await expect(planSigned(withNew(schema), { did: APP_DID })).rejects.toThrow( + StackValidationError, + ); + } + await install(manifest()); + for (const schema of [null, { text: { kind: 'object' } }]) { + await expect(planSigned(withNew(schema), { did: APP_DID })).rejects.toThrow( + StackValidationError, + ); + } + }); + + test('a schema declaring a reserved or unaddressable field name is refused', async () => { + await expect( + planSigned(withNew({ 'a.b': { kind: 'text' } }), { did: APP_DID }), + ).rejects.toThrow(StackValidationError); + }); + + test('a type listed twice is refused', async () => { + const m = manifest({ types: [...manifest().types, ...manifest().types] }); + await expect(planSigned(m, { did: APP_DID })).rejects.toThrow(StackValidationError); + }); + + test('a refused manifest writes none of its types', async () => { + await install(manifest()); + await expect(install(withNew({ text: { kind: 'number' } }))).rejects.toThrow( + StackSchemaDriftError, + ); + expect(await stack.getType(NEW_1)).toBeNull(); + }); +}); + describe('what an installed app sees', () => { test('each linked key can read its own install, and no one else can', async () => { const record = await install(manifest()); From 17e1084361a7030b979604931049b695eb162d93 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 01:39:28 +0000 Subject: [PATCH 10/11] refactor(core): move publisher signing and certified keys out of app installs Signed manifests, publisher pinning, forward-only releases and certified keys are a trust layer that the install model doesn't need in order to work, and with a did:key publisher they still come down to trust on first use, with the owner's review as the real check. They also overlap the identity problem identity.md defers under key rotation. They will be designed there, in a follow-up issue. The install model, POST /installs, migration by an installed app, and the review fixes that don't depend on signing all stay: the file-reference gate on an app's migration, comparing requests as sets of actions, a name- or version-only change counting as a non-empty plan, and refusing invalid manifest schemas at plan time. A manifest type's derived keys are once again accepted and ignored, as on POST /types. That refusal existed only to keep signatures valid. apps.md states the remaining appId risk and where it is deferred. Refs #359 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AdV1VbrMeuGtMoFiE31tqg --- .changeset/app-install-publishers.md | 8 - .changeset/app-install-review-fixes.md | 3 +- README.md | 4 +- docs/spec/apps.md | 63 +--- docs/spec/wire-format.md | 14 +- packages/adapter-api/src/index.ts | 14 +- .../adapter-api/tests/conformance.test.ts | 46 +-- packages/conformance-fixtures/src/index.ts | 189 +----------- packages/core/src/identity-bindings.ts | 11 +- packages/core/src/index.ts | 19 +- packages/core/src/install.ts | 219 +------------- packages/core/src/scoped-stack.ts | 3 +- packages/core/src/stack.ts | 105 +------ packages/core/src/types.ts | 12 - packages/core/src/wire-body.ts | 46 +-- packages/core/tests/install.test.ts | 271 +++--------------- packages/core/tests/wire-body.test.ts | 47 +-- packages/wire-types/src/index.ts | 10 +- 18 files changed, 115 insertions(+), 969 deletions(-) delete mode 100644 .changeset/app-install-publishers.md diff --git a/.changeset/app-install-publishers.md b/.changeset/app-install-publishers.md deleted file mode 100644 index 2d84a140..00000000 --- a/.changeset/app-install-publishers.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -'@haverstack/core': minor -'@haverstack/wire-types': minor -'@haverstack/conformance-fixtures': minor -'@haverstack/adapter-api': minor ---- - -App manifests name a `publisher` DID and travel signed: `planInstall()`, `installApp()`, `POST /installs` and `APIAdapter.requestInstall()` take `{ manifest, signature }`, made with `signManifest()` over `manifestPayload()`. A `did:key` publisher verifies with no lookup; any other method needs a `verifyPublisher` callback. A live install pins its publisher on `_install.publisher`, so a manifest under the same `appId` signed by anyone else is refused, whichever key presents it; once uninstalled, a new publisher may take it up, unlinking the old keys. Manifests carry a `release` that may not go backwards. A publisher may certify each key with `certifyKey()`; the app sends the result as `keyCertificate`, and the plan reports `keyCertified`. A plan's `namespaceVerified` is true when a `did:web` publisher's domain, reversed, is the `appId`. diff --git a/.changeset/app-install-review-fixes.md b/.changeset/app-install-review-fixes.md index c70c11dc..8ea110b5 100644 --- a/.changeset/app-install-review-fixes.md +++ b/.changeset/app-install-review-fixes.md @@ -1,6 +1,5 @@ --- '@haverstack/core': minor -'@haverstack/conformance-fixtures': minor --- -Once a key joins an install with the publisher's `keyCertificate`, `_install.keysCertified` is set and a new key presenting none is refused. An installed app's `commitMigration()` is held to the file-reference gate. Requests compare as sets of actions, a plan that changes only `name`, `version` or `release` is no longer empty, and a manifest type carrying a derived Type key is refused on the wire instead of dropped. +An installed app's `commitMigration()` is held to the file-reference gate. Requests compare as sets of actions, and a plan that changes only `name` or `version` is no longer empty. diff --git a/README.md b/README.md index 048aebc9..28bb2252 100644 --- a/README.md +++ b/README.md @@ -41,10 +41,10 @@ await stack.grantType('com.example.myapp/note', { }); ``` -An app can instead ship those steps as a manifest — its types and the grants it asks for, signed by its publisher — which the owner reviews and applies in one call. The stack keeps the approval as an `_install` record, so the grants it made can be listed, upgraded and withdrawn together, and the app can migrate its own types without the owner running its code. See [App installs](./docs/spec/apps.md). +An app can instead ship those steps as a manifest — its types and the grants it asks for — which the owner reviews and applies in one call. The stack keeps the approval as an `_install` record, so the grants it made can be listed, upgraded and withdrawn together, and the app can migrate its own types without the owner running its code. See [App installs](./docs/spec/apps.md). ```ts -const plan = await stack.planInstall(signedManifest, { did: notesAppDid }); // show this to the owner +const plan = await stack.planInstall(manifest, { did: notesAppDid }); // show this to the owner await stack.installApp(plan); ``` diff --git a/docs/spec/apps.md b/docs/spec/apps.md index 07943b45..ce140803 100644 --- a/docs/spec/apps.md +++ b/docs/spec/apps.md @@ -9,18 +9,13 @@ type AppManifest = { appId: AppId; // reverse-DNS, as on an _app card name: string; version?: string; - publisher: string; // the DID that signs the manifest — see Who publishes an app - release: number; // a positive integer the publisher raises with every manifest it signs types: DefineTypeOptions[]; // the types the app defines requests: InstallRequest[]; // the grants its keys hold }; type InstallRequest = { baseId: BaseId; actions: GrantAction[] }; -type SignedManifest = { manifest: AppManifest; signature: string }; // base64url Ed25519 -type InstallSubmission = SignedManifest & { keyCertificate?: string }; // see Certified keys -const signed = await signManifest(manifest, publisherPrivateKey); // the app author, once per release -const plan = await stack.planInstall(signed, { did: appDid }); +const plan = await stack.planInstall(manifest, { did: appDid }); // …show the plan to the owner… await stack.installApp(plan); ``` @@ -36,9 +31,6 @@ type InstallContent = { appId: AppId; // a binding: immutable, unique among installs name: string; version?: string; - publisher: string; // pinned while the install is live - release: number; // the approved manifest's release - keysCertified?: boolean; // set once a key joins with a certificate — see Certified keys defines: TypeId[]; // every version the owner has approved requests: InstallRequest[]; // the grants each of the app's keys holds }; @@ -49,7 +41,6 @@ type InstallContent = { - **It cannot be granted.** `grantType()` refuses `_install` beside `_grant`, `_config` and `_app` (see [Access control § What a grant covers](./access-control.md#what-a-grant-covers)), and a `request` naming any of the four is refused at the write. - **Only the owner acting alone writes one.** `ScopedStack` refuses every write to an `_install` Record on the same terms as a `_grant` Record, whatever the Record's own `permissions` say. - **`appId` is a binding**, immutable and unique on the terms [Identity § DID bindings](./identity.md#did-bindings) sets out: one install answers for each app, and an existing install cannot be relabelled to answer for another. -- **`publisher` is pinned while the install is live**: `planInstall()` refuses a manifest from any other publisher until the owner uninstalls (see [Who publishes an app](#who-publishes-an-app)). It is not unique — one publisher may ship many apps. - **It claims only its own families.** Every `defines` entry must be a versioned TypeId in the install's own namespace (see [Who owns a family](#who-owns-a-family)); anything else is refused with `StackValidationError` on create, patch, migration and restore alike. `appId` being unique is what makes each family's owner single. Every `request` must name a family and actions from the grant vocabulary; `StackValidationError` otherwise. @@ -68,48 +59,16 @@ Two kinds of family belong to no app. **System families** (`_entity`, `_grant`, **Using a family is a request; owning one is control of its schema.** Any app may ask for grants on any grantable family — another app's, a commons one, `_entity` — and the owner sees each such request, with the family's owner, in the plan. Only the owner of a family defines its versions and migrates its records, so two apps can never publish rival versions of the same family. An app that wants to add to records it does not own defines a family of its own and links its records to them with `relationship` associations; the shared family is untouched. -An `appId` is a claim the manifest makes, and [Who publishes an app](#who-publishes-an-app) is what makes it more than one. - -## Who publishes an app - -Every manifest names a **publisher** — a DID belonging to the app's author, distinct from the per-device keys an install links — and carries the publisher's signature over `manifestPayload(manifest)`: the label `haverstack-manifest-v1`, a newline, and the manifest as canonical JSON (keys sorted at every depth, no whitespace), so the same manifest signs the same way however it was serialized. `signManifest(manifest, privateKey)` produces one. `planInstall()` refuses a manifest whose signature does not verify with `StackValidationError` at `signature`, before it looks at anything else the stack holds. - -**A live install is pinned to its publisher.** Once a stack has installed `com.example.notes` from one publisher, a manifest under that `appId` signed by anyone else is refused with `StackConflictError`, whichever key presents it, so no one else can upgrade the install. A publisher that loses its key is in the position [key rotation](./identity.md#deferred-key-rotation) describes: a new key is a new identity. The owner accepts it by uninstalling, after which a manifest from the new publisher may take the install up. The plan says so with `publisherChanged`, and applying it unlinks every key the old publisher's app had linked — each must be installed again — while `defines` and the install's version history carry over. - -**Releases only go forward.** A signature never expires, so `release` is what keeps an older manifest from being replayed over a newer install: `planInstall()` refuses a manifest whose `release` is below the installed one with `StackConflictError`. An equal release is accepted, which is how a second key installs the manifest the first already has. The check does not cross a publisher change, since a new publisher numbers its own releases. - -**The pin is on the publisher, not on the key presenting the manifest.** A signed manifest ships with its app, so any key can copy one and present it as its own. That is what [Certified keys](#certified-keys) are for. - -**How strongly a publisher is known depends on its DID method**, following the [method table](./identity.md): - -- **`did:key`** is verified from the DID itself, with no lookup. That proves the same key signed every manifest an install accepts — trust on first use. It does not prove who holds the key: on a stack the real app never reached, the first manifest under its `appId` is pinned, whoever signed it. The plan names the publisher, so an owner can compare it with the one the app's author publishes. -- **`did:web`** is verified by a `PublisherVerifier` the caller passes as `verifyPublisher` to `planInstall()` and `installApp()`, which resolves the DID document from its domain; core resolves no method but `did:key`, and refuses any other publisher without a verifier rather than taking it on trust. When the `did:web` host, reversed, is the `appId` — `did:web:notes.example.com` for `com.example.notes` — the plan's `namespaceVerified` is true: the domain the namespace names vouches for the manifest. That is the one case where an `appId` is proven rather than claimed, and it inherits a domain's limits: whoever controls the domain controls the app. - -Nothing requires a domain. An app with only a key gets pinning; an app that wants its namespace proven publishes from `did:web`. - -## Certified keys - -A publisher may vouch for a key as one of its app's by signing `keyCertificatePayload({ appId, did })` — the label `haverstack-app-key-v1`, a newline, and `{ appId, did }` as canonical JSON — with `certifyKey()`. The app presents the result as `keyCertificate` beside its signed manifest. `planInstall()` verifies it as it verifies the manifest's signature, and the plan's `keyCertified` says whether the key came with one. - -**It is optional.** An uncertified key may still be approved; the owner is then the only check on whether it is really the app. A certificate that is presented and does not verify for this `appId` and key is refused with `StackValidationError` at `keyCertificate` rather than treated as absent: it is a forgery or a copy. - -**Once one key is certified, every new key must be.** Applying a plan whose key came with a certificate sets `_install.keysCertified`, and from then on `planInstall()` refuses a key not yet linked to the install that presents none, with `StackConflictError`. Without that, a key holding only a copy of the signed manifest could still ask to join an app whose real keys are all certified, and the owner would be the only check. Keys already linked are unaffected; the flag survives uninstalling, and clears only when a [new publisher](#who-publishes-an-app) takes the install up, since the old publisher's certificates say nothing about the new one's keys. - -**It proves what the publisher's issuing does.** For an app the publisher runs on its own servers, the publisher certifies the keys it holds, and a certified key is the app. For an app running on its users' devices, the publisher's private key cannot ship with it, so a device asks the publisher for a certificate — and the certificate then means only what the publisher checked before issuing it: a platform attestation, an account login, or nothing. The owner sees that a publisher vouches for the key; how much that is worth is the publisher's to earn. +**Residual, stated rather than fixed:** `appId` is the app's own claim. Any key can present a manifest under `com.example.notes`. Where that app is not installed, approving it gives the key the app's families. Where it is, approving it links the key to the existing install: the key is registered as that app, holds its grants and may migrate its types, and the manifest's `requests` replace those of every key already linked. The plan names the `appId` asking and the keys already linked, so the owner is the check. Proving who publishes an app is deferred alongside [key rotation](./identity.md#deferred-key-rotation), which is the same problem: tying a key to an identity that outlives it. ## Plan, then apply -`planInstall(submission, { did, verifyPublisher? })` writes nothing. It reports what applying the manifest for that key would change: +`planInstall(manifest, { did })` writes nothing. It reports what applying the manifest for that key would change: ```ts type InstallPlan = { manifest: AppManifest; did: EntityId; - signature: string; // carried so installApp() verifies it again - keyCertificate?: string; // likewise, when one was presented - keyCertified: boolean; // the publisher certified `did` — see Certified keys - publisherChanged: boolean; // an uninstalled install taken up by a new publisher - namespaceVerified: boolean; // a did:web publisher whose domain is the appId reversed existing: (StackRecord & { content: InstallContent }) | null; newFamilies: BaseId[]; // families the install would claim for the first time newVersions: TypeId[]; // versions not yet in `defines` @@ -124,22 +83,20 @@ type InstallPlan = { `typeChanges` lists every manifest type whose definition would write — not yet defined, or defined with a different schema or name — commons types included. Defining a commons type claims nothing, but the first definition of a version fixes its shape for every app that reads it, so the owner sees it like any other. -`linkedKeys` matters most beside `newKey`: a new key on an existing install joins those keys as the same app, and `requestsAdded` and `requestsRemoved` apply to all of them. `keyCertified` is what says whether the publisher vouches for that key (see [Certified keys](#certified-keys)). +`linkedKeys` matters most beside `newKey`: a new key on an existing install joins those keys as the same app (see [Who owns a family](#who-owns-a-family)), and `requestsAdded` and `requestsRemoved` apply to all of them. `foreignRequests` are the requests on families outside the app's own namespace, each naming the family's [owner](#who-owns-a-family): another app's `appId`, `'commons'`, `'system'`, or `null` for a family with no namespace. They are the requests an approval most needs to show — an app asking to read another app's records, or `_entity`, is asking for reach beyond its own data. -It refuses what no approval could make valid: a type outside the app's own namespace and the commons (`StackValidationError` — use a request instead), a type whose schema [`defineType()` would refuse](./data-model.md#types) or that is listed twice (`StackValidationError`), a type already defined whose new schema is not [additive](./data-model.md#schema-drift-detection) (`StackSchemaDriftError`), a request [`grantType()` would refuse](./access-control.md#type-level-grants) (`StackValidationError`), a manifest its publisher did not sign or a key certificate that does not verify (`StackValidationError`), and a manifest under an `appId` live from another publisher, one older than the installed release, a new key with no certificate on an install whose `keysCertified` is set, or a `did` whose `_app` card names a different `appId` (`StackConflictError`). - -A request is compared to the one the install holds by family and the _set_ of its actions, so repeating an action changes nothing. +It refuses what no approval could make valid: a type outside the app's own namespace and the commons (`StackValidationError` — use a request instead), a type whose schema [`defineType()` would refuse](./data-model.md#types) or that is listed twice (`StackValidationError`), a type already defined whose new schema is not [additive](./data-model.md#schema-drift-detection) (`StackSchemaDriftError`), a request [`grantType()` would refuse](./access-control.md#type-level-grants) (`StackValidationError`), and a `did` whose `_app` card names a different `appId` (`StackConflictError`). -`installApp(plan, { verifyPublisher? })` applies the plan: +`installApp(plan)` applies the plan: 1. Defines each of the manifest's types. The plan has already refused a schema `defineType()` would, so a manifest's types are written all or none. 2. Registers the key on an `_app` card when it has none, undeleting a soft-deleted one. An existing card's `name` is left alone: it is the owner's label. -3. Creates the install, or patches it — undeleting it first if it was uninstalled, and unlinking the old keys if its publisher changed. `defines` gains the manifest's versions and never loses any, since a Type once defined stays defined; `requests`, `name`, `version` and `release` become the manifest's, and `keysCertified` is set when the key came with a certificate. +3. Creates the install, or patches it — undeleting it first if it was uninstalled. `defines` gains the manifest's versions and never loses any, since a Type once defined stays defined; `requests` becomes the manifest's. 4. Brings the grants of **every** key linked to the install to exactly `requests`: a grant no longer requested is revoked, a missing one is written, and the links follow. -**Nothing is applied that was not approved.** `installApp()` plans the same manifest again and refuses with `StackConflictError` when the result differs from the plan it was handed — the install changing, a type being defined, or a key being linked, since it was planned. The remedy is to plan again and show the owner the new plan. A plan holds a frozen copy of the manifest it was made from, so changing the caller's object afterwards changes nothing that is applied. Re-applying a manifest whose plan is empty changes nothing. A plan is empty only when the manifest's `name`, `version` and `release` are also the install's, so an upgrade that changes only those still reaches the owner. +**Nothing is applied that was not approved.** `installApp()` plans the same manifest again and refuses with `StackConflictError` when the result differs from the plan it was handed — the install changing, a type being defined, or a key being linked, since it was planned. The remedy is to plan again and show the owner the new plan. A plan holds a frozen copy of the manifest it was made from, so changing the caller's object afterwards changes nothing that is applied. Re-applying a manifest whose plan is empty changes nothing. A plan is empty only when the manifest's `name` and `version` are also the install's, so an upgrade that changes only those still reaches the owner. ## Migrating an installed app's types @@ -177,12 +134,12 @@ Installing the same `appId` again undeletes the install and grants its requests Install has two halves, and only one of them is the same on every server. **The app's half** — present a manifest, learn whether it was approved — is pinned by [`POST /installs`](./wire-format.md#installs), so an app installs itself the same way on any stack. **The owner's half** — review the plan, approve it — is a person deciding, through whatever the server offers (an admin page, a notification); it is not on the wire, and underneath it is `planInstall()` then `installApp()`. ```ts -const result = await adapter.requestInstall({ ...signed, keyCertificate }); // keyCertificate optional +const result = await adapter.requestInstall(manifest); // { status: 'pending' } until the owner approves this manifest for this key, // then { status: 'installed', install } once applying it would change nothing ``` -**The key is the session's; the publisher is the signature's.** The request names no installing DID: the handshake already proved which key is asking, so no app can ask for an install on behalf of a key it does not hold. The manifest's signature proves who published it, so no key can present a manifest its publisher did not sign; a key certificate, when present, proves the publisher vouches for the key presenting it. +**The key is the session's.** The request names no DID: the handshake already proved which key is asking, so no app can ask for an install on behalf of a key it does not hold. **A request is not an approval.** A pending request lives with the server, not in the stack, so a key that merely authenticated writes nothing into the owner's data. The stack holds only what the owner approved. diff --git a/docs/spec/wire-format.md b/docs/spec/wire-format.md index debd0594..a455b20a 100644 --- a/docs/spec/wire-format.md +++ b/docs/spec/wire-format.md @@ -707,18 +707,18 @@ POST /installs — present an app's manifest for the owner to approve How an app holding its own key asks to be installed (see [App installs § Over the wire](./apps.md#over-the-wire)). Only the asking is specified here; the owner approves through whatever the server offers, with `Stack.planInstall()` and `installApp()`. -**The body is `{ "manifest": { … }, "signature": "…", "keyCertificate"?: "…" }`**, a signed `AppManifest`: `appId`, `name`, optional `version`, `publisher`, `release` (a positive integer), `types` (each read as a [`POST /types`](#types) body is, carrying only `id`, `name`, `schema` and `migratesFrom`: a key a Type derives is refused rather than dropped, since dropping a signed key would fail the signature) and `requests` (each `{ baseId, actions }`), with the publisher's base64url signature over it (see [App installs § Who publishes an app](./apps.md#who-publishes-an-app)), and optionally the publisher's base64url certificate for the session's key (see [App installs § Certified keys](./apps.md#certified-keys)). `parseInstallBody()` from `@haverstack/core/wire` reads it, refusing an unknown key at either level with **400** like any other [unrecognized input](#unrecognized-input). **The key being installed is never in the body**: it is the session's principal. +**The body is `{ "manifest": { … } }`**, an `AppManifest`: `appId`, `name`, optional `version`, `types` (each read as a [`POST /types`](#types) body is) and `requests` (each `{ baseId, actions }`). `parseInstallBody()` from `@haverstack/core/wire` reads it, refusing an unknown key at either level with **400** like any other [unrecognized input](#unrecognized-input). **The key being installed is never in the body**: it is the session's principal. **The request must come from the key acting as itself.** A delegated session names someone else as the subject, and an install is for the key that authenticated, so it answers **403** (code `permission`). The server plans the manifest for the session's key and answers with the result: -| Status | Body | When | -| ------ | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `202` | `{ "status": "pending" }` | Applying the manifest would change something. The server queues it for the owner and writes nothing to the stack. | -| `200` | `{ "status": "installed", "install": … }` | The plan is empty — `isPlanEmpty()` from `@haverstack/core` decides — and `install` is the `_install` record, which the key can read. | -| `422` | `validation` | The signature is not the publisher's over this manifest, a `keyCertificate` is not the publisher's for this key, the manifest defines a type outside the app's [own namespace](./apps.md#who-owns-a-family) and the commons, or a request breaks the grant rules. | -| `409` | `conflict` | The `appId` is live from another publisher, the manifest's `release` is older than the installed one, the install takes new keys only with a certificate and none came, or the key's `_app` card names a different `appId`. | +| Status | Body | When | +| ------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| `202` | `{ "status": "pending" }` | Applying the manifest would change something. The server queues it for the owner and writes nothing to the stack. | +| `200` | `{ "status": "installed", "install": … }` | The plan is empty — `isPlanEmpty()` from `@haverstack/core` decides — and `install` is the `_install` record, which the key can read. | +| `422` | `validation` | The manifest defines a type outside the app's [own namespace](./apps.md#who-owns-a-family) and the commons, or a request breaks the grant rules. | +| `409` | `conflict` | The key's `_app` card names a different `appId`. | Re-sending the same manifest is how an app checks: it answers `202` until the owner approves and `200` after. A manifest that changes anything an approved one said — a new version, a changed request — is `202` again, so an upgrade takes the same path as a first install. A client refuses `requestInstall()` locally when discovery does not advertise `installs`, rather than learning it as a `404`. diff --git a/packages/adapter-api/src/index.ts b/packages/adapter-api/src/index.ts index 985a8073..5eb47dcd 100644 --- a/packages/adapter-api/src/index.ts +++ b/packages/adapter-api/src/index.ts @@ -50,7 +50,7 @@ import type { RecordChangeSet, StackCapabilities, MissingCapability, - InstallSubmission, + AppManifest, InstallContent, } from '@haverstack/core'; import type { StackAdapter, SubscribeChangesOptions } from '@haverstack/core/adapter'; @@ -1095,13 +1095,13 @@ export class APIAdapter implements StackAdapter { } /** - * Present this app's signed manifest for the owner to approve, as the - * key this adapter authenticated with. `pending` until the owner approves this + * Present this app's manifest for the owner to approve, as the key this + * adapter authenticated with. `pending` until the owner approves this * manifest for this key; `installed`, with the `_install` record, once * applying it would change nothing. Refused locally when the server does * not advertise install requests. See docs/spec/wire-format.md § Installs. */ - async requestInstall(submission: InstallSubmission): Promise { + async requestInstall(manifest: AppManifest): Promise { if (!this.installRequests) { throw new APIAdapterCapabilityError( 'installs', @@ -1110,11 +1110,7 @@ export class APIAdapter implements StackAdapter { ); } const raw = await this.request('POST', '/installs', { - manifest: submission.manifest, - signature: submission.signature, - ...(submission.keyCertificate !== undefined && { - keyCertificate: submission.keyCertificate, - }), + manifest, }); if (raw?.status === 'pending') return { status: 'pending' }; if (raw?.status === 'installed' && raw.install) { diff --git a/packages/adapter-api/tests/conformance.test.ts b/packages/adapter-api/tests/conformance.test.ts index 15aa12cc..d456a9fa 100644 --- a/packages/adapter-api/tests/conformance.test.ts +++ b/packages/adapter-api/tests/conformance.test.ts @@ -39,8 +39,6 @@ import { getJournalFixtures, commitMigrationFixtures, installRequestFixtures, - INSTALL_FIXTURE_KEY, - INSTALL_FIXTURE_PUBLISHER, discoveryFixtures, errorResponseFixtures, attachmentUploadFixtures, @@ -67,8 +65,6 @@ import { StackSchemaDriftError, StackPayloadTooLargeError, StackTimeoutError, - manifestPayload, - keyCertificatePayload, } from '@haverstack/core'; import { buildAuthChallengePayload, @@ -76,7 +72,7 @@ import { base64urlDecode, didCredentialFromKeypair, } from '@haverstack/core/wire'; -import { generateDidKeypair, verifyDidSignature } from '@haverstack/core/did'; +import { generateDidKeypair } from '@haverstack/core/did'; useFetchMock(); @@ -730,12 +726,6 @@ describe('commitMigration fixtures', () => { // travels at all: it is the session's. // ------------------------------------------------------- -// Forged on purpose, or refused before the signature is read. -const UNSIGNED_INSTALL_FIXTURES = new Set([ - 'install-request-signature-not-the-publishers', - 'install-request-manifest-type-derived-key', -]); - describe('install request fixtures', () => { const openWithInstalls = (): Promise => openDiscovered({ ...DISCOVERY, installs: { requests: true } }, { token: undefined }); @@ -745,7 +735,7 @@ describe('install request fixtures', () => { const adapter = await openWithInstalls(); mockFetch.mockResolvedValueOnce(jsonResponse(fixture.responseBody, fixture.responseStatus)); - const attempt = adapter.requestInstall(fixture.requestBody!); + const attempt = adapter.requestInstall(fixture.requestBody!.manifest); const body = fixture.responseBody!; if ('error' in body) { await expect(attempt).rejects.toBeInstanceOf( @@ -768,41 +758,11 @@ describe('install request fixtures', () => { }); } - // The signatures are real, so a server can verify them rather than trust - // its own derivation of the signed bytes, except in UNSIGNED_INSTALL_FIXTURES. - test('every fixture signature but the unsigned ones is the publisher’s', async () => { - for (const fixture of installRequestFixtures) { - const { manifest, signature } = fixture.requestBody!; - expect(manifest.publisher).toBe(INSTALL_FIXTURE_PUBLISHER); - const valid = await verifyDidSignature( - manifest.publisher, - base64urlDecode(signature), - manifestPayload(manifest), - ); - expect(valid, fixture.name).toBe(!UNSIGNED_INSTALL_FIXTURES.has(fixture.name)); - } - }); - - test('every fixture key certificate but the misdirected one is the publisher’s', async () => { - for (const fixture of installRequestFixtures) { - const { manifest, keyCertificate } = fixture.requestBody!; - if (keyCertificate === undefined) continue; - const valid = await verifyDidSignature( - manifest.publisher, - base64urlDecode(keyCertificate), - keyCertificatePayload({ appId: manifest.appId, did: INSTALL_FIXTURE_KEY }), - ); - expect(valid, fixture.name).toBe( - fixture.name !== 'install-request-certificate-not-for-this-key', - ); - } - }); - test('a server advertising no install requests is refused locally', async () => { const adapter = await openAdapter(); const calls = mockFetch.mock.calls.length; await expect( - adapter.requestInstall(installRequestFixtures[0]!.requestBody!), + adapter.requestInstall(installRequestFixtures[0]!.requestBody!.manifest), ).rejects.toBeInstanceOf(APIAdapterCapabilityError); expect(mockFetch.mock.calls.length).toBe(calls); }); diff --git a/packages/conformance-fixtures/src/index.ts b/packages/conformance-fixtures/src/index.ts index cd89f0df..c93d0d1f 100644 --- a/packages/conformance-fixtures/src/index.ts +++ b/packages/conformance-fixtures/src/index.ts @@ -2167,40 +2167,15 @@ export const commitMigrationFixtures: ConformanceFixture< // its own key for (docs/spec/wire-format.md § Installs). The owner // approves out of band; these pin only what the app sees. The key being // installed is always the session's — the body never names one. -// -// Every manifest carries a real Ed25519 signature by INSTALL_FIXTURE_PUBLISHER -// over manifestPayload() from @haverstack/core, and every key certificate -// one over keyCertificatePayload(), so a server can verify one rather than -// trusting its own derivation of the signed bytes. - -/** The publisher whose key signed the install fixtures' manifests. */ -export const INSTALL_FIXTURE_PUBLISHER = 'did:key:z6Mkf2zQmvB1cfYsgtiWAJu9F9axVsp95LFGw8TVhkf6BpBw'; - -/** The session key the certified fixtures are certified for. */ -export const INSTALL_FIXTURE_KEY = 'did:key:z6Mkfsz9oK6i2355mvEwtDYdAmqCN6kmQETThJtARfj9iGum'; const INSTALL_MANIFEST: WireInstallRequest['manifest'] = { appId: 'com.example.notes', name: 'Notes', version: '1.0.0', - publisher: INSTALL_FIXTURE_PUBLISHER, - release: 1, types: [{ id: 'com.example.notes/note@1', name: 'Note', schema: { text: { kind: 'text' } } }], requests: [{ baseId: 'com.example.notes/note', actions: ['create', 'read-any'] }], }; -const SIGNED_INSTALL: WireInstallRequest = { - manifest: INSTALL_MANIFEST, - signature: - '9YCWMoIrzmJQUcQCO30ijsrYtAg4Nu6fxCF0k-qFHU2p-dHgvtOEeeP1yeNcT-8qByvLO7-Qe7hMZEr3q0_zBA', -}; - -const CERTIFIED_INSTALL: WireInstallRequest = { - ...SIGNED_INSTALL, - keyCertificate: - '0DUpaK8BYrGUWLQYQCmNrMEo81zsdNHV913q_Oa5L-FV2yoHBQEQdExRDE93pFF3rCr3Z1QHBQKReuV6ZSXtDA', -}; - export const installRequestFixtures: ConformanceFixture< WireInstallRequest, WireInstallResponse | WireError @@ -2215,71 +2190,10 @@ export const installRequestFixtures: ConformanceFixture< 'adding a version or changing a request — is pending again in the same way.', method: 'POST', path: '/installs', - requestBody: SIGNED_INSTALL, - responseStatus: 202, - responseBody: { status: 'pending' }, - }, - { - name: 'install-request-certified-key-pending', - description: - "POST /installs carrying keyCertificate — the publisher's signature over " + - "keyCertificatePayload({ appId, did }) for the session's key — answers 202 pending like " + - 'any other request; the certificate lets the owner see the publisher vouches for this ' + - `key. Assumes the session's DID is ${INSTALL_FIXTURE_KEY}. See docs/spec/apps.md ` + - '§ Certified keys.', - method: 'POST', - path: '/installs', - requestBody: CERTIFIED_INSTALL, + requestBody: { manifest: INSTALL_MANIFEST }, responseStatus: 202, responseBody: { status: 'pending' }, }, - { - name: 'install-request-certificate-not-for-this-key', - description: - "POST /installs whose keyCertificate does not verify for the session's key — here one " + - 'the publisher issued to a different key — answers 422 with code "validation" at ' + - '"keyCertificate". A certificate that is presented and wrong is refused, never treated ' + - "as an uncertified key: it is a forgery or a copied certificate. Assumes the session's " + - `DID is ${INSTALL_FIXTURE_KEY}.`, - method: 'POST', - path: '/installs', - requestBody: { - ...SIGNED_INSTALL, - keyCertificate: - 'Zqqu77XqEmDFiKDOKbHgESPoPivm7BAHq8CrS98qK53YruMbkEuVoRkwocNzt_v7sta-cLYjYi1Wzj7xxR2sDA', - }, - responseStatus: 422, - responseBody: { - error: { - code: 'validation', - message: 'Content validation failed', - details: [ - { - path: 'keyCertificate', - message: 'The signature is not the publisher’s over this key', - }, - ], - }, - }, - }, - { - name: 'install-request-older-release', - description: - 'POST /installs with a manifest whose release is lower than the installed one answers ' + - '409 with code "conflict". A signature never expires, so the release is what keeps an ' + - 'older signed manifest from being replayed over a newer install. Assumes ' + - '"com.example.notes" is installed from the same publisher at release 2.', - method: 'POST', - path: '/installs', - requestBody: SIGNED_INSTALL, - responseStatus: 409, - responseBody: { - error: { - code: 'conflict', - message: '"com.example.notes" is installed at release 2; this manifest is release 1', - }, - }, - }, { name: 'install-request-already-installed', description: @@ -2290,7 +2204,7 @@ export const installRequestFixtures: ConformanceFixture< "approved exactly this manifest for the session's key.", method: 'POST', path: '/installs', - requestBody: SIGNED_INSTALL, + requestBody: { manifest: INSTALL_MANIFEST }, responseStatus: 200, responseBody: { status: 'installed', @@ -2303,8 +2217,6 @@ export const installRequestFixtures: ConformanceFixture< appId: 'com.example.notes', name: 'Notes', version: '1.0.0', - publisher: INSTALL_FIXTURE_PUBLISHER, - release: 1, defines: ['com.example.notes/note@1'], requests: [{ baseId: 'com.example.notes/note', actions: ['create', 'read-any'] }], }, @@ -2326,8 +2238,6 @@ export const installRequestFixtures: ConformanceFixture< ...INSTALL_MANIFEST, types: [{ id: 'com.example.tags/tag@1', name: 'Tag', schema: {} }], }, - signature: - 'BgnK_89lwtE_kwWEVDibw_UEug8Z0DCwzenqNZorq0RDDnKWzPISdzWyji2HOknIFWTUokw5SjfkDZKJf-MHDA', }, responseStatus: 422, responseBody: { @@ -2345,57 +2255,6 @@ export const installRequestFixtures: ConformanceFixture< }, }, }, - { - name: 'install-request-signature-not-the-publishers', - description: - "POST /installs whose signature is not the publisher's over this manifest — here a " + - 'request widened after signing — answers 422 with code "validation" at "signature". ' + - "The publisher's signature is what lets an install refuse anyone else's upgrade, so a " + - 'manifest that does not carry one is not considered at all. See docs/spec/apps.md § Who ' + - 'publishes an app.', - method: 'POST', - path: '/installs', - requestBody: { - manifest: { - ...INSTALL_MANIFEST, - requests: [{ baseId: 'com.example.notes/note', actions: ['read-any', 'update-any'] }], - }, - signature: SIGNED_INSTALL.signature, - }, - responseStatus: 422, - responseBody: { - error: { - code: 'validation', - message: 'Content validation failed', - details: [ - { - path: 'signature', - message: 'The signature is not the publisher’s over this manifest', - }, - ], - }, - }, - }, - { - name: 'install-request-publisher-pinned', - description: - 'POST /installs for an appId already installed from another publisher answers 409 with ' + - 'code "conflict", whichever key asks. A live install is pinned to its publisher, so only ' + - 'a manifest the same publisher signed can upgrade it or link another key to it. Assumes ' + - '"com.example.notes" is installed from did:web:notes.example.com.', - method: 'POST', - path: '/installs', - requestBody: SIGNED_INSTALL, - responseStatus: 409, - responseBody: { - error: { - code: 'conflict', - message: - '"com.example.notes" is installed from did:web:notes.example.com; this manifest is ' + - `signed by ${INSTALL_FIXTURE_PUBLISHER}`, - }, - }, - }, { name: 'install-request-key-registered-to-another-app', description: @@ -2404,7 +2263,7 @@ export const installRequestFixtures: ConformanceFixture< 'Assumes the session\'s DID is registered to "com.example.other".', method: 'POST', path: '/installs', - requestBody: SIGNED_INSTALL, + requestBody: { manifest: INSTALL_MANIFEST }, responseStatus: 409, responseBody: { error: { @@ -2423,52 +2282,12 @@ export const installRequestFixtures: ConformanceFixture< 'itself; a delegated token names someone else as the subject.', method: 'POST', path: '/installs', - requestBody: SIGNED_INSTALL, + requestBody: { manifest: INSTALL_MANIFEST }, responseStatus: 403, responseBody: { error: { code: 'permission', message: 'An install request must come from the key itself' }, }, }, - { - name: 'install-request-key-not-certified', - description: - 'POST /installs from a key not yet linked to an install whose keysCertified is set, ' + - 'presenting no keyCertificate, answers 409 with code "conflict". Once a key has joined ' + - "with the publisher's certificate, every new key needs one, so a copy of the signed " + - 'manifest is not enough to join. Assumes "com.example.notes" is installed from ' + - "INSTALL_FIXTURE_PUBLISHER with keysCertified set, and the session's key is not linked to it.", - method: 'POST', - path: '/installs', - requestBody: SIGNED_INSTALL, - responseStatus: 409, - responseBody: { - error: { - code: 'conflict', - message: `"com.example.notes" takes new keys only with a keyCertificate from ${INSTALL_FIXTURE_PUBLISHER}`, - }, - }, - }, - { - name: 'install-request-manifest-type-derived-key', - description: - 'POST /installs whose manifest type carries a key a Type derives — here schemaHash — ' + - 'answers 400 with code "bad_request", before any signature is checked. A manifest type ' + - 'is id, name, schema and migratesFrom; a derived key is refused rather than dropped, ' + - 'since dropping a signed key would fail the signature.', - method: 'POST', - path: '/installs', - requestBody: { - manifest: { - ...INSTALL_MANIFEST, - types: [{ ...INSTALL_MANIFEST.types[0]!, schemaHash: 'sha256:0' } as never], - }, - signature: SIGNED_INSTALL.signature, - }, - responseStatus: 400, - responseBody: { - error: { code: 'bad_request', message: 'Unknown key in manifest.types[0]: schemaHash' }, - }, - }, ]; // ------------------------------------------------------- diff --git a/packages/core/src/identity-bindings.ts b/packages/core/src/identity-bindings.ts index a45b8bc9..78d0f9d0 100644 --- a/packages/core/src/identity-bindings.ts +++ b/packages/core/src/identity-bindings.ts @@ -16,10 +16,7 @@ import { SYSTEM_TYPES } from './types.js'; * claims one, and something later resolves through it. Every one of them is * immutable once set. See docs/spec/identity.md § DID bindings. */ -/** A content field something resolves through. */ -export type BindingField = 'did' | 'appId'; - -const BINDING_FIELDS: ReadonlyMap = new Map([ +const BINDING_FIELDS: ReadonlyMap = new Map([ [SYSTEM_TYPES.APP, ['did', 'appId'] as const], [SYSTEM_TYPES.ENTITY, ['did'] as const], [SYSTEM_TYPES.INSTALL, ['appId'] as const], @@ -40,15 +37,15 @@ const BINDING_FIELDS: ReadonlyMap = new Map([ * card onto another's `appId` is what immutability already refuses. * See docs/spec/identity.md § DID bindings. */ -const UNIQUE_BINDING_FIELDS: ReadonlyMap = new Map([ +const UNIQUE_BINDING_FIELDS: ReadonlyMap = new Map([ [SYSTEM_TYPES.APP, ['did'] as const], [SYSTEM_TYPES.ENTITY, ['did'] as const], // One install answers for each app; see docs/spec/apps.md § The `_install` record. [SYSTEM_TYPES.INSTALL, ['appId'] as const], ]); -export const bindingFieldsOf = (family: string): readonly BindingField[] => +export const bindingFieldsOf = (family: string): readonly ('did' | 'appId')[] => BINDING_FIELDS.get(family) ?? []; -export const uniqueBindingFieldsOf = (family: string): readonly BindingField[] => +export const uniqueBindingFieldsOf = (family: string): readonly ('did' | 'appId')[] => UNIQUE_BINDING_FIELDS.get(family) ?? []; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6da74cba..d7fd3f76 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -35,23 +35,8 @@ export type { CollectAttachmentGarbageResult, MigrateAllOptions, } from './stack.js'; -export type { - AppManifest, - InstallPlan, - ForeignRequest, - TypeChange, - SignedManifest, - InstallSubmission, - PublisherVerifier, -} from './install.js'; -export { - isPlanEmpty, - signManifest, - manifestPayload, - appIdVouchedBy, - certifyKey, - keyCertificatePayload, -} from './install.js'; +export type { AppManifest, InstallPlan, ForeignRequest, TypeChange } from './install.js'; +export { isPlanEmpty } from './install.js'; // Type handles export { typeHandle } from './type-handle.js'; diff --git a/packages/core/src/install.ts b/packages/core/src/install.ts index d02f5934..ad9892f0 100644 --- a/packages/core/src/install.ts +++ b/packages/core/src/install.ts @@ -14,22 +14,14 @@ * families without the owner running its code — see * ScopedStack.commitMigration(). * - * A manifest is signed by its publisher's key and numbered by release. - * A live install pins that publisher and refuses an older release, so only - * a newer manifest the same publisher signed can upgrade it. A publisher - * may also certify each key of its app, which the plan reports. - * * This module holds the parts that read an install as data: the write-time - * shape rules, the family claim, manifest and key signing, and the plan diff. The verbs that write + * shape rules, the family claim, and the plan diff. The verbs that write * live on `Stack`. See docs/spec/apps.md. */ import { baseIdOf, familyIdProblem, parseTypeId } from './schema.js'; import { GRANT_ACTION_SET, UNGRANTABLE_SYSTEM_TYPES } from './grants.js'; import { SYSTEM_TYPES } from './types.js'; -import { isValidDidKey, signWithDid, verifyDidSignature } from './did.js'; -import { base64urlDecode, base64urlEncode } from './auth.js'; -import { StackValidationError } from './errors.js'; import type { AppId, AuthorityAssociation, @@ -52,49 +44,10 @@ export type AppManifest = { appId: AppId; name: string; version?: string; - /** - * The DID of whoever publishes the app — a key of the author's, not one - * of the per-device keys being installed. See docs/spec/apps.md - * § Who publishes an app. - */ - publisher: string; - /** - * A positive integer the publisher raises with every manifest it signs, - * so an older one cannot be replayed over a newer install. - * See docs/spec/apps.md § Who publishes an app. - */ - release: number; types: DefineTypeOptions[]; requests: InstallRequest[]; }; -/** A manifest with its publisher's signature over manifestPayload(). */ -export type SignedManifest = { - manifest: AppManifest; - /** base64url Ed25519 signature. */ - signature: string; -}; - -/** - * What an install request carries: the signed manifest and, optionally, - * the publisher's certificate for the key being installed. - * See docs/spec/apps.md § Certified keys. - */ -export type InstallSubmission = SignedManifest & { - /** base64url Ed25519 signature by the publisher over keyCertificatePayload(). */ - keyCertificate?: string; -}; - -/** - * Verifies a signature by a publisher whose DID method core does not - * resolve — `did:web`, say. `did:key` needs none: its public key is the DID. - */ -export type PublisherVerifier = ( - publisher: string, - signature: Uint8Array, - payload: Uint8Array, -) => Promise; - /** A request on a family this install does not define, and who owns that family. */ export type ForeignRequest = InstallRequest & { /** @@ -124,26 +77,6 @@ export type InstallPlan = { manifest: AppManifest; /** The key being installed. */ did: EntityId; - /** The publisher's signature over `manifest`, carried so the plan can be verified again. */ - signature: string; - /** The publisher's certificate for `did`, when one was presented. */ - keyCertificate?: string; - /** - * Whether the publisher certified `did` as a key of this app. An - * uncertified key may still be approved; the owner is then the only check. - */ - keyCertified: boolean; - /** - * Whether an uninstalled install is being taken up by a different - * publisher. Its keys are unlinked, so each must be installed again. - */ - publisherChanged: boolean; - /** - * Whether the publisher's DID is a `did:web` whose domain is the - * `appId` reversed — the domain vouching for the namespace, beyond the - * key alone. See docs/spec/apps.md § Who publishes an app. - */ - namespaceVerified: boolean; /** The install as it stood when planned — null for a first install. */ existing: (StackRecord & { content: InstallContent }) | null; /** Families this install would claim that it does not claim yet. */ @@ -320,149 +253,7 @@ function deepFreeze(value: T): T { return value; } -const MANIFEST_PAYLOAD_LABEL = 'haverstack-manifest-v1'; - -/** - * The bytes a publisher signs: a label, then the manifest as canonical - * JSON — keys sorted at every depth, no whitespace — so the same manifest - * signs the same way however it was serialized. - */ -export function manifestPayload(manifest: AppManifest): Uint8Array { - return new TextEncoder().encode(`${MANIFEST_PAYLOAD_LABEL}\n${canonicalJson(manifest)}`); -} - -function canonicalJson(value: unknown): string { - if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`; - if (typeof value === 'object' && value !== null) { - const entries = Object.keys(value) - .filter((k) => (value as Record)[k] !== undefined) - .sort() - .map((k) => `${JSON.stringify(k)}:${canonicalJson((value as Record)[k])}`); - return `{${entries.join(',')}}`; - } - return JSON.stringify(value); -} - -/** Sign `manifest` with the private key behind its `publisher`. */ -export async function signManifest( - manifest: AppManifest, - privateKey: CryptoKey, -): Promise { - const signature = await signWithDid(privateKey, manifestPayload(manifest)); - return { manifest, signature: base64urlEncode(signature) }; -} - -/** - * Whether `publisher` signed `payload`. A `did:key` publisher is verified - * from the DID itself; any other method needs `verifier`, and is refused - * without one rather than taken on trust. - */ -async function assertPublisherSigned( - publisher: string, - encoded: string, - payload: Uint8Array, - path: string, - what: string, - verifier?: PublisherVerifier, -): Promise { - const fail = (at: string, message: string): never => { - throw new StackValidationError([{ path: at, message }]); - }; - if (typeof publisher !== 'string' || !publisher.startsWith('did:')) { - fail('manifest.publisher', 'Expected the publisher’s DID'); - } - let signature: Uint8Array; - try { - signature = base64urlDecode(encoded); - } catch { - return fail(path, 'Expected a base64url signature'); - } - let valid: boolean; - if (isValidDidKey(publisher)) { - valid = await verifyDidSignature(publisher, signature, payload).catch(() => false); - } else if (verifier) { - valid = await verifier(publisher, signature, payload); - } else { - return fail( - 'manifest.publisher', - `Cannot verify a ${publisher.split(':')[1]} publisher without a verifier for that DID method`, - ); - } - if (!valid) fail(path, `The signature is not the publisher’s over this ${what}`); -} - -/** Refuse a manifest its publisher did not sign. */ -export async function assertManifestSigned( - signed: SignedManifest, - verifier?: PublisherVerifier, -): Promise { - await assertPublisherSigned( - signed.manifest.publisher, - signed.signature, - manifestPayload(signed.manifest), - 'signature', - 'manifest', - verifier, - ); -} - -const KEY_CERTIFICATE_LABEL = 'haverstack-app-key-v1'; - -/** - * The bytes a publisher signs to certify `did` as a key of `appId`. The - * label keeps a certificate from ever verifying as a manifest, or the - * reverse. - */ -export function keyCertificatePayload(cert: { appId: AppId; did: EntityId }): Uint8Array { - return new TextEncoder().encode( - `${KEY_CERTIFICATE_LABEL}\n${canonicalJson({ appId: cert.appId, did: cert.did })}`, - ); -} - -/** Certify `did` as a key of `appId`, with the private key behind the app's publisher. */ -export async function certifyKey( - cert: { appId: AppId; did: EntityId }, - privateKey: CryptoKey, -): Promise { - return base64urlEncode(await signWithDid(privateKey, keyCertificatePayload(cert))); -} - -/** - * Refuse a certificate that does not verify: one presented and wrong is a - * forgery or a mistake, not an uncertified key. - */ -export async function assertKeyCertified( - manifest: AppManifest, - did: EntityId, - keyCertificate: string, - verifier?: PublisherVerifier, -): Promise { - await assertPublisherSigned( - manifest.publisher, - keyCertificate, - keyCertificatePayload({ appId: manifest.appId, did }), - 'keyCertificate', - 'key', - verifier, - ); -} - -/** - * The `appId` a `did:web` publisher's domain vouches for: its host - * reversed, so `did:web:notes.example.com` vouches for `com.example.notes`. - * Null for any other DID, or a `did:web` with a path or port. - */ -export function appIdVouchedBy(publisher: string): AppId | null { - const match = /^did:web:([a-z0-9.-]+)$/i.exec(publisher); - if (!match) return null; - return match[1]!.toLowerCase().split('.').reverse().join('.'); -} - -/** - * Whether two requests ask for the same family and the same set of - * actions. Compared as sets, so a repeated action can't pad one list to - * the other's length. - */ +/** Whether two requests ask for the same family and exactly the same actions. */ export function sameRequest(a: InstallRequest, b: InstallRequest): boolean { if (a.baseId !== b.baseId) return false; const as = new Set(a.actions); @@ -484,7 +275,7 @@ export function grantIsRequest( /** * Whether applying `plan` would change nothing: the install is live, this * key is linked to it, the manifest adds and removes nothing, and its - * `name`, `version` and `release` are the ones the install holds. A server + * `name` and `version` are the ones the install holds. A server * answers such a request as already installed rather than queuing it. * See docs/spec/wire-format.md § Installs. */ @@ -495,7 +286,6 @@ export function isPlanEmpty(plan: InstallPlan): boolean { !plan.newKey && plan.manifest.name === plan.existing.content.name && (plan.manifest.version ?? null) === (plan.existing.content.version ?? null) && - plan.manifest.release === plan.existing.content.release && plan.newFamilies.length === 0 && plan.newVersions.length === 0 && plan.typeChanges.length === 0 && @@ -521,8 +311,5 @@ export function planFingerprint(plan: InstallPlan): string { plan.typeChanges, plan.newKey, plan.linkedKeys, - plan.namespaceVerified, - plan.keyCertified, - plan.publisherChanged, ]); } diff --git a/packages/core/src/scoped-stack.ts b/packages/core/src/scoped-stack.ts index 2f551bd1..96e18406 100644 --- a/packages/core/src/scoped-stack.ts +++ b/packages/core/src/scoped-stack.ts @@ -90,7 +90,6 @@ import { UNGRANTABLE_SYSTEM_TYPES, } from './grants.js'; import { bindingFieldsOf } from './identity-bindings.js'; -import type { BindingField } from './identity-bindings.js'; import { claimedFamilies, familyStanding, linkedIds, INSTALL_APP_LABEL } from './install.js'; import { assertAttachmentSize } from './limits.js'; import { validateIdTimestampSkew, validateRecordId } from './record-id.js'; @@ -1276,7 +1275,7 @@ export class ScopedStack implements StackClient { */ private requireOwnerForAppIdentity( typeId: TypeId, - touches: (field: BindingField) => boolean, + touches: (field: 'did' | 'appId') => boolean, ): void { if (baseIdOf(typeId) !== SYSTEM_TYPES.APP) return; if (!bindingFieldsOf(SYSTEM_TYPES.APP).some(touches)) return; diff --git a/packages/core/src/stack.ts b/packages/core/src/stack.ts index 28ee90cf..99c76322 100644 --- a/packages/core/src/stack.ts +++ b/packages/core/src/stack.ts @@ -125,11 +125,7 @@ import { } from './grants.js'; import type { GrantQuery } from './grants.js'; import { bindingFieldsOf, uniqueBindingFieldsOf } from './identity-bindings.js'; -import type { BindingField } from './identity-bindings.js'; import { - appIdVouchedBy, - assertKeyCertified, - assertManifestSigned, claimedFamilies, familyStanding, grantIsRequest, @@ -146,7 +142,6 @@ import { INSTALL_APP_LABEL, INSTALL_GRANT_LABEL, } from './install.js'; -import type { InstallSubmission, PublisherVerifier } from './install.js'; import type { AppManifest, ForeignRequest, InstallPlan, TypeChange } from './install.js'; import { assertAttachmentSize, assertContentSize } from './limits.js'; import { @@ -2187,7 +2182,7 @@ export class Stack implements StackClient { */ private async checkBindingUnique( family: string, - field: BindingField, + field: 'did' | 'appId', value: unknown, excludeId?: RecordId, ): Promise { @@ -2223,7 +2218,7 @@ export class Stack implements StackClient { */ private checkBindingImmutable( family: string, - field: BindingField, + field: 'did' | 'appId', existing: unknown, next: unknown, ): void { @@ -2859,36 +2854,14 @@ export class Stack implements StackClient { * the commons, a request the grant rules refuse, or a `did` already * registered to a different app. See docs/spec/apps.md § Plan, then apply. */ - async planInstall( - submission: InstallSubmission, - opts: { did: EntityId; verifyPublisher?: PublisherVerifier }, - ): Promise { + async planInstall(submitted: AppManifest, opts: { did: EntityId }): Promise { this.assertOpen(); - const { did, verifyPublisher } = opts; - const manifest = snapshotManifest(submission.manifest); + const { did } = opts; + const manifest = snapshotManifest(submitted); this.checkManifest(manifest, did); - await assertManifestSigned({ manifest, signature: submission.signature }, verifyPublisher); - const { keyCertificate } = submission; - if (keyCertificate !== undefined) { - await assertKeyCertified(manifest, did, keyCertificate, verifyPublisher); - } const installs = await this.loadInstalls(); const existing = installs.find((r) => r.content.appId === manifest.appId) ?? null; - // A live install is pinned to its publisher; an uninstalled one may be - // taken up by another, which is how an owner accepts a rotated key. - // See docs/spec/apps.md § Who publishes an app. - const publisherChanged = !!existing && existing.content.publisher !== manifest.publisher; - if (existing && publisherChanged && !existing.deletedAt) { - throw new StackConflictError( - `"${manifest.appId}" is installed from ${existing.content.publisher}; this manifest is signed by ${manifest.publisher}`, - ); - } - if (existing && !publisherChanged && manifest.release < existing.content.release) { - throw new StackConflictError( - `"${manifest.appId}" is installed at release ${existing.content.release}; this manifest is release ${manifest.release}`, - ); - } const ownVersions = ownTypeIds(manifest); const ownFamilies = new Set(ownVersions.map(baseIdOf)); @@ -2929,17 +2902,7 @@ export class Stack implements StackClient { } const linkedKeys: EntityId[] = []; - const keptLinks = existing && !publisherChanged ? linkedIds(existing, INSTALL_APP_LABEL) : []; - const newKey = !card || !keptLinks.includes(card.id); - // Once the publisher has certified a key, an uncertified one joining is - // a downgrade only a copied manifest needs. See docs/spec/apps.md § Certified keys. - const certifiesKeys = !publisherChanged && existing?.content.keysCertified === true; - if (newKey && keyCertificate === undefined && certifiesKeys) { - throw new StackConflictError( - `"${manifest.appId}" takes new keys only with a keyCertificate from ${manifest.publisher}`, - ); - } - for (const id of keptLinks) { + for (const id of existing ? linkedIds(existing, INSTALL_APP_LABEL) : []) { const key = ((await this.get(id))?.content as AppContent | undefined)?.did; if (typeof key === 'string') linkedKeys.push(key); } @@ -2947,11 +2910,6 @@ export class Stack implements StackClient { return { manifest, did, - signature: submission.signature, - ...(keyCertificate !== undefined && { keyCertificate }), - keyCertified: keyCertificate !== undefined, - publisherChanged, - namespaceVerified: appIdVouchedBy(manifest.publisher) === manifest.appId, existing, newFamilies: [...ownFamilies].filter((f) => !claimed.has(f)), newVersions: ownVersions.filter((id) => !defined.has(id)), @@ -2959,7 +2917,7 @@ export class Stack implements StackClient { requestsRemoved: prior.filter((p) => !manifest.requests.some((r) => sameRequest(p, r))), foreignRequests, typeChanges, - newKey, + newKey: !existing || !card || !linkedIds(existing, INSTALL_APP_LABEL).includes(card.id), linkedKeys, }; } @@ -2972,25 +2930,15 @@ export class Stack implements StackClient { * what is applied is what was approved. Reinstalls a soft-deleted * install. See docs/spec/apps.md § Plan, then apply. */ - async installApp( - plan: InstallPlan, - opts: { verifyPublisher?: PublisherVerifier } = {}, - ): Promise { + async installApp(plan: InstallPlan): Promise { this.assertOpen(); - const fresh = await this.planInstall( - { - manifest: plan.manifest, - signature: plan.signature, - ...(plan.keyCertificate !== undefined && { keyCertificate: plan.keyCertificate }), - }, - { did: plan.did, verifyPublisher: opts.verifyPublisher }, - ); + const fresh = await this.planInstall(plan.manifest, { did: plan.did }); if (planFingerprint(fresh) !== planFingerprint(plan)) { throw new StackConflictError( `The stack changed since the install of "${plan.manifest.appId}" was planned; plan it again`, ); } - const { manifest, did, existing, publisherChanged } = fresh; + const { manifest, did, existing } = fresh; for (const type of manifest.types) await this.defineType(type); const card = await this.ensureAppCard(manifest, did); @@ -3009,39 +2957,16 @@ export class Stack implements StackClient { appId: manifest.appId, name: manifest.name, ...(manifest.version !== undefined && { version: manifest.version }), - publisher: manifest.publisher, - release: manifest.release, - ...(fresh.keyCertified && { keysCertified: true }), defines, requests, }, { associations: [installAppLink(card.id)] }, ); } else { - let current = existing.deletedAt ? await this.undelete(existing.id) : existing; - const oldKeys = publisherChanged ? linkedIds(current, INSTALL_APP_LABEL) : []; - if (oldKeys.length > 0) { - // The keys the old publisher's app linked are not this publisher's. - current = await this.amendAssociations( - current.id, - oldKeys.map((id) => ({ op: 'remove' as const, association: installAppLink(id) })), - ); - } + const current = existing.deletedAt ? await this.undelete(existing.id) : existing; install = await this.patchContent( existing.id, - { - name: manifest.name, - version: manifest.version ?? null, - publisher: manifest.publisher, - release: manifest.release, - // A new publisher's certificates start afresh; the old one's bound nothing it signs. - keysCertified: - fresh.keyCertified || (!publisherChanged && existing.content.keysCertified) - ? true - : null, - defines, - requests, - }, + { name: manifest.name, version: manifest.version ?? null, defines, requests }, { ifVersion: current.version }, ); if (!linkedIds(install, INSTALL_APP_LABEL).includes(card.id)) { @@ -3087,9 +3012,6 @@ export class Stack implements StackClient { if (typeof manifest.name !== 'string' || manifest.name === '') { errors.push({ path: 'name', message: 'Expected a non-empty name' }); } - if (!Number.isSafeInteger(manifest.release) || manifest.release < 1) { - errors.push({ path: 'release', message: 'Expected a positive integer release' }); - } const seen = new Set(); manifest.types.forEach((t, i) => { if (seen.has(t.id)) { @@ -3359,9 +3281,6 @@ export class Stack implements StackClient { appId: { kind: 'string', required: true }, name: { kind: 'string', required: true }, version: { kind: 'string' }, - publisher: { kind: 'string', required: true }, - release: { kind: 'number', required: true }, - keysCertified: { kind: 'boolean' }, defines: { kind: 'array', items: { kind: 'string' }, required: true }, requests: { kind: 'array', diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 8ee9d12c..50ec8be9 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -513,18 +513,6 @@ export type InstallContent = { appId: AppId; name: string; version?: string; - /** - * The DID that signed the approved manifest. While the install is live, - * only a manifest the same publisher signed can change it. - */ - publisher: string; - /** The approved manifest's release; an older one is refused. */ - release: number; - /** - * Set once a key joins with the publisher's certificate. From then on a - * new key joins only with one. See docs/spec/apps.md § Certified keys. - */ - keysCertified?: boolean; /** * Every type version the owner has approved for this app. The families * they name are claimed by this install and by no other. diff --git a/packages/core/src/wire-body.ts b/packages/core/src/wire-body.ts index fa3c3848..f3ba89c2 100644 --- a/packages/core/src/wire-body.ts +++ b/packages/core/src/wire-body.ts @@ -20,7 +20,7 @@ import { StackBadRequestError, StackValidationError } from './errors.js'; import type { DefineTypeOptions } from './stack.js'; import { assertKnownKeys, validateAssociation } from './query-validation.js'; -import type { AppManifest, InstallSubmission } from './install.js'; +import type { AppManifest } from './install.js'; import type { AssociationEdit, GrantAction, StackType, TypeId, TypeSchema } from './types.js'; export function requireBody(body: unknown, label: string): Record { @@ -225,44 +225,25 @@ export function parseMigrationBody(body: unknown): WireMigrationRequest { // POST /installs // ------------------------------------------------------- -/** Every key a manifest type carries — a `DefineTypeOptions`'s. */ -const MANIFEST_TYPE_KEYS: readonly string[] = Object.keys({ - id: true, - name: true, - schema: true, - migratesFrom: true, -} satisfies Record); - /** - * Parse a `POST /installs` body, `{ manifest, signature, keyCertificate? }`, - * into the submission `planInstall()` takes. Each type is read as a - * `POST /types` body is, less the keys `defineType()` derives. + * Parse a `POST /installs` body, `{ manifest }`, into the manifest + * `planInstall()` takes. Each type is read as a `POST /types` body is. * Which families a manifest may define, and which requests the grant rules * allow, are `planInstall()`'s to judge. See docs/spec/wire-format.md § Installs. */ -export function parseInstallBody(body: unknown): InstallSubmission { - const b = requireKnownBody(body, ['manifest', 'signature', 'keyCertificate'], 'install body'); - const signature = requiredString(b, 'signature', 'install body'); - if (b.keyCertificate !== undefined && typeof b.keyCertificate !== 'string') { - fieldError('keyCertificate', 'keyCertificate must be a string'); - } +export function parseInstallBody(body: unknown): AppManifest { + const b = requireKnownBody(body, ['manifest'], 'install body'); const m = requireKnownBody( requiredObject(b, 'manifest', 'install body'), - ['appId', 'name', 'version', 'publisher', 'release', 'types', 'requests'], + ['appId', 'name', 'version', 'types', 'requests'], 'manifest', ); const manifest: AppManifest = { appId: nestedString(m, 'manifest', 'appId'), name: nestedString(m, 'manifest', 'name'), - publisher: nestedString(m, 'manifest', 'publisher'), - release: nestedRelease(m), types: nestedArray(m, 'manifest', 'types').map((t, i) => { if (typeof t !== 'object' || t === null || Array.isArray(t)) fieldError(`manifest.types[${i}]`, 'a type must be an object'); - // Only what a manifest type carries: a key parseTypeBody() would - // drop is one the signature covers, and the stripped manifest would - // then fail to verify. - requireKnownBody(t, MANIFEST_TYPE_KEYS, `manifest.types[${i}]`); return parseTypeBody(t); }), requests: nestedArray(m, 'manifest', 'requests').map((r, i) => { @@ -282,20 +263,7 @@ export function parseInstallBody(body: unknown): InstallSubmission { if (typeof m.version !== 'string') fieldError('manifest.version', 'version must be a string'); manifest.version = m.version; } - return { - manifest, - signature, - ...(b.keyCertificate !== undefined && { keyCertificate: b.keyCertificate as string }), - }; -} - -/** `manifest.release`, a positive integer. */ -function nestedRelease(m: Record): number { - const value = m.release; - if (value === undefined) throw new StackBadRequestError('Invalid manifest: release is required'); - if (!Number.isSafeInteger(value) || (value as number) < 1) - fieldError('manifest.release', 'release must be a positive integer'); - return value as number; + return manifest; } /** A required string inside a nested object, its 422 naming the full path. */ diff --git a/packages/core/tests/install.test.ts b/packages/core/tests/install.test.ts index 0fd9b1e7..52dd1efb 100644 --- a/packages/core/tests/install.test.ts +++ b/packages/core/tests/install.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect, beforeAll, beforeEach } from 'vitest'; +import { describe, test, expect, beforeEach } from 'vitest'; import { Stack } from '../src/stack.js'; import { StackConflictError, @@ -8,10 +8,8 @@ import { StackValidationError, } from '../src/errors.js'; import { MemoryAdapter } from '../src/testing.js'; -import { appIdVouchedBy, certifyKey, isPlanEmpty, signManifest } from '../src/install.js'; +import { isPlanEmpty } from '../src/install.js'; import type { AppManifest } from '../src/install.js'; -import { generateDidKeypair } from '../src/did.js'; -import type { DidKeypair } from '../src/did.js'; import type { AppContent, GrantContent, InstallContent, StackRecord } from '../src/types.js'; const OWNER = 'did:key:owner'; @@ -28,8 +26,6 @@ const manifest = (overrides: Partial = {}): AppManifest => ({ appId: 'com.example.notes', name: 'Notes', version: '1.0.0', - publisher: publisherKey.did, - release: 1, types: [{ id: NOTE_1, name: 'Note', schema: { text: { kind: 'text' } } }], requests: [{ baseId: 'com.example.notes/note', actions: ['create', 'read-any'] }], ...overrides, @@ -47,19 +43,9 @@ const MIGRATING = [ ] as AppManifest['requests']; let stack: Stack; -let publisherKey: DidKeypair; - -beforeAll(async () => { - publisherKey = await generateDidKeypair(); -}); - -/** planInstall() for a manifest signed by `key`, the publisher's own by default. */ -async function planSigned(m: AppManifest, opts: { did: string }, key = publisherKey) { - return stack.planInstall(await signManifest(m, key.privateKey), opts); -} async function install(m: AppManifest, did = APP_DID) { - return stack.installApp(await planSigned(m, { did })); + return stack.installApp(await stack.planInstall(m, { did })); } async function linkedGrants(record: StackRecord): Promise { @@ -88,8 +74,6 @@ describe('installApp()', () => { appId: 'com.example.notes', name: 'Notes', version: '1.0.0', - publisher: publisherKey.did, - release: 1, defines: [NOTE_1], requests: [{ baseId: 'com.example.notes/note', actions: ['create', 'read-any'] }], }); @@ -109,7 +93,7 @@ describe('installApp()', () => { test('re-applying the same manifest changes nothing', async () => { const first = await install(manifest()); - const plan = await planSigned(manifest(), { did: APP_DID }); + const plan = await stack.planInstall(manifest(), { did: APP_DID }); expect(plan).toMatchObject({ newFamilies: [], newVersions: [], @@ -125,7 +109,7 @@ describe('installApp()', () => { test('an upgrade adds approved versions and brings grants to the new requests', async () => { const first = await install(manifest()); const next = manifest({ version: '2.0.0', types: [NOTE_2_TYPE], requests: MIGRATING }); - const plan = await planSigned(next, { did: APP_DID }); + const plan = await stack.planInstall(next, { did: APP_DID }); expect(plan.newVersions).toEqual([NOTE_2]); expect(plan.newFamilies).toEqual([]); expect(plan.requestsAdded).toEqual(MIGRATING); @@ -142,7 +126,7 @@ describe('installApp()', () => { test('a second key gets the same grants, and an upgrade reaches every linked key', async () => { await install(manifest()); - const plan = await planSigned(manifest(), { did: OTHER_DID }); + const plan = await stack.planInstall(manifest(), { did: OTHER_DID }); expect(plan.newKey).toBe(true); await stack.installApp(plan); expect( @@ -156,14 +140,14 @@ describe('installApp()', () => { }); test('refuses a plan the stack has moved on from', async () => { - const plan = await planSigned(manifest(), { did: APP_DID }); + const plan = await stack.planInstall(manifest(), { did: APP_DID }); await install(manifest()); await expect(stack.installApp(plan)).rejects.toThrow(StackConflictError); }); test('the plan lists every type it would write, commons types included', async () => { const commons = { id: COMMONS_NOTE, name: 'Note', schema: { body: { kind: 'text' } } } as const; - const plan = await planSigned(manifest({ types: [manifest().types[0]!, commons] }), { + const plan = await stack.planInstall(manifest({ types: [manifest().types[0]!, commons] }), { did: APP_DID, }); expect(plan.typeChanges).toEqual([ @@ -172,14 +156,14 @@ describe('installApp()', () => { ]); await stack.installApp(plan); - const renamed = await planSigned( + const renamed = await stack.planInstall( manifest({ types: [{ ...manifest().types[0]!, name: 'Memo' }] }), { did: APP_DID }, ); expect(renamed.typeChanges).toEqual([{ id: NOTE_1, change: 'name' }]); expect(isPlanEmpty(renamed)).toBe(false); - const widened = await planSigned( + const widened = await stack.planInstall( manifest({ types: [ { ...manifest().types[0]!, schema: { text: { kind: 'text' }, x: { kind: 'string' } } }, @@ -191,39 +175,41 @@ describe('installApp()', () => { }); test('the plan names the keys already linked, whose grants it also sets', async () => { - expect((await planSigned(manifest(), { did: APP_DID })).linkedKeys).toEqual([]); + expect((await stack.planInstall(manifest(), { did: APP_DID })).linkedKeys).toEqual([]); await install(manifest()); - const plan = await planSigned(manifest({ requests: MIGRATING }), { did: OTHER_DID }); + const plan = await stack.planInstall(manifest({ requests: MIGRATING }), { did: OTHER_DID }); expect(plan.newKey).toBe(true); expect(plan.linkedKeys).toEqual([APP_DID]); }); test('a plan made before a type was defined is refused', async () => { - const plan = await planSigned(manifest(), { did: APP_DID }); + const plan = await stack.planInstall(manifest(), { did: APP_DID }); await stack.defineType(manifest().types[0]!); await expect(stack.installApp(plan)).rejects.toThrow(StackConflictError); }); test('a key registered to another app is refused', async () => { await stack.create('_app@1', { appId: 'com.example.other', name: 'Other', did: APP_DID }); - await expect(planSigned(manifest(), { did: APP_DID })).rejects.toThrow(StackConflictError); + await expect(stack.planInstall(manifest(), { did: APP_DID })).rejects.toThrow( + StackConflictError, + ); }); test('system types can be neither defined nor requested when ungrantable', async () => { await expect( - planSigned(manifest({ types: [{ id: '_entity@2', name: 'Entity', schema: {} }] }), { + stack.planInstall(manifest({ types: [{ id: '_entity@2', name: 'Entity', schema: {} }] }), { did: APP_DID, }), ).rejects.toThrow(StackValidationError); await expect( - planSigned(manifest({ requests: [{ baseId: '_install', actions: ['create'] }] }), { + stack.planInstall(manifest({ requests: [{ baseId: '_install', actions: ['create'] }] }), { did: APP_DID, }), ).rejects.toThrow(StackValidationError); }); test('requests outside the app’s own namespace name the family’s owner', async () => { - const plan = await planSigned( + const plan = await stack.planInstall( manifest({ requests: [ { baseId: 'com.example.notes/note', actions: ['create'] }, @@ -252,7 +238,7 @@ describe('installApp()', () => { OTHER_DID, ); await expect( - planSigned(manifest({ types: [{ id: TAG_1, name: 'Tag', schema: {} }] }), { + stack.planInstall(manifest({ types: [{ id: TAG_1, name: 'Tag', schema: {} }] }), { did: APP_DID, }), ).rejects.toThrow(StackValidationError); @@ -296,19 +282,19 @@ describe("a manifest's schemas", () => { test('a non-additive change to a defined type is refused at plan time', async () => { await install(manifest()); await expect( - planSigned(withNew({ text: { kind: 'number' } }), { did: APP_DID }), + stack.planInstall(withNew({ text: { kind: 'number' } }), { did: APP_DID }), ).rejects.toThrow(StackSchemaDriftError); }); test('a malformed schema is refused at plan time, defined type or not', async () => { for (const schema of [null, { text: { kind: 'object' } }]) { - await expect(planSigned(withNew(schema), { did: APP_DID })).rejects.toThrow( + await expect(stack.planInstall(withNew(schema), { did: APP_DID })).rejects.toThrow( StackValidationError, ); } await install(manifest()); for (const schema of [null, { text: { kind: 'object' } }]) { - await expect(planSigned(withNew(schema), { did: APP_DID })).rejects.toThrow( + await expect(stack.planInstall(withNew(schema), { did: APP_DID })).rejects.toThrow( StackValidationError, ); } @@ -316,13 +302,13 @@ describe("a manifest's schemas", () => { test('a schema declaring a reserved or unaddressable field name is refused', async () => { await expect( - planSigned(withNew({ 'a.b': { kind: 'text' } }), { did: APP_DID }), + stack.planInstall(withNew({ 'a.b': { kind: 'text' } }), { did: APP_DID }), ).rejects.toThrow(StackValidationError); }); test('a type listed twice is refused', async () => { const m = manifest({ types: [...manifest().types, ...manifest().types] }); - await expect(planSigned(m, { did: APP_DID })).rejects.toThrow(StackValidationError); + await expect(stack.planInstall(m, { did: APP_DID })).rejects.toThrow(StackValidationError); }); test('a refused manifest writes none of its types', async () => { @@ -363,7 +349,7 @@ describe('what an installed app sees', () => { test('a plan applies the manifest as planned, whatever happens to the caller’s object', async () => { const m = manifest(); - const plan = await planSigned(m, { did: APP_DID }); + const plan = await stack.planInstall(m, { did: APP_DID }); m.requests.push({ baseId: 'com.example.notes/note', actions: ['delete-any'] }); expect(() => { (plan.manifest.requests as unknown[]).push({ baseId: '_entity', actions: ['read-any'] }); @@ -373,31 +359,33 @@ describe('what an installed app sees', () => { }); test('a plan is empty only once its key is installed and nothing would change', async () => { - expect(isPlanEmpty(await planSigned(manifest(), { did: APP_DID }))).toBe(false); + expect(isPlanEmpty(await stack.planInstall(manifest(), { did: APP_DID }))).toBe(false); await install(manifest()); - expect(isPlanEmpty(await planSigned(manifest(), { did: APP_DID }))).toBe(true); - expect(isPlanEmpty(await planSigned(manifest(), { did: OTHER_DID }))).toBe(false); - expect(isPlanEmpty(await planSigned(manifest({ requests: [] }), { did: APP_DID }))).toBe(false); + expect(isPlanEmpty(await stack.planInstall(manifest(), { did: APP_DID }))).toBe(true); + expect(isPlanEmpty(await stack.planInstall(manifest(), { did: OTHER_DID }))).toBe(false); + expect(isPlanEmpty(await stack.planInstall(manifest({ requests: [] }), { did: APP_DID }))).toBe( + false, + ); await stack.uninstallApp('com.example.notes'); - expect(isPlanEmpty(await planSigned(manifest(), { did: APP_DID }))).toBe(false); + expect(isPlanEmpty(await stack.planInstall(manifest(), { did: APP_DID }))).toBe(false); }); - test('a manifest that changes only name, version or release is not an empty plan', async () => { + test('a manifest that changes only name or version is not an empty plan', async () => { await install(manifest()); - for (const change of [{ name: 'Notes+' }, { version: '1.0.1' }, { release: 2 }]) { + for (const change of [{ name: 'Notes+' }, { version: '1.0.1' }]) { expect( - isPlanEmpty(await planSigned(manifest(change), { did: APP_DID })), + isPlanEmpty(await stack.planInstall(manifest(change), { did: APP_DID })), JSON.stringify(change), ).toBe(false); } - const bumped = await planSigned(manifest({ version: '1.0.1', release: 2 }), { did: APP_DID }); + const bumped = await stack.planInstall(manifest({ version: '1.0.1' }), { did: APP_DID }); const record = await stack.installApp(bumped); - expect(record.content).toMatchObject({ version: '1.0.1', release: 2 }); + expect(record.content).toMatchObject({ version: '1.0.1' }); }); test('requests compare as sets of actions, so a repeated action cannot keep one dropped', async () => { await install(manifest({ requests: MIGRATING })); - const padded = await planSigned( + const padded = await stack.planInstall( manifest({ requests: [{ baseId: 'com.example.notes/note', actions: ['read-any', 'read-any'] }], }), @@ -410,9 +398,9 @@ describe('what an installed app sees', () => { const reordered = (actions: ('create' | 'read-any')[]) => manifest({ requests: [{ baseId: 'com.example.notes/note', actions }] }); await install(reordered(['read-any', 'create'])); - expect(isPlanEmpty(await planSigned(reordered(['create', 'read-any']), { did: APP_DID }))).toBe( - true, - ); + expect( + isPlanEmpty(await stack.planInstall(reordered(['create', 'read-any']), { did: APP_DID })), + ).toBe(true); }); }); @@ -423,8 +411,6 @@ describe('the _install record', () => { stack.create('_install@1', { appId: 'com.example.rival', name: 'Rival', - publisher: publisherKey.did, - release: 1, defines: [id], requests: [], }), @@ -438,8 +424,6 @@ describe('the _install record', () => { stack.create('_install@1', { appId: 'com.example.notes', name: 'Notes again', - publisher: publisherKey.did, - release: 1, defines: [], requests: [], }), @@ -641,174 +625,3 @@ describe('the _app card an install registers', () => { expect(card.createdBy).toBeUndefined(); }); }); - -describe('the publisher', () => { - test('a manifest its publisher did not sign is refused', async () => { - const stranger = await generateDidKeypair(); - await expect(planSigned(manifest(), { did: APP_DID }, stranger)).rejects.toThrow( - StackValidationError, - ); - - const signed = await signManifest(manifest(), publisherKey.privateKey); - const tampered = { ...signed, manifest: { ...signed.manifest, requests: MIGRATING } }; - await expect(stack.planInstall(tampered, { did: APP_DID })).rejects.toThrow( - StackValidationError, - ); - }); - - test('is pinned: no other publisher can upgrade a live install or link a key to it', async () => { - await install(manifest()); - const impostor = await generateDidKeypair(); - const theirs = manifest({ publisher: impostor.did, requests: MIGRATING }); - for (const did of [APP_DID, OTHER_DID]) { - await expect(planSigned(theirs, { did }, impostor)).rejects.toThrow(StackConflictError); - } - }); - - test('an uninstalled install may be taken up by a new publisher, unlinking the old keys', async () => { - await install(manifest()); - await install(manifest(), OTHER_DID); - await stack.uninstallApp('com.example.notes'); - - const rotated = await generateDidKeypair(); - const next = manifest({ publisher: rotated.did, release: 1 }); - const plan = await planSigned(next, { did: APP_DID }, rotated); - expect(plan.publisherChanged).toBe(true); - expect(plan.newKey).toBe(true); - expect(plan.linkedKeys).toEqual([]); - - const record = await stack.installApp(plan); - expect(record.deletedAt).toBeUndefined(); - expect(record.content.publisher).toBe(rotated.did); - expect( - (await stack.listTypeGrants()).map( - (g) => (g.content.grantee as { entityId: string }).entityId, - ), - ).toEqual([APP_DID]); - expect(await stack.asEntity(OTHER_DID).get(record.id)).toBeNull(); - await expect(planSigned(manifest(), { did: APP_DID })).rejects.toThrow(StackConflictError); - }); - - test('a release older than the installed one is refused', async () => { - await install(manifest({ release: 2, requests: [] })); - await expect( - planSigned(manifest({ release: 1, requests: MIGRATING }), { did: APP_DID }), - ).rejects.toThrow(StackConflictError); - expect((await planSigned(manifest({ release: 2 }), { did: OTHER_DID })).newKey).toBe(true); - await expect(planSigned(manifest({ release: 0 }), { did: APP_DID })).rejects.toThrow( - StackValidationError, - ); - }); - - test('a did:web publisher needs a verifier, and vouches for the appId its domain reverses', async () => { - const web = manifest({ publisher: 'did:web:notes.example.com' }); - const signed = await signManifest(web, publisherKey.privateKey); - await expect(stack.planInstall(signed, { did: APP_DID })).rejects.toThrow(StackValidationError); - - const verifyPublisher = async () => true; - const plan = await stack.planInstall(signed, { did: APP_DID, verifyPublisher }); - expect(plan.namespaceVerified).toBe(true); - expect((await stack.installApp(plan, { verifyPublisher })).content.publisher).toBe( - 'did:web:notes.example.com', - ); - - const elsewhere = await signManifest( - manifest({ appId: 'com.example.tags', types: [], publisher: 'did:web:notes.example.com' }), - publisherKey.privateKey, - ); - expect( - (await stack.planInstall(elsewhere, { did: OTHER_DID, verifyPublisher })).namespaceVerified, - ).toBe(false); - expect( - (await planSigned(manifest({ appId: 'com.example.keyed', types: [] }), { did: PERSON })) - .namespaceVerified, - ).toBe(false); - }); - - test('a key the publisher certified is marked so; a wrong certificate is refused', async () => { - const signed = await signManifest(manifest(), publisherKey.privateKey); - expect((await stack.planInstall(signed, { did: APP_DID })).keyCertified).toBe(false); - - const keyCertificate = await certifyKey( - { appId: 'com.example.notes', did: APP_DID }, - publisherKey.privateKey, - ); - const plan = await stack.planInstall({ ...signed, keyCertificate }, { did: APP_DID }); - expect(plan.keyCertified).toBe(true); - await stack.installApp(plan); - - for (const [cert, did] of [ - [keyCertificate, OTHER_DID], - [ - await certifyKey({ appId: 'com.example.tags', did: OTHER_DID }, publisherKey.privateKey), - OTHER_DID, - ], - [ - await certifyKey( - { appId: 'com.example.notes', did: OTHER_DID }, - (await generateDidKeypair()).privateKey, - ), - OTHER_DID, - ], - [signed.signature, APP_DID], - ] as const) { - await expect(stack.planInstall({ ...signed, keyCertificate: cert }, { did })).rejects.toThrow( - StackValidationError, - ); - } - }); - - test('once a key is certified, a new key needs a certificate too', async () => { - const certified = async (did: string) => - stack.planInstall( - { - ...(await signManifest(manifest(), publisherKey.privateKey)), - keyCertificate: await certifyKey( - { appId: 'com.example.notes', did }, - publisherKey.privateKey, - ), - }, - { did }, - ); - await install(manifest()); - expect((await planSigned(manifest(), { did: OTHER_DID })).newKey).toBe(true); - - const record = await stack.installApp(await certified(APP_DID)); - expect(record.content.keysCertified).toBe(true); - await expect(planSigned(manifest(), { did: OTHER_DID })).rejects.toThrow(StackConflictError); - await stack.installApp(await certified(OTHER_DID)); - // A key already linked is not new, so it needs none. - expect(isPlanEmpty(await planSigned(manifest(), { did: APP_DID }))).toBe(true); - - await stack.uninstallApp('com.example.notes'); - await expect(planSigned(manifest(), { did: PERSON })).rejects.toThrow(StackConflictError); - }); - - test('a new publisher taking up an install starts without certified keys', async () => { - const plan = await stack.planInstall( - { - ...(await signManifest(manifest(), publisherKey.privateKey)), - keyCertificate: await certifyKey( - { appId: 'com.example.notes', did: APP_DID }, - publisherKey.privateKey, - ), - }, - { did: APP_DID }, - ); - await stack.installApp(plan); - await stack.uninstallApp('com.example.notes'); - - const rotated = await generateDidKeypair(); - const record = await stack.installApp( - await planSigned(manifest({ publisher: rotated.did }), { did: OTHER_DID }, rotated), - ); - expect(record.content.keysCertified).toBeUndefined(); - }); - - test('appIdVouchedBy() reverses a bare did:web host and nothing else', () => { - expect(appIdVouchedBy('did:web:notes.example.com')).toBe('com.example.notes'); - expect(appIdVouchedBy('did:web:example.com:apps:notes')).toBeNull(); - expect(appIdVouchedBy('did:web:example.com%3A8443')).toBeNull(); - expect(appIdVouchedBy(publisherKey.did)).toBeNull(); - }); -}); diff --git a/packages/core/tests/wire-body.test.ts b/packages/core/tests/wire-body.test.ts index 6611b04e..acee25b4 100644 --- a/packages/core/tests/wire-body.test.ts +++ b/packages/core/tests/wire-body.test.ts @@ -214,62 +214,33 @@ describe('parseInstallBody', () => { appId: 'com.example.notes', name: 'Notes', version: '1.0.0', - publisher: DID, - release: 1, types: [{ id: 'com.example.notes/note@1', name: 'Note', schema: { text: { kind: 'text' } } }], requests: [{ baseId: 'com.example.notes/note', actions: ['create', 'read-any'] }], }; - const signature = 'c2lnbmF0dXJl'; - - test('reads a signed manifest into what planInstall() takes', () => { - expect(parseInstallBody({ manifest, signature })).toEqual({ manifest, signature }); - expect(parseInstallBody({ manifest, signature, keyCertificate: signature })).toEqual({ - manifest, - signature, - keyCertificate: signature, - }); - }); - test('a manifest type carries no key a Type derives, since dropping one would void the signature', () => { - for (const key of ['baseId', 'version', 'schemaHash', 'createdAt']) { - const types = [{ ...manifest.types[0]!, [key]: 'x' }]; - expect(() => parseInstallBody({ manifest: { ...manifest, types }, signature })).toThrow( - StackBadRequestError, - ); - } + test('reads a manifest into what planInstall() takes', () => { + expect(parseInstallBody({ manifest })).toEqual(manifest); }); test('an unknown key at either level, or a missing field, is not this request', () => { - expect(() => parseInstallBody({ manifest, signature, did: DID })).toThrow(StackBadRequestError); - expect(() => parseInstallBody({ manifest: { ...manifest, did: DID }, signature })).toThrow( - StackBadRequestError, - ); - expect(() => parseInstallBody({ manifest })).toThrow(StackBadRequestError); - const { publisher: _, ...unpublished } = manifest; - expect(() => parseInstallBody({ manifest: unpublished, signature })).toThrow( + expect(() => parseInstallBody({ manifest, did: DID })).toThrow(StackBadRequestError); + expect(() => parseInstallBody({ manifest: { ...manifest, did: DID } })).toThrow( StackBadRequestError, ); + const { requests: _, ...noRequests } = manifest; + expect(() => parseInstallBody({ manifest: noRequests })).toThrow(StackBadRequestError); }); test('a wrongly typed field names its path', () => { - expect( - pathOf(() => parseInstallBody({ manifest: { ...manifest, types: {} }, signature })), - ).toBe('manifest.types'); + expect(pathOf(() => parseInstallBody({ manifest: { ...manifest, types: {} } }))).toBe( + 'manifest.types', + ); expect( pathOf(() => parseInstallBody({ manifest: { ...manifest, requests: [{ baseId: 'com.example.notes/note', actions: [1] }] }, - signature, }), ), ).toBe('manifest.requests[0].actions[0]'); - for (const release of [0, 1.5, '1']) { - expect( - pathOf(() => parseInstallBody({ manifest: { ...manifest, release }, signature })), - ).toBe('manifest.release'); - } - expect(pathOf(() => parseInstallBody({ manifest, signature, keyCertificate: 1 }))).toBe( - 'keyCertificate', - ); }); }); diff --git a/packages/wire-types/src/index.ts b/packages/wire-types/src/index.ts index 5ad58d16..87590488 100644 --- a/packages/wire-types/src/index.ts +++ b/packages/wire-types/src/index.ts @@ -13,7 +13,7 @@ import { StackTimeoutError, } from '@haverstack/core'; import type { - InstallSubmission, + AppManifest, NativeSortField, StackRecord, StackType, @@ -806,12 +806,8 @@ export function supportsInstallRequests(discovery: DiscoveryResponse): boolean { return discovery.installs?.requests === true; } -/** - * POST /installs: a manifest, its publisher's signature, and optionally the - * publisher's certificate for the session's key. The key being installed - * is the session's, never named here. - */ -export type WireInstallRequest = InstallSubmission; +/** POST /installs. The key being installed is the session's, never named here. */ +export type WireInstallRequest = { manifest: AppManifest }; /** * POST /installs answers `pending` (202) while the owner has not approved From 26e2eac1c8106a1879b59f52c8cc64c553e2456f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 02:02:12 +0000 Subject: [PATCH 11/11] fix(core): count a key with a soft-deleted _app card as new in an install plan planInstall() read linked cards through get(), which hides soft-deleted ones, so a key whose card the owner deleted planned as newKey: false and was missing from linkedKeys. installApp() then undeleted the card and restored its grants without the plan showing it, and resending the same manifest produced an empty plan that a server applies without asking. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015LB1V25YTpWsyq2sdsg59r --- docs/spec/apps.md | 4 +++- packages/core/src/install.ts | 6 +++++- packages/core/src/stack.ts | 6 +++++- packages/core/tests/install.test.ts | 11 +++++++++++ 4 files changed, 24 insertions(+), 3 deletions(-) diff --git a/docs/spec/apps.md b/docs/spec/apps.md index ce140803..ae37d5c2 100644 --- a/docs/spec/apps.md +++ b/docs/spec/apps.md @@ -76,13 +76,15 @@ type InstallPlan = { requestsRemoved: InstallRequest[]; foreignRequests: (InstallRequest & { owner: AppId | 'commons' | 'system' | null })[]; typeChanges: { id: TypeId; change: 'new' | 'schema' | 'name' }[]; // types installApp() would write - newKey: boolean; // whether `did` is not yet linked to this install + newKey: boolean; // whether `did` is not yet linked, or linked through a soft-deleted `_app` card linkedKeys: EntityId[]; // keys already linked, whose grants the plan also sets }; ``` `typeChanges` lists every manifest type whose definition would write — not yet defined, or defined with a different schema or name — commons types included. Defining a commons type claims nothing, but the first definition of a version fixes its shape for every app that reads it, so the owner sees it like any other. +A key whose `_app` card the owner soft-deleted counts as new: `installApp()` undeletes the card and restores its grants, so the owner approves that as they would a key never seen before, and the plan is never empty. `linkedKeys` lists only keys with a live card. + `linkedKeys` matters most beside `newKey`: a new key on an existing install joins those keys as the same app (see [Who owns a family](#who-owns-a-family)), and `requestsAdded` and `requestsRemoved` apply to all of them. `foreignRequests` are the requests on families outside the app's own namespace, each naming the family's [owner](#who-owns-a-family): another app's `appId`, `'commons'`, `'system'`, or `null` for a family with no namespace. They are the requests an approval most needs to show — an app asking to read another app's records, or `_entity`, is asking for reach beyond its own data. diff --git a/packages/core/src/install.ts b/packages/core/src/install.ts index ad9892f0..1e763c2d 100644 --- a/packages/core/src/install.ts +++ b/packages/core/src/install.ts @@ -89,7 +89,11 @@ export type InstallPlan = { foreignRequests: ForeignRequest[]; /** Every manifest type `installApp()` would define or redefine. */ typeChanges: TypeChange[]; - /** Whether `did` is a key this install is not yet linked to. */ + /** + * Whether `did` would gain access: not yet linked to this install, or + * linked through an `_app` card that is soft-deleted, which installApp() + * undeletes. See docs/spec/apps.md § Plan, then apply. + */ newKey: boolean; /** * The keys already linked to the install. Each holds `requests`, so a diff --git a/packages/core/src/stack.ts b/packages/core/src/stack.ts index 99c76322..e4dce78d 100644 --- a/packages/core/src/stack.ts +++ b/packages/core/src/stack.ts @@ -2917,7 +2917,11 @@ export class Stack implements StackClient { requestsRemoved: prior.filter((p) => !manifest.requests.some((r) => sameRequest(p, r))), foreignRequests, typeChanges, - newKey: !existing || !card || !linkedIds(existing, INSTALL_APP_LABEL).includes(card.id), + newKey: + !existing || + !card || + card.deletedAt !== undefined || + !linkedIds(existing, INSTALL_APP_LABEL).includes(card.id), linkedKeys, }; } diff --git a/packages/core/tests/install.test.ts b/packages/core/tests/install.test.ts index 52dd1efb..56485731 100644 --- a/packages/core/tests/install.test.ts +++ b/packages/core/tests/install.test.ts @@ -182,6 +182,17 @@ describe('installApp()', () => { expect(plan.linkedKeys).toEqual([APP_DID]); }); + test('a key whose _app card was soft-deleted is new to the plan', async () => { + await install(manifest()); + const card = (await stack.query({ filter: { baseId: '_app' } })).records[0]!; + await stack.delete(card.id); + + const plan = await stack.planInstall(manifest(), { did: APP_DID }); + expect(plan.newKey).toBe(true); + expect(plan.linkedKeys).toEqual([]); + expect(isPlanEmpty(plan)).toBe(false); + }); + test('a plan made before a type was defined is refused', async () => { const plan = await stack.planInstall(manifest(), { did: APP_DID }); await stack.defineType(manifest().types[0]!);