@pikku/skills 0.12.25 → 0.12.27
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 +43 -0
- package/dist/skills.gen.js +1 -1
- package/package.json +1 -1
- package/skills/pikku-a11y/SKILL.md +59 -0
- package/skills/pikku-admin-to-fabric/SKILL.md +212 -0
- package/skills/pikku-architect/SKILL.md +1 -0
- package/skills/pikku-auth/references/better-auth.md +125 -7
- package/skills/pikku-blueprint-to-fabric/SKILL.md +377 -0
- package/skills/pikku-blueprint-to-fabric/scripts/inventory.mjs +367 -0
- package/skills/pikku-build/SKILL.md +1 -0
- package/skills/pikku-build/references/app.md +1 -1
- package/skills/pikku-build/references/multi-app.md +55 -0
- package/skills/pikku-build/references/ship.md +2 -2
- package/skills/pikku-fabric/SKILL.md +35 -18
- package/skills/pikku-i18n/SKILL.md +5 -3
- package/skills/pikku-knowledge/SKILL.md +1 -0
- package/skills/pikku-list-query/SKILL.md +163 -0
- package/skills/pikku-permissions/SKILL.md +126 -0
- package/skills/pikku-react/references/client.md +20 -0
- package/skills/pikku-realtime/SKILL.md +147 -0
- package/skills/pikku-seo/SKILL.md +133 -0
- package/skills/pikku-software-archaeology/SKILL.md +1 -0
- package/skills/pikku-webhook/SKILL.md +25 -0
- package/skills/pikku-workflow/SKILL.md +37 -0
package/package.json
CHANGED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pikku-a11y
|
|
3
|
+
description: >-
|
|
4
|
+
Accessibility rules (WCAG 2.2) for the app UI: labeled inputs, real buttons/links, keyboard and focus, contrast and not-color-alone, modals, reduced motion.
|
|
5
|
+
TRIGGER when: building forms or any interactive UI, icon-only buttons, modals/drawers, tables/lists with actions, keyboard/focus work, or the user mentions accessibility / screen readers / WCAG.
|
|
6
|
+
DO NOT TRIGGER when: working on backend functions, database, or deployment with no UI.
|
|
7
|
+
installGroups: [client]
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Accessibility Rules
|
|
11
|
+
|
|
12
|
+
Mantine components are accessible ONLY when used properly — the rules below are the
|
|
13
|
+
"properly". They apply to every page; heading order, landmarks, and image alt text are
|
|
14
|
+
covered in the `pikku-seo` skill and apply app-wide, not just on public pages.
|
|
15
|
+
|
|
16
|
+
## Every input has a label
|
|
17
|
+
|
|
18
|
+
- Use the `label` prop on every Mantine input — a placeholder is NOT a label (it
|
|
19
|
+
disappears on input and is never announced as one). Placeholder = example value only.
|
|
20
|
+
- Use the `error` and `description` props for validation/help text — Mantine associates
|
|
21
|
+
them with the input for screen readers; a loose `<Text c="red">` next to the field
|
|
22
|
+
does not.
|
|
23
|
+
- Icon-only controls (`ActionIcon`, icon `Button`) MUST have `aria-label={m.key()}`
|
|
24
|
+
naming the action ("Delete item", not "Trash icon").
|
|
25
|
+
|
|
26
|
+
## Interactive = a real button or link
|
|
27
|
+
|
|
28
|
+
- Never `onClick` on a `div`/`Box`/`Card` — it is invisible to keyboard and screen
|
|
29
|
+
readers. Use `Button`, `ActionIcon`, `UnstyledButton`, or `<Link>`; navigation is a
|
|
30
|
+
link (href), actions are buttons.
|
|
31
|
+
- Everything reachable by Tab, activatable by Enter/Space. Never remove focus outlines
|
|
32
|
+
(the theme owns the focus ring), never set `tabIndex` greater than 0, never trap focus
|
|
33
|
+
yourself.
|
|
34
|
+
- Whole-row/whole-card click: put the button/link INSIDE with the row as its label —
|
|
35
|
+
don't make the container clickable and unfocusable.
|
|
36
|
+
|
|
37
|
+
## Don't say it with color alone
|
|
38
|
+
|
|
39
|
+
- Status must carry text or an icon, not only a color: a Badge says "Overdue", a form
|
|
40
|
+
error has a message — a red tint by itself is invisible to colorblind users.
|
|
41
|
+
- Contrast comes from the theme; don't undermine it by stacking `c="dimmed"` on small
|
|
42
|
+
text over tinted backgrounds. Body copy stays at least AA-readable.
|
|
43
|
+
- Touch targets: WCAG 2.2 minimum 24px — don't shrink `ActionIcon`/`Checkbox` below
|
|
44
|
+
size `sm`, and keep adjacent row actions spaced.
|
|
45
|
+
|
|
46
|
+
## Overlays and motion
|
|
47
|
+
|
|
48
|
+
- Modals/drawers: use Mantine `Modal`/`Drawer` and ALWAYS pass `title` — that is what
|
|
49
|
+
gets announced; focus trap and Escape come built in. (This project uses drawers, not
|
|
50
|
+
dialogs.)
|
|
51
|
+
- Landing-page animation (the only custom-CSS surface) respects
|
|
52
|
+
`prefers-reduced-motion: reduce` — gate transforms/parallax behind the media query.
|
|
53
|
+
|
|
54
|
+
## Self-check before declaring UI done
|
|
55
|
+
|
|
56
|
+
Tab through the page once: every control reachable and visibly focused, every input
|
|
57
|
+
labeled, every icon button named, every status readable without color. A browser
|
|
58
|
+
scenario proves the flow works, not that it is reachable without a mouse — this
|
|
59
|
+
manual pass is the only check that does.
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pikku-admin-to-fabric
|
|
3
|
+
description: 'Port a legacy back-office admin (ActiveAdmin, Django admin, Rails Admin, Laravel Nova, Filament) to Fabric admin screens, driven by a `.knowledge/` Product Blueprint. Covers the admin-DSL→Fabric mapping (resources→screens, index/column→tables, filter→query params, scope→query variants, member_action/collection_action→pikkuFuncs, permit_params→input schemas), the "the admin is half your app" audit, and admin-specific permissions. TRIGGER when: porting/rebuilding a legacy app that has a generated/DSL-driven admin, or the user says "port the admin screens" / "implement the admin". DO NOT TRIGGER when: no legacy admin exists (use pikku-fabric to build screens fresh), or the app is being extended rather than ported (use pikku-build).'
|
|
4
|
+
installGroups: [fabric]
|
|
5
|
+
argument-hint: '<path to .knowledge/> [resource to port next]'
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Legacy admin → Fabric admin screens
|
|
9
|
+
|
|
10
|
+
## Agent Operating Procedure
|
|
11
|
+
|
|
12
|
+
1. **Count first.** How many commands cite the admin? That number decides whether this is a chore or a third of the project.
|
|
13
|
+
2. **The blueprint already has the commands.** Do not re-derive them from the DSL. Map to them.
|
|
14
|
+
3. **Port the actions before the screens.** A screen with no action behind it is a table; the actions are the product.
|
|
15
|
+
4. **One resource per slice**, same as `pikku-blueprint-to-fabric`. Verify green before the next.
|
|
16
|
+
5. **An admin permission is not a checkbox.** Legacy admins routinely authenticate and do not authorize. Do not port that.
|
|
17
|
+
6. **Record what you did NOT port**, per resource, in the parity report.
|
|
18
|
+
|
|
19
|
+
## The mistake this skill exists to prevent
|
|
20
|
+
|
|
21
|
+
> "It's just the admin — CRUD screens over the same tables. We'll scaffold it at the end."
|
|
22
|
+
|
|
23
|
+
This is wrong in a specific, measurable way, and you can check it in one command
|
|
24
|
+
before you believe anything else in this file:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
node -e "
|
|
28
|
+
const c = require('./.knowledge/commands.json').commands
|
|
29
|
+
const admin = c.filter(x => (x.evidence||[]).some(e => (e.file||'').match(/admin/)))
|
|
30
|
+
console.log(admin.length + ' of ' + c.length + ' commands live in the admin')
|
|
31
|
+
"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
On a real Rails app (Applause, 39 ActiveAdmin resources, 5,545 lines of DSL) the
|
|
35
|
+
answer was **82 of 187 — 44%**, plus 71 of 223 API surfaces. The admin was not a
|
|
36
|
+
side panel over the customer-facing app. It was nearly **half the application's
|
|
37
|
+
write surface**, and a large share of those commands existed *nowhere else*:
|
|
38
|
+
issue a refund, retrigger a payment, queue a sync, impersonate a user, mark a
|
|
39
|
+
blade returned, reassign a company. There is no customer screen for any of them.
|
|
40
|
+
|
|
41
|
+
So: the admin is not the last 10% of the port. Budget it as what the count says.
|
|
42
|
+
|
|
43
|
+
**Corollary — the admin is where the unguarded capabilities live.** A
|
|
44
|
+
`member_action :impersonate` or `:create_stripe_refund` is a command that moves
|
|
45
|
+
money or identity, defended in legacy by nothing more than "you reached an
|
|
46
|
+
`/admin` URL". Every one of these needs a real `pikkuPermission` in the rebuild,
|
|
47
|
+
and writing them is the point of the port, not overhead on top of it.
|
|
48
|
+
|
|
49
|
+
## Stage 0 — Preflight
|
|
50
|
+
|
|
51
|
+
- The `.knowledge/` blueprint must exist and validate (`0 error(s)`). If not, run
|
|
52
|
+
**pikku-software-archaeology** first. This skill maps to the blueprint; it does
|
|
53
|
+
not parse Ruby.
|
|
54
|
+
- Read `parity-*.md` for the domains you are about to touch. Renames decided in an
|
|
55
|
+
earlier slice (a `tenant` that became a `Market`, a `membership_level` that
|
|
56
|
+
became a `certification_level`) are binding here. An admin screen that reintroduces
|
|
57
|
+
the old word undoes the decision.
|
|
58
|
+
- Run the count above and say the number out loud in your plan.
|
|
59
|
+
|
|
60
|
+
## Stage 1 — Inventory the DSL
|
|
61
|
+
|
|
62
|
+
Every generated admin is the same six ideas under different syntax. Inventory
|
|
63
|
+
them, do not read them line by line:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
# ActiveAdmin
|
|
67
|
+
grep -rhoE "^\s{0,4}(index|show|form|filter|scope|action_item|member_action|collection_action|batch_action|permit_params|csv|sidebar|panel|actions)\b" app/admin/*.rb | sort | uniq -c | sort -rn
|
|
68
|
+
grep -rhoE "(member_action|collection_action) :[a-z_]+" app/admin/*.rb | sort -u
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
| Legacy | Also called | Becomes in Fabric |
|
|
72
|
+
|---|---|---|
|
|
73
|
+
| `ActiveAdmin.register X` / `class XAdmin` | resource, ModelAdmin, Nova Resource | one TanStack route + one screen |
|
|
74
|
+
| `index do … column :x` | `list_display`, `columns()` | a Mantine `Table`/`DataTable`, columns from the query's return type |
|
|
75
|
+
| `filter :x` | `list_filter`, `searchable` | typed input fields on the list query |
|
|
76
|
+
| `scope :active` | `get_queryset` variants, `Nova::Filters` | a named variant of the list query — NOT a new function per scope |
|
|
77
|
+
| `form do f.input …` | `fields()`, `fieldsets` | a Mantine form; inputs from the command's zod input schema |
|
|
78
|
+
| `permit_params` | `fields`, `$fillable` | you already have this: it is the command's input schema. Cross-check, don't re-derive. |
|
|
79
|
+
| `member_action :foo` | custom action, `Nova::Actions` | **a `pikkuFunc`** — almost always already in `commands.json` |
|
|
80
|
+
| `collection_action :foo` | bulk action | a `pikkuFunc` taking a set |
|
|
81
|
+
| `batch_action` | admin action | a `pikkuFunc` taking ids[] |
|
|
82
|
+
| `csv do … end` | export | a readonly func returning rows; render client-side |
|
|
83
|
+
| `panel`/`sidebar` | inlines, `relations` | a section on the show screen, fed by a related query |
|
|
84
|
+
| `action_item` | — | a button. It is not a capability; find the action it calls. |
|
|
85
|
+
|
|
86
|
+
**`action_item` vs `member_action` is the distinction that matters.** An
|
|
87
|
+
`action_item` is a *button*; a `member_action` is a *capability*. Legacy files
|
|
88
|
+
pair them, and a fast reader counts the buttons. Count the capabilities.
|
|
89
|
+
|
|
90
|
+
## Stage 2 — Map actions to blueprint commands (do this before any UI)
|
|
91
|
+
|
|
92
|
+
For each `member_action`/`collection_action`, find its command in
|
|
93
|
+
`commands.json`. Three outcomes, and the third is the valuable one:
|
|
94
|
+
|
|
95
|
+
1. **Found** — the archaeology already lifted it (`ArchiveProduct`,
|
|
96
|
+
`CancelInvoice`). Wire the screen to it. Nothing to build.
|
|
97
|
+
2. **Found under a different name** — the blueprint names concepts in domain
|
|
98
|
+
language, the DSL names them after routes. `member_action :rerun` may be
|
|
99
|
+
`RetryWebhookDelivery`. Match on behaviour, not spelling. **Use the blueprint's
|
|
100
|
+
name.**
|
|
101
|
+
3. **Not found** — stop. Either the archaeology missed a command (fix
|
|
102
|
+
`.knowledge/`, do not paper over it here) or the action is dead code. Both are
|
|
103
|
+
findings. Do not quietly invent a command to fill the gap: a command with no
|
|
104
|
+
blueprint entry has no evidence, no policy and no actor, and you will not
|
|
105
|
+
notice which.
|
|
106
|
+
|
|
107
|
+
Two legacy shapes to expect and *not* reproduce:
|
|
108
|
+
|
|
109
|
+
- **`*_form` + `*` action pairs** (`create_stripe_refund_form` +
|
|
110
|
+
`create_stripe_refund`, `issue_payment_form` + `issue_payment`). The `_form` half
|
|
111
|
+
is a GET that renders a modal — it is a *screen*, not a capability. It collapses
|
|
112
|
+
into the screen; only the second half is a `pikkuFunc`. Porting both doubles your
|
|
113
|
+
command count with phantoms.
|
|
114
|
+
- **A `member_action` that only redirects** to another action. That is routing.
|
|
115
|
+
- **An action disabled in the production environment is not a live capability.**
|
|
116
|
+
Grep the environment guards before porting:
|
|
117
|
+
```bash
|
|
118
|
+
grep -rn "env.production?\|env\.development?\|ENV\[" app/admin/*.rb
|
|
119
|
+
```
|
|
120
|
+
On Applause, both halves of `create_stripe_refund` open with
|
|
121
|
+
`return redirect_to … if Rails.env.production?` — the admin refund screen has
|
|
122
|
+
never run in production, and refunds are actually issued in the Stripe dashboard.
|
|
123
|
+
Porting it faithfully would ship a prominent button for a capability the business
|
|
124
|
+
does not use through this app, and quietly move refunds into a surface nobody has
|
|
125
|
+
ever tested. Whether it should now exist is a **product decision**, not a port.
|
|
126
|
+
Check the guard is on the *mutating* half too: if the form is blocked and the POST
|
|
127
|
+
is not, you have found a hole rather than a dead feature.
|
|
128
|
+
|
|
129
|
+
## Stage 3 — Permissions (the part legacy skipped)
|
|
130
|
+
|
|
131
|
+
Generated admins authenticate and then trust. The whole admin sits behind one
|
|
132
|
+
"is an admin" check, and every action inside it is equally reachable — refunds,
|
|
133
|
+
impersonation and editing an FAQ all guarded identically.
|
|
134
|
+
|
|
135
|
+
- Read `policies.json` for the real rule per command. If the blueprint says the
|
|
136
|
+
policy is `enforcedBy: nothing`, that is a **gap you are now closing**, not a
|
|
137
|
+
behaviour to port.
|
|
138
|
+
- With Better Auth's `admin()` plugin, `user.role` is the platform role and
|
|
139
|
+
`session.impersonated_by` is set during impersonation. Both are yours already.
|
|
140
|
+
- **Money and identity actions deserve their own permission**, not the blanket one.
|
|
141
|
+
If the blueprint offers no rule, that is a `decisionsNeeded` entry — ask, do not
|
|
142
|
+
invent.
|
|
143
|
+
- **Impersonation:** Better Auth's `impersonateUser`/`stopImpersonating` replace the
|
|
144
|
+
hand-rolled version. If the legacy audit table recorded only the *start* of an
|
|
145
|
+
impersonation (no `ended_at`, no session id), do not port it — the Fabric audit
|
|
146
|
+
table already answers "what did they do while impersonating", which was the whole
|
|
147
|
+
question it failed to answer.
|
|
148
|
+
|
|
149
|
+
## Stage 4 — Screens
|
|
150
|
+
|
|
151
|
+
- The list query is `pikkuSessionlessFunc` + `readonly: true`; filters and scopes
|
|
152
|
+
are **input fields on one function**, not one function per scope. Legacy needs a
|
|
153
|
+
method per scope because the DSL has no parameters. You do not.
|
|
154
|
+
- Columns come from the query's return type. If a column exists in the DSL but not
|
|
155
|
+
in the type, the DSL was computing it in Ruby per row — that is an N+1 wearing a
|
|
156
|
+
column, and it belongs in the query.
|
|
157
|
+
- Reuse the app's Mantine theme. An admin styled differently from the product is
|
|
158
|
+
how design systems fork.
|
|
159
|
+
- **Server-computed charts** (chartkick/groupdate and friends) do not port. The
|
|
160
|
+
aggregation becomes a real query; the plot becomes a chart component. This is the
|
|
161
|
+
one genuinely expensive screen in most admins — cost it separately.
|
|
162
|
+
- **Drag-and-drop reordering** (`acts_as_list`, sortable tables) is custom logic,
|
|
163
|
+
not a table. Port the position semantics deliberately.
|
|
164
|
+
|
|
165
|
+
## Stage 5 — Verify and report
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
pikku all && pikku fabric validate --json
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Per resource, the parity report records:
|
|
172
|
+
|
|
173
|
+
- **Ported** — screens + which blueprint commands back them.
|
|
174
|
+
- **Deliberately not ported** — with the reason. Expect: `*_form` halves, dead
|
|
175
|
+
actions, single-member enums, screens over dropped tables.
|
|
176
|
+
- **Now authorized** — every action that was guarded by "reached an /admin URL"
|
|
177
|
+
and now has a real permission. This is the port's dividend; name it.
|
|
178
|
+
- **Still open** — actions whose rule the blueprint could not settle.
|
|
179
|
+
|
|
180
|
+
## Red Flags
|
|
181
|
+
|
|
182
|
+
| Thought | Reality |
|
|
183
|
+
|---|---|
|
|
184
|
+
| "The admin is just CRUD, scaffold it last" | Run the count. It was 44% of commands on a real app, and those commands exist nowhere else. |
|
|
185
|
+
| "I'll read the DSL and write the commands" | The blueprint already has them, with evidence, actors and policies. Map; don't re-derive. |
|
|
186
|
+
| "One function per scope" | A scope is a filter argument. The DSL needed a method because it has no parameters. |
|
|
187
|
+
| "`action_item` count = capability count" | Buttons aren't capabilities. Count `member_action`/`collection_action`. |
|
|
188
|
+
| "Port `create_stripe_refund_form` too" | It is a GET that renders a modal. It is a screen. Only the non-`_form` half is a command. |
|
|
189
|
+
| "Admins are admins; one permission is fine" | That is the legacy bug. Refunds and FAQ edits are not the same risk. |
|
|
190
|
+
| "The admin action isn't in commands.json, I'll add it" | Stop. Either the archaeology missed it (fix the blueprint) or it is dead. Both are findings. |
|
|
191
|
+
| "I'll restyle the admin, it's internal" | An admin off the product's theme is how a design system forks. |
|
|
192
|
+
|
|
193
|
+
## Quick Reference
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
# 1. how much of the app is actually the admin?
|
|
197
|
+
node -e "const c=require('./.knowledge/commands.json').commands;console.log(c.filter(x=>(x.evidence||[]).some(e=>(e.file||'').match(/admin/))).length+'/'+c.length)"
|
|
198
|
+
|
|
199
|
+
# 2. inventory the capabilities (not the buttons)
|
|
200
|
+
grep -rhoE "(member_action|collection_action) :[a-z_]+" app/admin/*.rb | sort -u
|
|
201
|
+
|
|
202
|
+
# 3. per resource: map actions -> commands.json, then build the screen
|
|
203
|
+
# 4. verify
|
|
204
|
+
pikku all && pikku fabric validate --json
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
## Related skills
|
|
208
|
+
|
|
209
|
+
- **pikku-software-archaeology** — produces the `.knowledge/` blueprint this needs.
|
|
210
|
+
- **pikku-blueprint-to-fabric** — the parent port; run this per-domain alongside it.
|
|
211
|
+
- **pikku-auth** — roles, ban and impersonation, and the scopes that gate them.
|
|
212
|
+
- **pikku-fabric** — screens, theme, Mantine conventions.
|
|
@@ -11,6 +11,7 @@ description: >-
|
|
|
11
11
|
NOT TRIGGER when: the milestone notes themselves are still being written (use pikku-knowledge),
|
|
12
12
|
the plan already exists and the job is to build it (use pikku-build), or the ask is a one-off
|
|
13
13
|
edit to a working app.
|
|
14
|
+
installGroups: [core]
|
|
14
15
|
---
|
|
15
16
|
|
|
16
17
|
# Plan one milestone
|
|
@@ -15,7 +15,6 @@ The only acceptable auth implementation in a Pikku app is the one described in t
|
|
|
15
15
|
|
|
16
16
|
---
|
|
17
17
|
|
|
18
|
-
|
|
19
18
|
## Installation
|
|
20
19
|
|
|
21
20
|
```bash
|
|
@@ -448,9 +447,14 @@ someone" means **a particular kind of user** rather than one fixed admin.
|
|
|
448
447
|
Register it explicitly — it is not automatic:
|
|
449
448
|
|
|
450
449
|
```typescript
|
|
451
|
-
import { pikkuActor } from '@pikku/better-auth'
|
|
452
|
-
|
|
453
|
-
plugins: [
|
|
450
|
+
import { ACTOR_SIGN_IN_OPT_IN_ENV, pikkuActor } from '@pikku/better-auth'
|
|
451
|
+
|
|
452
|
+
plugins: [
|
|
453
|
+
pikkuActor({
|
|
454
|
+
secret: SCENARIO_ACTOR_SECRET,
|
|
455
|
+
allowSignIn: await variables.get(ACTOR_SIGN_IN_OPT_IN_ENV),
|
|
456
|
+
}),
|
|
457
|
+
]
|
|
454
458
|
```
|
|
455
459
|
|
|
456
460
|
`POST ${basePath}/sign-in/actor` `{ email, secret, name? }` → 200 + the normal
|
|
@@ -481,9 +485,19 @@ itself instead.
|
|
|
481
485
|
A stage that genuinely must run scenarios opts in on purpose, with
|
|
482
486
|
`PIKKU_ALLOW_ACTOR_SIGN_IN=passwordless-actor-sign-in`. Any other value is
|
|
483
487
|
ignored and warned about, so the hatch cannot be opened by copying a `true` from
|
|
484
|
-
the line above
|
|
485
|
-
|
|
486
|
-
|
|
488
|
+
the line above.
|
|
489
|
+
|
|
490
|
+
**Pass it in on any runtime without a populated `process.env`.** The gate reads
|
|
491
|
+
the environment by default, which is enough for Node but not for a Worker: there
|
|
492
|
+
the opt-in arrives as a binding and reaches user code through the variables
|
|
493
|
+
service, so a gate left to `process.env` stays shut on exactly the stages a
|
|
494
|
+
deployment targets. `allowSignIn` takes the value the caller already read —
|
|
495
|
+
`await variables.get(ACTOR_SIGN_IN_OPT_IN_ENV)` — and is checked against the same
|
|
496
|
+
literal, near-miss warning included. It is deliberately a value and not a flag:
|
|
497
|
+
what opens the gate is still something the deployment set and an operator can
|
|
498
|
+
read back out of it, never something compiled into the bundle. A value passed
|
|
499
|
+
here is the one consulted, so the environment cannot quietly override what the
|
|
500
|
+
stage was configured with.
|
|
487
501
|
|
|
488
502
|
**Signing in and provisioning are separate powers.** An unknown address becomes
|
|
489
503
|
an `actor: true` row only under `pikku dev`. With the opt-in set, a stage signs
|
|
@@ -624,3 +638,107 @@ export const auth = pikkuBetterAuth(async ({ secrets, variables, kysely }) => be
|
|
|
624
638
|
- Export exactly ONE `pikkuBetterAuth` per project; the CLI generates a single catch-all worker for all auth routes.
|
|
625
639
|
- `betterAuthSession({ auth })` (generated) bridges the better-auth session into the Pikku session on every request — you never add it by hand.
|
|
626
640
|
- MFA, organizations, passkeys, etc. are better-auth plugins: add them to `betterAuth({ plugins: [...] })`. The catch-all route already forwards their endpoints.
|
|
641
|
+
|
|
642
|
+
---
|
|
643
|
+
|
|
644
|
+
## Post-signup side effects
|
|
645
|
+
|
|
646
|
+
Anything that must happen after a user signs up — a welcome email, seeding a first
|
|
647
|
+
row, creating a personal organization — goes in `databaseHooks.user.create.after`
|
|
648
|
+
inside the `betterAuth({...})` config. Never a custom signup RPC that writes the
|
|
649
|
+
user itself: better-auth owns the `user` table, and a second write path desyncs it
|
|
650
|
+
from the session bridge.
|
|
651
|
+
|
|
652
|
+
## Two-factor (2FA / MFA)
|
|
653
|
+
|
|
654
|
+
TOTP authenticator apps, email/SMS OTP, backup codes and trusted devices are all
|
|
655
|
+
better-auth's `twoFactor()` plugin. Never hand-roll TOTP or OTP.
|
|
656
|
+
|
|
657
|
+
1. **Enable + migrate** — add `twoFactor({ issuer: '<AppName>' })` to the `plugins`
|
|
658
|
+
array in the `pikkuBetterAuth` factory, then generate its schema and apply it as a
|
|
659
|
+
migration like any other table change. Run the CLI at the version of better-auth the
|
|
660
|
+
project actually has installed (`npx @better-auth/cli@<that version> generate`) — a
|
|
661
|
+
bare `npx @better-auth/cli` resolves the latest release, which can emit a schema for
|
|
662
|
+
a library you are not running. `twoFactorSecret` lands on `user`.
|
|
663
|
+
2. **Client** — add `twoFactorClient({ onTwoFactorRedirect() { /* go to /2fa */ } })`
|
|
664
|
+
to `createAuthClient({ plugins: [...] })`, and expose thin wrappers
|
|
665
|
+
(`enable2FA`, `verifyTotp`, `disable2FA`, …) rather than leaking the raw
|
|
666
|
+
`authClient`, same as every other auth call.
|
|
667
|
+
3. **OTP delivery** — `otpOptions.sendOTP` goes through the injected email service
|
|
668
|
+
and a rendered template, not a raw `sendEmail`. Set `storeOTP: 'encrypted'`.
|
|
669
|
+
4. **Sign-in flow** — the challenge is raised on the three CREDENTIAL endpoints:
|
|
670
|
+
`signIn.email`, `signIn.username` and `signIn.phoneNumber`. Check
|
|
671
|
+
`context.data.twoFactorRedirect` in `onSuccess`; if true, route to a `/2fa` page
|
|
672
|
+
and verify via `verifyTotp`/`verifyOtp`/`verifyBackupCode` (`trustDevice: true`
|
|
673
|
+
for a 30-day trusted device). The response also carries `twoFactorMethods`
|
|
674
|
+
(`'totp'` only once that user has a verified secret, `'otp'` whenever
|
|
675
|
+
`otpOptions.sendOTP` is configured) — render the choice from it rather than
|
|
676
|
+
assuming TOTP. The session cookie is only created after verification: the
|
|
677
|
+
credential handler's session is deleted while the challenge is in flight, so a
|
|
678
|
+
hook reading `ctx.context.newSession` after sign-in must null-check it.
|
|
679
|
+
5. **UI** — a QR rendered from `data.totpURI` plus the `data.backupCodes` list.
|
|
680
|
+
Enabling, disabling and regenerating backup codes all require the user's
|
|
681
|
+
password.
|
|
682
|
+
|
|
683
|
+
TOTP secrets and backup codes are encrypted at rest with the auth secret, and
|
|
684
|
+
`/two-factor/*` is rate-limited (3/10s) out of the box.
|
|
685
|
+
|
|
686
|
+
**2FA gates credential sign-in only.** Magic link, email OTP and OAuth are not
|
|
687
|
+
matched by the plugin's hook, so a user with 2FA enabled who signs in through one
|
|
688
|
+
of them is NOT challenged. If every route into the app must be gated, either do not
|
|
689
|
+
offer the passwordless ones to 2FA users or add your own check — enabling the
|
|
690
|
+
plugin does not do it.
|
|
691
|
+
|
|
692
|
+
## Security hardening
|
|
693
|
+
|
|
694
|
+
The `pikkuBetterAuth` factory — where `betterAuth({...})` is built — is the one
|
|
695
|
+
place to harden. Everything below is a `betterAuth` option, not a pikku one.
|
|
696
|
+
|
|
697
|
+
- **Secret** — `BETTER_AUTH_SECRET` comes from the injected secrets service
|
|
698
|
+
(`await secrets.getSecret('BETTER_AUTH_SECRET')`), never `process.env`, a
|
|
699
|
+
literal, or a fallback default. 32+ chars, high entropy
|
|
700
|
+
(`openssl rand -base64 32`). Better Auth rejects placeholder secrets in
|
|
701
|
+
production.
|
|
702
|
+
- **Trusted origins** — the `baseURL` origin is auto-trusted, so a single-domain
|
|
703
|
+
app serving its API same-origin needs nothing. Add `trustedOrigins` (or a
|
|
704
|
+
comma-separated `BETTER_AUTH_TRUSTED_ORIGINS` variable; wildcards like
|
|
705
|
+
`*.example.com` allowed) ONLY when the browser origin differs from the API
|
|
706
|
+
origin — embedded, preview, or custom-domain deployments. An untrusted
|
|
707
|
+
`callbackURL`/`redirectTo`/`origin` is a 403.
|
|
708
|
+
- **CSRF** — keep it on (`advanced.disableCSRFCheck: false`, the default). A
|
|
709
|
+
proxy in front of the app must preserve the `/api/auth/*` prefix so origin
|
|
710
|
+
checks still work; do not disable the check to "fix" a redirect.
|
|
711
|
+
- **Rate limiting** — on by default in production (100/10s global, 3/10s on
|
|
712
|
+
sign-in/up/change-password). `storage: 'memory'` resets on restart, so a
|
|
713
|
+
deployed app wants `storage: 'database'`. Tighten sensitive routes with
|
|
714
|
+
`customRules`, e.g. `'/sign-in/email': { window: 60, max: 5 }` — the key is matched
|
|
715
|
+
against the path with the base path ALREADY STRIPPED, so a rule written as
|
|
716
|
+
`'/api/auth/sign-in/email'` matches nothing and silently leaves the route on the
|
|
717
|
+
default.
|
|
718
|
+
- **Cookies & sessions** — `httpOnly`, `sameSite: 'lax'` and `path: '/'` are
|
|
719
|
+
unconditional, but `secure` and the `__Secure-` name prefix are NOT: they follow a
|
|
720
|
+
`baseURL` on `https://` (or production, or an explicit
|
|
721
|
+
`advanced.useSecureCookies: true`). A deployment whose TLS terminates at a proxy
|
|
722
|
+
and passes an `http://` baseURL through therefore ships session cookies with no
|
|
723
|
+
`secure` flag — set `useSecureCookies` there rather than assuming. Defaults are
|
|
724
|
+
`session.expiresIn` 7d and
|
|
725
|
+
`updateAge` 1d. Add `freshAge` for sensitive actions, and
|
|
726
|
+
`cookieCache: { strategy: 'jwe' }` if the session carries sensitive data — see
|
|
727
|
+
the cookieCache section above, which you want enabled regardless. Only enable
|
|
728
|
+
`crossSubDomainCookies` if auth is genuinely shared across subdomains.
|
|
729
|
+
- **OAuth tokens** — set `account.encryptOAuthTokens: true` (AES-256-GCM) if you
|
|
730
|
+
store provider tokens to call their APIs later.
|
|
731
|
+
- **Audit** — drive auth events from `databaseHooks` (`session.create.after`,
|
|
732
|
+
`user.update.after` for email changes, `account.create.after` for links) into
|
|
733
|
+
whatever audit service the app injects, never a bespoke audit table wired into
|
|
734
|
+
a function body. Returning `false` from a `before` hook blocks the operation.
|
|
735
|
+
- **Background tasks** — on a serverless target, hand genuinely disposable work
|
|
736
|
+
(analytics, logging) to `advanced.backgroundTasks.handler` → `ctx.waitUntil(promise)`
|
|
737
|
+
so it does not delay the response. Not mail: the default handler is
|
|
738
|
+
`p.catch(() => {})`, so anything that must actually arrive — an invitation, a
|
|
739
|
+
password reset — is lost without a trace if the platform reaps the request first.
|
|
740
|
+
Better Auth sends its own through `runInBackgroundOrAwait`, which awaits when no
|
|
741
|
+
handler is configured; do the same for yours.
|
|
742
|
+
- **Enumeration** — handled already (generic "Invalid credentials", dummy work on
|
|
743
|
+
unknown users). Keep your own error copy generic too; never leak "user not
|
|
744
|
+
found".
|