@haverstack/server 0.8.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. package/dist/app.d.ts +8 -0
  2. package/dist/app.d.ts.map +1 -0
  3. package/dist/app.js +70 -0
  4. package/dist/app.js.map +1 -0
  5. package/dist/config.d.ts +21 -0
  6. package/dist/config.d.ts.map +1 -0
  7. package/dist/config.js +115 -0
  8. package/dist/config.js.map +1 -0
  9. package/dist/index.d.ts +16 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +12 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/lib/changeStreams.d.ts +17 -0
  14. package/dist/lib/changeStreams.d.ts.map +1 -0
  15. package/{src/lib/changeStreams.ts → dist/lib/changeStreams.js} +12 -12
  16. package/dist/lib/changeStreams.js.map +1 -0
  17. package/dist/lib/frameGate.d.ts +26 -0
  18. package/dist/lib/frameGate.d.ts.map +1 -0
  19. package/dist/lib/frameGate.js +44 -0
  20. package/dist/lib/frameGate.js.map +1 -0
  21. package/dist/lib/json.d.ts +10 -0
  22. package/dist/lib/json.d.ts.map +1 -0
  23. package/dist/lib/json.js +27 -0
  24. package/dist/lib/json.js.map +1 -0
  25. package/dist/lib/nonceStore.d.ts +50 -0
  26. package/dist/lib/nonceStore.d.ts.map +1 -0
  27. package/dist/lib/nonceStore.js +96 -0
  28. package/dist/lib/nonceStore.js.map +1 -0
  29. package/dist/lib/queryLimit.d.ts +4 -0
  30. package/dist/lib/queryLimit.d.ts.map +1 -0
  31. package/{src/lib/queryLimit.ts → dist/lib/queryLimit.js} +5 -6
  32. package/dist/lib/queryLimit.js.map +1 -0
  33. package/dist/lib/queryWorker/pool.d.ts +60 -0
  34. package/dist/lib/queryWorker/pool.d.ts.map +1 -0
  35. package/dist/lib/queryWorker/pool.js +278 -0
  36. package/dist/lib/queryWorker/pool.js.map +1 -0
  37. package/{src/lib/queryWorker/protocol.ts → dist/lib/queryWorker/protocol.d.ts} +21 -18
  38. package/dist/lib/queryWorker/protocol.d.ts.map +1 -0
  39. package/dist/lib/queryWorker/protocol.js +2 -0
  40. package/dist/lib/queryWorker/protocol.js.map +1 -0
  41. package/dist/lib/queryWorker/worker.d.ts +2 -0
  42. package/dist/lib/queryWorker/worker.d.ts.map +1 -0
  43. package/{src/lib/queryWorker/worker.ts → dist/lib/queryWorker/worker.js} +19 -22
  44. package/dist/lib/queryWorker/worker.js.map +1 -0
  45. package/dist/lib/resumeBuffer.d.ts +143 -0
  46. package/dist/lib/resumeBuffer.d.ts.map +1 -0
  47. package/dist/lib/resumeBuffer.js +216 -0
  48. package/dist/lib/resumeBuffer.js.map +1 -0
  49. package/dist/lib/resumeCursor.d.ts +16 -0
  50. package/dist/lib/resumeCursor.d.ts.map +1 -0
  51. package/{src/lib/resumeCursor.ts → dist/lib/resumeCursor.js} +22 -24
  52. package/dist/lib/resumeCursor.js.map +1 -0
  53. package/dist/lib/tokenSweep.d.ts +17 -0
  54. package/dist/lib/tokenSweep.d.ts.map +1 -0
  55. package/dist/lib/tokenSweep.js +40 -0
  56. package/dist/lib/tokenSweep.js.map +1 -0
  57. package/dist/main.d.ts +2 -0
  58. package/dist/main.d.ts.map +1 -0
  59. package/dist/main.js +46 -0
  60. package/dist/main.js.map +1 -0
  61. package/dist/middleware/auth.d.ts +25 -0
  62. package/dist/middleware/auth.d.ts.map +1 -0
  63. package/dist/middleware/auth.js +74 -0
  64. package/dist/middleware/auth.js.map +1 -0
  65. package/dist/middleware/errors.d.ts +17 -0
  66. package/dist/middleware/errors.d.ts.map +1 -0
  67. package/dist/middleware/errors.js +64 -0
  68. package/dist/middleware/errors.js.map +1 -0
  69. package/dist/routes/attachments.d.ts +5 -0
  70. package/dist/routes/attachments.d.ts.map +1 -0
  71. package/dist/routes/attachments.js +108 -0
  72. package/dist/routes/attachments.js.map +1 -0
  73. package/dist/routes/auth.d.ts +6 -0
  74. package/dist/routes/auth.d.ts.map +1 -0
  75. package/dist/routes/auth.js +110 -0
  76. package/dist/routes/auth.js.map +1 -0
  77. package/dist/routes/changes.d.ts +22 -0
  78. package/dist/routes/changes.d.ts.map +1 -0
  79. package/dist/routes/changes.js +233 -0
  80. package/dist/routes/changes.js.map +1 -0
  81. package/dist/routes/entity.d.ts +5 -0
  82. package/dist/routes/entity.d.ts.map +1 -0
  83. package/dist/routes/entity.js +30 -0
  84. package/dist/routes/entity.js.map +1 -0
  85. package/dist/routes/health.d.ts +4 -0
  86. package/dist/routes/health.d.ts.map +1 -0
  87. package/dist/routes/health.js +7 -0
  88. package/dist/routes/health.js.map +1 -0
  89. package/dist/routes/records.d.ts +5 -0
  90. package/dist/routes/records.d.ts.map +1 -0
  91. package/dist/routes/records.js +231 -0
  92. package/dist/routes/records.js.map +1 -0
  93. package/dist/routes/tokens.d.ts +5 -0
  94. package/dist/routes/tokens.d.ts.map +1 -0
  95. package/dist/routes/tokens.js +65 -0
  96. package/dist/routes/tokens.js.map +1 -0
  97. package/dist/routes/types.d.ts +5 -0
  98. package/dist/routes/types.d.ts.map +1 -0
  99. package/dist/routes/types.js +46 -0
  100. package/dist/routes/types.js.map +1 -0
  101. package/dist/routes/wellknown.d.ts +6 -0
  102. package/dist/routes/wellknown.d.ts.map +1 -0
  103. package/dist/routes/wellknown.js +45 -0
  104. package/dist/routes/wellknown.js.map +1 -0
  105. package/dist/shutdown.d.ts +23 -0
  106. package/dist/shutdown.d.ts.map +1 -0
  107. package/dist/shutdown.js +32 -0
  108. package/dist/shutdown.js.map +1 -0
  109. package/dist/stack.d.ts +20 -0
  110. package/dist/stack.d.ts.map +1 -0
  111. package/dist/stack.js +50 -0
  112. package/dist/stack.js.map +1 -0
  113. package/dist/testing.d.ts +49 -0
  114. package/dist/testing.d.ts.map +1 -0
  115. package/dist/testing.js +130 -0
  116. package/dist/testing.js.map +1 -0
  117. package/{src/types.ts → dist/types.d.ts} +5 -5
  118. package/dist/types.d.ts.map +1 -0
  119. package/dist/types.js +2 -0
  120. package/dist/types.js.map +1 -0
  121. package/{src/wireError.ts → dist/wireError.d.ts} +2 -9
  122. package/dist/wireError.d.ts.map +1 -0
  123. package/dist/wireError.js +13 -0
  124. package/dist/wireError.js.map +1 -0
  125. package/package.json +42 -17
  126. package/.claude/settings.local.json +0 -22
  127. package/.dockerignore +0 -10
  128. package/.env.example +0 -90
  129. package/.github/pull_request_template.md +0 -23
  130. package/.github/workflows/ci.yml +0 -55
  131. package/.github/workflows/release.yml +0 -99
  132. package/.prettierignore +0 -3
  133. package/.prettierrc +0 -7
  134. package/AGENTS.md +0 -91
  135. package/CONTRIBUTING.md +0 -234
  136. package/Dockerfile +0 -30
  137. package/SECURITY.md +0 -54
  138. package/docs/api.md +0 -277
  139. package/docs/deployment.md +0 -212
  140. package/eslint.config.mjs +0 -18
  141. package/pnpm-workspace.yaml +0 -13
  142. package/src/app.ts +0 -86
  143. package/src/config.ts +0 -176
  144. package/src/index.ts +0 -54
  145. package/src/lib/frameGate.ts +0 -42
  146. package/src/lib/json.ts +0 -27
  147. package/src/lib/nonceStore.ts +0 -103
  148. package/src/lib/queryWorker/pool.ts +0 -324
  149. package/src/lib/resumeBuffer.ts +0 -268
  150. package/src/lib/tokenSweep.ts +0 -43
  151. package/src/middleware/auth.ts +0 -77
  152. package/src/middleware/errors.ts +0 -73
  153. package/src/routes/attachments.ts +0 -126
  154. package/src/routes/auth.ts +0 -137
  155. package/src/routes/changes.ts +0 -279
  156. package/src/routes/entity.ts +0 -33
  157. package/src/routes/health.ts +0 -8
  158. package/src/routes/records.ts +0 -259
  159. package/src/routes/tokens.ts +0 -85
  160. package/src/routes/types.ts +0 -57
  161. package/src/routes/wellknown.ts +0 -55
  162. package/src/shutdown.ts +0 -56
  163. package/src/stack.ts +0 -96
  164. package/tests/app.test.ts +0 -86
  165. package/tests/changeFeedClient.ts +0 -156
  166. package/tests/config.test.ts +0 -112
  167. package/tests/conformance.test.ts +0 -2322
  168. package/tests/fixtures/blockingWorker.ts +0 -27
  169. package/tests/fixtures/brokenWorker.ts +0 -7
  170. package/tests/lib/changeFeedClient.test.ts +0 -126
  171. package/tests/lib/frameGate.test.ts +0 -93
  172. package/tests/lib/queryLimit.test.ts +0 -33
  173. package/tests/lib/queryWorker.test.ts +0 -60
  174. package/tests/lib/queryWorkerPool.test.ts +0 -129
  175. package/tests/lib/resumeBuffer.test.ts +0 -341
  176. package/tests/lib/resumeCursor.test.ts +0 -46
  177. package/tests/middleware/errors.test.ts +0 -176
  178. package/tests/routes/associations.test.ts +0 -170
  179. package/tests/routes/attachments.test.ts +0 -621
  180. package/tests/routes/auth.test.ts +0 -448
  181. package/tests/routes/changes.test.ts +0 -567
  182. package/tests/routes/contentQuery.test.ts +0 -68
  183. package/tests/routes/entity.test.ts +0 -170
  184. package/tests/routes/groups.test.ts +0 -69
  185. package/tests/routes/records.test.ts +0 -1965
  186. package/tests/routes/relatedToQuery.test.ts +0 -182
  187. package/tests/routes/tokens.test.ts +0 -157
  188. package/tests/routes/tombstones.test.ts +0 -93
  189. package/tests/routes/types.test.ts +0 -129
  190. package/tests/routes/versions.test.ts +0 -177
  191. package/tests/routes/wellknown.test.ts +0 -71
  192. package/tests/setup.ts +0 -154
  193. package/tests/shutdown.test.ts +0 -121
  194. package/tests/stack.test.ts +0 -198
  195. package/tsconfig.build.json +0 -9
  196. package/tsconfig.json +0 -19
  197. package/vitest.config.ts +0 -12
package/docs/api.md DELETED
@@ -1,277 +0,0 @@
1
- # API Reference
2
-
3
- All routes are prefixed by the base URL. Requests are authenticated with a `Bearer` token in the `Authorization` header.
4
-
5
- ## Discovery
6
-
7
- | Method | Path | Auth | Description |
8
- | ------ | -------------------- | ---- | ------------------------------- |
9
- | GET | `/.well-known/stack` | None | Stack metadata and capabilities |
10
- | GET | `/health` | None | Liveness check |
11
-
12
- `GET /.well-known/stack` returns:
13
-
14
- ```json
15
- {
16
- "version": "1.0",
17
- "entityId": "did:key:z6Mk...",
18
- "timezone": "America/New_York",
19
- "capabilities": {
20
- "filter": { "content": "path", "contentPresent": true, "search": true },
21
- "sort": { "fields": ["createdAt", "updatedAt", "version"], "contentField": true },
22
- "limits": { "attachmentBytes": 52428800, "contentBytes": 1048576 }
23
- },
24
- "auth": { "methods": ["did-challenge"] },
25
- "changes": { "transports": ["sse"], "resume": true, "records": true }
26
- }
27
- ```
28
-
29
- `version` is the wire protocol's own `MAJOR.MINOR` version (from `@haverstack/wire-types`' `WIRE_PROTOCOL_VERSION`), not this server's software version — a client refuses to `open()` a server whose major differs from its own; a minor difference is never a refusal in either direction. `timezone` is present only when the stack was configured with one — there is no default. `capabilities.limits.attachmentBytes` and `contentBytes` are this server's own enforced ceilings (413 past either), letting a client pre-check and get a typed error instead of burning a round trip. `auth: { methods: ["did-challenge"] }` is always present — this server always implements the DID challenge-response handshake described below.
30
-
31
- `changes` is a top-level field, not part of `capabilities` — a client checks it and fails locally at `open()` rather than discovering a missing feed as a 404 partway through a connection. `transports` lists what it speaks (`sse` is the only one this version defines); `resume` is `true` — `GET /changes` mints and honors resume cursors; `records` is `true` because `GET /changes` already honors `?include=record` unconditionally. Both `resume` and `records` false is also a fully conformant response for a server that doesn't implement either — see [Change feed](#change-feed) below.
32
-
33
- `capabilities.filter.content` is `"path"`, spread through from the underlying sqlite adapter: a `content` filter key may be a dot-separated path (`"profile.email"`), not just a top-level field — see [Content filter keys](#content-filter-keys) below. `capabilities` and its shape are defined by `@haverstack/core`'s `AdapterCapabilities`; see core's `wire-format.md` § Discovery for the normative reach ladder and field-by-field meaning.
34
-
35
- ## Authentication
36
-
37
- | Method | Path | Auth | Description |
38
- | ------ | ----------------- | ---- | ----------------------------------------- |
39
- | POST | `/auth/challenge` | None | Request a nonce to prove control of a DID |
40
- | POST | `/auth/token` | None | Redeem a signed nonce for a bearer token |
41
-
42
- Both endpoints are unauthenticated — they're how a token is earned in the first place. This is the DID challenge-response handshake: a client proves it holds the private key behind a `did:key` DID, and the server issues a bearer token for that DID without any out-of-band secret handoff. `POST /tokens` (owner-only, see [Entity & Tokens](#entity--tokens)) remains as the owner's separate escape hatch for admin-issued and delegated tokens.
43
-
44
- ```
45
- POST /auth/challenge { "did": "did:key:z6Mk..." }
46
- → { "nonce": "k7Qm2ZxRt9vLbNc4Hy8Wf3", "expiresAt": "2024-06-15T12:05:00.000Z" }
47
-
48
- POST /auth/token { "did": "...", "nonce": "...", "signature": "..." }
49
- → { "token": "...", "expiresAt": "...", "principalId": "...", "subjectId": "..." }
50
- ```
51
-
52
- `signature` is base64url. What gets signed is not the nonce alone but a domain-separated payload built by `buildAuthChallengePayload()` in `@haverstack/core/wire` — `haverstack-auth-v1\n<origin>\n<did>\n<nonce>` — where `<origin>` is this server's own configured public origin (`BASE_URL`), never a request header. Binding the origin into the signed payload is what stops a signature obtained for one server from being redeemed at another. `verifyAuthChallenge()` verifies it server-side; a client uses `signAuthChallenge()` / `didCredentialFromKeypair()` from the same module so both sides build the identical payload.
53
-
54
- `POST /auth/token` never delegates: `principalId` and `subjectId` in the response are always equal, since proving key possession proves the principal and says nothing about whom that key may act for. A delegated token is only ever asserted by the owner out of band, via `POST /tokens`' `onBehalfOf`.
55
-
56
- A nonce is single-use, bound to the DID it was issued for, and expires per the `expiresAt` `POST /auth/challenge` returns (5 minutes). Redeeming it — successfully or not — spends it; a second redemption of the same nonce always fails. A redemption naming a _different_ DID than the nonce was issued to spends nothing, so a third party who learns a nonce cannot burn it out from under its holder.
57
-
58
- Both endpoints require a `did:key`. Other DID methods are well-formed DIDs but are refused with `invalid_did`: verification decodes the Ed25519 public key straight out of the DID, so no other method could produce a signature this server can check.
59
-
60
- Handshake-issued tokens carry the label `did-challenge` and a 7-day expiry. The label is what makes them distinguishable in `GET /tokens` from tokens the owner minted deliberately — the two are otherwise identical rows. Expired tokens are reclaimed periodically rather than accumulating.
61
-
62
- ### Auth errors
63
-
64
- Auth failures use their own vocabulary, deliberately outside the `WireErrorCode` taxonomy used elsewhere in this document — no Stack operation has begun yet, so none of these is a `StackError`:
65
-
66
- | Code | Status | Meaning | Retryable |
67
- | ------------------- | ------ | ----------------------------------------------------- | --------- |
68
- | `invalid_did` | 400 | Not a well-formed `did:key` | No |
69
- | `unknown_nonce` | 401 | Never issued, already spent, or issued to another DID | Yes |
70
- | `expired_nonce` | 401 | Past `expiresAt` | Yes |
71
- | `invalid_signature` | 401 | Does not verify against the payload | No |
72
-
73
- The retryable column is why these carry a `code` at all rather than being bodyless 401s like other auth failures on this server: a stale or unknown nonce means re-run the handshake once from `POST /auth/challenge`; a rejected signature will be rejected identically forever, so a client that retried it would loop. `unknown_nonce` deliberately does not distinguish "never issued" from "already spent" from "issued to a different DID" — the three differ only in what an attacker would learn.
74
-
75
- ## Records
76
-
77
- | Method | Path | Auth | Description |
78
- | ------ | ---------------------------------- | ---------- | --------------------------------------- |
79
- | GET | `/records` | Optional | Query records via URL params |
80
- | POST | `/records/query` | Optional | Query records with content filters |
81
- | POST | `/records` | Required | Create a record |
82
- | GET | `/records/:id` | Optional | Get a record by ID |
83
- | PATCH | `/records/:id` | Required | Apply a change set to a record |
84
- | DELETE | `/records/:id` | Required | Soft-delete (or hard with `?hard=true`) |
85
- | POST | `/records/:id/undelete` | Required | Reverse a soft delete |
86
- | GET | `/records/:id/permissions` | Optional | Get permissions |
87
- | GET | `/records/:id/associations` | Optional | List associations |
88
- | POST | `/records/:id/associations` | Required | Add an association |
89
- | POST | `/records/:id/associations/delete` | Required | Remove an association |
90
- | GET | `/records/:id/versions` | Optional | List version history |
91
- | GET | `/records/:id/versions/:version` | Optional | Get a specific version |
92
- | POST | `/records/:id/restore/:version` | Required | Restore a previous version |
93
- | POST | `/records/:id/migrate` | Owner only | Change a record's typeId |
94
-
95
- ### The change set
96
-
97
- `PATCH /records/:id` takes a **change set**: an envelope naming any combination of a record's native aspects, applied as one atomic write that produces exactly one version.
98
-
99
- ```json
100
- {
101
- "contentPatch": { "title": "Q3 plan" },
102
- "parentId": "1hk153x0000f",
103
- "permissions": [{ "access": "public" }],
104
- "associations": [{ "kind": "tag", "label": "planning" }],
105
- "unlisted": false
106
- }
107
- ```
108
-
109
- Keys are read for **presence, not truthiness** — `unlisted: false` and `parentId: null` name aspects and are applied, while an omitted key is left alone. A change set naming no key at all is `400`: it addresses nothing, so there is nothing it could have failed to satisfy. One already satisfied in every key writes nothing and answers with the record unchanged.
110
-
111
- `contentPatch` is the one key that **merges**; every other key replaces the aspect it names. The merge is one level deep — a nested object in a patch replaces rather than merges — and a `null` field value removes the field. Because content is a key rather than the whole body, a `parentId` naming a container is unambiguously distinct from a content field of that name.
112
-
113
- Authority resolves **per key, against the record as it stands**: `contentPatch` and `associations` are reachable by a write-holder or an `update-*` grantee; `parentId` needs that same write access _and_ a destination the requester can read; `permissions` and `unlisted` need the owner or the record's own creator. A requester who may perform every key may perform the set; a requester who may not perform one of them may not perform any of it. Every gate reads the record's pre-change state, so a widened `permissions` in a change set never satisfies the read check on a `parentId` named in the same call. A refused key refuses the whole call — nothing is partially applied and no key is silently dropped.
114
-
115
- An unrecognized top-level key is `400`; a key whose value is the wrong shape is `422`, as is a key the type does not declare inside `contentPatch`. A `parentId` a caller names must be well-formed (`400` otherwise — the empty string is not a spelling of the root, `null` is) and must name a record that exists (`409` otherwise).
116
-
117
- **Which of those two a requester sees depends on who they are.** Both answers are the owner's: for anyone else, a destination they cannot read and one that is not there are a single indistinguishable `403`, which is what stops the gate being an existence oracle. So a non-owner write-holder naming a missing container gets `403`, not `409` — they learn they may not name it, never whether it exists.
118
-
119
- `associate()` / `dissociate()` keep their own endpoints: they amend the association set where the `associations` key replaces it, so two clients tagging one record both succeed through `POST .../associations` and race through the key.
120
-
121
- Version history requires the same access `PATCH`/`DELETE` require — a write-holder, or the owner — not plain read. A read-only requester gets `403`.
122
-
123
- `POST /records/:id/migrate` is the only way a record's `typeId` changes after creation, and is owner-acting-alone only — a non-owner gets `403` regardless of any write grant or record-level permission they hold. Body is `{ toTypeId, content }`: `content` is the full post-migration content, computed client-side by the type's owning app; the server validates it against `toTypeId`'s schema before writing and leaves a pre-migration snapshot in version history. Accepts the same `If-Match` header as `PATCH`.
124
-
125
- Removing an association is `POST /records/:id/associations/delete`, not a body-bearing `DELETE` — a `DELETE` request body has no defined semantics (RFC 9110 §9.3.5) and is a portability landmine for proxies/gateways that drop or reject it.
126
-
127
- A `relationship` association's `target` is a discriminated union naming which identifier space the value belongs to — a Record (in this Stack or another), an identity, or something outside the Stack entirely:
128
-
129
- ```json
130
- { "kind": "relationship", "label": "reply-to", "target": { "scope": "record", "recordId": "xyz789" } }
131
- { "kind": "relationship", "label": "author", "target": { "scope": "entity", "entityId": "did:key:z6Mk..." } }
132
- { "kind": "relationship", "label": "syndicated-to", "target": { "scope": "external", "ns": "atproto", "id": "at://..." } }
133
- ```
134
-
135
- A change set's `permissions` key replaces every permission on the record and answers `200` with the updated record. An empty array makes the record private (owner-only). `GET /records/:id/permissions` reads them back under the `{ "permissions": [...] }` envelope.
136
-
137
- `POST /records/:id/undelete` reverses a soft delete and returns the record as it now stands (`deletedAt` absent). Idempotent — a second call on an already-active record returns the same result.
138
-
139
- `POST /records` returns `200`, not `201` — the response is the created record, same shape as every other write. The body is the full record: `id` is client-minted (12 lowercase Crockford base-32 characters, no reserved `_` prefix — omit it to let the server generate one) and, when supplied, must encode a creation timestamp within the server's clock-skew tolerance; `version` is never accepted from the client — that, like `entityId`/`principalId`, is always server-assigned. A duplicate `id` returns `409` with code `conflict`.
140
-
141
- `createdAt`/`updatedAt` are honored only from the stack owner acting alone (undelegated, authenticated as the owner) — the same tier that gates hard delete, `commitMigration()`, and `includeUnlisted`. From anyone else, both are dropped rather than forwarded: forwarding them would otherwise turn an ordinary grantee's create into a `403`, since a non-owner backdating attempt is refused outright rather than silently ignored. For an owner-acting-alone create: `updatedAt` defaults to `createdAt` (never to now), so a plain import doesn't fabricate an edit; `updatedAt` earlier than `createdAt` is a `422` validation error, including when `createdAt` itself defaulted to now; omitting `id` derives it from `createdAt` instead of the current time; supplying both `id` and `createdAt` checks them against each other under the same clock-skew tolerance. An owner's plain `id`-only create (no `createdAt`) is unaffected — it still gets the ordinary id-vs-now check. A malformed `createdAt`/`updatedAt` value returns `422` with code `validation`.
142
-
143
- `If-Match: "<version>"` is accepted for optimistic concurrency on every endpoint that bumps a record's version — `PATCH /records/:id`, `DELETE /records/:id`, `POST /records/:id/undelete`, `POST /records/:id/restore/:version`, `POST /records/:id/migrate`, `POST /records/:id/associations`, and `POST /records/:id/associations/delete`. One `If-Match` fences a whole multi-aspect change set. A mismatch returns `412` with code `version_conflict` and a `versionConflict: { recordId, expectedVersion, actualVersion }` payload; omitting the header keeps last-writer-wins. A malformed value (not a bare, optionally quoted version — a weak comparator like `W/"5"` included) is `400`, not read as absent: a header sent to fence a write that silently degrades to unconditional last-writer-wins defeats the only thing it was sent to do.
144
-
145
- ### Query parameters
146
-
147
- `GET /records` accepts, among others: `typeId`, `parentId`, `appId`, `entityId`, `principalId`, `tag`, `hasAttachment`, `attachmentFileId`, `relatedTo` (+ `relatedToStack`, `relatedToLabel`), `relatedToEntity` (+ `relatedToLabel`), `relatedToNs` (+ `relatedToId`, `relatedToLabel`), `search`, `createdBefore`/`createdAfter`, `updatedBefore`/`updatedAfter`, `includeDeleted`, `includeUnlisted`, `sort`/`sortContent`/`direction`, `limit`, `cursor`. `entityId` filters by the record's attributed subject; `principalId` filters by the delegating app, if any (see [Permissions](#permissions) below). `includeUnlisted` is owner-only — see [Unlisted](#unlisted) below. `POST /records/query` accepts the same filters as a JSON body, plus `content` (field-equality) and `contentPresent` (field-presence) filters. Omitting `limit` returns one default-sized page (50 records), never the whole result set — `cursor` is the only end-of-results signal; a `limit` above this server's own ceiling (1000) is clamped down to it rather than rejected.
148
-
149
- Parsing and encoding these parameters is `@haverstack/core/wire`'s job, not this server's — `parseQueryParams`, `parseQueryBody`, and `parseChangeParams` there are the exact inverse of what `@haverstack/adapter-api` builds, and are the normative reference for every rule below. `docs/spec/wire-format.md` § Query parameters in `haverstack/core` documents the full parameter set and its edge cases; what follows here is a summary, kept for this server's own reference.
150
-
151
- Both query endpoints' response envelope is `{ records, cursor }`. It carries no count of the whole match — every response has passed a permission boundary, so a count that ignores pagination would report how many records exist beyond what the requester may read. An empty `records` array with a non-null `cursor` is a valid response and does not mean the result set is exhausted — a low-visibility requester can see several empty pages before results appear, so `cursor: null` is the only end-of-results signal.
152
-
153
- #### Relationship target filter (`relatedTo` family)
154
-
155
- A relationship association's target names one of three scopes — a Record, an identity, or something outside the Stack — and the filter parameters mirror that:
156
-
157
- | Parameter | Scope | Pairs with |
158
- | ----------------- | ------------ | ----------------------------------------------------------- |
159
- | `relatedTo` | `record` | `relatedToStack` (that Record's Stack URL, if not this one) |
160
- | `relatedToEntity` | `entity` | — |
161
- | `relatedToNs` | `external` | `relatedToId` (omit to match the whole namespace) |
162
- | `relatedToLabel` | any, or none | narrows any of the above; valid alone |
163
-
164
- `relatedTo`, `relatedToEntity`, and `relatedToNs` name different, mutually exclusive scopes — mixing parameters from two of them is `400`. `relatedToStack` is only meaningful alongside `relatedTo`, and `relatedToId` only alongside `relatedToNs`; either appearing without its pair is `400`. Absence and emptiness are not the same thing: omitting `relatedToStack` means the target has no `stackUrl` (i.e. this Stack) and does _not_ match a target that carries one, while an _empty_ `relatedToStack` is `400` rather than being read as "local" or "any". The same holds for `relatedToId`: omit it to match the whole namespace; an empty value is `400`. A bare `relatedToLabel`, with none of the scope parameters, is valid and matches every target under that label. `filter.relatedTo` in the `POST /records/query` body carries the same rule as a `{ label?, target? }` object, `target` being `{ scope: 'record' | 'entity' | 'external', ... }`.
165
-
166
- #### Content filter keys
167
-
168
- `POST /records/query`'s `filter.content` matches content fields by exact value, keyed by field name. A key may be a dot-separated path (`"profile.email"`) to match a field nested inside an `object`-typed field — this server declares `capabilities.filter.content: "path"` in [Discovery](#discovery), so a multi-segment key is accepted rather than refused with `400`. An array anywhere along the path is matched element-wise. `filter.contentPresent` lists paths that must hold a value, the question an exact-match filter value can't ask.
169
-
170
- ### Soft delete and tombstones
171
-
172
- `DELETE /records/:id` without `?hard=true` leaves a **tombstone**: the record continues to exist and remains reachable by `id`, but its `content` is projected as `{}` and `deletedAt` is set. This applies uniformly everywhere the record is served with content attached — `GET /records/:id`, `GET`/`POST /records/query` with `filter.includeDeleted: true`, and a `deleted`-kind change feed frame with `?include=record` — so a client sees the same emptied shape regardless of which route it came through. `GET /records/:id/versions` and `GET /records/:id/versions/:version` are the deliberate exception: version history is never tombstoned and continues to serve full content.
173
-
174
- A soft-deleted record refuses further mutation: `PATCH`, `POST .../associations`, `POST .../associations/delete`, `POST .../migrate`, and `POST .../restore/:version` all return `409` with code `conflict` until `POST /records/:id/undelete` reverses the delete. `GET` and version-history reads are unaffected — the refusal applies only to mutation.
175
-
176
- ## Unlisted
177
-
178
- `unlistedAt` is orthogonal to `permissions`: it says nothing about who may read a record, only whether it is enumerable. A record with `unlistedAt` set is still reachable by `GET /records/:id` for anyone who may already read it — unlisted withholds a record from enumeration, never from access.
179
-
180
- ```
181
- PATCH /records/:id body: { "unlisted": boolean }
182
- ```
183
-
184
- Answers `200` with the updated record — it bumps `version` like any other mutation, and is snapshotted to version history the same way — carrying `unlistedAt` when `true`, absent when `false`. Accepts the same optional `If-Match` precondition as every other mutating endpoint, and returns `409` on a soft-deleted record like the other mutating endpoints above. Gated exactly like the `permissions` key: owner or creator, asked of both identities under delegation. A non-boolean value is `422`, the shape error every change-set key answers with.
185
-
186
- **`includeUnlisted` is owner-only.** `GET /records`, `POST /records/query`, and `GET /changes` all accept it (excluded by default, like `includeDeleted`), and all three 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. On `GET /changes` the refusal happens before the SSE stream opens.
187
-
188
- A `POST /records` body may carry `unlistedAt` to create the record already unlisted, so there is no window where it exists and is enumerable before a later change set catches up — presence of the field is what matters; its value is not honored (`unlistedAt` on the response is stamped to the real create time, like `version`). This is **not** owner-only, unlike `createdAt`/`updatedAt`: it's gated the same way the `permissions` field is at create time — refused with `403` for a delegated app acting for anyone but the owner, but available to any other creator holding a create grant for the type.
189
-
190
- ### The feed transitions
191
-
192
- `unlist` and `list` are new change-feed ops (`unlist` maps to `kind: "deleted"`, `list` to `kind: "changed"` — see [Change feed](#change-feed)). Only the `unlist` transition needs special-casing; every other row falls out of checking the record's current `unlistedAt` against the subscriber's `includeUnlisted`:
193
-
194
- | Transition | Emits to a default subscriber? |
195
- | ---------------------------- | :----------------------------: |
196
- | Created unlisted | No |
197
- | Listed → unlisted (`unlist`) | **Yes** |
198
- | Any change while unlisted | No |
199
- | Unlisted → listed (`list`) | **Yes** |
200
- | Hard delete while unlisted | No |
201
-
202
- ## Change feed
203
-
204
- | Method | Path | Auth | Description |
205
- | ------ | ---------- | -------- | --------------------------------------------------- |
206
- | GET | `/changes` | Optional | Live stream of record changes (`text/event-stream`) |
207
-
208
- `GET /changes` opens a Server-Sent Events connection scoped exactly like `GET /records` — an authenticated requester sees what their session may read, an anonymous one sees only public records — via the same `canRead` predicate `get()`/`query()` answer with. Every connection gets a `ready` frame first, always, before anything else: it's what makes subscribe-then-query gap-free, since a client that awaits it before querying knows every later change reaches it as a frame.
209
-
210
- This server mints resume cursors: `ready` carries the current head as `seq`, and every `record` frame carries that same value as its SSE `id:`. A reconnect presenting `Last-Event-ID` (or the equivalent `?since=`) resumes from there — receiving exactly the changes it missed, never one it already has — or gets a `reset` frame naming why it can't: `cursor_expired` for a cursor this server no longer recognizes (its buffer aged out, or was minted for different query params — a cursor is self-describing, so a mismatch is detectable rather than silently resumed against the wrong stream), or `overflow` for a recognized buffer that has already dropped what a full resume would need. A cursor outside the base64url alphabet is refused locally as a `400`, before the connection ever opens, rather than answered with a `reset` — it isn't a value this server could ever have minted, so there's nothing to reconcile by resuming from it.
211
-
212
- Resume is per-connection state, not global: each distinct (session, filter) pairing gets its own buffer, retained for a bounded time and depth past the last connection using it — long enough for an ordinary reconnect, not indefinitely. A gap resumption can't close is exactly what `reset` exists for; a client's repair is the same either way — reconcile by querying `GET /records` / `POST /records/query`. A `purged` frame in a replayed backlog is never re-checked against current permissions (there is no record left to check, and the mutation-time decision is the only one that will ever exist); every other replayed frame is.
213
-
214
- Query parameters, all optional and composable: `typeId` (repeatable, matched by baseId so a type-version bump never orphans a subscription), `parentId` (`"null"` selects root records, same as `GET /records`), `entityId` (the record's author, not who made the change), `kind` (repeatable: `created`, `changed`, `deleted`, `purged`), `include=record` (attach the record as of the change; ignored for `kind=purged`), `includeUnlisted` (owner-only — see [Unlisted](#unlisted)). Filtering is exact, not advisory — a filtered connection never receives a frame it did not match, and an unrecognized `kind` or `include` value is a `400`. A `parentId` filter is the one place where matching and frame contents come apart, and a subscriber that reads the two as the same thing will misroute a move:
215
-
216
- A `reparent` is matched against **both** containers the move concerns, so a subscription filtered on the origin learns the record left it — the record's post-change state alone would answer only for the destination. The frame carries the destination in `parentId`, as every frame carries the record's state at the moment of the change, so a subscriber compares it against its own filter to tell a departure from an arrival. Its `kind` is `changed`, not `deleted`: the record is still there and still readable, only its container moved.
217
-
218
- Every change (`event: record`) carries `kind`, `ops`, `recordId`, `typeId`, `version`, `updatedAt`, and — when known — `actor` (who made the change; never the record's own author, which is what `entityId` filters on). `kind` is the coarse branch a handler can be complete on; `ops` names every aspect the version actually moved (`create`, `patch`, `associate`, `dissociate`, `permissions`, `reparent`, `migrate`, `restore`, `delete`, `undelete`, `hard-delete`, `unlist`, `list`) for a consumer that distinguishes, say, a reshare from an edit. It is derived by diffing the record against its prior state rather than read off the request, so a change set naming an aspect without moving it is never reported as moving it; it is never empty, and is multi-entry only for a change set, since every other op names a whole-record transition and is emitted alone. `kind` resolves to the most conservative entry — a change set carrying `unlist` is `deleted` whatever else it carries, because a subscriber holding the record still has to drop it. A `purged` frame — from a hard delete — carries none of `record`, `parentId`, or the record's own author, whatever the connection asked for: hard delete is the erasure primitive, and a frame naming what was destroyed is deliberately all that survives it.
219
-
220
- A record this connection may not read produces no frame at all — not an empty or redacted one — the same reasoning that keeps a count of the whole match off the query envelope: the existence of a change is itself a disclosure.
221
-
222
- `: keepalive` comment lines go out on an otherwise-idle connection periodically, same as any SSE comment — a conforming client ignores them. A connection whose bearer token is revoked or expires is closed by the server rather than left delivering on stale authority; a reconnect gets the same `401` every other endpoint already answers with. A connection that falls too far behind (more in-flight frames than it's draining) is also closed rather than queued without bound — a client that can't tell it missed something can't repair it either, so the server disconnects instead of silently dropping a frame.
223
-
224
- This server is single-process: `GET /changes` reports only writes made through this same process's `Stack`. A second process pointed at the same underlying storage would see nothing from this endpoint — worth knowing before running more than one instance against one stack.
225
-
226
- ## Types
227
-
228
- | Method | Path | Auth | Description |
229
- | ------ | ------------ | ---------- | ------------------------- |
230
- | GET | `/types` | None | List all registered types |
231
- | GET | `/types/:id` | None | Get a type by ID |
232
- | POST | `/types` | Owner only | Register a new type |
233
-
234
- ## Attachments
235
-
236
- | Method | Path | Auth | Description |
237
- | ------ | ---------------------- | ---------- | ---------------------------------------------------------------- |
238
- | POST | `/attachments` | Required | Store bytes and create the `_attachment@1` record in one request |
239
- | GET | `/attachments/:fileId` | Optional | Download a file |
240
- | DELETE | `/attachments/:fileId` | Owner only | Delete a file |
241
- | POST | `/attachments/gc` | Owner only | Sweep and delete orphaned attachment bytes |
242
-
243
- `POST /attachments` takes the raw bytes as the request body, `Content-Type` as the declared `mimeType` (defaults to `application/octet-stream`), an optional `Content-Disposition` header for the filename (RFC 5987 `filename*` form), and an optional `?appId=` query param. It returns the created `_attachment@1` record (`200`) — the same shape as `POST /records` — not a bare `fileId`. Requires a `create` grant on `_attachment@1`; anonymous requests get `401`, an authenticated requester with no grant gets `403`.
244
-
245
- `GET /attachments/:fileId` resolves `Content-Type` from three sources, in order: the `?contentType` query param, extension inference from `?filename`, then the fileId's stored metadata (the first-recorded `_attachment@1` record's `mimeType`, by earliest `createdAt`). Whichever candidate wins is checked against a safe-list (`image/*` except `svg+xml`, `video/*`, `audio/*`, `application/pdf`, `text/plain`, `application/octet-stream`); anything else is served as `application/octet-stream` instead, regardless of which source produced it. When the candidate is forced this way, `Content-Disposition` is also forced to a bare `attachment` with no filename — Content-Type forcing alone doesn't stop a browser from sniffing the body back into the original type without both `nosniff` and a non-inline disposition. Otherwise `Content-Disposition`'s filename resolves as: `?filename` if given, else the requester's own `_attachment@1` record (by `entityId`), else the first-recorded record's. `X-Content-Type-Options: nosniff` is set on every response. For a non-owner requester, a missing fileId and one they simply can't access are indistinguishable — both return `403` (`401` if the requester is anonymous) — so a guessed fileId can't be used to probe whether specific bytes exist on the stack.
246
-
247
- `POST /attachments/gc` sweeps for attachment bytes unreachable from any record — live or soft-deleted — and deletes both the bytes and their `_attachment@1` metadata. Body is `{ graceMs?, dryRun? }`, both optional: `graceMs` is how recently-uploaded an unreferenced file must be to survive collection (default 24 hours, covering the upload-then-associate window; `0` collects immediately), `dryRun` computes the result without deleting anything. Returns `{ deleted: [fileId...], reclaimedBytes }`. No built-in scheduling — invoke it directly, or drive it from an external cron; `dryRun` makes a probe-first workflow safe.
248
-
249
- ## Entity & Tokens
250
-
251
- | Method | Path | Auth | Description |
252
- | ------ | ------------- | ---------- | ------------------------------ |
253
- | GET | `/entity` | Required | Get the owner entity record |
254
- | PATCH | `/entity` | Owner only | Update the owner entity record |
255
- | GET | `/tokens` | Owner only | List API tokens |
256
- | POST | `/tokens` | Owner only | Create an API token |
257
- | DELETE | `/tokens/:id` | Owner only | Revoke an API token |
258
-
259
- `POST /tokens` accepts `{ entityId, onBehalfOf?, label?, expiresAt? }`. `entityId` is the token's principal (who authenticates); `onBehalfOf` optionally asserts a delegation — the subject the principal acts for. Both, when given, must be DIDs (`422` otherwise). The response always reports both: `{ id, token, principalId, subjectId, label, createdAt, expiresAt }`. Omitting `onBehalfOf` issues an undelegated token, where `subjectId` equals `principalId`.
260
-
261
- ## Permissions
262
-
263
- Records are private by default (readable only by the stack owner). The `permissions` field controls access:
264
-
265
- ```json
266
- { "access": "public" }
267
- { "access": "entity", "entityId": "...", "read": true, "write": false }
268
- { "access": "group", "groupId": "...", "read": true, "write": true }
269
- ```
270
-
271
- Non-owner entities authenticate either via the DID challenge-response handshake above or with tokens issued by the owner via `POST /tokens`, and are subject to both record-level permissions and create-grant checks on write.
272
-
273
- A type-level grant names a specific entity (`grant(<did>, ...)`) or no entity at all. **A grant with no grantee is a grant to the public.** It resolves for any authenticated entity, and since `POST /auth/challenge` lets anyone authenticate by generating a keypair, "any authenticated entity" means anyone who can reach the server — there is no vouching step in between. Grant a DID by name unless you genuinely mean everyone. (Note the asymmetry with record-level permissions above, which have a `group` tier between `entity` and `public`; type-level grants have no group tier, so a set of entities is expressed as one named grant each.)
274
-
275
- ### Principal and subject
276
-
277
- A token names two identities: the **principal**, who authenticated (governs authority — grant lookups, a change set's `permissions` key), and the **subject**, who the principal acts for (governs attribution — `record.entityId` on writes, `-own` matching). They're equal unless the token was issued with `onBehalfOf`. A delegated write stamps both: `entityId` is the subject, and `principalId` appears on the record when the two differ. `GET /records` and `POST /records/query` can filter on either via `entityId`/`principalId`. Effective authority under delegation is the intersection of both parties' grants — a delegated app can't act beyond what the subject itself also permits.
@@ -1,212 +0,0 @@
1
- # Deployment Guide
2
-
3
- The server speaks plain HTTP. In production, always place it behind a reverse proxy that handles TLS termination and rate limiting. Never expose the plain-HTTP port directly to the internet.
4
-
5
- - [Caddy](#caddy) — simplest; automatic HTTPS, minimal config
6
- - [nginx](#nginx) — most common; full control over headers and limits
7
- - [Traefik](#traefik) — best for Docker; auto-discovers containers
8
-
9
- ---
10
-
11
- ## Caddy
12
-
13
- Caddy obtains and renews TLS certificates automatically via Let's Encrypt.
14
-
15
- ```
16
- stack.example.com {
17
- reverse_proxy localhost:3000
18
- }
19
- ```
20
-
21
- **Rate limiting** is not built into the standard Caddy binary. Options:
22
-
23
- - Build Caddy with the [`caddy-ratelimit`](https://github.com/mholt/caddy-ratelimit) community module via [caddyserver.com/download](https://caddyserver.com/download).
24
- - Use a CDN or WAF (e.g. Cloudflare) in front of Caddy for IP-based limiting.
25
-
26
- ---
27
-
28
- ## nginx
29
-
30
- Obtain a certificate first with [Certbot](https://certbot.eff.org/), then use this configuration. The `limit_req_zone` directives must live in the `http` block (typically `/etc/nginx/nginx.conf` or a file included from it); the `server` block below goes in `/etc/nginx/sites-available/haverstack`.
31
-
32
- ```nginx
33
- # In the http block:
34
- limit_req_zone $binary_remote_addr zone=hs_global:10m rate=60r/m;
35
- limit_req_zone $binary_remote_addr zone=hs_tokens:10m rate=5r/m;
36
- ```
37
-
38
- ```nginx
39
- # /etc/nginx/sites-available/haverstack
40
- server {
41
- listen 80;
42
- server_name stack.example.com;
43
- return 301 https://$host$request_uri;
44
- }
45
-
46
- server {
47
- listen 443 ssl;
48
- server_name stack.example.com;
49
-
50
- ssl_certificate /etc/letsencrypt/live/stack.example.com/fullchain.pem;
51
- ssl_certificate_key /etc/letsencrypt/live/stack.example.com/privkey.pem;
52
-
53
- location / {
54
- limit_req zone=hs_global burst=20 nodelay;
55
-
56
- proxy_pass http://localhost:3000;
57
- proxy_set_header Host $host;
58
- proxy_set_header X-Real-IP $remote_addr;
59
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
60
- proxy_set_header X-Forwarded-Proto $scheme;
61
- }
62
-
63
- # Tighter limit on token issuance to slow brute-force attempts.
64
- location = /tokens {
65
- limit_req zone=hs_tokens burst=2 nodelay;
66
-
67
- proxy_pass http://localhost:3000;
68
- proxy_set_header Host $host;
69
- proxy_set_header X-Real-IP $remote_addr;
70
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
71
- proxy_set_header X-Forwarded-Proto $scheme;
72
- }
73
- }
74
- ```
75
-
76
- ---
77
-
78
- ## Traefik
79
-
80
- Traefik is a good fit when you're already running Docker. It discovers the server container automatically via labels and handles Let's Encrypt itself.
81
-
82
- Create a `docker-compose.yml`:
83
-
84
- ```yaml
85
- services:
86
- traefik:
87
- image: traefik:v3
88
- command:
89
- - --providers.docker=true
90
- - --providers.docker.exposedbydefault=false
91
- - --entrypoints.web.address=:80
92
- - --entrypoints.web.http.redirections.entrypoint.to=websecure
93
- - --entrypoints.web.http.redirections.entrypoint.scheme=https
94
- - --entrypoints.websecure.address=:443
95
- - --certificatesresolvers.letsencrypt.acme.email=you@example.com
96
- - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json
97
- - --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web
98
- ports:
99
- - '80:80'
100
- - '443:443'
101
- volumes:
102
- - /var/run/docker.sock:/var/run/docker.sock:ro
103
- - letsencrypt:/letsencrypt
104
-
105
- server:
106
- image: ghcr.io/haverstack/server:latest
107
- environment:
108
- OWNER_TOKEN: ${OWNER_TOKEN}
109
- ENTITY_ID: ${ENTITY_ID} # only needed on first run
110
- volumes:
111
- - data:/app/data
112
- labels:
113
- - traefik.enable=true
114
- - traefik.http.routers.haverstack.rule=Host(`stack.example.com`)
115
- - traefik.http.routers.haverstack.entrypoints=websecure
116
- - traefik.http.routers.haverstack.tls.certresolver=letsencrypt
117
- # Rate limit: 60 req/min per IP, burst of 20
118
- - traefik.http.middlewares.haverstack-rl.ratelimit.average=60
119
- - traefik.http.middlewares.haverstack-rl.ratelimit.period=1m
120
- - traefik.http.middlewares.haverstack-rl.ratelimit.burst=20
121
- - traefik.http.routers.haverstack.middlewares=haverstack-rl
122
-
123
- volumes:
124
- data:
125
- letsencrypt:
126
- ```
127
-
128
- Start with:
129
-
130
- ```sh
131
- docker compose up -d
132
- ```
133
-
134
- ---
135
-
136
- ## Request body size limits
137
-
138
- The server enforces two ceilings of its own — `MAX_CONTENT_BYTES` (default 1 MB, JSON bodies: records, patches, and every other JSON-accepting route) and `MAX_ATTACHMENT_BYTES` (default 50 MB, `POST /attachments` only) — and declares both in `GET /.well-known/stack` (`capabilities.limits.contentBytes`/`attachmentBytes`) so clients can pre-check.
139
-
140
- A reverse proxy sitting in front, however, often has its own request-body limit, independent of the app's. If that limit is smaller than `MAX_ATTACHMENT_BYTES`, the proxy rejects large uploads before they ever reach the server — usually with a proxy-branded error page, not the server's `413 payload_too_large` wire response. Raise the proxy's own limit to at least `MAX_ATTACHMENT_BYTES` (or match it exactly, so the two ceilings agree and the server's typed error is what clients actually see):
141
-
142
- - **nginx** defaults `client_max_body_size` to `1m`. Set it explicitly in the `server` or `location` block:
143
- ```nginx
144
- client_max_body_size 50m;
145
- ```
146
- - **Caddy** has no body-size limit by default — nothing to configure unless you want one tighter than the app's own.
147
- - **Traefik** has no body-size limit by default either, unless you've added a `buffering` middleware with `maxRequestBodyBytes` set — if so, raise it to match.
148
-
149
- ---
150
-
151
- ## Bounding query cost
152
-
153
- `GET /records` and `POST /records/query` can run a full-text or content-field search over the whole store. The sanitizers bound a `search` string's _complexity_ (wildcards stripped, `NEAR` neutralized, parenthesis nesting capped), but not its _cost_ — a syntactically modest query over a large index can still be slow, and `node:sqlite` runs synchronously with no way to cancel a query from inside the call once it's started (there is no `sqlite3_interrupt` exposed).
154
-
155
- The server addresses this the way `docs/spec/wire-format.md § Bounding query cost` prescribes: both query routes run on a small pool of worker threads (`QUERY_WORKER_POOL_SIZE`, default 2) rather than the request thread, each holding its own connection to the same `stack.db` — legal because worker threads share the process's PID with the storage-ownership lock, and because SQLite's WAL mode is built for multiple connections against one file. A query still _executing_ past `QUERY_TIMEOUT_MS` (default 10s) is abandoned: the request gets `503` (code `timeout`, meaning "retryable — narrow the query or try again," never `bad_request`), and the worker it was running on is terminated and replaced so a stuck query costs one pool slot rather than blocking every other request. Terminating mid-query is safe — SQLite's WAL journaling means an uncommitted transaction is simply gone once the file is reopened.
156
-
157
- `QUERY_TIMEOUT_MS` bounds execution, and its clock starts when a search reaches a worker — not when the request arrived. That distinction is the difference between a busy server and a broken one: a search waiting for a free worker is _slow_, exactly as it was before any of this existed, and only a search that genuinely sits inside an uncancellable SQLite call gets cut off. Were the deadline to cover queue time too, ordinary contention would turn into `503`s — with two workers and 3-second searches, the seventh concurrent one would fail rather than simply take its turn.
158
-
159
- What bounds the waiting instead is `QUERY_QUEUE_LIMIT` (default 64): the number of searches allowed to queue for a worker. Past it the server sheds load explicitly, answering `503` (`timeout`, retryable) rather than accepting work it has no prospect of starting — which keeps "slow under load" from becoming an unbounded queue of held requests. So the worst case a queued search can wait is roughly `QUERY_QUEUE_LIMIT / QUERY_WORKER_POOL_SIZE × QUERY_TIMEOUT_MS`, and that only if every search ahead of it is pathological; in the ordinary case the queue drains in milliseconds.
160
-
161
- A worker that dies _unexpectedly_ — as opposed to one deliberately recycled at a deadline — is respawned with exponential backoff (100ms doubling to 30s), and after five consecutive failures the pool reports itself unhealthy and fails searches fast with the underlying error. This is what keeps a fatal misconfiguration (a `DB_PATH` that has become unreadable, say) from spinning the process respawning threads forever, and makes it surface as itself rather than as a stream of timeouts. The pool recovers on its own once the cause clears.
162
-
163
- Every other route (`GET /records/:id`, `POST /records`, `PATCH /records/:id`, etc.) still runs on the main request thread — those are index-bound lookups and writes, not full-index scans, so they aren't the unbounded-cost case this section covers.
164
-
165
- Raise `QUERY_TIMEOUT_MS` if your store is large enough that legitimate searches routinely take longer than the default, and raise `QUERY_WORKER_POOL_SIZE` if concurrent searches should run in parallel rather than queue behind each other — each pool worker is a full second (or third, ...) connection to the database, so size it against expected concurrent search load, not total request volume. Raise `QUERY_QUEUE_LIMIT` only if you would rather hold bursts than shed them; lower it to fail fast under overload.
166
-
167
- Note that `GET /records` and `POST /records/query` are both reachable without authentication, so anonymous callers can occupy pool workers. The deadline caps what any one of them can hold (a slot for at most `QUERY_TIMEOUT_MS`), but a public deployment expecting hostile traffic should rate-limit the query routes at the reverse proxy as well, alongside the other exposure considerations in [Public endpoints](#public-endpoints).
168
-
169
- ---
170
-
171
- ## Single-writer topology
172
-
173
- The `data` volume (`DB_PATH`'s directory) holds more than the database file itself: `stack.db-wal` and `stack.db-shm` are SQLite's write-ahead-log sidecar files, present whenever the process has the database open, and `stack.db.lock` is a storage-ownership lock recording the PID of the process that opened it. Back up or copy the whole directory, not just `stack.db` — a `stack.db` file copied without its `-wal` sidecar can be missing recently-committed data.
174
-
175
- The directory also holds `stack.db.tokens` — bearer-token issuance and lookup, kept in its own file rather than inside `stack.db` on purpose: auth material shouldn't travel with the portable stack data. Include it in operational backups (revoking a token should stay revoked across a restore), but exclude it from any "export your data" or stack-sharing flow — a copy of `stack.db` alone is the correct unit for that, and restoring one should never resurrect tokens the owner revoked.
176
-
177
- A given `DB_PATH` may be held open by only one process at a time. If a second server instance (a botched redeploy, a stuck old container, a manual `pnpm start` alongside a running one) tries to open the same path, it fails at startup with an error naming the PID already holding it, rather than corrupting the database or silently serving alongside it — WAL and real file locking make concurrent _storage_ access safer than the old single-file adapter, but the single-writer topology stands regardless: permissions only exist behind this one server process, so a second writer would be a second, unenforced trust boundary. If you see this error on a routine restart, the previous process likely hasn't exited yet — give it time to shut down cleanly, or confirm it has actually stopped, before starting the replacement.
178
-
179
- ---
180
-
181
- ## CORS
182
-
183
- Set `CORS_ORIGINS` to a comma-separated list of the origins that need cross-origin access to your stack:
184
-
185
- ```
186
- CORS_ORIGINS=https://app.example.com,https://admin.example.com
187
- ```
188
-
189
- The default is empty (no cross-origin access allowed). Set it to `*` only for fully-public, read-only stacks where unauthenticated access is intentional.
190
-
191
- Because the stack is designed to be accessed by many different kinds of apps, you may legitimately need a broad allowlist. The key risk to avoid is combining a wildcard origin with endpoints that accept bearer tokens — browsers will not send `Authorization` headers with credentialed cross-origin requests unless the origin is explicitly listed, so a wildcard does not grant unintended authenticated access from arbitrary origins.
192
-
193
- ## The DID handshake is open by design
194
-
195
- `POST /auth/challenge` and `POST /auth/token` are unauthenticated: anyone who can reach the server can generate an Ed25519 keypair and obtain a bearer token for the `did:key` it derives. That is the point — the handshake proves _which_ DID is calling, so the owner can grant a DID by name ahead of time instead of handing out a shared secret out of band. It authorizes nothing on its own.
196
-
197
- Two consequences worth being deliberate about:
198
-
199
- - **A grant with no grantee is a grant to the public.** `grant(null, ...)` resolves for any authenticated entity, and with the handshake open that is anyone at all. See [Access Control](./api.md#access-control). Named grants (`grant(<did>, ...)`) are unaffected — those are the vouching mechanism.
200
- - **Rate limiting matters more than it used to.** A stranger's handshake writes a token row with a 7-day expiry. Expired rows are reclaimed, but live ones are bounded only by issuance rate × TTL, so the proxy-level rate limit configured above is what actually caps the table. The reverse-proxy examples in this guide already cover `/auth/*`; if you write your own, do not exempt it.
201
-
202
- Read access is unaffected either way: `GET /records`, `POST /records/query`, `GET /records/:id` and `GET /types` already serve anonymous requests, subject to record-level permissions, and a token changes nothing about what they return.
203
-
204
- ## Public endpoints
205
-
206
- `GET /.well-known/stack` is intentionally public and unauthenticated. It exposes the owner entity ID, configured timezone, and capability list. This information is required by `@haverstack/adapter-api` to bootstrap a client connection. If your stack is private, ensure the endpoint is only reachable by intended clients (e.g. by network policy) rather than by auth-gating it.
207
-
208
- ## Schema Commons seeding
209
-
210
- `SEED_COMMONS_TYPES=true` registers the [Schema Commons](https://github.com/haverstack/core/blob/main/docs/commons/README.md) types (`org.haverstack/note`, `bookmark`, `task`, `contact`, `article`, `place`, `page`, `photo`) from `@haverstack/commons` on every boot, via `defineType()`, which is idempotent by construction — safe to leave on permanently, and safe to flip on for an already-running stack.
211
-
212
- It defaults to **off**. Registering a type is not free: it shows up in `GET /types` and gets cached by every app that talks to this stack, whether or not that app uses it. More importantly, `@haverstack/commons` is Draft status — the [governance doc](https://github.com/haverstack/core/blob/main/docs/commons/README.md) explicitly reserves the right to change a Draft type's definition in place, without a version bump, until there's an install base to break. Opting in is the honest posture for a package with that status; turning it on is a statement that you want this reference server to demonstrate commons interop, not a default every deployer should inherit silently.
package/eslint.config.mjs DELETED
@@ -1,18 +0,0 @@
1
- import js from '@eslint/js';
2
- import tseslint from 'typescript-eslint';
3
- import prettierConfig from 'eslint-config-prettier';
4
-
5
- export default tseslint.config(
6
- js.configs.recommended,
7
- ...tseslint.configs.recommended,
8
- prettierConfig,
9
- {
10
- rules: {
11
- '@typescript-eslint/no-explicit-any': 'warn',
12
- '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
13
- },
14
- },
15
- {
16
- ignores: ['dist/**', 'node_modules/**'],
17
- },
18
- );
@@ -1,13 +0,0 @@
1
- allowBuilds:
2
- esbuild: true
3
- # pnpm 11 waits `minimumReleaseAge` (default 1440 — a day) before it will
4
- # resolve a newly published version, and appends every package it had to
5
- # hold to this list on the install that hits one. Haverstack releases in
6
- # lockstep, so a sync lands the whole scope on its release day and every
7
- # one of them was being written back here, a version at a time.
8
- #
9
- # The scope pattern says once what those entries said one by one: these are
10
- # this project's own packages, published by its own CI, and the cooldown is
11
- # not the check that vets them. Third-party dependencies keep it.
12
- minimumReleaseAgeExclude:
13
- - '@haverstack/*'