@pikku/skills 0.12.4 → 0.12.8

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 (65) hide show
  1. package/dist/skills.gen.js +1 -1
  2. package/package.json +1 -1
  3. package/skills/pikku-addon/SKILL.md +74 -33
  4. package/skills/pikku-ai-agent/SKILL.md +197 -105
  5. package/skills/pikku-ai-vercel/SKILL.md +57 -18
  6. package/skills/pikku-ai-voice/SKILL.md +126 -52
  7. package/skills/pikku-audit/SKILL.md +35 -13
  8. package/skills/pikku-aws/SKILL.md +66 -16
  9. package/skills/pikku-backblaze/SKILL.md +44 -11
  10. package/skills/pikku-better-auth/SKILL.md +45 -10
  11. package/skills/pikku-cli/SKILL.md +67 -18
  12. package/skills/pikku-cli/references/complete-example.md +2 -0
  13. package/skills/pikku-concepts/SKILL.md +75 -10
  14. package/skills/pikku-config/SKILL.md +56 -14
  15. package/skills/pikku-cron/SKILL.md +13 -6
  16. package/skills/pikku-deploy-azure/SKILL.md +83 -28
  17. package/skills/pikku-deploy-cloudflare/SKILL.md +79 -37
  18. package/skills/pikku-deploy-express/SKILL.md +40 -4
  19. package/skills/pikku-deploy-fastify/SKILL.md +22 -1
  20. package/skills/pikku-deploy-lambda/SKILL.md +99 -19
  21. package/skills/pikku-deploy-nextjs/SKILL.md +49 -5
  22. package/skills/pikku-deploy-uws/SKILL.md +54 -1
  23. package/skills/pikku-deps/SKILL.md +29 -8
  24. package/skills/pikku-emails/SKILL.md +36 -5
  25. package/skills/pikku-fabric/SKILL.md +27 -2
  26. package/skills/pikku-fabric-debug/SKILL.md +5 -1
  27. package/skills/pikku-feature/SKILL.md +6 -1
  28. package/skills/pikku-gateway-slack/SKILL.md +72 -11
  29. package/skills/pikku-http/SKILL.md +18 -5
  30. package/skills/pikku-http/references/http-options.md +10 -5
  31. package/skills/pikku-i18n/SKILL.md +18 -7
  32. package/skills/pikku-info/SKILL.md +18 -8
  33. package/skills/pikku-jose/SKILL.md +35 -6
  34. package/skills/pikku-knowledge/SKILL.md +50 -7
  35. package/skills/pikku-kysely/SKILL.md +78 -15
  36. package/skills/pikku-machine-auth/SKILL.md +36 -1
  37. package/skills/pikku-mcp/SKILL.md +159 -149
  38. package/skills/pikku-middleware/SKILL.md +17 -5
  39. package/skills/pikku-mongodb/SKILL.md +10 -2
  40. package/skills/pikku-n8n-import/SKILL.md +14 -6
  41. package/skills/pikku-permissions/SKILL.md +102 -22
  42. package/skills/pikku-pino/SKILL.md +12 -4
  43. package/skills/pikku-product-second-opinion/SKILL.md +3 -3
  44. package/skills/pikku-queue/SKILL.md +45 -16
  45. package/skills/pikku-react/SKILL.md +41 -14
  46. package/skills/pikku-react-query/SKILL.md +14 -10
  47. package/skills/pikku-realtime/SKILL.md +44 -22
  48. package/skills/pikku-redis/SKILL.md +12 -3
  49. package/skills/pikku-rpc/SKILL.md +23 -12
  50. package/skills/pikku-rtl/SKILL.md +21 -17
  51. package/skills/pikku-scenario/SKILL.md +141 -76
  52. package/skills/pikku-schedule/SKILL.md +39 -6
  53. package/skills/pikku-schema-ajv/SKILL.md +24 -2
  54. package/skills/pikku-schema-cfworker/SKILL.md +22 -2
  55. package/skills/pikku-security/SKILL.md +54 -9
  56. package/skills/pikku-services/SKILL.md +49 -9
  57. package/skills/pikku-services/references/audit-wire-service.md +2 -1
  58. package/skills/pikku-template-clone/SKILL.md +10 -5
  59. package/skills/pikku-trigger/SKILL.md +50 -6
  60. package/skills/pikku-versioning/SKILL.md +46 -17
  61. package/skills/pikku-websocket/SKILL.md +72 -44
  62. package/skills/pikku-workflow/SKILL.md +123 -11
  63. package/skills/pikku-workflow/references/workflow-reference.md +13 -8
  64. package/skills/pikku-workflows-client/SKILL.md +13 -6
  65. package/skills/pikku-ws/SKILL.md +44 -8
@@ -45,15 +45,28 @@ await jwt.init()
45
45
 
46
46
  **Constructor Parameters:**
47
47
 
48
- - `getSecrets` — Async function returning an array of `{ id, value }` key pairs. First key is used for signing; all keys are tried for verification (supports rotation).
48
+ - `getSecrets` — Async function returning an array of `{ id, value }` key pairs. The **first** entry signs; every entry can verify.
49
49
  - `logger` — Optional logger instance.
50
50
 
51
51
  **Methods:**
52
52
 
53
53
  - `init(): Promise<void>` — Fetch and cache secrets. Call at startup.
54
- - `encode<T>(expiresIn: RelativeTimeInput, payload: T): Promise<string>` — Create a signed JWT.
55
- - `decode<T>(token: string): Promise<T>` — Decode a JWT payload without verification.
56
- - `verify(token: string): Promise<void>` — Verify a JWT signature and expiry.
54
+ - `encode<T>(expiresIn: RelativeTimeInput, payload: T): Promise<string>` — Create a signed JWT, stamping the signing key's `id` as the token's `kid` header.
55
+ - `decode<T>(token: string): Promise<T>` — **Verifies** the signature and expiry, then returns the payload.
56
+ - `verify(token: string): Promise<void>` — The same check, discarding the payload.
57
+
58
+ `decode` is not an unchecked read: both methods run `jose.jwtVerify` and both
59
+ throw on a bad signature or an expired token. There is no way to inspect an
60
+ untrusted payload through this service — reach for `jose.decodeJwt` directly if
61
+ you genuinely need that, and treat the result as unauthenticated input.
62
+
63
+ Tokens are signed **HS256** with a symmetric secret. The algorithm is fixed and
64
+ pinned on verification, so a token arriving with any other `alg` is rejected —
65
+ but it also means this service has no asymmetric (RS256/ES256) mode.
66
+
67
+ `init()` is not strictly required: `encode` calls it lazily on first use. Call it
68
+ at startup anyway so a missing or unreachable secret fails at boot rather than
69
+ on the first request that needs a token.
57
70
 
58
71
  ## Usage Patterns
59
72
 
@@ -63,15 +76,20 @@ await jwt.init()
63
76
  import { JoseJWTService } from '@pikku/jose'
64
77
 
65
78
  const jwt = new JoseJWTService(
66
- async () => [{ id: 'key-1', value: process.env.JWT_SECRET! }],
79
+ async () => [{ id: 'key-1', value: await secrets.getSecret('JWT_SECRET') }],
67
80
  logger
68
81
  )
69
82
  await jwt.init()
70
83
  ```
71
84
 
85
+ A signing key is a secret, so it comes from the secrets service rather than
86
+ `process.env` — and because `getSecrets` is a function called on demand, reading
87
+ it there (not once at construction) is what makes the re-init-on-unknown-kid path
88
+ above actually see a rotated key. See `pikku-config`.
89
+
72
90
  ### Secret Rotation
73
91
 
74
- Supply multiple keys. The first is used for signing; all are tried for verification:
92
+ Supply multiple keys. The first signs; the rest stay available for verification:
75
93
 
76
94
  ```typescript
77
95
  const jwt = new JoseJWTService(async () => [
@@ -80,6 +98,17 @@ const jwt = new JoseJWTService(async () => [
80
98
  ])
81
99
  ```
82
100
 
101
+ Verification resolves the key by the token's `kid` header rather than trying each
102
+ secret in turn — which is why `encode` stamps the signing key's `id` there, and
103
+ why the ids must stay stable across a rotation. Keep an id in the list for as
104
+ long as tokens bearing it can still be in flight.
105
+
106
+ When a `kid` isn't in the cache, the service re-runs `getSecrets()` once before
107
+ giving up with `Missing secret for id: <kid>`. That is what lets a running server
108
+ pick up a newly added key without a restart, provided `getSecrets` reads from
109
+ something live (a secret store) rather than a value captured at boot. A token
110
+ with no `kid` at all falls back to the current signing key.
111
+
83
112
  ### With Pikku Services
84
113
 
85
114
  ```typescript
@@ -5,13 +5,14 @@ description: >-
5
5
  notes that say what the app is, in the language its users use. Covers the Open Knowledge Format
6
6
  note (path-as-identity markdown, YAML frontmatter, only `type` required), the sections of the
7
7
  app-project profile (slices, entities, decisions, questions, wishlist) and the one question each
8
- answers, slice status/entities/gherkin rules, the `resource:` URI scheme that ties a note to the
8
+ answers, slice status/entities/gherkin rules, the `resource:` URI scheme tying a note to the
9
9
  code it is about, the shapes that are NOT a knowledge base, and the `pikku knowledge
10
- validate|index` commands. TRIGGER when: user asks to write down a decision, a requirement, an
11
- entity or an open question; asks what the app does or is; asks about knowledge/, notes, slices,
12
- or an index.md; or hands over a product brief to record. DO NOT TRIGGER when: user asks what
10
+ validate|index` commands. TRIGGER when: user asks to write down a decision, requirement, entity
11
+ or open question; asks what the app does or is; asks about knowledge/, notes, slices,
12
+ an index.md, or a diagram, callout or decision block; or hands over a product
13
+ brief to record. DO NOT TRIGGER when: user asks what
13
14
  functions, routes, tables or permissions exist (that is `pikku meta` / `pikku info`, never a
14
- note), or asks to write a scenario test (use pikku-scenario).
15
+ note), or to write a scenario test (use pikku-scenario).
15
16
  installGroups: [core]
16
17
  ---
17
18
 
@@ -142,7 +143,49 @@ And writing again replaces it rather than adding a second
142
143
 
143
144
  - **`status`** is `proposed` → `dispatched` → `built`. Nothing else. Every gate compares it literally.
144
145
  - **`entities`** lists what the slice touches, **at most three**. Past three it is not one buildable piece — split it.
145
- - **The scenario is a fenced `gherkin` block, in the third person.** `Given 'owner' has no entry` — never `Given I have no entry`. A quoted word _means a persona_, which is what lets a reader (and a test) tell who is acting. First person hides that, so it is rejected.
146
+ - **The scenario is a fenced `gherkin` block, in the third person.** `Given 'owner' has no entry` — never `Given I have no entry`. A quoted word _means a persona_, which is what lets a reader (and a test) tell who is acting. First person hides that, so it is rejected. The console draws the keywords as a column and each quoted persona as a chip, so a first-person scenario is visibly a block with no personas in it.
147
+
148
+ ## Showing it
149
+
150
+ A note is markdown, and four kinds of block are **drawn** rather than printed. Every one of them degrades to something readable — a diagram falls back to its source, a callout to a blockquote, a decision to a code block — so writing one costs nothing where it is not rendered.
151
+
152
+ None of this changes the governing rule. A diagram of the schema is still a copy of `pikku meta` that drifts, and it drifts while looking more authoritative than prose would. These are for the part no generator can derive.
153
+
154
+ **```mermaid — when the relationship is the point.** Prose is bad at graphs: "an entry belongs to a day, a day belongs to an owner, and a grant lets another owner read a day" is a sentence a reader has to re-read twice and draw themselves. Reach for one when a note is about how several things relate, an order of steps across time, or a state machine. Do not draw one thing, or two things and an arrow — that is a sentence.
155
+
156
+ ````markdown
157
+ ```mermaid
158
+ flowchart LR
159
+ owner -->|writes| entry
160
+ entry -->|belongs to| day
161
+ owner -->|grants read on| day
162
+ ```
163
+ ````
164
+
165
+ **`> [!NOTE]` — when a line must survive skimming.** Five kinds: `NOTE`, `TIP`, `IMPORTANT`, `WARNING`, `CAUTION`. Use one for the thing a reader who skips the paragraph must still not miss — a trap, a constraint that is easy to violate, an assumption the rest of the note rests on. Two callouts in a note is normal; six means the note has no prose left and nothing stands out.
166
+
167
+ ```markdown
168
+ > [!WARNING]
169
+ > A grant is checked on every request, not cached. A permission change is
170
+ > immediate everywhere, and there is no invalidation step to forget.
171
+ ```
172
+
173
+ **```decision — the answer a decision note owes.** `decisions/` answers "what was chosen, and what does that rule out?", and the second half is the half that gets dropped. The fence makes it checkable: `pikku knowledge validate` warns when a fence says what was chosen and never says what it closes off.
174
+
175
+ ````markdown
176
+ ```decision
177
+ chosen: A revoked grant stops working immediately, everywhere.
178
+ rules-out:
179
+ - A "revoked but valid until midnight" state
180
+ - A scheduled cleanup job
181
+ because: Two people disagreeing about who can see today is worse than one of
182
+ them losing access mid-session.
183
+ ```
184
+ ````
185
+
186
+ It is a **summary, not the note** — the argument continues in prose underneath. `rules-out:` takes one line or a `- item` block, and any value too long for one line wraps onto indented lines under it, as `because:` does above. A decision genuinely argued in prose needs no fence, and validate never asks for one; what it does ask is that a fence you did write is complete.
187
+
188
+ **Fences of any other language are code** — highlighted and copyable, which is right for a snippet and wrong for a scenario or a decision, so do not put either in a bare fence.
146
189
 
147
190
  ## `resource:` — tying a note to the code
148
191
 
@@ -196,7 +239,7 @@ pikku knowledge index # refresh every index.md
196
239
  pikku knowledge index --check # report stale indexes without writing (CI gate)
197
240
  ```
198
241
 
199
- `validate` reports: notes with no `type`, a missing `knowledge/index.md`, a section with no `index.md`, notes flat at the root, sections that duplicate what the project already declares, slices with a bad or missing `status`, slices over three entities, slices with no gherkin block or a first-person one, and every `resource:` that no longer resolves. Errors fail the command; warnings do not.
242
+ `validate` reports: notes with no `type`, a missing `knowledge/index.md`, a section with no `index.md`, notes flat at the root, sections that duplicate what the project already declares, slices with a bad or missing `status`, slices over three entities, slices with no gherkin block or a first-person one, `decision` fences that state no `chosen:` or rule nothing out, and every `resource:` that no longer resolves. Errors fail the command; warnings do not.
200
243
 
201
244
  `index` rewrites only the block between `<!-- pikku:knowledge-index -->` markers, creating a scaffolded `index.md` for a section that has none. It is idempotent — running it twice changes nothing.
202
245
 
@@ -28,7 +28,7 @@ Use this skill as an execution checklist, not reference material.
28
28
 
29
29
  ## Writing Queries — the Kysely query builder
30
30
 
31
- In a Pikku function body the injected `kysely` IS the `Kysely<DB>` instance — query it directly. Pikku wires the **CamelCasePlugin**, so you write **camelCase everywhere in TS** (columns, aliases) and raw **snake_case ONLY inside a `` sql`` `` literal**. Kysely is a query builder, NOT an ORM — there are no relations; shape nested data with the JSON helpers below. Never hand-roll SQL strings; never annotate the return type (in Pikku the output zod schema IS the type).
31
+ In a Pikku function body the injected `kysely` IS the `Kysely<DB>` instance — query it directly. Every connection factory below wires the **CamelCasePlugin** by default, so you write **camelCase everywhere in TS** (columns, aliases) and raw **snake_case ONLY inside a `` sql`` `` literal**. If a project opted out (`createNodeSqliteKysely({ camelCase: false })`), that inverts — check how the instance was built before assuming. Kysely is a query builder, NOT an ORM — there are no relations; shape nested data with the JSON helpers below. Never hand-roll SQL strings; never annotate the return type (in Pikku the output zod schema IS the type).
32
32
 
33
33
  ```typescript
34
34
  import { sql } from 'kysely'
@@ -92,12 +92,19 @@ await kysely.transaction().execute(async (trx) => {
92
92
  })
93
93
  ```
94
94
 
95
- Pikku provides SQL database services through four packages:
95
+ Pikku provides SQL database services through six packages:
96
96
 
97
- - `@pikku/kysely` — Base service implementations (database-agnostic)
98
- - `@pikku/kysely-postgres` — PostgreSQL-specific implementations + `PikkuKysely` connection wrapper
97
+ - `@pikku/kysely` — Base service implementations (database-agnostic), the serialize plugins, `createAuditedKysely` and the `pikkuSchemas` helpers
98
+ - `@pikku/kysely-postgres` — PostgreSQL-specific implementations + the `PikkuKysely` connection wrapper and `PgEventHubService` (LISTEN/NOTIFY-backed)
99
99
  - `@pikku/kysely-mysql` — MySQL-specific implementations
100
- - `@pikku/kysely-sqlite` — SQLite-specific implementations + `createSQLiteKysely` factory
100
+ - `@pikku/kysely-sqlite` — SQLite-specific implementations, `createSQLiteKysely`, and the `LibsqlWebDialect`
101
+ - `@pikku/kysely-node-sqlite` — `createNodeSqliteKysely` over `node:sqlite`, plus user-defined SQL functions and the coercion plugin
102
+ - `@pikku/kysely-bun-sqlite` — the same over `bun:sqlite`
103
+
104
+ The last two are runtime adapters rather than service sets: they build the
105
+ `Kysely<DB>` you inject into functions, while the dialect packages above supply
106
+ Pikku's own stores. They differ in one place — `bun:sqlite` cannot register
107
+ scalar functions, so `createBunSqliteKysely` throws if you pass `functions`.
101
108
 
102
109
  All implement standard Pikku interfaces from `@pikku/core`.
103
110
 
@@ -105,9 +112,11 @@ All implement standard Pikku interfaces from `@pikku/core`.
105
112
 
106
113
  ```bash
107
114
  # Pick your database
108
- yarn add @pikku/kysely @pikku/kysely-postgres # PostgreSQL
109
- yarn add @pikku/kysely @pikku/kysely-mysql # MySQL
110
- yarn add @pikku/kysely @pikku/kysely-sqlite # SQLite
115
+ yarn add @pikku/kysely @pikku/kysely-postgres # PostgreSQL
116
+ yarn add @pikku/kysely @pikku/kysely-mysql # MySQL
117
+ yarn add @pikku/kysely @pikku/kysely-sqlite # SQLite (stores)
118
+ yarn add @pikku/kysely-node-sqlite # SQLite on Node
119
+ yarn add @pikku/kysely-bun-sqlite # SQLite on Bun
111
120
  ```
112
121
 
113
122
  ## API Reference
@@ -120,7 +129,8 @@ import { PikkuKysely } from '@pikku/kysely-postgres'
120
129
  const db = new PikkuKysely<DB>(
121
130
  logger: Logger,
122
131
  connectionOrConfig: postgres.Sql | postgres.Options | string,
123
- defaultSchemaName?: string
132
+ defaultSchemaName?: string,
133
+ poolConfig?: PostgresConfig // maxPool, connectTimeout, idleTimeout, maxLifetime, prepare, statementTimeout
124
134
  )
125
135
 
126
136
  await db.init()
@@ -128,14 +138,39 @@ db.kysely // Kysely<DB> instance for queries
128
138
  await db.close()
129
139
  ```
130
140
 
131
- ### SQLite Factory — `createSQLiteKysely`
141
+ It builds a postgres.js-backed Kysely with the CamelCasePlugin. Pass an existing
142
+ `postgres.Sql` when something else owns the pool — the wrapper then leaves it
143
+ open on `close()`. `poolConfig` keys are only forwarded when set, so postgres.js
144
+ keeps its own defaults for the rest, and it is ignored entirely when you hand in
145
+ an already-constructed connection.
146
+
147
+ ### SQLite factories
148
+
149
+ ```typescript
150
+ import { createNodeSqliteKysely } from '@pikku/kysely-node-sqlite'
151
+
152
+ // Your application DB — CamelCasePlugin on by default
153
+ const kysely = createNodeSqliteKysely<DB>({
154
+ filename: 'app.db', // or ':memory:'
155
+ camelCase: true,
156
+ plugins: [], // layered on top
157
+ functions: {}, // scalar UDFs, registered as deterministic (Node only)
158
+ })
159
+ ```
132
160
 
133
161
  ```typescript
134
162
  import { createSQLiteKysely } from '@pikku/kysely-sqlite'
135
163
 
136
- const kysely = createSQLiteKysely(database: SqliteDatabase | (() => Promise<SqliteDatabase>))
164
+ // Pikku's own tables — returns Kysely<KyselyPikkuDB>, not your DB
165
+ const pikkuDb = createSQLiteKysely(database: SqliteDatabase | (() => Promise<SqliteDatabase>))
137
166
  ```
138
167
 
168
+ These two are not interchangeable. `createSQLiteKysely` is typed to
169
+ `KyselyPikkuDB` and wires the `SerializePlugin` (JSON columns in and out) rather
170
+ than the CamelCasePlugin, because it exists to back the stores below. Reach for
171
+ `createNodeSqliteKysely` / `createBunSqliteKysely` for the instance your
172
+ functions query.
173
+
139
174
  ### Available Services
140
175
 
141
176
  Each database variant exports these services with a prefix (`Pg`, `MySQL`, `SQLite`, or base `Kysely`):
@@ -151,24 +186,52 @@ Each database variant exports these services with a prefix (`Pg`, `MySQL`, `SQLi
151
186
  | `*AgentRunService` | `AgentRunService` | Agent execution tracking |
152
187
  | `*SecretService` | `SecretService` | Encrypted secret storage (envelope encryption) |
153
188
 
189
+ A handful more live only on the base package — there is no `Pg`/`MySQL`/`SQLite`
190
+ variant to reach for, you import them from `@pikku/kysely` whatever the engine:
191
+
192
+ | Service | Purpose |
193
+ | -------------------------- | --------------------------------------------- |
194
+ | `KyselySessionStore` | Persisted user sessions |
195
+ | `KyselyScopeService` | Scope and role storage |
196
+ | `KyselyWebhookService` | Webhook registrations and deliveries |
197
+ | `KyselyCredentialService` | Encrypted third-party credentials |
198
+ | `KyselyAIRunStateService` | AI run state (also implemented by AIStorage) |
199
+ | `KyselyWorkflowMirror` | Mirrors workflow runs into queryable tables |
200
+ | `KyselyAuditService` | Durable audit sink (see `pikku-audit`) |
201
+
154
202
  All services take a `Kysely<KyselyPikkuDB>` instance in their constructor and have an `init()` method that creates tables if needed.
155
203
 
156
204
  ### Secret Service
157
205
 
206
+ Envelope encryption: each secret gets its own DEK, wrapped by a KEK derived from
207
+ `key` plus a stored per-version salt. Keeping `previousKey` around is what makes
208
+ rotation possible — `rotateKEK` re-wraps every secret from the old key to the
209
+ current one and returns the new version, and it throws if no `previousKey` is
210
+ configured.
211
+
158
212
  ```typescript
159
213
  import { PgKyselySecretService } from '@pikku/kysely-postgres'
160
214
 
161
215
  const secrets = new PgKyselySecretService(db.kysely, {
162
- kekSecret: 'your-key-encryption-key',
163
- salt: 'your-salt',
216
+ key: 'your-key-encryption-passphrase',
217
+ keyVersion: 2, // defaults to 1
218
+ previousKey: 'the-passphrase-you-are-rotating-away-from',
219
+ audit: true, // log write/delete/rotate through the audit sink
220
+ auditReads: false, // reads too — noisy, off by default
164
221
  })
165
222
  await secrets.init()
166
223
 
167
224
  await secrets.setSecret('api-key', { key: 'sk-...' })
168
- const value = await secrets.getSecret<{ key: string }>('api-key')
169
- await secrets.rotateKEK() // Re-encrypt all secrets with new KEK
225
+ const secret = await secrets.getSecret<{ key: string }>('api-key')
226
+ await secrets.hasSecret('api-key')
227
+ await secrets.deleteSecret('api-key')
228
+ const newVersion = await secrets.rotateKEK()
170
229
  ```
171
230
 
231
+ `getSecret` hands back a `SecretValue<T>`, not the bare value — it serializes as
232
+ `[secret]` until something reveals it, which is what stops a secret drifting into
233
+ a log line or an audit row. See `pikku-config` for the reveal rules.
234
+
172
235
  ## Usage Patterns
173
236
 
174
237
  ### PostgreSQL Setup
@@ -157,7 +157,42 @@ addHTTPMiddleware([
157
157
 
158
158
  When the api-key header is present it is authoritative — the middleware never
159
159
  falls through to `getSession` (a bare mock session would shadow the scoped one).
160
- When it is absent, the human `getSession` path runs as normal.
160
+ When it is absent, the human `getSession` path runs as normal. Either way the
161
+ middleware bails out entirely if a session is already set, and it checks the
162
+ *live* session rather than the wire's construction-time snapshot, so it can't
163
+ clobber one an earlier middleware resolved.
164
+
165
+ ### Restricting a key below its owner
166
+
167
+ Set `scopes` on the session `mapKey` returns and that set is **authoritative** —
168
+ including an empty one. It is never widened back out to everything the owning
169
+ service user holds:
170
+
171
+ ```typescript
172
+ mapKey: async (key) => ({
173
+ userId: 'sandbox-runtime',
174
+ scopes: ['sandbox:read'], // this key can do only this
175
+ })
176
+ ```
177
+
178
+ Leave `scopes` unset for a key that acts with its owner's full rights. This is
179
+ what makes one stable service user safely able to own keys of very different
180
+ power — the restriction lives on the key, not on a proliferation of identities.
181
+
182
+ ### Failure handling is deliberately split
183
+
184
+ A key that fails to verify is logged and treated as an ordinary "not
185
+ authenticated" — an unusable credential is not an outage. A failure *inside*
186
+ `mapKey` (your scope store is down) propagates as a real error instead. That
187
+ asymmetry is on purpose: a scope lookup that silently failed would serve the
188
+ request anonymously, which is exactly the wrong direction to fail in.
189
+
190
+ ### `betterAuthStatelessSession` has no machine path
191
+
192
+ The lean cookie-cache middleware (`betterAuthStatelessSession` — no
193
+ `services.auth()`, no DB) handles only the human path. Machine auth needs
194
+ `betterAuthSession`, because `verifyApiKey` is a server call there is no
195
+ stateless equivalent of. Both accept an `impersonation` option.
161
196
 
162
197
  ### WebSocket channels authenticate on the upgrade handshake
163
198