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/.changeset/app-install-review-fixes.md b/.changeset/app-install-review-fixes.md new file mode 100644 index 00000000..8ea110b5 --- /dev/null +++ b/.changeset/app-install-review-fixes.md @@ -0,0 +1,5 @@ +--- +'@haverstack/core': minor +--- + +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/.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 8c135494..28bb2252 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 1f22698c..285a11f8 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()`, `amendAssociations()`, `grantAccess()`, `revokeAccess()`, `amendAccess()`, `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()`, `amendAssociations()`, `grantAccess()`, `revokeAccess()`, `amendAccess()`, `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. @@ -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 new file mode 100644 index 00000000..ae37d5c2 --- /dev/null +++ b/docs/spec/apps.md @@ -0,0 +1,148 @@ +# 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. An app reaches them only through the request it can make over the wire — see [Over the wire](#over-the-wire). + +## 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. +- **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. + +**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. + +## 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. 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(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 | 'commons' | 'system' | null })[]; + typeChanges: { id: TypeId; change: 'new' | 'schema' | 'name' }[]; // types installApp() would write + 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. + +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)` 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. `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` and `version` are also the install's, so an upgrade that changes only those still reaches the owner. + +## 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 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 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. + +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. + +## 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, 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/docs/spec/data-model.md b/docs/spec/data-model.md index 6139de2a..15c011b9 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 4802bb89..5249984b 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 a2a5e3ba..132023bc 100644 --- a/docs/spec/versioning.md +++ b/docs/spec/versioning.md @@ -154,7 +154,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 7d3d5124..a455b20a 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 @@ -388,7 +388,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 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 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 @@ -607,7 +607,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)). @@ -699,6 +699,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 54c283e7..5eb47dcd 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); @@ -822,6 +831,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; @@ -914,6 +925,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), ); } @@ -1082,6 +1094,36 @@ export class APIAdapter implements StackAdapter { return requireRecordBody(raw, `PATCH /records/${pathSegment(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/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..d7fd3f76 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -33,7 +33,10 @@ export type { DeleteAndReturnResult, CollectAttachmentGarbageOptions, CollectAttachmentGarbageResult, + MigrateAllOptions, } from './stack.js'; +export type { AppManifest, InstallPlan, ForeignRequest, TypeChange } from './install.js'; +export { isPlanEmpty } from './install.js'; // Type handles export { typeHandle } from './type-handle.js'; @@ -137,6 +140,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..1e763c2d --- /dev/null +++ b/packages/core/src/install.ts @@ -0,0 +1,319 @@ +/** + * 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, + AuthorityAssociation, + 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 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; +}; + +/** + * 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 + * 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[]; + /** Every manifest type `installApp()` would define or redefine. */ + typeChanges: TypeChange[]; + /** + * 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 + * 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. */ +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-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[] = []; + 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 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. + */ +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 (typeof c?.appId === 'string' && familyStanding(parsed.baseId, c.appId) !== 'own') { + errors.push({ + path: `defines[${i}]`, + message: `"${parsed.baseId}" is outside the namespace "${c.appId}"; an install claims only its own families`, + }); + } + }); + } + 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; +} + +/** 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)]; +} + +/** + * 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) 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`. */ +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); +} + +/** + * 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` 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. + */ +export function isPlanEmpty(plan: InstallPlan): boolean { + return ( + 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.newFamilies.length === 0 && + plan.newVersions.length === 0 && + plan.typeChanges.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. + */ +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.typeChanges, + plan.newKey, + plan.linkedKeys, + ]); +} diff --git a/packages/core/src/scoped-stack.ts b/packages/core/src/scoped-stack.ts index e43d3315..96e18406 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, @@ -65,6 +66,7 @@ import type { } from './types.js'; import { StackError, + StackConflictError, StackNotFoundError, StackPermissionError, RelayScopeError, @@ -88,6 +90,7 @@ import { UNGRANTABLE_SYSTEM_TYPES, } from './grants.js'; import { bindingFieldsOf } 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'; import { @@ -1327,15 +1330,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 +1528,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 +1549,85 @@ 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', + ); + } + // 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 }); } + /** + * 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 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. + * + * 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 (familyStanding(family, claimants[0]!.content.appId) !== 'own') 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; + } + // 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; + } + /** * 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..e4dce78d 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,24 @@ import { } from './grants.js'; import type { GrantQuery } from './grants.js'; import { bindingFieldsOf, uniqueBindingFieldsOf } from './identity-bindings.js'; +import { + claimedFamilies, + familyStanding, + grantIsRequest, + installAppLink, + installGrantLink, + installReader, + linkedIds, + namespaceOf, + ownTypeIds, + planFingerprint, + sameRequest, + snapshotManifest, + validateInstall, + INSTALL_APP_LABEL, + INSTALL_GRANT_LABEL, +} from './install.js'; +import type { AppManifest, ForeignRequest, InstallPlan, TypeChange } from './install.js'; import { assertAttachmentSize, assertContentSize } from './limits.js'; import { validateParentId, @@ -365,6 +386,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 +910,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 +930,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 +949,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 +971,7 @@ export class Stack implements StackClient { } while (cursor); } - return { migrated }; + return { migrated, skipped }; } // ------------------------------------------------------- @@ -993,6 +1039,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), @@ -1340,6 +1387,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); @@ -1886,6 +1934,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); @@ -1989,6 +2038,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); @@ -2793,6 +2843,326 @@ 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 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(submitted: AppManifest, opts: { did: EntityId }): Promise { + this.assertOpen(); + const { did } = opts; + const manifest = snapshotManifest(submitted); + this.checkManifest(manifest, did); + + const installs = await this.loadInstalls(); + const existing = installs.find((r) => r.content.appId === manifest.appId) ?? null; + 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) { + throw new StackConflictError( + `${did} is registered to "${(card.content as AppContent).appId}", not "${manifest.appId}"`, + ); + } + + const claimed = existing ? claimedFamilies(existing.content) : new Set(); + const defined = new Set(existing?.content.defines ?? []); + const prior = existing?.content.requests ?? []; + 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 }); + } + + 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))) { + // 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' }); + } + + 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, + existing, + 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, + typeChanges, + newKey: + !existing || + !card || + card.deletedAt !== undefined || + !linkedIds(existing, INSTALL_APP_LABEL).includes(card.id), + linkedKeys, + }; + } + + /** + * 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 ?? []), ...ownTypeIds(manifest)])]; + 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' }); + } + 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' }); + } 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); + 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 })[]; + } + + /** 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) }); + } + } + let result = edits.length > 0 ? await this.amendAssociations(install.id, edits) : install; + + // 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), + ); + 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; + } + // ------------------------------------------------------- // Private helpers // ------------------------------------------------------- @@ -2908,6 +3278,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/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 new file mode 100644 index 00000000..56485731 --- /dev/null +++ b/packages/core/tests/install.test.ts @@ -0,0 +1,638 @@ +import { describe, test, expect, beforeEach } from 'vitest'; +import { Stack } from '../src/stack.js'; +import { + StackConflictError, + StackNotFoundError, + StackPermissionError, + StackSchemaDriftError, + 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'; + +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.tags/tag@1'; +const COMMONS_NOTE = 'org.haverstack/note@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('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 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]!); + 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 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', + name: 'Tags', + types: [{ id: TAG_1, name: 'Tag', schema: { label: { kind: 'string' } } }], + requests: [], + }), + OTHER_DID, + ); + 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({ + types: [ + ...manifest().types, + { id: COMMONS_NOTE, name: 'Note', schema: { body: { kind: 'text' } } }, + ], + }), + ); + expect(await stack.getType(COMMONS_NOTE)).not.toBeNull(); + expect(record.content.defines).toEqual([NOTE_1]); + }); +}); + +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( + 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(stack.planInstall(withNew(schema), { did: APP_DID })).rejects.toThrow( + StackValidationError, + ); + } + await install(manifest()); + for (const schema of [null, { text: { kind: 'object' } }]) { + await expect(stack.planInstall(withNew(schema), { did: APP_DID })).rejects.toThrow( + StackValidationError, + ); + } + }); + + test('a schema declaring a reserved or unaddressable field name is refused', async () => { + await expect( + 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(stack.planInstall(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()); + 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 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()); + 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); + }); + + 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' }]) { + expect( + isPlanEmpty(await stack.planInstall(manifest(change), { did: APP_DID })), + JSON.stringify(change), + ).toBe(false); + } + 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' }); + }); + + test('requests compare as sets of actions, so a repeated action cannot keep one dropped', async () => { + await install(manifest({ requests: MIGRATING })); + const padded = await stack.planInstall( + 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 stack.planInstall(reordered(['create', 'read-any']), { did: APP_DID })), + ).toBe(true); + }); +}); + +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']) { + 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 () => { + 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('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('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); + 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'] }, + ]); + 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('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( + 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(); + }); +}); 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 };