@pikku/skills 0.12.15 → 0.12.17

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.
@@ -11,10 +11,81 @@ installGroups: [client, fabric]
11
11
  Use this skill as an execution checklist, not reference material.
12
12
 
13
13
  1. Every user-facing string in a frontend is a message. Never hardcode display text — add a key to `messages/en.json` and render `m.the__key()`. This holds even when the app ships only English; the messages are the seam a second language slots into later.
14
- 2. One `messages/<locale>.json` per language at the app root (NOT under `src/`), declared in `project.inlang/settings.json`. English (`en`) is `baseLocale` and the only locale until someone adds another.
14
+ 2. One `messages/<locale>.json` per language at the app root (NOT under `src/`), declared in `project.inlang/settings.json`. English (`en`) is `baseLocale` and the only locale until someone adds another. **`baseLocale` stays `en` whatever language the product speaks** — see [The product's language is not the code's language](#the-products-language-is-not-the-codes-language), which is the first thing to read if the brief says the app is not in English.
15
15
  3. Messages compile to typed ESM functions in `src/paraglide/` (generated, self-gitignored — never edit or commit it). The Vite plugin compiles during `dev`/`build` with HMR on message edits; run the CLI compile only when you need `tsc` before Vite has ever run.
16
16
  4. Validate with the app's own `tsc` then its `build`. The deploy pipeline compiles Paraglide and runs each frontend's `tsc` before building it — an i18n mistake blocks the deploy.
17
17
 
18
+ ## The product's language is not the code's language
19
+
20
+ A brief that says "the entire UI is German, no English strings visible anywhere"
21
+ is a statement about **one** of three separate things, and reading it as a
22
+ statement about the codebase is the single most expensive mistake available in
23
+ this skill. Three axes:
24
+
25
+ | Axis | What it covers | What sets it |
26
+ | --------------- | ------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- |
27
+ | **Identifiers** | Function, component, type and file names; database tables and columns | Nothing — always English, no setting |
28
+ | **Meta** | `description` / `name` / `title` / `template` authored inside the code, which the Pikku Console renders | `locale` in `pikku.config.json`, default `en` |
29
+ | **Product UI** | Every string the app shows a user | `messages/<locale>.json` + `defaultLocale` — **this axis only** |
30
+
31
+ A non-English product moves the third row and nothing else.
32
+
33
+ ### `baseLocale` stays `en`
34
+
35
+ `baseLocale` in `project.inlang/settings.json` does not mean "the language the
36
+ app is in". It names the message **source** — the catalogue every other locale is
37
+ cloned from and translated against. Setting it to the product's language looks
38
+ like it works, because the app does come up in that language, and then:
39
+
40
+ - there is no `en.json`, so `--add-locale` has no catalogue to translate from
41
+ - the app can never gain a second language without re-authoring every key
42
+ - a message missing from a locale falls back to a catalogue nobody wrote
43
+
44
+ The setting that actually decides what a first-time visitor sees is
45
+ `defaultLocale`, held in `apps/app/src/i18n/active.json` in the Fabric app
46
+ template and read by `src/i18n/config.ts`. It is deliberately a separate file
47
+ from `settings.json` for exactly this reason — the source language and the
48
+ served language are different questions.
49
+
50
+ So a German medical portal is **three** settings, not one:
51
+
52
+ ```jsonc
53
+ // project.inlang/settings.json — the source catalogue is English
54
+ { "baseLocale": "en", "locales": ["en", "de"] }
55
+
56
+ // apps/app/src/i18n/active.json — what a visitor opens in
57
+ { "defaultLocale": "de" }
58
+
59
+ // pikku.config.json — the language the team reads their Console in
60
+ { "locale": "de" }
61
+ ```
62
+
63
+ In the Fabric template both of the first two have a command, so you rarely edit
64
+ them by hand:
65
+
66
+ ```sh
67
+ fabric i18n --add-locale de # adds "de" to locales, seeds messages/de.json from en.json
68
+ fabric i18n --default-locale de # writes active.json — the app now OPENS in German
69
+ ```
70
+
71
+ ### The failure this is written from
72
+
73
+ A real build, from this template. The brief said the UI was German; the agent
74
+ set `baseLocale: "de"` with `locales: ["de"]` and no `en.json`, then carried the
75
+ same reading into the code — RPC functions `getUebersicht` and
76
+ `getPatientendetail`, components `Zeitstrahl` and `AufmerksamkeitStreifen`,
77
+ helpers `datumDeutsch` and `voraussichtlichFertig`, database tables `vorgang`
78
+ and `ereignis` with German columns.
79
+
80
+ The German UI it was asked for needed none of that. It needed German **values**
81
+ in a catalogue whose keys and source stayed English. What it got instead was a
82
+ project that cannot add a second language and cannot be picked up by anyone who
83
+ does not read German.
84
+
85
+ If you find a project in this state, say so plainly rather than working around
86
+ it: `baseLocale` cannot be repointed without re-keying every message, so it is a
87
+ migration someone has to agree to, not a fix to slip in.
88
+
18
89
  ## The moving parts (starter-template layout)
19
90
 
20
91
  - `messages/en.json` — flat keys, `{param}` interpolation, inlang message-format:
@@ -109,9 +180,10 @@ The gate catches _invalid_ messages but not _inlined_ strings. The `@pikku/manti
109
180
  ## Adding a second language
110
181
 
111
182
  1. `messages/fr.json` mirroring `en.json`'s keys (translate the values, keep `{param}` names identical).
112
- 2. Add `"fr"` to `locales` in `project.inlang/settings.json`.
183
+ 2. Add `"fr"` to `locales` in `project.inlang/settings.json`. **Leave `baseLocale` at `en`** — step 1 only works because there is an English catalogue to mirror.
113
184
  3. Recompile (restart/`vite dev` or the CLI compile). A locale file missing keys falls back to the base locale per message.
114
185
  4. Content is reachable via the `/<lang>` URL prefix (`detectLocale` already resolves it); the base locale needs no prefix. Expose the switcher via `useLocale().setLocale`.
186
+ 5. Only if the app should **open** in the new language rather than merely offer it: set `defaultLocale` (`active.json` / `fabric i18n --default-locale fr`). Adding a locale and changing the default are different asks — do the second only when asked.
115
187
 
116
188
  ## i18n debug mode (find inlined strings)
117
189
 
@@ -137,6 +209,9 @@ The wrapper alternative — a module that walks the namespace and pipes each mes
137
209
  ## What NOT to do
138
210
 
139
211
  - Don't hardcode display strings "just for now" — the message is the work.
212
+ - Don't set `baseLocale` to anything but `en`, whatever language the product speaks. It names the source catalogue, and a project without one can never add a language. Set `defaultLocale` instead.
213
+ - Don't let a non-English UI reach the identifiers. Functions, components, types, files, tables and columns are English in every project; the product's language lives in `messages/*.json` and nowhere else.
214
+ - Don't translate message **keys**. `auth__login__title` stays English in `de.json`; only the value changes.
140
215
  - Don't edit or commit anything under `src/paraglide/` — it's regenerated; change `messages/*.json` instead.
141
216
  - **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
217
 
@@ -184,7 +184,82 @@ const earlyMiddleware = pikkuMiddleware({
184
184
 
185
185
  Within the same priority level, the collection order above is preserved. Use priority when a middleware must run before/after others regardless of where it was registered (e.g. telemetry wrapping everything, session extraction before auth checks).
186
186
 
187
- ## Service-to-Service Bearer Auth (canonical pattern)
187
+ ## ⛔ MACHINE AUTH: THE TOKEN BECOMES A SESSION. ⛔
188
+
189
+ **A caller that has an identity — a sandbox, a deployed stage, a pool host, a device — is authenticated ONCE, in middleware, which calls `setSession`. The function is then an ordinary `pikkuFunc` gated with `scopes`. It reads `session`. It NEVER re-derives who the caller is.**
190
+
191
+ Either a function is sessionless (genuinely public) or it has a session. Anything in between — a token verified inside `func`, a token verified in a `permissions` check that returns `true`, an identity passed in the input schema, the same resolver memoised per request so N functions can each call it — is the anti-pattern this section exists to kill.
192
+
193
+ ```typescript
194
+ // middleware.ts — resolve the bearer ONCE, for every route
195
+ const sandboxBearerAuth = pikkuMiddleware<SingletonServices>(
196
+ async ({ kysely, auth }, { http, getSession, setSession }, next) => {
197
+ if (await getSession?.()) return next()
198
+ const header = http?.request?.header?.('authorization')
199
+ if (!header?.startsWith('Bearer ')) return next()
200
+ const sandbox = await resolveSandboxSession(kysely, auth, header.slice(7).trim())
201
+ if (sandbox) {
202
+ setSession?.({ userId: sandbox.createdByUserId ?? sandbox.sandboxInstanceId,
203
+ orgId: sandbox.organizationId, scopes: ['machine:sandbox'], sandbox } as UserSession)
204
+ }
205
+ return next()
206
+ },
207
+ )
208
+
209
+ addHTTPMiddleware('*', [cors(...), betterAuthSession(), apiBearerAuth, sandboxBearerAuth as any])
210
+ ```
211
+
212
+ ```typescript
213
+ // functions/report-something.function.ts
214
+ export const reportSomething = pikkuFunc({
215
+ expose: true,
216
+ scopes: ['machine:sandbox'], // ← the gate. Enforced by the runner, seen by the inspector.
217
+ input: ReportSomethingInput,
218
+ output: ReportSomethingOutput,
219
+ func: async ({ kysely }, input, { session }) => {
220
+ const sandbox = sandboxOf(session) // ← narrowing only, no verification
221
+ ...
222
+ },
223
+ })
224
+ ```
225
+
226
+ An unresolved token leaves the session unset and the function throws `MissingSessionError` — 401, for free. Declare the scope tree once with `defineScope` (see `pikku-permissions`).
227
+
228
+ ### It MUST be `addHTTPMiddleware`, never `addTagMiddleware`
229
+
230
+ **A session set in tag middleware is invisible to the function when the call arrives over `POST /rpc/:rpcName`.** Tag middleware runs inside `runPikkuFunc`, and the RPC dispatch calls it without a `sessionService`, so `invocationWire.session` is never re-read after your `setSession` — the function sees the session the OUTER wire had, which is none. `addHTTPMiddleware('*')` runs on the `/rpc` route itself, before its handler, and that session is the one the dispatched function inherits. Tag middleware is still right for a gate that only says yes/no.
231
+
232
+ ### A cron is a machine identity too — set it in the task's own middleware
233
+
234
+ A scheduled task has no caller and no header, but it is still a machine principal, and without a session it cannot invoke a gated RPC or be attributed in anything it writes. Give it one the same way, in the task's own `middleware`:
235
+
236
+ ```typescript
237
+ const cronSession = pikkuMiddleware(async (_services, { scheduledTask, setSession }, next) => {
238
+ setSession?.({ userId: `cron:${scheduledTask?.name}`, scopes: ['machine:cron'] } as UserSession)
239
+ return next()
240
+ })
241
+
242
+ wireScheduler({
243
+ name: 'tickVirtualUserSchedules',
244
+ schedule: '*/15 * * * *',
245
+ middleware: [cronSession],
246
+ func: tickVirtualUserSchedules,
247
+ })
248
+ ```
249
+
250
+ One `const`, not a `machineSession(name)` factory: the inspector rejects a bare `pikkuMiddleware()` that is not assigned to a variable or object property, and the task name is on the wire anyway. Parameterised middleware goes through `pikkuMiddlewareFactory`.
251
+
252
+ The task can then be a thin `rpc.invoke('someGatedRpc')` against the same entry point a person calls, instead of factoring the logic into a `lib/` helper purely to route around the missing identity.
253
+
254
+ Unlike tag middleware over `/rpc`, this works: `runScheduledTask` builds its wire with a `sessionService`, so the session set here is the one the function is frozen with. And unlike a person, a cron is **not** a user row — inventing a seeded account for it buys a phantom member in every list, seat count and bill, and a per-org membership that a cross-org sweep has to ignore anyway. Platform-wide authority is a scope, not a membership.
255
+
256
+ ### The one sessionless exception: bootstrap
257
+
258
+ An endpoint that runs BEFORE the caller has an identity — registering a new host with a shared bootstrap key, a login, a device-code request — has no session to set. That one stays `pikkuSessionlessFunc` and declares its gate in `permissions` (see `pikku-permissions`).
259
+
260
+ ## Service-to-Service Bearer Auth (gate-only pattern)
261
+
262
+ Use this when the callee needs to know only THAT the caller is trusted, not WHICH caller it is. If it needs to know which, use the session pattern above.
188
263
 
189
264
  A server that exposes RPCs only to a trusted caller (e.g. an API calling a machine-agent). Auth lives in a tag middleware — NOT in the function body. Authorization/permission checks belong in the `permissions` field (see `pikku-permissions`), never inside `func`.
190
265
 
@@ -13,6 +13,10 @@ installGroups: [core]
13
13
 
14
14
  # Pikku Permissions
15
15
 
16
+ ## ⛔ FIRST: is the caller a machine with a token? ⛔
17
+
18
+ **Then this is NOT a permissions problem.** Resolve the token in `addHTTPMiddleware('*')` middleware that calls `setSession`, make the function a `pikkuFunc`, and gate it with `scopes`. A `permissions` check that verifies a bearer token and returns `true` is authentication wearing an authorization hat — and it leaves the function sessionless, so every body still has to work out who called it. See the machine-auth section of `pikku-middleware`. The only exception is a bootstrap endpoint whose caller has no identity yet (a shared-secret registration, a login): that one is sessionless and declares its gate here.
19
+
16
20
  ## The Rule
17
21
 
18
22
  **ALWAYS put authorization checks in the `permissions` field of `pikkuFunc` or `pikkuSessionlessFunc` — NEVER inside the `func` body.**
@@ -356,6 +356,56 @@ const order = await scenario.do(
356
356
 
357
357
  Reach for a `pikkuScenarioStep` on the non-browser side only when one intent genuinely spans several RPCs, or when the step asserts something the RPC result alone does not say.
358
358
 
359
+ ### What language the prose is in
360
+
361
+ A scenario carries two kinds of text, and they do not share a language.
362
+
363
+ **Identifiers are English.** The exported const (`buysAnApple`,
364
+ `credentialFeature`), the step's `name` — which is its `pikkuFuncId`, the typed
365
+ string the generated step map is keyed by — the file name, and every helper in
366
+ `*.browser.ts`. These bind to generated code and to `pikku scenario list`; they
367
+ are English in every project regardless of who the product is for or what
368
+ language the team speaks. There is no setting that changes this.
369
+
370
+ **Prose follows `locale` in `pikku.config.json`** (default `en`). That is a
371
+ step's `description` and `template`, a feature's `name` and `description`, a
372
+ scenario's `title`, and the positional step names passed to
373
+ `scenario.given/when/then`. Read the field before you write any of them.
374
+
375
+ This split is the same one the feature table already states — _the export
376
+ identifier is the feature's id; `name` is the human-readable label_ — applied to
377
+ language. The report is the deliverable, and it is read by the team; the
378
+ identifier is an API, and it is read by the toolchain.
379
+
380
+ ```typescript
381
+ // pikku.config.json: { "locale": "de" }
382
+ export const buysAnApple = pikkuScenarioStep<{ qty: number }, { orderId: string }>({
383
+ name: 'buysAnApple', // identifier — English, always
384
+ description: 'kauft einen Apfel', // prose — follows locale
385
+ template: 'kauft {qty} Äpfel', // prose — follows locale
386
+ actor: true,
387
+ default: async (_services, { qty }, { actor }) =>
388
+ await actor.invoke('placeOrder', { qty }),
389
+ })
390
+ ```
391
+
392
+ Note what does **not** change: `placeOrder` is still `placeOrder`, and the file
393
+ is still `apple.scenario.ts`.
394
+
395
+ A product with a non-English UI is not on its own a reason to set `locale` — that
396
+ is the app's language, not the team's. Ask, or leave it `en`.
397
+
398
+ **Where a non-`en` `locale` still shows English, today.** The reporter composes a
399
+ sentence as `<Keyword> the <actor> <template>` (`composeStepProse`), and both the
400
+ keyword and the article `the` are English literals. The Console translates the
401
+ Given/When/Then keywords into its own UI language; the CLI reporter does not, and
402
+ nothing translates `the`. So `locale: "de"` gives you German step prose inside an
403
+ English frame — `Given the shopper kauft 1 Äpfel`. Write templates that read
404
+ acceptably in that frame rather than trying to defeat it. A second gap: where a
405
+ function or scenario declares no `title`, the Console falls back to splitting the
406
+ **identifier** into an English-looking label (`toEnglishName`), so under a
407
+ non-`en` `locale` meta is worth authoring rather than leaving to the fallback.
408
+
359
409
  ### `then` bindings are witnesses, not alternatives
360
410
 
361
411
  This is the one place the surface bindings do **not** behave like a switch, and it is the part worth reading twice.
@@ -783,6 +833,7 @@ Services are plain objects — a Pikku function is pure business logic, so a moc
783
833
  | Assuming a clean database | There is no state reset — it may be a staging server. Scope what you create. |
784
834
  | `sleep()` before asserting | Use `expectEventually`. |
785
835
  | A step named `clicksAddToBasket` / `opensThePage` | That is an action, not an intent. Name the step for what the actor wanted; put the clicking in a utility. |
836
+ | A step named `kauftEinenApfel` / a `vorgang` table | Identifiers are English in every project. The German belongs in `description` / `template`, and only when `pikku.config.json` sets `locale`. |
786
837
  | A browser step that assumes it is already on a page | It can then only run mid-flow. Arrive first — check the URL, navigate if needed. |
787
838
  | `getByLabel('Full Name')` in a translated app | Passes only in the base locale, and a copy edit breaks it as an unexplained timeout. Locate by message key. |
788
839
  | A `browser` binding guarding `if (!browser)` | The binding guarantees it. The guard hides the real error, which is a missing actor (`PKU677`). |
@@ -50,6 +50,29 @@ wireScheduler({
50
50
  })
51
51
  ```
52
52
 
53
+ ### Giving a cron an identity
54
+
55
+ A cron has no caller, so it runs with **no session at all**: it cannot invoke a
56
+ permission- or scope-gated RPC, and nothing it writes can be attributed. A
57
+ scheduled task is a machine principal — give it a session in the task's own
58
+ `middleware`, exactly as a bearer-authenticated caller gets one:
59
+
60
+ ```typescript
61
+ wireScheduler({
62
+ name: 'bookingLifecycleDaily',
63
+ schedule: '0 3 * * *',
64
+ middleware: [cronSession],
65
+ func: bookingLifecycleDaily,
66
+ })
67
+ ```
68
+
69
+ `runScheduledTask` builds its wire with a `sessionService`, so a `setSession`
70
+ here is the session the function is frozen with. See the machine-auth section of
71
+ `pikku-middleware` for the factory and for why a cron is not a user row.
72
+
73
+ A scheduler service running a task on someone's behalf can pass a session
74
+ directly instead: `runScheduledTask({ name, session })`.
75
+
53
76
  ### Wire Object (`wire.scheduledTask`)
54
77
 
55
78
  Inside scheduled functions: