@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/skills",
3
- "version": "0.12.25",
3
+ "version": "0.12.27",
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",
@@ -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: [pikkuActor({ secret: SCENARIO_ACTOR_SECRET })]
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, and it is the only hatch — there is no build-time option, because
485
- an option compiled into the bundle cannot be audited from the environment it
486
- runs in.
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".