From 9edf5d02925fc6db3d829c21e23150abf15d8a8f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 19:03:47 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20add=20unlisted=20records=20=E2=80=94=20?= =?UTF-8?q?reachable=20by=20URI,=20excluded=20from=20enumeration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #202. StackRecord.unlistedAt is a native field, orthogonal to permissions: it says nothing about who may read a record, only whether it's enumerable. An unlisted record is reachable by get() for anyone who may already read it, and excluded from an unfiltered query() and the change feed by default — the same posture soft delete already has. - create({ unlisted: true }) creates a record already unlisted, so there's no window where it exists and is briefly enumerable. - setUnlisted(id, unlisted) toggles it on an existing record, gated exactly like setPermissions() under ScopedStack. - RecordFilter.includeUnlisted and SubscribeOptions.includeUnlisted opt back in, refused to everyone but the stack owner acting alone under ScopedStack — enumeration standing rests on nothing but ownership. - The feed matches query()'s exclusion, with one exception: the unlist transition emits a dedicated op (kind 'deleted') so a subscriber that already knows the record is told to drop it; relisting emits 'list' (kind 'changed'), an ordinary upsert like undelete. Wires the field and filter through every adapter (sqlite-shared, record-adapter-sqlite, adapter-local, the in-memory test double, and the adapter-api wire client), extends the conformance fixtures, and updates the spec docs (access-control.md, events.md, wire-format.md, data-model.md, versioning.md). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01RZ3v68CDeYsFRHRPDnsU2k --- .changeset/unlisted-records.md | 35 +++ docs/spec.md | 2 +- docs/spec/access-control.md | 70 ++++++ docs/spec/data-model.md | 5 +- docs/spec/events.md | 38 ++- docs/spec/versioning.md | 2 + docs/spec/wire-format.md | 21 +- packages/adapter-api/src/index.ts | 19 ++ .../adapter-api/tests/conformance.test.ts | 26 +- packages/adapter-local/src/index.ts | 8 + packages/conformance-fixtures/src/index.ts | 230 ++++++++++++++++++ .../tests/change-feed.test.ts | 2 + packages/core/src/changes.ts | 22 ++ packages/core/src/combine.ts | 1 + packages/core/src/stack.ts | 105 +++++++- packages/core/src/testing.ts | 19 ++ packages/core/src/types.ts | 50 +++- packages/core/tests/change-events.test.ts | 56 +++++ packages/core/tests/combine.test.ts | 19 ++ packages/core/tests/scoped-stack.test.ts | 86 +++++++ packages/core/tests/scoped-subscribe.test.ts | 70 ++++++ packages/core/tests/stack.test.ts | 73 ++++++ packages/record-adapter-sqlite/src/index.ts | 8 + .../tests/record.test.ts | 46 ++++ packages/sqlite-shared/src/mappers.ts | 1 + packages/sqlite-shared/src/query.ts | 4 + packages/sqlite-shared/src/record-logic.ts | 38 ++- packages/sqlite-shared/src/schema.ts | 2 + packages/wire-types/src/index.ts | 2 + 29 files changed, 1034 insertions(+), 26 deletions(-) create mode 100644 .changeset/unlisted-records.md diff --git a/.changeset/unlisted-records.md b/.changeset/unlisted-records.md new file mode 100644 index 0000000..f5c1a43 --- /dev/null +++ b/.changeset/unlisted-records.md @@ -0,0 +1,35 @@ +--- +'@haverstack/core': minor +'@haverstack/sqlite-shared': minor +'@haverstack/record-adapter-sqlite': minor +'@haverstack/adapter-local': minor +'@haverstack/wire-types': minor +'@haverstack/adapter-api': minor +'@haverstack/conformance-fixtures': minor +--- + +Add an `unlisted` state for records — reachable by ID, absent from enumeration by default. + +`StackRecord.unlistedAt` is a native field, orthogonal to `permissions`: it says nothing +about who may read a record, only whether it is enumerable. A record with `unlistedAt` set +is reachable by `get()` for anyone who may already read it, and excluded from an unfiltered +`query()` and the change feed by default — the same posture soft delete already has. + +- `stack.create(typeId, content, { unlisted: true })` creates a record already unlisted, so + there is no window where it exists and is briefly enumerable. +- `stack.setUnlisted(id, unlisted)` toggles it on an existing record, gated exactly like + `setPermissions()` under `ScopedStack` — both decide who can discover a record, not merely + read one already found. +- `RecordFilter.includeUnlisted` and `SubscribeOptions.includeUnlisted` opt a query or + subscription back in. Unlike `includeDeleted`, `includeUnlisted` is refused to everyone but + the stack owner acting alone under `ScopedStack` — enumeration standing rests on nothing but + ownership, so no grant or delegation carries it. +- The change feed matches `query()`'s exclusion, with one exception: marking a record unlisted + emits a dedicated `unlist` op (kind `deleted`) so a subscriber that already knows the record + is told to drop it; relisting emits `list` (kind `changed`), an ordinary upsert like + `undelete`. Every other transition — created unlisted, an edit while already unlisted, a + purge of a record that was never listed — needs no special-casing, since it falls out of + checking the record's current state. + +See docs/spec/access-control.md § Unlisted records and docs/spec/events.md § The unlisted +transition. diff --git a/docs/spec.md b/docs/spec.md index 132c0f9..302a749 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -48,7 +48,7 @@ stack.timezone; // from adapter.timezone — string | undefined `LocalAdapter.initialize()` fails if the file already exists. `LocalAdapter.open()` fails if the file does not exist. This makes the distinction explicit and prevents silent config divergence. -**`StackClient` is the passable interface.** Plugin and extension code that doesn't need to know the underlying backend should accept `StackClient` rather than the concrete `Stack` or `ScopedStack`. It covers the full record API (`create`, `get`, `query`, `update`, `delete`, `undelete`, `associate`, `dissociate`, `setPermissions`, `getVersions`, `getVersion`, `restoreVersion`, `getAttachment`, `putAttachment`, `deleteAttachment`, `collectAttachmentGarbage`) plus a `features` getter. Both `Stack` and `ScopedStack` implement it. +**`StackClient` is the passable interface.** Plugin and extension code that doesn't need to know the underlying backend should accept `StackClient` rather than the concrete `Stack` or `ScopedStack`. It covers the full record API (`create`, `get`, `query`, `update`, `delete`, `undelete`, `associate`, `dissociate`, `setPermissions`, `setUnlisted`, `getVersions`, `getVersion`, `restoreVersion`, `getAttachment`, `putAttachment`, `deleteAttachment`, `collectAttachmentGarbage`) plus a `features` getter. Both `Stack` and `ScopedStack` implement it. ### The `_config` record diff --git a/docs/spec/access-control.md b/docs/spec/access-control.md index c361d07..f33882b 100644 --- a/docs/spec/access-control.md +++ b/docs/spec/access-control.md @@ -75,6 +75,75 @@ What this buys is an invariant the mutate gate already assumed: **anything that What is _not_ offered is blind mutation of an existing Record. `update()` is a merge patch defined over content the requester would not be able to see, `ifVersion` needs a version number that comes from a read, and the write bit's recoverability argument requires prior content by construction. A write-only surface is a total write; nothing in this model is one. +## Unlisted records + +`unlistedAt` is a native field, orthogonal to `permissions`: it says nothing about who may read a Record, only whether it is enumerable. A Record with `unlistedAt` set is reachable by `get()` for anyone who may already read it, and absent from an unfiltered `query()` and the change feed by default — a signal for content that is genuinely public where the _location_ is what's being withheld (a bonus post for feed subscribers, a superseded page kept alive for old links), never a substitute for `permissions` on content that must stay unreadable. + +```ts +type StackRecord = { + // ... + unlistedAt?: Date; // Present if withheld from enumeration +}; + +type RecordFilter = { + // ... + includeUnlisted?: boolean; // Unlisted Records are excluded by default +}; +``` + +**Stated plainly, because the name will otherwise over-promise:** + +> Unlisted withholds a Record from enumeration and announcement. It never withholds the Record. A requester who may read it and holds its ID gets it. A requester without the ID has no supported way to discover it. + +That sits inside the same threat model as [record IDs being guessable](#errors-and-information-exposure) — "refuse to confirm a candidate" is the existing posture, and unlisted is that posture applied to discovery rather than to a single ID. + +### Three tiers, not two + +The reach question access control usually asks is binary — enforced or not — but enumeration has a real middle tier, and `unlistedAt` occupies it rather than inventing a softer word for "advisory": + +| | Behavior | Occupants | +| ------------- | ------------------------------------------ | ------------------------------------ | +| **Enforced** | Refused regardless of what the caller asks | `permissions`, grants | +| **Defaulted** | Refused unless the caller asks | `deletedAt`, `unlistedAt`, `_config` | +| **Advisory** | Always returned; the consumer decides | A tag convention | + +A consumer that has never heard of `unlistedAt` gets correct behavior by default — an unfiltered `query()` excludes it, the same posture as `deletedAt` and `_config`. That default is what makes the field real rather than a documentation-only convention: nothing about it depends on every consumer choosing to respect it. + +### Setting it + +```ts +await stack.create(typeId, content, { unlisted: true }); // created already unlisted — no window where it's briefly enumerable +await stack.setUnlisted(recordId, true); // withhold an existing Record +await stack.setUnlisted(recordId, false); // relist it +``` + +`setUnlisted()` is gated exactly like [`setPermissions()`](#the-write-bit-a-recoverability-trust-model) under `ScopedStack` — owner-or-creator, asked of both identities under delegation — because both decide who or what can _discover_ a Record rather than merely read one already found. `_group` Records follow the same admin-or-owner rule `setPermissions()` uses there too. No-op if the Record is already in the requested state. + +### `includeUnlisted` is owner-only + +Unlike `includeDeleted` — which any `ScopedStack` requester may pass, since a soft-deleted Record's own `permissions` still gate whether they can see it — **`includeUnlisted` is refused to everyone but the owner acting alone**, on both `query()` and `subscribe()`: + +```ts +stack.query({ filter: { includeUnlisted: true } }); // plain Stack: honored +scoped.query({ filter: { includeUnlisted: true } }); // ScopedStack, non-owner: StackPermissionError +``` + +Enumeration standing rests on nothing but ownership. A grant conveys reach over specific Records or a type family; it says nothing about whether the requester should see the stack's _entire_ enumeration surface, unlisted Records included — so no grant, and no delegation, carries the flag. An owner principal acting for a visitor through the owner's own server does not lend that visitor the flag either, for the same reason delegation carries none of the [owner-acting-alone verbs](#delegation-principal-and-subject). The flag is refused outright rather than silently dropped: a caller that believes it captured the full enumeration and silently got the filtered one is worse off than one that was told no — the same reasoning [create-time `permissions`](#delegation-principal-and-subject) is refused under delegation rather than quietly stripped. + +### The feed matches `query()` + +An unlisted Record that emits a change event to a default subscriber is not unlisted — so `subscribe()`'s default exclusion and `includeUnlisted` opt-in mirror `query()`'s exactly, including the owner-only gate on the opt-in. The one wrinkle is the transition itself: marking a Record unlisted must still reach a subscriber who already knows it, so it can drop its copy, even though the Record's new state would otherwise fail that same exclusion. See [Change events § The unlisted transition](./events.md#the-unlisted-transition) for the full transition table and the `list`/`unlist` change ops. + +### What this is not + +**Not a fourth `Permission` variant.** `Permission` is a union over mutually exclusive answers to "who may read this"; `unlistedAt` is orthogonal to that question, not another answer to it, and composes with any permission tier — public-and-unlisted (a bonus post) and owner-only-and-unlisted are both coherent, meaning different things. + +**Not per-audience.** There is no `Listing[]` parallel to `Permission[]` — enumeration does not vary by who is asking, the way reach does. A record is unlisted for everyone or for no one; if a future need for audience-varying enumeration arises, that is new surface, not a reinterpretation of this field. + +**Not a query filter for the excluded half.** `RecordFilter` has no negation, so `includeUnlisted: true` returns _both_ listed and unlisted Records together — there is no "unlisted only" filter. A consumer that needs to tell them apart checks `unlistedAt` on the results it gets back. + +`ScopedStack.query()`'s `total` excludes unlisted Records from the count the same way it excludes everything else the requester can't see — a count that included what the exclusion just hid would leak the fact being withheld. + ## Type-level grants A Grant authorises one or more Entities to perform specific actions on Records of a given Type, without touching individual records — a `read-any` grant on `comment@1` makes all comments of that type readable by the grantee without setting `permissions` on each one. Grants are modeled as Records of the built-in system type `_grant`, making them queryable, versioned, and subject to the same lifecycle as any other Record. @@ -208,6 +277,7 @@ Read in the other direction, an **owner principal** acting for someone else — | Verb | Why delegation doesn't carry it | | ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `includeUnlisted` on `query()`/`subscribe()` | Enumeration standing rests on nothing but ownership — see [Unlisted records](#unlisted-records) | | Hard delete | Irreversible; the subject holds soft delete already | | `deleteAttachment()`, `collectAttachmentGarbage()` | Irreversible, and neither takes a Record to gate on | | Unstripped snapshot `permissions` | Discloses the stack's sharing graph | diff --git a/docs/spec/data-model.md b/docs/spec/data-model.md index 0e7a7e2..978ec7d 100644 --- a/docs/spec/data-model.md +++ b/docs/spec/data-model.md @@ -24,6 +24,7 @@ type StackRecord = { updatedBy?: string; // Who performed the most recent mutation. Unlike entityId, it moves with every write (see Authorship and attribution) updatedVia?: string; // The principal behind that mutation, when it isn't updatedBy deletedAt?: Date; // Present if soft-deleted + unlistedAt?: Date; // Present if withheld from enumeration — reachable by get(), absent from query()/the feed by default (see Access control) permissions?: Permission[]; // Access control (see Access control) associations?: Association[]; // Tags, attachments, relationships }; @@ -197,7 +198,7 @@ The migration registry is **per-stack-instance** — different stacks can be at - **`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. Throws `StackMigrationError` when a matched Record's version can't be reconciled with what this app instance has registered (see stale-writer behavior below). - **`update()` never migrates.** It validates the merge-patched 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. - **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 — call it at app startup after registering migrations, or after a schema change. It sweeps soft-deleted Records unconditionally (`includeDeleted` is not a caller option in either direction — see [Deletion](./versioning.md#deletion)), 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. +- **`migrateAll("com.example.myapp/note")`** eagerly commits all pending migrations for a type family in one deliberate pass — 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](./access-control.md#unlisted-records)), 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()`/`update()` 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()`. 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. @@ -280,6 +281,8 @@ By default, `query()` (like `get()`) returns Records exactly as stored — see [ **`query()` never returns the `_config` record**, regardless of filter — it's addressable only by ID, via `get('_config')` or the adapter's own typed `ownerEntityId`/`timezone` properties (see [Stack initialization](../spec.md#stack-initialization)). This is the one exception to "adapters are storage engines, `Stack` is the invariant layer": the exclusion must live in the adapter's own query predicate (a `WHERE` clause, or the equivalent for an in-memory adapter) rather than be post-filtered by `Stack`, since post-filtering after the adapter applies `limit` would silently under-fill a page. Every adapter — including test doubles — implements this exclusion directly; it is not optional convention. +**Unlisted Records are excluded by default too**, the same posture as soft-deleted ones: `includeUnlisted` opts a query back in, and — unlike `includeDeleted` — `ScopedStack` restricts that opt-in to the owner acting alone. See [Unlisted records](./access-control.md#unlisted-records). + ### Sorting and pagination ```ts diff --git a/docs/spec/events.md b/docs/spec/events.md index 2c24dfc..48e020f 100644 --- a/docs/spec/events.md +++ b/docs/spec/events.md @@ -36,7 +36,9 @@ type ChangeOp = | 'restore' | 'delete' | 'undelete' - | 'hard-delete'; + | 'hard-delete' + | 'unlist' + | 'list'; type RecordChange = { kind: ChangeKind; @@ -54,12 +56,12 @@ type RecordChange = { `kind` and `op` map deterministically: -| `kind` | `op` | -| --------- | ------------------------------------------------------------------------------------ | -| `created` | `create` | -| `changed` | `update`, `associate`, `dissociate`, `permissions`, `migrate`, `restore`, `undelete` | -| `deleted` | `delete` (soft) | -| `purged` | `hard-delete` | +| `kind` | `op` | +| --------- | -------------------------------------------------------------------------------------------- | +| `created` | `create` | +| `changed` | `update`, `associate`, `dissociate`, `permissions`, `migrate`, `restore`, `undelete`, `list` | +| `deleted` | `delete` (soft), `unlist` | +| `purged` | `hard-delete` | **Two discriminators at different altitudes, not per-verb events.** `kind` is the coarse branch every consumer must make, and it is closed at four values: a subscriber that handles exactly `created`/`changed`/`deleted`/`purged` is _correct_, not merely adequate. `changed` is an **upsert** signal, never "you have seen this before" — a subscriber can receive `changed` for a record it has never seen, because gaining access arrives that way. `op` is the precise verb, for audit logs and sync engines that care whether a permission change or a content edit produced this version. @@ -142,6 +144,7 @@ type SubscribeChangesOptions = { filter?: ChangeFilter; since?: string; includeRecords?: boolean; + includeUnlisted?: boolean; onError?: (err: unknown) => void; onReset?: () => void; }; @@ -169,6 +172,7 @@ type Unsubscribe = () => void; type SubscribeOptions = { filter?: ChangeFilter; includeRecords?: boolean; + includeUnlisted?: boolean; // owner-only under ScopedStack — see Access control § Unlisted records onError?: (err: unknown) => void; onReset?: () => void; }; @@ -196,6 +200,8 @@ interface StackClient { **A scoped feed is the events that scope may read, and nothing else.** The predicate is literally `canRead` applied per event — no second vocabulary, no feed-specific ACL. `ScopedStack.subscribe()` filters `Stack`'s stream, so scoping needs no adapter cooperation. +**The unlisted exclusion composes with `canRead` rather than replacing it, and is not a second ACL either.** It is the same boundary an unfiltered `query()` applies, asked again here so the feed can never deliver more than an equivalent `query()` would return — see [The unlisted transition](#the-unlisted-transition) below. + **A scoped view of a stack that relays refuses to subscribe**, with `StackRelayScopeError`. `canRead` needs the record, and a relayed frame does not carry one — on a `purged` frame there is nothing left to fetch either. Neither answer available here is honest: delivering relayed frames would hand a narrower scope events it may not be entitled to, and delivering only local writes would silently drop every change made elsewhere, which is [the failure that looks fine in testing](./wire-format.md#feed-implementation-checklist). A relayed feed is already scoped by the session that opened it, so the way to scope one is to open it with the session you mean — a server does exactly that, subscribing unscoped at the storage owner it holds and fanning out per connection. - **A record a subscriber cannot read produces no event**, not an empty or redacted one. Event existence is itself a disclosure — the same reasoning that makes `ScopedStack.query()`'s `total` always `null`. @@ -207,6 +213,24 @@ interface StackClient { **This is only sound because every authority-changing write emits.** `_grant` and `_group` writes are ordinary record mutations and reach the emitter like any other. A future change that altered either without emitting would strand every cached decision, so that invariant belongs to this section as much as to [Where events come from](#where-events-come-from). +## The unlisted transition + +An unlisted record that emits a change event to a default subscriber is not unlisted, so the feed excludes them the same way `query()` does — `includeUnlisted` opts a subscription back in, gated exactly as [`RecordFilter.includeUnlisted`](./access-control.md#includeunlisted-is-owner-only) is. Since `unlistedAt` deliberately keeps `get()` working, an ID is sufficient to fetch; if `query()` excluded unlisted records but the feed did not, the feed would be a strictly better enumeration channel than the query it is supposed to match. + +**Suppression is not total, or a default subscriber would keep a stale copy forever.** Soft delete is the model: `query()` hides a deleted record while the feed still emits `deleted`, because that event is what tells a subscriber to drop its copy. The same reasoning governs every transition here: + +| Transition | Emits to a default subscriber? | Why | +| ---------------------------- | :----------------------------: | ------------------------------------------------------------------- | +| Created unlisted | No | Nobody knew it existed; the announcement _is_ the disclosure | +| Listed → unlisted (`unlist`) | **Yes** | Subscribers already know it and must drop it | +| Any change while unlisted | No | The ongoing case the feature exists for | +| Unlisted → listed (`list`) | **Yes** | The publish moment | +| Hard delete while unlisted | No | Same reasoning as row 1 — nothing was ever announced to un-announce | + +Only the second row needs special-casing. Every other row falls out of checking the record's **current** `unlistedAt` against the subscriber's `includeUnlisted`, the same check `query()`'s default filter makes: a just-created or still-unlisted record's current state already excludes it, with no need to know which op produced the event. The `unlist` transition is the one case where that check would give the wrong answer, because the record's post-change state is exactly what it is announcing — so the exclusion is asked of the **pre**-change state there, which is why `unlist` gets a dedicated `op` (mapped to `kind: 'deleted'`, per [The event shape](#the-event-shape)) rather than reusing `permissions`'s pattern of one op for both directions. + +**`list` needs no new semantics.** Kind `changed` is already an upsert a subscriber may never have seen before — the same case [gaining access](#known-limitations) already covers — so a record created silently, edited silently any number of times while unlisted, and finally relisted reaches a default subscriber as a single `changed` event it upserts as if seeing the record for the first time. + ## Delivery - **At-least-once.** Duplicates are legal and expected. The dedupe key is `(recordId, version, kind)` — `kind` is in the key because a `changed` at v7 and a `purged` at v7 are different events about the same version. diff --git a/docs/spec/versioning.md b/docs/spec/versioning.md index 2044e00..9d0a027 100644 --- a/docs/spec/versioning.md +++ b/docs/spec/versioning.md @@ -115,3 +115,5 @@ const record = await stack.undelete(recordId); // clears deletedAt, returns the Under `ScopedStack`, `undelete()` is gated the same way as `delete()` — the `write` bit or a `delete-own`/`delete-any` grant. Undelete is the inverse of soft delete, so the same capability governs both directions; granting one without the other would be backwards. (Hard delete's owner-only carve-out is unaffected — it has no inverse.) Undelete does not re-run migrations. If a soft-deleted Record's schema fell behind while it was deleted, it comes back stale — a legal state, self-healing the next time it's written or `migrateAll()` sweeps it. `migrateAll()` includes soft-deleted Records in its sweep, so a Record can be migrated while deleted and come back current on undelete. + +**`unlistedAt` is a sibling mechanism, not a variant of this one.** It withholds a Record from enumeration rather than from access — the Record stays fully readable and mutable throughout — and its own opt-in flag, ownership rule, and feed behavior differ from soft delete's in ways worth reading directly rather than assuming symmetric. See [Unlisted records](./access-control.md#unlisted-records). diff --git a/docs/spec/wire-format.md b/docs/spec/wire-format.md index 15f98f1..54d3536 100644 --- a/docs/spec/wire-format.md +++ b/docs/spec/wire-format.md @@ -139,6 +139,7 @@ Core runs no server, so every control below lives in the implementer's code — - **Return 401 and 403 for different things.** Anonymous or invalid token is 401; verified-but-ungranted is 403 (see [Error responses](#error-responses)). Collapsing them discards the distinction the whole identity model rests on. - **Log the refusal you didn't send.** A 404 covering a record the requester could not read is [deliberately indistinguishable from a missing one](./access-control.md#errors-and-information-exposure) — to the client. The operator is not the adversary, so record which of the two it was, along with the requester DID (as [Identity](./identity.md#authentication-challengeresponse) already asks for denied-but-verified requests), the record ID, and the check that refused. Without it the distinction is lost to everyone: a grant that is subtly wrong looks exactly like a bad ID or a write that never landed, so it sends whoever is debugging it to the write path, which is the one place the bug is not. This is the bullet that makes the anti-oracle rule cheap to live with, and skipping it is how a deployment concludes the rule is not worth keeping. Treat the log as sensitive in its own right — "who asked after what, and was refused" is the sharing graph, written down. - **Rate-limit record reads, not just writes.** The remaining ID-guessing attack is online — every candidate costs a request — so a limit sane for a personal Stack is most of the defence. One known millisecond is 32,768 candidates: 33 seconds to exhaust at 1000 req/s, 55 minutes at 10 req/s, and a single second of uncertainty about the timestamp multiplies both by a thousand. A server that answers an unauthenticated 404 with `WWW-Authenticate` keeps the login prompt working without reopening the distinction, since it says the same thing for a missing record. +- **Strip `includeUnlisted` before it reaches an unscoped `Stack`.** Routing the request through `ScopedStack` makes the owner-only refusal automatic; a server that maps query params or a subscribe request straight onto an unscoped adapter call must refuse or strip the flag itself, exactly as it already must for `entityId`/`principalId` on a create body. See [Unlisted § The one unsafe path](#unlisted). - **A duplicate client-minted `id` answers 409, and that is an existence check.** Anyone holding a `create` grant on any type can learn whether an ID is taken by trying to use it (see [Record IDs](./data-model.md#record-ids)). This one cannot be closed in the response — any answer other than "created" confirms the ID — and it is accepted rather than closed, because it is what makes a create safe to retry after a network blip. Unlike a read, it is loud: every probe writes a record stamped with the requester's `entityId` that the owner can see and count, and that the requester cannot hard delete. Rate-limit creates, and narrow `idTimestampSkewMs` if a deployment wants the window tighter than 24 hours. Two more sit in code this spec doesn't reach but a server does: `entityId` and `principalId` [must be ignored on input](#records), and a session's two identities must reach `ScopedStack` in the right order — `Stack.forSession()` takes the pair whole for that reason. @@ -216,7 +217,7 @@ POST /records/:id/undelete — undelete (reverse a soft delete; idempotent) POST /records/:id/migrate — commit a migration (change typeId + content together) ``` -**Every mutation that bumps `version` answers with the record it produced** — `POST /records`, `PATCH /records/:id`, both association endpoints, `PUT .../permissions`, `DELETE` (soft), `POST .../undelete`, `POST .../migrate` and `POST .../restore/:version` all return `200` with a Record body. A hard delete produces no version and returns `204`. +**Every mutation that bumps `version` answers with the record it produced** — `POST /records`, `PATCH /records/:id`, both association endpoints, `PUT .../permissions`, `PUT .../unlisted`, `DELETE` (soft), `POST .../undelete`, `POST .../migrate` and `POST .../restore/:version` all return `200` with a Record body. A hard delete produces no version and returns `204`. This is what lets a client report a mutation's outcome without a second read, and it is load-bearing for [change events](./events.md): the emitter reads the version, timestamp and acting identity of a change off what was persisted rather than inferring them, so a frame cannot disagree with storage. A server answering `204` to any of the above leaves a client unable to say what it just wrote. @@ -243,6 +244,7 @@ This is what lets a client report a mutation's outcome without a second read, an ?limit= ?cursor= ?includeDeleted= +?includeUnlisted= (owner-only — see Unlisted) ``` `GET /records` covers all native field queries and is usable from a browser or simple HTTP client without a JSON body. `POST /records/query` is a superset — it accepts the full `Query` object as a JSON body and additionally supports `content` field filtering. A server that declares `contentFieldQuery: false` in discovery does not support the POST query endpoint. @@ -251,7 +253,7 @@ This is what lets a client report a mutation's outcome without a second read, an `PATCH /records/:id` accepts a partial content object. Omitted fields retain their current values. A field set to `null` is removed (RFC 7396 / JSON Merge Patch). Associations and permissions are managed via their own endpoints. -**Optimistic concurrency:** `PATCH`, `DELETE`, `POST .../undelete`, `POST .../restore/:version`, `POST .../migrate`, and the association/permission endpoints below all accept an optional `If-Match` header: +**Optimistic concurrency:** `PATCH`, `DELETE`, `POST .../undelete`, `POST .../restore/:version`, `POST .../migrate`, and the association/permission/unlisted endpoints below all accept an optional `If-Match` header: ``` PATCH /records/abc123 @@ -315,9 +317,21 @@ PUT /records/:id/permissions — replace all permissions (empty array = An entry conveying `write` without `read` is refused with `422` (code `validation`), here and wherever else a request body carries `permissions`: the write bit reaches content and history through the mutate surface, so it withholds nothing without read. See [Access control § Write implies read](./access-control.md#write-implies-read). +## Unlisted + +``` +PUT /records/:id/unlisted — withhold from enumeration, or relist +``` + +Request body: `{ "unlisted": boolean }`. Answers `200` with the updated **Record** — it bumps `version` like any other mutation — carrying `unlistedAt` when `true`, absent when `false`. Accepts the same optional `If-Match` precondition as every other mutating endpoint. Orthogonal to `PUT .../permissions`: it decides whether the record is enumerable, never who may read it. See [Access control § Unlisted records](./access-control.md#unlisted-records). + +**`GET /records` and `POST /records/query` accept `includeUnlisted`** (a query parameter on the former, a `filter` key on the latter), excluded by default like `includeDeleted`. **A server built on `ScopedStack` MUST refuse it with `403` for any requester but the owner acting alone** — enumeration standing rests on nothing but ownership, so no grant or delegation carries it (see [Access control § `includeUnlisted` is owner-only](./access-control.md#includeunlisted-is-owner-only)). `GET /changes` accepts the same parameter, refused on the same terms, for the change feed's own default exclusion — see [Change feed](#change-feed) below. + +**The one unsafe path is a server mapping a request body straight onto an unscoped `Stack`.** Unscoped `Stack` honors `includeUnlisted` unconditionally, the same as `includeDeleted` — it is fully trusted by definition — so a server that forwards a request's filter verbatim onto one must strip `includeUnlisted` itself before dispatching, exactly as it already must for `entityId`/`principalId` on a create body (see [Records](#records)). Routing the request through `ScopedStack` instead makes this the library's problem rather than the server's, which is the shape every example in this document assumes. + ## Versions -**The server snapshots prior state automatically on every mutating endpoint that bumps `version`** — there is no client-initiated endpoint to write a version directly. The list is exhaustive on purpose: `PATCH /records/:id`, the association endpoints, `PUT .../permissions`, `DELETE` (soft), `POST .../undelete`, `POST .../migrate`, and `POST .../restore/:version` itself (restore always creates a new version). `saveVersion()` is a deliberate no-op over `APIAdapter` — the server is the only snapshot writer for this adapter — so a server that implements anything less than every endpoint above silently loses rollback history for that endpoint's mutations. +**The server snapshots prior state automatically on every mutating endpoint that bumps `version`** — there is no client-initiated endpoint to write a version directly. The list is exhaustive on purpose: `PATCH /records/:id`, the association endpoints, `PUT .../permissions`, `PUT .../unlisted`, `DELETE` (soft), `POST .../undelete`, `POST .../migrate`, and `POST .../restore/:version` itself (restore always creates a new version). `saveVersion()` is a deliberate no-op over `APIAdapter` — the server is the only snapshot writer for this adapter — so a server that implements anything less than every endpoint above silently loses rollback history for that endpoint's mutations. ``` GET /records/:id/versions — list all versions (newest first) @@ -490,6 +504,7 @@ Last-Event-ID: (equivalently ?since=) ?entityId= (the record's author, not the actor) ?kind= (repeatable: created|changed|deleted|purged) ?include=record (ignored for kind=purged) +?includeUnlisted= (owner-only — see Unlisted) ``` Response: `200 text/event-stream`, a stream of frames. diff --git a/packages/adapter-api/src/index.ts b/packages/adapter-api/src/index.ts index bea2b9c..7f67ebf 100644 --- a/packages/adapter-api/src/index.ts +++ b/packages/adapter-api/src/index.ts @@ -241,6 +241,7 @@ const parseRecord = (raw: WireRecord): StackRecord => { if (raw.updatedBy != null) record.updatedBy = raw.updatedBy; if (raw.updatedVia != null) record.updatedVia = raw.updatedVia; if (raw.deletedAt != null) record.deletedAt = new Date(raw.deletedAt); + if (raw.unlistedAt != null) record.unlistedAt = new Date(raw.unlistedAt); if (raw.permissions != null) record.permissions = raw.permissions; if (raw.associations != null) record.associations = raw.associations; return record; @@ -330,6 +331,7 @@ const buildQueryParams = (query: StackQuery): URLSearchParams => { } if (f.search) p.set('search', f.search); if (f.includeDeleted) p.set('includeDeleted', 'true'); + if (f.includeUnlisted) p.set('includeUnlisted', 'true'); if (query.sort?.field) p.set('sort', query.sort.field); if (query.sort?.direction) p.set('direction', query.sort.direction); if (query.limit) p.set('limit', String(query.limit)); @@ -380,6 +382,7 @@ const buildChangeParams = (opts: SubscribeChangesOptions): URLSearchParams => { if (f.entityId !== undefined) p.set('entityId', f.entityId); if (f.kinds !== undefined) for (const kind of f.kinds) p.append('kind', kind); if (opts.includeRecords) p.set('include', 'record'); + if (opts.includeUnlisted) p.set('includeUnlisted', 'true'); return p; }; @@ -1002,6 +1005,22 @@ export class APIAdapter implements StackAdapter { return requireRecordBody(raw, `PUT /records/${id}/permissions`); } + async setUnlisted( + id: RecordId, + unlisted: boolean, + opts: { expectedVersion?: number } = {}, + ): Promise { + const raw = await this.request( + 'PUT', + `/records/${id}/unlisted`, + { unlisted }, + { + ifMatch: opts.expectedVersion, + }, + ); + return requireRecordBody(raw, `PUT /records/${id}/unlisted`); + } + // ------------------------------------------------------- // Versions // ------------------------------------------------------- diff --git a/packages/adapter-api/tests/conformance.test.ts b/packages/adapter-api/tests/conformance.test.ts index 4245a1b..759bc74 100644 --- a/packages/adapter-api/tests/conformance.test.ts +++ b/packages/adapter-api/tests/conformance.test.ts @@ -17,6 +17,7 @@ import { associateFixtures, dissociateFixtures, setPermissionsFixtures, + setUnlistedFixtures, getVersionsFixtures, getVersionFixtures, getVersionsAfterMutateFixtures, @@ -255,12 +256,13 @@ describe('createRecord fixtures', () => { const adapter = await openAdapter(); mockFetch.mockResolvedValueOnce(jsonResponse(fixture.responseBody, fixture.responseStatus)); - const { createdAt, updatedAt, deletedAt, ...req } = fixture.requestBody!; + const { createdAt, updatedAt, deletedAt, unlistedAt, ...req } = fixture.requestBody!; await adapter.createRecord({ ...req, createdAt: new Date(createdAt), updatedAt: new Date(updatedAt), ...(deletedAt !== undefined && { deletedAt: new Date(deletedAt) }), + ...(unlistedAt !== undefined && { unlistedAt: new Date(unlistedAt) }), }); const [url, init] = mockFetch.mock.lastCall as [string, RequestInit]; @@ -447,6 +449,28 @@ describe('setPermissions fixtures', () => { } }); +describe('setUnlisted fixtures', () => { + for (const fixture of setUnlistedFixtures) { + test(fixture.name, async () => { + const adapter = await openAdapter(); + mockFetch.mockResolvedValueOnce(jsonResponse(fixture.responseBody, fixture.responseStatus)); + + const result = await adapter.setUnlisted( + idFromPath(fixture.path), + fixture.requestBody!.unlisted, + ); + + 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); + expect(result.unlistedAt).toEqual( + fixture.responseBody!.unlistedAt ? new Date(fixture.responseBody!.unlistedAt) : undefined, + ); + }); + } +}); + describe('getVersions fixtures', () => { for (const fixture of getVersionsFixtures) { test(fixture.name, async () => { diff --git a/packages/adapter-local/src/index.ts b/packages/adapter-local/src/index.ts index dbc4c31..b59ac27 100644 --- a/packages/adapter-local/src/index.ts +++ b/packages/adapter-local/src/index.ts @@ -267,6 +267,14 @@ export class LocalAdapter implements StackAdapter { return this.record.setPermissions(id, permissions, opts); } + async setUnlisted( + id: RecordId, + unlisted: boolean, + opts?: { expectedVersion?: number; snapshot?: RecordVersion } & ActorOptions, + ): Promise { + return this.record.setUnlisted(id, unlisted, opts); + } + async getVersions(id: RecordId): Promise { return this.record.getVersions(id); } diff --git a/packages/conformance-fixtures/src/index.ts b/packages/conformance-fixtures/src/index.ts index 8f81332..1e9f831 100644 --- a/packages/conformance-fixtures/src/index.ts +++ b/packages/conformance-fixtures/src/index.ts @@ -236,6 +236,36 @@ export const createRecordFixtures: ConformanceFixture[] version: 1, }, }, + { + name: 'create-record-unlisted', + description: + 'A create body carrying unlistedAt is honoured verbatim — creating a record already ' + + 'unlisted, so there is no window where it exists and is enumerable before a later ' + + 'PUT .../unlisted catches up. Excluded from an unfiltered GET/POST /records/query and the ' + + 'change feed by default, the same as any other unlisted record. See ' + + 'docs/spec/access-control.md § Unlisted records.', + method: 'POST', + path: '/records', + requestBody: { + id: '1hk153x00009', + typeId: 'com.example/note@1', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + content: { title: 'Link-shared draft' }, + version: 1, + unlistedAt: '2024-01-01T00:00:00.000Z', + }, + responseStatus: 200, + responseBody: { + id: '1hk153x00009', + typeId: 'com.example/note@1', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + content: { title: 'Link-shared draft' }, + version: 1, + unlistedAt: '2024-01-01T00:00:00.000Z', + }, + }, { name: 'create-record-ignores-client-supplied-entity-and-principal', description: @@ -718,6 +748,53 @@ export const setPermissionsFixtures: ConformanceFixture<{ permissions: unknown[] }, ]; +// ------------------------------------------------------- +// Unlisted +// ------------------------------------------------------- + +export const setUnlistedFixtures: ConformanceFixture<{ unlisted: boolean }, WireRecord>[] = [ + { + name: 'set-unlisted-true', + description: + 'PUT /records/:id/unlisted withholds a record from enumeration without changing who may ' + + 'read it: the response carries unlistedAt and the bumped version, but permissions (if any) ' + + 'are untouched. Orthogonal to PUT .../permissions — see docs/spec/access-control.md ' + + '§ Unlisted records.', + method: 'PUT', + path: '/records/1hk153x00001/unlisted', + requestBody: { unlisted: true }, + responseStatus: 200, + responseBody: { + id: '1hk153x00001', + typeId: 'com.example/note@1', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + content: { title: 'Hello', body: 'World' }, + version: 2, + unlistedAt: '2024-01-02T00:00:00.000Z', + }, + }, + { + name: 'set-unlisted-false-relists', + description: + 'PUT /records/:id/unlisted with { "unlisted": false } reverses it — the record comes back ' + + 'with unlistedAt absent, and is enumerable again by an unfiltered query() and the change ' + + 'feed. Idempotent, like undelete: assumes prior state from set-unlisted-true.', + method: 'PUT', + path: '/records/1hk153x00001/unlisted', + requestBody: { unlisted: false }, + responseStatus: 200, + responseBody: { + id: '1hk153x00001', + typeId: 'com.example/note@1', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-03T00:00:00.000Z', + content: { title: 'Hello', body: 'World' }, + version: 3, + }, + }, +]; + // ------------------------------------------------------- // Versions: read // ------------------------------------------------------- @@ -948,6 +1025,22 @@ export const errorResponseFixtures: ConformanceFixture[] = [ responseStatus: 403, responseBody: { error: { code: 'permission', message: 'Permission denied' } }, }, + { + name: 'error-permission-denied-includeUnlisted-non-owner', + description: + 'POST /records/query with filter.includeUnlisted (equally, GET /records?includeUnlisted=true, ' + + 'or ?includeUnlisted=true on GET /changes) from anyone but the stack owner acting as itself ' + + 'returns 403 / code "permission" — enumeration standing rests on nothing but ownership, so ' + + 'no grant or delegation carries it. A server MUST refuse the flag outright rather than ' + + 'silently drop it: a caller that believes it asked for the full picture and silently got ' + + 'the filtered one is worse than one that was told no. See docs/spec/access-control.md ' + + '§ Unlisted records.', + method: 'POST', + path: '/records/query', + requestBody: { filter: { includeUnlisted: true } }, + responseStatus: 403, + responseBody: { error: { code: 'permission', message: 'Permission denied' } }, + }, { name: 'error-permission-denied-restore-reference-reconveyance', description: @@ -2227,6 +2320,142 @@ export const changeFeedFixtures: ChangeFeedFixture[] = [ }, ], }, + { + name: 'change-feed-unlist-frame-is-a-deleted-kind', + description: + 'Marking a record unlisted arrives as kind "deleted" / op "unlist" — not "changed" — even ' + + 'though the record still exists and get() still resolves it. A subscriber without ' + + 'includeUnlisted already knows this record from before, and the record’s new state ' + + '(unlistedAt now set) would otherwise be excluded by the very filter this event announces, ' + + 'so the transition is delivered on the same terms as an ordinary soft delete: the point is ' + + 'telling a subscriber to drop its copy, not that the record is gone. See ' + + 'docs/spec/events.md § The unlisted transition.', + path: '/changes', + responseStatus: 200, + openingFrames: [READY], + activity: [ + { + mutation: { + name: 'change-feed-unlist-frame-mutation', + description: 'The owner marks the note unlisted.', + method: 'PUT', + path: '/records/1hk153x00001/unlisted', + requestBody: { unlisted: true }, + responseStatus: 200, + responseBody: { + id: '1hk153x00001', + typeId: 'com.example/note@1', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-03T00:00:00.000Z', + content: { title: 'Hello' }, + version: 3, + entityId: FEED_OWNER, + unlistedAt: '2024-01-03T00:00:00.000Z', + }, + }, + frames: [ + { + id: 'AA3f1U', + event: 'record', + data: { + kind: 'deleted', + op: 'unlist', + recordId: '1hk153x00001', + typeId: 'com.example/note@1', + version: 3, + updatedAt: '2024-01-03T00:00:00.000Z', + actor: { entityId: FEED_OWNER }, + }, + }, + ], + }, + ], + }, + { + name: 'change-feed-list-frame-is-a-changed-kind', + description: + 'Relisting a previously-unlisted record arrives as kind "changed" / op "list" — the ' + + 'publish moment, mechanically identical to an ordinary upsert. A subscriber applies it the ' + + 'same way it applies "undelete": it may never have seen this record before (its earlier ' + + 'create and any edits while unlisted were withheld), and this is the first event that ' + + 'names it. Assumes prior state from change-feed-unlist-frame-is-a-deleted-kind. See ' + + 'docs/spec/events.md § The unlisted transition.', + path: '/changes', + responseStatus: 200, + openingFrames: [READY], + activity: [ + { + mutation: { + name: 'change-feed-list-frame-mutation', + description: 'The owner relists the note.', + method: 'PUT', + path: '/records/1hk153x00001/unlisted', + requestBody: { unlisted: false }, + responseStatus: 200, + responseBody: { + id: '1hk153x00001', + typeId: 'com.example/note@1', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-04T00:00:00.000Z', + content: { title: 'Hello' }, + version: 4, + entityId: FEED_OWNER, + }, + }, + frames: [ + { + id: 'AA3f1V', + event: 'record', + data: { + kind: 'changed', + op: 'list', + recordId: '1hk153x00001', + typeId: 'com.example/note@1', + version: 4, + updatedAt: '2024-01-04T00:00:00.000Z', + actor: { entityId: FEED_OWNER }, + }, + }, + ], + }, + ], + }, + { + name: 'change-feed-unlisted-record-produces-no-frame-by-default', + description: + 'An edit to a record that is currently unlisted produces no frame at all for a subscriber ' + + 'without includeUnlisted — not an empty or redacted one. This is what makes the feed match ' + + 'an equivalent query(): a record excluded from listings is excluded from the announcement ' + + 'stream too, on every op except the unlist transition itself (see ' + + 'change-feed-unlist-frame-is-a-deleted-kind). Assumes the note was already made unlisted. ' + + 'See docs/spec/events.md § The unlisted transition.', + path: '/changes', + responseStatus: 200, + openingFrames: [READY], + activity: [ + { + mutation: { + name: 'change-feed-unlisted-record-edit-mutation', + description: 'The owner edits the still-unlisted note.', + method: 'PATCH', + path: '/records/1hk153x00001', + requestBody: { title: 'Edited while unlisted' }, + responseStatus: 200, + responseBody: { + id: '1hk153x00001', + typeId: 'com.example/note@1', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-04T00:00:00.000Z', + content: { title: 'Edited while unlisted' }, + version: 4, + entityId: FEED_OWNER, + unlistedAt: '2024-01-03T00:00:00.000Z', + }, + }, + frames: [], + }, + ], + }, { name: 'change-feed-purged-frame-carries-nothing-about-the-record', description: @@ -2689,6 +2918,7 @@ export const allConformanceFixtures: ConformanceFixture[] = [ ...associateFixtures, ...dissociateFixtures, ...setPermissionsFixtures, + ...setUnlistedFixtures, ...getVersionsFixtures, ...getVersionFixtures, ...getVersionsAfterMutateFixtures, diff --git a/packages/conformance-fixtures/tests/change-feed.test.ts b/packages/conformance-fixtures/tests/change-feed.test.ts index e4f6309..9374e0b 100644 --- a/packages/conformance-fixtures/tests/change-feed.test.ts +++ b/packages/conformance-fixtures/tests/change-feed.test.ts @@ -39,6 +39,8 @@ const KIND_OF_OP: Record = { undelete: 'changed', delete: 'deleted', 'hard-delete': 'purged', + list: 'changed', + unlist: 'deleted', }; describe('change feed fixture names', () => { diff --git a/packages/core/src/changes.ts b/packages/core/src/changes.ts index b375120..a0767f5 100644 --- a/packages/core/src/changes.ts +++ b/packages/core/src/changes.ts @@ -70,6 +70,25 @@ export function matchesFilter(emitted: EmittedChange, filter?: ChangeFilter): bo return true; } +/** + * Whether an emission passes the unlisted-enumeration boundary for one + * subscription. Unlisted records are excluded from the feed by default, + * exactly as they are from an unfiltered `query()` — with one exception: + * the `unlist` transition itself must still reach a subscriber lacking + * `includeUnlisted`, despite its post-change record (`unlistedAt` now set) + * otherwise failing this very check. Without that exception a subscriber + * who already knows the record would never be told to drop it. Every other + * transition (create-unlisted, an edit while already unlisted, a purge of + * a record that was never listed, the `list` transition itself) needs no + * special-casing: it falls out of checking the record's current state. + * See docs/spec/events.md § The unlisted transition. + */ +export function passesUnlistedBoundary(emitted: EmittedChange, includeUnlisted?: boolean): boolean { + if (includeUnlisted) return true; + if (emitted.change.op === 'unlist') return true; + return !emitted.record.unlistedAt; +} + /** * The frame a subscriber receives. A `purged` frame never carries the * record, whatever was asked for: hard delete is the erasure primitive, @@ -196,6 +215,7 @@ export class RelayDelivery { class UnscopedSubscription extends Subscription { accept(emission: EmittedChange): void { if (!matchesFilter(emission, this.opts.filter)) return; + if (!passesUnlistedBoundary(emission, this.opts.includeUnlisted)) return; this.deliver(emission); } } @@ -311,4 +331,6 @@ export const CHANGE_KINDS: Record = { undelete: 'changed', delete: 'deleted', 'hard-delete': 'purged', + list: 'changed', + unlist: 'deleted', }; diff --git a/packages/core/src/combine.ts b/packages/core/src/combine.ts index 085bf40..b42932e 100644 --- a/packages/core/src/combine.ts +++ b/packages/core/src/combine.ts @@ -32,6 +32,7 @@ export function combineAdapters(parts: { associate: (id, assoc, opts) => parts.record.associate(id, assoc, opts), dissociate: (id, assoc, opts) => parts.record.dissociate(id, assoc, opts), setPermissions: (id, permissions, opts) => parts.record.setPermissions(id, permissions, opts), + setUnlisted: (id, unlisted, opts) => parts.record.setUnlisted(id, unlisted, opts), getVersions: (id) => parts.record.getVersions(id), getVersion: (id, v) => parts.record.getVersion(id, v), diff --git a/packages/core/src/stack.ts b/packages/core/src/stack.ts index 03da8a4..d4fb622 100644 --- a/packages/core/src/stack.ts +++ b/packages/core/src/stack.ts @@ -35,6 +35,7 @@ import { Subscription, buildEmission, matchesFilter, + passesUnlistedBoundary, } from './changes.js'; import type { EmittedChange } from './changes.js'; import { SYSTEM_TYPES, GRANT_ACTIONS } from './types.js'; @@ -296,6 +297,13 @@ export type CreateRecordOptions = { principalId?: EntityId; permissions?: Permission[]; associations?: Association[]; + /** + * Create the record already unlisted, so the create event itself is + * withheld from the feed — there is no window where the record exists + * and is listed before setUnlisted() catches up. See + * docs/spec/access-control.md § Unlisted records. + */ + unlisted?: boolean; }; export type ScopedStackOptions = { @@ -750,6 +758,7 @@ export interface StackClient { associate(id: string, association: Association, opts?: IfVersionOptions): Promise; dissociate(id: string, association: Association, opts?: IfVersionOptions): Promise; setPermissions(id: string, permissions: Permission[], opts?: IfVersionOptions): Promise; + setUnlisted(id: string, unlisted: boolean, opts?: IfVersionOptions): Promise; delete(id: string, opts?: DeleteRecordOptions): Promise; undelete(id: string, opts?: IfVersionOptions): Promise; getVersions(id: string): Promise; @@ -948,7 +957,7 @@ export class Stack implements StackClient { const entityTypeId = `${SYSTEM_TYPES.ENTITY}@1`; const existing = await findFirstMatch( (q) => this.query(q), - { filter: { baseId: SYSTEM_TYPES.ENTITY, includeDeleted: true } }, + { filter: { baseId: SYSTEM_TYPES.ENTITY, includeDeleted: true, includeUnlisted: true } }, (r) => (r.content as EntityContent).did === this.ownerEntityId, ); if (existing) return; @@ -1195,7 +1204,7 @@ export class Stack implements StackClient { let cursor: string | undefined; do { const result: QueryResult = await this.adapter.queryRecords({ - filter: { typeId, includeDeleted: true }, + filter: { typeId, includeDeleted: true, includeUnlisted: true }, limit: 100, cursor, }); @@ -1280,6 +1289,7 @@ export class Stack implements StackClient { ...(opts.principalId && { updatedVia: opts.principalId }), ...(opts.permissions?.length && { permissions: opts.permissions }), ...(associations?.length && { associations }), + ...(opts.unlisted && { unlistedAt: now }), }; const created = await this.adapter.createRecord(record); @@ -1496,6 +1506,40 @@ export class Stack implements StackClient { this.emitChange('permissions', updated); } + /** + * Withhold a record from enumeration, or restore it. Orthogonal to + * setPermissions(): it says nothing about who may read the record, only + * whether `query()` and the change feed enumerate it by default. No-op if + * already in the requested state. See docs/spec/access-control.md § + * Unlisted records. + * + * The op passed to emitChange() carries the transition direction — + * `unlist` (kind `deleted`, so subscribers already holding the record are + * told to drop it) or `list` (kind `changed`, an upsert like `undelete`, + * for the record's publish moment). + */ + async setUnlisted( + id: string, + unlisted: boolean, + opts: IfVersionOptions & ActorOptions = {}, + ): Promise { + this.assertOpen(); + const existing = await this.adapter.getRecord(id); + if (!existing) { + throw new StackNotFoundError(`Record not found: "${id}"`); + } + this.checkIfVersion(existing, opts.ifVersion); + if (Boolean(existing.unlistedAt) === unlisted) return; + + const updated = await this.adapter.setUnlisted(id, unlisted, { + expectedVersion: opts.ifVersion, + snapshot: this.buildVersionSnapshot(existing), + updatedBy: opts.updatedBy, + updatedVia: opts.updatedVia, + }); + this.emitChange(unlisted ? 'unlist' : 'list', updated); + } + /** * Soft-delete a record (default) or hard-delete it permanently, removing * the record and its version history. Soft delete snapshots and bumps @@ -1921,6 +1965,7 @@ export class Stack implements StackClient { filter: { baseId: family, includeDeleted: true, + includeUnlisted: true, ...(this.features.contentFieldQuery && { content: { [field]: value } }), }, }, @@ -1985,6 +2030,7 @@ export class Stack implements StackClient { filter: { typeId: metadataTypeId, includeDeleted: true, + includeUnlisted: true, ...(this.features.contentFieldQuery && { content: { fileId } }), }, }); @@ -2180,23 +2226,25 @@ export class Stack implements StackClient { metadataTypeId: string, opts: ActorOptions = {}, ): Promise { - // A soft-deleted record still counts as a reference — it must find its - // attachments intact on undelete. See docs/spec/attachments.md - // § Deleting attachments. + // A soft-deleted or unlisted record still counts as a reference — it + // must find its attachments intact on undelete or relisting. See + // docs/spec/attachments.md § Deleting attachments. const refResult = await this.query({ - filter: { attachmentFileId: fileId, includeDeleted: true }, + filter: { attachmentFileId: fileId, includeDeleted: true, includeUnlisted: true }, limit: 1, }); if (refResult.records.length > 0) { throw new StackConflictError('Attachment is still referenced by one or more records'); } - // Cursor-walk with includeDeleted: metadata past page one, or soft- - // deleted, must be cleaned up too — not left pointing at deleted bytes. + // Cursor-walk with includeDeleted/includeUnlisted: metadata past page + // one, soft-deleted, or unlisted must be cleaned up too — not left + // pointing at deleted bytes. const metaResults = await queryAllPages((q) => this.query(q), { filter: { typeId: metadataTypeId, includeDeleted: true, + includeUnlisted: true, ...(this.features.contentFieldQuery && { content: { fileId } }), }, }); @@ -2227,7 +2275,7 @@ export class Stack implements StackClient { const metadataTypeId = `${SYSTEM_TYPES.ATTACHMENT}@1`; const metaRecords = await queryAllPages((q) => this.query(q), { - filter: { typeId: metadataTypeId, includeDeleted: true }, + filter: { typeId: metadataTypeId, includeDeleted: true, includeUnlisted: true }, }); // Newest metadata record's createdAt per fileId, and its size (constant @@ -2259,7 +2307,7 @@ export class Stack implements StackClient { for (const fileId of candidateFileIds) { const refResult = await this.query({ - filter: { attachmentFileId: fileId, includeDeleted: true }, + filter: { attachmentFileId: fileId, includeDeleted: true, includeUnlisted: true }, limit: 1, }); if (refResult.records.length > 0) continue; @@ -2354,6 +2402,7 @@ export class Stack implements StackClient { { ...(opts.filter !== undefined && { filter: opts.filter }), ...(opts.includeRecords !== undefined && { includeRecords: opts.includeRecords }), + ...(opts.includeUnlisted !== undefined && { includeUnlisted: opts.includeUnlisted }), ...(opts.onError !== undefined && { onError: opts.onError }), ...(opts.onReset !== undefined && { onReset: opts.onReset }), }, @@ -2820,6 +2869,7 @@ class ScopedSubscription extends Subscription { private async filterAndDeliver(emission: EmittedChange): Promise { if (this.isClosed) return; + if (!passesUnlistedBoundary(emission, this.opts.includeUnlisted)) return; try { if (!(await this.canRead(emission.record, this.cache))) return; } catch (err) { @@ -3309,6 +3359,11 @@ export class ScopedStack implements StackClient { 'A delegated principal cannot set permissions, at create time or after', ); } + if (opts.unlisted && !this.mayGrantAccess()) { + throw new StackPermissionError( + 'A delegated principal cannot create an unlisted record, at create time or after', + ); + } this.requireOwnerForOwnerDid(typeId, (content as Record).did); await this.requireAppIdMatchesPrincipal(opts.appId); if (opts.id !== undefined) { @@ -3375,6 +3430,9 @@ export class ScopedStack implements StackClient { */ async query(query: StackQuery = {}): Promise { assertValidSort(query.sort); + if (query.filter?.includeUnlisted && !this.ownerActingAlone) { + throw new StackPermissionError('includeUnlisted is owner-only'); + } const limit = Math.min(query.limit ?? DEFAULT_QUERY_LIMIT, MAX_QUERY_LIMIT); const records: StackRecord[] = []; const maxFetched = limit * 10; @@ -3475,6 +3533,7 @@ export class ScopedStack implements StackClient { filter: { baseId: SYSTEM_TYPES.APP, includeDeleted: true, + includeUnlisted: true, ...(this.stack.features.contentFieldQuery && { content: { did: this.principalEntityId }, }), @@ -3567,6 +3626,29 @@ export class ScopedStack implements StackClient { return this.stack.setPermissions(id, permissions, { ...opts, ...this.actor }); } + /** + * Withhold a record from enumeration, or restore it — gated exactly like + * setPermissions(), since both decide who or what can discover the + * record rather than merely read it once found. See + * docs/spec/access-control.md § Unlisted records. + */ + async setUnlisted(id: string, unlisted: boolean, opts: IfVersionOptions = {}): Promise { + const record = await this.stack.get(id); + if (!record) throw new StackNotFoundError(`Record not found: "${id}"`); + await this.requireOwnerForGrantRecord(record); + + if (baseIdOf(record.typeId) === SYSTEM_TYPES.GROUP) { + if (!this.isGroupManager(record)) throw await this.denialFor(record); + } else { + if (!this.mayReshare(this.principalEntityId, record)) throw await this.denialFor(record); + if (this.delegated && !this.mayReshare(this.subjectEntityId, record)) { + throw await this.denialFor(record); + } + } + + return this.stack.setUnlisted(id, unlisted, { ...opts, ...this.actor }); + } + /** * Hard delete is owner-only: it is irreversible and destroys version * history, so neither the write bit nor delete-own/delete-any grants @@ -3785,6 +3867,9 @@ export class ScopedStack implements StackClient { 'no record to re-check. Subscribe with a session-scoped stack instead.', ); } + if (opts.includeUnlisted && !this.ownerActingAlone) { + throw new StackPermissionError('includeUnlisted is owner-only'); + } return this.changes.add( new ScopedSubscription((record, cache) => this.canReadCached(record, cache), handler, opts), ); diff --git a/packages/core/src/testing.ts b/packages/core/src/testing.ts index 3b734a2..6248deb 100644 --- a/packages/core/src/testing.ts +++ b/packages/core/src/testing.ts @@ -173,6 +173,7 @@ export class MemoryAdapter implements StackAdapter { // generic query — mirroring the real SQL adapters' WHERE exclusion. results = results.filter((r) => r.id !== SYSTEM_TYPES.CONFIG); if (!f.includeDeleted) results = results.filter((r) => !r.deletedAt); + if (!f.includeUnlisted) results = results.filter((r) => !r.unlistedAt); if (f.typeId) { const ids = Array.isArray(f.typeId) ? f.typeId : [f.typeId]; results = results.filter((r) => ids.includes(r.typeId)); @@ -295,6 +296,24 @@ export class MemoryAdapter implements StackAdapter { return updated; } + async setUnlisted( + id: string, + unlisted: boolean, + opts: { expectedVersion?: number; snapshot?: RecordVersion } & ActorOptions = {}, + ) { + const record = this.records.get(id); + if (!record) throw new Error(`Not found: ${id}`); + this.checkExpectedVersion(record, opts.expectedVersion); + if (opts.snapshot) this.snapshotBeforeMutation(id, opts.snapshot); + const { unlistedAt: _unlistedAt, ...rest } = record; + const updated = this.bump( + unlisted ? { ...rest, unlistedAt: new Date() } : (rest as StackRecord), + opts, + ); + this.records.set(id, updated); + return updated; + } + async getVersions(id: string) { return this.versions.get(id) ?? []; } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index d688c7c..836fbec 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -122,6 +122,15 @@ export type StackRecord = { */ updatedVia?: EntityId; deletedAt?: Date; // Present if soft-deleted + /** + * Present when the record is withheld from enumeration — absent from + * `query()` and the change feed by default, but still reachable by + * `get()` for anyone who may read it. Orthogonal to `permissions`: it + * says nothing about who may read the record, only whether its + * existence is discoverable without already holding its ID. See + * docs/spec/access-control.md § Unlisted records. + */ + unlistedAt?: Date; permissions?: Permission[]; associations?: Association[]; }; @@ -393,6 +402,14 @@ export type RecordFilter = { // Soft-deleted records are excluded by default includeDeleted?: boolean; + + /** + * Unlisted records are excluded by default, like soft-deleted ones. + * Owner-only under `ScopedStack` — enumeration standing rests on nothing + * but ownership, so a grant or delegation never carries it. See + * docs/spec/access-control.md § Unlisted records. + */ + includeUnlisted?: boolean; }; export type QuerySort = { @@ -540,7 +557,16 @@ export type ChangeOp = | 'restore' | 'delete' | 'undelete' - | 'hard-delete'; + | 'hard-delete' + /** + * Emitted even though the record's post-change state (`unlistedAt` now + * set) would otherwise be excluded by the same filter it announces — + * subscribers who already know the record need telling to drop it. See + * docs/spec/events.md § The unlisted transition. + */ + | 'unlist' + /** The publish moment — mechanically an upsert, like `undelete`. */ + | 'list'; /** * Who performed a change — never who authored the record. Absent from a @@ -608,6 +634,14 @@ export type SubscribeOptions = { filter?: ChangeFilter; /** Ask the emitter to include `record`. Honored when it can; never assume it. */ includeRecords?: boolean; + /** + * Receive events for unlisted records too. Owner-only under + * `ScopedStack`, same authority as `RecordFilter.includeUnlisted` — the + * feed excludes unlisted records by default so it never delivers more + * than an equivalent `query()` would return. See + * docs/spec/access-control.md § Unlisted records. + */ + includeUnlisted?: boolean; /** * Where a throwing handler's error goes. Without one the error is * rethrown asynchronously rather than swallowed; either way it never @@ -634,6 +668,8 @@ export type SubscribeChangesOptions = { /** Resume from this cursor. A relay with none starts from the present. */ since?: string; includeRecords?: boolean; + /** See SubscribeOptions.includeUnlisted. */ + includeUnlisted?: boolean; onError?: (err: unknown) => void; onReset?: () => void; }; @@ -711,6 +747,18 @@ export interface StackRecordAdapter { opts?: ExpectedVersionOptions & SnapshotOptions & ActorOptions, ): Promise; + /** + * Set or clear `unlistedAt`. Bumps version internally, like + * setPermissions(). The caller (Stack.setUnlisted()) is responsible for + * picking the `unlist`/`list` change op from the transition, since the + * adapter has no opinion on eventing. + */ + setUnlisted( + id: RecordId, + unlisted: boolean, + opts?: ExpectedVersionOptions & SnapshotOptions & ActorOptions, + ): Promise; + // Versions getVersions(id: RecordId): Promise; getVersion(id: RecordId, version: number): Promise; diff --git a/packages/core/tests/change-events.test.ts b/packages/core/tests/change-events.test.ts index 1a79bfc..3479b93 100644 --- a/packages/core/tests/change-events.test.ts +++ b/packages/core/tests/change-events.test.ts @@ -229,6 +229,62 @@ describe('every record emits, including the ones a query hides', () => { }); }); +// ------------------------------------------------------- +// Unlisted records — the one exception to "every record emits", because +// unfiltered query() excludes them too. See docs/spec/events.md +// § The unlisted transition. +// ------------------------------------------------------- + +describe('an unlisted record is invisible to a default subscriber, even unscoped', () => { + test('created unlisted produces no event by default', async () => { + const { seen, handler } = collector(); + await stack.subscribe(handler, { filter: { typeId: NOTE } }); + + await stack.create(NOTE, { text: 'draft' }, { unlisted: true }); + + expect(seen).toEqual([]); + }); + + test('includeUnlisted: true — even on an unscoped Stack — opts back in', async () => { + const { seen, handler } = collector(); + await stack.subscribe(handler, { filter: { typeId: NOTE }, includeUnlisted: true }); + + await stack.create(NOTE, { text: 'draft' }, { unlisted: true }); + + expect(seen.map((c) => c.op)).toEqual(['create']); + }); + + test('the unlist transition emits kind "deleted" despite the post-change state', async () => { + const note = await stack.create(NOTE, { text: 'was listed' }); + const { seen, handler } = collector(); + await stack.subscribe(handler, { filter: { typeId: NOTE } }); + + await stack.setUnlisted(note.id, true); + + expect(seen.map((c) => [c.kind, c.op])).toEqual([['deleted', 'unlist']]); + }); + + test('the list transition emits kind "changed", an upsert like undelete', async () => { + const note = await stack.create(NOTE, { text: 'draft' }, { unlisted: true }); + const { seen, handler } = collector(); + await stack.subscribe(handler, { filter: { typeId: NOTE } }); + + await stack.setUnlisted(note.id, false); + + expect(seen.map((c) => [c.kind, c.op])).toEqual([['changed', 'list']]); + }); + + test('a hard delete of a still-unlisted record is not announced either', async () => { + const note = await stack.create(NOTE, { text: 'draft' }, { unlisted: true }); + const { seen, handler } = collector(); + await stack.subscribe(handler, { filter: { typeId: NOTE } }); + + await stack.delete(note.id, { hard: true }); + + expect(seen).toEqual([]); + }); +}); + // ------------------------------------------------------- // Attribution // ------------------------------------------------------- diff --git a/packages/core/tests/combine.test.ts b/packages/core/tests/combine.test.ts index 26dd0dd..90c59aa 100644 --- a/packages/core/tests/combine.test.ts +++ b/packages/core/tests/combine.test.ts @@ -56,6 +56,9 @@ function makeRecordAdapter(overrides: Partial = {}): StackRe setPermissions: async () => { throw new Error('not implemented'); }, + setUnlisted: async () => { + throw new Error('not implemented'); + }, getVersions: async () => [], getVersion: async () => null, saveVersion: async () => {}, @@ -128,6 +131,22 @@ describe('combineAdapters', () => { expect(fileId).toBe('computed-id'); }); + test('forwards setUnlisted to the record adapter', async () => { + let calledWith: [string, boolean] | undefined; + const adapter = combineAdapters({ + record: makeRecordAdapter({ + setUnlisted: async (id, unlisted) => { + calledWith = [id, unlisted]; + return { ...purgedRecord, unlistedAt: unlisted ? new Date() : undefined }; + }, + }), + blob: makeBlobAdapter(), + }); + + await adapter.setUnlisted('r1', true); + expect(calledWith).toEqual(['r1', true]); + }); + // putAttachmentWithMetadata promises bytes + record as one atomic // operation — something a record backend glued to a blob backend can // never honor, so combineAdapters() must not synthesize it. Its absence diff --git a/packages/core/tests/scoped-stack.test.ts b/packages/core/tests/scoped-stack.test.ts index c835beb..c7754f3 100644 --- a/packages/core/tests/scoped-stack.test.ts +++ b/packages/core/tests/scoped-stack.test.ts @@ -275,6 +275,42 @@ describe('ScopedStack — write access', () => { }); }); +// ------------------------------------------------------- +// setUnlisted — gated exactly like setPermissions, since both decide who +// or what can discover a record rather than merely read one already found. +// See docs/spec/access-control.md § Unlisted records. +// ------------------------------------------------------- + +describe('ScopedStack.setUnlisted', () => { + test('rejects write-access holder that is not creator or stack owner', async () => { + const record = await adapter.createRecord( + makeRecord({ + entityId: OWNER, + permissions: [{ access: 'entity', entityId: MEMBER, read: true, write: true }], + }), + ); + await expect(stack.asEntity(MEMBER).setUnlisted(record.id, true)).rejects.toThrow( + StackPermissionError, + ); + // Stack owner and record creator can still toggle it. + await stack.asEntity(OWNER).setUnlisted(record.id, true); + expect((await adapter.getRecord(record.id))?.unlistedAt).toBeInstanceOf(Date); + }); + + test('a stranger with no access gets StackNotFoundError', async () => { + const record = await adapter.createRecord(makeRecord()); + await expect(stack.asEntity(STRANGER).setUnlisted(record.id, true)).rejects.toThrow( + StackNotFoundError, + ); + }); + + test('the record creator (not stack owner) may toggle it', async () => { + const record = await adapter.createRecord(makeRecord({ entityId: MEMBER })); + await stack.asEntity(MEMBER).setUnlisted(record.id, true); + expect((await adapter.getRecord(record.id))?.unlistedAt).toBeInstanceOf(Date); + }); +}); + // ------------------------------------------------------- // Record-existence disclosure // ------------------------------------------------------- @@ -658,6 +694,46 @@ describe('ScopedStack.query', () => { expect(result.records.length).toBeGreaterThanOrEqual(0); expect(result.records.length).toBeLessThanOrEqual(1000); }); + + // includeUnlisted is owner-only: enumeration standing rests on nothing + // but ownership, so no grant or delegation carries it. See + // docs/spec/access-control.md § includeUnlisted is owner-only. + describe('includeUnlisted', () => { + test('refuses a non-owner requester with StackPermissionError', async () => { + await expect( + stack.asEntity(MEMBER).query({ filter: { includeUnlisted: true } }), + ).rejects.toThrow(StackPermissionError); + }); + + test('refuses an anonymous requester', async () => { + await expect( + stack.asEntity(null).query({ filter: { includeUnlisted: true } }), + ).rejects.toThrow(StackPermissionError); + }); + + test('the owner acting alone may pass it', async () => { + await adapter.createRecord( + makeRecord({ unlistedAt: new Date(), permissions: [{ access: 'public' }] }), + ); + const result = await stack.asEntity(OWNER).query({ filter: { includeUnlisted: true } }); + expect(result.records).toHaveLength(1); + }); + + test('an owner-delegated app does not inherit it', async () => { + const scoped = stack.asEntity('app-did', { onBehalfOf: OWNER }); + await expect(scoped.query({ filter: { includeUnlisted: true } })).rejects.toThrow( + StackPermissionError, + ); + }); + + test('unlisted records are excluded from a scoped query by default', async () => { + await adapter.createRecord( + makeRecord({ unlistedAt: new Date(), permissions: [{ access: 'public' }] }), + ); + const result = await stack.asEntity(null).query(); + expect(result.records).toHaveLength(0); + }); + }); }); // ------------------------------------------------------- @@ -2039,6 +2115,16 @@ describe('ScopedStack — group role gating', () => { expect((await adapter.getRecord(group.id))?.permissions).toEqual(perms); }); + test('setUnlisted on a group requires admin, not just record authorship', async () => { + const group = await makeGroup({ entityId: MEMBER }); + // MEMBER authored the record but isn't an admin — generic creator carve-out doesn't apply. + await expect(stack.asEntity(MEMBER).setUnlisted(group.id, true)).rejects.toThrow( + StackNotFoundError, + ); + await stack.asEntity(ADMIN).setUnlisted(group.id, true); + expect((await adapter.getRecord(group.id))?.unlistedAt).toBeInstanceOf(Date); + }); + test('creator is stamped as admin at create time and can manage the group afterward', async () => { await stack.grant(MEMBER, [{ actions: ['create'], typeId: '_group@1' }]); const group = await stack.asEntity(MEMBER).create('_group@1', { name: 'New Group' }); diff --git a/packages/core/tests/scoped-subscribe.test.ts b/packages/core/tests/scoped-subscribe.test.ts index 6079167..e633c62 100644 --- a/packages/core/tests/scoped-subscribe.test.ts +++ b/packages/core/tests/scoped-subscribe.test.ts @@ -273,6 +273,76 @@ describe('a revocation takes effect on the next event, not the next subscription }); }); +// ------------------------------------------------------- +// Unlisted records — the feed matches query()'s default exclusion, with +// the `unlist` transition itself as the one exception. See +// docs/spec/events.md § The unlisted transition. +// ------------------------------------------------------- + +describe('the feed excludes unlisted records like an equivalent query() would', () => { + test('includeUnlisted is refused to a non-owner at subscribe() time', async () => { + await expect( + stack.asEntity(READER).subscribe(() => {}, { includeUnlisted: true }), + ).rejects.toThrow('includeUnlisted is owner-only'); + }); + + test('a record created unlisted produces no event for a default subscriber', async () => { + const owner = collector(); + await stack.asEntity(OWNER).subscribe(owner.handler, { filter: { typeId: NOTE } }); + + await stack.create(NOTE, { text: 'draft' }, { unlisted: true }); + await settle(); + + expect(owner.seen).toEqual([]); + }); + + test('an edit to an already-unlisted record produces no event for a default subscriber', async () => { + const note = await stack.create(NOTE, { text: 'draft' }, { unlisted: true }); + const owner = collector(); + await stack.asEntity(OWNER).subscribe(owner.handler, { filter: { typeId: NOTE } }); + + await stack.update(note.id, { text: 'still unlisted' }); + await settle(); + + expect(owner.seen).toEqual([]); + }); + + test('the unlist transition itself reaches a default subscriber, as kind "deleted"', async () => { + const note = await stack.create(NOTE, { text: 'was public' }); + const owner = collector(); + await stack.asEntity(OWNER).subscribe(owner.handler, { filter: { typeId: NOTE } }); + + await stack.setUnlisted(note.id, true); + await settle(); + + expect(owner.seen.map((c) => [c.kind, c.op])).toEqual([['deleted', 'unlist']]); + }); + + test('the list transition reaches a default subscriber, as an ordinary upsert', async () => { + const note = await stack.create(NOTE, { text: 'draft' }, { unlisted: true }); + const owner = collector(); + await stack.asEntity(OWNER).subscribe(owner.handler, { filter: { typeId: NOTE } }); + + await stack.setUnlisted(note.id, false); + await settle(); + + expect(owner.seen.map((c) => [c.kind, c.op])).toEqual([['changed', 'list']]); + }); + + test('the owner acting alone with includeUnlisted sees the create and the silent edit', async () => { + const owner = collector(); + await stack + .asEntity(OWNER) + .subscribe(owner.handler, { filter: { typeId: NOTE }, includeUnlisted: true }); + + const note = await stack.create(NOTE, { text: 'draft' }, { unlisted: true }); + await stack.update(note.id, { text: 'still unlisted' }); + await settle(); + + expect(owner.seen.map((c) => c.op)).toEqual(['create', 'update']); + }); +}); + // ------------------------------------------------------- // Ordering and failure // ------------------------------------------------------- diff --git a/packages/core/tests/stack.test.ts b/packages/core/tests/stack.test.ts index ba463bd..f01b682 100644 --- a/packages/core/tests/stack.test.ts +++ b/packages/core/tests/stack.test.ts @@ -2676,6 +2676,79 @@ describe('setPermissions', () => { }); }); +// ------------------------------------------------------- +// setUnlisted +// ------------------------------------------------------- + +describe('setUnlisted', () => { + test('bumps version and sets unlistedAt', async () => { + const record = await stack.create(NOTE_V1, { text: 'hello' }); + await stack.setUnlisted(record.id, true); + const updated = await adapter.getRecord(record.id); + expect(updated?.version).toBe(2); + expect(updated?.unlistedAt).toBeInstanceOf(Date); + }); + + test('clears unlistedAt on the reverse call', async () => { + const record = await stack.create(NOTE_V1, { text: 'hello' }); + await stack.setUnlisted(record.id, true); + await stack.setUnlisted(record.id, false); + const updated = await adapter.getRecord(record.id); + expect(updated?.version).toBe(3); + expect(updated?.unlistedAt).toBeUndefined(); + }); + + test('is a no-op when already in the requested state — no version bump', async () => { + const record = await stack.create(NOTE_V1, { text: 'hello' }); + await stack.setUnlisted(record.id, false); + expect((await adapter.getRecord(record.id))?.version).toBe(1); + + await stack.setUnlisted(record.id, true); + await stack.setUnlisted(record.id, true); + expect((await adapter.getRecord(record.id))?.version).toBe(2); + }); + + test('does not touch permissions', async () => { + const record = await stack.create( + NOTE_V1, + { text: 'hello' }, + { permissions: [{ access: 'public' }] }, + ); + await stack.setUnlisted(record.id, true); + const updated = await adapter.getRecord(record.id); + expect(updated?.permissions).toEqual([{ access: 'public' }]); + }); + + test('throws StackNotFoundError for a missing record', async () => { + await expect(stack.setUnlisted('nonexistent', true)).rejects.toThrow(StackNotFoundError); + }); + + test('create({ unlisted: true }) stamps unlistedAt from the start', async () => { + const record = await stack.create(NOTE_V1, { text: 'hello' }, { unlisted: true }); + expect(record.unlistedAt).toBeInstanceOf(Date); + expect((await adapter.getRecord(record.id))?.unlistedAt).toBeInstanceOf(Date); + }); + + test('an unfiltered query() excludes unlisted records by default', async () => { + const listed = await stack.create(NOTE_V1, { text: 'listed' }); + await stack.create(NOTE_V1, { text: 'unlisted' }, { unlisted: true }); + const result = await stack.query({ filter: { typeId: NOTE_V1 } }); + expect(result.records.map((r) => r.id)).toEqual([listed.id]); + }); + + test('includeUnlisted: true on a plain Stack returns both', async () => { + await stack.create(NOTE_V1, { text: 'listed' }); + await stack.create(NOTE_V1, { text: 'unlisted' }, { unlisted: true }); + const result = await stack.query({ filter: { typeId: NOTE_V1, includeUnlisted: true } }); + expect(result.records).toHaveLength(2); + }); + + test('get() still resolves an unlisted record', async () => { + const record = await stack.create(NOTE_V1, { text: 'hello' }, { unlisted: true }); + expect(await stack.get(record.id)).not.toBeNull(); + }); +}); + // ------------------------------------------------------- // putAttachment // ------------------------------------------------------- diff --git a/packages/record-adapter-sqlite/src/index.ts b/packages/record-adapter-sqlite/src/index.ts index 08f7ae4..7345d4e 100644 --- a/packages/record-adapter-sqlite/src/index.ts +++ b/packages/record-adapter-sqlite/src/index.ts @@ -194,6 +194,14 @@ export class NativeSQLiteRecordAdapter implements StackRecordAdapter { return this.record.setPermissions(id, permissions, opts); } + setUnlisted( + id: string, + unlisted: boolean, + opts?: { expectedVersion?: number; snapshot?: RecordVersion } & ActorOptions, + ): Promise { + return this.record.setUnlisted(id, unlisted, opts); + } + restoreVersion( id: string, version: number, diff --git a/packages/record-adapter-sqlite/tests/record.test.ts b/packages/record-adapter-sqlite/tests/record.test.ts index 7b1da55..8a8b739 100644 --- a/packages/record-adapter-sqlite/tests/record.test.ts +++ b/packages/record-adapter-sqlite/tests/record.test.ts @@ -561,6 +561,19 @@ describe('records — queries', () => { expect(result.records.some((r) => r.id === record.id)).toBe(true); }); + test('excludes unlisted records by default, and includeUnlisted returns them', async () => { + const adapter = await initAdapter(); + const record = makeRecord(); + await adapter.createRecord(record); + await adapter.setUnlisted(record.id, true); + + const excluded = await adapter.queryRecords({}); + expect(excluded.records.some((r) => r.id === record.id)).toBe(false); + + const included = await adapter.queryRecords({ filter: { includeUnlisted: true } }); + expect(included.records.some((r) => r.id === record.id)).toBe(true); + }); + // The order clause and the cursor comparison interpolate sort.direction // straight into SQL. Core's assertValidSort() is the primary guard, but // the builder re-checks so a caller reaching the adapter directly cannot @@ -945,6 +958,39 @@ describe('setPermissions', () => { }); }); +describe('setUnlisted', () => { + test('sets unlistedAt and bumps version', async () => { + const adapter = await initAdapter(); + const record = makeRecord(); + await adapter.createRecord(record); + await adapter.setUnlisted(record.id, true); + const retrieved = await adapter.getRecord(record.id); + expect(retrieved?.unlistedAt).toBeInstanceOf(Date); + expect(retrieved?.version).toBe(2); + }); + + test('clears unlistedAt on the reverse call', async () => { + const adapter = await initAdapter(); + const record = makeRecord(); + await adapter.createRecord(record); + await adapter.setUnlisted(record.id, true); + await adapter.setUnlisted(record.id, false); + const retrieved = await adapter.getRecord(record.id); + expect(retrieved?.unlistedAt).toBeUndefined(); + expect(retrieved?.version).toBe(3); + }); + + test('enforces expectedVersion', async () => { + const adapter = await initAdapter(); + const record = makeRecord(); + await adapter.createRecord(record); + await expect( + adapter.setUnlisted(record.id, true, { expectedVersion: 99 }), + ).rejects.toBeInstanceOf(StackVersionConflictError); + await adapter.setUnlisted(record.id, true, { expectedVersion: 1 }); + }); +}); + // ------------------------------------------------------- // Versions // ------------------------------------------------------- diff --git a/packages/sqlite-shared/src/mappers.ts b/packages/sqlite-shared/src/mappers.ts index 8cf644d..d15e9a8 100644 --- a/packages/sqlite-shared/src/mappers.ts +++ b/packages/sqlite-shared/src/mappers.ts @@ -28,6 +28,7 @@ export const rowToRecord = ( if (row.updated_by) record.updatedBy = row.updated_by as string; if (row.updated_via) record.updatedVia = row.updated_via as string; if (row.deleted_at) record.deletedAt = fromMs(row.deleted_at as number); + if (row.unlisted_at) record.unlistedAt = fromMs(row.unlisted_at as number); if (row.permissions) record.permissions = JSON.parse(row.permissions as string); if (associations.length) record.associations = associations; return record; diff --git a/packages/sqlite-shared/src/query.ts b/packages/sqlite-shared/src/query.ts index bb930ea..633fa0b 100644 --- a/packages/sqlite-shared/src/query.ts +++ b/packages/sqlite-shared/src/query.ts @@ -38,6 +38,10 @@ export const buildWhereClause = (query: StackQuery): { sql: string; params: unkn conditions.push('r.deleted_at IS NULL'); } + if (!f.includeUnlisted) { + conditions.push('r.unlisted_at IS NULL'); + } + if (f.typeId !== undefined) { const ids = Array.isArray(f.typeId) ? f.typeId : [f.typeId]; conditions.push(`r.type_id IN (${ids.map(() => '?').join(',')})`); diff --git a/packages/sqlite-shared/src/record-logic.ts b/packages/sqlite-shared/src/record-logic.ts index b0b7851..23cccfd 100644 --- a/packages/sqlite-shared/src/record-logic.ts +++ b/packages/sqlite-shared/src/record-logic.ts @@ -117,8 +117,8 @@ export class SharedSqlRecordLogic { `INSERT INTO records (id, type_id, created_at, updated_at, content, version, parent_id, entity_id, app_id, principal_id, updated_by, updated_via, - deleted_at, permissions) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + deleted_at, unlisted_at, permissions) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ record.id, record.typeId, @@ -133,6 +133,7 @@ export class SharedSqlRecordLogic { record.updatedBy ?? null, record.updatedVia ?? null, record.deletedAt ? toMs(record.deletedAt) : null, + record.unlistedAt ? toMs(record.unlistedAt) : null, record.permissions ? JSON.stringify(record.permissions) : null, ], ); @@ -347,6 +348,39 @@ export class SharedSqlRecordLogic { return updated; } + async setUnlisted( + id: string, + unlisted: boolean, + opts: { expectedVersion?: number; snapshot?: RecordVersion } & ActorOptions = {}, + ): Promise { + this.exec.exec('BEGIN'); + try { + if (opts.snapshot) this.snapshotBeforeMutation(id, opts.snapshot); + const { clause, params: verParams } = this.versionGuard(opts.expectedVersion); + const now = toMs(new Date()); + const changed = this.exec.run( + `UPDATE records SET unlisted_at = ?, version = version + 1, updated_at = ?, updated_by = ?, updated_via = ? WHERE id = ?${clause}`, + [ + unlisted ? now : null, + now, + opts.updatedBy ?? null, + opts.updatedVia ?? null, + id, + ...verParams, + ], + ); + if (changed === 0) this.throwVersionConflict(id, opts.expectedVersion); + this.exec.exec('COMMIT'); + } catch (err) { + this.exec.exec('ROLLBACK'); + throw err; + } + + const updated = await this.getRecord(id); + if (!updated) throw new Error(`Record not found after setUnlisted: "${id}"`); + return updated; + } + async restoreVersion( id: string, version: number, diff --git a/packages/sqlite-shared/src/schema.ts b/packages/sqlite-shared/src/schema.ts index 95dad43..43d53c6 100644 --- a/packages/sqlite-shared/src/schema.ts +++ b/packages/sqlite-shared/src/schema.ts @@ -21,6 +21,7 @@ export const RECORD_SCHEMA_SQL = ` updated_by TEXT, updated_via TEXT, deleted_at INTEGER, + unlisted_at INTEGER, permissions TEXT CHECK (permissions IS NULL OR json_valid(permissions)) ) STRICT; @@ -76,6 +77,7 @@ export const RECORD_SCHEMA_SQL = ` CREATE INDEX IF NOT EXISTS idx_records_app_id ON records(app_id); CREATE INDEX IF NOT EXISTS idx_records_principal_id ON records(principal_id); CREATE INDEX IF NOT EXISTS idx_records_deleted_at ON records(deleted_at); + CREATE INDEX IF NOT EXISTS idx_records_unlisted_at ON records(unlisted_at); CREATE INDEX IF NOT EXISTS idx_records_created_at ON records(created_at); CREATE INDEX IF NOT EXISTS idx_records_updated_at ON records(updated_at); CREATE INDEX IF NOT EXISTS idx_assoc_record_id ON associations(record_id); diff --git a/packages/wire-types/src/index.ts b/packages/wire-types/src/index.ts index 3a1e904..b0e34aa 100644 --- a/packages/wire-types/src/index.ts +++ b/packages/wire-types/src/index.ts @@ -40,6 +40,7 @@ export type WireRecord = { updatedBy?: string; updatedVia?: string; deletedAt?: string; + unlistedAt?: string; permissions?: Permission[]; associations?: Association[]; }; @@ -102,6 +103,7 @@ export function serializeRecord(r: StackRecord): WireRecord { if (r.updatedBy !== undefined) w.updatedBy = r.updatedBy; if (r.updatedVia !== undefined) w.updatedVia = r.updatedVia; if (r.deletedAt !== undefined) w.deletedAt = r.deletedAt.toISOString(); + if (r.unlistedAt !== undefined) w.unlistedAt = r.unlistedAt.toISOString(); if (r.permissions !== undefined) w.permissions = r.permissions; if (r.associations !== undefined) w.associations = r.associations; return w;