@suluk/better-auth - v0.2.0
    Preparing search index...

    @suluk/better-auth - v0.2.0

    Suluk

    @suluk/better-auth

    Wire Better Auth into the v4 contract — auth methods become securitySchemes, the session becomes a principal, and Better Auth's own OpenAPI surface is ingested, never re-typed.

    Part of Suluk — one typed OpenAPI v4 contract projecting into every full-stack layer.


    CANDIDATE tooling — not official OpenAPI. Suluk is a single-contributor candidate for OpenAPI Specification v4.0 ("Moonwalk"), unaffiliated with the OpenAPI Initiative and unable to ratify anything on the SIG's behalf.

    bun add @suluk/better-auth
    

    better-auth and hono are optional peers — everything here is duck-typed, so you only need the ones you actually use.

    Better Auth is a Contract input (your auth settings). This package reflects it into the Suluk v4 document and closes the per-viewer loop, without you re-typing the auth surface:

    • Auth methods → v4 securitySchemesauthSecuritySchemes({ session, bearer, apiKey, … }) derives the components.securitySchemes block (session-cookie, HTTP bearer, API-key header).
    • Better Auth's OpenAPI 3.0 → v4ingestAuthOpenAPI normalizes Better Auth's own generateOpenAPISchema() output to JSON Schema 2020-12, lifts it to v4 (via @suluk/openapi-compat), and mergeAuth folds the auth routes + schemes into your app's document. /sign-up, /get-session, … get documented for free.
    • Session → { scopes } principalprincipalFromSession maps a Better Auth session (role, granted scopes, 2FA state, org memberships) to the { scopes } shape @suluk/hono's emitV4(routes, { principal }) uses to project the doc each viewer is allowed to see. verifyApiKey produces the same shape for API-key callers, so enforcement is identical for sessions and keys.
    • Thin Hono mount + auth-flow plumbingmountAuth routes /api/auth/* to the Better Auth handler; helpers cover open-redirect-safe redirects, frictionless email verification, a GDPR erasure cascade, and a fail-closed role-preview login route.

    Reach for it when your app uses Better Auth and you want auth reflected in the contract — its schemes and its endpoints in the v4 doc — plus a principal that drives per-viewer projection and @suluk/hono access enforcement. The session→principal shape is the seam many other packages key off; this is where it's produced.

    Don't reach for it to render the doc (that's @suluk/reference / @suluk/scalar / @suluk/swagger) or to build routes from entities (that's @suluk/builder / @suluk/drizzle). This package's job ends at handing those layers a contract that already knows about auth, and a principal to scope it.

    The common case: derive the schemes, ingest Better Auth's own OpenAPI, and merge both into the app doc.

    import { authSecuritySchemes, ingestAuthOpenAPI, mergeAuth } from "@suluk/better-auth";
    import { auth } from "./auth"; // your betterAuth({ … }) instance, with the openAPI() plugin enabled

    // 1. auth methods → v4 securitySchemes
    const { securitySchemes } = authSecuritySchemes({ session: true, bearer: true, apiKey: true });

    // 2. ingest Better Auth's own OpenAPI 3.0 surface, lifted to v4 and prefixed under its mount base
    const authSchema = await auth.api.generateOpenAPISchema(); // OpenAPI 3.0
    const authV4 = ingestAuthOpenAPI(authSchema, { basePath: "/api/auth" });

    // 3. fold auth routes + schemes into your app's v4 document
    const document = mergeAuth(appDocument, authV4, { securitySchemes });
    // → document.paths now has "api/auth/sign-up/email", …; components.securitySchemes has sessionCookie/bearerAuth/apiKey

    authSecuritySchemes also reports session-based plugins that add no new wire scheme but gate into the session — { twoFactor, passkey, organization } — via the returned plugins field.

    import { principalFromSession } from "@suluk/better-auth";
    import { emitV4 } from "@suluk/hono";

    const session = await auth.api.getSession({ headers: req.headers });
    const principal = principalFromSession(session, {
    roleScopes: { admin: ["read:*", "write:*"], user: ["read:self"] },
    });

    // @suluk/hono projects only the operations this viewer's scopes allow
    const { document } = emitV4(routes, { principal });

    principalFromSession also encodes plugin state as scopes: a 2FA-cleared session gains mfa:verified (the exported MFA_SCOPE), and each org membership contributes org:<id>:<scope> scopes (build/parse them with orgScope / parseOrgScope) — so 2FA and tenancy gate through the same scope check @suluk/hono already runs.

    Give API-key callers the same { scopes } principal as sessions, so @suluk/hono's createGuard / enforceAccess works for keys too:

    import { verifyApiKey } from "@suluk/better-auth";

    const result = await verifyApiKey(auth.api, key, { requireScopes: ["orders:read"] });
    if (!result.ok) {
    // result.reason is "invalid" | "insufficient_scope" | "error"
    return new Response("unauthorized", { status: 401 });
    }
    result.principal; // { scopes: ["orders:read", …] } — feed this to @suluk/hono
    result.key; // { id, userId, name, metadata } — metadata parsed past Better Auth's double-stringification

    scopesToPermissions / permissionsToScopes convert between flat "cart:read" scopes and Better Auth's { cart: ["read"] } permission shape.

    import { mountAuth } from "@suluk/better-auth";
    import { Hono } from "hono";

    const app = new Hono();
    mountAuth(app, auth); // routes POST/GET /api/auth/* → auth.handler(c.req.raw)
    import {
    resolveRedirectTo, withRedirectTo, isSafeRelativePath, emailVerificationConfig,
    } from "@suluk/better-auth";

    resolveRedirectTo(location.search); // read ?redirectTo, honored only if same-origin-relative (open-redirect-safe)
    withRedirectTo("/login", "/dashboard"); // "/login?redirectTo=%2Fdashboard" — return here post-auth

    // spread into betterAuth({ emailVerification: … }) — verify-on-sign-up + auto-sign-in after, by default
    const emailVerification = emailVerificationConfig({
    sendVerificationEmail: async ({ user, url }) => { await sendVerifyEmail(user.email, url); },
    });

    beforeDeleteCascade builds Better Auth's user.deleteUser.beforeDelete hook from an ordered list of steps. The package orchestrates; you supply the steps and pick the posture (deleteStep hard-deletes, anonymizeStep scrubs PII, step is generic). Fail-closed by default — a failed cleanup aborts the delete so no rows are orphaned.

    import { betterAuth } from "better-auth";
    import { beforeDeleteCascade, deleteStep } from "@suluk/better-auth";

    const steps = [
    deleteStep("orders", (user) => db.delete(orders).where(eq(orders.customerId, user.id)).run()),
    deleteStep("apiTokens", (user) => db.delete(apiToken).where(eq(apiToken.userId, user.id)).run()),
    ];

    betterAuth({
    user: { deleteUser: { enabled: true, beforeDelete: beforeDeleteCascade(steps) } },
    });

    previewLoginHandler mints a role-scoped session for the seeded demo user, for the generated preview Worker — fail-closed behind two independent locks (env.SULUK_PREVIEW === "1" and an env.PREVIEW_DB binding; absence of either ⇒ 404). The role comes from a server-side allow-list, never a client header.

    import { previewLoginHandler } from "@suluk/better-auth";

    // inside the preview Worker, for GET /preview/login?role=…
    return previewLoginHandler(request, env, {
    allowedRoles: ["user", "admin"], // from the contract's preview roles, NEVER hardcoded loosely
    mintSession: (role) => mintDemoSession(role), // binds to a seeded throwaway demo user for `role`
    });
    Export What it does
    authSecuritySchemes(methods) Auth methods → v4 securitySchemes + the enabled session-plugins (AuthMethodsAuthSecurity).
    ingestAuthOpenAPI(schema30, opts?) Normalize Better Auth's OpenAPI 3.0 to 2020-12 and lift it to a v4 document.
    normalizeOas30(node) Rewrite 3.0 Schema dialect (nullable, boolean exclusiveMin/Max) into JSON Schema 2020-12.
    mergeAuth(app, auth, extra?) Deep-merge auth paths + schemas + securitySchemes into the app's v4 document.
    principalFromSession(session, opts?) Better Auth session → { scopes } Principal (role, scopes, MFA, org).
    MFA_SCOPE, orgScope, parseOrgScope The mfa:verified scope + build/parse org:<id>:<scope> tenancy scopes.
    verifyApiKey(verifier, key, opts?) Verify an API key (optionally requireScopes) → the same { scopes } Principal.
    scopesToPermissions / permissionsToScopes Convert between flat "a:b" scopes and Better Auth's { a: ["b"] } permissions.
    parseApiKeyMetadata(raw) Parse key metadata past Better Auth's double-stringification quirk.
    beforeDeleteCascade(steps, opts?) Build the beforeDelete hook from an ordered, fail-closed erasure cascade.
    step / anonymizeStep / deleteStep Cascade-step constructors (generic / scrub-PII / hard-delete).
    isSafeRelativePath / resolveRedirectTo / withRedirectTo Open-redirect-safe redirectTo preservation.
    emailVerificationConfig(opts) A Better Auth emailVerification block with frictionless-activation defaults.
    mountAuth(app, auth, opts?) Mount the Better Auth handler onto a Hono app under basePath/*.
    previewLoginHandler(req, env, opts) / isPreviewRuntime(env) Fail-closed role-preview login + its two-lock gate check.

    This package reflects and reads Better Auth — it never becomes the auth runtime. Better Auth owns sessions, storage, and the credential flows; Suluk turns its configuration into a v4 contract and a scope-bearing principal. The seams are injected, not assumed: mountAuth / verifyApiKey are duck-typed against auth.handler / auth.api (no hard better-auth or hono import); beforeDeleteCascade takes the steps that touch your db; previewLoginHandler takes the mintSession that owns the session lookup. The session→principal shape is a stable contract many other @suluk/* packages depend on — extend the method→scheme and session→scope mappings, but keep that shape steady. Rendering the resulting doc, and hosting the auth itself, stay outside this line.

    Apache-2.0

    Class diagram