@pikku/skills 0.12.2 → 0.12.6

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 (68) hide show
  1. package/dist/skills.gen.js +1 -1
  2. package/package.json +1 -1
  3. package/skills/pikku-addon/SKILL.md +56 -29
  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 +80 -34
  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 +82 -10
  14. package/skills/pikku-concepts/references/concept-mapping.md +2 -2
  15. package/skills/pikku-config/SKILL.md +134 -52
  16. package/skills/pikku-cron/SKILL.md +13 -6
  17. package/skills/pikku-deploy-azure/SKILL.md +83 -28
  18. package/skills/pikku-deploy-cloudflare/SKILL.md +79 -37
  19. package/skills/pikku-deploy-express/SKILL.md +40 -4
  20. package/skills/pikku-deploy-fastify/SKILL.md +22 -1
  21. package/skills/pikku-deploy-lambda/SKILL.md +99 -19
  22. package/skills/pikku-deploy-nextjs/SKILL.md +49 -5
  23. package/skills/pikku-deploy-uws/SKILL.md +54 -1
  24. package/skills/pikku-deps/SKILL.md +29 -8
  25. package/skills/pikku-emails/SKILL.md +36 -5
  26. package/skills/pikku-fabric/SKILL.md +30 -5
  27. package/skills/pikku-fabric-debug/SKILL.md +5 -1
  28. package/skills/pikku-feature/SKILL.md +12 -7
  29. package/skills/pikku-gateway-slack/SKILL.md +72 -11
  30. package/skills/pikku-http/SKILL.md +18 -5
  31. package/skills/pikku-http/references/http-options.md +10 -5
  32. package/skills/pikku-i18n/SKILL.md +18 -7
  33. package/skills/pikku-info/SKILL.md +18 -8
  34. package/skills/pikku-jose/SKILL.md +35 -6
  35. package/skills/pikku-knowledge/SKILL.md +3 -3
  36. package/skills/pikku-kysely/SKILL.md +78 -15
  37. package/skills/pikku-machine-auth/SKILL.md +36 -1
  38. package/skills/pikku-mcp/SKILL.md +159 -149
  39. package/skills/pikku-middleware/SKILL.md +17 -5
  40. package/skills/pikku-mongodb/SKILL.md +10 -2
  41. package/skills/pikku-n8n-import/SKILL.md +14 -6
  42. package/skills/pikku-permissions/SKILL.md +102 -22
  43. package/skills/pikku-pino/SKILL.md +12 -4
  44. package/skills/pikku-product-second-opinion/SKILL.md +3 -3
  45. package/skills/pikku-queue/SKILL.md +45 -16
  46. package/skills/pikku-react/SKILL.md +41 -14
  47. package/skills/pikku-react-query/SKILL.md +14 -10
  48. package/skills/pikku-realtime/SKILL.md +44 -22
  49. package/skills/pikku-redis/SKILL.md +12 -3
  50. package/skills/pikku-rpc/SKILL.md +23 -12
  51. package/skills/pikku-rtl/SKILL.md +21 -17
  52. package/skills/pikku-scenario/SKILL.md +285 -50
  53. package/skills/pikku-schedule/SKILL.md +39 -6
  54. package/skills/pikku-schema-ajv/SKILL.md +24 -2
  55. package/skills/pikku-schema-cfworker/SKILL.md +22 -2
  56. package/skills/pikku-security/SKILL.md +54 -9
  57. package/skills/pikku-services/SKILL.md +49 -9
  58. package/skills/pikku-services/references/audit-wire-service.md +2 -1
  59. package/skills/pikku-software-archaeology/README.md +16 -6
  60. package/skills/pikku-software-archaeology/references/pikku-mapping.md +30 -30
  61. package/skills/pikku-template-clone/SKILL.md +10 -5
  62. package/skills/pikku-trigger/SKILL.md +50 -6
  63. package/skills/pikku-versioning/SKILL.md +46 -17
  64. package/skills/pikku-websocket/SKILL.md +72 -44
  65. package/skills/pikku-workflow/SKILL.md +35 -1
  66. package/skills/pikku-workflow/references/workflow-reference.md +13 -8
  67. package/skills/pikku-workflows-client/SKILL.md +13 -6
  68. package/skills/pikku-ws/SKILL.md +44 -8
@@ -126,12 +126,12 @@ in-app features don't.
126
126
  - **Migrations are inline SQL files** in the project's migrations dir
127
127
  (typically `sql/`). Use a numbered prefix matching existing files.
128
128
  - **Secrets and env-vars: NEVER `process.env`.** Declare them with
129
- `wireSecret` (sensitive) or `wireVariable` (non-sensitive) — both with a
130
- zod schema for type-safe access. Read with
131
- `services.secrets.getSecret('NAME')` or `services.variables.get('NAME')`.
132
- See the **pikku-config** skill for the full pattern (including
133
- OAuth2 credentials). This applies even in `config.ts` and singleton
134
- service factories.
129
+ `defineSecret` (sensitive) or `defineVariable` (non-sensitive) — both with a
130
+ zod schema for type-safe access. Read variables with
131
+ `services.variables.get('NAME')`. Secrets are **not available in functions** —
132
+ read them in `services.ts` with `secrets.getSecret('NAME')` and pass the value
133
+ into the service the function uses. See the **pikku-config** skill for the full
134
+ pattern (including OAuth2 credentials). This applies even in `config.ts`.
135
135
 
136
136
  ### Conventions to copy from neighbours
137
137
 
@@ -218,10 +218,15 @@ branch.
218
218
  ## Stage 6 — Commit
219
219
 
220
220
  ```bash
221
- git add -A
221
+ git add <the files you changed>
222
222
  git commit -m "feat: <short title>"
223
223
  ```
224
224
 
225
+ Stage the files you actually touched, by path. `git add -A` / `git add .` also
226
+ sweeps up regenerated artifacts you didn't mean to commit and, where more than
227
+ one agent shares the checkout, another agent's in-progress work — which lands in
228
+ your branch and silently breaks theirs.
229
+
225
230
  ## Stage 7 — Hand off
226
231
 
227
232
  Tell the user the branch name and how to review. Two options:
@@ -35,24 +35,67 @@ yarn add @pikku/gateway-slack @slack/web-api
35
35
  ```typescript
36
36
  import { SlackGatewayAdapter } from '@pikku/gateway-slack'
37
37
 
38
- const adapter = new SlackGatewayAdapter(options: SlackGatewayAdapterOptions)
38
+ const adapter = new SlackGatewayAdapter({
39
+ signingSecret: string,
40
+ tokenResolver: (teamId: string) => Promise<string | null>,
41
+ })
39
42
  ```
40
43
 
41
44
  Bridges Slack Events API webhooks with Pikku's gateway system for processing Slack events as Pikku functions.
42
45
 
46
+ **There is no `botToken` option.** One adapter serves every workspace, and the
47
+ bot token is resolved per `team_id` through `tokenResolver` — normally a lookup
48
+ against the row `exchangeSlackOAuthCode` wrote at install time. Returning `null`
49
+ throws for that event. `WebClient`s are cached per team, so call
50
+ `invalidateClient(teamId)` after a token rotation.
51
+
52
+ **Methods:**
53
+
54
+ - `verifyWebhook(data, request?)` — asserts the signature, then answers the `url_verification` challenge. It **fails closed**: no request access, missing headers, a stale timestamp, or an HMAC mismatch all throw `UnauthorizedError` before parse or the handler runs
55
+ - `parse(data)` — normalizes an `event_callback` into a `GatewayInboundMessage`, or returns `null` for anything to ignore
56
+ - `createBoundSend(teamId, channelId, threadTs?)` — the real send path
57
+ - `send(senderId, message)` — **a deliberate no-op.** The generic signature carries no channel context, and the gateway runner calls it for auto-send, so it swallows rather than throws. A reply written through it silently never reaches Slack
58
+ - `getClientForTeam(teamId)` / `invalidateClient(teamId)` / `close()`
59
+
60
+ `parse` returns `null` — meaning the event is dropped — for anything that isn't a
61
+ `message` or `app_mention`, for bot messages (loop prevention), for any subtype
62
+ other than `thread_broadcast`, and for events with no `user` or no `text`.
63
+ `metadata` carries `{ teamId, channelId, threadTs, messageTs, eventType }`, with
64
+ `threadTs` falling back to the message's own `ts` so replies always land
65
+ in-thread.
66
+
43
67
  ### `SlackGatewayHelper`
44
68
 
45
- Helper for handling Slack messages and metadata within gateway functions.
69
+ Wraps a parsed message plus the adapter and binds the channel/thread for you:
70
+
71
+ ```typescript
72
+ const slack = new SlackGatewayHelper(data, adapter)
73
+ await slack.sendText('Thinking…') // sends now
74
+ return slack.reply('Here is the answer') // auto-sent by the runner
75
+ ```
76
+
77
+ Also: `send(message)`, `replyBlocks(blocks)`, and the `channelId` / `threadTs` /
78
+ `teamId` getters.
46
79
 
47
80
  ### Slash Commands
48
81
 
49
82
  ```typescript
50
83
  import { parseSlashCommand, respondToSlashCommand } from '@pikku/gateway-slack'
51
84
 
52
- const command = parseSlashCommand(request)
53
- await respondToSlashCommand(responseUrl, { text: 'Done!' })
85
+ const command = parseSlashCommand(data)
86
+ // { raw, subcommand, args, argsList, teamId, userId, channelId, triggerId, responseUrl }
87
+ await respondToSlashCommand(command.responseUrl, { text: 'Done!' })
54
88
  ```
55
89
 
90
+ The parsed result is camelCase — reach for `command.responseUrl`, not
91
+ `command.response_url`; the underlying snake_case payload is on `command.raw`.
92
+ `text` is split on whitespace: the first word becomes `subcommand`, the rest
93
+ `args`/`argsList`.
94
+
95
+ `respondToSlashCommand` posts to the `response_url` and **ignores the result** —
96
+ a rejected response is invisible. Use it for the delayed reply when work exceeds
97
+ Slack's 3-second acknowledgement window.
98
+
56
99
  ### OAuth Flow
57
100
 
58
101
  ```typescript
@@ -81,9 +124,19 @@ const tokens = await exchangeSlackOAuthCode({
81
124
  ```typescript
82
125
  import { verifySlackSignature } from '@pikku/gateway-slack'
83
126
 
84
- verifySlackSignature(signingSecret, timestamp, body, signature)
127
+ verifySlackSignature(signingSecret, signature, timestamp, body): boolean
85
128
  ```
86
129
 
130
+ **Signature before timestamp** — the two middle arguments are both strings, so
131
+ swapping them compiles and simply never verifies. `signature` is the raw
132
+ `x-slack-signature` header (`v0=…`), `timestamp` is `x-slack-request-timestamp`
133
+ in Unix seconds, and `body` must be the **raw** request body: any re-serialization
134
+ changes the HMAC.
135
+
136
+ It returns `false` rather than throwing, including for a timestamp more than 5
137
+ minutes off (replay protection). The adapter already calls this for you — reach
138
+ for it directly only outside the gateway path, e.g. in a slash-command route.
139
+
87
140
  ## Usage Patterns
88
141
 
89
142
  ### Slack Bot Gateway
@@ -93,22 +146,30 @@ import { SlackGatewayAdapter } from '@pikku/gateway-slack'
93
146
 
94
147
  const slackGateway = new SlackGatewayAdapter({
95
148
  signingSecret: config.slackSigningSecret,
96
- botToken: config.slackBotToken,
149
+ tokenResolver: async (teamId) => {
150
+ const row = await kysely
151
+ .selectFrom('slackInstall')
152
+ .select('botToken')
153
+ .where('teamId', '=', teamId)
154
+ .executeTakeFirst()
155
+ return row?.botToken ?? null
156
+ },
97
157
  })
98
-
99
- // Register with your HTTP runner to handle /slack/events endpoint
100
158
  ```
101
159
 
102
160
  ### Slash Command Handler
103
161
 
162
+ Slack gives you 3 seconds to acknowledge, so anything slower answers immediately
163
+ and posts the real result to `responseUrl` afterwards:
164
+
104
165
  ```typescript
105
166
  const handleSlashCommand = pikkuSessionlessFunc({
106
167
  title: 'Handle Slack Command',
107
168
  func: async ({ db }, data) => {
108
169
  const command = parseSlashCommand(data)
109
- // Process command...
110
- await respondToSlashCommand(command.response_url, {
111
- text: `Processed: ${command.text}`,
170
+ await respondToSlashCommand(command.responseUrl, {
171
+ text: `Processed: ${command.args}`,
172
+ response_type: 'ephemeral',
112
173
  })
113
174
  },
114
175
  })
@@ -38,8 +38,13 @@ Follow existing patterns you find (naming, tag usage, file organization). See `p
38
38
 
39
39
  ## API Reference
40
40
 
41
- - `wireHTTP(config)` (from `@pikku/core/http`) — wire one function to one endpoint.
42
- - `defineHTTPRoutes(config)` + `wireHTTPRoutes(config)` (from `.pikku/pikku-types.gen.js`) — group routes with shared config; composable/nestable.
41
+ All three come from `#pikku` (the generated `.pikku/pikku-types.gen.js`), which
42
+ binds them to your project's service, session and middleware types. The
43
+ `@pikku/core/http` versions are the unbound generics — they compile, but you
44
+ lose the typing that makes the wiring worth having.
45
+
46
+ - `wireHTTP(config)` — wire one function to one endpoint.
47
+ - `defineHTTPRoutes(config)` + `wireHTTPRoutes(config)` — group routes with shared config; composable/nestable.
43
48
 
44
49
  Function input/output types come from the function's own `input:`/`output:` zod schemas — never declared in the wiring. Route `:params`, query params, and body are merged into the function's `data` arg (see Data Flow).
45
50
 
@@ -144,6 +149,9 @@ wireHTTP({
144
149
 
145
150
  ### SSE (Server-Sent Events)
146
151
 
152
+ `sse: true` is only accepted on `method: 'get'` — the wiring union offers it on
153
+ no other verb.
154
+
147
155
  ```typescript
148
156
  wireHTTP({
149
157
  method: 'get',
@@ -154,7 +162,7 @@ wireHTTP({
154
162
 
155
163
  const getTodos = pikkuFunc({
156
164
  title: 'Get Todos',
157
- func: async ({ db, channel }, {}) => {
165
+ func: async ({ db }, {}, { channel }) => {
158
166
  const todos = await db.getTodos()
159
167
 
160
168
  if (channel) {
@@ -170,12 +178,17 @@ const getTodos = pikkuFunc({
170
178
  })
171
179
  ```
172
180
 
181
+ `channel` is on the **wire** — the func's third argument — not on services, and
182
+ it is optional because the same function can be reached over plain HTTP or RPC,
183
+ where there is no stream to send on. The `if (channel)` guard is what lets one
184
+ function serve both; the return value is the non-streaming answer.
185
+
173
186
  ### Generated Fetch Client
174
187
 
175
188
  After `npx pikku all`, a type-safe client is generated:
176
189
 
177
190
  ```typescript
178
- import { pikkuFetch } from '.pikku/pikku-fetch.gen.js'
191
+ import { pikkuFetch } from '#pikku/pikku-fetch.gen.js'
179
192
 
180
193
  pikkuFetch.setServerUrl('http://localhost:4002')
181
194
 
@@ -213,7 +226,7 @@ export const getBook = pikkuFunc({
213
226
  })
214
227
 
215
228
  // wirings/books.http.ts — same defineHTTPRoutes/wireHTTPRoutes shape as the Route Groups example above
216
- import { addHTTPMiddleware } from '@pikku/core/http'
229
+ import { addHTTPMiddleware } from '#pikku'
217
230
  import { cors, authBearer } from '@pikku/core/middleware'
218
231
 
219
232
  addHTTPMiddleware('*', [cors(), authBearer()])
@@ -2,25 +2,30 @@
2
2
 
3
3
  ## `wireHTTP(config)`
4
4
 
5
- Wire a single function to an HTTP endpoint. Import from `@pikku/core/http`.
5
+ Wire a single function to an HTTP endpoint. Import from `#pikku`.
6
6
 
7
7
  | Option | Type | Notes |
8
8
  | --- | --- | --- |
9
- | `method` | `'get' \| 'post' \| 'put' \| 'patch' \| 'delete' \| 'head'` | HTTP verb |
9
+ | `method` | `'get' \| 'post' \| 'put' \| 'patch' \| 'delete' \| 'head' \| 'options'` | HTTP verb |
10
10
  | `route` | `string` | e.g. `/books/:bookId` — `:params` become `data` fields |
11
11
  | `func` | `PikkuFunc` | The function to call |
12
12
  | `auth?` | `boolean` | Override default auth (`true` = require session) |
13
13
  | `tags?` | `string[]` | For grouping, middleware targeting |
14
14
  | `middleware?` | `PikkuMiddleware[]` | Per-route middleware |
15
- | `sse?` | `boolean` | Enable Server-Sent Events |
15
+ | `sse?` | `boolean` | Enable Server-Sent Events — **`method: 'get'` only** |
16
+ | `query?` | `Array<keyof In>` | **`method: 'post'` only** — input fields also read from the query string |
16
17
  | `contentType?` | `'xml' \| 'json'` | Response content type |
17
18
  | `timeout?` | `number` | Request timeout in ms |
18
19
  | `headers?` | `HTTPHeadersSchema` | Expected headers schema |
19
- | `docs?` | `HTTPRouteDocsConfig` | OpenAPI docs config |
20
+
21
+ `sse` and `query` are constrained by the config union rather than by a runtime
22
+ check, so a `sse: true` on a `post` fails to typecheck rather than silently
23
+ serving a normal response. OpenAPI metadata is not declared here — it is derived
24
+ from the function's `description`/`summary` and its input/output schemas.
20
25
 
21
26
  ## `defineHTTPRoutes(config)` + `wireHTTPRoutes(config)`
22
27
 
23
- Group routes with shared configuration. Groups are composable and nestable. Import from `.pikku/pikku-types.gen.js`.
28
+ Group routes with shared configuration. Groups are composable and nestable. Import from `#pikku`.
24
29
 
25
30
  ```typescript
26
31
  const routes = defineHTTPRoutes({
@@ -56,18 +56,18 @@ function LoginPage() {
56
56
 
57
57
  ## Keys only known at runtime (enum labels, status maps)
58
58
 
59
- A DB value picking a label (`enums__document_status__${status}`) is the one case a
60
- generated message can't express. Paraglide's README (§ "What about dynamic or
61
- CMS-driven keys?") is explicit: use an **explicit mapping from value to message
62
- function**. Key it on the enum type, never `string`:
59
+ A DB value picking a label is the one case a generated message can't express.
60
+ Paraglide's README (§ "What about dynamic or CMS-driven keys?") is explicit: use
61
+ an **explicit mapping from value to message function**. Key it on the enum type,
62
+ never `string`:
63
63
 
64
64
  ```ts
65
65
  import { m } from '../paraglide/messages.js'
66
66
 
67
67
  const DOCUMENT_STATUS_LABEL: Record<DocumentStatus, () => string> = {
68
- completed: m.enums__document_status__completed,
69
- in_progress: m.enums__document_status__in_progress,
70
- required: m.enums__document_status__required,
68
+ completed: m.enum__document_status__completed,
69
+ in_progress: m.enum__document_status__in_progress,
70
+ required: m.enum__document_status__required,
71
71
  }
72
72
 
73
73
  // call site — no fallback, because there is no missing case
@@ -77,6 +77,13 @@ DOCUMENT_STATUS_LABEL[status]()
77
77
  `Record<DocumentStatus, …>` is exhaustive: add a value to the enum without a
78
78
  label and the build fails. That is the entire point.
79
79
 
80
+ **Don't write these maps by hand.** `@pikku/paraglide` generates them from the
81
+ `enum__<group>__<member>` keys in the catalog and types each one against the DB
82
+ enum it mirrors, so a migration adding a status is a compile error rather than a
83
+ map someone forgot. Use the namespace above (singular `enum`, `__` between
84
+ segments) so the generator picks the group up, and read `pikku-paraglide` before
85
+ adding one.
86
+
80
87
  Do NOT write `Record<string, () => string>` with a `?? status` fallback, and do
81
88
  NOT index the namespace with a computed key (`m[\`enums__${name}__${value}\`]`).
82
89
  Both compile, both render the raw identifier to users when a label is missing,
@@ -132,6 +139,10 @@ The wrapper alternative — a module that walks the namespace and pipes each mes
132
139
  - Don't hardcode display strings "just for now" — the message is the work.
133
140
  - Don't edit or commit anything under `src/paraglide/` — it's regenerated; change `messages/*.json` instead.
134
141
  - **Don't wrap `m`.** No re-export module, no branding layer, no resolver. Components import `m` from `../paraglide/messages.js` and call it. `@pikku/react`'s `I18nString` is declared as `string & { readonly __brand: 'LocalizedString' }` — deliberately identical to Paraglide's own `LocalizedString` — so `m.some__key()` satisfies the `@pikku/mantine` `I18nNode` gate natively. A wrapper adds nothing and costs per-message tree-shaking.
142
+
143
+ `packages/console` is the one place in this repo that still wraps it, in `src/i18n/messages.ts`, to keep the debug mask (`█`) it carried over from i18next. That wrapper is a leftover, not a pattern — the generated-locale approach above is how a new app gets the same masking without touching every export. Don't copy it.
144
+
145
+ The `mKey`/`mList` runtime resolvers that used to live beside it are **gone**, and must not come back. `mList` resolved indexed `prefix.0` keys that no longer exist; `mKey` took a computed string, which is exactly the type safety Paraglide exists to provide. Where a key really is dynamic, map the discriminant to a message *function* and call it — the map is type-checked, a string is not.
135
146
  - Don't re-resolve messages by string key or re-implement `{param}` interpolation. A key-string resolver turns a missing key back into silent runtime text, surrendering the type safety that is the entire reason to use Paraglide.
136
147
  - Don't reach for i18next/react-i18next or a runtime-fetch translation loader — Paraglide's compiled functions are the whole delivery mechanism.
137
148
  - Don't tokenize backend error messages or logs here — those are not frontend display strings.
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: pikku-info
3
3
  description: >-
4
- Discover what exists in a Pikku project — functions, tags, middleware, permissions, HTTP routes,
5
- channels, schedulers, queues, and more. Use when you need to understand the project structure,
6
- find existing functions, or check what middleware and permissions are defined. TRIGGER when:
7
- user asks "what functions exist?", "show me the project structure", "list
4
+ Discover what exists in a Pikku project — functions (with their transport, middleware and
5
+ permissions), tags, middleware and permission definitions. Use when you need to understand the
6
+ project structure, find existing functions, or check what middleware and permissions are
7
+ defined. TRIGGER when: user asks "what functions exist?", "show me the project structure", "list
8
8
  routes/middleware/permissions", or needs to understand an existing Pikku codebase. DO NOT
9
9
  TRIGGER when: user is writing new code (use the specific wiring skill) or asking about Pikku
10
10
  concepts (use pikku-concepts).
@@ -27,9 +27,19 @@ Use this skill as an execution checklist, not reference material.
27
27
 
28
28
  Use the `pikku info` CLI commands to inspect this Pikku project. Run the commands below and present the results to the user in a clear summary.
29
29
 
30
+ There are exactly four subcommands — `functions`, `tags`, `middleware`,
31
+ `permissions`. Routes, channels, schedulers and queues are not separate
32
+ subcommands; they show up as the *transport* column of `info functions --verbose`.
33
+
30
34
  ## Available Commands
31
35
 
32
- Always use `--silent` to suppress the banner and inspector logs.
36
+ `--silent` suppresses the banner and the inspector's diagnostics, which is what
37
+ you want when parsing the table. It is read by the CLI but not declared as an
38
+ option, so every run also prints `Warning: Unknown option: --silent (ignored)` —
39
+ the warning is wrong, the flag works. Ignore that one line.
40
+
41
+ For anything you intend to parse rather than read, prefer `--json` (alias
42
+ `-j`, or `--output json`), which emits NDJSON instead of a formatted table.
33
43
 
34
44
  ### Functions
35
45
 
@@ -91,9 +101,9 @@ yarn pikku info permissions --verbose --silent
91
101
 
92
102
  1. If the user specifies a subcommand (e.g., `/pikku-info functions`), run only that command.
93
103
  2. If no subcommand is specified, run all four commands to give a complete project overview.
94
- 3. Always use `--silent` to suppress the Pikku banner and inspector logs.
95
- 4. Use `--verbose` when the user asks for details, file paths, or "more info".
96
- 5. Use `--limit N` to control output size (default is 50 rows).
104
+ 3. Always use `--silent` to suppress the Pikku banner and inspector logs, and disregard the spurious "Unknown option" warning it prints.
105
+ 4. Use `--verbose` when the user asks for details, file paths, or "more info". On `tags` it swaps counts for names; elsewhere it adds columns.
106
+ 5. Use `--limit N` to control output size (default is 50 rows) — the footer tells you how many were withheld.
97
107
  6. After running the commands, summarize the findings concisely:
98
108
  - Total count of functions, tags, middleware, and permissions
99
109
  - Notable patterns (e.g., which transport types are in use, which tags group the most functions)
@@ -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
@@ -161,8 +161,8 @@ And writing again replaces it rather than adding a second
161
161
  | `channel:` | a channel name | generated channel meta |
162
162
  | `table:` | a table name | the generated db schema |
163
163
  | `addon:` | `@pikku/addon-x` or bare `x` | the manifests that declare the dependency |
164
- | `scope:` | a scope name | the `scopes:` a function gates itself with, plus the grants in `scenarios.actors` |
165
- | `persona:` | a persona name | `scenarios.personas` in `pikku.config.json` |
164
+ | `scope:` | a scope name | the `scopes:` a function gates itself with, plus the scopes a `defineSystemRole()` confers |
165
+ | `persona:` | a persona name | `definePersonas()` |
166
166
 
167
167
  Ids are case-sensitive: `createEntry` is not `createentry`.
168
168
 
@@ -176,7 +176,7 @@ These are all things that exist somewhere better, so a note is always the copy t
176
176
 
177
177
  | Do not write | Because it lives in |
178
178
  | ----------------------------------- | ---------------------------------------------------------- |
179
- | a `personas/` section | `scenarios.personas` in `pikku.config.json` |
179
+ | a `personas/` section | `definePersonas()` in the project's own code |
180
180
  | a `scenarios/` section | the gherkin block inside the slice it belongs to |
181
181
  | a `permissions/` section | a decision note under `decisions/security/` |
182
182
  | a list of tables, columns or routes | `pikku meta` — the generated schema _is_ the schema |
@@ -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