@binary1702/auth 0.2.5 → 0.4.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 (53) hide show
  1. package/AGENTS.md +155 -0
  2. package/CHANGELOG.md +49 -0
  3. package/INSTALL.md +27 -16
  4. package/PROMPT.md +325 -36
  5. package/README.md +338 -53
  6. package/dist/cli.js +248 -17
  7. package/dist/core/admin-html.d.ts +32 -0
  8. package/dist/core/admin-html.js +350 -0
  9. package/dist/core/admin.d.ts +56 -0
  10. package/dist/core/admin.js +90 -0
  11. package/dist/core/config.d.ts +10 -3
  12. package/dist/core/config.js +22 -8
  13. package/dist/core/db.d.ts +155 -10
  14. package/dist/core/db.js +449 -69
  15. package/dist/core/email.d.ts +49 -0
  16. package/dist/core/email.js +213 -0
  17. package/dist/core/guard.d.ts +52 -0
  18. package/dist/core/guard.js +106 -0
  19. package/dist/core/html.d.ts +43 -0
  20. package/dist/core/html.js +122 -28
  21. package/dist/core/invite.d.ts +7 -0
  22. package/dist/core/invite.js +30 -0
  23. package/dist/core/options.d.ts +95 -0
  24. package/dist/core/options.js +377 -0
  25. package/dist/core/org.d.ts +43 -0
  26. package/dist/core/org.js +65 -0
  27. package/dist/core/pages.d.ts +34 -0
  28. package/dist/core/pages.js +73 -0
  29. package/dist/core/password.d.ts +16 -0
  30. package/dist/core/password.js +41 -12
  31. package/dist/core/rate.d.ts +26 -0
  32. package/dist/core/rate.js +70 -0
  33. package/dist/core/reset.d.ts +9 -0
  34. package/dist/core/reset.js +31 -0
  35. package/dist/core/session.d.ts +11 -1
  36. package/dist/core/session.js +27 -7
  37. package/dist/core/signup.d.ts +13 -6
  38. package/dist/core/signup.js +30 -15
  39. package/dist/core/tokens.d.ts +29 -0
  40. package/dist/core/tokens.js +64 -0
  41. package/dist/core/verify.d.ts +8 -0
  42. package/dist/core/verify.js +33 -0
  43. package/dist/core/yaml.d.ts +10 -0
  44. package/dist/core/yaml.js +85 -0
  45. package/dist/index.d.ts +18 -6
  46. package/dist/index.js +18 -6
  47. package/dist/next/index.d.ts +10 -3
  48. package/dist/next/index.js +649 -63
  49. package/dist/next/proxy.d.ts +2 -1
  50. package/dist/next/proxy.js +22 -10
  51. package/dist/react/index.d.ts +72 -0
  52. package/dist/react/index.js +107 -0
  53. package/package.json +18 -4
package/dist/core/db.js CHANGED
@@ -1,105 +1,485 @@
1
1
  // ─────────────────────────────────────────────────────────────
2
- // Storage. One table, owned by this package.
2
+ // Storage. Tables owned by this package, created by migrations.
3
3
  //
4
- // auth_users (id, email, password_hash, created_at, session_epoch)
4
+ // auth_migrations (id, applied_at) which steps have run
5
+ // users, orgs, members, tokens see each migration step below
5
6
  //
6
7
  // session_epoch: every session token carries the value it was signed
7
8
  // under, and is only good while they match (see core/session.ts).
8
9
  // +1 revokes every session of that user; deleting the row revokes
9
10
  // them too, since there is nothing left to match.
10
11
  //
11
- // ensureSchema() is idempotent and memoized per process, so route
12
- // handlers can call it on every request for free after the first.
12
+ // Every function takes the store as its last argument and defaults
13
+ // to the real database, so handler logic can run against a fake in
14
+ // tests. ensureSchema() applies pending migrations once per process
15
+ // and never re-runs a completed one.
13
16
  // ─────────────────────────────────────────────────────────────
14
17
  import { neon } from "@neondatabase/serverless";
15
18
  import { getDatabaseUrl, TABLE } from "./config.js";
16
- let _sql = null;
17
- function sql() {
18
- return (_sql ??= neon(getDatabaseUrl()));
19
- }
20
- let _schema = null;
21
- export function ensureSchema() {
22
- return (_schema ??= (async () => {
23
- await sql() `
24
- CREATE TABLE IF NOT EXISTS auth_users (
19
+ let _db = null;
20
+ /** The real store, created once per process from DATABASE_URL. */
21
+ export function db() {
22
+ if (_db)
23
+ return _db;
24
+ const sql = neon(getDatabaseUrl());
25
+ return (_db = {
26
+ query: (text, params = []) => sql.query(text, params),
27
+ transaction: async (statements) => {
28
+ await sql.transaction(statements.map((s) => sql.query(s.text, s.params ?? [])));
29
+ },
30
+ });
31
+ }
32
+ async function runAll(store, statements) {
33
+ if (store.transaction)
34
+ return store.transaction(statements);
35
+ for (const s of statements)
36
+ await store.query(s.text, s.params);
37
+ }
38
+ /**
39
+ * Ordered, append-only. Never edit a step that has shipped: add a new
40
+ * one. Step 1 is the 0.2.5 schema exactly, so an existing install
41
+ * applies it as a no-op through IF NOT EXISTS.
42
+ */
43
+ export const MIGRATIONS = [
44
+ {
45
+ id: 1,
46
+ name: "users",
47
+ statements: [
48
+ `CREATE TABLE IF NOT EXISTS users (
25
49
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
26
50
  email TEXT NOT NULL UNIQUE,
27
51
  password_hash TEXT NOT NULL,
28
- created_at TIMESTAMPTZ NOT NULL DEFAULT now()
29
- )
30
- `;
31
- // Added in 0.2.5: installs from before get it on their next start
32
- await sql() `
33
- ALTER TABLE auth_users ADD COLUMN IF NOT EXISTS session_epoch INT NOT NULL DEFAULT 0
34
- `;
35
- })().catch((err) => {
36
- _schema = null; // let the next call retry
52
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
53
+ session_epoch INT NOT NULL DEFAULT 0
54
+ )`,
55
+ // Installs from before 0.2.5 have the table without this column
56
+ `ALTER TABLE users ADD COLUMN IF NOT EXISTS session_epoch INT NOT NULL DEFAULT 0`,
57
+ ],
58
+ },
59
+ {
60
+ id: 2,
61
+ name: "tokens",
62
+ statements: [
63
+ // One-time tokens: only the SHA-256 of the token is stored (see core/tokens.ts)
64
+ `CREATE TABLE IF NOT EXISTS tokens (
65
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
66
+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
67
+ kind TEXT NOT NULL,
68
+ token_hash TEXT NOT NULL UNIQUE,
69
+ expires_at TIMESTAMPTZ NOT NULL,
70
+ used_at TIMESTAMPTZ,
71
+ meta JSONB NOT NULL DEFAULT '{}'::jsonb,
72
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
73
+ )`,
74
+ `CREATE INDEX IF NOT EXISTS tokens_user_kind ON tokens (user_id, kind)`,
75
+ ],
76
+ },
77
+ {
78
+ id: 3,
79
+ name: "email_verified",
80
+ statements: [
81
+ // NULL until the user follows the link we emailed (see core/verify.ts).
82
+ // Existing users stay NULL; nothing changes for them unless
83
+ // AUTH_REQUIRE_VERIFIED is turned on.
84
+ `ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ`,
85
+ ],
86
+ },
87
+ {
88
+ id: 4,
89
+ name: "organizations",
90
+ statements: [
91
+ // A user belongs to organizations through members, with one
92
+ // role per membership. The session carries one "active" org:
93
+ // active_org_id when still a member, else the earliest membership.
94
+ // See core/org.ts.
95
+ `CREATE TABLE IF NOT EXISTS orgs (
96
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
97
+ name TEXT NOT NULL,
98
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
99
+ )`,
100
+ `CREATE TABLE IF NOT EXISTS members (
101
+ org_id UUID NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
102
+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
103
+ role TEXT NOT NULL DEFAULT 'member',
104
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
105
+ PRIMARY KEY (org_id, user_id)
106
+ )`,
107
+ `CREATE INDEX IF NOT EXISTS members_user ON members (user_id, created_at)`,
108
+ `ALTER TABLE users ADD COLUMN IF NOT EXISTS active_org_id UUID REFERENCES orgs(id) ON DELETE SET NULL`,
109
+ ],
110
+ },
111
+ {
112
+ id: 5,
113
+ name: "drop_auth_prefix",
114
+ statements: [
115
+ // Installs from before this step have auth_users, auth_orgs, …; a
116
+ // fresh install created the new names in steps 1-4 and these do nothing.
117
+ `ALTER TABLE IF EXISTS auth_users RENAME TO users`,
118
+ `ALTER TABLE IF EXISTS auth_orgs RENAME TO orgs`,
119
+ `ALTER TABLE IF EXISTS auth_members RENAME TO members`,
120
+ `ALTER TABLE IF EXISTS auth_tokens RENAME TO tokens`,
121
+ `ALTER INDEX IF EXISTS auth_tokens_user_kind RENAME TO tokens_user_kind`,
122
+ `ALTER INDEX IF EXISTS auth_members_user RENAME TO members_user`,
123
+ ],
124
+ },
125
+ {
126
+ id: 6,
127
+ name: "rate_limits",
128
+ statements: [
129
+ // One row per bucket ("signin:email:ben@x.y"): how many hits in the
130
+ // current window and when the window resets. See core/rate.ts.
131
+ `CREATE TABLE IF NOT EXISTS rate_limits (
132
+ bucket TEXT PRIMARY KEY,
133
+ count INT NOT NULL,
134
+ reset_at TIMESTAMPTZ NOT NULL
135
+ )`,
136
+ ],
137
+ },
138
+ {
139
+ id: 7,
140
+ name: "email_lower",
141
+ statements: [
142
+ // Emails have always been lowercased on the way in; make the
143
+ // database say so too, whatever writes to it. Two rows differing
144
+ // only in case would make this step fail with a clear message.
145
+ `UPDATE users SET email = LOWER(email) WHERE email <> LOWER(email)`,
146
+ `CREATE UNIQUE INDEX IF NOT EXISTS users_email_lower ON users (LOWER(email))`,
147
+ ],
148
+ },
149
+ {
150
+ id: 8,
151
+ name: "roles",
152
+ statements: [
153
+ // What a role may do, as saved on /auth/admin. b17_auth.yaml is the
154
+ // starting point; a row here wins. scope '' = every organization,
155
+ // an organization's id = that organization only.
156
+ `CREATE TABLE IF NOT EXISTS roles (
157
+ scope TEXT NOT NULL DEFAULT '',
158
+ name TEXT NOT NULL,
159
+ permissions TEXT[] NOT NULL,
160
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
161
+ PRIMARY KEY (scope, name)
162
+ )`,
163
+ ],
164
+ },
165
+ ];
166
+ /**
167
+ * The package's tables have plain names (users, orgs, members, tokens).
168
+ * A database that already holds a `users` table of its own would be
169
+ * silently shared by CREATE TABLE IF NOT EXISTS, so a fresh install
170
+ * checks first. Pure: decides from what the store reports.
171
+ */
172
+ export function foreignUsersTable(columns) {
173
+ return columns.length > 0 && !columns.includes("session_epoch");
174
+ }
175
+ export const FOREIGN_USERS_TABLE = "This database already has a `users` table that is not this package's. Point DATABASE_URL at a database (or Postgres schema) of its own.";
176
+ /** The steps still to run, in order. Pure. */
177
+ export function pendingSteps(applied, steps = MIGRATIONS) {
178
+ const done = new Set(applied);
179
+ return [...steps].filter((m) => !done.has(m.id)).sort((a, b) => a.id - b.id);
180
+ }
181
+ /** Everything a pending step needs to run, including recording that it ran. Pure. */
182
+ export function migrationStatements(steps) {
183
+ return steps.flatMap((m) => [
184
+ ...m.statements.map((text) => ({ text })),
185
+ { text: `INSERT INTO auth_migrations (id) VALUES ($1)`, params: [m.id] },
186
+ ]);
187
+ }
188
+ const ready = new WeakMap();
189
+ /**
190
+ * Apply pending migrations. Memoized per store and per process, so
191
+ * route handlers can call it on every request for free after the first.
192
+ * A failure clears the memo so the next call retries.
193
+ */
194
+ export function ensureSchema(store = db()) {
195
+ const cached = ready.get(store);
196
+ if (cached)
197
+ return cached;
198
+ const run = migrate(store).catch((err) => {
199
+ ready.delete(store);
37
200
  throw err;
38
- }));
201
+ });
202
+ ready.set(store, run);
203
+ return run;
204
+ }
205
+ async function appliedIds(store) {
206
+ return (await store.query(`SELECT id FROM auth_migrations`)).map((r) => Number(r.id));
39
207
  }
40
- export async function findUser(email) {
41
- await ensureSchema();
42
- const rows = (await sql() `
43
- SELECT id, email, password_hash, session_epoch AS epoch FROM auth_users WHERE email = ${email}
44
- `);
208
+ async function migrate(store) {
209
+ await store.query(`CREATE TABLE IF NOT EXISTS auth_migrations (
210
+ id INT PRIMARY KEY,
211
+ applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
212
+ )`);
213
+ const pending = pendingSteps(await appliedIds(store));
214
+ if (pending.length === 0)
215
+ return;
216
+ if (pending[0].id === 1) {
217
+ // A fresh install: make sure we are not about to adopt somebody else's users table
218
+ const cols = await store.query(`SELECT column_name FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'users'`);
219
+ if (foreignUsersTable(cols.map((c) => c.column_name)))
220
+ throw new Error(FOREIGN_USERS_TABLE);
221
+ }
222
+ try {
223
+ await runAll(store, migrationStatements(pending));
224
+ }
225
+ catch (err) {
226
+ // Two processes starting at once both see the same pending steps;
227
+ // the second one's INSERT hits the primary key and its transaction
228
+ // rolls back. If the first finished the job, that is fine.
229
+ if (pendingSteps(await appliedIds(store)).length === 0)
230
+ return;
231
+ const msg = err instanceof Error ? err.message : String(err);
232
+ throw new Error(`auth migration ${pending[0].id} (${pending[0].name}) failed: ${msg}`);
233
+ }
234
+ }
235
+ /** Emails are stored lowercase, so lookups are case-insensitive. */
236
+ const norm = (email) => email.trim().toLowerCase();
237
+ const USER_COLUMNS = `id, email, password_hash, session_epoch AS epoch, email_verified_at AS verified_at`;
238
+ export async function findUser(email, store = db()) {
239
+ await ensureSchema(store);
240
+ const rows = await store.query(`SELECT ${USER_COLUMNS} FROM users WHERE email = $1`, [norm(email)]);
45
241
  return rows[0] ?? null;
46
242
  }
47
- export async function createUser(email, passwordHash) {
48
- await ensureSchema();
49
- const [row] = (await sql() `
50
- INSERT INTO auth_users (email, password_hash)
51
- VALUES (${email}, ${passwordHash})
52
- RETURNING id, email, session_epoch AS epoch
53
- `);
54
- return row;
243
+ /** Two sign-ups raced on one email and the second lost: the handler answers 409. */
244
+ export class DuplicateUserError extends Error {
245
+ constructor(email) {
246
+ super(`A user with the email ${email} already exists`);
247
+ this.name = "DuplicateUserError";
248
+ }
249
+ }
250
+ /** Postgres's unique_violation */
251
+ export const isUniqueViolation = (err) => err?.code === "23505";
252
+ export async function createUser(email, passwordHash, opts = {}, store = db()) {
253
+ await ensureSchema(store);
254
+ let row;
255
+ try {
256
+ ;
257
+ [row] = await store.query(`INSERT INTO users (email, password_hash, email_verified_at)
258
+ VALUES ($1, $2, CASE WHEN $3 THEN now() ELSE NULL END)
259
+ RETURNING id, email, session_epoch AS epoch, email_verified_at AS verified_at`, [norm(email), passwordHash, Boolean(opts.verified)]);
260
+ }
261
+ catch (err) {
262
+ if (isUniqueViolation(err))
263
+ throw new DuplicateUserError(norm(email));
264
+ throw err;
265
+ }
266
+ return { id: row.id, email: row.email, epoch: row.epoch, verified: row.verified_at !== null };
267
+ }
268
+ /** Replace a hash with one made under stronger parameters. No epoch bump: nothing about the session changes. */
269
+ export async function rehashPassword(id, passwordHash, store = db()) {
270
+ if (!UUID.test(id))
271
+ return;
272
+ await ensureSchema(store);
273
+ await store.query(`UPDATE users SET password_hash = $2 WHERE id = $1`, [id, passwordHash]);
55
274
  }
56
275
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
57
- /** The user's current session_epoch, or null if the user is gone */
58
- export async function sessionEpoch(id) {
276
+ export async function userState(id, store = db()) {
59
277
  // A non-UUID would make Postgres throw on the cast; it can't be a user
60
278
  if (!UUID.test(id))
61
279
  return null;
62
- await ensureSchema();
63
- const rows = (await sql() `
64
- SELECT session_epoch FROM auth_users WHERE id = ${id}
65
- `);
66
- return rows[0]?.session_epoch ?? null;
280
+ await ensureSchema(store);
281
+ const rows = await store.query(`SELECT session_epoch, email_verified_at FROM users WHERE id = $1`, [id]);
282
+ const row = rows[0];
283
+ return row ? { epoch: row.session_epoch, verified: row.email_verified_at !== null } : null;
284
+ }
285
+ /** The user's current session_epoch, or null if the user is gone */
286
+ export async function sessionEpoch(id, store = db()) {
287
+ return (await userState(id, store))?.epoch ?? null;
288
+ }
289
+ export async function sessionRow(id, store = db()) {
290
+ if (!UUID.test(id))
291
+ return null;
292
+ await ensureSchema(store);
293
+ const rows = await store.query(`SELECT u.id, u.email, u.session_epoch, u.email_verified_at, o.id AS org_id, o.name AS org_name, m.role,
294
+ ro.permissions AS stored_org, ra.permissions AS stored_all
295
+ FROM users u
296
+ LEFT JOIN members m
297
+ ON m.user_id = u.id
298
+ AND m.org_id = COALESCE(
299
+ (SELECT a.org_id FROM members a WHERE a.user_id = u.id AND a.org_id = u.active_org_id),
300
+ (SELECT f.org_id FROM members f WHERE f.user_id = u.id ORDER BY f.created_at, f.org_id LIMIT 1)
301
+ )
302
+ LEFT JOIN orgs o ON o.id = m.org_id
303
+ LEFT JOIN roles ro ON ro.scope = o.id::text AND ro.name = m.role
304
+ LEFT JOIN roles ra ON ra.scope = '' AND ra.name = m.role
305
+ WHERE u.id = $1`, [id]);
306
+ const r = rows[0];
307
+ if (!r)
308
+ return null;
309
+ return {
310
+ id: r.id,
311
+ email: r.email,
312
+ epoch: r.session_epoch,
313
+ verified: r.email_verified_at !== null,
314
+ org_id: r.org_id,
315
+ org_name: r.org_name,
316
+ role: r.role,
317
+ stored_org: r.stored_org ?? null,
318
+ stored_all: r.stored_all ?? null,
319
+ };
320
+ }
321
+ /** Every saved role, all scopes. */
322
+ export async function storedRoles(store = db()) {
323
+ await ensureSchema(store);
324
+ return store.query(`SELECT scope, name, permissions, updated_at FROM roles ORDER BY scope, name`);
325
+ }
326
+ /** Save what a role may do: for every organization (scope "") or one (its id). Replaces the previous list. */
327
+ export async function setRolePermissions(scope, name, permissions, store = db()) {
328
+ await ensureSchema(store);
329
+ await store.query(`INSERT INTO roles (scope, name, permissions, updated_at) VALUES ($1, $2, $3, now())
330
+ ON CONFLICT (scope, name) DO UPDATE SET permissions = EXCLUDED.permissions, updated_at = now()`, [scope, name, permissions]);
331
+ }
332
+ /** Forget an organization's own rule for a role: it follows the rule for every organization again. False if there was none. */
333
+ export async function resetRole(scope, name, store = db()) {
334
+ await ensureSchema(store);
335
+ const rows = await store.query(`DELETE FROM roles WHERE scope = $1 AND name = $2 RETURNING name`, [scope, name]);
336
+ return rows.length > 0;
337
+ }
338
+ /**
339
+ * The starting point, written once: every role from b17_auth.yaml `roles:`
340
+ * that has no row for every organization yet. A role the admin page has
341
+ * already saved is never touched. Returns the names written.
342
+ */
343
+ export async function seedRoles(defaults, store = db()) {
344
+ await ensureSchema(store);
345
+ const written = [];
346
+ for (const [name, permissions] of Object.entries(defaults)) {
347
+ const rows = await store.query(`INSERT INTO roles (scope, name, permissions) VALUES ('', $1, $2) ON CONFLICT (scope, name) DO NOTHING RETURNING name`, [name, permissions]);
348
+ if (rows.length)
349
+ written.push(name);
350
+ }
351
+ return written;
352
+ }
353
+ /** The user's role in one organization, or null when they are not a member. */
354
+ export async function memberRole(userId, orgId, store = db()) {
355
+ if (!UUID.test(userId) || !UUID.test(orgId))
356
+ return null;
357
+ await ensureSchema(store);
358
+ const rows = await store.query(`SELECT role FROM members WHERE user_id = $1 AND org_id = $2`, [userId, orgId]);
359
+ return rows[0]?.role ?? null;
360
+ }
361
+ export async function createOrg(name, store = db()) {
362
+ await ensureSchema(store);
363
+ const [row] = await store.query(`INSERT INTO orgs (name) VALUES ($1) RETURNING id, name, created_at`, [name.trim()]);
364
+ return row;
365
+ }
366
+ /** By id (UUID) or exact name. Null if there is no such organization. */
367
+ export async function findOrg(idOrName, store = db()) {
368
+ await ensureSchema(store);
369
+ const rows = UUID.test(idOrName)
370
+ ? await store.query(`SELECT id, name, created_at FROM orgs WHERE id = $1`, [idOrName])
371
+ : await store.query(`SELECT id, name, created_at FROM orgs WHERE name = $1 ORDER BY created_at LIMIT 1`, [idOrName.trim()]);
372
+ return rows[0] ?? null;
373
+ }
374
+ export async function listOrgs(store = db()) {
375
+ await ensureSchema(store);
376
+ return store.query(`SELECT o.id, o.name, o.created_at, COUNT(m.user_id)::int AS members
377
+ FROM orgs o LEFT JOIN members m ON m.org_id = o.id
378
+ GROUP BY o.id ORDER BY o.created_at`);
379
+ }
380
+ /** Add or update a membership. Becomes the user's active org if they had none. */
381
+ export async function addMember(orgId, userId, role, store = db()) {
382
+ await ensureSchema(store);
383
+ await store.query(`INSERT INTO members (org_id, user_id, role) VALUES ($1, $2, $3)
384
+ ON CONFLICT (org_id, user_id) DO UPDATE SET role = EXCLUDED.role`, [orgId, userId, role]);
385
+ await store.query(`UPDATE users SET active_org_id = $2 WHERE id = $1 AND active_org_id IS NULL`, [userId, orgId]);
386
+ }
387
+ export async function removeMember(orgId, userId, store = db()) {
388
+ await ensureSchema(store);
389
+ const rows = await store.query(`DELETE FROM members WHERE org_id = $1 AND user_id = $2 RETURNING user_id`, [orgId, userId]);
390
+ return rows.length > 0;
391
+ }
392
+ export async function removeOrg(orgId, store = db()) {
393
+ if (!UUID.test(orgId))
394
+ return false;
395
+ await ensureSchema(store);
396
+ const rows = await store.query(`DELETE FROM orgs WHERE id = $1 RETURNING id`, [orgId]);
397
+ return rows.length > 0;
398
+ }
399
+ /** Every user with their memberships, one query. */
400
+ export async function adminUsers(store = db()) {
401
+ await ensureSchema(store);
402
+ const rows = await store.query(`SELECT u.id, u.email, u.created_at, (u.email_verified_at IS NOT NULL) AS verified,
403
+ COALESCE(json_agg(json_build_object('org_id', o.id, 'org', o.name, 'role', m.role) ORDER BY m.created_at)
404
+ FILTER (WHERE o.id IS NOT NULL), '[]'::json) AS memberships
405
+ FROM users u
406
+ LEFT JOIN members m ON m.user_id = u.id
407
+ LEFT JOIN orgs o ON o.id = m.org_id
408
+ GROUP BY u.id ORDER BY u.created_at`);
409
+ return rows.map((r) => ({ ...r, memberships: typeof r.memberships === "string" ? JSON.parse(r.memberships) : r.memberships }));
410
+ }
411
+ /** Every organization with its members, one query. */
412
+ export async function adminOrgs(store = db()) {
413
+ await ensureSchema(store);
414
+ const rows = await store.query(`SELECT o.id, o.name, o.created_at,
415
+ COALESCE(json_agg(json_build_object('user_id', u.id, 'email', u.email, 'role', m.role) ORDER BY m.created_at)
416
+ FILTER (WHERE u.id IS NOT NULL), '[]'::json) AS members
417
+ FROM orgs o
418
+ LEFT JOIN members m ON m.org_id = o.id
419
+ LEFT JOIN users u ON u.id = m.user_id
420
+ GROUP BY o.id ORDER BY o.created_at`);
421
+ return rows.map((r) => ({ ...r, members: typeof r.members === "string" ? JSON.parse(r.members) : r.members }));
422
+ }
423
+ /** Switch the user's active organization. False unless they are a member of it: the client's choice is checked, never trusted. */
424
+ export async function setActiveOrg(userId, orgId, store = db()) {
425
+ if (!UUID.test(userId) || !UUID.test(orgId))
426
+ return false;
427
+ await ensureSchema(store);
428
+ const rows = await store.query(`UPDATE users SET active_org_id = $2
429
+ WHERE id = $1 AND EXISTS (SELECT 1 FROM members WHERE user_id = $1 AND org_id = $2)
430
+ RETURNING id`, [userId, orgId]);
431
+ return rows.length > 0;
432
+ }
433
+ /** Record that the user followed the verification link. Idempotent. Null if the user is gone. */
434
+ export async function markVerified(id, store = db()) {
435
+ if (!UUID.test(id))
436
+ return null;
437
+ await ensureSchema(store);
438
+ const rows = await store.query(`UPDATE users SET email_verified_at = COALESCE(email_verified_at, now()) WHERE id = $1 RETURNING id, email`, [id]);
439
+ return rows[0] ?? null;
67
440
  }
68
441
  /** Revoke every session of this user. False if there is no such user. */
69
- export async function revokeSessions(email) {
70
- await ensureSchema();
71
- const rows = (await sql() `
72
- UPDATE auth_users SET session_epoch = session_epoch + 1 WHERE email = ${email} RETURNING id
73
- `);
442
+ export async function revokeSessions(email, store = db()) {
443
+ await ensureSchema(store);
444
+ const rows = await store.query(`UPDATE users SET session_epoch = session_epoch + 1 WHERE email = $1 RETURNING id`, [norm(email)]);
74
445
  return rows.length > 0;
75
446
  }
76
447
  /** Delete the user. Their sessions die with the row. False if none. */
77
- export async function removeUser(email) {
78
- await ensureSchema();
79
- const rows = (await sql() `
80
- DELETE FROM auth_users WHERE email = ${email} RETURNING id
81
- `);
448
+ export async function removeUser(email, store = db()) {
449
+ await ensureSchema(store);
450
+ const rows = await store.query(`DELETE FROM users WHERE email = $1 RETURNING id`, [norm(email)]);
82
451
  return rows.length > 0;
83
452
  }
84
- export async function countUsers() {
85
- await ensureSchema();
86
- const [row] = (await sql() `SELECT COUNT(*)::int AS n FROM auth_users`);
87
- return row.n;
453
+ export async function countUsers(store = db()) {
454
+ await ensureSchema(store);
455
+ const [row] = await store.query(`SELECT COUNT(*)::int AS n FROM users`);
456
+ return Number(row.n);
88
457
  }
89
- export async function listUsers() {
90
- await ensureSchema();
91
- return (await sql() `
92
- SELECT id, email, created_at FROM auth_users ORDER BY created_at
93
- `);
458
+ export async function listUsers(store = db()) {
459
+ await ensureSchema(store);
460
+ return store.query(`SELECT id, email, created_at FROM users ORDER BY created_at`);
94
461
  }
95
462
  /** Change a password. Also revokes every session, old password's included. */
96
- export async function setPassword(email, passwordHash) {
97
- await ensureSchema();
98
- const rows = (await sql() `
99
- UPDATE auth_users
100
- SET password_hash = ${passwordHash}, session_epoch = session_epoch + 1
101
- WHERE email = ${email} RETURNING id
102
- `);
463
+ export async function setPassword(email, passwordHash, store = db()) {
464
+ await ensureSchema(store);
465
+ const rows = await store.query(`UPDATE users
466
+ SET password_hash = $2, session_epoch = session_epoch + 1
467
+ WHERE email = $1 RETURNING id`, [norm(email), passwordHash]);
103
468
  return rows.length > 0;
104
469
  }
470
+ /**
471
+ * Same, by id, for a reset link. Also marks the email verified: following
472
+ * the link proved the address. Returns the user with the new epoch so the
473
+ * caller can sign them in; null if the user is gone.
474
+ */
475
+ export async function setPasswordById(id, passwordHash, store = db()) {
476
+ if (!UUID.test(id))
477
+ return null;
478
+ await ensureSchema(store);
479
+ const rows = await store.query(`UPDATE users
480
+ SET password_hash = $2, session_epoch = session_epoch + 1,
481
+ email_verified_at = COALESCE(email_verified_at, now())
482
+ WHERE id = $1 RETURNING id, email, session_epoch AS epoch`, [id, passwordHash]);
483
+ return rows[0] ?? null;
484
+ }
105
485
  export { TABLE };
@@ -0,0 +1,49 @@
1
+ import { type EmailOptions } from "./options.ts";
2
+ export type EmailMessage = {
3
+ to: string;
4
+ subject: string;
5
+ text: string;
6
+ html?: string;
7
+ };
8
+ /** What sendEmail needs: the email options plus whether this is production */
9
+ export type EmailConfig = EmailOptions & {
10
+ production: boolean;
11
+ };
12
+ export type Transport = "gmail" | "resend" | "console" | "none";
13
+ export declare const RESEND_URL = "https://api.resend.com/emails";
14
+ export declare const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
15
+ export declare const GMAIL_SEND_URL = "https://gmail.googleapis.com/gmail/v1/users/me/messages/send";
16
+ export declare const GMAIL_SCOPE = "https://www.googleapis.com/auth/gmail.send";
17
+ export declare const EMAIL_NOT_CONFIGURED = "Email is not configured. In b17_auth.yaml set email.gmail (user + clientEmail + privateKey) or email.resend (apiKey) + email.from (run: npx @binary1702/auth).";
18
+ /** Which way a message goes. Gmail wins when both are configured. Pure. */
19
+ export declare function emailTransport(cfg: EmailConfig): Transport;
20
+ /** RFC 2047 for a header that may hold non-ASCII (a subject with an accent) */
21
+ export declare function encodeHeader(value: string): string;
22
+ /**
23
+ * A From/To value as the header needs it. `Name <addr>` keeps the name
24
+ * quoted when it holds characters that mean something in a header
25
+ * (parentheses, commas, colons, …) and RFC 2047-encoded when it is not
26
+ * ASCII. A bare address passes through. Pure.
27
+ */
28
+ export declare function formatAddress(value: string): string;
29
+ /** The request Resend expects. Pure. */
30
+ export declare function resendRequest(msg: EmailMessage, cfg: EmailConfig): {
31
+ url: string;
32
+ init: RequestInit;
33
+ };
34
+ /** The raw RFC 2822 message Gmail wants, base64url-encoded. Pure. */
35
+ export declare function gmailRaw(msg: EmailMessage, from: string, replyTo?: string): string;
36
+ type Gmail = NonNullable<EmailOptions["gmail"]>;
37
+ /** The service account's email and PEM key (client_email and private_key in the JSON Google hands out) */
38
+ export declare function serviceAccount(gmail: Gmail): {
39
+ email: string;
40
+ key: string;
41
+ };
42
+ /** The signed assertion for a service account acting as the mailbox user (domain-wide delegation). */
43
+ export declare function serviceAccountAssertion(gmail: Gmail, now?: Date): Promise<string>;
44
+ /** The token request body, either grant. */
45
+ export declare function gmailTokenRequest(gmail: Gmail): Promise<URLSearchParams>;
46
+ /** Forget cached tokens (tests, or after rotating credentials). */
47
+ export declare function resetGmailTokens(): void;
48
+ export declare function sendEmail(msg: EmailMessage, cfg?: EmailConfig, fetchFn?: typeof fetch, log?: (line: string) => void): Promise<void>;
49
+ export {};