@suluk/better-auth 0.1.2 → 0.2.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/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  <h1 align="center">@suluk/better-auth</h1>
8
8
 
9
- <p align="center"><b>Official Better-Auth-on-Hono support for Suluk: auth methods -> v4 securitySchemes; ingest Better Auth's OpenAPI 3.0 -> v4; session -> principal for per-viewer docs.</b></p>
9
+ <p align="center"><b>Wire Better Auth into the v4 contract — auth methods become <code>securitySchemes</code>, the session becomes a <code>principal</code>, and Better Auth's own OpenAPI surface is ingested, never re-typed.</b></p>
10
10
 
11
11
  <p align="center">
12
12
  <em>Part of <a href="https://github.com/MahmoodKhalil57/suluk">Suluk</a> — one typed OpenAPI v4 contract projecting into every full-stack layer.</em>
@@ -24,11 +24,193 @@
24
24
  bun add @suluk/better-auth
25
25
  ```
26
26
 
27
- ## The Suluk cycle
27
+ `better-auth` and `hono` are optional peers — everything here is duck-typed, so you only need the ones
28
+ you actually use.
28
29
 
29
- `@suluk/better-auth` is one station on the Suluk walk — author one v4 source, then **validate · audit ·
30
- preview · generate · deploy** the whole stack from it. Explore the full toolchain in the
31
- [main repository](https://github.com/MahmoodKhalil57/suluk) or drive it from the [VS Code cockpit](https://marketplace.visualstudio.com/items?itemName=MahmoodKhalil.suluk-vscode).
30
+ ## What it does
31
+
32
+ [Better Auth](https://www.better-auth.com) is a **Contract input** (your auth settings). This package
33
+ reflects it into the Suluk v4 document and closes the per-viewer loop, without you re-typing the auth surface:
34
+
35
+ - **Auth methods → v4 `securitySchemes`** — `authSecuritySchemes({ session, bearer, apiKey, … })` derives the
36
+ `components.securitySchemes` block (session-cookie, HTTP bearer, API-key header).
37
+ - **Better Auth's OpenAPI 3.0 → v4** — `ingestAuthOpenAPI` normalizes Better Auth's own
38
+ `generateOpenAPISchema()` output to JSON Schema 2020-12, lifts it to v4 (via `@suluk/openapi-compat`), and
39
+ `mergeAuth` folds the auth routes + schemes into your app's document. `/sign-up`, `/get-session`, … get
40
+ documented for free.
41
+ - **Session → `{ scopes }` principal** — `principalFromSession` maps a Better Auth session (role, granted
42
+ scopes, 2FA state, org memberships) to the `{ scopes }` shape `@suluk/hono`'s `emitV4(routes, { principal })`
43
+ uses to project the doc each viewer is allowed to see. `verifyApiKey` produces the **same** shape for API-key
44
+ callers, so enforcement is identical for sessions and keys.
45
+ - **Thin Hono mount + auth-flow plumbing** — `mountAuth` routes `/api/auth/*` to the Better Auth handler;
46
+ helpers cover open-redirect-safe redirects, frictionless email verification, a GDPR erasure cascade, and a
47
+ fail-closed role-preview login route.
48
+
49
+ ## When to reach for it
50
+
51
+ Reach for it when your app uses Better Auth and you want **auth reflected in the contract** — its schemes and
52
+ its endpoints in the v4 doc — plus a **principal** that drives per-viewer projection and `@suluk/hono` access
53
+ enforcement. The session→principal shape is the seam many other packages key off; this is where it's produced.
54
+
55
+ Don't reach for it to *render* the doc (that's `@suluk/reference` / `@suluk/scalar` / `@suluk/swagger`) or to
56
+ build routes from entities (that's `@suluk/builder` / `@suluk/drizzle`). This package's job ends at handing
57
+ those layers a contract that already knows about auth, and a principal to scope it.
58
+
59
+ ## Usage
60
+
61
+ ### Reflect auth into the contract
62
+
63
+ The common case: derive the schemes, ingest Better Auth's own OpenAPI, and merge both into the app doc.
64
+
65
+ ```ts
66
+ import { authSecuritySchemes, ingestAuthOpenAPI, mergeAuth } from "@suluk/better-auth";
67
+ import { auth } from "./auth"; // your betterAuth({ … }) instance, with the openAPI() plugin enabled
68
+
69
+ // 1. auth methods → v4 securitySchemes
70
+ const { securitySchemes } = authSecuritySchemes({ session: true, bearer: true, apiKey: true });
71
+
72
+ // 2. ingest Better Auth's own OpenAPI 3.0 surface, lifted to v4 and prefixed under its mount base
73
+ const authSchema = await auth.api.generateOpenAPISchema(); // OpenAPI 3.0
74
+ const authV4 = ingestAuthOpenAPI(authSchema, { basePath: "/api/auth" });
75
+
76
+ // 3. fold auth routes + schemes into your app's v4 document
77
+ const document = mergeAuth(appDocument, authV4, { securitySchemes });
78
+ // → document.paths now has "api/auth/sign-up/email", …; components.securitySchemes has sessionCookie/bearerAuth/apiKey
79
+ ```
80
+
81
+ `authSecuritySchemes` also reports session-based plugins that add **no** new wire scheme but gate into the
82
+ session — `{ twoFactor, passkey, organization }` — via the returned `plugins` field.
83
+
84
+ ### Close the per-viewer loop (session → principal)
85
+
86
+ ```ts
87
+ import { principalFromSession } from "@suluk/better-auth";
88
+ import { emitV4 } from "@suluk/hono";
89
+
90
+ const session = await auth.api.getSession({ headers: req.headers });
91
+ const principal = principalFromSession(session, {
92
+ roleScopes: { admin: ["read:*", "write:*"], user: ["read:self"] },
93
+ });
94
+
95
+ // @suluk/hono projects only the operations this viewer's scopes allow
96
+ const { document } = emitV4(routes, { principal });
97
+ ```
98
+
99
+ `principalFromSession` also encodes plugin state **as scopes**: a 2FA-cleared session gains `mfa:verified`
100
+ (the exported `MFA_SCOPE`), and each org membership contributes `org:<id>:<scope>` scopes (build/parse them with
101
+ `orgScope` / `parseOrgScope`) — so 2FA and tenancy gate through the same scope check `@suluk/hono` already runs.
102
+
103
+ ### Scope-aware API-key verification
104
+
105
+ Give API-key callers the **same** `{ scopes }` principal as sessions, so `@suluk/hono`'s `createGuard` /
106
+ `enforceAccess` works for keys too:
107
+
108
+ ```ts
109
+ import { verifyApiKey } from "@suluk/better-auth";
110
+
111
+ const result = await verifyApiKey(auth.api, key, { requireScopes: ["orders:read"] });
112
+ if (!result.ok) {
113
+ // result.reason is "invalid" | "insufficient_scope" | "error"
114
+ return new Response("unauthorized", { status: 401 });
115
+ }
116
+ result.principal; // { scopes: ["orders:read", …] } — feed this to @suluk/hono
117
+ result.key; // { id, userId, name, metadata } — metadata parsed past Better Auth's double-stringification
118
+ ```
119
+
120
+ `scopesToPermissions` / `permissionsToScopes` convert between flat `"cart:read"` scopes and Better Auth's
121
+ `{ cart: ["read"] }` permission shape.
122
+
123
+ ### Mount the handler on Hono
124
+
125
+ ```ts
126
+ import { mountAuth } from "@suluk/better-auth";
127
+ import { Hono } from "hono";
128
+
129
+ const app = new Hono();
130
+ mountAuth(app, auth); // routes POST/GET /api/auth/* → auth.handler(c.req.raw)
131
+ ```
132
+
133
+ ### Auth-flow UX: redirects + frictionless verification
134
+
135
+ ```ts
136
+ import {
137
+ resolveRedirectTo, withRedirectTo, isSafeRelativePath, emailVerificationConfig,
138
+ } from "@suluk/better-auth";
139
+
140
+ resolveRedirectTo(location.search); // read ?redirectTo, honored only if same-origin-relative (open-redirect-safe)
141
+ withRedirectTo("/login", "/dashboard"); // "/login?redirectTo=%2Fdashboard" — return here post-auth
142
+
143
+ // spread into betterAuth({ emailVerification: … }) — verify-on-sign-up + auto-sign-in after, by default
144
+ const emailVerification = emailVerificationConfig({
145
+ sendVerificationEmail: async ({ user, url }) => { await sendVerifyEmail(user.email, url); },
146
+ });
147
+ ```
148
+
149
+ ### GDPR erasure cascade
150
+
151
+ `beforeDeleteCascade` builds Better Auth's `user.deleteUser.beforeDelete` hook from an ordered list of steps.
152
+ The package orchestrates; you supply the steps and pick the posture (`deleteStep` hard-deletes, `anonymizeStep`
153
+ scrubs PII, `step` is generic). Fail-closed by default — a failed cleanup aborts the delete so no rows are orphaned.
154
+
155
+ ```ts
156
+ import { betterAuth } from "better-auth";
157
+ import { beforeDeleteCascade, deleteStep } from "@suluk/better-auth";
158
+
159
+ const steps = [
160
+ deleteStep("orders", (user) => db.delete(orders).where(eq(orders.customerId, user.id)).run()),
161
+ deleteStep("apiTokens", (user) => db.delete(apiToken).where(eq(apiToken.userId, user.id)).run()),
162
+ ];
163
+
164
+ betterAuth({
165
+ user: { deleteUser: { enabled: true, beforeDelete: beforeDeleteCascade(steps) } },
166
+ });
167
+ ```
168
+
169
+ ### Role-preview login (the one credentialed surface)
170
+
171
+ `previewLoginHandler` mints a role-scoped session for the seeded demo user, for the generated preview Worker —
172
+ fail-closed behind two independent locks (`env.SULUK_PREVIEW === "1"` **and** an `env.PREVIEW_DB` binding;
173
+ absence of either ⇒ 404). The role comes from a server-side allow-list, never a client header.
174
+
175
+ ```ts
176
+ import { previewLoginHandler } from "@suluk/better-auth";
177
+
178
+ // inside the preview Worker, for GET /preview/login?role=…
179
+ return previewLoginHandler(request, env, {
180
+ allowedRoles: ["user", "admin"], // from the contract's preview roles, NEVER hardcoded loosely
181
+ mintSession: (role) => mintDemoSession(role), // binds to a seeded throwaway demo user for `role`
182
+ });
183
+ ```
184
+
185
+ ## API
186
+
187
+ | Export | What it does |
188
+ | --- | --- |
189
+ | `authSecuritySchemes(methods)` | Auth methods → v4 `securitySchemes` + the enabled session-plugins (`AuthMethods` → `AuthSecurity`). |
190
+ | `ingestAuthOpenAPI(schema30, opts?)` | Normalize Better Auth's OpenAPI 3.0 to 2020-12 and lift it to a v4 document. |
191
+ | `normalizeOas30(node)` | Rewrite 3.0 Schema dialect (`nullable`, boolean `exclusiveMin/Max`) into JSON Schema 2020-12. |
192
+ | `mergeAuth(app, auth, extra?)` | Deep-merge auth paths + schemas + `securitySchemes` into the app's v4 document. |
193
+ | `principalFromSession(session, opts?)` | Better Auth session → `{ scopes }` `Principal` (role, scopes, MFA, org). |
194
+ | `MFA_SCOPE`, `orgScope`, `parseOrgScope` | The `mfa:verified` scope + build/parse `org:<id>:<scope>` tenancy scopes. |
195
+ | `verifyApiKey(verifier, key, opts?)` | Verify an API key (optionally `requireScopes`) → the same `{ scopes }` `Principal`. |
196
+ | `scopesToPermissions` / `permissionsToScopes` | Convert between flat `"a:b"` scopes and Better Auth's `{ a: ["b"] }` permissions. |
197
+ | `parseApiKeyMetadata(raw)` | Parse key metadata past Better Auth's double-stringification quirk. |
198
+ | `beforeDeleteCascade(steps, opts?)` | Build the `beforeDelete` hook from an ordered, fail-closed erasure cascade. |
199
+ | `step` / `anonymizeStep` / `deleteStep` | Cascade-step constructors (generic / scrub-PII / hard-delete). |
200
+ | `isSafeRelativePath` / `resolveRedirectTo` / `withRedirectTo` | Open-redirect-safe `redirectTo` preservation. |
201
+ | `emailVerificationConfig(opts)` | A Better Auth `emailVerification` block with frictionless-activation defaults. |
202
+ | `mountAuth(app, auth, opts?)` | Mount the Better Auth handler onto a Hono app under `basePath/*`. |
203
+ | `previewLoginHandler(req, env, opts)` / `isPreviewRuntime(env)` | Fail-closed role-preview login + its two-lock gate check. |
204
+
205
+ ## Boundary
206
+
207
+ This package **reflects and reads** Better Auth — it never becomes the auth runtime. Better Auth owns sessions,
208
+ storage, and the credential flows; Suluk turns its configuration into a v4 contract and a scope-bearing principal.
209
+ The seams are injected, not assumed: `mountAuth` / `verifyApiKey` are duck-typed against `auth.handler` /
210
+ `auth.api` (no hard `better-auth` or `hono` import); `beforeDeleteCascade` takes the steps that touch *your* db;
211
+ `previewLoginHandler` takes the `mintSession` that owns the session lookup. The session→principal **shape** is a
212
+ stable contract many other `@suluk/*` packages depend on — extend the method→scheme and session→scope mappings,
213
+ but keep that shape steady. Rendering the resulting doc, and hosting the auth itself, stay outside this line.
32
214
 
33
215
  ## License
34
216
 
package/package.json CHANGED
@@ -1,45 +1,45 @@
1
1
  {
2
2
  "name": "@suluk/better-auth",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Official Better-Auth-on-Hono support for Suluk: auth methods -> v4 securitySchemes; ingest Better Auth's OpenAPI 3.0 -> v4; session -> principal for per-viewer docs. CANDIDATE tooling.",
5
5
  "publishConfig": {
6
- "access": "public"
7
- },
8
- "license": "Apache-2.0",
9
- "repository": {
10
- "type": "git",
11
- "url": "git+https://github.com/MahmoodKhalil57/suluk.git",
12
- "directory": "tooling/ts/packages/better-auth"
13
- },
14
- "homepage": "https://github.com/MahmoodKhalil57/suluk#readme",
15
- "bugs": "https://github.com/MahmoodKhalil57/suluk/issues",
16
- "type": "module",
17
- "main": "src/index.ts",
18
- "exports": {
19
- ".": "./src/index.ts"
20
- },
21
- "dependencies": {
22
- "@suluk/core": "0.1.6",
23
- "@suluk/openapi-compat": "0.1.1"
24
- },
25
- "peerDependencies": {
26
- "better-auth": "^1.0.0",
27
- "hono": "^4.0.0"
28
- },
29
- "peerDependenciesMeta": {
30
- "better-auth": {
31
- "optional": true
32
- },
33
- "hono": {
34
- "optional": true
35
- }
36
- },
37
- "devDependencies": {
38
- "@types/bun": "latest",
39
- "@suluk/hono": "0.1.1"
40
- },
41
- "scripts": {
42
- "test": "bun test",
43
- "typecheck": "tsc --noEmit -p ."
44
- }
6
+ "access": "public"
7
+ },
8
+ "license": "Apache-2.0",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/MahmoodKhalil57/suluk.git",
12
+ "directory": "tooling/ts/packages/better-auth"
13
+ },
14
+ "homepage": "https://github.com/MahmoodKhalil57/suluk#readme",
15
+ "bugs": "https://github.com/MahmoodKhalil57/suluk/issues",
16
+ "type": "module",
17
+ "main": "src/index.ts",
18
+ "exports": {
19
+ ".": "./src/index.ts"
20
+ },
21
+ "dependencies": {
22
+ "@suluk/core": "^0.1.13",
23
+ "@suluk/openapi-compat": "^0.1.3"
24
+ },
25
+ "peerDependencies": {
26
+ "better-auth": "^1.0.0",
27
+ "hono": "^4.0.0"
28
+ },
29
+ "peerDependenciesMeta": {
30
+ "better-auth": {
31
+ "optional": true
32
+ },
33
+ "hono": {
34
+ "optional": true
35
+ }
36
+ },
37
+ "devDependencies": {
38
+ "@types/bun": "latest",
39
+ "@suluk/hono": "^0.1.5"
40
+ },
41
+ "scripts": {
42
+ "test": "bun test",
43
+ "typecheck": "tsc --noEmit -p ."
44
+ }
45
45
  }
package/src/apikey.ts ADDED
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Scope-aware API-key verification (saastarter-parity Phase 0). Closes the key-auth hole the advisory-facet model
3
+ * leaves: @suluk/hono's enforceAccess works off a `{ scopes }` Principal, but only `principalFromSession` produced
4
+ * one — a caller authenticating with an API KEY had no Principal, so a scope-gated op was unreachable-or-undefended
5
+ * for key auth. `verifyApiKey` wraps Better Auth's server `verifyApiKey` and returns the SAME `{ scopes }` Principal,
6
+ * so enforceAccess / createGuard gate key callers and session callers identically.
7
+ *
8
+ * Ported from saastarter src/lib/api-key/ (scopes.ts, metadata.ts) + the scope-check in services/auth.ts:133-147.
9
+ */
10
+ import type { Principal } from "./principal";
11
+
12
+ /** Metadata stored on a key for delegation tracking (saastarter metadata.ts:4-8). */
13
+ export interface ApiKeyMetadata {
14
+ parentKeyId?: string;
15
+ parentKeyName?: string;
16
+ createdVia?: "delegation";
17
+ }
18
+
19
+ /**
20
+ * Safely parse key metadata, handling Better Auth's potential DOUBLE-stringification of the JSON field.
21
+ * Ported verbatim from saastarter metadata.ts:14-39 (the double-JSON.parse guard is load-bearing — without it a
22
+ * double-stringified blob silently reads as a string, not the object).
23
+ */
24
+ export function parseApiKeyMetadata(raw: unknown): ApiKeyMetadata | null {
25
+ if (!raw) return null;
26
+ if (typeof raw === "object" && !Array.isArray(raw)) return raw as ApiKeyMetadata;
27
+ if (typeof raw === "string") {
28
+ try {
29
+ let parsed: unknown = JSON.parse(raw);
30
+ if (typeof parsed === "string") parsed = JSON.parse(parsed); // double-stringified
31
+ if (typeof parsed === "object" && parsed && !Array.isArray(parsed)) return parsed as ApiKeyMetadata;
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+ return null;
37
+ }
38
+
39
+ /**
40
+ * Flat scopes → Better Auth permissions. `["cart:read","cart:write"]` → `{ cart: ["read","write"] }`.
41
+ * Ported from saastarter scopes.ts:150-161 — `split(":")` destructures only the first two segments, so a malformed
42
+ * `"a:b:c"` yields `{ a: ["b"] }` and a segment-less `"x"` is skipped (no action). Faithful to saastarter semantics.
43
+ */
44
+ export function scopesToPermissions(scopes: string[]): Record<string, string[]> {
45
+ const perms: Record<string, string[]> = {};
46
+ for (const scope of scopes) {
47
+ const [resource, action] = scope.split(":");
48
+ if (!resource || !action) continue;
49
+ (perms[resource] ??= []).push(action);
50
+ }
51
+ return perms;
52
+ }
53
+
54
+ /**
55
+ * Better Auth permissions → flat scopes. `{ cart: ["read","write"] }` → `["cart:read","cart:write"]`.
56
+ * DEVIATION from saastarter scopes.ts:167-179 (receipted): the `if (scope in API_SCOPES)` catalog filter is REMOVED.
57
+ * The scope catalog is APP-domain vocabulary (saastarter's ecommerce products/cart/orders), not auth machinery —
58
+ * baking a fixed catalog into a candidate-spec package would couple it to one app's domain. An app that wants
59
+ * catalog-validation filters the result against its own catalog. Lowered ceiling: this is reusable-primitive intent,
60
+ * not a behavioral port.
61
+ */
62
+ export function permissionsToScopes(perms: Record<string, string[]> | null | undefined): string[] {
63
+ if (!perms) return [];
64
+ const scopes: string[] = [];
65
+ for (const [resource, actions] of Object.entries(perms)) {
66
+ for (const action of actions ?? []) scopes.push(`${resource}:${action}`);
67
+ }
68
+ return scopes;
69
+ }
70
+
71
+ /** A duck-typed view of Better Auth's server `verifyApiKey` (the app injects `betterAuth.api`). */
72
+ export interface ApiKeyVerifierLike {
73
+ verifyApiKey(args: { body: { key: string; permissions?: Record<string, string[]> } }): Promise<{
74
+ valid: boolean;
75
+ error?: { message?: string; code?: string } | null;
76
+ key?: { id?: string; userId?: string; name?: string; permissions?: Record<string, string[]> | null; metadata?: unknown } | null;
77
+ }>;
78
+ }
79
+
80
+ export type VerifyReason = "invalid" | "insufficient_scope" | "error";
81
+
82
+ /** The verified key's identity surface (metadata parsed via the double-stringification guard). */
83
+ export interface VerifiedKey {
84
+ id?: string;
85
+ userId?: string;
86
+ name?: string;
87
+ metadata: ApiKeyMetadata | null;
88
+ }
89
+
90
+ export interface VerifyApiKeyResult {
91
+ ok: boolean;
92
+ /** why verification failed (absent on success). */
93
+ reason?: VerifyReason;
94
+ /** the `{ scopes }` Principal — the SAME shape principalFromSession returns, so enforceAccess works identically. */
95
+ principal?: Principal;
96
+ key?: VerifiedKey;
97
+ }
98
+
99
+ export interface VerifyApiKeyOptions {
100
+ /** require the key to carry these scopes (checked in the SAME call via Better Auth `permissions`, services/auth.ts:133-147). */
101
+ requireScopes?: string[];
102
+ }
103
+
104
+ /**
105
+ * Verify an API key (optionally requiring scopes) and return a `{ scopes }` Principal.
106
+ *
107
+ * DEVIATION from saastarter (receipted): saastarter never derives IDENTITY from `verifyApiKey` — identity comes from
108
+ * the session, and `verifyApiKey` is used ONLY to check scopes (services/auth.ts:133-147). Suluk's key-auth-only
109
+ * path uses the verified key's `userId` + `permissions` AS the Principal — an invented composition for stateless API
110
+ * callers that have no session. Result-returning (not throwing) to match the package idiom (preview.ts/principal.ts).
111
+ */
112
+ export async function verifyApiKey(
113
+ verifier: ApiKeyVerifierLike,
114
+ key: string,
115
+ opts: VerifyApiKeyOptions = {},
116
+ ): Promise<VerifyApiKeyResult> {
117
+ const want = opts.requireScopes?.length ? scopesToPermissions(opts.requireScopes) : undefined;
118
+ let res: Awaited<ReturnType<ApiKeyVerifierLike["verifyApiKey"]>>;
119
+ try {
120
+ res = await verifier.verifyApiKey({ body: want ? { key, permissions: want } : { key } });
121
+ } catch {
122
+ return { ok: false, reason: "error" };
123
+ }
124
+ if (!res?.valid || !res.key) {
125
+ // best-effort: distinguish a scope failure from a bad key via the error code/message (only when scopes were required).
126
+ const code = `${res?.error?.code ?? ""} ${res?.error?.message ?? ""}`;
127
+ if (want && /scope|permission|forbidden/i.test(code)) return { ok: false, reason: "insufficient_scope" };
128
+ return { ok: false, reason: "invalid" };
129
+ }
130
+ return {
131
+ ok: true,
132
+ principal: { scopes: permissionsToScopes(res.key.permissions) },
133
+ key: { id: res.key.id, userId: res.key.userId, name: res.key.name, metadata: parseApiKeyMetadata(res.key.metadata) },
134
+ };
135
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Auth-flow UX helpers (saastarter parity: "auth preserves where you came from", "frictionless activation").
3
+ * Two concerns, both library-owned so every builder's auth pages inherit them instead of re-deriving:
4
+ * - redirect preservation with an OPEN-REDIRECT guard (a `redirectTo` is honored only when it's a same-origin
5
+ * relative path), threaded into social/magic-link callbackURL + the post-sign-in redirect + sign-out return;
6
+ * - a Better Auth emailVerification block with frictionless defaults (verify-on-sign-up + auto-sign-in after).
7
+ * Pure + dependency-free.
8
+ */
9
+
10
+ /** A path is safe to redirect to iff it's a SINGLE-leading-slash relative path (rejects "//host", "http(s)://…",
11
+ * backslash tricks, and protocol-relative URLs) — defends against open-redirect. */
12
+ export function isSafeRelativePath(p: string | null | undefined): p is string {
13
+ return !!p && p.startsWith("/") && !p.startsWith("//") && !p.startsWith("/\\") && !p.includes("://") && !p.includes("\\");
14
+ }
15
+
16
+ /** Read `redirectTo` from a query string / URLSearchParams; return it only if same-origin-relative, else `fallback`. */
17
+ export function resolveRedirectTo(search: string | URLSearchParams, fallback = "/"): string {
18
+ const params = typeof search === "string" ? new URLSearchParams(search.startsWith("?") ? search.slice(1) : search) : search;
19
+ const raw = params.get("redirectTo");
20
+ return isSafeRelativePath(raw) ? raw : fallback;
21
+ }
22
+
23
+ /** Append a (guarded) `redirectTo` to an href — e.g. point "/login" at the page the user was on, so post-auth
24
+ * returns there. A non-safe target is dropped (the href is returned unchanged). */
25
+ export function withRedirectTo(href: string, redirectTo: string | null | undefined): string {
26
+ if (!isSafeRelativePath(redirectTo)) return href;
27
+ return href + (href.includes("?") ? "&" : "?") + "redirectTo=" + encodeURIComponent(redirectTo);
28
+ }
29
+
30
+ export interface EmailVerificationOptions {
31
+ /** send the verification email — bind to your branded-email builder. */
32
+ sendVerificationEmail: (data: { user: { email: string }; url: string; token?: string }) => Promise<void> | void;
33
+ /** sign the user in automatically after they click the verification link (default true — frictionless). */
34
+ autoSignIn?: boolean;
35
+ /** send a verification email on sign-up (default true). */
36
+ sendOnSignUp?: boolean;
37
+ }
38
+
39
+ /** A Better Auth `emailVerification` block with frictionless-activation defaults. Spread into
40
+ * `betterAuth({ emailVerification: emailVerificationConfig({ sendVerificationEmail }) })`. */
41
+ export function emailVerificationConfig(opts: EmailVerificationOptions) {
42
+ return {
43
+ sendOnSignUp: opts.sendOnSignUp ?? true,
44
+ autoSignInAfterVerification: opts.autoSignIn ?? true,
45
+ sendVerificationEmail: opts.sendVerificationEmail,
46
+ };
47
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * devLoginHandler (C057) — the LOCAL-DEV any-email login: the mock for Google OAuth when no `GOOGLE_CLIENT_ID` is set,
3
+ * so you sign in as ANY email with no password. It mints a REAL Better Auth session via the PUBLIC server API
4
+ * (`signUpEmail` idempotently, then `signInEmail({ asResponse: true })` with a fixed internal dev password) — it never
5
+ * hand-forges a cookie or touches the internal adapter, so the session is exactly what a real login produces.
6
+ *
7
+ * The security control lives HERE (npm), so it flows to every consumer and an app can't weaken it by editing its wiring:
8
+ * it is FAIL-CLOSED behind an `armed` flag the caller must pass `true`, checked FIRST — before any request input is read.
9
+ * The registry wiring arms it only in dev-mock mode (non-production AND no Google key); a prod deploy (ENVIRONMENT=
10
+ * "production") passes `armed: false`, so the endpoint returns 404 as if it did not exist. The fixed dev password is an
11
+ * internal detail (never surfaced) used purely to drive the email/password flow; it is not a real credential.
12
+ */
13
+
14
+ /** The Better Auth surface this needs — its public `signUpEmail`/`signInEmail` server endpoints. Duck-typed. */
15
+ export interface DevLoginAuthLike {
16
+ api: {
17
+ signUpEmail(input: { body: { email: string; password: string; name: string } }): Promise<unknown>;
18
+ signInEmail(input: { body: { email: string; password: string }; asResponse: true }): Promise<Response>;
19
+ };
20
+ }
21
+
22
+ export interface DevLoginOptions {
23
+ /** FAIL-CLOSED gate — MUST be `true` to arm the endpoint. The registry passes its dev-mock condition; prod passes false. */
24
+ armed: boolean;
25
+ /** the Better Auth instance (its `api.signUpEmail`/`signInEmail`). */
26
+ auth: DevLoginAuthLike;
27
+ /** the incoming request — a JSON body `{ email }`. */
28
+ request: Request;
29
+ /** override the fixed internal dev password (dev only; never surfaced). */
30
+ devPassword?: string;
31
+ }
32
+
33
+ /** The fixed internal password the dev-login uses to drive email/password sign-up + sign-in. Not a real credential. */
34
+ export const DEV_LOGIN_PASSWORD = "suluk-dev-login-fixed-pw-00000000";
35
+
36
+ const isEmail = (s: string): boolean => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(s);
37
+ const json = (obj: unknown, status: number): Response => new Response(JSON.stringify(obj), { status, headers: { "content-type": "application/json" } });
38
+
39
+ /**
40
+ * Handle `POST /api/auth/dev-login` with `{ email }`. FAIL-CLOSED: 404 unless `armed` (checked before reading input);
41
+ * 400 for a missing/invalid email; else mint a real session for that email and return the sign-in Response (Set-Cookie).
42
+ * Never throws on a hostile request.
43
+ */
44
+ export async function devLoginHandler(opts: DevLoginOptions): Promise<Response> {
45
+ // GATE first — before any client input is read — so a request to a non-armed deploy can never reach the mint path.
46
+ if (opts.armed !== true) return new Response("not found", { status: 404 });
47
+
48
+ let email = "";
49
+ try { email = String(((await opts.request.json()) as { email?: unknown }).email ?? "").trim().toLowerCase(); } catch { email = ""; }
50
+ if (!email || !isEmail(email)) return json({ error: "a valid `email` is required" }, 400);
51
+
52
+ const password = opts.devPassword ?? DEV_LOGIN_PASSWORD;
53
+ // find-or-create: sign up (idempotent — a re-login of an existing dev user throws "exists", which we ignore), then
54
+ // sign in AS A RESPONSE so the session Set-Cookie rides back exactly as a real login would set it.
55
+ try { await opts.auth.api.signUpEmail({ body: { email, password, name: email } }); } catch { /* user already exists */ }
56
+ return opts.auth.api.signInEmail({ body: { email, password }, asResponse: true });
57
+ }
package/src/erasure.ts ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * GDPR erasure cascade (saastarter-parity Phase 0). Closes the account-deletion hole: when a user is deleted their
3
+ * owned rows + external records (Stripe customer, addresses, …) must be erased too. Ported from saastarter's
4
+ * `user.deleteUser.beforeDelete` hook (src/lib/auth/options.ts:127) — but made a REUSABLE primitive: the package
5
+ * ORCHESTRATES an ordered cascade and the app supplies the concrete steps + PICKS the posture.
6
+ *
7
+ * Posture is the app's choice (the reviewer's hard fork): the package ships BOTH hard-DELETE (cascade-remove) and
8
+ * ANONYMIZE (keep the row, scrub PII — the FK-safe right-to-be-forgotten posture, the recommended default) as thin
9
+ * labeled step constructors, and imposes NEITHER. Recovery-WITHIN-a-step (e.g. saastarter's Stripe-already-deleted →
10
+ * delete-by-email fallback, options.ts:147-167) lives inside the step's `run`, never in this orchestrator.
11
+ */
12
+
13
+ /** One step of the erasure cascade — the erasure of one subsystem for one user. */
14
+ export interface CascadeStep<U> {
15
+ /** a label for logs/diagnostics. */
16
+ name: string;
17
+ /** perform the erasure. Put any in-step recovery (already-deleted → fallback) HERE, not in the orchestrator. */
18
+ run: (user: U) => Promise<void> | void;
19
+ }
20
+
21
+ export interface CascadeOptions {
22
+ /** if a step throws: log + continue (true), or ABORT the whole cascade (false — the fail-closed default, so a
23
+ * failed cleanup never silently half-erases and then deletes the user). */
24
+ continueOnError?: boolean;
25
+ /** diagnostics sink (default console.error). */
26
+ log?: (step: string, error: unknown) => void;
27
+ }
28
+
29
+ /** A generic cascade step. */
30
+ export function step<U>(name: string, run: (user: U) => Promise<void> | void): CascadeStep<U> {
31
+ return { name, run };
32
+ }
33
+
34
+ /** An ANONYMIZE step — keep the row, scrub its PII (the FK-safe posture; recommended default). */
35
+ export function anonymizeStep<U>(name: string, run: (user: U) => Promise<void> | void): CascadeStep<U> {
36
+ return { name: `anonymize:${name}`, run };
37
+ }
38
+
39
+ /** A hard-DELETE step — cascade-remove a subsystem's rows for the user. */
40
+ export function deleteStep<U>(name: string, run: (user: U) => Promise<void> | void): CascadeStep<U> {
41
+ return { name: `delete:${name}`, run };
42
+ }
43
+
44
+ /**
45
+ * Build the Better Auth `user.deleteUser.beforeDelete` hook (options.ts:127) from an ordered erasure cascade.
46
+ * Runs each step in order; on a step error it logs and — unless `continueOnError` — rethrows to ABORT (so the user
47
+ * is NOT deleted when cleanup failed, never orphaning their external records).
48
+ */
49
+ export function beforeDeleteCascade<U>(
50
+ steps: CascadeStep<U>[],
51
+ opts: CascadeOptions = {},
52
+ ): (user: U) => Promise<void> {
53
+ const log = opts.log ?? ((s, e) => console.error(`erasure step "${s}" failed:`, e));
54
+ return async (user: U) => {
55
+ for (const s of steps) {
56
+ try {
57
+ await s.run(user);
58
+ } catch (e) {
59
+ log(s.name, e);
60
+ if (!opts.continueOnError) throw e; // fail-closed: abort rather than half-erase
61
+ }
62
+ }
63
+ };
64
+ }
package/src/index.ts CHANGED
@@ -1,16 +1,32 @@
1
1
  /**
2
- * @suluk/better-auth — official Better-Auth-on-Hono support for the Suluk derivation engine.
2
+ * `@suluk/better-auth` — official Better-Auth-on-Hono support for the Suluk derivation engine.
3
3
  *
4
4
  * Better Auth is a Contract input (auth settings). This package: (1) derives v4 securitySchemes from the
5
5
  * enabled auth methods; (2) ingests Better Auth's own OpenAPI 3.0 output (normalizing it to 2020-12) and
6
6
  * lifts it to v4 via @suluk/openapi-compat, then merges it into the app doc — so the auth surface is
7
7
  * documented without re-typing; (3) maps a Better Auth session to a { scopes } principal that feeds
8
- * @suluk/hono's per-viewer emitV4; (4) mounts the auth handler on Hono. CANDIDATE tooling.
8
+ * `@suluk/hono`'s per-viewer emitV4; (4) mounts the auth handler on Hono. CANDIDATE tooling.
9
9
  */
10
10
  export { authSecuritySchemes, type AuthMethods, type AuthSecurity } from "./security";
11
11
  export { normalizeOas30, ingestAuthOpenAPI, mergeAuth, type IngestOptions } from "./ingest";
12
- export { principalFromSession, type Principal, type SessionLike, type PrincipalOptions } from "./principal";
12
+ export {
13
+ principalFromSession, MFA_SCOPE, orgScope, parseOrgScope,
14
+ type Principal, type SessionLike, type PrincipalOptions,
15
+ } from "./principal";
13
16
  export { mountAuth, type AuthHandlerLike, type HonoLike, type MountAuthOptions } from "./mount";
17
+ // scope-aware API-key verification (Phase 0): wraps Better Auth's verifyApiKey to return a { scopes } Principal,
18
+ // so @suluk/hono enforcement works for key callers, not just sessions.
19
+ export {
20
+ verifyApiKey, scopesToPermissions, permissionsToScopes, parseApiKeyMetadata,
21
+ type ApiKeyVerifierLike, type VerifyApiKeyResult, type VerifyApiKeyOptions, type VerifiedKey, type VerifyReason, type ApiKeyMetadata,
22
+ } from "./apikey";
23
+ // GDPR erasure cascade (Phase 0): the reusable beforeDelete orchestrator; the app supplies steps + picks the posture.
24
+ export { beforeDeleteCascade, step, anonymizeStep, deleteStep, type CascadeStep, type CascadeOptions } from "./erasure";
25
+ // auth-flow UX: open-redirect-safe redirect preservation + a frictionless emailVerification config.
26
+ export { isSafeRelativePath, resolveRedirectTo, withRedirectTo, emailVerificationConfig, type EmailVerificationOptions } from "./auth-flow";
14
27
  // live role-preview (charter-bounded by C020): the fail-closed, deploy-gated role-login handler. The extension
15
28
  // holds NO token — it deep-links this route in the browser; the credentialed mint happens here, server-side.
16
29
  export { previewLoginHandler, isPreviewRuntime, type PreviewRequestLike, type PreviewEnvLike, type MintedSession, type PreviewLoginOptions } from "./preview";
30
+ // C057 local-dev any-email login (the Google mock when no GOOGLE_CLIENT_ID) — fail-closed behind an `armed` flag, mints
31
+ // a REAL session via the public signUp/signIn API. The registry arms it only in dev-mock mode; a prod deploy 404s it.
32
+ export { devLoginHandler, DEV_LOGIN_PASSWORD, type DevLoginAuthLike, type DevLoginOptions } from "./dev-login";
package/src/principal.ts CHANGED
@@ -14,14 +14,39 @@ export interface SessionLike {
14
14
  /** apiKey plugin: a key carries its own permissions/scopes. */
15
15
  apiKey?: { scopes?: string[]; permissions?: Record<string, string[]> } | null;
16
16
  scopes?: string[];
17
+ /** twoFactor plugin: the session has cleared its second factor ⇒ the `mfa:verified` scope (Phase 1). */
18
+ twoFactorVerified?: boolean;
19
+ /** organization plugin: memberships → `org:<id>:<scope>` scopes (Phase 1, tenancy via scope-encoding). */
20
+ organizations?: { id: string; role?: string; scopes?: string[] }[];
17
21
  }
18
22
 
19
23
  export interface PrincipalOptions {
20
24
  /** Map a role name → the scopes it grants (e.g. { admin: ["read:*","write:*"], user: ["read:self"] }). */
21
25
  roleScopes?: Record<string, string[]>;
26
+ /** Map an ORG role → the scopes it grants WITHIN an org (each namespaced to `org:<id>:<scope>`). */
27
+ orgRoleScopes?: Record<string, string[]>;
22
28
  }
23
29
 
24
- /** Extract a { scopes } principal from a Better Auth session. Null/undefined session ⇒ anonymous (no scopes). */
30
+ /** The scope a route requires to be sure the caller cleared their second factor (twoFactor plugin). */
31
+ export const MFA_SCOPE = "mfa:verified" as const;
32
+
33
+ /** Build the org-namespaced scope `org:<orgId>:<action>` (the tenancy encoding). */
34
+ export function orgScope(orgId: string, action: string): string {
35
+ return `org:${orgId}:${action}`;
36
+ }
37
+
38
+ /** Parse an `org:<id>:<action>` scope back into its parts (null if it isn't one). */
39
+ export function parseOrgScope(scope: string): { orgId: string; action: string } | null {
40
+ const m = /^org:([^:]+):(.+)$/.exec(scope);
41
+ return m ? { orgId: m[1], action: m[2] } : null;
42
+ }
43
+
44
+ /**
45
+ * Extract a { scopes } principal from a Better Auth session. Null/undefined session ⇒ anonymous (no scopes).
46
+ * Beyond the user/apiKey scopes, it encodes MFA + org state AS scopes (Phase 1): a 2FA-cleared session gains
47
+ * `mfa:verified`, and each org membership contributes `org:<id>:<scope>` (explicit + role-mapped) — so a route
48
+ * gates 2FA/tenancy through the same scope check enforceAccess already does, no richer Principal type required.
49
+ */
25
50
  export function principalFromSession(session: SessionLike | null | undefined, opts: PrincipalOptions = {}): Principal {
26
51
  if (!session) return { scopes: [] };
27
52
  const scopes = new Set<string>();
@@ -34,5 +59,14 @@ export function principalFromSession(session: SessionLike | null | undefined, op
34
59
  const roleList = Array.isArray(roles) ? roles : roles ? [roles] : [];
35
60
  for (const role of roleList) for (const s of opts.roleScopes?.[role] ?? []) scopes.add(s);
36
61
 
62
+ // MFA state → scope (the twoFactor plugin)
63
+ if (session.twoFactorVerified) scopes.add(MFA_SCOPE);
64
+
65
+ // org memberships → org-namespaced scopes (the organization plugin)
66
+ for (const org of session.organizations ?? []) {
67
+ for (const s of org.scopes ?? []) scopes.add(orgScope(org.id, s));
68
+ if (org.role) for (const s of opts.orgRoleScopes?.[org.role] ?? []) scopes.add(orgScope(org.id, s));
69
+ }
70
+
37
71
  return { scopes: [...scopes] };
38
72
  }
package/src/security.ts CHANGED
@@ -15,6 +15,12 @@ export interface AuthMethods {
15
15
  bearer?: boolean;
16
16
  /** API key (the apiKey plugin). `true` ⇒ default "x-api-key" header; or pass a custom header. */
17
17
  apiKey?: boolean | { header?: string };
18
+ /** twoFactor plugin — MFA on top of the session (no new wire scheme; gates via the `mfa:verified` scope). */
19
+ twoFactor?: boolean;
20
+ /** passkey (WebAuthn) plugin — a credential method that authenticates INTO a session (no new wire scheme). */
21
+ passkey?: boolean;
22
+ /** organization plugin — multi-tenancy via `org:<id>:<scope>` scopes (no new wire scheme). */
23
+ organization?: boolean;
18
24
  }
19
25
 
20
26
  export interface AuthSecurity {
@@ -22,6 +28,8 @@ export interface AuthSecurity {
22
28
  securitySchemes: Record<string, SecurityScheme>;
23
29
  /** Convenience: the scheme names, to build by-name security requirements. */
24
30
  names: string[];
31
+ /** Enabled session-based plugins (NOT wire schemes — they gate into the session via scope-encoding). */
32
+ plugins: { twoFactor: boolean; passkey: boolean; organization: boolean };
25
33
  }
26
34
 
27
35
  const DEFAULT_SESSION_COOKIE = "better-auth.session_token";
@@ -41,5 +49,9 @@ export function authSecuritySchemes(methods: AuthMethods): AuthSecurity {
41
49
  const header = typeof methods.apiKey === "object" ? methods.apiKey.header ?? DEFAULT_APIKEY_HEADER : DEFAULT_APIKEY_HEADER;
42
50
  securitySchemes.apiKey = { type: "apiKey", in: "header", name: header };
43
51
  }
44
- return { securitySchemes, names: Object.keys(securitySchemes) };
52
+ return {
53
+ securitySchemes,
54
+ names: Object.keys(securitySchemes),
55
+ plugins: { twoFactor: !!methods.twoFactor, passkey: !!methods.passkey, organization: !!methods.organization },
56
+ };
45
57
  }
@@ -0,0 +1,80 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { Hono } from "hono";
3
+ import { createGuard } from "@suluk/hono";
4
+ import {
5
+ verifyApiKey, scopesToPermissions, permissionsToScopes, parseApiKeyMetadata,
6
+ type ApiKeyVerifierLike,
7
+ } from "../src/index";
8
+
9
+ describe("scope ⇄ permission conversion (ported from saastarter api-key/scopes.ts)", () => {
10
+ test("scopesToPermissions groups by resource (scopes.ts:150-161)", () => {
11
+ expect(scopesToPermissions(["cart:read", "cart:write", "products:read"])).toEqual({
12
+ cart: ["read", "write"], products: ["read"],
13
+ });
14
+ });
15
+
16
+ test("scopesToPermissions: a segment-less scope is skipped; 'a:b:c' keeps only resource:action (faithful destructure)", () => {
17
+ expect(scopesToPermissions(["bare", "a:b:c"])).toEqual({ a: ["b"] }); // 'c' dropped, 'bare' skipped
18
+ });
19
+
20
+ test("permissionsToScopes flattens; null ⇒ []", () => {
21
+ expect(permissionsToScopes({ cart: ["read", "write"] })).toEqual(["cart:read", "cart:write"]);
22
+ expect(permissionsToScopes(null)).toEqual([]);
23
+ });
24
+
25
+ test("permissionsToScopes is round-trip-stable and (DEVIATION) keeps non-catalog scopes", () => {
26
+ const perms = { anything: ["do"], orders: ["read"] };
27
+ // saastarter would filter `anything:do` against API_SCOPES; we keep it (the catalog is the app's concern).
28
+ expect(permissionsToScopes(perms)).toEqual(["anything:do", "orders:read"]);
29
+ expect(scopesToPermissions(permissionsToScopes(perms))).toEqual(perms);
30
+ });
31
+ });
32
+
33
+ describe("parseApiKeyMetadata (ported verbatim, metadata.ts:14-39)", () => {
34
+ test("object passthrough; single + double-stringified JSON; invalid ⇒ null", () => {
35
+ expect(parseApiKeyMetadata({ createdVia: "delegation" })).toEqual({ createdVia: "delegation" });
36
+ expect(parseApiKeyMetadata(JSON.stringify({ parentKeyId: "p1" }))).toEqual({ parentKeyId: "p1" });
37
+ expect(parseApiKeyMetadata(JSON.stringify(JSON.stringify({ parentKeyId: "p2" })))).toEqual({ parentKeyId: "p2" });
38
+ expect(parseApiKeyMetadata("not json")).toBeNull();
39
+ expect(parseApiKeyMetadata(null)).toBeNull();
40
+ });
41
+ });
42
+
43
+ describe("verifyApiKey — scope-aware, returns a { scopes } Principal", () => {
44
+ const verifier = (resp: unknown, capture?: (body: unknown) => void): ApiKeyVerifierLike => ({
45
+ verifyApiKey: async ({ body }) => { capture?.(body); return resp as never; },
46
+ });
47
+
48
+ test("a valid key yields ok + a Principal flattened from its permissions + the key identity", async () => {
49
+ const r = await verifyApiKey(verifier({ valid: true, key: { userId: "u1", permissions: { orders: ["read"] }, metadata: JSON.stringify({ createdVia: "delegation" }) } }), "k");
50
+ expect(r.ok).toBe(true);
51
+ expect(r.principal).toEqual({ scopes: ["orders:read"] });
52
+ expect(r.key).toMatchObject({ userId: "u1", metadata: { createdVia: "delegation" } });
53
+ });
54
+
55
+ test("requireScopes is checked in the SAME verify call via permissions (services/auth.ts:133-147)", async () => {
56
+ let sentBody: unknown;
57
+ await verifyApiKey(verifier({ valid: true, key: { userId: "u1", permissions: { orders: ["read"] } } }, (b) => (sentBody = b)), "k", { requireScopes: ["orders:read"] });
58
+ expect(sentBody).toEqual({ key: "k", permissions: { orders: ["read"] } });
59
+ });
60
+
61
+ test("an invalid key ⇒ ok:false reason 'invalid'; a scope error ⇒ 'insufficient_scope'; a throw ⇒ 'error'", async () => {
62
+ expect((await verifyApiKey(verifier({ valid: false, key: null }), "k")).reason).toBe("invalid");
63
+ expect((await verifyApiKey(verifier({ valid: false, error: { code: "INSUFFICIENT_PERMISSIONS" } }), "k", { requireScopes: ["orders:write"] })).reason).toBe("insufficient_scope");
64
+ const thrower: ApiKeyVerifierLike = { verifyApiKey: async () => { throw new Error("down"); } };
65
+ expect((await verifyApiKey(thrower, "k")).reason).toBe("error");
66
+ });
67
+ });
68
+
69
+ describe("integration: the key Principal makes @suluk/hono enforcement work for key auth", () => {
70
+ test("a key with orders:read passes requireScopes('orders:read') but is 403 for admin", async () => {
71
+ const verifier: ApiKeyVerifierLike = { verifyApiKey: async () => ({ valid: true, key: { userId: "u1", permissions: { orders: ["read"] } } }) };
72
+ const result = await verifyApiKey(verifier, "k");
73
+ const guard = createGuard({ principal: () => result.key!.userId, scopes: () => result.principal!.scopes });
74
+ const app = new Hono();
75
+ app.get("/orders", guard.requireScopes("orders:read"), (c) => c.text("ok"));
76
+ app.get("/admin", guard.requireScopes("admin"), (c) => c.text("ok"));
77
+ expect((await app.request("/orders")).status).toBe(200);
78
+ expect((await app.request("/admin")).status).toBe(403);
79
+ });
80
+ });
@@ -0,0 +1,39 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { isSafeRelativePath, resolveRedirectTo, withRedirectTo, emailVerificationConfig } from "../src/auth-flow";
3
+
4
+ describe("redirect preservation — open-redirect-safe", () => {
5
+ test("accepts same-origin relative paths, rejects everything that could escape origin", () => {
6
+ expect(isSafeRelativePath("/account")).toBe(true);
7
+ expect(isSafeRelativePath("/products?x=1#h")).toBe(true);
8
+ for (const bad of [null, undefined, "", "account", "//evil.com", "https://evil.com", "http://evil.com", "/\\evil.com", "javascript://%0aalert(1)", "/a\\b"]) {
9
+ expect(isSafeRelativePath(bad as any), `must reject: ${bad}`).toBe(false);
10
+ }
11
+ });
12
+ test("resolveRedirectTo reads ?redirectTo, falls back when absent/unsafe", () => {
13
+ expect(resolveRedirectTo("?redirectTo=/dashboard", "/account")).toBe("/dashboard");
14
+ expect(resolveRedirectTo("redirectTo=%2Fcart", "/account")).toBe("/cart"); // decoded by URLSearchParams
15
+ expect(resolveRedirectTo("?foo=1", "/account")).toBe("/account"); // absent → fallback
16
+ expect(resolveRedirectTo("?redirectTo=https://evil.com", "/account")).toBe("/account"); // unsafe → fallback
17
+ expect(resolveRedirectTo(new URLSearchParams("redirectTo=/x"))).toBe("/x");
18
+ });
19
+ test("withRedirectTo appends a guarded redirectTo (and drops an unsafe one)", () => {
20
+ expect(withRedirectTo("/login", "/checkout")).toBe("/login?redirectTo=%2Fcheckout");
21
+ expect(withRedirectTo("/login?x=1", "/cart")).toBe("/login?x=1&redirectTo=%2Fcart");
22
+ expect(withRedirectTo("/login", "//evil.com")).toBe("/login"); // unsafe dropped
23
+ });
24
+ });
25
+
26
+ describe("emailVerificationConfig — frictionless defaults", () => {
27
+ test("defaults to verify-on-sign-up + auto-sign-in, threads the sender", () => {
28
+ const send = () => {};
29
+ const cfg = emailVerificationConfig({ sendVerificationEmail: send });
30
+ expect(cfg.sendOnSignUp).toBe(true);
31
+ expect(cfg.autoSignInAfterVerification).toBe(true);
32
+ expect(cfg.sendVerificationEmail).toBe(send);
33
+ });
34
+ test("overrides are honored", () => {
35
+ const cfg = emailVerificationConfig({ sendVerificationEmail: () => {}, autoSignIn: false, sendOnSignUp: false });
36
+ expect(cfg.autoSignInAfterVerification).toBe(false);
37
+ expect(cfg.sendOnSignUp).toBe(false);
38
+ });
39
+ });
@@ -0,0 +1,45 @@
1
+ import { test, expect } from "bun:test";
2
+ import { devLoginHandler } from "../src/dev-login";
3
+
4
+ const req = (body: unknown) => new Request("http://localhost/api/auth/dev-login", { method: "POST", body: JSON.stringify(body), headers: { "content-type": "application/json" } });
5
+
6
+ function fakeAuth() {
7
+ const calls: any = { signUp: [], signIn: [] };
8
+ const auth = {
9
+ api: {
10
+ async signUpEmail(input: any) { calls.signUp.push(input.body); if (input.body.email === "exists@x.com") throw new Error("User already exists"); return {}; },
11
+ async signInEmail(input: any) { calls.signIn.push(input.body); return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "set-cookie": "better-auth.session_token=abc; Path=/" } }); },
12
+ },
13
+ };
14
+ return { auth, calls };
15
+ }
16
+
17
+ test("FAIL-CLOSED: armed=false → 404 before any input is read", async () => {
18
+ const { auth, calls } = fakeAuth();
19
+ const res = await devLoginHandler({ armed: false, auth, request: req({ email: "a@b.com" }) });
20
+ expect(res.status).toBe(404);
21
+ expect(calls.signUp).toHaveLength(0); // never reached the mint path
22
+ });
23
+
24
+ test("armed + valid email → signs up + signs in, returns the session Response", async () => {
25
+ const { auth, calls } = fakeAuth();
26
+ const res = await devLoginHandler({ armed: true, auth, request: req({ email: "New@B.com" }) });
27
+ expect(res.status).toBe(200);
28
+ expect(res.headers.get("set-cookie")).toContain("session_token");
29
+ expect(calls.signUp[0].email).toBe("new@b.com"); // normalized lower-case
30
+ expect(calls.signIn[0].email).toBe("new@b.com");
31
+ });
32
+
33
+ test("armed + already-existing user → ignores signUp error, still signs in", async () => {
34
+ const { auth, calls } = fakeAuth();
35
+ const res = await devLoginHandler({ armed: true, auth, request: req({ email: "exists@x.com" }) });
36
+ expect(res.status).toBe(200);
37
+ expect(calls.signIn).toHaveLength(1);
38
+ });
39
+
40
+ test("armed + invalid/missing email → 400, no mint", async () => {
41
+ const { auth, calls } = fakeAuth();
42
+ expect((await devLoginHandler({ armed: true, auth, request: req({ email: "not-an-email" }) })).status).toBe(400);
43
+ expect((await devLoginHandler({ armed: true, auth, request: req({}) })).status).toBe(400);
44
+ expect(calls.signIn).toHaveLength(0);
45
+ });
@@ -0,0 +1,52 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { beforeDeleteCascade, step, anonymizeStep, deleteStep } from "../src/index";
3
+
4
+ type User = { id: string; email: string };
5
+ const user: User = { id: "u1", email: "a@b.co" };
6
+
7
+ describe("beforeDeleteCascade — the GDPR erasure orchestrator (ported from saastarter options.ts:127)", () => {
8
+ test("runs every step in order", async () => {
9
+ const order: string[] = [];
10
+ const hook = beforeDeleteCascade<User>([
11
+ step("stripe", () => { order.push("stripe"); }),
12
+ anonymizeStep("profile", () => { order.push("profile"); }),
13
+ deleteStep("addresses", () => { order.push("addresses"); }),
14
+ ]);
15
+ await hook(user);
16
+ expect(order).toEqual(["stripe", "profile", "addresses"]);
17
+ });
18
+
19
+ test("fail-closed by default: a throwing step ABORTS — later steps don't run, the hook rejects", async () => {
20
+ const order: string[] = [];
21
+ const hook = beforeDeleteCascade<User>([
22
+ step("ok", () => { order.push("ok"); }),
23
+ step("boom", () => { throw new Error("stripe down"); }),
24
+ step("never", () => { order.push("never"); }),
25
+ ], { log: () => {} });
26
+ await expect(hook(user)).rejects.toThrow("stripe down");
27
+ expect(order).toEqual(["ok"]); // "never" did not run; the user is NOT deleted
28
+ });
29
+
30
+ test("continueOnError logs + proceeds; the hook resolves", async () => {
31
+ const order: string[] = [];
32
+ const logged: string[] = [];
33
+ const hook = beforeDeleteCascade<User>([
34
+ step("boom", () => { throw new Error("already gone"); }),
35
+ step("addresses", () => { order.push("addresses"); }),
36
+ ], { continueOnError: true, log: (s) => logged.push(s) });
37
+ await hook(user);
38
+ expect(order).toEqual(["addresses"]);
39
+ expect(logged).toEqual(["boom"]);
40
+ });
41
+
42
+ test("recovery-within-a-step keeps the cascade clean (the saastarter Stripe-already-deleted → email fallback)", async () => {
43
+ const calls: string[] = [];
44
+ const deleteStripeCustomer = async (u: User) => {
45
+ try { calls.push("del-by-id"); throw new Error("no such customer"); }
46
+ catch { calls.push(`del-by-email:${u.email}`); } // recovery lives in the step, not the orchestrator
47
+ };
48
+ const hook = beforeDeleteCascade<User>([deleteStep("stripe", deleteStripeCustomer)]);
49
+ await hook(user); // resolves — the step handled its own recovery
50
+ expect(calls).toEqual(["del-by-id", "del-by-email:a@b.co"]);
51
+ });
52
+ });
@@ -0,0 +1,40 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { principalFromSession, MFA_SCOPE, orgScope, parseOrgScope, authSecuritySchemes } from "../src/index";
3
+
4
+ describe("2FA + org scope-encoding (Phase 1) — Principal extension via scopes", () => {
5
+ test("a 2FA-cleared session gains mfa:verified; an un-verified one does not", () => {
6
+ expect(principalFromSession({ twoFactorVerified: true }).scopes).toContain(MFA_SCOPE);
7
+ expect(principalFromSession({ twoFactorVerified: false }).scopes).not.toContain(MFA_SCOPE);
8
+ expect(principalFromSession({}).scopes).not.toContain(MFA_SCOPE);
9
+ });
10
+
11
+ test("org memberships → org:<id>:<scope> (explicit + role-mapped, namespaced per org)", () => {
12
+ const p = principalFromSession(
13
+ { organizations: [{ id: "123", scopes: ["read"], role: "owner" }, { id: "456", scopes: ["read"] }] },
14
+ { orgRoleScopes: { owner: ["write", "admin"] } },
15
+ );
16
+ expect(p.scopes).toEqual(expect.arrayContaining(["org:123:read", "org:123:write", "org:123:admin", "org:456:read"]));
17
+ // org 456 has no owner role → no write/admin there (tenancy isolation)
18
+ expect(p.scopes).not.toContain("org:456:write");
19
+ });
20
+
21
+ test("orgScope builds and parseOrgScope round-trips; a non-org scope parses null", () => {
22
+ expect(orgScope("123", "read")).toBe("org:123:read");
23
+ expect(parseOrgScope("org:123:read")).toEqual({ orgId: "123", action: "read" });
24
+ expect(parseOrgScope("admin")).toBeNull();
25
+ });
26
+
27
+ test("the encoded scopes work with a plain scope check (what enforceAccess does)", () => {
28
+ const p = principalFromSession({ twoFactorVerified: true, organizations: [{ id: "1", scopes: ["billing:write"] }] });
29
+ const has = (s: string) => p.scopes.includes(s);
30
+ expect(has(MFA_SCOPE)).toBe(true); // a route can requireScopes("mfa:verified")
31
+ expect(has("org:1:billing:write")).toBe(true);
32
+ });
33
+
34
+ test("authSecuritySchemes surfaces the session-based plugins (no new wire scheme)", () => {
35
+ const sec = authSecuritySchemes({ session: true, twoFactor: true, passkey: true, organization: true });
36
+ expect(sec.plugins).toEqual({ twoFactor: true, passkey: true, organization: true });
37
+ // they add NO new securityScheme — they gate INTO the session
38
+ expect(Object.keys(sec.securitySchemes)).toEqual(["sessionCookie"]);
39
+ });
40
+ });