@pikku/skills 0.12.15 → 0.12.16

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/skills",
3
- "version": "0.12.15",
3
+ "version": "0.12.16",
4
4
  "description": "The Pikku agent skills — the instruction set coding agents read to build, wire and deploy Pikku projects",
5
5
  "author": "yasser.fadl@gmail.com",
6
6
  "license": "MIT",
@@ -137,21 +137,21 @@ resolves the caller's scopes through the registered `ScopeService` and checks th
137
137
  `admin:*` tree (`ADMIN_SCOPES` exports the ids so you never spell them as bare
138
138
  strings):
139
139
 
140
- | Gate | Scope required |
141
- | -------------------------------------------------------------------- | ------------------------ |
142
- | `impersonation` (`betterAuthSession` / `betterAuthStatelessSession`) | `admin:impersonate` |
143
- | `credentialOAuth`'s `canLinkSingleton` | `admin:credentials:link` |
144
- | the console's user directory | `admin:users:list` |
145
- | create a user out of band | `admin:users:create` |
146
- | ban / unban | `admin:users:ban` |
147
- | delete a user and their data | `admin:users:remove` |
148
- | revoke a user's sessions | `admin:users:sessions` |
149
- | set a user's password | `admin:users:password` |
150
- | read credential values and who holds them | `admin:credentials:read` |
140
+ | Gate | Scope required |
141
+ | -------------------------------------------------------------------- | -------------------------- |
142
+ | `impersonation` (`betterAuthSession` / `betterAuthStatelessSession`) | `admin:impersonate` |
143
+ | `credentialOAuth`'s `canLinkSingleton` | `admin:credentials:link` |
144
+ | the console's user directory | `admin:users:list` |
145
+ | create a user out of band | `admin:users:create` |
146
+ | ban / unban | `admin:users:ban` |
147
+ | delete a user and their data | `admin:users:remove` |
148
+ | revoke a user's sessions | `admin:users:sessions` |
149
+ | set a user's password | `admin:users:password` |
150
+ | read credential values and who holds them | `admin:credentials:read` |
151
151
  | set and delete credentials | `admin:credentials:manage` |
152
- | view declared scopes, roles, and who holds them | `admin:scopes:read` |
153
- | create roles, change their scopes, grant them | `admin:scopes:manage` |
154
- | read the audit trail | `admin:audit:read` |
152
+ | view declared scopes, roles, and who holds them | `admin:scopes:read` |
153
+ | create roles, change their scopes, grant them | `admin:scopes:manage` |
154
+ | read the audit trail | `admin:audit:read` |
155
155
 
156
156
  Holding the bare `admin` scope satisfies all of them — a parent grant covers
157
157
  everything nested beneath it — so `admin` is the direct replacement for the old
@@ -247,6 +247,112 @@ create a session for a banned user, lapsing an expired ban as it goes. It makes
247
247
  no authorization decision — who may ban is decided by `admin:users:ban` — so it
248
248
  never needs to know about scopes or roles.
249
249
 
250
+ ### The plugins `@pikku/better-auth` ships
251
+
252
+ Five, all imported from the package root and passed to `betterAuth({ plugins })`
253
+ like any other. None is automatic — an app wires the ones it needs.
254
+
255
+ | Plugin | Plugin `id` | Adds | Use it when |
256
+ | ------------------- | ------------------ | ----------------------------------------------------------------------- | ------------------------------------------------------------- |
257
+ | `ban()` | `pikku-ban` | `user.banned/banReason/banExpires` | You ban users (the schema + enforcement half of the above) |
258
+ | `actor()` | `actor` | `POST /sign-in/actor`, `user.actor` | Scenarios or a dev switcher sign in as a persona |
259
+ | `credentialOAuth()` | `credential-oauth` | `POST /credential-oauth/link`, `/credential-oauth/callback/:providerId` | An app links OAuth2 **API credentials** for a user |
260
+ | `delegatedAuth()` | `delegated-auth` | `POST /sign-in/delegated` | An imported upstream API is the system of record for identity |
261
+ | `fabric()` | `fabric` | `POST /sign-in/fabric` | A Fabric-deployed app lets a control-plane operator in |
262
+
263
+ The plugin's `id` is what better-auth stores; the **export name** is what the
264
+ inspector reads off your `plugins` array and what generated metadata is keyed
265
+ by, so the two differ for `ban` and `delegatedAuth`.
266
+
267
+ #### `credentialOAuth()` — link API credentials, not identities
268
+
269
+ ```typescript
270
+ credentialOAuth({
271
+ config: [
272
+ {
273
+ providerId: 'github',
274
+ type: 'wire',
275
+ clientId,
276
+ clientSecret,
277
+ authorizationUrl,
278
+ tokenUrl,
279
+ scopes: ['repo'],
280
+ },
281
+ { providerId: 'slack', type: 'singleton' /* … */ },
282
+ ],
283
+ scopeService,
284
+ logger,
285
+ })
286
+ ```
287
+
288
+ Wraps better-auth's `genericOAuth` to keep its token exchange and refresh, and
289
+ replaces only the two identity-bound endpoints. `genericOAuth`'s own
290
+ `/oauth2/link` models an identity **provider**: it demands a userinfo response
291
+ and refuses to link when the provider's email differs from the user's. A
292
+ credential is not an identity — most credential providers expose only
293
+ `/authorize` and `/token` — so the account row is keyed on _whose_ credential it
294
+ is (`accountId` = the linking user's id), making `(providerId, userId)` unique
295
+ by construction. Tokens land in better-auth's `account` table, so
296
+ `auth.api.getAccessToken()` refreshes them on read.
297
+
298
+ `type` decides the blast radius:
299
+
300
+ - **`wire`** — every user links their own, and the credential is read on
301
+ the wire that runs as them. Signed in is enough.
302
+ - **`singleton`** — one token the whole app shares, owned by a reserved
303
+ `pikku-platform` user row created on demand. Rebinding it changes the
304
+ credential for _everyone_, so it is gated on `admin:credentials:link` (or the
305
+ `admin` root above it), and **fails closed** with no `ScopeService`. Override
306
+ the whole gate with `canLinkSingleton`.
307
+
308
+ An undeclared `providerId` is a 404; an anonymous caller a 401; a refused
309
+ singleton a 403 that leaves no platform user behind.
310
+
311
+ #### `delegatedAuth()` — the upstream API is the identity provider
312
+
313
+ ```typescript
314
+ delegatedAuth({
315
+ authenticate: async ({ email, password, apiKey }) => upstream.login(...),
316
+ storeCredential: (userId, identity) =>
317
+ credentialService.set('acme', identity.credential, userId),
318
+ defaultRole: 'member',
319
+ mapRole: (upstreamRole) => ROLE_MAP[upstreamRole],
320
+ scopeService,
321
+ logger,
322
+ })
323
+ ```
324
+
325
+ `POST /sign-in/delegated` forwards the credentials the user already has to
326
+ `authenticate`. On success it JIT-provisions a real user row (email-keyed and
327
+ `emailVerified` — the upstream just verified them), links it via an `account`
328
+ row (`providerId: 'delegated'`, `accountId: externalId`), persists the upstream
329
+ token **before** minting the session, and returns a normal session cookie.
330
+ Passwords are never stored. Exactly one upstream per app: additional imported
331
+ APIs are linked integrations (`credentialOAuth`), not extra login methods.
332
+
333
+ A resolved role is granted through the `ScopeService` as a pikku role, so it
334
+ lands in `pikku_user_role` rather than on a column. A role the app never
335
+ defined is a provisioning gap, not a sign-in failure — the grant is dropped with
336
+ a warning and the user still gets in.
337
+
338
+ `storeCredential` failing, by contrast, **fails the sign-in**: every proxied
339
+ call would be dead anyway.
340
+
341
+ #### `fabric()` — control-plane operator sign-in
342
+
343
+ ```typescript
344
+ fabric({ publicKey: FABRIC_AUTH_PUBLIC_KEY, scopeService, logger })
345
+ ```
346
+
347
+ `POST /sign-in/fabric` verifies a short-lived RS256 token that the Fabric
348
+ control plane signed for an operator session, then signs them into a synthetic
349
+ `fabric-<id>@fabric.internal` row holding the `admin` scope. Asymmetric on
350
+ purpose: the app holds only the public key, so it can never forge an operator
351
+ login, and the same `FABRIC_AUTH_PUBLIC_KEY` is distributed to every stage with
352
+ no per-environment secret. A missing or empty key disables the endpoint, and a
353
+ token whose `purpose` claim is not `fabric-admin` is rejected. Without a
354
+ `ScopeService` the operator signs in holding nothing.
355
+
250
356
  ### 2. Production database adapter
251
357
 
252
358
  For real deployments swap `memoryAdapter` for the Kysely adapter backed by an injected DB. Better Auth owns its own tables (`user`, `session`, `account`, `verification`, plus plugin tables) — generate its schema with `npx @better-auth/cli generate` and apply it as a migration.
@@ -371,14 +477,35 @@ plugins: [actor({ secret: SCENARIO_ACTOR_SECRET })]
371
477
  session cookie. `secret` may also be a (possibly async) function, so it can come
372
478
  off the secrets service instead of a captured value.
373
479
 
480
+ **Which command is running decides whether it works, not whether a secret is
481
+ set.** `pikku dev` sets `PIKKU_DEV_ACTOR_SIGN_IN` and mints an ephemeral
482
+ `SCENARIO_ACTOR_SECRET` for the run, so local development needs no configuration
483
+ at all. Everywhere else the endpoint refuses (`Actor sign-in is disabled outside
484
+ \`pikku dev\``) — `pikku serve` clears the marker outright, so a secret that
485
+ leaked into a production environment enables nothing and gets a warning naming
486
+ itself instead.
487
+
488
+ A stage that genuinely must run scenarios opts in on purpose, with
489
+ `PIKKU_ALLOW_ACTOR_SIGN_IN=passwordless-actor-sign-in`. Any other value is
490
+ ignored and warned about, so the hatch cannot be opened by copying a `true` from
491
+ the line above, and it is the only hatch — there is no build-time option, because
492
+ an option compiled into the bundle cannot be audited from the environment it
493
+ runs in.
494
+
495
+ **Signing in and provisioning are separate powers.** An unknown address becomes
496
+ an `actor: true` row only under `pikku dev`. With the opt-in set, a stage signs
497
+ in as the personas the deployment provisioned when it started and refuses
498
+ everything else (`No actor account exists for that address`), so holding the
499
+ secret on such a stage does not let anyone invent identities. Those rows are
500
+ written by `provisionPersonas` from `@pikku/better-auth`, called in the server's
501
+ own lifecycle, so provisioning needs no actor secret and works on a stage whose
502
+ endpoint is shut.
503
+
374
504
  **`SCENARIO_ACTOR_SECRET` is a credential as powerful as the most privileged
375
- persona.** `pikku persona sync` grants declared roles to actor accounts, so an
376
- `admin` persona is an actor holding real admin — anyone with the secret can take
377
- a session as one. Keep the endpoint **off outside development and sandbox
378
- deployments**: leave the secret unset on any stage that should not run scenarios,
379
- which is the supported switch (`Actor sign-in is not configured`), rather than
380
- conditionally registering the plugin. Do not treat "actors only" as a licence to
381
- enable it in production.
505
+ persona.** Provisioning grants declared roles to actor accounts, so an
506
+ `admin` persona is an actor holding real admin — anyone with the secret _and_ the
507
+ opt-in can take a session as one. Do not treat "actors only" as a licence to open
508
+ the hatch in production.
382
509
 
383
510
  Within that boundary, three properties bound the damage:
384
511
 
@@ -386,9 +513,9 @@ Within that boundary, three properties bound the damage:
386
513
  column; an email matching a row without it is refused with `User is not an
387
514
  actor`. So the secret cannot take over a **real user's** account — the blast
388
515
  radius is the actor accounts and whatever roles they were granted.
389
- - **Unknown emails are created**, flagged `actor: true`, so a scenario that
390
- declares a new persona needs no seed step. Note the flip side: the secret
391
- mints accounts, it does not merely use existing ones.
516
+ - **Unknown emails are created only under `pikku dev`**, flagged `actor: true`,
517
+ so a local scenario declaring a new persona needs no seed step. Anywhere else
518
+ the account has to have been provisioned at boot first.
392
519
  - **The comparison is constant-time and length-hiding**, so a wrong secret leaks
393
520
  neither the length nor a prefix of the right one.
394
521
 
@@ -396,6 +523,65 @@ This is the endpoint `pikku scenario` signs its actors in through, and the one
396
523
  the frontend dev switcher posts to — see `pikku-scenario` for declaring the
397
524
  actors and `pikku-react` for `useDevActors()`.
398
525
 
526
+ ### Provisioning personas (`provisionPersonas`)
527
+
528
+ Anywhere but `pikku dev`, the accounts have to exist before anyone signs in.
529
+ The deployment creates them itself, from its own lifecycle:
530
+
531
+ ```ts
532
+ import { provisionPersonas } from '@pikku/better-auth'
533
+ import {
534
+ personaConfigs,
535
+ personaEnvironments,
536
+ } from '#pikku/pikku-personas.gen.js'
537
+
538
+ await provisionPersonas(singletonServices, {
539
+ personas: personaConfigs,
540
+ environments: personaEnvironments,
541
+ })
542
+ ```
543
+
544
+ It runs where the database already is, which is the point: the CLI has no
545
+ connection to a deployed environment's database — it resolves one from the local
546
+ project config — so a `pikku persona sync staging` that wrote rows would write
547
+ them to whatever database the checkout happened to point at. `pikku persona sync
548
+ <environment>` still exists, and reports who that environment will provision and
549
+ why anyone was skipped, which is what you run _before_ the deploy.
550
+
551
+ It creates missing accounts as `actor: true`, applies the roles each persona
552
+ declares, and is additive — it never revokes. `PIKKU_ENV` (or an explicit
553
+ `environment`) selects who is eligible, through the same rule that decides who
554
+ may run there; an address already held by a real, non-actor user throws rather
555
+ than being granted the persona's roles.
556
+
557
+ **Deleting a persona does not delete its account.** Being additive leaves a hole:
558
+ the account keeps every role it was granted, and the actor endpoint authenticates
559
+ on the `actor` column alone without consulting the declaration — so an `admin`
560
+ persona nobody declares any more is still a live way in wherever that endpoint is
561
+ open. By default provisioning warns about those accounts and changes nothing.
562
+ `orphans: 'ban'` shuts them:
563
+
564
+ ```ts
565
+ await provisionPersonas(singletonServices, {
566
+ personas: personaConfigs,
567
+ environments: personaEnvironments,
568
+ orphans: 'ban',
569
+ })
570
+ ```
571
+
572
+ It writes the same `banned` column the console's ban RPC writes (so it needs the
573
+ `ban()` plugin wired, and says so if it isn't), revokes the account's sessions,
574
+ and leaves the row, its grants and its history intact — provisioning lifts the
575
+ ban again by itself if the persona comes back. Deleting is deliberately not
576
+ offered: an actor row is referenced by whatever those scenarios did while it
577
+ existed.
578
+
579
+ `report` is the default because a rolling deploy runs the new replica's
580
+ provisioning while the old replica is still serving, so for the length of that
581
+ overlap "no persona claims this" is a statement about the newer declaration only.
582
+ A persona pinned to another environment counts as unclaimed here — it has no
583
+ business holding a signable account in an environment its own rule refuses it.
584
+
399
585
  ---
400
586
 
401
587
  ## Secret Management
@@ -325,7 +325,9 @@ or not the note says `built`.
325
325
  for working on an empty state the test data would hide). Because reset always
326
326
  arrives at a database it just wiped, **the seed file is plain `INSERT`s** — no
327
327
  `ON CONFLICT DO NOTHING`, no `INSERT OR IGNORE`. Nothing ever applies it
328
- twice, so it never has to defend itself.
328
+ twice, so it never has to defend itself. It is also **local only** — no deploy
329
+ applies it, so anything the app would be broken without in production is
330
+ configuration and belongs in a migration, not here.
329
331
  Do this generously and do it now: an empty app demos badly and critiques
330
332
  badly, and you cannot judge a screen's hierarchy, overflow, or truncation
331
333
  against zero rows. Seed rows each persona sees differently — with an ownership
@@ -115,7 +115,9 @@ Then, in this order — it is the order codegen depends on:
115
115
  2. **Seed** — rows in `db/sqlite-dev-seed.sql`. There is no seed command:
116
116
  `bunx --bun pikku db reset` wipes, migrates and seeds in one go, and is the
117
117
  only thing that applies the file. It always starts from a wiped database, so
118
- the file is plain `INSERT`s — no `ON CONFLICT DO NOTHING`. **Be generous, and
118
+ the file is plain `INSERT`s — no `ON CONFLICT DO NOTHING`. It is local only:
119
+ no deploy applies it, so anything the app cannot run without belongs in a
120
+ migration instead. **Be generous, and
119
121
  seed rows for both personas.** An empty app demos badly, and you cannot see a
120
122
  layout break against zero rows.
121
123
  3. **Functions** — one `pikkuFunc` per `*.function.ts`, `expose: true`. Pikku
@@ -109,11 +109,25 @@ plain `INSERT`s** — no `INSERT OR IGNORE`, no `ON CONFLICT DO NOTHING`, no
109
109
  you find yourself reaching for an idempotent form, that's a sign the data wants
110
110
  to be a migration instead.
111
111
 
112
- This is **test data only**: enough rows that a fresh dev database isn't an empty
113
- app. It never reaches staging or production — reset refuses `NODE_ENV=production`
114
- and refuses a database outside the runtime directory. Anything a real environment
115
- needs — accounts, role grants — is provisioning, not seeding, and belongs in
116
- `pikku persona sync` or a migration.
112
+ This is **local dev data only**: enough rows that a fresh dev database isn't an
113
+ empty app. Nothing else ever runs it. A deployed stage applies `db/<engine>/*.sql`
114
+ and stops there — reset refuses `NODE_ENV=production` and refuses a database
115
+ outside the runtime directory, and no deploy step reaches for the seed file.
116
+
117
+ So the test is not "is this row realistic?", it is **"would the app be broken
118
+ without it in production?"** If yes, it is configuration and belongs in a
119
+ migration, however much it looks like sample data. A venue and its rooms, a
120
+ product catalogue, a tenant, a country list, the organization the whole
121
+ deployment hangs off — all configuration. Accounts and role grants are
122
+ provisioning: `provisionPersonas` at deployment, or a migration. What is left
123
+ over is the seed's job — the bookings, orders and messages a demo needs and a real
124
+ environment starts without.
125
+
126
+ Get this wrong and it hides: the app is perfect locally, where reset has just
127
+ run, and every deployed environment comes up with empty tables. The signature is
128
+ a stage whose pages return 200 — the shell renders fine — while its first data
129
+ read throws `no result` or a foreign-key violation on a row the seed was
130
+ silently supplying.
117
131
 
118
132
  A Better Auth app has a second constraint: the plugins you enable (`ban()`,
119
133
  `actor()`, …) each declare columns, and `pikku db migrate` refuses to run while