@terreno/mcp 57.16.1 → 57.17.0
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/dist/docs/templates/bootstrap/frontend/app/(tabs)/profile.template.tsx +26 -5
- package/dist/docs/templates/bootstrap/frontend/store/sdk.template.md +2 -0
- package/dist/docs/upgrades/57.17.0.md +126 -0
- package/dist/docs/versioned/next/approval-rubric.md +171 -0
- package/dist/docs/versioned/next/explanation/admin-interface.md +5 -1
- package/dist/docs/versioned/next/explanation/agent-ui-asks.md +1 -0
- package/dist/docs/versioned/next/explanation/authentication.md +60 -10
- package/dist/docs/versioned/next/explanation/example-coverage.md +2 -1
- package/dist/docs/versioned/next/explanation/model-router-actions.md +32 -1
- package/dist/docs/versioned/next/how-to/README.md +1 -0
- package/dist/docs/versioned/next/how-to/add-github-oauth.md +49 -16
- package/dist/docs/versioned/next/how-to/admin-add-model.md +4 -1
- package/dist/docs/versioned/next/how-to/ai-feature-development.md +5 -0
- package/dist/docs/versioned/next/how-to/background-jobs.md +18 -5
- package/dist/docs/versioned/next/how-to/circleci.md +39 -3
- package/dist/docs/versioned/next/how-to/configure-better-auth.md +283 -5
- package/dist/docs/versioned/next/how-to/connect-mcp-service-token.md +3 -1
- package/dist/docs/versioned/next/how-to/deploy-backend-to-cloud-run.md +1 -1
- package/dist/docs/versioned/next/how-to/enable-mcp-oauth.md +134 -0
- package/dist/docs/versioned/next/how-to/expose-mcp-tools.md +43 -4
- package/dist/docs/versioned/next/how-to/install-agent-skills.md +8 -1
- package/dist/docs/versioned/next/how-to/migrate-rtk-to-syncdb.md +7 -4
- package/dist/docs/versioned/next/how-to/password-reset.md +8 -2
- package/dist/docs/versioned/next/how-to/product-announcements.md +5 -3
- package/dist/docs/versioned/next/how-to/rate-limiting.md +2 -2
- package/dist/docs/versioned/next/how-to/run-tests-locally.md +50 -0
- package/dist/docs/versioned/next/how-to/use-syncdb-correctly.md +1 -1
- package/dist/docs/versioned/next/how-to/websocket-integration.md +42 -9
- package/dist/docs/versioned/next/how-to/whats-new-banner.md +55 -0
- package/dist/docs/versioned/next/implementationPlans/agent-ui-asks.md +2 -0
- package/dist/docs/versioned/next/implementationPlans/app-mcp-server.md +7 -4
- package/dist/docs/versioned/next/implementationPlans/fix-1594-cta-realtime-better-auth.md +41 -0
- package/dist/docs/versioned/next/implementationPlans/fix-better-auth-sessions-and-config.md +80 -0
- package/dist/docs/versioned/next/implementationPlans/rbac-permissions.md +5 -2
- package/dist/docs/versioned/next/implementationPlans/rtk-token-source-web-bearer.md +246 -0
- package/dist/docs/versioned/next/reference/admin-backend.md +46 -0
- package/dist/docs/versioned/next/reference/admin-config.md +2 -2
- package/dist/docs/versioned/next/reference/ai.md +48 -4
- package/dist/docs/versioned/next/reference/api.md +410 -12
- package/dist/docs/versioned/next/reference/blocks.md +1 -1
- package/dist/docs/versioned/next/reference/comms.md +19 -1
- package/dist/docs/versioned/next/reference/environment-variables.md +6 -1
- package/dist/docs/versioned/next/reference/feature-flags.md +1 -1
- package/dist/docs/versioned/next/reference/jobs.md +8 -6
- package/dist/docs/versioned/next/reference/legacy/rtk.md +145 -18
- package/dist/docs/versioned/next/reference/lifecycle-plugin.md +5 -0
- package/dist/docs/versioned/next/reference/syncdb.md +64 -3
- package/dist/docs/versioned/next/reference/ui.md +23 -1
- package/package.json +3 -3
- package/src/docs/templates/bootstrap/frontend/app/(tabs)/profile.template.tsx +26 -5
- package/src/docs/templates/bootstrap/frontend/store/sdk.template.md +2 -0
- package/src/docs/upgrades/57.17.0.md +126 -0
- package/src/docs/versioned/next/approval-rubric.md +171 -0
- package/src/docs/versioned/next/explanation/admin-interface.md +5 -1
- package/src/docs/versioned/next/explanation/agent-ui-asks.md +1 -0
- package/src/docs/versioned/next/explanation/authentication.md +60 -10
- package/src/docs/versioned/next/explanation/example-coverage.md +2 -1
- package/src/docs/versioned/next/explanation/model-router-actions.md +32 -1
- package/src/docs/versioned/next/how-to/README.md +1 -0
- package/src/docs/versioned/next/how-to/add-github-oauth.md +49 -16
- package/src/docs/versioned/next/how-to/admin-add-model.md +4 -1
- package/src/docs/versioned/next/how-to/ai-feature-development.md +5 -0
- package/src/docs/versioned/next/how-to/background-jobs.md +18 -5
- package/src/docs/versioned/next/how-to/circleci.md +39 -3
- package/src/docs/versioned/next/how-to/configure-better-auth.md +283 -5
- package/src/docs/versioned/next/how-to/connect-mcp-service-token.md +3 -1
- package/src/docs/versioned/next/how-to/deploy-backend-to-cloud-run.md +1 -1
- package/src/docs/versioned/next/how-to/enable-mcp-oauth.md +134 -0
- package/src/docs/versioned/next/how-to/expose-mcp-tools.md +43 -4
- package/src/docs/versioned/next/how-to/install-agent-skills.md +8 -1
- package/src/docs/versioned/next/how-to/migrate-rtk-to-syncdb.md +7 -4
- package/src/docs/versioned/next/how-to/password-reset.md +8 -2
- package/src/docs/versioned/next/how-to/product-announcements.md +5 -3
- package/src/docs/versioned/next/how-to/rate-limiting.md +2 -2
- package/src/docs/versioned/next/how-to/run-tests-locally.md +50 -0
- package/src/docs/versioned/next/how-to/use-syncdb-correctly.md +1 -1
- package/src/docs/versioned/next/how-to/websocket-integration.md +42 -9
- package/src/docs/versioned/next/how-to/whats-new-banner.md +55 -0
- package/src/docs/versioned/next/implementationPlans/agent-ui-asks.md +2 -0
- package/src/docs/versioned/next/implementationPlans/app-mcp-server.md +7 -4
- package/src/docs/versioned/next/implementationPlans/fix-1594-cta-realtime-better-auth.md +41 -0
- package/src/docs/versioned/next/implementationPlans/fix-better-auth-sessions-and-config.md +80 -0
- package/src/docs/versioned/next/implementationPlans/rbac-permissions.md +5 -2
- package/src/docs/versioned/next/implementationPlans/rtk-token-source-web-bearer.md +246 -0
- package/src/docs/versioned/next/reference/admin-backend.md +46 -0
- package/src/docs/versioned/next/reference/admin-config.md +2 -2
- package/src/docs/versioned/next/reference/ai.md +48 -4
- package/src/docs/versioned/next/reference/api.md +410 -12
- package/src/docs/versioned/next/reference/blocks.md +1 -1
- package/src/docs/versioned/next/reference/comms.md +19 -1
- package/src/docs/versioned/next/reference/environment-variables.md +6 -1
- package/src/docs/versioned/next/reference/feature-flags.md +1 -1
- package/src/docs/versioned/next/reference/jobs.md +8 -6
- package/src/docs/versioned/next/reference/legacy/rtk.md +145 -18
- package/src/docs/versioned/next/reference/lifecycle-plugin.md +5 -0
- package/src/docs/versioned/next/reference/syncdb.md +64 -3
- package/src/docs/versioned/next/reference/ui.md +23 -1
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Fix create-terreno-app: generated backend builds a second Better Auth instance
|
|
2
|
+
|
|
3
|
+
Generated backends build their own Better Auth instance for `RealtimeApp` next to the
|
|
4
|
+
registered `BetterAuthApp`. Since #1592, `RealtimeApp` finds the registered instance
|
|
5
|
+
itself, so the extra one only drifts from the docs and drops `BetterAuthConfig`
|
|
6
|
+
`options` and `plugins` on sockets.
|
|
7
|
+
|
|
8
|
+
Selected: https://github.com/TerrenoLabs/terreno/issues/1594, resolved 2026-10-19
|
|
9
|
+
(no time window), GitHub TerrenoLabs/terreno.
|
|
10
|
+
|
|
11
|
+
## Issues
|
|
12
|
+
|
|
13
|
+
| Issue | Title | Disposition | Root cause | Task |
|
|
14
|
+
| --- | --- | --- | --- | --- |
|
|
15
|
+
| #1594 | create-terreno-app: use the registered BetterAuthApp for RealtimeApp | fix | `create-terreno-app/src/generate.ts:540` builds `betterAuthInstance` and passes it to `RealtimeApp` | T1 |
|
|
16
|
+
|
|
17
|
+
## Tasks
|
|
18
|
+
|
|
19
|
+
- [x] **T1** — Use the registered BetterAuthApp for the generated RealtimeApp (#1594)
|
|
20
|
+
- Depends on: none
|
|
21
|
+
- Files: create-terreno-app/src/generate.ts, create-terreno-app/src/__tests__/generate.test.ts, changelog/unreleased/cta-realtime-better-auth.md
|
|
22
|
+
- Root cause: the `server.ts` template calls `createBetterAuth` and passes the result as `RealtimeApp({betterAuth})`.
|
|
23
|
+
- Fix: drop `createBetterAuth`, `getMongoClientFromMongoose`, `betterAuthInstance`, and the `betterAuth:` option from the `server.ts` template. Keep the seed script's instance.
|
|
24
|
+
- Proof: `create-terreno-app/src/__tests__/generate.test.ts` "generated backend server lets RealtimeApp use the registered BetterAuthApp" fails on the base (the template contains `createBetterAuth(`) and passes on the branch.
|
|
25
|
+
- Closes: #1594
|
|
26
|
+
|
|
27
|
+
## Assumptions
|
|
28
|
+
|
|
29
|
+
- Needs #1592 (merged 2026-10-19): before it, `RealtimeApp` could not find `BetterAuthApp`, so removing the wiring would have broken sockets.
|
|
30
|
+
|
|
31
|
+
## Open questions (recommendation assumed)
|
|
32
|
+
|
|
33
|
+
None.
|
|
34
|
+
|
|
35
|
+
## Not fixed in this run
|
|
36
|
+
|
|
37
|
+
None.
|
|
38
|
+
|
|
39
|
+
## Sign-off
|
|
40
|
+
|
|
41
|
+
Status: approved 2026-10-19 (tap: #1594)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Fix Better Auth: disabled users, socket sessions, and config passthrough
|
|
2
|
+
|
|
3
|
+
Three Better Auth gaps that apps migrating from JWT hit right away. Disabling an account
|
|
4
|
+
does not lock out its live Better Auth sessions on REST or sockets. Sockets only accept
|
|
5
|
+
Better Auth sessions when each app wires the instance in by hand, and they refuse a user
|
|
6
|
+
who has not made an HTTP request yet. `BetterAuthConfig` cannot pass any Better Auth
|
|
7
|
+
option or plugin Terreno does not set itself.
|
|
8
|
+
|
|
9
|
+
Selected: "the better-auth issues from the last day", resolved 2026-10-18T10:52:18Z
|
|
10
|
+
(window 2026-10-17T10:52:18Z to 2026-10-18T10:52:18Z), GitHub TerrenoLabs/terreno.
|
|
11
|
+
|
|
12
|
+
## Issues
|
|
13
|
+
|
|
14
|
+
| Issue | Title | Disposition | Root cause | Task |
|
|
15
|
+
| --- | --- | --- | --- | --- |
|
|
16
|
+
| #1577 | Better Auth session middleware does not reject disabled users | fix | `api/src/betterAuthSetup.ts:410` sets `req.user` without checking `disabled` | T1 |
|
|
17
|
+
| #1576 | Better Auth: wire RealtimeApp automatically and lazily sync users at socket handshake | fix | `api/src/realtime/socketAuth.ts:161` refuses unsynced users and skips `disabled`; `api/src/realtime/realtimeApp.ts:398` reads only `config.betterAuth` | T2, T3 |
|
|
18
|
+
| #1578 | BetterAuthConfig: pass through Better Auth options and plugins | fix | `api/src/betterAuthSetup.ts:189` builds options only from fixed fields; `plugins: [bearer()]` is hard-coded | T4 |
|
|
19
|
+
|
|
20
|
+
## Tasks
|
|
21
|
+
|
|
22
|
+
- [x] **T1** — Reject disabled users' Better Auth sessions on REST (#1577)
|
|
23
|
+
- Depends on: none
|
|
24
|
+
- Files: api/src/betterAuthSetup.ts, api/src/betterAuthSetup.test.ts, docs/explanation/authentication.md, docs/how-to/configure-better-auth.md
|
|
25
|
+
- Root cause: `createBetterAuthSessionMiddleware` assigns the app user without the `disabled` check JWT (`auth.ts:565`) and MCP (`mcp/auth.ts:21`) make.
|
|
26
|
+
- Fix: add an exported `resolveBetterAuthAppUser({userModel, betterAuthUser})` that finds the user by `betterAuthId`, falls back to `syncBetterAuthUser`, and reports disabled users. The middleware leaves `req.user` unset for a disabled user, as MCP's `rejectIfDisabled` does, so protected routes answer 401 and Better Auth's own routes (sign-out) stay reachable.
|
|
27
|
+
- Proof: `api/src/betterAuthSetup.test.ts` tests for a disabled user and a user disabled while the session is live fail on the base (`req.user` is set) and pass on the branch.
|
|
28
|
+
- Closes: #1577
|
|
29
|
+
- [x] **T2** — Sync and check disabled users at the Better Auth socket handshake (#1576)
|
|
30
|
+
- Depends on: T1
|
|
31
|
+
- Files: api/src/realtime/socketAuth.ts, api/src/realtime/socketAuth.test.ts
|
|
32
|
+
- Root cause: the Better Auth socket check looks up the app user by `betterAuthId` and throws when none exists; it never checks `disabled`.
|
|
33
|
+
- Fix: use `resolveBetterAuthAppUser` from T1, so a fresh user is created at handshake and a disabled one is refused.
|
|
34
|
+
- Proof: `api/src/realtime/socketAuth.test.ts` tests "creates the app user" and "rejects a disabled user" fail on the base and pass on the branch.
|
|
35
|
+
- Closes: #1576 (with T3)
|
|
36
|
+
- [x] **T3** — Find the Better Auth instance for RealtimeApp from the registered plugins (#1576)
|
|
37
|
+
- Depends on: T2
|
|
38
|
+
- Files: api/src/realtime/realtimeApp.ts, api/src/realtime/types.ts, api/src/realtime/realtimeAppSetup.test.ts, example-backend/src/server.ts, docs/how-to/configure-better-auth.md, docs/how-to/migrate-rtk-to-syncdb.md, docs/reference/syncdb.md, docs/explanation/authentication.md
|
|
39
|
+
- Root cause: `RealtimeApp` reads only `config.betterAuth` at startup, in the handshake middleware, and in the revalidation sweep, and ignores the `terrenoApp` passed to `register`.
|
|
40
|
+
- Fix: store `terrenoApp` in `register`; in `onServerCreated`, resolve `config.betterAuth ?? {auth: ensureAuth(), userModel}` from the registered Better Auth plugin and use it in all three places. Explicit `config.betterAuth` still wins.
|
|
41
|
+
- Proof: `api/src/realtime/realtimeAppSetup.test.ts` tests with `BetterAuthApp` registered and no `betterAuth` option — handshake with a Better Auth bearer authenticates, and the sweep reports `disabled` rather than `invalid-session` — fail on the base and pass on the branch.
|
|
42
|
+
- Closes: #1576 (with T2)
|
|
43
|
+
- [x] **T4** — Pass Better Auth options and plugins through BetterAuthConfig (#1578)
|
|
44
|
+
- Depends on: T3
|
|
45
|
+
- Files: api/src/betterAuth.ts, api/src/betterAuthSetup.ts, api/src/betterAuthSetup.test.ts, api/src/betterAuthApp.test.ts, docs/how-to/configure-better-auth.md
|
|
46
|
+
- Root cause: `createBetterAuth` builds a fixed options literal and hard-codes `plugins: [bearer()]`.
|
|
47
|
+
- Fix: add `options?: Omit<Partial<BetterAuthOptions>, "plugins" | "basePath">` and `plugins?: BetterAuthPlugin[]`; deep-merge `options` over Terreno's defaults with arrays replaced, and append `plugins` after `bearer()`.
|
|
48
|
+
- Proof: `api/src/betterAuthSetup.test.ts` "requireEmailVerification blocks sign-in" and "plugin endpoints mount under the base path" fail on the base and pass on the branch; existing defaults tests stay green.
|
|
49
|
+
- Closes: #1578
|
|
50
|
+
- [x] **T5** — Resolve a Better Auth user created concurrently on first contact (#1576)
|
|
51
|
+
- Depends on: T4
|
|
52
|
+
- Files: api/src/betterAuthSetup.ts, api/src/betterAuthSetup.test.ts
|
|
53
|
+
- Root cause: found in branch review. The socket handshake (T2) and the first REST calls race to create a new user; the loser's insert hits the unique `_id` index, and the socket reports it as Unauthorized.
|
|
54
|
+
- Fix: `resolveBetterAuthAppUser` looks the user up again after a duplicate-key error.
|
|
55
|
+
- Proof: `api/src/betterAuthSetup.test.ts` describe "resolveBetterAuthAppUser" fails on T4's head (8 of 13 runs for the concurrent test, 5 of 5 for the stub test) and passes on the branch.
|
|
56
|
+
- Closes: #1576 (with T2 and T3)
|
|
57
|
+
|
|
58
|
+
T2 depends on T1 for the helper. T3 and T4 depend on the previous task only because they edit the same docs page.
|
|
59
|
+
|
|
60
|
+
## Assumptions
|
|
61
|
+
|
|
62
|
+
- A disabled user's session falls through as anonymous instead of answering 401 directly, because `TerrenoApp` mounts the session middleware before Better Auth's routes and a direct 401 blocked sign-out (Roast T1 attempt 1).
|
|
63
|
+
- Auto-wiring has no opt-out: it only adds one more accepted credential, after the JWT check.
|
|
64
|
+
- `options` overrides win, including over the adapter and security defaults, as #1578 says; docs list what an override drops.
|
|
65
|
+
- Plugin endpoints added through `plugins` are not typed on `auth.api`; docs say so.
|
|
66
|
+
|
|
67
|
+
## Open questions (recommendation assumed)
|
|
68
|
+
|
|
69
|
+
None.
|
|
70
|
+
|
|
71
|
+
## Not fixed in this run
|
|
72
|
+
|
|
73
|
+
| Issue | Disposition | Next action |
|
|
74
|
+
| --- | --- | --- |
|
|
75
|
+
| #1579 | decide | New rtk public API with four design choices (token-source shape, #1217 composition, web storage, bearer default), about three PRs. Run `/brewery:distill #1579`. |
|
|
76
|
+
| #1574 | skipped | Open PR #1581 already covers it. |
|
|
77
|
+
|
|
78
|
+
## Sign-off
|
|
79
|
+
|
|
80
|
+
Status: approved 2026-10-18 (tap: #1576, #1577, #1578)
|
|
@@ -112,7 +112,7 @@ export const terrenoStatements = {
|
|
|
112
112
|
// Managing the RBAC system itself
|
|
113
113
|
rbac: ["read", "manageRoles", "assignRoles"],
|
|
114
114
|
// The framework user model (auth surfaces, admin user CRUD)
|
|
115
|
-
user: ["create", "list", "read", "update", "delete", "impersonate", "setPassword"],
|
|
115
|
+
user: ["create", "list", "read", "update", "delete", "impersonate", "setPassword", "updateIdentity"],
|
|
116
116
|
// Feature flag / configuration app
|
|
117
117
|
configuration: ["read", "update"],
|
|
118
118
|
} as const;
|
|
@@ -880,6 +880,9 @@ export interface PermissionSource {
|
|
|
880
880
|
// "use-stale-bounded" with a short staleMaxAgeMs — never unbounded stale reuse.
|
|
881
881
|
staleOnFailure?: StaleOnFailurePolicy;
|
|
882
882
|
staleMaxAgeMs?: number; // required when staleOnFailure === "use-stale-bounded"
|
|
883
|
+
// Default true: a failure with no last-known deny posture for the user throws 503.
|
|
884
|
+
// Set false on additive-only sources that never return deny.
|
|
885
|
+
failClosedWhenDenyUnknown?: boolean;
|
|
883
886
|
getGrants(args: {user: User}): Promise<PermissionSourceGrants | null>;
|
|
884
887
|
}
|
|
885
888
|
```
|
|
@@ -909,7 +912,7 @@ behavior is per-source `staleOnFailure` (default `"deny"`):
|
|
|
909
912
|
|
|
910
913
|
| Policy | Behavior |
|
|
911
914
|
|---|---|
|
|
912
|
-
| `"deny"` (default) | Omit this source's additive grants (`roles`, `permissions`) for the request. Local `user.roles` and other sources still apply. If a prior fetch
|
|
915
|
+
| `"deny"` (default) | Omit this source's additive grants (`roles`, `permissions`) for the request. Local `user.roles` and other sources still apply. If a prior fetch returned a `deny` set, keep applying that last-known deny so IdP/ABAC restrictions do not lift while the source is down; cache invalidation and eviction do not drop it, and a set resolved during a failure is not cached. With no last-known deny (cold process, or the posture was evicted), the check throws 503 `Permission source unavailable`, because the source's deny is unknown. Additive-only sources that never return `deny` set `failClosedWhenDenyUnknown: false`; their failure then only drops their grants and local roles apply. Revoked upstream *elevations* cannot linger because a dependency is down. |
|
|
913
916
|
| `"use-stale-bounded"` | Reuse the last successful grants only if younger than `staleMaxAgeMs` (hard cap). Suitable for read-only hints, not elevation. |
|
|
914
917
|
| `"use-stale"` | Reuse last successful grants until TTL expiry. **Not permitted** for sources that can grant roles/permissions beyond what local roles already provide — elevation paths must fail closed. |
|
|
915
918
|
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# One token source for @terreno/rtk, and a web bearer scheme for Better Auth
|
|
2
|
+
|
|
3
|
+
Better Auth is the default for new Terreno apps, but most of `@terreno/rtk` still reads the
|
|
4
|
+
legacy JWT from storage. The RTK base query, the socket hook, the chat hook, the MCP tools
|
|
5
|
+
hook and offline replay all call the JWT `getAuthToken`. A Better Auth app either forks the
|
|
6
|
+
base query (example-frontend and every generated app do) or gets logged out on its first
|
|
7
|
+
RTK call (#1217). On web, Better Auth also needs cookies, so an app whose frontend and API
|
|
8
|
+
are on different sites depends on third-party cookies that Safari blocks. Lede is
|
|
9
|
+
migrating to Better Auth on two Vercel hosts and carries shims until this ships. After
|
|
10
|
+
this change, an app registers one token source at startup and every rtk consumer uses it,
|
|
11
|
+
and a cross-site web app can sign in, with email and password or a social provider, and
|
|
12
|
+
stay signed in without any cookie.
|
|
13
|
+
|
|
14
|
+
Source: #1579. Also fixes #1217.
|
|
15
|
+
|
|
16
|
+
## The idea
|
|
17
|
+
|
|
18
|
+
rtk gets a registered token source: `setTokenSource(source)`, typed as syncdb's
|
|
19
|
+
`AuthProvider` (`getToken`, `getUserId`, `onAuthChange`, `refresh?`). rtk imports that
|
|
20
|
+
type from `@terreno/syncdb`, so the object an app already builds for syncdb with
|
|
21
|
+
`betterAuthAdapter(betterAuthClient)` is the same one rtk uses. Until an app registers one,
|
|
22
|
+
rtk uses a built-in JWT source that wraps today's `getAuthToken` and `refreshAuthToken`, so
|
|
23
|
+
JWT apps change nothing. Each consumer also takes an optional `tokenSource` override.
|
|
24
|
+
|
|
25
|
+
`createBetterAuthClient` gets an opt-in `webAuth: "bearer"` mode. On web it captures the
|
|
26
|
+
`set-auth-token` header that Better Auth's bearer plugin returns, stores it in
|
|
27
|
+
localStorage (or a `bearerStorage` the app passes), sends `Authorization: Bearer` on every
|
|
28
|
+
Better Auth client call including `getSession`, and sends no cookies
|
|
29
|
+
(`credentials: "omit"`). rtk exports `getBetterAuthBearerToken()` to read that stored
|
|
30
|
+
token, and syncdb's `betterAuthAdapter` takes it as an explicit `getBearerToken` option so
|
|
31
|
+
it reads the token locally instead of calling `getSession()` per read. Social sign-in is a redirect, so the client never sees
|
|
32
|
+
`set-auth-token`. Instead, rtk starts the sign-in with a random nonce, the server appends a
|
|
33
|
+
single-use, three-minute one-time token to the callback redirect's URL fragment (only for
|
|
34
|
+
configured web origins), and rtk exchanges it for the bearer on return, but only when the
|
|
35
|
+
nonce matches the sign-in it started. Native is unchanged: the expo plugin already keeps the
|
|
36
|
+
session without browser cookies.
|
|
37
|
+
|
|
38
|
+
Rejected: passing the token source to every consumer with no global default. Hooks deep in
|
|
39
|
+
the tree, like `useMCPTools`, would need it threaded through props, and it is the same
|
|
40
|
+
object everywhere.
|
|
41
|
+
|
|
42
|
+
## The plan
|
|
43
|
+
|
|
44
|
+
| # | Task | Depends on | Lands in | Proves it |
|
|
45
|
+
| --- | --- | --- | --- | --- |
|
|
46
|
+
| T1 | Registered token source, used by the RTK base query | none | rtk | base query sends the registered source's token and never logs out a Better Auth user for a missing JWT (#1217) |
|
|
47
|
+
| T2 | Web bearer scheme for `createBetterAuthClient` | T3 | rtk | sign-in token is stored and sent, cookies are not; sign-out clears it, a failed read does not |
|
|
48
|
+
| T3 | Server accepts cookie-less bearer from another origin | none | api | cross-origin sign-in exposes the token; bearer alone reaches `/auth/me`, a model route and `/mcp` |
|
|
49
|
+
| T4 | `betterAuthAdapter` takes a `getBearerToken` option | none | syncdb | with the option, `getToken` makes no `getSession` call |
|
|
50
|
+
| T5 | Socket hook reads the token source | T1 | rtk | handshake re-reads the token per attempt, refreshes once per failure, reconnects on auth change |
|
|
51
|
+
| T6 | Chat hook reads the token source | T1 | rtk | chat requests carry the source's token |
|
|
52
|
+
| T7 | MCP tools hook reads the token source | T1 | rtk | MCP transport carries the source's current token |
|
|
53
|
+
| T8 | Offline queue and replay read the token source | T1 | rtk | a Better Auth user's mutations queue and replay with the source's token |
|
|
54
|
+
| T9 | Document the consumer `tokenSource` options | T5, T6, T7, T8 | docs | reference and how-to pages match the new signatures |
|
|
55
|
+
| T10 | example-frontend drops its forks; bearer on web behind a flag | T1, T2, T4, T5, T7 | example-frontend | Playwright, cross-site with no cookies: email sign-in, reload, profile, socket and MCP tools work |
|
|
56
|
+
| T11 | Generated apps use the token source | T1 | create-terreno-app | generated store registers `betterAuthAdapter` and drops the base-query fork |
|
|
57
|
+
| T12 | Server hands a one-time token to the OAuth callback | none | api | the callback redirect to a configured web origin carries `#ott`, which verifies once into a bearer; nothing else mints one |
|
|
58
|
+
| T13 | rtk exchanges the OAuth one-time token for the bearer | T2, T12 | rtk | a nonce-matched return stores the bearer; an unsolicited `#ott` is ignored |
|
|
59
|
+
| T14 | Cross-site social sign-in in example-frontend | T10, T13 | example-frontend | Playwright drives a mock OIDC sign-in cross-site with no cookies and lands signed in |
|
|
60
|
+
| T15 | Deployed example-frontend runs in bearer mode | T10 | CI deploy | Netlify builds set the flag; a smoke run against the PR preview signs in with no cookie |
|
|
61
|
+
|
|
62
|
+
Tracer: T1, the registered source through the RTK base query.
|
|
63
|
+
Out of scope: moving rtk's hooks out of rtk, admin-frontend's header getter, the missing `/api/chat` route (see Expansions).
|
|
64
|
+
Open risks: the one-time token travels in a URL fragment, which servers, access logs and `Referer` never see, but browser history and client-side scripts loaded before the strip could; it is single-use, expires in three minutes, is checked by the web app against a `handoff=` nonce it stored (which stops a planted token, not a stolen one), and goes only to an exactly listed path (`oauthHandoff.callbackPaths`, default `/`) on a configured web origin. Because a browser carries the fragment through a redirect that has none, that page must strip the fragment first and never redirect; an open redirect elsewhere on the origin cannot receive the token. A localStorage token can be read by an XSS on the frontend and outlives browser restarts, which matters on shared family devices. Bearer mode is opt-in, `bearerStorage` accepts sessionStorage, and the docs say when to use which.
|
|
65
|
+
|
|
66
|
+
## Tasks
|
|
67
|
+
|
|
68
|
+
- [x] **T1** — Register a token source and use it in the RTK base query
|
|
69
|
+
- Depends on: none
|
|
70
|
+
- Files: rtk/package.json, rtk/src/tokenSource.ts, rtk/src/tokenSource.test.ts, rtk/src/authEvents.ts, rtk/src/authSlice.ts, rtk/src/emptyApi.ts, rtk/src/isolated/emptyApi.staggered.isolated.ts, rtk/src/index.ts, docs/reference/legacy/rtk.md, plugins/terreno-planning/skills/terreno-data-fetching/references/auth-and-tokens.md, skills/terreno-data-fetching/references/auth-and-tokens.md, plugins/terreno-claude/skills/terreno-data-fetching/references/auth-and-tokens.md, changelog/unreleased/rtk-token-source.md
|
|
71
|
+
- Behavior: `setTokenSource(source)` and `getTokenSource()` are exported from `@terreno/rtk`; `AuthProvider` is imported as a type from `@terreno/syncdb`, added as a `workspace:*` dependency. Seams, chosen so rtk stays cycle-free: `tokenSource.ts` is a leaf that holds the registered source (`undefined` until an app registers one) and imports nothing from rtk. `authEvents.ts` is a leaf emitter that the `authSlice.ts` login and logout listeners and `refreshAuthToken` call. `emptyApi.ts` exports `jwtTokenSource` (`getToken` is `getAuthToken`, `refresh` is `refreshAuthToken` resolving `true` on success, `getUserId` reads the JWT `sub`, `onAuthChange` subscribes to `authEvents`) for apps that want to register it explicitly. Every consumer treats "no source registered" or `jwtTokenSource` as today's JWT path, so JWT apps run the same code. `staggeredBaseQuery` keeps its exact JWT behavior on that path. With any other source it sends that source's token, sends the request with no `authorization` header when the token is null, and never dispatches `auth/logout` for a missing token. On a 401 it calls `refresh()` once and retries, and dispatches `auth/logout` only when the retry is also 401.
|
|
72
|
+
- Acceptance:
|
|
73
|
+
- With a fake `AuthProvider` registered and no JWT in storage, a query sends `authorization: Bearer <fake token>` and dispatches no `auth/logout`. Verification: new case in `rtk/src/isolated/emptyApi.staggered.isolated.ts`; fails on master (logout dispatched), passes on the branch.
|
|
74
|
+
- With a fake source, a 401 then a 200 calls `refresh` once and returns the 200; two 401s dispatch `auth/logout`. Verification: `rtk/src/isolated/emptyApi.staggered.isolated.ts`.
|
|
75
|
+
- Existing JWT base-query tests pass unchanged, and the default source's `getToken` and `refresh` delegate to `getAuthToken` and `refreshAuthToken`. Verification: `cd rtk && bun test ./src/isolated/emptyApi.isolated.ts && bun test ./src/isolated/emptyApi.staggered.isolated.ts && bun test src/tokenSource.test.ts` (each file its own process; `tokenSource.test.ts` uses no module mocks, otherwise it moves under `isolated/`).
|
|
76
|
+
- rtk coverage stays at or above its 95% threshold. Verification: `cd rtk && bun run test:coverage`.
|
|
77
|
+
- Generated skill copies match, and dependency-cruiser finds no new cycle. Verification: `bun run skills:sync && bun run skills:check && bun run analyze:full`.
|
|
78
|
+
- Docs: `docs/reference/legacy/rtk.md` gets a token-source section. Its Better Auth slice snippet and the skill's `auth-and-tokens.md` contradict the code (`{sessionReducer, sessionMiddleware}` and a nonexistent `basePath`); fix them here because the new section sits next to them.
|
|
79
|
+
- Skills: terreno-data-fetching, update-docs.
|
|
80
|
+
- [x] **T2** — Add a web bearer scheme to createBetterAuthClient
|
|
81
|
+
- Depends on: T3
|
|
82
|
+
- Files: rtk/src/betterAuthClient.ts, rtk/src/betterAuthTypes.ts, rtk/src/betterAuthClient.test.ts, docs/how-to/configure-better-auth.md, docs/explanation/authentication.md, docs/how-to/migrate-rtk-to-syncdb.md, changelog/unreleased/better-auth-web-bearer.md
|
|
83
|
+
- Behavior: `BetterAuthClientConfig` gains `webAuth?: "cookie" | "bearer"` (default `"cookie"`) and `bearerStorage?: {getItem, setItem, removeItem}` (default `window.localStorage`, key `${storagePrefix}_bearer_token`). With `"bearer"` on web, the client passes `fetchOptions` with: `credentials: "omit"`; `onSuccess` that stores a `set-auth-token` response header, and clears the stored token after a successful `get-session` response whose body has no session, but only when the token that request sent equals the token stored now; `auth: {type: "Bearer", token: () => stored token}`. A failed or errored `get-session` leaves the token in place. Calling `signOut` clears the stored token locally before the request, whatever the response, so a failed sign-out never leaves a usable token on the device. rtk exports `getBetterAuthBearerToken({storagePrefix?, bearerStorage?}): string | null`, which reads the same key. Nothing is added to the client object: `createAuthClient` returns a route proxy, so an added property would be unreadable and `client.getBearerToken` already resolves to a `/get-bearer-token` request. On native, or with `"cookie"`, the options passed to `createAuthClient` are unchanged.
|
|
84
|
+
- Acceptance:
|
|
85
|
+
- On web with `webAuth: "bearer"`, `onSuccess` with a `set-auth-token: t1` response stores `t1`; `fetchOptions.auth.token()` and `getBetterAuthBearerToken()` return `t1`; `fetchOptions.credentials` is `"omit"`. Verification: `rtk/src/betterAuthClient.test.ts` against the captured `createAuthClient` options.
|
|
86
|
+
- A sign-out clears the token even when its request fails or returns 5xx. A successful `get-session` with a null session clears it, unless a newer token was stored while that request was in flight. A `get-session` that errors or returns 5xx keeps it. Verification: `rtk/src/betterAuthClient.test.ts`.
|
|
87
|
+
- A custom `bearerStorage` is used instead of localStorage. Verification: `rtk/src/betterAuthClient.test.ts`.
|
|
88
|
+
- On native, or without `webAuth`, the captured options equal today's. Verification: existing `createBetterAuthClient` tests pass unchanged.
|
|
89
|
+
- Docs: `configure-better-auth.md` gets "Frontend and API on different sites": when to use bearer, that it sends no cookies so the server's `corsOrigin` default works and the bearer plugin exposes `set-auth-token` itself (both proved by T3), the XSS and shared-device trade-off, and `sessionStorage` for shared devices. Its slice snippet and the missing required `scheme` contradict the code; fix them in this section's neighbourhood. `authentication.md:39` and `:311` describe bearer on web as the cross-site option. `migrate-rtk-to-syncdb.md:336` stops saying web tokens live in AsyncStorage.
|
|
90
|
+
- Skills: update-docs.
|
|
91
|
+
- [x] **T3** — Accept a cookie-less bearer from another origin on the server
|
|
92
|
+
- Depends on: none
|
|
93
|
+
- Files: api/src/betterAuthApp.test.ts
|
|
94
|
+
- Behavior: with `TerrenoApp` and `BetterAuthApp`, both with the default `corsOrigin` (`"*"`) and with an explicit one, a cross-origin `POST /api/auth/sign-in/email` sent without credentials returns `set-auth-token` and lists it in `access-control-expose-headers`. A later request carrying only `Authorization: Bearer <that token>` and no cookie reaches `GET /auth/me` as the user, an authenticated `modelRouter` list route returns 200, and `POST /mcp` `initialize` succeeds. If any of these fails, the task fixes the server (for example `exposedHeaders` in `api/src/terrenoApp.ts`) and adds that file.
|
|
95
|
+
- Acceptance: the cases pass. Verification: `cd api && bun test src/betterAuthApp.test.ts`.
|
|
96
|
+
- Skills: terreno-backend-api.
|
|
97
|
+
- [x] **T4** — Take a getBearerToken option in betterAuthAdapter
|
|
98
|
+
- Depends on: none
|
|
99
|
+
- Files: syncdb/src/auth/betterAuthAdapter.ts, syncdb/src/auth/betterAuthAdapter.test.ts, docs/reference/syncdb.md, changelog/unreleased/syncdb-adapter-bearer-token.md
|
|
100
|
+
- Behavior: `BetterAuthAdapterOptions` gains `getBearerToken?: () => string | null`. When it is passed and returns a token, `getToken` returns that token without calling `getSession()`. Otherwise `getToken` behaves as today. `getUserId`, `onAuthChange` and `refresh` are unchanged. The option is explicit, never detected on the client, so the bridged client from `bridgeBetterAuthReactClient` needs no change.
|
|
101
|
+
- Acceptance: with `getBearerToken` returning `t1`, `getToken()` resolves `t1` and the fake client's `getSession` is not called; without the option, or with it returning null, `getSession` is read as today. Verification: `cd syncdb && bun test src/auth/betterAuthAdapter.test.ts`.
|
|
102
|
+
- Docs: `docs/reference/syncdb.md` adapter section.
|
|
103
|
+
- [x] **T5** — Read the token source in useSocketConnection
|
|
104
|
+
- Depends on: T1
|
|
105
|
+
- Files: rtk/src/socket.ts, rtk/src/isolated/socket.web.isolated.ts, changelog/unreleased/rtk-socket-token-source.md
|
|
106
|
+
- Behavior: `getAuthToken` becomes optional. Without it, the hook reads the token source (the `tokenSource` option, else the registered one). The socket's `auth` is a callback that reads the token on every connect and reconnect. On an auth `connect_error` it calls `refresh()` once per failure episode and reconnects; a second auth failure before a successful connect stops reconnecting and calls `onConnectError`. A successful connect resets the episode. It reconnects when `onAuthChange` fires. The JWT expiry check stays as it is while the JWT source is registered.
|
|
107
|
+
- Acceptance:
|
|
108
|
+
- With a registered fake source and no `getAuthToken`, the handshake sends `Bearer <token>`; after the token changes and `onAuthChange` fires, the next handshake sends the new token. Verification: `rtk/src/isolated/socket.web.isolated.ts`.
|
|
109
|
+
- When `refresh` resolves true but the server keeps rejecting the token, the hook refreshes once and stops. Verification: same file.
|
|
110
|
+
- Passing `getAuthToken` keeps today's behavior, and existing socket tests pass. Verification: `cd rtk && bun test ./src/isolated/socket.isolated.ts && bun test ./src/isolated/socket.web.isolated.ts && bun test src/socket.test.ts`.
|
|
111
|
+
- Docs: JSDoc on the new options (feeds the generated reference); prose in T9.
|
|
112
|
+
- [x] **T6** — Read the token source in useTerrenoChat
|
|
113
|
+
- Depends on: T1
|
|
114
|
+
- Files: rtk/src/useTerrenoChat.ts, rtk/src/isolated/useTerrenoChat.isolated.ts, changelog/unreleased/rtk-chat-token-source.md
|
|
115
|
+
- Behavior: the transport `headers` callback reads the token source (`tokenSource` option, else the registered one) per request, and omits `Authorization` when there is no token.
|
|
116
|
+
- Acceptance: with a registered fake source, the headers callback returns `Authorization: Bearer <token>`; with a null token it returns none. Verification: `cd rtk && bun test ./src/isolated/useTerrenoChat.isolated.ts`.
|
|
117
|
+
- Docs: JSDoc on the new option; prose in T9.
|
|
118
|
+
- [x] **T7** — Read the token source in useMCPTools
|
|
119
|
+
- Depends on: T1
|
|
120
|
+
- Files: rtk/src/useMCPTools.ts, rtk/src/isolated/useMCPTools.isolated.ts, changelog/unreleased/rtk-mcp-token-source.md
|
|
121
|
+
- Behavior: the MCP transport's `authProvider.token` reads the token source (`tokenSource` option, else the registered one) on each call instead of capturing one token per fetch.
|
|
122
|
+
- Acceptance: with a registered fake source, `authProvider.token()` resolves to the source's current token, including after it changes. Verification: `cd rtk && bun test ./src/isolated/useMCPTools.isolated.ts`.
|
|
123
|
+
- Docs: JSDoc on the new option; prose in T9.
|
|
124
|
+
- [x] **T8** — Read the token source in the offline queue and replay
|
|
125
|
+
- Depends on: T1
|
|
126
|
+
- Files: rtk/src/offlineMiddleware.ts, rtk/src/isolated/offlineMiddleware.isolated.ts, changelog/unreleased/rtk-offline-token-source.md
|
|
127
|
+
- Behavior: with a non-JWT source (`OfflineMiddlewareConfig.tokenSource`, else the registered one), the middleware tags and filters queued mutations by the source's `getUserId()` instead of `state.auth.userId` (`offlineMiddleware.ts:142-152, 541, 641`), and `replayMutation` sends the source's token, omitting `authorization` when there is none. On the JWT path it behaves as today.
|
|
128
|
+
- Acceptance: with a registered fake Better Auth source and no `state.auth` slice, a mutation made offline is queued under the source's user id and, on reconnect, replayed with `authorization: Bearer <token>`. Verification: `cd rtk && bun test ./src/isolated/offlineMiddleware.isolated.ts`; existing offline tests pass.
|
|
129
|
+
- Docs: JSDoc on the new option; prose in T9.
|
|
130
|
+
- [x] **T9** — Document the token-source options on rtk's consumers
|
|
131
|
+
- Depends on: T5, T6, T7, T8
|
|
132
|
+
- Files: docs/reference/legacy/rtk.md, docs/how-to/websocket-integration.md
|
|
133
|
+
- Behavior: `legacy/rtk.md` stops calling `getAuthToken` required and JWT-only (`:165`), and documents `tokenSource` on the socket, chat, MCP and offline APIs. `websocket-integration.md:146-165` describes the registered source. The generated rtk reference is built from JSDoc at docs-site build time, so it needs no edit.
|
|
134
|
+
- Acceptance: every option named on the pages exists in the exported signatures, and the docs site builds with the generated rtk reference. Verification: `cd website && bun run build` succeeds, and the docs-audit skill reports no rtk drift.
|
|
135
|
+
- Skills: update-docs, docs-audit.
|
|
136
|
+
- [x] **T10** — Use the token source in example-frontend, with web bearer behind a flag
|
|
137
|
+
- Depends on: T1, T2, T4, T5, T7
|
|
138
|
+
- Files: example-frontend/lib/betterAuth.ts, example-frontend/store/adminAuthHeaders.ts (renamed from store/betterAuthApi.ts), example-frontend/store/index.ts, example-frontend/store/syncdb.ts, example-frontend/openapi-config.ts, example-frontend/store/openApiSdk.ts, example-frontend/app/_layout.tsx, example-frontend/app/admin/_layout.tsx, example-frontend/e2e/crossSiteBearer.spec.ts, example-frontend/e2e/helpers/syncdbSuite.ts, example-frontend/playwright.config.ts, example-frontend/.env.example
|
|
139
|
+
- Behavior: the app registers the `authProvider` it already builds in `store/syncdb.ts` (`betterAuthAdapter(bridgeBetterAuthReactClient(betterAuthClient), ...)`, now with `getBearerToken: getBetterAuthBearerToken`) with `setTokenSource` at startup. It turns on `webAuth: "bearer"` on web when `EXPO_PUBLIC_BETTER_AUTH_WEB_BEARER=true`; T15 sets the flag in the Netlify builds (D6). The generated SDK points back at rtk's `emptySplitApi`, and the old `store/betterAuthApi.ts` becomes `store/adminAuthHeaders.ts`, holding only `getAdminAuthHeaders`, which reads the token source. `_layout.tsx` stops passing its own `getAuthToken` to `useSocketConnection`.
|
|
140
|
+
- Acceptance:
|
|
141
|
+
- A Playwright project runs the web app at `localhost:8082` against the API at `127.0.0.1:4000` (different sites) with the flag on. The test strips `set-cookie` from API responses, signs in through the UI, asserts `context.cookies()` holds no Better Auth session cookie, reloads, and sees the profile screen load (`useGetMeQuery`), the socket connect, and the AI tab's MCP tools load. Verification: `cd example-frontend && bunx playwright test e2e/crossSiteBearer.spec.ts`; it fails on master.
|
|
142
|
+
- The `useMCPTools error` console allowance is removed and the existing Better Auth suites pass: login, signup, profile, realtime, ai-chat. Verification: `bun run check:e2e-affected` then the affected shards.
|
|
143
|
+
- Screenshots of the signed-in profile and AI tab after the reload are saved to `/opt/cursor/artifacts/` and attached to the PR. Verification: verify-ui-changes skill.
|
|
144
|
+
- Skills: generate-sdk, verify-ui-changes, terreno-data-fetching.
|
|
145
|
+
- [x] **T11** — Generate apps that register the token source
|
|
146
|
+
- Depends on: T1
|
|
147
|
+
- Files: create-terreno-app/src/generate.ts, create-terreno-app/src/__tests__/generate.test.ts, changelog/unreleased/cta-token-source.md
|
|
148
|
+
- Behavior: the generated store registers its existing bridged `authProvider` (`generate.ts:2544-2564`) with `setTokenSource`, the generated codegen config points at rtk's `emptySplitApi`, and `store/betterAuthApi.ts` (with its `readSessionToken` fork) is no longer generated. The generated client keeps cookie mode.
|
|
149
|
+
- Acceptance:
|
|
150
|
+
- Generator tests assert the registration and the codegen target, and that no `betterAuthApi.ts` is emitted. Verification: `cd create-terreno-app && bun test src/`.
|
|
151
|
+
- A generated app type-checks against the workspace packages. Verification: generate into a temp dir, link `node_modules`, run `tsc -p frontend/tsconfig.json`.
|
|
152
|
+
|
|
153
|
+
- [x] **T12** — Hand a one-time token to the OAuth callback on the server
|
|
154
|
+
- Depends on: none
|
|
155
|
+
- Files: api/src/betterAuth.ts, api/src/betterAuthSetup.ts, api/src/betterAuthOAuthHandoff.ts, api/src/betterAuthOAuthHandoff.test.ts, api/src/rateLimit/policies.ts, api/src/rateLimit/rateLimit.test.ts, docs/how-to/rate-limiting.md, changelog/unreleased/better-auth-oauth-handoff.md
|
|
156
|
+
- Behavior: `BetterAuthConfig` gains `oauthHandoff?: {webOrigins: string[]}` (unset by default). When set, `createBetterAuth` adds Better Auth's `oneTimeToken` plugin with `expiresIn: 3`, `storeToken: "hashed"`, `disableClientRequest: true` and `setOttHeaderOnNewSession` off, then a Terreno plugin whose after-hook matches only `ctx.path?.startsWith("/callback/")` (social and generic OAuth share `/callback/:id` in 1.7.4). When the callback created a session (`ctx.context.newSession`), the hook reads the redirect `Location`, which must be an absolute http(s) URL already in the URL parser's normal form (relative locations are refused, not resolved). If its origin exactly equals an http(s) origin in `webOrigins` and its `pathname` exactly equals one of `oauthHandoff.callbackPaths` (default `["/"]`; the query is free, so `handoff=` works; entries must be absolute, normalized paths, checked at startup), it mints a token with `auth.api.generateOneTimeToken` (server-side, headers `authorization: Bearer <new session token>`) and appends `ott=<token>` to the `Location` fragment. App schemes (`exp://`, `terreno://`), relative paths and the API's own origin never get a token. `POST /api/auth/one-time-token/verify` returns the session with `set-auth-token`, once. Because Better Auth's OAuth state check needs a cookie that a cross-site `POST /sign-in/social` fetch cannot keep, the plugin also serves `GET /api/auth/oauth-handoff/start?provider&callbackURL[&errorCallbackURL]`: a top-level navigation that checks each URL against `webOrigins` and `trustedOrigins` (400, no redirect, otherwise), starts the flow through `signInSocial` server-side, sets the state cookie first-party on the API origin, and redirects to the provider (decided by the parent after review, attempt 2). The start endpoint applies the same exact-path rule to `callbackURL`; `errorCallbackURL` is checked by origin only, since an error redirect never carries a token. The handed-off callback redirect drops the session cookies it set on the API origin (and the `set-auth-token` header), and verify drops its Set-Cookie while keeping `set-auth-token` (attempt 3).
|
|
157
|
+
- Acceptance:
|
|
158
|
+
- With `oauthHandoff.webOrigins` set and a local mock OIDC provider registered through `plugins` (`genericOAuth`), completing the flow with a `callbackURL` on a listed web origin redirects with `#ott=` in `Location`. Verifying it returns `set-auth-token`; a second verify fails. Verification: `cd api && bun test src/betterAuthOAuthHandoff.test.ts`.
|
|
159
|
+
- A `callbackURL` that passes Better Auth's origin check but is not a listed web origin (an `exp://` scheme in `trustedOrigins`, a relative path, the API origin) gets no `ott`. Verification: same file.
|
|
160
|
+
- The client route `GET /one-time-token/generate` is refused; email sign-in and verify responses carry no `set-ott` header; with `oauthHandoff` unset, no `/one-time-token/*` route exists. Verification: same file.
|
|
161
|
+
- Docs: in T13 (the same page).
|
|
162
|
+
- Skills: terreno-backend-api.
|
|
163
|
+
- [x] **T13** — Exchange the OAuth one-time token for the bearer in rtk
|
|
164
|
+
- Depends on: T2, T12
|
|
165
|
+
- Files: rtk/src/betterAuthClient.ts, rtk/src/betterAuthClient.test.ts, rtk/src/isolated/betterAuthClient.native.isolated.ts, docs/how-to/configure-better-auth.md, docs/reference/legacy/rtk.md, changelog/unreleased/better-auth-web-bearer-oauth.md
|
|
166
|
+
- Behavior: rtk exports `signInSocialWithHandoff({client, provider, callbackURL?, errorCallbackURL?})`, which stores a random nonce in sessionStorage and navigates the page top-level (`window.location.assign`) to T12's `GET <baseURL><basePath>/oauth-handoff/start?provider=…&callbackURL=…&errorCallbackURL=…`, with a `callbackURL` on one of the server's `callbackPaths` (default `/`) that carries `handoff=<nonce>` in its query (D7). A top-level start keeps Better Auth's OAuth state cookie first-party on the API origin. It also exports `completeBetterAuthOAuthHandoff({client}): Promise<boolean>`. On web in bearer mode, when the URL fragment has `ott` and the `handoff` query equals the stored nonce, it removes `ott`, `handoff` and the stored nonce first (`history.replaceState`), then POSTs the token to `/one-time-token/verify`; T2's `onSuccess` stores the bearer. A missing or mismatched nonce drops `ott` from the URL and makes no request. It resolves `true` only when a token was stored. Apps await it before anything reads the session, before any code acts on other query parameters, and before Sentry or analytics start. A return with `?error=provider_not_found|sign_in_failed` resolves `false` and leaves the error for the app to show.
|
|
167
|
+
- Acceptance:
|
|
168
|
+
- After `signInSocialWithHandoff`, a URL with the matching `handoff` and `#ott=abc` makes the function strip both before the request, call verify with `abc`, and leave `t2` stored from a `set-auth-token: t2` response. Verification: `rtk/src/betterAuthClient.test.ts`.
|
|
169
|
+
- An unsolicited `#ott` (no stored nonce, or a different one) makes no request and stores nothing. Without `ott`, on native, or in cookie mode, no request is made. A failed verify resolves `false`. Verification: same file.
|
|
170
|
+
- A null-session `get-session` that was sent before verify finished does not clear the token verify stored. Verification: same file, with the two responses resolved in that order.
|
|
171
|
+
- `ott` is removed from the URL before verify is sent and before the function returns, even when the callback URL carries other query parameters such as `next=`. Verification: same file.
|
|
172
|
+
- `signInSocialWithHandoff` navigates to the start endpoint with an encoded `callbackURL` carrying the nonce. Verification: same file.
|
|
173
|
+
- Docs: `configure-better-auth.md` "Frontend and API on different sites" covers social sign-in: `oauthHandoff: {webOrigins}` on the server, `signInSocialWithHandoff` for the buttons, and awaiting `completeBetterAuthOAuthHandoff` at startup before session reads and Sentry.
|
|
174
|
+
- Skills: update-docs.
|
|
175
|
+
- [x] **T14** — Run cross-site social sign-in in example-frontend
|
|
176
|
+
- Depends on: T10, T13
|
|
177
|
+
- Files: example-frontend/lib/betterAuth.ts, example-frontend/lib/oauthHandoff.ts, example-frontend/lib/oauthHandoffUrl.ts, example-frontend/lib/oauthErrorMessage.ts, example-frontend/index.js, example-frontend/app/_layout.tsx, example-frontend/app/login.tsx, example-frontend/app/signup.tsx, example-frontend/e2e/crossSiteOAuth.spec.ts, example-frontend/e2e/helpers/mockOidcProvider.ts, example-frontend/e2e/helpers/crossSiteApi.ts, example-frontend/playwright.config.ts, example-backend/src/utils/betterAuthConfig.ts
|
|
178
|
+
- Behavior: with the bearer flag on, the social buttons use `signInSocialWithHandoff`, and the app's entry (`index.js`) runs `completeBetterAuthOAuthHandoff` before Expo Router loads, since Expo Router copies the URL when imported and writes it back (built: `lib/oauthHandoffUrl.ts` hides the handoff, `lib/oauthHandoff.ts` restores and exchanges it in one synchronous step); `_layout.tsx` syncs the session only after it resolves, and nothing starts Sentry in this app. example-backend sets `oauthHandoff.webOrigins` from `getWebOrigins()` (and `callbackPaths` for the app's return page) when `BETTER_AUTH_OAUTH_HANDOFF=true`, and registers a `genericOAuth` provider only when `MOCK_OIDC_ISSUER` is set (never in production). Playwright starts the mock OIDC provider as a web server.
|
|
179
|
+
- Acceptance:
|
|
180
|
+
- Cross-site (`localhost:8082` to `127.0.0.1:4000`), with `set-cookie` stripped from API responses, clicking the mock provider's button completes the redirect and lands signed in. `ott` is gone from the URL and `context.cookies()` holds no Better Auth session cookie. Verification: `cd example-frontend && bunx playwright test e2e/crossSiteOAuth.spec.ts`.
|
|
181
|
+
- Opening the app with a made-up `#ott=` does not sign in. Verification: same file.
|
|
182
|
+
- A screenshot of the signed-in page after the return is saved to `/opt/cursor/artifacts/` and attached to the PR. Verification: verify-ui-changes skill.
|
|
183
|
+
- Skills: verify-ui-changes.
|
|
184
|
+
|
|
185
|
+
- [x] **T15** — Deploy example-frontend in bearer mode
|
|
186
|
+
- Depends on: T10
|
|
187
|
+
- Files: scripts/ci/netlify-deploy.sh, scripts/ci/netlify-deploy.test.ts, .circleci/continue-config.yml, example-frontend/e2e/deployedBearer.smoke.ts, example-frontend/playwright.smoke.config.ts, example-frontend/package.json, docs/how-to/run-tests-locally.md
|
|
188
|
+
- Behavior: `netlify-deploy.sh frontend` exports `EXPO_PUBLIC_BETTER_AUTH_WEB_BEARER=true` before `bun run export`, for production and previews. The backends keep `CROSS_DOMAIN_AUTH_COOKIES=true`, so a browser still holding an old cookie build keeps working until it reloads. `deploy-frontend-preview` then runs `bun run smoke:deployed` with `SMOKE_BASE_URL=https://pr-<n>--terreno-frontend.netlify.app`: a Playwright config with no web servers and one spec that signs up a fresh `smoke-<run id>@example.com` user on the PR's isolated preview backend through the UI, reloads, sees the profile screen load, and asserts `context.cookies()` for the API origin holds no Better Auth session cookie. Production deploys only build with the flag: the smoke never creates users on the prod backend.
|
|
189
|
+
- Acceptance:
|
|
190
|
+
- The frontend build step exports the flag before `bun run export`, for both modes. Verification: a `scripts/ci/netlify-deploy.test.ts` case that runs the script with `bun run export` stubbed and asserts the stub saw `EXPO_PUBLIC_BETTER_AUTH_WEB_BEARER=true`.
|
|
191
|
+
- The smoke spec passes against a local cross-site stack (`SMOKE_BASE_URL=http://localhost:8082`, API at `127.0.0.1:4000`). Verification: `cd example-frontend && SMOKE_BASE_URL=http://localhost:8082 bun run smoke:deployed`.
|
|
192
|
+
- On the PR, the `deploy-frontend-preview` CircleCI job runs the smoke after the deploy and it passes. Verification: the job's log on the PR head.
|
|
193
|
+
- Docs: `run-tests-locally.md` maps the new CircleCI step to its local command.
|
|
194
|
+
- Skills: verify-ui-changes.
|
|
195
|
+
|
|
196
|
+
## Assumptions
|
|
197
|
+
|
|
198
|
+
- `@terreno/syncdb` has no `@terreno/*` dependencies, so `rtk → syncdb` adds no cycle. Both are lockstep-versioned.
|
|
199
|
+
- No Redux shape changes for auth: the JWT `onAuthChange` comes from the `authEvents.ts` emitter, not from state. T8 adds optional fields to the offline slice (`lastUserId`, and `userIdConfirmed` on queued mutations) so mutations made after an offline launch keep their user; they are additive.
|
|
200
|
+
- The registered source is module-global, like rtk's existing `baseUrl`. One app, one source.
|
|
201
|
+
- Changelog fragments are `Added` or `Fixed`, except `cta-token-source.md` (`Changed`: generated apps now log out after a 401 survives one refresh, and drop `store/betterAuthApi.ts`). Its migration steps feed the next release's upgrade note, which `bun run check:upgrade-docs` requires.
|
|
202
|
+
- T1 also orders `syncdb` before `rtk` in the CI compile lists (`.circleci/*.yml`, `.github/workflows/admin-spa-ci.yml`) and maps `syncdb/src` changes to `run-rtk`, because rtk's types now import syncdb's.
|
|
203
|
+
- The cross-site Playwright specs (T10, T14) run locally only (`cross-site-bearer` shard, `blocksPullRequest: false`): CircleCI builds one cookie-mode web export for all shards. In CI, bearer mode is covered by T15's deployed-preview smoke.
|
|
204
|
+
- The bearer plugin exposes `set-auth-token` itself on responses that set a session (`node_modules/better-auth/dist/plugins/bearer/index.mjs:62-65`); T3 proves it from another origin.
|
|
205
|
+
- The OAuth handoff hook mints its own token through `auth.api.generateOneTimeToken`; `setOttHeaderOnNewSession` stays off because it fires on every new session, including email sign-in and verify (`node_modules/better-auth/dist/plugins/one-time-token/index.mjs:89-100`). After-hooks run on the callback's thrown redirect, and a hook can rewrite `Location`, as `oauthPopup` does.
|
|
206
|
+
- T9 is a docs join: the prose pages are shared by T5–T8, so one task writes them after all four land. Each of T5–T8 ships its own JSDoc and changelog fragment.
|
|
207
|
+
|
|
208
|
+
## Open questions (recommendation assumed)
|
|
209
|
+
|
|
210
|
+
| ID | Question | Recommendation | Why | If answered differently | Tasks affected |
|
|
211
|
+
| --- | --- | --- | --- | --- | --- |
|
|
212
|
+
| Q1 | Is the web bearer scheme opt-in, or on by default on web? | Opt-in with `webAuth: "bearer"` | The issue asks for opt-in; same-site apps keep HttpOnly cookies, which an XSS cannot read | Default flips to `"bearer"` on web: T2's default and tests change, and the change becomes `Changed` with an upgrade note | T2, T10 |
|
|
213
|
+
| Q2 | Does this plan also close #1217? | Yes | #1579 names the base query as a consumer, and T1's test is #1217's repro | T1 drops the base-query change and only adds the registry; #1217 stays open | T1, T10, T11 |
|
|
214
|
+
| Q3 | With a non-JWT source, what does the base query do when the token is null? | Send the request without auth and let the server answer; log out only after a 401 survives one refresh | The #1217 bug is exactly the client deciding "logged out" on its own | Dispatch `auth/logout` on a null token, as JWT does: T1's first acceptance case inverts | T1 |
|
|
215
|
+
|
|
216
|
+
## Expansions (follow up later)
|
|
217
|
+
|
|
218
|
+
| ID | Idea | Why it came up | Rough size | Depends on |
|
|
219
|
+
| --- | --- | --- | --- | --- |
|
|
220
|
+
| X1 | Move the token source and hooks out of rtk before the next major | `versioning-policy.md:21`: rtk does not ship in the next major | M | T1–T8 |
|
|
221
|
+
| X2 | admin-frontend's `AdminGetAuthHeaders` reads the token source by default | It is another hand-rolled token getter | S | T1 |
|
|
222
|
+
| X3 | `useTerrenoChat`'s default `/api/chat` route does not exist on any backend | Found while tracing chat auth; T6 proves the header, not a live chat | S | T6 |
|
|
223
|
+
| X4 | `createSessionApi` uses `credentials: "same-origin"`, so it cannot work cross-site | Found while tracing cookies | S | none |
|
|
224
|
+
| X5 | Move the deployed example-frontend and API onto same-site custom domains (first-party `SameSite=Lax` cookies) | Q4 trade-offs: neither cross-site cookies nor bearer is the production best practice | S (about half a day, DNS plus deploy config) | none |
|
|
225
|
+
| X6 | syncdb's `betterAuthAdapter` marks its first-load emission so rtk's offline queue can tell it from a sign-in | T8 drops unconfirmed offline-launch writes on any `onAuthChange`, including the adapter's first successful load | S | T8 |
|
|
226
|
+
| X7 | rtk's base-query response handler null-checks a JSON `null` body before reading `more` | The removed example-frontend and generated forks did; no endpoint returns `null` today | S | T10, T11 |
|
|
227
|
+
| X8 | A CircleCI job (nightly or path-gated) that runs the cross-site specs with a bearer-mode export | The handoff browser flow (hide before Expo Router, nonce, strip, verify) only runs locally | S | T10, T14 |
|
|
228
|
+
| X9 | When create-terreno-app gains a bearer option, emit the pre–Expo Router `ott` guard (as example-frontend `index.js` does) or ship one rtk startup helper | Expo Router copies the URL fragment on import, so bearer social sign-in needs an entry-file step today | S | T11, T14 |
|
|
229
|
+
|
|
230
|
+
## Decisions
|
|
231
|
+
|
|
232
|
+
| ID | Question asked | Answer | What it changes |
|
|
233
|
+
| --- | --- | --- | --- |
|
|
234
|
+
| D1 | Where does the token-source API live? | Import syncdb's `AuthProvider` type into rtk | rtk depends on `@terreno/syncdb`; syncdb's `betterAuthAdapter` is the Better Auth source, so rtk writes no Better Auth source of its own |
|
|
235
|
+
| D2 | Where does the web bearer scheme store the token? | localStorage by default, with a `bearerStorage` option | T2's default storage; docs state the XSS and shared-device trade-off |
|
|
236
|
+
| D3 | How do apps give rtk the token source? | Register once with `setTokenSource`, JWT by default, optional per-consumer override | Every consumer task reads the registry first |
|
|
237
|
+
| D4 | Q5: Should bearer mode cover social OAuth sign-in in this plan? | Yes, add the handoff (recommendation was to document the limit) | Adds T12 (server one-time token on the callback, web origins only), T13 (nonce-bound rtk exchange) and T14 (cross-site mock-OIDC Playwright run) |
|
|
238
|
+
| D5 | Approve the plan? | Approved 2026-10-20, with D4 | Status: approved after cut checked the D4 tasks |
|
|
239
|
+
| D6 | Q4: Should the deployed example-frontend switch to bearer mode? | Yes, deploy bearer (recommendation was flag-only) | Adds T15: Netlify builds set the flag, and the PR preview runs a cookie-less sign-in smoke |
|
|
240
|
+
| D7 | Build-time: a cross-site `POST /sign-in/social` loses Better Auth's OAuth state cookie (third-party), so the callback fails with `state_mismatch` | Start the flow with a top-level `GET /oauth-handoff/start` instead of skipping the state check; the ott goes only to exact `callbackPaths` on listed web origins | T12 adds the start endpoint and `callbackPaths`; T13 navigates to it; T14 configures `callbackPaths` |
|
|
241
|
+
| D8 | Build-time: the bearer build's social buttons go to `/oauth-handoff/start`, which 404s unless the deployed backend enables the handoff | Enable `BETTER_AUTH_OAUTH_HANDOFF=true` on the deployed example-backend (prod and previews), with loopback origins excluded from the handoff in production; follows from D4 + D6 | T15 also edits `scripts/ci/gcp-deploy.sh` and example-backend config; the smoke checks the start link redirects |
|
|
242
|
+
|
|
243
|
+
## Sign-off
|
|
244
|
+
|
|
245
|
+
Status: approved 2026-10-20
|
|
246
|
+
Cut: 3 rounds (the third on the D4 tasks), 28 findings: 26 fixed, 1 moved to questions then decided (social OAuth → Q5 → D4), 1 rebutted in part (round 1 F11: the slice-snippet fixes stay, because distill must resolve docs that contradict the code in the affected area)
|
|
@@ -186,6 +186,33 @@ pipeline (Mongoose validation, conflict/baseVersion checks, and ledger ordering
|
|
|
186
186
|
Organization-scoped models do not register this scope and use REST mutations. Product clients
|
|
187
187
|
that omit the marker keep product sync permissions and hooks.
|
|
188
188
|
|
|
189
|
+
### Per-model permission methods
|
|
190
|
+
|
|
191
|
+
`admin.adminPermissions` holds optional `PermissionMethod` arrays per action (`list`, `read`,
|
|
192
|
+
`create`, `update`, `delete`). AdminApp ANDs them with the admin permissions above on every path
|
|
193
|
+
that uses that action: REST CRUD, search (`list`), bulk-patch (`update`), the `/admin/config`
|
|
194
|
+
capability flags and `_adminCapabilities`, and the admin-window sync read and write scopes. They
|
|
195
|
+
can only restrict. An empty array disables the action; for `list` the model also drops out of
|
|
196
|
+
`/admin/config`.
|
|
197
|
+
|
|
198
|
+
### Row scope
|
|
199
|
+
|
|
200
|
+
A model's `queryFilter` (or `admin.adminFilter`, which AdminApp turns into one) is its admin row
|
|
201
|
+
scope, the equivalent of Django's `get_queryset`. List and search add it to the query. Declared
|
|
202
|
+
`filters` only narrow results inside the scope: when a client filter names a field the scope also
|
|
203
|
+
sets, the scope's value is used. Every route
|
|
204
|
+
that resolves one document by id applies it too:
|
|
205
|
+
|
|
206
|
+
- `GET`, `PATCH`, and `DELETE {basePath}{routePath}/:id` (and the array-field routes) return 403
|
|
207
|
+
when the document does not match the scope. The check runs in MongoDB with the same filter the
|
|
208
|
+
list uses. A scope that returns `null` denies every by-id route; the list stays empty.
|
|
209
|
+
- `bulk-patch` lists out-of-scope ids under `failures` with `Forbidden` and patches the rest.
|
|
210
|
+
- Admin-window sync `update` and `delete` mutations nack with 403 for out-of-scope rows.
|
|
211
|
+
- `PATCH`, `bulk-patch`, and admin-window sync updates that write a field the scope constrains
|
|
212
|
+
must leave the document inside the scope (the new state is checked in memory with
|
|
213
|
+
`matchesQuery`). Otherwise they get 403 `Admin write outside row scope`. Creates are not checked
|
|
214
|
+
against the scope, because hooks and defaults often fill the scoped field after validation.
|
|
215
|
+
|
|
189
216
|
With `accessControl`, each model can use a standard admin resource with three actions:
|
|
190
217
|
|
|
191
218
|
| Action | Access |
|
|
@@ -257,6 +284,25 @@ When `AuditApp` is registered, successful admin POST/PATCH/DELETE persist append
|
|
|
257
284
|
not required for the framework log. Failures in either sink do not change the mutation HTTP
|
|
258
285
|
status. `AuditEvent` itself is never audited.
|
|
259
286
|
|
|
287
|
+
Admin create, update, bulk-patch, and admin-window sync bodies drop `readonlyFields`,
|
|
288
|
+
`hiddenFields`, `excludeFields`, and system fields, including dotted paths into them
|
|
289
|
+
(`address.city` for a readonly `address`). With `accessControl`, writes to the user model (`"User"`, or the `userModel` passed to
|
|
290
|
+
`createAccess` under any name) also drop
|
|
291
|
+
`organizationIds` and `roles` in any key form (`roles.0`). A `roles` array in the body is
|
|
292
|
+
assigned after the write through `access.roles.assign`, which requires `rbac:assignRoles`.
|
|
293
|
+
Admin update, bulk-patch, delete, and admin-window sync writes to another user also require the
|
|
294
|
+
actor to hold every permission the target user holds (the RoleManager target-privilege rule).
|
|
295
|
+
A delegate with `user:update` gets 403 `Cannot modify a user with permissions you do not hold` for
|
|
296
|
+
a superadmin target; bulk-patch reports that target under `failures` and patches the rest. Legacy
|
|
297
|
+
`admin: true` actors and writes to the actor's own record are not checked.
|
|
298
|
+
|
|
299
|
+
Changing another user's `email`, `disabled`, `betterAuthId`, `githubId`, or `emailVerified`
|
|
300
|
+
through admin `PATCH`, bulk-patch, or admin-window sync also needs `user:updateIdentity`. Without it
|
|
301
|
+
the write gets 403 `Missing user:updateIdentity permission`, and bulk-patch lists the target under
|
|
302
|
+
`failures` with that title. Values equal to the stored ones do not count, so the edit form can
|
|
303
|
+
still save other fields. Existing deployments must add `updateIdentity` to the stored `admin` role.
|
|
304
|
+
See [RBAC user identity writes](api.md#rbac-user-identity-writes).
|
|
305
|
+
|
|
260
306
|
**Important:** Only expose models that should be editable via admin panel. Avoid sensitive internal models.
|
|
261
307
|
|
|
262
308
|
## Document storage
|
|
@@ -20,8 +20,8 @@
|
|
|
20
20
|
| `excludeFields` | `exclude` | Additional response-scrubbing fields. |
|
|
21
21
|
| `autocompleteFields` | `autocomplete_fields` | Reference fields using async search. |
|
|
22
22
|
| `fieldOverrides` | `formfield_overrides` | Per-field label/help/widget metadata. |
|
|
23
|
-
| `adminPermissions` | `has_*_permission` |
|
|
24
|
-
| `adminFilter` | `get_queryset` | Server-enforced
|
|
23
|
+
| `adminPermissions` | `has_*_permission` | Per-action permission methods ANDed with the admin RBAC/`IsAdmin` check on admin REST, search, bulk patch, config capabilities, and admin-window sync. `[]` disables the action, including `list` and `read`. |
|
|
24
|
+
| `adminFilter` | `get_queryset` | Server-enforced row scope for list, search, by-id read/update/delete, bulk patch, and admin-window sync writes. |
|
|
25
25
|
| `actions` | `actions` | Declarative bulk-patch/background actions. |
|
|
26
26
|
| `bulkPatchAllowlist` | — | Fields accepted by bulk patch. |
|
|
27
27
|
| `recordTitleField` | `__str__` | Field used for edit-page titles. |
|