@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.
- package/CHANGELOG.md +69 -0
- package/dist/skills.gen.js +1 -1
- package/package.json +1 -1
- package/skills/pikku-better-auth/SKILL.md +210 -24
- package/skills/pikku-build-app/SKILL.md +65 -2
- package/skills/pikku-build-platform/SKILL.md +7 -0
- package/skills/pikku-build-quick/SKILL.md +12 -1
- package/skills/pikku-concepts/SKILL.md +91 -1
- package/skills/pikku-fabric/SKILL.md +21 -6
- package/skills/pikku-feature/SKILL.md +7 -0
- package/skills/pikku-i18n/SKILL.md +77 -2
- package/skills/pikku-middleware/SKILL.md +76 -1
- package/skills/pikku-permissions/SKILL.md +4 -0
- package/skills/pikku-scenario/SKILL.md +51 -0
- package/skills/pikku-schedule/SKILL.md +23 -0
|
@@ -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
|
-
##
|
|
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:
|