@suluk/better-auth 0.1.1 → 0.1.3
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 +35 -0
- package/package.json +19 -8
- package/src/apikey.ts +135 -0
- package/src/auth-flow.ts +47 -0
- package/src/erasure.ts +64 -0
- package/src/index.ts +17 -1
- package/src/preview.ts +83 -0
- package/src/principal.ts +35 -1
- package/src/security.ts +13 -1
- package/test/apikey.test.ts +80 -0
- package/test/auth-flow.test.ts +39 -0
- package/test/erasure.test.ts +52 -0
- package/test/mfa-org.test.ts +40 -0
- package/test/preview.test.ts +82 -0
package/README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<a href="https://github.com/MahmoodKhalil57/suluk">
|
|
3
|
+
<img src="https://raw.githubusercontent.com/MahmoodKhalil57/suluk/main/branding/export/wordmark.png" alt="Suluk" width="360" />
|
|
4
|
+
</a>
|
|
5
|
+
</p>
|
|
6
|
+
|
|
7
|
+
<h1 align="center">@suluk/better-auth</h1>
|
|
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>
|
|
10
|
+
|
|
11
|
+
<p align="center">
|
|
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>
|
|
13
|
+
</p>
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
> **CANDIDATE tooling — not official OpenAPI.** Suluk is a single-contributor candidate for
|
|
18
|
+
> OpenAPI Specification v4.0 ("Moonwalk"), unaffiliated with the OpenAPI Initiative and unable
|
|
19
|
+
> to ratify anything on the SIG's behalf.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
bun add @suluk/better-auth
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## The Suluk cycle
|
|
28
|
+
|
|
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).
|
|
32
|
+
|
|
33
|
+
## License
|
|
34
|
+
|
|
35
|
+
Apache-2.0
|
package/package.json
CHANGED
|
@@ -1,15 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suluk/better-auth",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
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
|
-
"
|
|
6
|
-
"
|
|
7
|
-
|
|
8
|
-
|
|
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"
|
|
9
20
|
},
|
|
10
21
|
"dependencies": {
|
|
11
|
-
"@suluk/core": "0.1.
|
|
12
|
-
"@suluk/openapi-compat": "0.1.
|
|
22
|
+
"@suluk/core": "^0.1.7",
|
|
23
|
+
"@suluk/openapi-compat": "^0.1.2"
|
|
13
24
|
},
|
|
14
25
|
"peerDependencies": {
|
|
15
26
|
"better-auth": "^1.0.0",
|
|
@@ -25,7 +36,7 @@
|
|
|
25
36
|
},
|
|
26
37
|
"devDependencies": {
|
|
27
38
|
"@types/bun": "latest",
|
|
28
|
-
"@suluk/hono": "0.1.
|
|
39
|
+
"@suluk/hono": "^0.1.2"
|
|
29
40
|
},
|
|
30
41
|
"scripts": {
|
|
31
42
|
"test": "bun test",
|
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
|
+
}
|
package/src/auth-flow.ts
ADDED
|
@@ -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
|
+
}
|
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
|
@@ -9,5 +9,21 @@
|
|
|
9
9
|
*/
|
|
10
10
|
export { authSecuritySchemes, type AuthMethods, type AuthSecurity } from "./security";
|
|
11
11
|
export { normalizeOas30, ingestAuthOpenAPI, mergeAuth, type IngestOptions } from "./ingest";
|
|
12
|
-
export {
|
|
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";
|
|
27
|
+
// live role-preview (charter-bounded by C020): the fail-closed, deploy-gated role-login handler. The extension
|
|
28
|
+
// holds NO token — it deep-links this route in the browser; the credentialed mint happens here, server-side.
|
|
29
|
+
export { previewLoginHandler, isPreviewRuntime, type PreviewRequestLike, type PreviewEnvLike, type MintedSession, type PreviewLoginOptions } from "./preview";
|
package/src/preview.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* previewLoginHandler — the ONE credentialed surface in the whole cockpit, and the only place a session is
|
|
3
|
+
* minted for role-preview (the LAST roadmap slice, charter-bounded by C020). It runs INSIDE the generated
|
|
4
|
+
* PREVIEW Worker, never in the IDE: the extension merely deep-links `GET /preview/login?role=…` in the system
|
|
5
|
+
* browser, so no auth token ever lives in the editor.
|
|
6
|
+
*
|
|
7
|
+
* It is FAIL-CLOSED behind TWO INDEPENDENT LOCKS — both must say "preview":
|
|
8
|
+
* 1. a deploy-time var `env.SULUK_PREVIEW === "1"` (a prod wrangler config never sets it)
|
|
9
|
+
* 2. a binding `env.PREVIEW_DB` is present (a D1 only the preview deploy declares; prod has none)
|
|
10
|
+
* Absence of EITHER ⇒ 404, as if the route did not exist. A prod deploy that copy-pastes the var still lacks
|
|
11
|
+
* the binding; a stray binding without the var still 404s. The role is decided SERVER-SIDE from the verified
|
|
12
|
+
* gate + the request's `role` query param checked against an allow-list — never a client-trusted header, and
|
|
13
|
+
* the session binds ONLY to a seeded throwaway demo user (the injected mintSession owns that lookup), never a
|
|
14
|
+
* real row. Dependency-injected (env, allowedRoles, mintSession, now) so it is hermetically unit-testable with
|
|
15
|
+
* a plain Request + a fake env — no Worker, no D1, no wrangler, no creds.
|
|
16
|
+
*
|
|
17
|
+
* TTL / single-use is the deployed Worker's Better Auth SESSION policy (a preview session should be short and
|
|
18
|
+
* the env ephemeral) — not a Suluk artifact; previewDeployPlan's DEPLOY.md notes it and the teardown. This
|
|
19
|
+
* handler owns the GATE + the role-binding, which is Suluk's responsibility.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** A minimal view of the Worker request — only `.url` (to read the `role` query param) is needed. Web `Request` satisfies it. */
|
|
23
|
+
export interface PreviewRequestLike {
|
|
24
|
+
url: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The two independent locks live on the Worker env: a var and a binding. Duck-typed; extra keys ignored. */
|
|
28
|
+
export interface PreviewEnvLike {
|
|
29
|
+
/** lock 1 — the deploy-time preview flag. */
|
|
30
|
+
SULUK_PREVIEW?: string;
|
|
31
|
+
/** lock 2 — a D1 binding only the preview deploy declares (presence is the lock; we never read prod's DB here). */
|
|
32
|
+
PREVIEW_DB?: unknown;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** What a successful mint returns: the headers to set on the redirect (e.g. the session Set-Cookie). */
|
|
36
|
+
export interface MintedSession {
|
|
37
|
+
setCookie: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface PreviewLoginOptions {
|
|
41
|
+
/** The roles a preview may assume — derive from the contract (cockpit previewRoles), NEVER a hardcoded list.
|
|
42
|
+
* A requested role MUST be a member; "anonymous" is handled by the launcher (it opens the app with no login). */
|
|
43
|
+
allowedRoles: string[];
|
|
44
|
+
/** Establish a role-scoped session for the SEEDED demo user of `role` (looks it up in env.PREVIEW_DB).
|
|
45
|
+
* This is the only code that touches a session; it must bind to a seeded throwaway row, never a real user. */
|
|
46
|
+
mintSession: (role: string) => MintedSession | Promise<MintedSession>;
|
|
47
|
+
/** Where to land after login (default "/"). */
|
|
48
|
+
redirectTo?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** True iff BOTH independent locks say "preview". Exported so callers/tests can assert the gate in isolation. */
|
|
52
|
+
export function isPreviewRuntime(env: PreviewEnvLike): boolean {
|
|
53
|
+
return env.SULUK_PREVIEW === "1" && env.PREVIEW_DB != null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Handle `GET /preview/login?role=…`. Fail-closed: 404 unless both locks pass; 403 for a role not in the
|
|
58
|
+
* allow-list; else mint the seeded demo session and 302 to the app. Never throws on a hostile request.
|
|
59
|
+
*/
|
|
60
|
+
export async function previewLoginHandler(
|
|
61
|
+
req: PreviewRequestLike,
|
|
62
|
+
env: PreviewEnvLike,
|
|
63
|
+
opts: PreviewLoginOptions,
|
|
64
|
+
): Promise<Response> {
|
|
65
|
+
// LOCK: both must say preview, or the route does not exist. Checked FIRST — before reading any client input —
|
|
66
|
+
// so a forged ?role= or x-role header can never reach the mint path on a non-preview deploy.
|
|
67
|
+
if (!isPreviewRuntime(env)) return new Response("not found", { status: 404 });
|
|
68
|
+
|
|
69
|
+
let role = "";
|
|
70
|
+
try { role = new URL(req.url).searchParams.get("role") ?? ""; } catch { role = ""; }
|
|
71
|
+
|
|
72
|
+
// "anonymous" is login-less by definition — never a mintable session, regardless of the allow-list (the launcher
|
|
73
|
+
// opens the app with no login for anonymous). Reject it explicitly so a caller can never mint an "anonymous" user.
|
|
74
|
+
if (role === "anonymous") return new Response("anonymous is not a preview login", { status: 403 });
|
|
75
|
+
// the role must be one the contract declares; membership is the only thing trusted from the client.
|
|
76
|
+
if (!opts.allowedRoles.includes(role)) return new Response("unknown preview role", { status: 403 });
|
|
77
|
+
|
|
78
|
+
const minted = await opts.mintSession(role); // binds to the SEEDED demo user for `role` (mintSession owns the lookup)
|
|
79
|
+
return new Response(null, {
|
|
80
|
+
status: 302,
|
|
81
|
+
headers: { Location: opts.redirectTo ?? "/", "Set-Cookie": minted.setCookie },
|
|
82
|
+
});
|
|
83
|
+
}
|
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
|
-
/**
|
|
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 {
|
|
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,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
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { previewLoginHandler, isPreviewRuntime, type PreviewEnvLike } from "../src/preview";
|
|
3
|
+
|
|
4
|
+
const ALLOWED = ["user", "admin", "superadmin"];
|
|
5
|
+
// a mintSession spy that records the role it was asked to bind to (and never runs unless the gate passes).
|
|
6
|
+
function spy() {
|
|
7
|
+
const calls: string[] = [];
|
|
8
|
+
const mintSession = (role: string) => { calls.push(role); return { setCookie: `session=preview-${role}; HttpOnly` }; };
|
|
9
|
+
return { calls, mintSession };
|
|
10
|
+
}
|
|
11
|
+
const req = (role?: string) => ({ url: `https://app-preview.example.com/preview/login${role === undefined ? "" : `?role=${encodeURIComponent(role)}`}` });
|
|
12
|
+
const previewEnv: PreviewEnvLike = { SULUK_PREVIEW: "1", PREVIEW_DB: {} };
|
|
13
|
+
|
|
14
|
+
describe("previewLoginHandler — fail-closed behind TWO independent locks (INV-01)", () => {
|
|
15
|
+
test("flag unset ⇒ 404, mint NEVER called", async () => {
|
|
16
|
+
const { calls, mintSession } = spy();
|
|
17
|
+
const res = await previewLoginHandler(req("admin"), { PREVIEW_DB: {} }, { allowedRoles: ALLOWED, mintSession });
|
|
18
|
+
expect(res.status).toBe(404);
|
|
19
|
+
expect(calls).toHaveLength(0);
|
|
20
|
+
});
|
|
21
|
+
test("flag set but NO PREVIEW_DB binding ⇒ 404 (the second lock), mint NEVER called", async () => {
|
|
22
|
+
const { calls, mintSession } = spy();
|
|
23
|
+
const res = await previewLoginHandler(req("admin"), { SULUK_PREVIEW: "1" }, { allowedRoles: ALLOWED, mintSession });
|
|
24
|
+
expect(res.status).toBe(404);
|
|
25
|
+
expect(calls).toHaveLength(0);
|
|
26
|
+
});
|
|
27
|
+
test("a non-'1' flag value does not pass (no truthiness slop)", async () => {
|
|
28
|
+
const { calls, mintSession } = spy();
|
|
29
|
+
for (const v of ["0", "true", "yes", "preview", " 1"]) {
|
|
30
|
+
const res = await previewLoginHandler(req("admin"), { SULUK_PREVIEW: v, PREVIEW_DB: {} }, { allowedRoles: ALLOWED, mintSession });
|
|
31
|
+
expect(res.status).toBe(404);
|
|
32
|
+
}
|
|
33
|
+
expect(calls).toHaveLength(0);
|
|
34
|
+
});
|
|
35
|
+
test("BOTH locks + a valid seeded role ⇒ 302 to '/' with the role-scoped session cookie", async () => {
|
|
36
|
+
const { calls, mintSession } = spy();
|
|
37
|
+
const res = await previewLoginHandler(req("admin"), previewEnv, { allowedRoles: ALLOWED, mintSession });
|
|
38
|
+
expect(res.status).toBe(302);
|
|
39
|
+
expect(res.headers.get("location")).toBe("/");
|
|
40
|
+
expect(res.headers.get("set-cookie")).toContain("preview-admin");
|
|
41
|
+
expect(calls).toEqual(["admin"]); // bound exactly to the requested seeded role
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("previewLoginHandler — role allow-list, never client-trusted (INV-04)", () => {
|
|
46
|
+
test("a role outside the contract's set ⇒ 403, mint NEVER called", async () => {
|
|
47
|
+
const { calls, mintSession } = spy();
|
|
48
|
+
for (const r of ["root", "", "ADMIN", "superadmin "]) {
|
|
49
|
+
const res = await previewLoginHandler(req(r), previewEnv, { allowedRoles: ALLOWED, mintSession });
|
|
50
|
+
expect(res.status).toBe(403);
|
|
51
|
+
}
|
|
52
|
+
expect(calls).toHaveLength(0);
|
|
53
|
+
});
|
|
54
|
+
test("missing role param ⇒ 403, mint NEVER called", async () => {
|
|
55
|
+
const { calls, mintSession } = spy();
|
|
56
|
+
const res = await previewLoginHandler(req(undefined), previewEnv, { allowedRoles: ALLOWED, mintSession });
|
|
57
|
+
expect(res.status).toBe(403);
|
|
58
|
+
expect(calls).toHaveLength(0);
|
|
59
|
+
});
|
|
60
|
+
test("'anonymous' is rejected even if a caller smuggles it into allowedRoles (login-less by definition)", async () => {
|
|
61
|
+
const { calls, mintSession } = spy();
|
|
62
|
+
const res = await previewLoginHandler(req("anonymous"), previewEnv, { allowedRoles: [...ALLOWED, "anonymous"], mintSession });
|
|
63
|
+
expect(res.status).toBe(403);
|
|
64
|
+
expect(calls).toHaveLength(0);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("previewLoginHandler — the gate precedes input (INV-07: server-side, not a client header)", () => {
|
|
69
|
+
test("with the flag OFF, a forged x-role header cannot reach the mint — still 404", async () => {
|
|
70
|
+
const { calls, mintSession } = spy();
|
|
71
|
+
// the handler only reads env + the role QUERY param; a header is meaningless. Gate is checked first.
|
|
72
|
+
const res = await previewLoginHandler({ url: "https://x/preview/login?role=admin" }, { PREVIEW_DB: {} }, { allowedRoles: ALLOWED, mintSession });
|
|
73
|
+
expect(res.status).toBe(404);
|
|
74
|
+
expect(calls).toHaveLength(0);
|
|
75
|
+
});
|
|
76
|
+
test("isPreviewRuntime requires BOTH a '1' flag and a PREVIEW_DB binding", () => {
|
|
77
|
+
expect(isPreviewRuntime({ SULUK_PREVIEW: "1", PREVIEW_DB: {} })).toBe(true);
|
|
78
|
+
expect(isPreviewRuntime({ SULUK_PREVIEW: "1" })).toBe(false);
|
|
79
|
+
expect(isPreviewRuntime({ PREVIEW_DB: {} })).toBe(false);
|
|
80
|
+
expect(isPreviewRuntime({})).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
});
|