@hyperfixation/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.
@@ -0,0 +1,81 @@
1
+ import { hfAccount, hfInvitation, hfMember, hfOrganization, hfPasskey, hfSession, hfUser, hfVerification, } from "@hyperfixation/db";
2
+ import { passkey } from "@better-auth/passkey";
3
+ import { betterAuth } from "better-auth";
4
+ import { drizzleAdapter } from "better-auth/adapters/drizzle";
5
+ import { admin, emailOTP, organization } from "better-auth/plugins";
6
+ import { drizzle } from "drizzle-orm/node-postgres";
7
+ import { ADMIN_ROLE } from "./policy.js";
8
+ import { sessionFactorForPath } from "./session.js";
9
+ /** Model name → the `hf_*` table it lives in. Every better-auth table is prefixed, forever. */
10
+ export const AUTH_SCHEMA = {
11
+ user: hfUser,
12
+ session: hfSession,
13
+ account: hfAccount,
14
+ verification: hfVerification,
15
+ passkey: hfPasskey,
16
+ organization: hfOrganization,
17
+ member: hfMember,
18
+ invitation: hfInvitation,
19
+ };
20
+ /** Promotes a code-factor session in place; see `upgradeSessionFactor`. */
21
+ const UPGRADE_SESSION_FACTOR_STATEMENT = "UPDATE hf_session SET factor = 'passkey', updated_at = now() WHERE token = $1 AND factor = 'code'";
22
+ /**
23
+ * The better-auth instance, wired to the seven `hf_*` tables chunk 2 created.
24
+ *
25
+ * `disableSignUp: true` on the OTP plugin is the shape of the whole product: there is no
26
+ * self-serve path into an app. A user exists because `bootstrapAdmin` or an existing admin put
27
+ * them there, and a code sent to an address with no `hf_user` row signs nobody in.
28
+ *
29
+ * `emailAndPassword` is left off entirely rather than configured off — the `password` column on
30
+ * `hf_account` is better-auth's, not a supported credential here — and the only two ways to
31
+ * hold a session are an emailed code and a passkey, which is exactly what `factor` records.
32
+ */
33
+ export function createAuth(options) {
34
+ const db = drizzle(options.pool, { schema: AUTH_SCHEMA });
35
+ return betterAuth({
36
+ database: drizzleAdapter(db, { provider: "pg", schema: AUTH_SCHEMA }),
37
+ baseURL: options.baseURL,
38
+ secret: options.secret,
39
+ trustedOrigins: options.trustedOrigins,
40
+ session: {
41
+ additionalFields: {
42
+ // `input: false` — the factor is stamped from the endpoint that minted the session,
43
+ // never from anything a client sends.
44
+ factor: { type: "string", required: false, input: false, defaultValue: "code" },
45
+ },
46
+ },
47
+ databaseHooks: {
48
+ session: {
49
+ create: {
50
+ before: async (session, ctx) => ({
51
+ data: { ...session, factor: sessionFactorForPath(ctx?.path) },
52
+ }),
53
+ },
54
+ },
55
+ },
56
+ plugins: [
57
+ emailOTP({ sendVerificationOTP: options.sendVerificationOTP, disableSignUp: true }),
58
+ passkey({ rpID: options.rpID, rpName: options.rpName, origin: options.origin }),
59
+ admin({ defaultRole: "member", adminRoles: [ADMIN_ROLE] }),
60
+ // `invitationLimit: 0` because an invitation ends in a sign-up, and there is no sign-up:
61
+ // an invited stranger has nowhere to land. `hf_invitation` exists all the same — the
62
+ // plugin refuses to initialise with a model it cannot reach — so turning invitations on
63
+ // later is a config change, not a migration.
64
+ organization({ invitationLimit: 0 }),
65
+ ],
66
+ });
67
+ }
68
+ /**
69
+ * Promotes the session that just enrolled a passkey, so the user is not sent back through the
70
+ * email code to reach the app they enrolled from.
71
+ *
72
+ * Registration is not authentication, so it does not go through `sessionFactorForPath`: this is
73
+ * a deliberate second entry into `passkey`, taken only after `/passkey/verify-registration`
74
+ * succeeded for this very session. It grants nothing an immediate passkey sign-in would not —
75
+ * whoever enrolled the authenticator can use it — and the `factor = 'code'` predicate makes it
76
+ * a no-op on a session that already holds the stronger factor.
77
+ */
78
+ export async function upgradeSessionFactor(pool, sessionToken) {
79
+ const result = await pool.query(UPGRADE_SESSION_FACTOR_STATEMENT, [sessionToken]);
80
+ return result.rowCount === 1;
81
+ }
@@ -0,0 +1,6 @@
1
+ export { createAuth, upgradeSessionFactor, AUTH_SCHEMA, type CreateAuthOptions, type HyperfixationAuth, } from "./factory.js";
2
+ export { hasRole, sessionFactorForPath, sessionFactors, EMAIL_OTP_SIGN_IN_PATH, PASSKEY_AUTHENTICATION_PATH, PASSKEY_REGISTRATION_PATH, type AuthSession, type SessionFactor, type SessionUser, } from "./session.js";
3
+ export { evaluateAccess, routeAreaOf, ADMIN_AREA, ADMIN_ROLE, AUTH_AREA, DEFAULT_SIGN_IN_PATH, DEFAULT_STEP_UP_PATH, type AccessDecision, type AccessPaths, type AccessRefusal, type AccessRequest, type RouteArea, } from "./policy.js";
4
+ export { createSessionGuard, AccessRefused, type RequireSession, type RequireSessionOptions, type SessionGuardOptions, } from "./require-session.js";
5
+ export { bootstrapAdmin, BootstrapRefused, BOOTSTRAPPED_MARKER, BOOTSTRAP_EMAIL_ENV, type BootstrapAdminOptions, type BootstrapRefusal, type BootstrapResult, } from "./bootstrap.js";
6
+ export { createResetSecondFactorAction, resetSecondFactor, SECOND_FACTOR_RESET_MARKER, type ResetSecondFactorAction, type ResetSecondFactorActionOptions, type ResetSecondFactorOptions, type ResetSecondFactorResult, } from "./reset-second-factor.js";
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export { createAuth, upgradeSessionFactor, AUTH_SCHEMA, } from "./factory.js";
2
+ export { hasRole, sessionFactorForPath, sessionFactors, EMAIL_OTP_SIGN_IN_PATH, PASSKEY_AUTHENTICATION_PATH, PASSKEY_REGISTRATION_PATH, } from "./session.js";
3
+ export { evaluateAccess, routeAreaOf, ADMIN_AREA, ADMIN_ROLE, AUTH_AREA, DEFAULT_SIGN_IN_PATH, DEFAULT_STEP_UP_PATH, } from "./policy.js";
4
+ export { createSessionGuard, AccessRefused, } from "./require-session.js";
5
+ export { bootstrapAdmin, BootstrapRefused, BOOTSTRAPPED_MARKER, BOOTSTRAP_EMAIL_ENV, } from "./bootstrap.js";
6
+ export { createResetSecondFactorAction, resetSecondFactor, SECOND_FACTOR_RESET_MARKER, } from "./reset-second-factor.js";
@@ -0,0 +1,65 @@
1
+ import type { AuthSession, SessionFactor } from "./session.js";
2
+ /** Where a code-factor session is allowed to be, and where an unauthenticated one is sent. */
3
+ export declare const AUTH_AREA = "auth";
4
+ /** The area that does not exist for anyone who cannot enter it. */
5
+ export declare const ADMIN_AREA = "admin";
6
+ export declare const DEFAULT_SIGN_IN_PATH = "/auth/sign-in";
7
+ export declare const DEFAULT_STEP_UP_PATH = "/auth/passkey";
8
+ /** The role `/admin/*` requires, and the one `bootstrapAdmin` grants. */
9
+ export declare const ADMIN_ROLE = "admin";
10
+ export type RouteArea = "auth" | "admin" | "app";
11
+ export type AccessRefusal = "no-session" | "banned" | "code-factor" | "missing-role";
12
+ export type AccessDecision = {
13
+ outcome: "allow";
14
+ session: AuthSession;
15
+ } | {
16
+ outcome: "redirect";
17
+ to: string;
18
+ refusal: AccessRefusal;
19
+ } | {
20
+ outcome: "not-found";
21
+ refusal: AccessRefusal;
22
+ };
23
+ export interface AccessPaths {
24
+ signInPath?: string;
25
+ stepUpPath?: string;
26
+ }
27
+ export interface AccessRequest extends AccessPaths {
28
+ session: AuthSession | null | undefined;
29
+ /**
30
+ * The route being entered. A layout or route handler knows it; a server action does not, and
31
+ * omitting it is the strict case — see `evaluateAccess`.
32
+ */
33
+ pathname?: string;
34
+ /** Raise the bar above what the route alone demands. Never lowers `/admin/*`. */
35
+ factor?: SessionFactor;
36
+ role?: string;
37
+ }
38
+ /**
39
+ * The first segment, with a leading `/api` stripped, so `/api/admin/users` is admin territory
40
+ * to a route handler exactly as `/admin/users` is to a layout. Stripping `/api` is also what
41
+ * keeps `/api/auth/*` — better-auth's own handler, which a code-factor session must be able to
42
+ * drive in order to enrol a passkey at all — inside the auth area.
43
+ */
44
+ export declare function routeAreaOf(pathname: string): RouteArea;
45
+ /**
46
+ * The session-factor policy, as one pure function over a fake-able session.
47
+ *
48
+ * Three rules, and the order between them is the point:
49
+ *
50
+ * 1. A check that names a role — every `/admin/*` route, and any action that asks for one —
51
+ * answers **404 for every refusal**, including "not signed in at all". A redirect would say
52
+ * that the path exists and that signing in is worth trying; the whole reason `/admin/*`
53
+ * 404s rather than redirects is that it must not say so. So the role test runs before the
54
+ * factor test: an admin holding only a code-factor session gets the same 404 as a stranger,
55
+ * not a step-up redirect that would confirm the area.
56
+ * 2. A code-factor session is confined to the auth area. Everywhere else it is refused and sent
57
+ * to step up, because it is one factor — possession of an inbox — and nothing more.
58
+ * 3. Requirements are minimums. A passkey session satisfies `factor: 'code'`; the reverse is
59
+ * what rule 2 refuses.
60
+ *
61
+ * `pathname` omitted means "app area", the strict case: a server action states its own bar, and
62
+ * one that carries no `factor` is passkey-only. An action reachable from `/auth/*` — sending a
63
+ * code, enrolling the first passkey — opts down with `factor: 'code'` explicitly.
64
+ */
65
+ export declare function evaluateAccess(request: AccessRequest): AccessDecision;
package/dist/policy.js ADDED
@@ -0,0 +1,68 @@
1
+ import { hasRole } from "./session.js";
2
+ /** Where a code-factor session is allowed to be, and where an unauthenticated one is sent. */
3
+ export const AUTH_AREA = "auth";
4
+ /** The area that does not exist for anyone who cannot enter it. */
5
+ export const ADMIN_AREA = "admin";
6
+ export const DEFAULT_SIGN_IN_PATH = "/auth/sign-in";
7
+ export const DEFAULT_STEP_UP_PATH = "/auth/passkey";
8
+ /** The role `/admin/*` requires, and the one `bootstrapAdmin` grants. */
9
+ export const ADMIN_ROLE = "admin";
10
+ /**
11
+ * The first segment, with a leading `/api` stripped, so `/api/admin/users` is admin territory
12
+ * to a route handler exactly as `/admin/users` is to a layout. Stripping `/api` is also what
13
+ * keeps `/api/auth/*` — better-auth's own handler, which a code-factor session must be able to
14
+ * drive in order to enrol a passkey at all — inside the auth area.
15
+ */
16
+ export function routeAreaOf(pathname) {
17
+ const path = pathname.split("?")[0].split("#")[0];
18
+ const segments = path.split("/").filter((segment) => segment.length > 0);
19
+ const first = (segments[0] === "api" ? segments.slice(1) : segments)[0];
20
+ if (first === AUTH_AREA)
21
+ return "auth";
22
+ if (first === ADMIN_AREA)
23
+ return "admin";
24
+ return "app";
25
+ }
26
+ /**
27
+ * The session-factor policy, as one pure function over a fake-able session.
28
+ *
29
+ * Three rules, and the order between them is the point:
30
+ *
31
+ * 1. A check that names a role — every `/admin/*` route, and any action that asks for one —
32
+ * answers **404 for every refusal**, including "not signed in at all". A redirect would say
33
+ * that the path exists and that signing in is worth trying; the whole reason `/admin/*`
34
+ * 404s rather than redirects is that it must not say so. So the role test runs before the
35
+ * factor test: an admin holding only a code-factor session gets the same 404 as a stranger,
36
+ * not a step-up redirect that would confirm the area.
37
+ * 2. A code-factor session is confined to the auth area. Everywhere else it is refused and sent
38
+ * to step up, because it is one factor — possession of an inbox — and nothing more.
39
+ * 3. Requirements are minimums. A passkey session satisfies `factor: 'code'`; the reverse is
40
+ * what rule 2 refuses.
41
+ *
42
+ * `pathname` omitted means "app area", the strict case: a server action states its own bar, and
43
+ * one that carries no `factor` is passkey-only. An action reachable from `/auth/*` — sending a
44
+ * code, enrolling the first passkey — opts down with `factor: 'code'` explicitly.
45
+ */
46
+ export function evaluateAccess(request) {
47
+ const area = request.pathname === undefined ? "app" : routeAreaOf(request.pathname);
48
+ const requiredRole = request.role ?? (area === "admin" ? ADMIN_ROLE : undefined);
49
+ const requiredFactor = request.factor ?? (area === "auth" ? "code" : "passkey");
50
+ const hidden = requiredRole !== undefined;
51
+ const signIn = request.signInPath ?? DEFAULT_SIGN_IN_PATH;
52
+ const stepUp = request.stepUpPath ?? DEFAULT_STEP_UP_PATH;
53
+ const refuse = (refusal, to) => hidden ? { outcome: "not-found", refusal } : { outcome: "redirect", to, refusal };
54
+ const { session } = request;
55
+ if (!session)
56
+ return refuse("no-session", signIn);
57
+ // A ban lands on the user row while their sessions are still live; it has to be read here or
58
+ // a banned admin keeps the tab they already had open.
59
+ if (session.user.banned === true)
60
+ return refuse("banned", signIn);
61
+ if (requiredRole !== undefined && !hasRole(session.user, requiredRole)) {
62
+ return { outcome: "not-found", refusal: "missing-role" };
63
+ }
64
+ if (requiredFactor === "passkey" && session.factor !== "passkey") {
65
+ return refuse("code-factor", stepUp);
66
+ }
67
+ return { outcome: "allow", session };
68
+ }
@@ -0,0 +1,34 @@
1
+ import { type AccessDecision, type AccessPaths, type AccessRequest } from "./policy.js";
2
+ import type { AuthSession } from "./session.js";
3
+ /**
4
+ * What a refusal throws when the host's handler returns instead of diverting. Next's
5
+ * `redirect()` and `notFound()` both throw, so in the template this is unreachable — it exists
6
+ * so that `requireSession` cannot return an unauthorized session under a handler that forgets.
7
+ */
8
+ export declare class AccessRefused extends Error {
9
+ readonly decision: AccessDecision;
10
+ constructor(decision: AccessDecision);
11
+ }
12
+ export interface SessionGuardOptions extends AccessPaths {
13
+ /**
14
+ * Reads the session for the current request. In Next this is
15
+ * `auth.api.getSession({ headers: await headers() })`; in a test it is a fake.
16
+ */
17
+ getSession: () => Promise<AuthSession | null | undefined>;
18
+ /** `redirect(to)` in Next. Expected to throw; if it returns, `AccessRefused` is thrown. */
19
+ onRedirect?: (to: string, decision: AccessDecision) => void | Promise<void>;
20
+ /** `notFound()` in Next. Same contract. */
21
+ onNotFound?: (decision: AccessDecision) => void | Promise<void>;
22
+ }
23
+ export interface RequireSessionOptions {
24
+ factor?: AccessRequest["factor"];
25
+ role?: AccessRequest["role"];
26
+ /** The route being entered. Omitted in a server action — see `evaluateAccess`. */
27
+ pathname?: string;
28
+ }
29
+ export type RequireSession = (options?: RequireSessionOptions) => Promise<AuthSession>;
30
+ /**
31
+ * `requireSession({ factor, role })` for layouts, server actions and route handlers, bound once
32
+ * per app to a session reader and to whatever this host diverts with.
33
+ */
34
+ export declare function createSessionGuard(options: SessionGuardOptions): RequireSession;
@@ -0,0 +1,42 @@
1
+ import { evaluateAccess, } from "./policy.js";
2
+ /**
3
+ * What a refusal throws when the host's handler returns instead of diverting. Next's
4
+ * `redirect()` and `notFound()` both throw, so in the template this is unreachable — it exists
5
+ * so that `requireSession` cannot return an unauthorized session under a handler that forgets.
6
+ */
7
+ export class AccessRefused extends Error {
8
+ decision;
9
+ constructor(decision) {
10
+ super(decision.outcome === "not-found"
11
+ ? `access refused (${decision.refusal}): not found`
12
+ : decision.outcome === "redirect"
13
+ ? `access refused (${decision.refusal}): redirect to ${decision.to}`
14
+ : "access refused");
15
+ this.name = "AccessRefused";
16
+ this.decision = decision;
17
+ }
18
+ }
19
+ /**
20
+ * `requireSession({ factor, role })` for layouts, server actions and route handlers, bound once
21
+ * per app to a session reader and to whatever this host diverts with.
22
+ */
23
+ export function createSessionGuard(options) {
24
+ return async (required = {}) => {
25
+ const session = await options.getSession();
26
+ const decision = evaluateAccess({
27
+ session,
28
+ pathname: required.pathname,
29
+ factor: required.factor,
30
+ role: required.role,
31
+ signInPath: options.signInPath,
32
+ stepUpPath: options.stepUpPath,
33
+ });
34
+ if (decision.outcome === "allow")
35
+ return decision.session;
36
+ if (decision.outcome === "redirect")
37
+ await options.onRedirect?.(decision.to, decision);
38
+ else
39
+ await options.onNotFound?.(decision);
40
+ throw new AccessRefused(decision);
41
+ };
42
+ }
@@ -0,0 +1,37 @@
1
+ import type { Pool } from "pg";
2
+ import type { RequireSession } from "./require-session.js";
3
+ /** One line per reset; the user has just lost every way in and support will be asked why. */
4
+ export declare const SECOND_FACTOR_RESET_MARKER = "hf-auth: second factor reset";
5
+ export interface ResetSecondFactorOptions {
6
+ userId: string;
7
+ /** The admin who did it. Null only for a reset driven from the CLI. */
8
+ actorId?: string | null;
9
+ reason?: string;
10
+ }
11
+ export interface ResetSecondFactorResult {
12
+ userId: string;
13
+ passkeysRemoved: number;
14
+ sessionsRevoked: number;
15
+ }
16
+ /**
17
+ * Unenrols every passkey a user holds, so someone who lost their device can be let back in
18
+ * through the email code and enrol a new one.
19
+ *
20
+ * It revokes **all** of the user's sessions, not only the passkey-factor ones. A code-factor
21
+ * session is confined to `/auth/*`, but `/auth/*` is exactly where enrolment lives: leaving one
22
+ * alive would let whoever holds the lost device's still-valid session enrol a fresh
23
+ * authenticator and promote itself back to `passkey`, which is the state this reset exists to
24
+ * end. The passkey rows and the sessions go in one transaction for the same reason.
25
+ */
26
+ export declare function resetSecondFactor(pool: Pool, options: ResetSecondFactorOptions): Promise<ResetSecondFactorResult>;
27
+ export interface ResetSecondFactorActionOptions {
28
+ pool: Pool;
29
+ requireSession: RequireSession;
30
+ }
31
+ export type ResetSecondFactorAction = (options: ResetSecondFactorOptions) => Promise<ResetSecondFactorResult>;
32
+ /**
33
+ * The admin action, with its guard attached rather than left to the caller to remember. It
34
+ * takes the actor from the guarded session, so the audit row names who actually did it and not
35
+ * whoever the form said.
36
+ */
37
+ export declare function createResetSecondFactorAction(options: ResetSecondFactorActionOptions): ResetSecondFactorAction;
@@ -0,0 +1,60 @@
1
+ import { ADMIN_ROLE } from "./policy.js";
2
+ /** One line per reset; the user has just lost every way in and support will be asked why. */
3
+ export const SECOND_FACTOR_RESET_MARKER = "hf-auth: second factor reset";
4
+ const DELETE_PASSKEYS_STATEMENT = "DELETE FROM hf_passkey WHERE user_id = $1 RETURNING id";
5
+ const DELETE_SESSIONS_STATEMENT = "DELETE FROM hf_session WHERE user_id = $1 RETURNING id";
6
+ const AUDIT_STATEMENT = "INSERT INTO hf_audit (actor_id, action, target_type, target_id, meta) " +
7
+ "VALUES ($1, 'auth.second_factor_reset', 'user', $2, $3::jsonb)";
8
+ /**
9
+ * Unenrols every passkey a user holds, so someone who lost their device can be let back in
10
+ * through the email code and enrol a new one.
11
+ *
12
+ * It revokes **all** of the user's sessions, not only the passkey-factor ones. A code-factor
13
+ * session is confined to `/auth/*`, but `/auth/*` is exactly where enrolment lives: leaving one
14
+ * alive would let whoever holds the lost device's still-valid session enrol a fresh
15
+ * authenticator and promote itself back to `passkey`, which is the state this reset exists to
16
+ * end. The passkey rows and the sessions go in one transaction for the same reason.
17
+ */
18
+ export async function resetSecondFactor(pool, options) {
19
+ const client = await pool.connect();
20
+ try {
21
+ await client.query("BEGIN");
22
+ const passkeys = await client.query(DELETE_PASSKEYS_STATEMENT, [options.userId]);
23
+ const sessions = await client.query(DELETE_SESSIONS_STATEMENT, [options.userId]);
24
+ await client.query(AUDIT_STATEMENT, [
25
+ options.actorId ?? null,
26
+ options.userId,
27
+ JSON.stringify({
28
+ passkeysRemoved: passkeys.rowCount ?? 0,
29
+ sessionsRevoked: sessions.rowCount ?? 0,
30
+ reason: options.reason ?? null,
31
+ }),
32
+ ]);
33
+ await client.query("COMMIT");
34
+ const result = {
35
+ userId: options.userId,
36
+ passkeysRemoved: passkeys.rowCount ?? 0,
37
+ sessionsRevoked: sessions.rowCount ?? 0,
38
+ };
39
+ console.info(SECOND_FACTOR_RESET_MARKER, JSON.stringify(result));
40
+ return result;
41
+ }
42
+ catch (error) {
43
+ await client.query("ROLLBACK");
44
+ throw error;
45
+ }
46
+ finally {
47
+ client.release();
48
+ }
49
+ }
50
+ /**
51
+ * The admin action, with its guard attached rather than left to the caller to remember. It
52
+ * takes the actor from the guarded session, so the audit row names who actually did it and not
53
+ * whoever the form said.
54
+ */
55
+ export function createResetSecondFactorAction(options) {
56
+ return async (reset) => {
57
+ const session = await options.requireSession({ factor: "passkey", role: ADMIN_ROLE });
58
+ return resetSecondFactor(options.pool, { ...reset, actorId: session.user.id });
59
+ };
60
+ }
@@ -0,0 +1,34 @@
1
+ import { sessionFactors, type SessionFactor } from "@hyperfixation/db";
2
+ export type { SessionFactor };
3
+ export { sessionFactors };
4
+ /** The endpoint that proves possession of an enrolled authenticator. */
5
+ export declare const PASSKEY_AUTHENTICATION_PATH = "/passkey/verify-authentication";
6
+ /** Enrolment. It creates no session, but it does let an existing one be promoted. */
7
+ export declare const PASSKEY_REGISTRATION_PATH = "/passkey/verify-registration";
8
+ /** The email-OTP sign-in endpoint, named here so the stamping rule reads as a pair. */
9
+ export declare const EMAIL_OTP_SIGN_IN_PATH = "/sign-in/email-otp";
10
+ /** What the guard needs of a user; better-auth's own user object is a superset. */
11
+ export interface SessionUser {
12
+ id: string;
13
+ email?: string;
14
+ role?: string | null;
15
+ banned?: boolean | null;
16
+ }
17
+ /** What the guard needs of a session; better-auth's `session` is a superset. */
18
+ export interface AuthSession {
19
+ factor: SessionFactor;
20
+ user: SessionUser;
21
+ }
22
+ /**
23
+ * An **allowlist**: only the passkey authentication endpoint mints a passkey-factor session,
24
+ * and every other path — including one a future plugin adds — gets `code`. The column defaults
25
+ * the same way for the same reason; a session must not reach a passkey-gated check because
26
+ * nobody taught this function about the path that created it.
27
+ */
28
+ export declare function sessionFactorForPath(path: string | null | undefined): SessionFactor;
29
+ /**
30
+ * better-auth's admin plugin stores roles as one comma-separated `text` column, so membership
31
+ * is a list test rather than an equality test. Comparison is case-insensitive because the role
32
+ * arrives from whatever wrote the column — the bootstrap path, an admin UI, or a hand-run SQL.
33
+ */
34
+ export declare function hasRole(user: SessionUser | null | undefined, role: string): boolean;
@@ -0,0 +1,31 @@
1
+ import { sessionFactors } from "@hyperfixation/db";
2
+ export { sessionFactors };
3
+ /** The endpoint that proves possession of an enrolled authenticator. */
4
+ export const PASSKEY_AUTHENTICATION_PATH = "/passkey/verify-authentication";
5
+ /** Enrolment. It creates no session, but it does let an existing one be promoted. */
6
+ export const PASSKEY_REGISTRATION_PATH = "/passkey/verify-registration";
7
+ /** The email-OTP sign-in endpoint, named here so the stamping rule reads as a pair. */
8
+ export const EMAIL_OTP_SIGN_IN_PATH = "/sign-in/email-otp";
9
+ /**
10
+ * An **allowlist**: only the passkey authentication endpoint mints a passkey-factor session,
11
+ * and every other path — including one a future plugin adds — gets `code`. The column defaults
12
+ * the same way for the same reason; a session must not reach a passkey-gated check because
13
+ * nobody taught this function about the path that created it.
14
+ */
15
+ export function sessionFactorForPath(path) {
16
+ return path === PASSKEY_AUTHENTICATION_PATH ? "passkey" : "code";
17
+ }
18
+ /**
19
+ * better-auth's admin plugin stores roles as one comma-separated `text` column, so membership
20
+ * is a list test rather than an equality test. Comparison is case-insensitive because the role
21
+ * arrives from whatever wrote the column — the bootstrap path, an admin UI, or a hand-run SQL.
22
+ */
23
+ export function hasRole(user, role) {
24
+ if (!user?.role)
25
+ return false;
26
+ const wanted = role.trim().toLowerCase();
27
+ return user.role
28
+ .split(",")
29
+ .map((entry) => entry.trim().toLowerCase())
30
+ .includes(wanted);
31
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@hyperfixation/auth",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "description": "better-auth factory, session-factor policy, requireSession, and the bootstrap user",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/grahamlutz/hyperfixation-core.git",
9
+ "directory": "packages/auth"
10
+ },
11
+ "publishConfig": {
12
+ "access": "public"
13
+ },
14
+ "type": "module",
15
+ "types": "./dist/index.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "default": "./dist/index.js"
20
+ }
21
+ },
22
+ "files": [
23
+ "dist",
24
+ "!dist/**/*.test.*"
25
+ ],
26
+ "devDependencies": {
27
+ "@hyperfixation/eslint-config": "0.1.0",
28
+ "@hyperfixation/testing": "0.1.0",
29
+ "@microsoft/api-extractor": "^7.59.1",
30
+ "@types/pg": "^8.23.1",
31
+ "eslint": "^10.10.0"
32
+ },
33
+ "dependencies": {
34
+ "@better-auth/passkey": "1.7.5",
35
+ "@hyperfixation/db": "0.1.0",
36
+ "@simplewebauthn/server": "13.3.3",
37
+ "better-auth": "1.7.5",
38
+ "drizzle-orm": "^0.45.2",
39
+ "pg": "^8.23.0",
40
+ "zod": "4.6.5"
41
+ },
42
+ "scripts": {
43
+ "build": "tsc -p tsconfig.json",
44
+ "typecheck": "tsc -p tsconfig.json --noEmit",
45
+ "lint": "eslint src",
46
+ "api-extractor": "api-extractor run",
47
+ "api-extractor:update": "api-extractor run --local",
48
+ "test": "vitest run --passWithNoTests"
49
+ }
50
+ }