Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/app-install-requests.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/app-install-review-fixes.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/app-installs.md
Original file line number Diff line number Diff line change
@@ -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 }`.
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
8 changes: 4 additions & 4 deletions docs/spec/access-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.

Expand Down
Loading
Loading