@haverstack/server 0.8.0

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 (82) hide show
  1. package/.claude/settings.local.json +22 -0
  2. package/.dockerignore +10 -0
  3. package/.env.example +90 -0
  4. package/.github/pull_request_template.md +23 -0
  5. package/.github/workflows/ci.yml +55 -0
  6. package/.github/workflows/release.yml +99 -0
  7. package/.prettierignore +3 -0
  8. package/.prettierrc +7 -0
  9. package/AGENTS.md +91 -0
  10. package/CONTRIBUTING.md +234 -0
  11. package/Dockerfile +30 -0
  12. package/LICENSE +121 -0
  13. package/README.md +107 -0
  14. package/SECURITY.md +54 -0
  15. package/docs/api.md +277 -0
  16. package/docs/deployment.md +212 -0
  17. package/eslint.config.mjs +18 -0
  18. package/package.json +42 -0
  19. package/pnpm-workspace.yaml +13 -0
  20. package/src/app.ts +86 -0
  21. package/src/config.ts +176 -0
  22. package/src/index.ts +54 -0
  23. package/src/lib/changeStreams.ts +23 -0
  24. package/src/lib/frameGate.ts +42 -0
  25. package/src/lib/json.ts +27 -0
  26. package/src/lib/nonceStore.ts +103 -0
  27. package/src/lib/queryLimit.ts +11 -0
  28. package/src/lib/queryWorker/pool.ts +324 -0
  29. package/src/lib/queryWorker/protocol.ts +39 -0
  30. package/src/lib/queryWorker/worker.ts +51 -0
  31. package/src/lib/resumeBuffer.ts +268 -0
  32. package/src/lib/resumeCursor.ts +48 -0
  33. package/src/lib/tokenSweep.ts +43 -0
  34. package/src/middleware/auth.ts +77 -0
  35. package/src/middleware/errors.ts +73 -0
  36. package/src/routes/attachments.ts +126 -0
  37. package/src/routes/auth.ts +137 -0
  38. package/src/routes/changes.ts +279 -0
  39. package/src/routes/entity.ts +33 -0
  40. package/src/routes/health.ts +8 -0
  41. package/src/routes/records.ts +259 -0
  42. package/src/routes/tokens.ts +85 -0
  43. package/src/routes/types.ts +57 -0
  44. package/src/routes/wellknown.ts +55 -0
  45. package/src/shutdown.ts +56 -0
  46. package/src/stack.ts +96 -0
  47. package/src/types.ts +9 -0
  48. package/src/wireError.ts +21 -0
  49. package/tests/app.test.ts +86 -0
  50. package/tests/changeFeedClient.ts +156 -0
  51. package/tests/config.test.ts +112 -0
  52. package/tests/conformance.test.ts +2322 -0
  53. package/tests/fixtures/blockingWorker.ts +27 -0
  54. package/tests/fixtures/brokenWorker.ts +7 -0
  55. package/tests/lib/changeFeedClient.test.ts +126 -0
  56. package/tests/lib/frameGate.test.ts +93 -0
  57. package/tests/lib/queryLimit.test.ts +33 -0
  58. package/tests/lib/queryWorker.test.ts +60 -0
  59. package/tests/lib/queryWorkerPool.test.ts +129 -0
  60. package/tests/lib/resumeBuffer.test.ts +341 -0
  61. package/tests/lib/resumeCursor.test.ts +46 -0
  62. package/tests/middleware/errors.test.ts +176 -0
  63. package/tests/routes/associations.test.ts +170 -0
  64. package/tests/routes/attachments.test.ts +621 -0
  65. package/tests/routes/auth.test.ts +448 -0
  66. package/tests/routes/changes.test.ts +567 -0
  67. package/tests/routes/contentQuery.test.ts +68 -0
  68. package/tests/routes/entity.test.ts +170 -0
  69. package/tests/routes/groups.test.ts +69 -0
  70. package/tests/routes/records.test.ts +1965 -0
  71. package/tests/routes/relatedToQuery.test.ts +182 -0
  72. package/tests/routes/tokens.test.ts +157 -0
  73. package/tests/routes/tombstones.test.ts +93 -0
  74. package/tests/routes/types.test.ts +129 -0
  75. package/tests/routes/versions.test.ts +177 -0
  76. package/tests/routes/wellknown.test.ts +71 -0
  77. package/tests/setup.ts +154 -0
  78. package/tests/shutdown.test.ts +121 -0
  79. package/tests/stack.test.ts +198 -0
  80. package/tsconfig.build.json +9 -0
  81. package/tsconfig.json +19 -0
  82. package/vitest.config.ts +12 -0
@@ -0,0 +1,22 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(openssl rand *)",
5
+ "Bash(pnpm ls *)",
6
+ "Bash(find / -path \"*haverstack/core*\" -name \"*.js\")",
7
+ "Read(//var/home/jen/Dev/haverstack/core/packages/core/**)",
8
+ "Bash(node -e \"const {generateId} = require\\('./dist/id.js'\\); console.log\\(generateId\\(\\)\\)\")",
9
+ "Bash(node --input-type=module -e \"import {generateId} from '/var/home/jen/Dev/haverstack/core/packages/core/dist/id.js'; console.log\\(generateId\\(\\)\\)\")",
10
+ "Bash(node *)",
11
+ "Bash(echo \"exit: $?\")",
12
+ "Bash(pnpm -v)",
13
+ "Bash(pnpm help *)",
14
+ "Bash(python3 -c ' *)",
15
+ "Bash(gh issue *)",
16
+ "Bash(pnpm typecheck *)",
17
+ "Bash(pnpm test *)",
18
+ "Bash(pnpm lint *)",
19
+ "Bash(pnpm format *)"
20
+ ]
21
+ }
22
+ }
package/.dockerignore ADDED
@@ -0,0 +1,10 @@
1
+ node_modules
2
+ dist
3
+ .git
4
+ .github
5
+ tests
6
+ *.db
7
+ attachments/
8
+ .env
9
+ .env.local
10
+ coverage/
package/.env.example ADDED
@@ -0,0 +1,90 @@
1
+ # Port to listen on (default: 3000)
2
+ PORT=3000
3
+
4
+ # Path to the SQLite database file.
5
+ # On first run (file does not exist), the database is initialized.
6
+ # On subsequent runs, the existing database is opened.
7
+ DB_PATH=./stack.db
8
+
9
+ # Owner entity ID. Required only on first run when the DB is being initialized.
10
+ # Must be a DID (e.g. "did:key:z6Mk..."). Generate one with:
11
+ # node -e "require('@haverstack/core/did').generateDidKeypair().then(k => console.log(k.did))"
12
+ # Keep the corresponding private key — this only prints the public DID.
13
+ ENTITY_ID=
14
+
15
+ # Owner's display name (optional). When set, the server ensures the owner's
16
+ # own _entity profile record exists on every open — safe to leave set
17
+ # permanently. No profile record is created if omitted.
18
+ OWNER_NAME=
19
+
20
+ # Owner's handle (optional). Only used alongside OWNER_NAME.
21
+ OWNER_HANDLE=
22
+
23
+ # IANA timezone string (optional). Used only on first run. No default — an
24
+ # absent timezone stays absent (omitted from discovery), rather than
25
+ # asserting a timezone the stack was never actually given.
26
+ TIMEZONE=
27
+
28
+ # Master bearer token for the stack owner. This is a break-glass credential:
29
+ # the DID challenge-response handshake lets a client holding the owner's key
30
+ # authenticate without it, but OWNER_TOKEN remains required to bootstrap the
31
+ # server and manage other tokens via POST /tokens. Keep this secret and
32
+ # treat it like a root password. All other tokens are issued via the API
33
+ # and stored in the DB.
34
+ # Generate with `openssl rand -hex 32`
35
+ OWNER_TOKEN=
36
+
37
+ # Allowed CORS origins. Default: empty (no cross-origin access).
38
+ # Set to * to allow all origins, or a comma-separated list:
39
+ # CORS_ORIGINS=https://app.example.com,https://admin.example.com
40
+ # See docs/deployment.md for guidance.
41
+ CORS_ORIGINS=
42
+
43
+ # Canonical public base URL of this server. Required: it's the origin the
44
+ # DID challenge-response handshake (POST /auth/challenge, POST /auth/token)
45
+ # signs and verifies against, and MUST be this server's own public origin —
46
+ # never auto-detected from a request header, which a client controls.
47
+ # Example: https://stack.example.com
48
+ BASE_URL=
49
+
50
+ # Maximum upload size for attachments in bytes (default: 52428800 = 50 MB).
51
+ MAX_ATTACHMENT_BYTES=52428800
52
+
53
+ # Maximum size of a JSON request body in bytes — record bodies, PATCH merge
54
+ # patches, and every other JSON-accepting route (default: 1048576 = 1 MB).
55
+ # Applies globally, except POST /attachments, which is bounded by
56
+ # MAX_ATTACHMENT_BYTES instead. Declared to clients via maxContentBytes in
57
+ # GET /.well-known/stack.
58
+ MAX_CONTENT_BYTES=1048576
59
+
60
+ # Deadline in milliseconds for a GET /records or POST /records/query search
61
+ # (default: 10000 = 10s). Bounds execution only — the clock starts when the
62
+ # search reaches a worker, so waiting for a free worker makes a request slow,
63
+ # never failed. A query still executing past it is abandoned with 503 (code
64
+ # "timeout"). See docs/deployment.md#bounding-query-cost.
65
+ QUERY_TIMEOUT_MS=10000
66
+
67
+ # Number of worker threads a slow search can occupy without blocking other
68
+ # requests (default: 2, max 32). See docs/deployment.md#bounding-query-cost.
69
+ QUERY_WORKER_POOL_SIZE=2
70
+
71
+ # How many searches may queue for a free worker before the server sheds load
72
+ # with 503 (code "timeout") rather than queueing further (default: 64). This
73
+ # is what bounds the waiting that QUERY_TIMEOUT_MS deliberately does not.
74
+ # See docs/deployment.md#bounding-query-cost.
75
+ QUERY_QUEUE_LIMIT=64
76
+
77
+ # Register the Schema Commons types (org.haverstack/note, bookmark, task,
78
+ # contact, article, place, page, photo) from @haverstack/commons on startup
79
+ # (default: false). Off by default because the package is Draft status and
80
+ # registering types is not free — they show up in GET /types and in every
81
+ # app's type cache. See docs/deployment.md#schema-commons-seeding.
82
+ SEED_COMMONS_TYPES=false
83
+
84
+ # How long shutdown waits (in milliseconds) for open/keep-alive connections
85
+ # to drain on server.close() before forcing them closed and finishing the
86
+ # flush/close sequence anyway (default: 10000 = 10s).
87
+ SHUTDOWN_TIMEOUT_MS=10000
88
+
89
+ # See docs/deployment.md for production deployment guidance (TLS, CORS,
90
+ # rate limiting, and public endpoint exposure).
@@ -0,0 +1,23 @@
1
+ ## Summary
2
+
3
+ <!-- What changed, and why. Link any issue this closes. -->
4
+
5
+ ## Docs
6
+
7
+ <!-- Does this change observable behavior — a route, status code, error code,
8
+ auth rule, or environment variable? If so, link the docs/ sections updated
9
+ in this PR (docs/api.md, docs/deployment.md, README's config table,
10
+ .env.example). If not, say "No behavior change."
11
+
12
+ If the change is to the wire contract itself rather than to this server's
13
+ implementation of it, the spec and conformance fixtures live in
14
+ haverstack/core — link that PR here. -->
15
+
16
+ ## Verification
17
+
18
+ <!-- What you ran, and anything you checked by hand.
19
+ pnpm run format:check && pnpm run lint && pnpm typecheck && pnpm test -->
20
+
21
+ ## Notes for reviewers
22
+
23
+ <!-- Optional: trade-offs, alternatives considered, follow-up work worth filing. -->
@@ -0,0 +1,55 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ format:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: pnpm/action-setup@v4
14
+ - uses: actions/setup-node@v4
15
+ with:
16
+ node-version: 22
17
+ cache: pnpm
18
+ - run: pnpm install --frozen-lockfile
19
+ - run: pnpm run format:check
20
+
21
+ lint:
22
+ runs-on: ubuntu-latest
23
+ steps:
24
+ - uses: actions/checkout@v4
25
+ - uses: pnpm/action-setup@v4
26
+ - uses: actions/setup-node@v4
27
+ with:
28
+ node-version: 22
29
+ cache: pnpm
30
+ - run: pnpm install --frozen-lockfile
31
+ - run: pnpm run lint
32
+
33
+ typecheck:
34
+ runs-on: ubuntu-latest
35
+ steps:
36
+ - uses: actions/checkout@v4
37
+ - uses: pnpm/action-setup@v4
38
+ - uses: actions/setup-node@v4
39
+ with:
40
+ node-version: 22
41
+ cache: pnpm
42
+ - run: pnpm install --frozen-lockfile
43
+ - run: pnpm run typecheck
44
+
45
+ test:
46
+ runs-on: ubuntu-latest
47
+ steps:
48
+ - uses: actions/checkout@v4
49
+ - uses: pnpm/action-setup@v4
50
+ - uses: actions/setup-node@v4
51
+ with:
52
+ node-version: 22
53
+ cache: pnpm
54
+ - run: pnpm install --frozen-lockfile
55
+ - run: pnpm test
@@ -0,0 +1,99 @@
1
+ name: Release
2
+
3
+ # Runs on every push to main except the version-bump commit this workflow
4
+ # produces, preventing an infinite loop.
5
+ on:
6
+ push:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ release:
11
+ if: "!startsWith(github.event.head_commit.message, 'chore: release')"
12
+ runs-on: ubuntu-latest
13
+ permissions:
14
+ contents: write # push version bump + tag, create release
15
+ packages: write # push Docker image to ghcr.io
16
+
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+ with:
20
+ fetch-depth: 0 # full history so we can inspect commits since last tag
21
+
22
+ - uses: pnpm/action-setup@v4
23
+
24
+ - uses: actions/setup-node@v4
25
+ with:
26
+ node-version: 22
27
+ cache: pnpm
28
+
29
+ - run: pnpm install --frozen-lockfile
30
+
31
+ # Inspect commit messages since the last tag (or all commits if no tag
32
+ # yet) and pick a semver bump level using conventional commits:
33
+ # BREAKING CHANGE or ! suffix → major
34
+ # feat: → minor
35
+ # anything else → patch
36
+ - name: Determine version bump
37
+ id: semver
38
+ run: |
39
+ LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
40
+
41
+ if [ -z "$LATEST_TAG" ]; then
42
+ COMMITS=$(git log --pretty=format:"%s%n%b")
43
+ else
44
+ COMMITS=$(git log "${LATEST_TAG}..HEAD" --pretty=format:"%s%n%b")
45
+ fi
46
+
47
+ BUMP="patch"
48
+ while IFS= read -r line; do
49
+ if echo "$line" | grep -qE "BREAKING[- ]CHANGE|^[a-z]+(\(.+\))?!:"; then
50
+ BUMP="major"
51
+ break
52
+ elif echo "$line" | grep -qE "^feat(\(.+\))?:"; then
53
+ if [ "$BUMP" = "patch" ]; then BUMP="minor"; fi
54
+ fi
55
+ done <<< "$COMMITS"
56
+
57
+ echo "bump=$BUMP" >> "$GITHUB_OUTPUT"
58
+
59
+ - name: Bump version in package.json
60
+ id: version
61
+ run: |
62
+ NEW_VERSION=$(npm version ${{ steps.semver.outputs.bump }} --no-git-tag-version)
63
+ echo "new_version=$NEW_VERSION" >> "$GITHUB_OUTPUT"
64
+
65
+ - name: Log in to GitHub Container Registry
66
+ uses: docker/login-action@v3
67
+ with:
68
+ registry: ghcr.io
69
+ username: ${{ github.actor }}
70
+ password: ${{ secrets.GITHUB_TOKEN }}
71
+
72
+ - name: Build and push Docker image
73
+ uses: docker/build-push-action@v6
74
+ with:
75
+ context: .
76
+ push: true
77
+ tags: |
78
+ ghcr.io/${{ github.repository }}:${{ steps.version.outputs.new_version }}
79
+ ghcr.io/${{ github.repository }}:latest
80
+
81
+ # Commit the package.json bump, tag it, and push. Requires that the
82
+ # github-actions bot is allowed to push to main (configure via branch
83
+ # protection → "Allow specific actors to bypass required pull requests").
84
+ - name: Commit, tag, and push
85
+ run: |
86
+ git config user.name "github-actions[bot]"
87
+ git config user.email "github-actions[bot]@users.noreply.github.com"
88
+ git add package.json
89
+ git commit -m "chore: release ${{ steps.version.outputs.new_version }}"
90
+ git tag -a "${{ steps.version.outputs.new_version }}" -m "Release ${{ steps.version.outputs.new_version }}"
91
+ git push origin main --follow-tags
92
+
93
+ - name: Create GitHub release
94
+ env:
95
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
96
+ run: |
97
+ gh release create "${{ steps.version.outputs.new_version }}" \
98
+ --title "${{ steps.version.outputs.new_version }}" \
99
+ --generate-notes
@@ -0,0 +1,3 @@
1
+ dist/
2
+ node_modules/
3
+ pnpm-lock.yaml
package/.prettierrc ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "semi": true,
3
+ "singleQuote": true,
4
+ "trailingComma": "all",
5
+ "printWidth": 100,
6
+ "tabWidth": 2
7
+ }
package/AGENTS.md ADDED
@@ -0,0 +1,91 @@
1
+ # Agent Guide
2
+
3
+ Instructions for AI coding agents working in this repository. Humans should read [CONTRIBUTING.md](./CONTRIBUTING.md), which explains the reasoning behind everything here; this file is the short, checkable form.
4
+
5
+ This is the reference server for [Haverstack](https://github.com/haverstack/core). The contract it implements lives in core, and most of the rules below follow from that.
6
+
7
+ ## Before you push
8
+
9
+ Run all four. They mirror `.github/workflows/ci.yml`, so a clean local run means a green PR.
10
+
11
+ ```sh
12
+ pnpm install
13
+ pnpm run format:check # pnpm run format to fix in place
14
+ pnpm run lint
15
+ pnpm typecheck
16
+ pnpm test
17
+ ```
18
+
19
+ No build step is needed first — `typecheck` runs against `src/`, and `@haverstack/*` resolve from `node_modules`.
20
+
21
+ Report results honestly. If something fails, say so with the output rather than describing the change as complete.
22
+
23
+ A change that depends on unpublished core changes cannot pass CI here, which installs from the registry. Land the core release first; don't edit a version range to point at a local checkout.
24
+
25
+ ## Which repository
26
+
27
+ Two repositories, one contract. Filing or fixing in the wrong one leaves the problem standing everywhere else.
28
+
29
+ - **Here**: routes, status codes, config and environment variables, the Docker image, deployment guidance, the change feed, the query worker pool, token issuance and storage.
30
+ - **[core](https://github.com/haverstack/core)**: the data model, permission semantics, identity and DIDs, the wire format and error taxonomy, the conformance fixtures, and the `@haverstack/*` packages.
31
+
32
+ **When it isn't clear which side a bug or a security finding sits on, raise it in core** and say which server behavior prompted it. The wire contract is what multiple implementations must agree on, so an ambiguous defect is more usefully argued there, and a spec section or fixture is the durable form of the answer. For a suspected vulnerability use a private advisory, not an issue — see [SECURITY.md](./SECURITY.md).
33
+
34
+ ## Writing comments
35
+
36
+ These rules are enforced in review. Applying them to code you touch is expected; a sweeping unrelated comment refactor is not.
37
+
38
+ - **Answer _why_, not _how_.** The how should be readable in the code. Don't narrate the next few lines.
39
+ - **Four or five lines is the maximum**, not the target. Longer explanations link out: `See docs/deployment.md § Bounding query cost.`
40
+ - **File-top module comments are exempt** — they may be as long as needed to say what the module is and why it exists.
41
+ - **Never cite GitHub issues** (`#44`, `core#52`) in code comments. They belong in commit messages and PR descriptions only.
42
+ - **Never describe previous implementations.** No "the old path", "this used to…", "before this fix". State the current invariant. This applies to test comments too: say what the test pins, not what bug prompted it.
43
+
44
+ ## The spec is the source of truth, and it lives in core
45
+
46
+ Design lives in core's [`docs/spec/`](https://github.com/haverstack/core/tree/main/docs/spec): data model, identity, access control, versioning, attachments, adapters, wire format. This server implements it and does not redefine it.
47
+
48
+ - A change to the **wire contract** is a core PR first — spec, then `@haverstack/conformance-fixtures`, then the implementation here. Changing what this server sends so a fixture passes, without the fixture moving, is divergence.
49
+ - A change to **this server's** observable behavior (a route, status code, error code, auth rule, env var) updates this repo's docs in the same change: `docs/api.md`, `docs/deployment.md`, the README config table, `.env.example`. A new env var touches at least three of those.
50
+ - Section names are load-bearing — comments reference them as `docs/spec/<file>.md § Section`. Verify a section exists before linking to it.
51
+ - **Prose states the system as it is, never how it got there.** "now runs on workers", "as of 0.6", "once #88 lands" date the document and describe a system the reader can't see.
52
+
53
+ ## No backward compatibility
54
+
55
+ There is **no install base**. Nothing depends on this server's current behavior.
56
+
57
+ - Don't add compatibility shims, deprecation paths, or handler branches for request shapes this server never accepted. Delete rather than deprecate.
58
+ - Don't use the word "legacy". A third-party implementation of the wire protocol is _foreign_, not legacy.
59
+ - Genuinely foreign input — an optional field a client omits, a stack file from another adapter — is current-behavior handling and stays.
60
+ - **Stored data is the exception.** A change to what lives under `DB_PATH` (database, token store, nonce store, attachments) meets running deployments with no migration path. Flag it explicitly in the PR.
61
+
62
+ ## Architecture rules
63
+
64
+ - **The server is a thin, enforcing layer over `Stack`.** Validation, ID rules, and permission logic belong to `@haverstack/core`. Don't reimplement a model invariant in a route handler.
65
+ - **Routes are sub-apps.** One file per URL prefix in `src/routes/`, mounted in `src/app.ts`. Handlers parse, call `Stack`, serialize. Mechanism goes in `src/lib/`, cross-cutting concerns in `src/middleware/`.
66
+ - **Errors take one of two paths, never a hand-rolled body.** A thrown core `StackError` is mapped by `errorMiddleware` via `serializeError()`. Failures with no core class use `wireError()`. Never `c.json()` an error directly; never let an engine error or a stack trace reach the client.
67
+ - **Compare secrets with `safeCompare()`** from `src/middleware/auth.ts`. Every owner-token comparison goes through it.
68
+ - **Anything that can scan the whole store runs on `QueryWorkerPool`**, because `node:sqlite` is synchronous and uncancellable. Point lookups and writes stay on the request thread.
69
+ - **Config is parsed once** in `src/config.ts`; downstream code takes the `Config` object. New variables land in `src/config.ts`, `.env.example`, and the README table together, with a default that is safe for a public deployment.
70
+ - **Register cleanup in `src/shutdown.ts`** for anything holding a file handle, a thread, or a long-lived response.
71
+
72
+ ## Tests
73
+
74
+ Vitest, in `tests/`, mirroring `src/`. Use `tests/setup.ts` rather than building a server by hand:
75
+
76
+ - `buildTestApp()` — real Hono app over a real SQLite database in an isolated temp dir, plus `cleanup()`. Always `await cleanup()`; a leaked worker pool keeps the run alive.
77
+ - `req(app, method, path, opts)` — returns `{ status, data }`.
78
+ - `TEST_BASE_URL` matches `@haverstack/conformance-fixtures`' `AUTH_FIXTURE_ORIGIN` — the auth fixtures carry real signatures over that exact origin. Don't change it.
79
+
80
+ `tests/conformance.test.ts` is an acceptance gate: every fixture is dispatched or listed in a `SKIPPED` set with a reason, and each block's coverage test fails when core adds or renames one. Handle the new fixture or skip it with a reason — never loosen the coverage check.
81
+
82
+ Name tests after the invariant they pin, not the defect that prompted them.
83
+
84
+ ## Commits and pull requests
85
+
86
+ - Conventional Commits, and they drive the release: `feat:` → minor, `feat!:` or a `BREAKING CHANGE` footer → major, everything else (`fix:`, `chore:`, `docs:`, `refactor:`, `test:`, `perf:`) → patch. Scope optional: `fix(attachments): …`. Getting the type wrong produces the wrong version bump silently — when in doubt, `fix:`.
87
+ - Imperative subject; the body explains why. On a squash merge, the squashed subject is what the release workflow reads.
88
+ - Issue references are welcome in commit messages and PR bodies.
89
+ - One concern per PR. Fill in `.github/pull_request_template.md`, including the Docs section.
90
+ - Never hand-edit `version` in `package.json` — `.github/workflows/release.yml` owns it, and merging to `main` is the act of releasing.
91
+ - Don't open a PR unless asked to.
@@ -0,0 +1,234 @@
1
+ # Contributing
2
+
3
+ Thanks for working on this. This document covers how to get set up, what to run before you push, where to file what, and the conventions the codebase already follows — the ones that aren't obvious from reading a single file.
4
+
5
+ This is the reference server. The contract it implements — the data model, the permission rules, the wire format — lives in [`haverstack/core`](https://github.com/haverstack/core), and that split shapes most of what follows.
6
+
7
+ ---
8
+
9
+ ## Setup
10
+
11
+ Node 22.9+ and pnpm 11; CI runs Node 22 and the `packageManager` pin.
12
+
13
+ ```sh
14
+ cp .env.example .env
15
+ # Edit .env — set OWNER_TOKEN, ENTITY_ID, and BASE_URL at minimum
16
+ pnpm install
17
+ pnpm dev
18
+ ```
19
+
20
+ `pnpm dev` starts with live reload on `PORT` (default `3000`) and initializes a SQLite database at `DB_PATH` on first run.
21
+
22
+ ---
23
+
24
+ ## Before you push
25
+
26
+ Run all four:
27
+
28
+ ```sh
29
+ pnpm run format:check # or: pnpm run format, to fix in place
30
+ pnpm run lint
31
+ pnpm typecheck
32
+ pnpm test
33
+ ```
34
+
35
+ These mirror `.github/workflows/ci.yml` exactly, so a clean local run means a green PR. Failures block merge.
36
+
37
+ Unlike core, nothing here needs a build first — `pnpm typecheck` runs against `src/` directly, and the `@haverstack/*` packages resolve from `node_modules` as published builds.
38
+
39
+ **Testing against unreleased core changes.** When a change here depends on something not yet published from core, don't hand-edit the version range: link the workspace (`pnpm link --global` in the core package, or a `pnpm.overrides` entry you drop before pushing) and land the core release first. A PR here that only passes against a local core checkout will fail CI, which installs from the registry.
40
+
41
+ ---
42
+
43
+ ## Where to file an issue
44
+
45
+ Two repositories, one contract. Filing in the wrong one usually means a fix that lands in one place and quietly leaves the problem standing everywhere else.
46
+
47
+ - **Here** — a route's behavior, a status code this server returns, config and environment variables, the Docker image, deployment guidance, the change feed, the query worker pool, token issuance and storage.
48
+ - **[`haverstack/core`](https://github.com/haverstack/core/issues)** — the data model, permission semantics, identity and DID handling, the wire format and its error taxonomy, the conformance fixtures, and anything in `@haverstack/core`, `@haverstack/adapter-local`, or `@haverstack/wire-types`.
49
+
50
+ **When it isn't clear which side something sits on, file it in core** and say which server behavior prompted it. The wire contract is the thing multiple implementations have to agree on, so an ambiguous bug is more usefully argued there — and a fixture or spec section is the durable form of the answer. Moving an issue afterwards is cheap; discovering six months later that a protocol flaw was filed as a server bug is not.
51
+
52
+ For a suspected **security** issue, don't open an issue at all — see [SECURITY.md](./SECURITY.md), which draws the same line for advisories.
53
+
54
+ ---
55
+
56
+ ## Commit style
57
+
58
+ This project uses [Conventional Commits](https://www.conventionalcommits.org/). Every commit message must start with a type prefix. The release workflow reads commit messages since the last tag to determine the version bump automatically:
59
+
60
+ | Commit type | Example | Version bump |
61
+ | ------------------------------------------------------------------------ | ----------------------------------- | ------------ |
62
+ | `feat:` | `feat: add token expiry` | minor |
63
+ | `feat!:` or `BREAKING CHANGE` footer | `feat!: rename /entity to /owner` | major |
64
+ | Anything else (`fix:`, `chore:`, `docs:`, `refactor:`, `test:`, `perf:`) | `fix: return 404 on missing record` | patch |
65
+
66
+ A scope is optional: `fix(attachments): reject empty filename`.
67
+
68
+ Getting the type wrong produces the wrong version bump with no warning, so when in doubt use `fix:` for patches and `feat:` only for genuinely new capabilities.
69
+
70
+ Write the subject in the imperative, and use the body to explain _why_ the change was made, not what the diff already shows.
71
+
72
+ Issue references (`#123`, `core#52`) belong in commit messages, PR titles, and PR bodies. They do **not** belong in code comments — see [Comments](#comments).
73
+
74
+ ---
75
+
76
+ ## Pull requests
77
+
78
+ - One concern per PR. Split unrelated changes.
79
+ - The PR description should explain _why_, not just _what_ — the diff already shows what changed.
80
+ - Link to any relevant issue with `Closes #N`.
81
+ - Keep PRs small enough to review in one sitting; large refactors are fine but flag them early.
82
+ - Fill in [`.github/pull_request_template.md`](./.github/pull_request_template.md), including the Docs section — answer it even when the answer is "no behavior change."
83
+ - Work on a branch; CI must be green before merge.
84
+
85
+ If you're an AI coding agent, [AGENTS.md](./AGENTS.md) is the condensed, checkable form of this document.
86
+
87
+ ---
88
+
89
+ ## Releasing
90
+
91
+ Releases are automatic. `.github/workflows/release.yml` runs on every push to `main` (skipping its own `chore: release` commit), reads the conventional-commit prefixes since the last tag to pick a bump, then:
92
+
93
+ 1. bumps `version` in `package.json`,
94
+ 2. builds and pushes `ghcr.io/haverstack/server` at that version and `:latest`,
95
+ 3. commits the bump, tags it, pushes to `main`, and cuts a GitHub release with generated notes.
96
+
97
+ Nobody edits `version` by hand and nobody builds the image by hand. Two things are load-bearing: the workflow needs `contents: write` and `packages: write`, and the `github-actions[bot]` push to `main` requires a branch-protection bypass (Settings → Branches → _Allow specified actors to bypass required pull requests_). Merging to `main` is the act of releasing, which is why `main` is protected.
98
+
99
+ The version bump is derived from commit subjects, so a squash merge means the squashed subject is what counts — check it before confirming the merge.
100
+
101
+ ---
102
+
103
+ ## Comments
104
+
105
+ The convention this codebase follows, in order of how often it comes up:
106
+
107
+ **Comments answer _why_. The _how_ should be visible in the code itself.** If a comment is narrating what the next few lines do, delete it and let the code speak. If the code is hard enough to follow that it needs narration, that's usually a signal to simplify the code.
108
+
109
+ **Keep them short — four or five lines is the extreme case, not the target.** Anything needing more explanation than that links out to the docs or to core's spec instead of carrying the argument inline:
110
+
111
+ ```ts
112
+ // A search past QUERY_TIMEOUT_MS is abandoned and its worker replaced: a
113
+ // stuck query costs one pool slot rather than every request. See
114
+ // docs/deployment.md § Bounding query cost.
115
+ ```
116
+
117
+ **File-top module comments are exempt.** A comment at the top of a file may be as long as it needs to be to explain what the module does and why it exists.
118
+
119
+ **No GitHub issue references.** An issue captured something that needed to change, and the change is now reflected in the code and the docs. A `(#44)` in a comment adds nothing a reader can act on, and it makes the source unreadable anywhere the issue tracker isn't at hand. If the rationale matters, state it or link to the section that holds it.
120
+
121
+ **No references to previous implementations.** Comments describe the code as it is now. Phrases like "the old single-threaded path", "this used to run on the request thread", or "before this fix" date the comment and describe code that no longer exists. State the invariant instead:
122
+
123
+ ```ts
124
+ // Don't:
125
+ // Auth used to fall through to anonymous on a malformed header.
126
+
127
+ // Do:
128
+ // A present-but-unparseable Authorization header is a 401, never a silent
129
+ // downgrade to anonymous — a client that sent *something* meant to.
130
+ ```
131
+
132
+ The same applies to tests. A regression test's name and comment should say what invariant it pins, not what bug prompted it.
133
+
134
+ ---
135
+
136
+ ## The spec is the source of truth, and it lives in core
137
+
138
+ Design decisions — the data model, identity, access control, versioning, attachments, the wire format — live in core's [`docs/spec/`](https://github.com/haverstack/core/tree/main/docs/spec). This server implements them; it does not get to redefine them.
139
+
140
+ That means a change to the wire contract is a core PR first: spec section, then `@haverstack/conformance-fixtures`, then the implementation here. A server-side "fix" that makes a fixture pass by changing what this server sends, without the fixture moving, is this server diverging from the contract — which `tests/conformance.test.ts` exists to catch.
141
+
142
+ This repository's own docs cover what is genuinely the server's:
143
+
144
+ - [`docs/api.md`](./docs/api.md) — routes, auth handshake, query parameters, permissions as this server exposes them
145
+ - [`docs/deployment.md`](./docs/deployment.md) — TLS, proxies, CORS, body limits, query cost, single-writer topology
146
+ - [`README.md`](./README.md) — the configuration table
147
+ - [`.env.example`](./.env.example) — every variable, with its default
148
+
149
+ A change to observable behavior updates the relevant one **in the same PR**. A new environment variable touches three of them (`.env.example`, the README table, and usually a deployment section); they drift the moment they're updated separately.
150
+
151
+ When you link to a core spec section from a comment, use the `docs/spec/<file>.md § Section` form and make sure the section actually exists.
152
+
153
+ **Prose describes the system as it is, not how it got there.** The no-references-to-previous-implementations rule above applies to documentation, and bites harder there: an operator reading the deployment guide is deciding what to configure, and "this now runs on workers", "as of 0.6 the timeout also covers queueing", or "once #88 lands" describe a system they cannot see. They also rot silently — the sentence stays true-sounding long after it stopped being news.
154
+
155
+ ---
156
+
157
+ ## No backward compatibility yet
158
+
159
+ **There is no install base.** Nothing depends on this server's current behavior, so there is nothing to preserve. Prefer changing things in place over carrying compatibility shims:
160
+
161
+ - No accepting an older request shape "just in case" — no such client exists.
162
+ - No deprecation cycles; delete the old route.
163
+ - No `legacy` branches in handlers, and no `legacy` in prose. A third-party implementation of the wire protocol is _foreign_, not legacy — that distinction is real and worth keeping.
164
+
165
+ Note that this is about _our_ history, not about inputs we genuinely receive. Handling a client that omits an optional field, or a stack database written by another adapter, is current-behavior compatibility and stays.
166
+
167
+ One thing does not get this freedom: **stored data**. A change to what lives in `DB_PATH` — the database, the token store, the nonce store, the attachments directory — meets a running deployment with data in it and no migration path this repository provides. Say so explicitly in the PR and in the release notes.
168
+
169
+ ---
170
+
171
+ ## Architecture conventions
172
+
173
+ **The server is a thin, enforcing layer over `Stack`.** Validation, ID rules, permission logic, and the record model belong to `@haverstack/core` and are inherited, not reimplemented. What this repository owns is everything the library can't see: HTTP shape, authentication, config, transport limits, and process lifecycle. A permission check written inside a route handler is a bug waiting to diverge from the model — the exceptions are the ones the wire format assigns to the server, like deciding what an anonymous caller may reach.
174
+
175
+ **One module per concern**, following the layout in [Where things live](#where-things-live):
176
+
177
+ - `src/routes/*` — one file per URL prefix, each exporting a `Hono` sub-app mounted in `src/app.ts`. Handlers parse, call `Stack`, and serialize; anything longer than that usually belongs in `src/lib/`.
178
+ - `src/middleware/*` — cross-cutting: `auth.ts` resolves a credential to a principal/subject pair (or anonymous), `errors.ts` maps thrown `StackError`s to wire responses.
179
+ - `src/lib/*` — mechanism with no HTTP in it: the query worker pool, change streams, resume cursors, the nonce store, token sweeping.
180
+
181
+ **Errors go out through one of two paths, never by hand.** A thrown core `StackError` is mapped by `errorMiddleware` via `serializeError()`, which is what keeps the [error taxonomy](https://github.com/haverstack/core/blob/main/docs/spec/wire-format.md) intact. Failures with no core class — a missing bearer token, a `404` for an unrouted path — use `wireError()` so the `{ error: { code, message } }` shape stays uniform. Never `c.json()` an error body directly, and never let an engine error reach the client as itself.
182
+
183
+ **Compare credentials in constant time.** `safeCompare()` in `src/middleware/auth.ts` is the only way to check a presented secret against a configured one; every owner-token comparison goes through it, including the ones that re-derive "is this the owner" outside the middleware.
184
+
185
+ **Anything that can scan the whole store runs on the worker pool.** `GET /records` and `POST /records/query` go through `QueryWorkerPool` because `node:sqlite` is synchronous and uncancellable in-process. Point lookups and writes stay on the request thread — they're index-bound. A new route that can trigger a full scan belongs on the pool; see [Deployment § Bounding query cost](./docs/deployment.md#bounding-query-cost).
186
+
187
+ **Config is parsed once, at the edge.** `src/config.ts` reads and validates the environment into a `Config` object, and everything downstream takes that object rather than reaching for `process.env`. The one exception is `src/index.ts`, which reads `LOG_LEVEL` and `NODE_ENV` to build the logger before config parsing runs — so that a config error is logged rather than thrown into the void. A new variable is added to `src/config.ts`, `.env.example`, and the README table together, and gets a default that is safe for a public deployment, or is required outright.
188
+
189
+ **Shutdown is part of the contract.** `src/shutdown.ts` drains open connections within `SHUTDOWN_TIMEOUT_MS` and then closes the stack, token store, nonce store, and worker pool. Anything new holding a file handle, a thread, or a long-lived response (the change feed does all three) registers its cleanup there.
190
+
191
+ ---
192
+
193
+ ## Testing conventions
194
+
195
+ Tests use [Vitest](https://vitest.dev/) and live in `tests/`, mirroring `src/`.
196
+
197
+ `tests/setup.ts` is the shared harness. Use it rather than building a server by hand:
198
+
199
+ - **`buildTestApp()`** — a real `Hono` app over a real SQLite database in an isolated temp directory, plus a `cleanup()` that closes everything and removes it. Tests exercise the actual HTTP surface, not a mocked one.
200
+ - **`req(app, method, path, opts)`** — fires a request and returns `{ status, data }`.
201
+ - **`TEST_ENTITY_ID` / `OTHER_ENTITY_ID` / `TEST_TOKEN` / `TEST_BASE_URL`** — the fixed identities. `TEST_BASE_URL` matches `@haverstack/conformance-fixtures`' `AUTH_FIXTURE_ORIGIN`, because the auth fixtures carry real signatures over that exact origin; changing it breaks the handshake fixtures.
202
+
203
+ Each test gets its own temp directory so parallel runs don't collide over the database's sibling `attachments/` folder. Always `await cleanup()` — a leaked worker pool keeps the run alive.
204
+
205
+ **`tests/conformance.test.ts` is an acceptance gate, not a snapshot.** It runs `@haverstack/conformance-fixtures` against the real HTTP surface, and every fixture in each imported array is either dispatched or named in that block's `SKIPPED` set with a reason. Each block's final coverage test fails when core adds, removes, or renames a fixture this file hasn't been told about. When that happens, the fix is to handle the new fixture or skip it with a reason — never to loosen the coverage check.
206
+
207
+ Name tests after the behavior they pin, not the defect that prompted them. `'a revoked token is rejected on a route that allows anonymous reads'` survives refactoring; `'regression for #44'` doesn't.
208
+
209
+ ---
210
+
211
+ ## Where things live
212
+
213
+ ```
214
+ docs/
215
+ api.md # Routes, auth, query parameters, permissions
216
+ deployment.md # TLS, proxies, CORS, limits, query cost, topology
217
+ src/
218
+ index.ts # Entry point: config, stack, server, signals
219
+ app.ts # Hono app: middleware order and route mounting
220
+ config.ts # Environment parsing and validation
221
+ stack.ts # StackContext: adapter, stack, tokens, nonces, workers
222
+ shutdown.ts # Connection draining and orderly resource close
223
+ wireError.ts # `{ error: { code, message } }` for non-StackError failures
224
+ types.ts # Hono environment bindings
225
+ middleware/
226
+ auth.ts # Bearer resolution, principal/subject, constant-time compare
227
+ errors.ts # StackError → wire response mapping
228
+ routes/ # One sub-app per URL prefix
229
+ lib/ # Mechanism: query workers, change streams, cursors, nonces
230
+ tests/
231
+ setup.ts # buildTestApp(), req(), shared identities
232
+ conformance.test.ts # @haverstack/conformance-fixtures acceptance gate
233
+ routes/ lib/ # Mirrors src/
234
+ ```