@binary1702/auth 0.2.4 → 0.3.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.
Files changed (49) hide show
  1. package/AGENTS.md +139 -0
  2. package/CHANGELOG.md +31 -0
  3. package/INSTALL.md +30 -17
  4. package/PROMPT.md +325 -37
  5. package/README.md +293 -56
  6. package/dist/cli.js +255 -22
  7. package/dist/core/admin-html.d.ts +26 -0
  8. package/dist/core/admin-html.js +252 -0
  9. package/dist/core/admin.d.ts +26 -0
  10. package/dist/core/admin.js +48 -0
  11. package/dist/core/config.d.ts +8 -1
  12. package/dist/core/config.js +20 -6
  13. package/dist/core/db.d.ts +138 -7
  14. package/dist/core/db.js +406 -47
  15. package/dist/core/email.d.ts +49 -0
  16. package/dist/core/email.js +213 -0
  17. package/dist/core/guard.d.ts +49 -0
  18. package/dist/core/guard.js +103 -0
  19. package/dist/core/html.d.ts +36 -0
  20. package/dist/core/html.js +106 -25
  21. package/dist/core/options.d.ts +76 -0
  22. package/dist/core/options.js +310 -0
  23. package/dist/core/org.d.ts +34 -0
  24. package/dist/core/org.js +55 -0
  25. package/dist/core/password.d.ts +16 -0
  26. package/dist/core/password.js +41 -12
  27. package/dist/core/rate.d.ts +26 -0
  28. package/dist/core/rate.js +70 -0
  29. package/dist/core/reset.d.ts +9 -0
  30. package/dist/core/reset.js +31 -0
  31. package/dist/core/session.d.ts +23 -2
  32. package/dist/core/session.js +45 -7
  33. package/dist/core/signup.d.ts +9 -6
  34. package/dist/core/signup.js +26 -15
  35. package/dist/core/tokens.d.ts +29 -0
  36. package/dist/core/tokens.js +64 -0
  37. package/dist/core/verify.d.ts +8 -0
  38. package/dist/core/verify.js +33 -0
  39. package/dist/core/yaml.d.ts +10 -0
  40. package/dist/core/yaml.js +85 -0
  41. package/dist/index.d.ts +17 -6
  42. package/dist/index.js +17 -6
  43. package/dist/next/index.d.ts +11 -4
  44. package/dist/next/index.js +455 -51
  45. package/dist/next/proxy.d.ts +2 -1
  46. package/dist/next/proxy.js +16 -7
  47. package/dist/react/index.d.ts +18 -0
  48. package/dist/react/index.js +55 -0
  49. package/package.json +16 -3
@@ -0,0 +1,9 @@
1
+ import type { EmailMessage } from "./email.ts";
2
+ export declare const FORGOT_PATH = "/auth/forgot";
3
+ export declare const RESET_PATH = "/auth/reset";
4
+ export declare const RESET_TTL_SECONDS: number;
5
+ export declare const FORGOT_SENT = "If that address has an account, we sent a link to reset the password.";
6
+ export declare const RESET_LINK_INVALID = "This reset link is invalid, already used, or older than an hour.";
7
+ /** The absolute link that goes in the email */
8
+ export declare function resetLink(base: string, rawToken: string): string;
9
+ export declare function resetEmail(to: string, link: string, site: string): EmailMessage;
@@ -0,0 +1,31 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // Password reset. Pure: the link, the email, the constants.
3
+ //
4
+ // POST /auth/forgot always answers "if that address has an account,
5
+ // we sent a link", so it never reveals who exists.
6
+ // When the user exists: a `reset` token (1 h) in
7
+ // an emailed link to /auth/reset?token=…
8
+ // POST /auth/reset consumes the token, sets the password (which
9
+ // bumps session_epoch: every old session dies),
10
+ // marks the email verified (the link proved it),
11
+ // signs the user in.
12
+ // ─────────────────────────────────────────────────────────────
13
+ export const FORGOT_PATH = "/auth/forgot";
14
+ export const RESET_PATH = "/auth/reset";
15
+ export const RESET_TTL_SECONDS = 60 * 60;
16
+ export const FORGOT_SENT = "If that address has an account, we sent a link to reset the password.";
17
+ export const RESET_LINK_INVALID = "This reset link is invalid, already used, or older than an hour.";
18
+ /** The absolute link that goes in the email */
19
+ export function resetLink(base, rawToken) {
20
+ const url = new URL(RESET_PATH, base);
21
+ url.searchParams.set("token", rawToken);
22
+ return url.toString();
23
+ }
24
+ export function resetEmail(to, link, site) {
25
+ return {
26
+ to,
27
+ subject: `Reset your password for ${site}`,
28
+ text: `Someone asked to reset the password for this address on ${site}. If that was you, open the link below. It works once and expires in one hour.\n\n${link}\n\nIf it was not you, ignore this email: nothing changes until the link is used.`,
29
+ html: `<p>Someone asked to reset the password for this address on ${site}. If that was you, open the link below. It works once and expires in one hour.</p><p><a href="${link}">${link}</a></p><p>If it was not you, ignore this email: nothing changes until the link is used.</p>`,
30
+ };
31
+ }
@@ -2,5 +2,26 @@ export type User = {
2
2
  id: string;
3
3
  email: string;
4
4
  };
5
- export declare function signSession(user: User, secret: string): Promise<string>;
6
- export declare function verifySession(token: string, secret: string): Promise<User | null>;
5
+ /** What a valid token says: who, and under which epoch it was signed */
6
+ export type Claim = User & {
7
+ epoch: number;
8
+ };
9
+ export declare const ISSUER = "@binary1702/auth";
10
+ export declare const AUDIENCE = "session";
11
+ /** Tokens issued at or after this moment must carry iss and aud (0.3.0's release day) */
12
+ export declare const CLAIMS_REQUIRED_FROM: number;
13
+ export declare function signSession(user: User & {
14
+ epoch?: number;
15
+ }, secret: string, days?: number): Promise<string>;
16
+ /** The iss/aud rule: required for new tokens, forgiven for old ones. Pure. */
17
+ export declare function claimsAcceptable(payload: {
18
+ iat?: number;
19
+ iss?: string;
20
+ aud?: string | string[];
21
+ }): boolean;
22
+ export declare function verifySession(token: string, secret: string): Promise<Claim | null>;
23
+ /**
24
+ * The revocation rule. `epoch` is the user's current session_epoch,
25
+ * or null when the user no longer exists.
26
+ */
27
+ export declare function isCurrent(claim: Claim, epoch: number | null): boolean;
@@ -1,29 +1,67 @@
1
1
  // ─────────────────────────────────────────────────────────────
2
2
  // Session token. A signed JWT (HS256) carried in an httpOnly cookie.
3
3
  //
4
- // Stateless: verifying needs the secret only, never the database.
5
- // That is what lets the proxy run on every request at zero cost.
6
- // Trade-off: a token cannot be revoked before it expires (7 days).
4
+ // Two gates, two costs:
5
+ // signature verifySession() needs the secret only, never the
6
+ // database. The proxy runs this on every request, free.
7
+ // epoch isCurrent() the token carries the user's
8
+ // session_epoch from when it was signed. getSession()
9
+ // looks the user up and compares. Deleting the user (no
10
+ // row) or bumping the epoch (password change, sign-out-
11
+ // everywhere) makes every older token stop matching.
12
+ //
13
+ // So a revoked token still passes the proxy and loads a page shell,
14
+ // but getSession()/requireSession() say no, and those guard the data.
15
+ //
16
+ // Since 0.3.0 tokens carry iss and aud, so a token minted by another
17
+ // app that happens to share the secret is refused. Tokens from before
18
+ // have neither; they are accepted only if signed before the cutoff.
7
19
  // ─────────────────────────────────────────────────────────────
8
20
  import { SignJWT, jwtVerify } from "jose";
9
21
  import { SESSION_DAYS } from "./config.js";
22
+ export const ISSUER = "@binary1702/auth";
23
+ export const AUDIENCE = "session";
24
+ /** Tokens issued at or after this moment must carry iss and aud (0.3.0's release day) */
25
+ export const CLAIMS_REQUIRED_FROM = Date.UTC(2026, 9, 22) / 1000;
10
26
  const key = (secret) => new TextEncoder().encode(secret);
11
- export async function signSession(user, secret) {
12
- return new SignJWT({ email: user.email })
27
+ export async function signSession(user, secret, days = SESSION_DAYS) {
28
+ return new SignJWT({ email: user.email, ep: user.epoch ?? 0 })
13
29
  .setProtectedHeader({ alg: "HS256" })
14
30
  .setSubject(user.id)
31
+ .setIssuer(ISSUER)
32
+ .setAudience(AUDIENCE)
15
33
  .setIssuedAt()
16
- .setExpirationTime(`${SESSION_DAYS}d`)
34
+ .setExpirationTime(`${days}d`)
17
35
  .sign(key(secret));
18
36
  }
37
+ /** The iss/aud rule: required for new tokens, forgiven for old ones. Pure. */
38
+ export function claimsAcceptable(payload) {
39
+ const aud = Array.isArray(payload.aud) ? payload.aud : payload.aud ? [payload.aud] : [];
40
+ const has = payload.iss === ISSUER && aud.includes(AUDIENCE);
41
+ if (has)
42
+ return true;
43
+ return typeof payload.iat === "number" && payload.iat < CLAIMS_REQUIRED_FROM;
44
+ }
19
45
  export async function verifySession(token, secret) {
20
46
  try {
21
47
  const { payload } = await jwtVerify(token, key(secret), { algorithms: ["HS256"] });
22
48
  if (typeof payload.sub !== "string" || typeof payload.email !== "string")
23
49
  return null;
24
- return { id: payload.sub, email: payload.email };
50
+ if (!claimsAcceptable(payload))
51
+ return null;
52
+ // Tokens from before epochs existed carry no `ep`: they count as 0,
53
+ // so upgrading the package signs nobody out
54
+ const epoch = typeof payload.ep === "number" ? payload.ep : 0;
55
+ return { id: payload.sub, email: payload.email, epoch };
25
56
  }
26
57
  catch {
27
58
  return null;
28
59
  }
29
60
  }
61
+ /**
62
+ * The revocation rule. `epoch` is the user's current session_epoch,
63
+ * or null when the user no longer exists.
64
+ */
65
+ export function isCurrent(claim, epoch) {
66
+ return epoch !== null && claim.epoch === epoch;
67
+ }
@@ -1,3 +1,4 @@
1
+ import type { SignupMode } from "./options.ts";
1
2
  export type SignupPolicy = {
2
3
  /** Can anyone reach the sign-up page right now? */
3
4
  allowed: boolean;
@@ -6,12 +7,14 @@ export type SignupPolicy = {
6
7
  /** Why it is allowed. "first" skips the invite code: there is nobody to invite you. */
7
8
  reason: "open" | "first" | "closed";
8
9
  };
9
- export declare function signupPolicy(env: {
10
- AUTH_SIGNUP?: string;
11
- AUTH_INVITE_CODE?: string;
12
- }, userCount: number): SignupPolicy;
10
+ export declare function signupPolicy(mode: SignupMode, userCount: number): SignupPolicy;
13
11
  export declare const EMAIL_RE: RegExp;
12
+ export declare const PASSWORD_MIN = 8;
13
+ /** scrypt on a megabyte of password is a denial of service; cap before hashing */
14
+ export declare const PASSWORD_MAX = 256;
15
+ /** Returns an error message, or null when the password is acceptable. */
16
+ export declare function validatePassword(password: string): string | null;
14
17
  /** Returns an error message, or null when the pair is acceptable. */
15
- export declare function validateCredentials(email: string, password: string, env?: {
16
- AUTH_EMAIL_DOMAIN?: string;
18
+ export declare function validateCredentials(email: string, password: string, opts?: {
19
+ emailDomain?: string;
17
20
  }): string | null;
@@ -1,30 +1,41 @@
1
1
  // ─────────────────────────────────────────────────────────────
2
- // Sign-up policy. One pure function, so it can be tested and read.
2
+ // Sign-up policy. Pure functions, so they can be tested and read.
3
3
  //
4
- // AUTH_SIGNUP=open anyone may register at /auth/sign-up
5
- // AUTH_INVITE_CODE=<code> with "open": the form also needs this code
6
- // (unset) closed, EXCEPT when there are no users yet,
7
- // so a fresh install can create its first
8
- // account in the browser instead of the CLI
4
+ // signup: open anyone may register at /auth/sign-up
5
+ // signup: invite anyone who knows inviteCode
6
+ // signup: closed nobody, EXCEPT when there are no users yet, so a
7
+ // fresh install can create its first account in the
8
+ // browser instead of the CLI
9
+ // (b17_auth.yaml keys; AUTH_SIGNUP=open and AUTH_INVITE_CODE map onto them)
9
10
  // ─────────────────────────────────────────────────────────────
10
- export function signupPolicy(env, userCount) {
11
+ export function signupPolicy(mode, userCount) {
11
12
  if (userCount === 0)
12
13
  return { allowed: true, inviteRequired: false, reason: "first" };
13
- if (env.AUTH_SIGNUP === "open") {
14
- return { allowed: true, inviteRequired: Boolean(env.AUTH_INVITE_CODE), reason: "open" };
15
- }
14
+ if (mode === "open")
15
+ return { allowed: true, inviteRequired: false, reason: "open" };
16
+ if (mode === "invite")
17
+ return { allowed: true, inviteRequired: true, reason: "open" };
16
18
  return { allowed: false, inviteRequired: false, reason: "closed" };
17
19
  }
18
20
  export const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
21
+ export const PASSWORD_MIN = 8;
22
+ /** scrypt on a megabyte of password is a denial of service; cap before hashing */
23
+ export const PASSWORD_MAX = 256;
24
+ /** Returns an error message, or null when the password is acceptable. */
25
+ export function validatePassword(password) {
26
+ if (password.length < PASSWORD_MIN)
27
+ return `Password must be at least ${PASSWORD_MIN} characters.`;
28
+ if (password.length > PASSWORD_MAX)
29
+ return `Password must be at most ${PASSWORD_MAX} characters.`;
30
+ return null;
31
+ }
19
32
  /** Returns an error message, or null when the pair is acceptable. */
20
- export function validateCredentials(email, password, env) {
33
+ export function validateCredentials(email, password, opts) {
21
34
  if (!EMAIL_RE.test(email))
22
35
  return "Please enter a valid email address.";
23
- const domain = env?.AUTH_EMAIL_DOMAIN;
36
+ const domain = opts?.emailDomain;
24
37
  if (domain && !email.toLowerCase().endsWith(`@${domain.toLowerCase()}`)) {
25
38
  return `Only @${domain} emails are allowed.`;
26
39
  }
27
- if (password.length < 8)
28
- return "Password must be at least 8 characters.";
29
- return null;
40
+ return validatePassword(password);
30
41
  }
@@ -0,0 +1,29 @@
1
+ import { type Db } from "./db.ts";
2
+ export type TokenKind = "verify" | "reset" | "magic" | "invite";
3
+ /** What a consumed token tells the handler */
4
+ export type Token = {
5
+ id: string;
6
+ userId: string;
7
+ kind: TokenKind;
8
+ meta: Record<string, unknown>;
9
+ };
10
+ export type TokenRow = {
11
+ id: string;
12
+ user_id: string;
13
+ kind: TokenKind;
14
+ token_hash: string;
15
+ expires_at: string | Date;
16
+ used_at: string | Date | null;
17
+ meta: Record<string, unknown> | null;
18
+ };
19
+ /** 256 bits of randomness, URL-safe: 43 characters */
20
+ export declare function newRawToken(): string;
21
+ export declare function hashToken(raw: string): string;
22
+ /** Constant-time equality of two hashes, false on a length mismatch instead of throwing */
23
+ export declare function sameHash(a: string, b: string): boolean;
24
+ /** Why a stored token may or may not be consumed right now */
25
+ export declare function tokenState(row: Pick<TokenRow, "expires_at" | "used_at">, now?: Date): "ok" | "used" | "expired";
26
+ /** Create a token for the user and return the raw value to put in a link. */
27
+ export declare function issueToken(userId: string, kind: TokenKind, ttlSeconds: number, meta?: Record<string, unknown>, store?: Db): Promise<string>;
28
+ /** Redeem a raw token of the given kind. Null for anything but a live, unused token; works once. */
29
+ export declare function consumeToken(raw: string, kind: TokenKind, store?: Db, now?: Date): Promise<Token | null>;
@@ -0,0 +1,64 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // One-time tokens: the thing in an emailed link.
3
+ //
4
+ // issueToken() 32 random bytes → base64url. Only the SHA-256 is
5
+ // stored, so a database leak gives away no live link.
6
+ // consumeToken() returns the row once: unknown, expired, used, or
7
+ // wrong kind all come back null, and the claim is one
8
+ // UPDATE ... WHERE used_at IS NULL, so two clicks at
9
+ // the same instant cannot both succeed.
10
+ //
11
+ // Kinds in use: verify (email), reset (password).
12
+ // Reserved for later versions: magic (sign-in link), invite (org).
13
+ //
14
+ // The rules are pure functions; the store is injected (see core/db.ts).
15
+ // ─────────────────────────────────────────────────────────────
16
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
17
+ import { db, ensureSchema } from "./db.js";
18
+ // ── pure ──────────────────────────────────────────────────────
19
+ /** 256 bits of randomness, URL-safe: 43 characters */
20
+ export function newRawToken() {
21
+ return randomBytes(32).toString("base64url");
22
+ }
23
+ export function hashToken(raw) {
24
+ return createHash("sha256").update(raw).digest("hex");
25
+ }
26
+ /** Constant-time equality of two hashes, false on a length mismatch instead of throwing */
27
+ export function sameHash(a, b) {
28
+ const x = Buffer.from(a);
29
+ const y = Buffer.from(b);
30
+ return x.length === y.length && timingSafeEqual(x, y);
31
+ }
32
+ /** Why a stored token may or may not be consumed right now */
33
+ export function tokenState(row, now = new Date()) {
34
+ if (row.used_at)
35
+ return "used";
36
+ if (new Date(row.expires_at).getTime() <= now.getTime())
37
+ return "expired";
38
+ return "ok";
39
+ }
40
+ // ── store ─────────────────────────────────────────────────────
41
+ /** Create a token for the user and return the raw value to put in a link. */
42
+ export async function issueToken(userId, kind, ttlSeconds, meta = {}, store = db()) {
43
+ await ensureSchema(store);
44
+ const raw = newRawToken();
45
+ const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString();
46
+ await store.query(`INSERT INTO tokens (user_id, kind, token_hash, expires_at, meta) VALUES ($1, $2, $3, $4, $5::jsonb)`, [userId, kind, hashToken(raw), expiresAt, JSON.stringify(meta)]);
47
+ // Housekeeping: rows nobody can use any more
48
+ await store.query(`DELETE FROM tokens WHERE expires_at < now() - interval '7 days'`);
49
+ return raw;
50
+ }
51
+ /** Redeem a raw token of the given kind. Null for anything but a live, unused token; works once. */
52
+ export async function consumeToken(raw, kind, store = db(), now = new Date()) {
53
+ if (!/^[A-Za-z0-9_-]{43}$/.test(raw))
54
+ return null;
55
+ await ensureSchema(store);
56
+ const hash = hashToken(raw);
57
+ const [row] = await store.query(`SELECT id, user_id, kind, token_hash, expires_at, used_at, meta FROM tokens WHERE token_hash = $1 AND kind = $2`, [hash, kind]);
58
+ if (!row || !sameHash(hash, row.token_hash) || tokenState(row, now) !== "ok")
59
+ return null;
60
+ const claimed = await store.query(`UPDATE tokens SET used_at = now() WHERE id = $1 AND used_at IS NULL RETURNING id`, [row.id]);
61
+ if (claimed.length === 0)
62
+ return null;
63
+ return { id: row.id, userId: row.user_id, kind: row.kind, meta: row.meta ?? {} };
64
+ }
@@ -0,0 +1,8 @@
1
+ import type { EmailMessage } from "./email.ts";
2
+ export declare const VERIFY_PATH = "/auth/verify";
3
+ export declare const VERIFY_TTL_SECONDS: number;
4
+ /** The gate: may a user with this verification state have a session? */
5
+ export declare function mayAccess(verified: boolean, requireVerified: boolean): boolean;
6
+ /** The absolute link that goes in the email */
7
+ export declare function verifyLink(base: string, rawToken: string, next?: string): string;
8
+ export declare function verifyEmail(to: string, link: string, site: string): EmailMessage;
@@ -0,0 +1,33 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // Email verification policy and the email itself. Pure.
3
+ //
4
+ // requireVerified: true (b17_auth.yaml, or AUTH_REQUIRE_VERIFIED=true)
5
+ // an unverified user gets no session: getUser()
6
+ // is null, sign-in says "check your email".
7
+ // Default off, so existing installs change nothing.
8
+ //
9
+ // Sign-up always issues a `verify` token (24 h) and emails the link;
10
+ // the flag only decides whether that gate is closed meanwhile.
11
+ // ─────────────────────────────────────────────────────────────
12
+ export const VERIFY_PATH = "/auth/verify";
13
+ export const VERIFY_TTL_SECONDS = 24 * 60 * 60;
14
+ /** The gate: may a user with this verification state have a session? */
15
+ export function mayAccess(verified, requireVerified) {
16
+ return verified || !requireVerified;
17
+ }
18
+ /** The absolute link that goes in the email */
19
+ export function verifyLink(base, rawToken, next) {
20
+ const url = new URL(VERIFY_PATH, base);
21
+ url.searchParams.set("token", rawToken);
22
+ if (next && next !== "/")
23
+ url.searchParams.set("next", next);
24
+ return url.toString();
25
+ }
26
+ export function verifyEmail(to, link, site) {
27
+ return {
28
+ to,
29
+ subject: `Verify your email for ${site}`,
30
+ text: `Confirm this is your address by opening the link below. It works once and expires in 24 hours.\n\n${link}\n\nIf you did not create an account, ignore this email.`,
31
+ html: `<p>Confirm this is your address by opening the link below. It works once and expires in 24 hours.</p><p><a href="${link}">${link}</a></p><p>If you did not create an account, ignore this email.</p>`,
32
+ };
33
+ }
@@ -0,0 +1,10 @@
1
+ export type YamlTree = {
2
+ [key: string]: string | YamlTree;
3
+ };
4
+ export declare class YamlError extends Error {
5
+ line: number;
6
+ constructor(message: string, line: number);
7
+ }
8
+ export declare function parseYaml(text: string): YamlTree;
9
+ /** Words of a space-separated value. "" → []. */
10
+ export declare function words(value: string | undefined): string[];
@@ -0,0 +1,85 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // The subset of YAML that b17_auth.yaml uses. No dependency.
3
+ //
4
+ // key: value a string (quotes optional; "…" or '…' strip them)
5
+ // key: a section; children indented deeper than the key
6
+ // # … a comment, whole line or after a value
7
+ // no arrays, no anchors, no multi-line scalars. Lists are words
8
+ // separated by spaces, split by whoever reads the key.
9
+ //
10
+ // Everything is a string or a section. Typing happens in options.ts,
11
+ // which knows what each key means. Pure.
12
+ // ─────────────────────────────────────────────────────────────
13
+ export class YamlError extends Error {
14
+ line;
15
+ constructor(message, line) {
16
+ super(`b17_auth.yaml line ${line}: ${message}`);
17
+ this.name = "YamlError";
18
+ this.line = line;
19
+ }
20
+ }
21
+ /** Strip a trailing comment, respecting quotes. */
22
+ function stripComment(s) {
23
+ let quote = null;
24
+ for (let i = 0; i < s.length; i++) {
25
+ const c = s[i];
26
+ if (quote) {
27
+ if (c === quote)
28
+ quote = null;
29
+ }
30
+ else if (c === '"' || c === "'") {
31
+ quote = c;
32
+ }
33
+ else if (c === "#" && (i === 0 || /\s/.test(s[i - 1]))) {
34
+ return s.slice(0, i);
35
+ }
36
+ }
37
+ return s;
38
+ }
39
+ function unquote(v) {
40
+ const m = v.match(/^(["'])(.*)\1$/s);
41
+ return m ? m[2] : v;
42
+ }
43
+ export function parseYaml(text) {
44
+ const root = {};
45
+ // The stack of open sections with the indentation of their keys
46
+ const stack = [{ indent: -1, node: root }];
47
+ const lines = text.split(/\r?\n/);
48
+ for (let i = 0; i < lines.length; i++) {
49
+ const lineNo = i + 1;
50
+ const raw = lines[i];
51
+ if (raw.includes("\t"))
52
+ throw new YamlError("tabs are not allowed; indent with spaces", lineNo);
53
+ const content = stripComment(raw).trimEnd();
54
+ if (!content.trim())
55
+ continue;
56
+ const indent = content.length - content.trimStart().length;
57
+ const body = content.trim();
58
+ if (body.startsWith("- "))
59
+ throw new YamlError("lists use spaces between items, not dashes", lineNo);
60
+ const m = body.match(/^([^\s:#][^:]*?)\s*:(?:\s+(.*))?$/);
61
+ if (!m)
62
+ throw new YamlError(`expected "key: value" or "key:", got "${body}"`, lineNo);
63
+ const key = unquote(m[1].trim());
64
+ const value = m[2]?.trim();
65
+ // Close sections that this line is not inside of
66
+ while (stack.length > 1 && indent <= stack[stack.length - 1].indent)
67
+ stack.pop();
68
+ const parent = stack[stack.length - 1].node;
69
+ if (key in parent)
70
+ throw new YamlError(`duplicate key "${key}"`, lineNo);
71
+ if (value === undefined || value === "") {
72
+ const section = {};
73
+ parent[key] = section;
74
+ stack.push({ indent, node: section });
75
+ }
76
+ else {
77
+ parent[key] = unquote(value);
78
+ }
79
+ }
80
+ return root;
81
+ }
82
+ /** Words of a space-separated value. "" → []. */
83
+ export function words(value) {
84
+ return (value ?? "").split(/\s+/).filter(Boolean);
85
+ }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,17 @@
1
- export { hashPassword, verifyPassword } from "./core/password.ts";
2
- export { signSession, verifySession, type User } from "./core/session.ts";
3
- export { ensureSchema, findUser, createUser, countUsers, listUsers, setPassword } from "./core/db.ts";
4
- export { signInPage, signUpPage } from "./core/html.ts";
5
- export { signupPolicy, validateCredentials, type SignupPolicy } from "./core/signup.ts";
6
- export { COOKIE_NAME, SESSION_DAYS, SIGN_IN_PATH, SIGN_OUT_PATH, SIGN_UP_PATH, getSecret, getDatabaseUrl } from "./core/config.ts";
1
+ export { hashPassword, verifyPassword, needsRehash, parseHash, SCRYPT_N } from "./core/password.ts";
2
+ export { isCurrent, signSession, verifySession, claimsAcceptable, ISSUER, AUDIENCE, CLAIMS_REQUIRED_FROM, type Claim, type User } from "./core/session.ts";
3
+ export { db, ensureSchema, pendingSteps, MIGRATIONS, findUser, createUser, countUsers, listUsers, setPassword, setPasswordById, sessionEpoch, userState, sessionRow, markVerified, revokeSessions, removeUser, createOrg, findOrg, listOrgs, addMember, removeMember, removeOrg, adminUsers, adminOrgs, setActiveOrg, type Db, type UserState, type SessionRow, type OrgRow, type Migration, type Statement, type UserRow, } from "./core/db.ts";
4
+ export { signInPage, signUpPage, forgotPage, resetPage, messagePage, securityHeaders } from "./core/html.ts";
5
+ export { resetLink, resetEmail, FORGOT_PATH, RESET_PATH, RESET_TTL_SECONDS } from "./core/reset.ts";
6
+ export { mayAccess, verifyLink, verifyEmail, VERIFY_PATH, VERIFY_TTL_SECONDS } from "./core/verify.ts";
7
+ export { options, loadOptions, resolveOptions, resetOptions, literalSecrets, CONFIG_FILE, DEFAULTS, type Options, type EmailOptions, type SignupMode, } from "./core/options.ts";
8
+ export { parseYaml, YamlError, type YamlTree } from "./core/yaml.ts";
9
+ export { isAdmin, refusedOnSelf, roleChoices, ADMIN_PATH } from "./core/admin.ts";
10
+ export { rateLimit, decide, clientIp, bucketKey, retryMessage, LIMITS, type Bucket, type Decision } from "./core/rate.ts";
11
+ export { adminPage } from "./core/admin-html.ts";
12
+ export { buildSession, hasPermission, permissionsFor, NO_SESSION, type Session, type Organization, type Authorization, type PermissionSource, } from "./core/org.ts";
13
+ export { sendEmail, emailTransport, type EmailMessage, type Transport } from "./core/email.ts";
14
+ export { issueToken, consumeToken, hashToken, tokenState, type Token, type TokenKind } from "./core/tokens.ts";
15
+ export { signupPolicy, validateCredentials, validatePassword, PASSWORD_MIN, PASSWORD_MAX, type SignupPolicy } from "./core/signup.ts";
16
+ export { safeNext, safeEqual, isPublic, matchesPattern, isSameOrigin, MATCHER } from "./core/guard.ts";
17
+ export { COOKIE_NAME, cookieName, SESSION_DAYS, SIGN_IN_PATH, SIGN_OUT_PATH, SIGN_UP_PATH, getSecret, getDatabaseUrl } from "./core/config.ts";
package/dist/index.js CHANGED
@@ -1,8 +1,19 @@
1
1
  // Core primitives. Framework-free. Use these if you are not on Next.js
2
2
  // or want to build your own handlers.
3
- export { hashPassword, verifyPassword } from "./core/password.js";
4
- export { signSession, verifySession } from "./core/session.js";
5
- export { ensureSchema, findUser, createUser, countUsers, listUsers, setPassword } from "./core/db.js";
6
- export { signInPage, signUpPage } from "./core/html.js";
7
- export { signupPolicy, validateCredentials } from "./core/signup.js";
8
- export { COOKIE_NAME, SESSION_DAYS, SIGN_IN_PATH, SIGN_OUT_PATH, SIGN_UP_PATH, getSecret, getDatabaseUrl } from "./core/config.js";
3
+ export { hashPassword, verifyPassword, needsRehash, parseHash, SCRYPT_N } from "./core/password.js";
4
+ export { isCurrent, signSession, verifySession, claimsAcceptable, ISSUER, AUDIENCE, CLAIMS_REQUIRED_FROM } from "./core/session.js";
5
+ export { db, ensureSchema, pendingSteps, MIGRATIONS, findUser, createUser, countUsers, listUsers, setPassword, setPasswordById, sessionEpoch, userState, sessionRow, markVerified, revokeSessions, removeUser, createOrg, findOrg, listOrgs, addMember, removeMember, removeOrg, adminUsers, adminOrgs, setActiveOrg, } from "./core/db.js";
6
+ export { signInPage, signUpPage, forgotPage, resetPage, messagePage, securityHeaders } from "./core/html.js";
7
+ export { resetLink, resetEmail, FORGOT_PATH, RESET_PATH, RESET_TTL_SECONDS } from "./core/reset.js";
8
+ export { mayAccess, verifyLink, verifyEmail, VERIFY_PATH, VERIFY_TTL_SECONDS } from "./core/verify.js";
9
+ export { options, loadOptions, resolveOptions, resetOptions, literalSecrets, CONFIG_FILE, DEFAULTS, } from "./core/options.js";
10
+ export { parseYaml, YamlError } from "./core/yaml.js";
11
+ export { isAdmin, refusedOnSelf, roleChoices, ADMIN_PATH } from "./core/admin.js";
12
+ export { rateLimit, decide, clientIp, bucketKey, retryMessage, LIMITS } from "./core/rate.js";
13
+ export { adminPage } from "./core/admin-html.js";
14
+ export { buildSession, hasPermission, permissionsFor, NO_SESSION, } from "./core/org.js";
15
+ export { sendEmail, emailTransport } from "./core/email.js";
16
+ export { issueToken, consumeToken, hashToken, tokenState } from "./core/tokens.js";
17
+ export { signupPolicy, validateCredentials, validatePassword, PASSWORD_MIN, PASSWORD_MAX } from "./core/signup.js";
18
+ export { safeNext, safeEqual, isPublic, matchesPattern, isSameOrigin, MATCHER } from "./core/guard.js";
19
+ export { COOKIE_NAME, cookieName, SESSION_DAYS, SIGN_IN_PATH, SIGN_OUT_PATH, SIGN_UP_PATH, getSecret, getDatabaseUrl } from "./core/config.js";
@@ -1,15 +1,22 @@
1
1
  import { NextResponse, type NextRequest } from "next/server.js";
2
2
  import { type User } from "../core/session.ts";
3
- export type { User };
3
+ import { type Authorization, type Organization, type Session } from "../core/org.ts";
4
+ export type { User, Session, Organization, Authorization };
4
5
  export declare class AuthError extends Error {
5
6
  status: number;
6
- constructor(message?: string);
7
+ constructor(message?: string, status?: number);
7
8
  }
8
- /** Current user from the session cookie, or null. Never touches the database. */
9
+ /** The session contract for the current request, or null if signed out, deleted or revoked. */
10
+ export declare function getSession(): Promise<Session | null>;
11
+ /** The session, or throw AuthError (status 401). */
12
+ export declare function requireSession(): Promise<Session>;
13
+ /** The session, or throw AuthError: 401 when signed out, 403 when the role lacks the permission. */
14
+ export declare function requirePermission(permission: string): Promise<Session>;
15
+ /** Current user from the session cookie, or null if signed out, deleted or revoked. */
9
16
  export declare function getUser(): Promise<User | null>;
10
17
  /** Current user, or throw AuthError (status 401). */
11
18
  export declare function requireUser(): Promise<User>;
12
- /** A 401 JSON response for catch blocks: `catch (e) { return unauthorized(e) }` */
19
+ /** A 401/403 JSON response for catch blocks: `catch (e) { return unauthorized(e) }` */
13
20
  export declare function unauthorized(err?: unknown): NextResponse;
14
21
  type Ctx = {
15
22
  params: Promise<{