@suluk/better-auth 0.1.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/package.json +34 -0
- package/src/index.ts +13 -0
- package/src/ingest.ts +72 -0
- package/src/mount.ts +28 -0
- package/src/principal.ts +38 -0
- package/src/security.ts +45 -0
- package/test/better-auth.test.ts +121 -0
- package/tsconfig.json +5 -0
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@suluk/better-auth",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@suluk/core": "0.1.0",
|
|
12
|
+
"@suluk/openapi-compat": "0.1.0"
|
|
13
|
+
},
|
|
14
|
+
"peerDependencies": {
|
|
15
|
+
"better-auth": "^1.0.0",
|
|
16
|
+
"hono": "^4.0.0"
|
|
17
|
+
},
|
|
18
|
+
"peerDependenciesMeta": {
|
|
19
|
+
"better-auth": {
|
|
20
|
+
"optional": true
|
|
21
|
+
},
|
|
22
|
+
"hono": {
|
|
23
|
+
"optional": true
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/bun": "latest",
|
|
28
|
+
"@suluk/hono": "0.1.0"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"test": "bun test",
|
|
32
|
+
"typecheck": "tsc --noEmit -p ."
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @suluk/better-auth — official Better-Auth-on-Hono support for the Suluk derivation engine.
|
|
3
|
+
*
|
|
4
|
+
* Better Auth is a Contract input (auth settings). This package: (1) derives v4 securitySchemes from the
|
|
5
|
+
* enabled auth methods; (2) ingests Better Auth's own OpenAPI 3.0 output (normalizing it to 2020-12) and
|
|
6
|
+
* lifts it to v4 via @suluk/openapi-compat, then merges it into the app doc — so the auth surface is
|
|
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.
|
|
9
|
+
*/
|
|
10
|
+
export { authSecuritySchemes, type AuthMethods, type AuthSecurity } from "./security";
|
|
11
|
+
export { normalizeOas30, ingestAuthOpenAPI, mergeAuth, type IngestOptions } from "./ingest";
|
|
12
|
+
export { principalFromSession, type Principal, type SessionLike, type PrincipalOptions } from "./principal";
|
|
13
|
+
export { mountAuth, type AuthHandlerLike, type HonoLike, type MountAuthOptions } from "./mount";
|
package/src/ingest.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ingest Better Auth's own OpenAPI output (from auth.api.generateOpenAPISchema(), which emits OpenAPI 3.0)
|
|
3
|
+
* and lift it into the v4 "Suluk" model — so the auth surface (/sign-up, /sign-in, /get-session, …) is
|
|
4
|
+
* documented in the app's v4 doc without re-typing it.
|
|
5
|
+
*
|
|
6
|
+
* Two steps: (1) normalize 3.0-isms to JSON Schema 2020-12 (nullable, boolean exclusiveMin/Max), since
|
|
7
|
+
* compat.upgrade passes Schema Objects through verbatim and v4 schemas must be 2020-12; (2) compat.upgrade.
|
|
8
|
+
*/
|
|
9
|
+
import { upgrade } from "@suluk/openapi-compat";
|
|
10
|
+
import type { OpenAPIv4Document, PathItem, Components, SecurityScheme } from "@suluk/core";
|
|
11
|
+
|
|
12
|
+
/** Recursively rewrite OpenAPI-3.0 Schema-Object dialect into JSON Schema 2020-12. */
|
|
13
|
+
export function normalizeOas30(node: unknown): unknown {
|
|
14
|
+
if (Array.isArray(node)) return node.map(normalizeOas30);
|
|
15
|
+
if (node && typeof node === "object") {
|
|
16
|
+
const o = { ...(node as Record<string, unknown>) };
|
|
17
|
+
// nullable: true → add "null" to the type (or fold into enum); drop the 3.0-only keyword.
|
|
18
|
+
if (o.nullable === true) {
|
|
19
|
+
delete o.nullable;
|
|
20
|
+
if (typeof o.type === "string") o.type = [o.type, "null"];
|
|
21
|
+
else if (Array.isArray(o.type) && !o.type.includes("null")) o.type = [...o.type, "null"];
|
|
22
|
+
else if (Array.isArray(o.enum) && !o.enum.includes(null)) o.enum = [...o.enum, null];
|
|
23
|
+
} else if (o.nullable === false) {
|
|
24
|
+
delete o.nullable;
|
|
25
|
+
}
|
|
26
|
+
// boolean exclusiveMinimum/Maximum (3.0) → numeric form (2020-12).
|
|
27
|
+
if (o.exclusiveMinimum === true && typeof o.minimum === "number") { o.exclusiveMinimum = o.minimum; delete o.minimum; }
|
|
28
|
+
else if (o.exclusiveMinimum === false) delete o.exclusiveMinimum;
|
|
29
|
+
if (o.exclusiveMaximum === true && typeof o.maximum === "number") { o.exclusiveMaximum = o.maximum; delete o.maximum; }
|
|
30
|
+
else if (o.exclusiveMaximum === false) delete o.exclusiveMaximum;
|
|
31
|
+
const out: Record<string, unknown> = {};
|
|
32
|
+
for (const [k, v] of Object.entries(o)) out[k] = normalizeOas30(v);
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
return node;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface IngestOptions {
|
|
39
|
+
/** Prefix every ingested path with this base (Better Auth mounts under "/api/auth"). */
|
|
40
|
+
basePath?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Normalize + upgrade Better Auth's OpenAPI 3.0 schema to a v4 document (the auth surface). */
|
|
44
|
+
export function ingestAuthOpenAPI(schema30: Record<string, unknown>, opts: IngestOptions = {}): OpenAPIv4Document {
|
|
45
|
+
const normalized = normalizeOas30(schema30) as Record<string, unknown>;
|
|
46
|
+
const v4 = upgrade(normalized);
|
|
47
|
+
if (opts.basePath) {
|
|
48
|
+
const prefix = opts.basePath.replace(/\/$/, "");
|
|
49
|
+
const reKeyed: Record<string, PathItem> = {};
|
|
50
|
+
for (const [path, pi] of Object.entries(v4.paths)) {
|
|
51
|
+
const clean = path.replace(/^\//, "");
|
|
52
|
+
reKeyed[`${prefix.replace(/^\//, "")}/${clean}`] = pi as PathItem;
|
|
53
|
+
}
|
|
54
|
+
v4.paths = reKeyed;
|
|
55
|
+
}
|
|
56
|
+
return v4;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Deep-merge auth paths + components (schemas + securitySchemes) into an app's v4 document. */
|
|
60
|
+
export function mergeAuth(
|
|
61
|
+
app: OpenAPIv4Document,
|
|
62
|
+
auth: Partial<OpenAPIv4Document>,
|
|
63
|
+
extra: { securitySchemes?: Record<string, SecurityScheme> } = {},
|
|
64
|
+
): OpenAPIv4Document {
|
|
65
|
+
const out: OpenAPIv4Document = { ...app, paths: { ...app.paths, ...(auth.paths ?? {}) } };
|
|
66
|
+
const components: Components = { ...(app.components ?? {}) };
|
|
67
|
+
if (auth.components?.schemas) components.schemas = { ...(components.schemas ?? {}), ...auth.components.schemas };
|
|
68
|
+
const mergedSchemes = { ...(components.securitySchemes ?? {}), ...(auth.components?.securitySchemes ?? {}), ...(extra.securitySchemes ?? {}) };
|
|
69
|
+
if (Object.keys(mergedSchemes).length) components.securitySchemes = mergedSchemes;
|
|
70
|
+
if (Object.keys(components).length) out.components = components;
|
|
71
|
+
return out;
|
|
72
|
+
}
|
package/src/mount.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mountAuth — the thin Hono adapter for Better Auth (the documented integration:
|
|
3
|
+
* app.on(["POST","GET"], "/api/auth/*", c => auth.handler(c.req.raw))). Duck-typed so it needs neither a
|
|
4
|
+
* hard better-auth nor hono import — it only relies on app.on(...) and auth.handler(Request).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface AuthHandlerLike {
|
|
8
|
+
handler(request: Request): Response | Promise<Response>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface HonoLike {
|
|
12
|
+
on(methods: string[], path: string, handler: (c: { req: { raw: Request } }) => unknown): unknown;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface MountAuthOptions {
|
|
16
|
+
/** Base path Better Auth is mounted at (default "/api/auth"). */
|
|
17
|
+
basePath?: string;
|
|
18
|
+
/** HTTP methods to route (default ["POST","GET"]). */
|
|
19
|
+
methods?: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Mount the Better Auth handler onto a Hono app under basePath/* (default /api/auth/*). */
|
|
23
|
+
export function mountAuth<T extends HonoLike>(app: T, auth: AuthHandlerLike, opts: MountAuthOptions = {}): T {
|
|
24
|
+
const basePath = (opts.basePath ?? "/api/auth").replace(/\/$/, "");
|
|
25
|
+
const methods = opts.methods ?? ["POST", "GET"];
|
|
26
|
+
app.on(methods, `${basePath}/*`, (c) => auth.handler(c.req.raw));
|
|
27
|
+
return app;
|
|
28
|
+
}
|
package/src/principal.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The principal extractor — the loop-closer for per-viewer docs. A Better Auth session (its user role,
|
|
3
|
+
* granted permissions, or an apiKey's scopes) is mapped to a { scopes } principal that @suluk/hono's
|
|
4
|
+
* emitV4(routes, { principal }) uses to project the doc each viewer is allowed to see.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface Principal {
|
|
8
|
+
scopes: string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** A minimal view of a Better Auth session (duck-typed; works with the real Session shape). */
|
|
12
|
+
export interface SessionLike {
|
|
13
|
+
user?: { role?: string | string[]; scopes?: string[] } | null;
|
|
14
|
+
/** apiKey plugin: a key carries its own permissions/scopes. */
|
|
15
|
+
apiKey?: { scopes?: string[]; permissions?: Record<string, string[]> } | null;
|
|
16
|
+
scopes?: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface PrincipalOptions {
|
|
20
|
+
/** Map a role name → the scopes it grants (e.g. { admin: ["read:*","write:*"], user: ["read:self"] }). */
|
|
21
|
+
roleScopes?: Record<string, string[]>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Extract a { scopes } principal from a Better Auth session. Null/undefined session ⇒ anonymous (no scopes). */
|
|
25
|
+
export function principalFromSession(session: SessionLike | null | undefined, opts: PrincipalOptions = {}): Principal {
|
|
26
|
+
if (!session) return { scopes: [] };
|
|
27
|
+
const scopes = new Set<string>();
|
|
28
|
+
for (const s of session.scopes ?? []) scopes.add(s);
|
|
29
|
+
for (const s of session.user?.scopes ?? []) scopes.add(s);
|
|
30
|
+
for (const s of session.apiKey?.scopes ?? []) scopes.add(s);
|
|
31
|
+
for (const list of Object.values(session.apiKey?.permissions ?? {})) for (const s of list) scopes.add(s);
|
|
32
|
+
|
|
33
|
+
const roles = session.user?.role;
|
|
34
|
+
const roleList = Array.isArray(roles) ? roles : roles ? [roles] : [];
|
|
35
|
+
for (const role of roleList) for (const s of opts.roleScopes?.[role] ?? []) scopes.add(s);
|
|
36
|
+
|
|
37
|
+
return { scopes: [...scopes] };
|
|
38
|
+
}
|
package/src/security.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derive v4 securitySchemes (C014) from a declarative description of the Better Auth methods in use.
|
|
3
|
+
* Better Auth's standard mechanisms map onto OpenAPI security schemes as:
|
|
4
|
+
* - session → an apiKey-in-cookie (default cookie "better-auth.session_token")
|
|
5
|
+
* - bearer → http bearer (Authorization: Bearer <token>; the bearer plugin)
|
|
6
|
+
* - apiKey → apiKey-in-header (default "x-api-key"; the apiKey plugin)
|
|
7
|
+
* The exact names are Better Auth conventions and are overridable.
|
|
8
|
+
*/
|
|
9
|
+
import type { SecurityScheme } from "@suluk/core";
|
|
10
|
+
|
|
11
|
+
export interface AuthMethods {
|
|
12
|
+
/** Session cookie (default). `true` ⇒ default cookie name; or pass a custom cookie name. */
|
|
13
|
+
session?: boolean | { cookieName?: string };
|
|
14
|
+
/** Bearer token (the bearer plugin). */
|
|
15
|
+
bearer?: boolean;
|
|
16
|
+
/** API key (the apiKey plugin). `true` ⇒ default "x-api-key" header; or pass a custom header. */
|
|
17
|
+
apiKey?: boolean | { header?: string };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface AuthSecurity {
|
|
21
|
+
/** v4 components.securitySchemes entries, keyed by scheme name. */
|
|
22
|
+
securitySchemes: Record<string, SecurityScheme>;
|
|
23
|
+
/** Convenience: the scheme names, to build by-name security requirements. */
|
|
24
|
+
names: string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const DEFAULT_SESSION_COOKIE = "better-auth.session_token";
|
|
28
|
+
const DEFAULT_APIKEY_HEADER = "x-api-key";
|
|
29
|
+
|
|
30
|
+
/** Build v4 securitySchemes from the enabled Better Auth methods. */
|
|
31
|
+
export function authSecuritySchemes(methods: AuthMethods): AuthSecurity {
|
|
32
|
+
const securitySchemes: Record<string, SecurityScheme> = {};
|
|
33
|
+
if (methods.session) {
|
|
34
|
+
const cookieName = typeof methods.session === "object" ? methods.session.cookieName ?? DEFAULT_SESSION_COOKIE : DEFAULT_SESSION_COOKIE;
|
|
35
|
+
securitySchemes.sessionCookie = { type: "apiKey", in: "cookie", name: cookieName };
|
|
36
|
+
}
|
|
37
|
+
if (methods.bearer) {
|
|
38
|
+
securitySchemes.bearerAuth = { type: "http", scheme: "bearer" };
|
|
39
|
+
}
|
|
40
|
+
if (methods.apiKey) {
|
|
41
|
+
const header = typeof methods.apiKey === "object" ? methods.apiKey.header ?? DEFAULT_APIKEY_HEADER : DEFAULT_APIKEY_HEADER;
|
|
42
|
+
securitySchemes.apiKey = { type: "apiKey", in: "header", name: header };
|
|
43
|
+
}
|
|
44
|
+
return { securitySchemes, names: Object.keys(securitySchemes) };
|
|
45
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { validateDocument } from "@suluk/core";
|
|
3
|
+
import { emitV4, contract } from "@suluk/hono";
|
|
4
|
+
import * as z from "zod";
|
|
5
|
+
import { authSecuritySchemes, normalizeOas30, ingestAuthOpenAPI, mergeAuth, principalFromSession } from "../src/index";
|
|
6
|
+
|
|
7
|
+
/** A realistic slice of what Better Auth's generateOpenAPISchema() emits (OpenAPI 3.0, with a nullable field). */
|
|
8
|
+
const betterAuthOpenAPI30: Record<string, unknown> = {
|
|
9
|
+
openapi: "3.0.3",
|
|
10
|
+
info: { title: "Better Auth", version: "1.0.0" },
|
|
11
|
+
paths: {
|
|
12
|
+
"/sign-up/email": {
|
|
13
|
+
post: {
|
|
14
|
+
operationId: "signUpEmail",
|
|
15
|
+
requestBody: { content: { "application/json": { schema: { $ref: "#/components/schemas/SignUpBody" } } } },
|
|
16
|
+
responses: { "200": { description: "Session", content: { "application/json": { schema: { $ref: "#/components/schemas/Session" } } } } },
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
"/get-session": {
|
|
20
|
+
get: {
|
|
21
|
+
operationId: "getSession",
|
|
22
|
+
responses: { "200": { description: "Current session", content: { "application/json": { schema: { $ref: "#/components/schemas/Session" } } } } },
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
components: {
|
|
27
|
+
schemas: {
|
|
28
|
+
SignUpBody: { type: "object", required: ["email", "password"], properties: { email: { type: "string", format: "email" }, password: { type: "string" }, name: { type: "string", nullable: true } } },
|
|
29
|
+
Session: { type: "object", properties: { token: { type: "string" }, user: { type: "object", properties: { id: { type: "string" }, image: { type: "string", nullable: true } } } } },
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
describe("authSecuritySchemes — auth methods → v4 securitySchemes (C014)", () => {
|
|
35
|
+
test("session → cookie, bearer → http bearer, apiKey → header", () => {
|
|
36
|
+
const { securitySchemes, names } = authSecuritySchemes({ session: true, bearer: true, apiKey: true });
|
|
37
|
+
expect(securitySchemes.sessionCookie).toEqual({ type: "apiKey", in: "cookie", name: "better-auth.session_token" });
|
|
38
|
+
expect(securitySchemes.bearerAuth).toEqual({ type: "http", scheme: "bearer" });
|
|
39
|
+
expect(securitySchemes.apiKey).toEqual({ type: "apiKey", in: "header", name: "x-api-key" });
|
|
40
|
+
expect(names.sort()).toEqual(["apiKey", "bearerAuth", "sessionCookie"]);
|
|
41
|
+
});
|
|
42
|
+
test("custom names are honored", () => {
|
|
43
|
+
const { securitySchemes } = authSecuritySchemes({ session: { cookieName: "myapp.sid" }, apiKey: { header: "X-Key" } });
|
|
44
|
+
expect((securitySchemes.sessionCookie as any).name).toBe("myapp.sid");
|
|
45
|
+
expect((securitySchemes.apiKey as any).name).toBe("X-Key");
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("normalizeOas30 — 3.0 Schema dialect → JSON Schema 2020-12", () => {
|
|
50
|
+
test("nullable:true folds 'null' into the type", () => {
|
|
51
|
+
expect(normalizeOas30({ type: "string", nullable: true })).toEqual({ type: ["string", "null"] });
|
|
52
|
+
});
|
|
53
|
+
test("boolean exclusiveMinimum → numeric form", () => {
|
|
54
|
+
expect(normalizeOas30({ type: "number", minimum: 0, exclusiveMinimum: true })).toEqual({ type: "number", exclusiveMinimum: 0 });
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("ingestAuthOpenAPI — Better Auth 3.0 → v4", () => {
|
|
59
|
+
const v4 = ingestAuthOpenAPI(betterAuthOpenAPI30, { basePath: "/api/auth" });
|
|
60
|
+
|
|
61
|
+
test("lifts to a structurally valid v4 document", () => {
|
|
62
|
+
const r = validateDocument(v4);
|
|
63
|
+
if (!r.valid) console.error(r.errors);
|
|
64
|
+
expect(r.valid).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
test("prefixes auth paths under the mount base", () => {
|
|
67
|
+
expect(Object.keys(v4.paths)).toContain("api/auth/sign-up/email");
|
|
68
|
+
expect(Object.keys(v4.paths)).toContain("api/auth/get-session");
|
|
69
|
+
});
|
|
70
|
+
test("the nullable 3.0 field became a 2020-12 type array (no leftover 'nullable')", () => {
|
|
71
|
+
const name = (v4.components!.schemas!.SignUpBody as any).properties.name;
|
|
72
|
+
expect(name.type).toEqual(["string", "null"]);
|
|
73
|
+
expect(name.nullable).toBeUndefined();
|
|
74
|
+
});
|
|
75
|
+
test("preserves the operation by-name handle (operationId → request name)", () => {
|
|
76
|
+
const reqs = v4.paths["api/auth/sign-up/email"].requests;
|
|
77
|
+
expect(Object.keys(reqs)).toContain("signUpEmail");
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("mergeAuth — fold the auth surface into an app's v4 doc", () => {
|
|
82
|
+
test("merged document (app routes + auth routes + securitySchemes) is valid v4", () => {
|
|
83
|
+
const appRoutes = contract([
|
|
84
|
+
{ method: "get", path: "/pet", name: "listPets", summary: "List", responses: [{ status: 200, description: "ok", schema: z.array(z.object({ name: z.string() })) }] },
|
|
85
|
+
]);
|
|
86
|
+
const { document: appDoc } = emitV4(appRoutes, { info: { title: "Pets", version: "1.0.0" } });
|
|
87
|
+
const authV4 = ingestAuthOpenAPI(betterAuthOpenAPI30, { basePath: "/api/auth" });
|
|
88
|
+
const { securitySchemes } = authSecuritySchemes({ session: true, bearer: true });
|
|
89
|
+
|
|
90
|
+
const merged = mergeAuth(appDoc, authV4, { securitySchemes });
|
|
91
|
+
expect(validateDocument(merged).valid).toBe(true);
|
|
92
|
+
// both surfaces present
|
|
93
|
+
expect(Object.keys(merged.paths)).toContain("pet");
|
|
94
|
+
expect(Object.keys(merged.paths)).toContain("api/auth/sign-up/email");
|
|
95
|
+
// securitySchemes carried (C014 anchor)
|
|
96
|
+
expect(merged.components!.securitySchemes!.sessionCookie).toBeDefined();
|
|
97
|
+
expect(merged.components!.schemas!.Session).toBeDefined();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("principalFromSession — the per-viewer loop closer", () => {
|
|
102
|
+
test("maps role → scopes via the provided map", () => {
|
|
103
|
+
const p = principalFromSession({ user: { role: "admin" } }, { roleScopes: { admin: ["read:pets", "write:pets"] } });
|
|
104
|
+
expect(p.scopes.sort()).toEqual(["read:pets", "write:pets"]);
|
|
105
|
+
});
|
|
106
|
+
test("collects scopes from session, user, and apiKey permissions", () => {
|
|
107
|
+
const p = principalFromSession({ scopes: ["a"], user: { scopes: ["b"] }, apiKey: { permissions: { pets: ["write:pets"] } } });
|
|
108
|
+
expect(p.scopes.sort()).toEqual(["a", "b", "write:pets"]);
|
|
109
|
+
});
|
|
110
|
+
test("a writer principal sees a scope-gated operation that an anonymous viewer does not", () => {
|
|
111
|
+
const routes = contract([
|
|
112
|
+
{ method: "post", path: "/pet", name: "createPet", scopes: ["write:pets"], request: { json: z.object({ name: z.string() }) }, responses: [{ status: 201, description: "created" }] },
|
|
113
|
+
]);
|
|
114
|
+
const anon = principalFromSession(null);
|
|
115
|
+
const writer = principalFromSession({ user: { role: "admin" } }, { roleScopes: { admin: ["write:pets"] } });
|
|
116
|
+
const anonNames = Object.values(emitV4(routes, { principal: anon }).document.paths).flatMap((pi) => Object.keys(pi.requests));
|
|
117
|
+
const writerNames = Object.values(emitV4(routes, { principal: writer }).document.paths).flatMap((pi) => Object.keys(pi.requests));
|
|
118
|
+
expect(anonNames).not.toContain("createPet");
|
|
119
|
+
expect(writerNames).toContain("createPet");
|
|
120
|
+
});
|
|
121
|
+
});
|