@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.
- package/AGENTS.md +155 -0
- package/CHANGELOG.md +49 -0
- package/INSTALL.md +27 -16
- package/PROMPT.md +325 -36
- package/README.md +338 -53
- package/dist/cli.js +248 -17
- package/dist/core/admin-html.d.ts +32 -0
- package/dist/core/admin-html.js +350 -0
- package/dist/core/admin.d.ts +56 -0
- package/dist/core/admin.js +90 -0
- package/dist/core/config.d.ts +10 -3
- package/dist/core/config.js +22 -8
- package/dist/core/db.d.ts +155 -10
- package/dist/core/db.js +449 -69
- package/dist/core/email.d.ts +49 -0
- package/dist/core/email.js +213 -0
- package/dist/core/guard.d.ts +52 -0
- package/dist/core/guard.js +106 -0
- package/dist/core/html.d.ts +43 -0
- package/dist/core/html.js +122 -28
- package/dist/core/invite.d.ts +7 -0
- package/dist/core/invite.js +30 -0
- package/dist/core/options.d.ts +95 -0
- package/dist/core/options.js +377 -0
- package/dist/core/org.d.ts +43 -0
- package/dist/core/org.js +65 -0
- package/dist/core/pages.d.ts +34 -0
- package/dist/core/pages.js +73 -0
- package/dist/core/password.d.ts +16 -0
- package/dist/core/password.js +41 -12
- package/dist/core/rate.d.ts +26 -0
- package/dist/core/rate.js +70 -0
- package/dist/core/reset.d.ts +9 -0
- package/dist/core/reset.js +31 -0
- package/dist/core/session.d.ts +11 -1
- package/dist/core/session.js +27 -7
- package/dist/core/signup.d.ts +13 -6
- package/dist/core/signup.js +30 -15
- package/dist/core/tokens.d.ts +29 -0
- package/dist/core/tokens.js +64 -0
- package/dist/core/verify.d.ts +8 -0
- package/dist/core/verify.js +33 -0
- package/dist/core/yaml.d.ts +10 -0
- package/dist/core/yaml.js +85 -0
- package/dist/index.d.ts +18 -6
- package/dist/index.js +18 -6
- package/dist/next/index.d.ts +10 -3
- package/dist/next/index.js +649 -63
- package/dist/next/proxy.d.ts +2 -1
- package/dist/next/proxy.js +22 -10
- package/dist/react/index.d.ts +72 -0
- package/dist/react/index.js +107 -0
- package/package.json +18 -4
package/dist/core/db.js
CHANGED
|
@@ -1,105 +1,485 @@
|
|
|
1
1
|
// ─────────────────────────────────────────────────────────────
|
|
2
|
-
// Storage.
|
|
2
|
+
// Storage. Tables owned by this package, created by migrations.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
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
|
-
//
|
|
12
|
-
//
|
|
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
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
return (
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
41
|
-
await
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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 =
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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 =
|
|
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 =
|
|
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] =
|
|
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 (
|
|
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 =
|
|
99
|
-
|
|
100
|
-
|
|
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 {};
|