@suluk/better-auth 0.1.1 → 0.1.2
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/index.ts +3 -0
- package/src/preview.ts +83 -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.2",
|
|
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.6",
|
|
23
|
+
"@suluk/openapi-compat": "0.1.1"
|
|
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.1"
|
|
29
40
|
},
|
|
30
41
|
"scripts": {
|
|
31
42
|
"test": "bun test",
|
package/src/index.ts
CHANGED
|
@@ -11,3 +11,6 @@ export { authSecuritySchemes, type AuthMethods, type AuthSecurity } from "./secu
|
|
|
11
11
|
export { normalizeOas30, ingestAuthOpenAPI, mergeAuth, type IngestOptions } from "./ingest";
|
|
12
12
|
export { principalFromSession, type Principal, type SessionLike, type PrincipalOptions } from "./principal";
|
|
13
13
|
export { mountAuth, type AuthHandlerLike, type HonoLike, type MountAuthOptions } from "./mount";
|
|
14
|
+
// live role-preview (charter-bounded by C020): the fail-closed, deploy-gated role-login handler. The extension
|
|
15
|
+
// holds NO token — it deep-links this route in the browser; the credentialed mint happens here, server-side.
|
|
16
|
+
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
|
+
}
|
|
@@ -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
|
+
});
|