@everystack/auth 0.4.0 → 0.4.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/auth",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "JWT authentication handlers, password hashing, and React auth context",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
@@ -91,7 +91,7 @@
91
91
  },
92
92
  "dependencies": {
93
93
  "bcryptjs": "3.0.3",
94
- "@everystack/model": "0.4.0"
94
+ "@everystack/model": "0.4.12"
95
95
  },
96
96
  "peerDependencies": {
97
97
  "@everystack/api": ">=0.1.0",
@@ -120,7 +120,7 @@
120
120
  "postgres": "3.4.9",
121
121
  "ts-jest": "29.4.9",
122
122
  "typescript": "5.9.3",
123
- "@everystack/api": "0.2.10"
123
+ "@everystack/api": "0.4.0"
124
124
  },
125
125
  "scripts": {
126
126
  "test": "jest",
@@ -0,0 +1,965 @@
1
+ /**
2
+ * The auth package's SECURITY DEFINER functions, as DECLARED derived objects.
3
+ *
4
+ * WHY THIS FILE EXISTS. These same functions have always shipped, but on `defineModule`'s
5
+ * deprecated `functions` thunk — and exactly ONE command in the toolchain reads that thunk:
6
+ * `db:generate --init`, the greenfield generator (cli/migration-compile.ts, step 3). Every other
7
+ * lane, `db:reconcile` included, reads `module.derived`, which was empty. So an app that adopted
8
+ * brownfield — pull, sync, plan, apply, reconcile — installed NONE of these, and every gate
9
+ * reported clean, because a reconciler can only compare what was declared and nothing was.
10
+ * All seven auth models declare `writtenBy: 'functions'` (ENABLE-not-FORCE RLS on the reasoning
11
+ * that owner functions mediate their writes), so the declared posture and the live database
12
+ * disagreed about who mediates auth writes. Reported by a consumer, 2026-08-04.
13
+ *
14
+ * WHY defineFunction AND NOT defineSql. The escape hatch would have taken the whole blob in one
15
+ * descriptor and one content hash — but `defineSql` objects are deliberately SKIPPED by the
16
+ * search_path gates (model/src/derived.ts:526-534). Routing 24 SECURITY DEFINER functions through
17
+ * the one hatch that exempts them from the pinned-search_path check is the wrong trade for the
18
+ * auth package specifically. Declared as functions, each one is hashed, ordered, gate-checked,
19
+ * and — the part that matters most here — the compiler emits
20
+ * `REVOKE ALL ON FUNCTION … FROM PUBLIC` for EVERY function unconditionally
21
+ * (cli/derived-compile.ts:72). The defect that shipped in 0.4.0, seven functions left
22
+ * PUBLIC-executable because nobody remembered their REVOKE, becomes structurally impossible.
23
+ *
24
+ * HOW THESE WERE PRODUCED. Not transcribed by hand. The legacy `generateAuthSQL()` was applied to
25
+ * a scratch database and each descriptor was DERIVED from what PostgreSQL actually recorded —
26
+ * `pg_get_function_arguments`, `pg_get_function_result`, `prosrc`, `prosecdef`, `proconfig`,
27
+ * `provolatile`, and the ACL. Deriving beats transcribing when the cost of a mistyped argument
28
+ * type is a security bug. The derivation is then PROVEN, not trusted: the equivalence test in
29
+ * @everystack/cli builds one database from the thunk and one from these descriptors and compares
30
+ * the resulting catalogs function by function.
31
+ *
32
+ * Two derivation traps worth naming, both caught before this file was written:
33
+ * - the ACL always contains the function OWNER, so a naive read emits an ability granting
34
+ * EXECUTE to whatever role happened to build the scratch database;
35
+ * - `array_to_string(proconfig, ',')` is ambiguous with the commas INSIDE
36
+ * `search_path=auth, public, pg_temp`, silently truncating the pin to its first schema.
37
+ *
38
+ * The bodies are byte-identical to what the thunk produced. Edit `sql.ts` and re-derive; do not
39
+ * hand-patch a body here.
40
+ */
41
+
42
+ import { defineFunction, arg, can, sql } from '@everystack/model';
43
+
44
+ export const changePasswordFn = defineFunction('auth.change_password', {
45
+ args: [arg('p_current_password', 'text'), arg('p_new_password', 'text')],
46
+ returns: 'jsonb',
47
+ language: 'plpgsql',
48
+ security: 'definer',
49
+ searchPath: ['auth', 'public', 'pg_temp'],
50
+ abilities: [can('execute', { role: 'authenticated' })],
51
+ body: sql`
52
+ DECLARE
53
+ v_user_id UUID;
54
+ v_hash TEXT;
55
+ BEGIN
56
+ -- The caller is authenticated; their verified subject claim identifies them.
57
+ -- Read the claim inline so this wrapper has no dependency on the accessor functions.
58
+ v_user_id := (nullif(current_setting('request.jwt.claims', true), '')::jsonb->>'sub')::uuid;
59
+ IF v_user_id IS NULL THEN
60
+ RAISE EXCEPTION 'not_authenticated' USING ERRCODE = 'P0001';
61
+ END IF;
62
+
63
+ SELECT encrypted_password INTO v_hash FROM auth.users
64
+ WHERE id = v_user_id AND deleted_at IS NULL;
65
+ IF NOT FOUND OR v_hash IS NULL THEN
66
+ RAISE EXCEPTION 'invalid_credentials' USING ERRCODE = 'P0001';
67
+ END IF;
68
+
69
+ -- Verify the current password before changing it (the gate).
70
+ IF NOT auth.verify_password(p_current_password, v_hash) THEN
71
+ RAISE EXCEPTION 'invalid_credentials' USING ERRCODE = 'P0001';
72
+ END IF;
73
+
74
+ UPDATE auth.users
75
+ SET encrypted_password = auth.hash_password(p_new_password), updated_at = now()
76
+ WHERE id = v_user_id;
77
+
78
+ -- A password change invalidates every existing session — force re-login everywhere.
79
+ PERFORM auth.revoke_refresh_tokens(v_user_id);
80
+
81
+ RETURN jsonb_build_object('changed', true, 'userId', v_user_id);
82
+ END;
83
+ `,
84
+ });
85
+
86
+ export const cleanupRefreshTokensFn = defineFunction('auth.cleanup_refresh_tokens', {
87
+ args: [arg('p_consumed_retention', 'interval', { default: sql`'7 days'::interval` })],
88
+ returns: 'integer',
89
+ language: 'plpgsql',
90
+ security: 'definer',
91
+ searchPath: ['auth', 'public', 'pg_temp'],
92
+ body: sql`
93
+ DECLARE
94
+ v_count INTEGER;
95
+ BEGIN
96
+ DELETE FROM auth.refresh_tokens
97
+ WHERE expires_at < now()
98
+ OR (status = 'consumed' AND created_at < now() - p_consumed_retention);
99
+
100
+ GET DIAGNOSTICS v_count = ROW_COUNT;
101
+ RETURN v_count;
102
+ END;
103
+ `,
104
+ });
105
+
106
+ export const confirmEmailFn = defineFunction('auth.confirm_email', {
107
+ args: [arg('p_token', 'text')],
108
+ returns: 'jsonb',
109
+ language: 'plpgsql',
110
+ security: 'definer',
111
+ searchPath: ['auth', 'public', 'pg_temp'],
112
+ abilities: [can('execute', { role: 'anon' })],
113
+ body: sql`
114
+ DECLARE
115
+ v_result JSONB;
116
+ v_user_id UUID;
117
+ BEGIN
118
+ -- Raises on an invalid/expired/consumed token (fails closed). One-time use.
119
+ v_result := auth.verify_one_time_token(p_token, 'confirmation');
120
+ v_user_id := (v_result->>'userId')::uuid;
121
+
122
+ UPDATE auth.users
123
+ SET email_confirmed_at = now(), updated_at = now()
124
+ WHERE id = v_user_id;
125
+
126
+ RETURN jsonb_build_object('confirmed', true, 'userId', v_user_id);
127
+ END;
128
+ `,
129
+ });
130
+
131
+ export const createOneTimeTokenFn = defineFunction('auth.create_one_time_token', {
132
+ args: [arg('p_user_id', 'uuid'), arg('p_token_type', 'text'), arg('p_relates_to', 'text')],
133
+ returns: 'text',
134
+ language: 'plpgsql',
135
+ security: 'definer',
136
+ searchPath: ['auth', 'public', 'pg_temp'],
137
+ body: sql`
138
+ DECLARE
139
+ v_raw_token TEXT;
140
+ v_token_hash TEXT;
141
+ BEGIN
142
+ -- Delete any existing token of this type for this user
143
+ DELETE FROM auth.one_time_tokens
144
+ WHERE user_id = p_user_id AND token_type = p_token_type;
145
+
146
+ -- Generate 32 random bytes, base64url encoded
147
+ v_raw_token := rtrim(translate(
148
+ encode(gen_random_bytes(32), 'base64'),
149
+ '+/=', '-_ '
150
+ ));
151
+
152
+ v_token_hash := encode(digest(v_raw_token, 'sha256'), 'hex');
153
+
154
+ INSERT INTO auth.one_time_tokens (user_id, token_type, token_hash, relates_to)
155
+ VALUES (p_user_id, p_token_type, v_token_hash, p_relates_to);
156
+
157
+ RETURN v_raw_token;
158
+ END;
159
+ `,
160
+ });
161
+
162
+ export const hashPasswordFn = defineFunction('auth.hash_password', {
163
+ args: [arg('p_password', 'text')],
164
+ returns: 'text',
165
+ language: 'plpgsql',
166
+ security: 'definer',
167
+ searchPath: ['auth', 'public', 'pg_temp'],
168
+ body: sql`
169
+ BEGIN
170
+ RETURN crypt(p_password, gen_salt('bf', 10));
171
+ END;
172
+ `,
173
+ });
174
+
175
+ export const issueRefreshTokenFn = defineFunction('auth.issue_refresh_token', {
176
+ args: [arg('p_user_id', 'uuid'), arg('p_family_id', 'uuid', { default: sql`NULL::uuid` }), arg('p_ip_address', 'text', { default: sql`NULL::text` }), arg('p_user_agent', 'text', { default: sql`NULL::text` }), arg('p_device_id', 'uuid', { default: sql`NULL::uuid` }), arg('p_repository_id', 'uuid', { default: sql`NULL::uuid` }), arg('p_ttl', 'interval', { default: sql`'30 days'::interval` })],
177
+ returns: 'jsonb',
178
+ language: 'plpgsql',
179
+ security: 'definer',
180
+ searchPath: ['auth', 'public', 'pg_temp'],
181
+ body: sql`
182
+ DECLARE
183
+ v_raw_token TEXT;
184
+ v_token_hash TEXT;
185
+ v_family_id UUID;
186
+ v_expires_at TIMESTAMPTZ;
187
+ v_family_count INTEGER;
188
+ v_oldest_family UUID;
189
+ BEGIN
190
+ -- Clean up expired tokens for this user (scoped by repository)
191
+ DELETE FROM auth.refresh_tokens
192
+ WHERE user_id = p_user_id
193
+ AND repository_id IS NOT DISTINCT FROM p_repository_id
194
+ AND expires_at < now();
195
+
196
+ -- Enforce per-user family ceiling (only for new families)
197
+ IF p_family_id IS NULL THEN
198
+ SELECT count(DISTINCT family_id) INTO v_family_count
199
+ FROM auth.refresh_tokens
200
+ WHERE user_id = p_user_id
201
+ AND repository_id IS NOT DISTINCT FROM p_repository_id
202
+ AND status = 'active';
203
+
204
+ IF v_family_count >= 10 THEN
205
+ -- Revoke the oldest family
206
+ SELECT family_id INTO v_oldest_family
207
+ FROM auth.refresh_tokens
208
+ WHERE user_id = p_user_id
209
+ AND repository_id IS NOT DISTINCT FROM p_repository_id
210
+ AND status = 'active'
211
+ GROUP BY family_id
212
+ ORDER BY MIN(created_at) ASC
213
+ LIMIT 1;
214
+
215
+ DELETE FROM auth.refresh_tokens
216
+ WHERE user_id = p_user_id AND family_id = v_oldest_family;
217
+ END IF;
218
+ END IF;
219
+
220
+ -- Generate 32 random bytes, base64url encoded
221
+ v_raw_token := rtrim(translate(
222
+ encode(gen_random_bytes(32), 'base64'),
223
+ '+/=', '-_ '
224
+ ));
225
+
226
+ -- Store SHA-256 hash, not the raw token
227
+ v_token_hash := encode(digest(v_raw_token, 'sha256'), 'hex');
228
+ v_family_id := COALESCE(p_family_id, gen_random_uuid());
229
+ v_expires_at := now() + p_ttl;
230
+
231
+ INSERT INTO auth.refresh_tokens (
232
+ repository_id, user_id, token_hash, family_id, status,
233
+ ip_address, user_agent, device_id, expires_at
234
+ )
235
+ VALUES (
236
+ p_repository_id, p_user_id, v_token_hash, v_family_id, 'active',
237
+ p_ip_address, p_user_agent, p_device_id, v_expires_at
238
+ );
239
+
240
+ RETURN jsonb_build_object(
241
+ 'token', v_raw_token,
242
+ 'familyId', v_family_id,
243
+ 'expiresAt', v_expires_at
244
+ );
245
+ END;
246
+ `,
247
+ });
248
+
249
+ export const jwtFn = defineFunction('auth.jwt', {
250
+ returns: 'jsonb',
251
+ language: 'sql',
252
+ volatility: 'stable',
253
+ abilities: [can('execute', { role: 'admin' }), can('execute', { role: 'anon' }), can('execute', { role: 'authenticated' })],
254
+ body: sql`
255
+ SELECT COALESCE(
256
+ nullif(current_setting('request.jwt.claims', true), '')::jsonb,
257
+ '{}'::jsonb
258
+ );
259
+ `,
260
+ });
261
+
262
+ export const oauthSignInFn = defineFunction('auth.oauth_sign_in', {
263
+ args: [arg('p_provider', 'text'), arg('p_provider_id', 'text'), arg('p_email', 'text'), arg('p_identity_data', 'jsonb', { default: sql`'{}'::jsonb` })],
264
+ returns: 'jsonb',
265
+ language: 'plpgsql',
266
+ security: 'definer',
267
+ searchPath: ['auth', 'public', 'pg_temp'],
268
+ body: sql`
269
+ DECLARE
270
+ v_identity RECORD;
271
+ v_user RECORD;
272
+ v_providers JSONB;
273
+ BEGIN
274
+ -- Look up existing identity
275
+ SELECT * INTO v_identity FROM auth.identities
276
+ WHERE provider = p_provider AND provider_id = p_provider_id;
277
+
278
+ IF FOUND THEN
279
+ -- Existing user, update identity data and sign in
280
+ SELECT * INTO v_user FROM auth.users WHERE id = v_identity.user_id AND deleted_at IS NULL;
281
+ IF NOT FOUND THEN
282
+ RAISE EXCEPTION 'user_disabled' USING ERRCODE = 'P0004';
283
+ END IF;
284
+
285
+ UPDATE auth.identities
286
+ SET identity_data = p_identity_data, last_sign_in_at = now(), updated_at = now()
287
+ WHERE id = v_identity.id;
288
+
289
+ UPDATE auth.users SET last_sign_in_at = now(), updated_at = now()
290
+ WHERE id = v_user.id;
291
+
292
+ RETURN jsonb_build_object(
293
+ 'sub', v_user.id,
294
+ 'email', v_user.email,
295
+ 'role', v_user.role,
296
+ 'isNewUser', false
297
+ );
298
+ END IF;
299
+
300
+ -- Check if a user with this email already exists (link account)
301
+ SELECT * INTO v_user FROM auth.users
302
+ WHERE email = lower(trim(p_email)) AND deleted_at IS NULL;
303
+
304
+ IF FOUND THEN
305
+ -- Link the new provider to existing user
306
+ v_providers := COALESCE(v_user.raw_app_meta_data->'providers', '[]'::jsonb);
307
+ IF NOT v_providers ? p_provider THEN
308
+ v_providers := v_providers || to_jsonb(p_provider);
309
+ END IF;
310
+
311
+ UPDATE auth.users
312
+ SET raw_app_meta_data = COALESCE(raw_app_meta_data, '{}'::jsonb)
313
+ || jsonb_build_object('providers', v_providers),
314
+ last_sign_in_at = now(),
315
+ updated_at = now()
316
+ WHERE id = v_user.id;
317
+
318
+ INSERT INTO auth.identities (user_id, provider, provider_id, identity_data, email)
319
+ VALUES (v_user.id, p_provider, p_provider_id, p_identity_data, lower(trim(p_email)));
320
+
321
+ RETURN jsonb_build_object(
322
+ 'sub', v_user.id,
323
+ 'email', v_user.email,
324
+ 'role', v_user.role,
325
+ 'isNewUser', false
326
+ );
327
+ END IF;
328
+
329
+ -- New user: create auth.users + auth.identities
330
+ INSERT INTO auth.users (
331
+ email, role, aud, raw_user_meta_data, raw_app_meta_data,
332
+ email_confirmed_at, last_sign_in_at
333
+ )
334
+ VALUES (
335
+ lower(trim(p_email)), 'authenticated', 'authenticated',
336
+ p_identity_data,
337
+ jsonb_build_object('provider', p_provider, 'providers', jsonb_build_array(p_provider)),
338
+ now(), now()
339
+ )
340
+ RETURNING * INTO v_user;
341
+
342
+ INSERT INTO auth.identities (user_id, provider, provider_id, identity_data, email)
343
+ VALUES (v_user.id, p_provider, p_provider_id, p_identity_data, v_user.email);
344
+
345
+ RETURN jsonb_build_object(
346
+ 'sub', v_user.id,
347
+ 'email', v_user.email,
348
+ 'role', v_user.role,
349
+ 'isNewUser', true
350
+ );
351
+ END;
352
+ `,
353
+ });
354
+
355
+ export const oauthSignInJwtFn = defineFunction('auth.oauth_sign_in_jwt', {
356
+ args: [arg('p_provider', 'text'), arg('p_provider_id', 'text'), arg('p_email', 'text'), arg('p_identity_data', 'jsonb', { default: sql`'{}'::jsonb` })],
357
+ returns: 'jsonb',
358
+ language: 'plpgsql',
359
+ security: 'definer',
360
+ searchPath: ['auth', 'public', 'pg_temp'],
361
+ body: sql`
362
+ DECLARE
363
+ v_payload JSONB;
364
+ v_claims JSONB;
365
+ v_token TEXT;
366
+ v_refresh JSONB;
367
+ BEGIN
368
+ v_payload := auth.oauth_sign_in(p_provider, p_provider_id, p_email, p_identity_data);
369
+
370
+ v_claims := v_payload || jsonb_build_object(
371
+ 'iat', extract(epoch FROM now())::bigint,
372
+ 'exp', (extract(epoch FROM now()) + 3600)::bigint
373
+ );
374
+ v_token := auth.sign_jwt(v_claims);
375
+
376
+ v_refresh := auth.issue_refresh_token((v_payload->>'sub')::uuid);
377
+
378
+ RETURN jsonb_build_object(
379
+ 'access_token', v_token,
380
+ 'refresh_token', v_refresh->>'token',
381
+ 'user', v_payload
382
+ );
383
+ END;
384
+ `,
385
+ });
386
+
387
+ export const resetPasswordFn = defineFunction('auth.reset_password', {
388
+ args: [arg('p_token', 'text'), arg('p_new_password', 'text')],
389
+ returns: 'jsonb',
390
+ language: 'plpgsql',
391
+ security: 'definer',
392
+ searchPath: ['auth', 'public', 'pg_temp'],
393
+ abilities: [can('execute', { role: 'anon' })],
394
+ body: sql`
395
+ DECLARE
396
+ v_result JSONB;
397
+ v_user_id UUID;
398
+ BEGIN
399
+ -- Raises on an invalid/expired/consumed token (fails closed). One-time use.
400
+ v_result := auth.verify_one_time_token(p_token, 'recovery');
401
+ v_user_id := (v_result->>'userId')::uuid;
402
+
403
+ UPDATE auth.users
404
+ SET encrypted_password = auth.hash_password(p_new_password), updated_at = now()
405
+ WHERE id = v_user_id;
406
+
407
+ -- A password reset invalidates every existing session — force re-login everywhere.
408
+ PERFORM auth.revoke_refresh_tokens(v_user_id);
409
+
410
+ RETURN jsonb_build_object('reset', true, 'userId', v_user_id);
411
+ END;
412
+ `,
413
+ });
414
+
415
+ export const revokeRefreshTokensFn = defineFunction('auth.revoke_refresh_tokens', {
416
+ args: [arg('p_user_id', 'uuid'), arg('p_family_id', 'uuid', { default: sql`NULL::uuid` }), arg('p_repository_id', 'uuid', { default: sql`NULL::uuid` })],
417
+ returns: 'integer',
418
+ language: 'plpgsql',
419
+ security: 'definer',
420
+ searchPath: ['auth', 'public', 'pg_temp'],
421
+ body: sql`
422
+ DECLARE
423
+ v_count INTEGER;
424
+ BEGIN
425
+ IF p_family_id IS NOT NULL THEN
426
+ DELETE FROM auth.refresh_tokens
427
+ WHERE user_id = p_user_id AND family_id = p_family_id;
428
+ ELSIF p_repository_id IS NOT NULL THEN
429
+ DELETE FROM auth.refresh_tokens
430
+ WHERE user_id = p_user_id AND repository_id = p_repository_id;
431
+ ELSE
432
+ DELETE FROM auth.refresh_tokens
433
+ WHERE user_id = p_user_id;
434
+ END IF;
435
+
436
+ GET DIAGNOSTICS v_count = ROW_COUNT;
437
+ RETURN v_count;
438
+ END;
439
+ `,
440
+ });
441
+
442
+ export const roleFn = defineFunction('auth.role', {
443
+ returns: 'text',
444
+ language: 'sql',
445
+ volatility: 'stable',
446
+ abilities: [can('execute', { role: 'admin' }), can('execute', { role: 'anon' }), can('execute', { role: 'authenticated' })],
447
+ body: sql`
448
+ SELECT COALESCE(
449
+ nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role',
450
+ 'anon'
451
+ );
452
+ `,
453
+ });
454
+
455
+ export const signInFn = defineFunction('auth.sign_in', {
456
+ args: [arg('p_email', 'text'), arg('p_password', 'text')],
457
+ returns: 'jsonb',
458
+ language: 'plpgsql',
459
+ security: 'definer',
460
+ searchPath: ['auth', 'public', 'pg_temp'],
461
+ body: sql`
462
+ DECLARE
463
+ v_user RECORD;
464
+ v_valid BOOLEAN;
465
+ BEGIN
466
+ SELECT * INTO v_user FROM auth.users
467
+ WHERE email = lower(trim(p_email)) AND deleted_at IS NULL;
468
+
469
+ IF NOT FOUND THEN
470
+ -- Timing-safe: still run bcrypt to prevent user enumeration
471
+ PERFORM auth.verify_password(p_password,
472
+ '$2a$10$abcdefghijklmnopqrstuuABCDEFGHIJKLMNOPQRSTUVWXYZ012345');
473
+ RAISE EXCEPTION 'invalid_credentials' USING ERRCODE = 'P0001';
474
+ END IF;
475
+
476
+ -- Check banned
477
+ IF v_user.banned_until IS NOT NULL AND v_user.banned_until > now() THEN
478
+ RAISE EXCEPTION 'user_banned' USING ERRCODE = 'P0006';
479
+ END IF;
480
+
481
+ v_valid := auth.verify_password(p_password, v_user.encrypted_password);
482
+ IF NOT v_valid THEN
483
+ RAISE EXCEPTION 'invalid_credentials' USING ERRCODE = 'P0001';
484
+ END IF;
485
+
486
+ -- Update last sign in
487
+ UPDATE auth.users SET last_sign_in_at = now(), updated_at = now()
488
+ WHERE id = v_user.id;
489
+
490
+ RETURN jsonb_build_object(
491
+ 'sub', v_user.id,
492
+ 'email', v_user.email,
493
+ 'role', v_user.role
494
+ );
495
+ END;
496
+ `,
497
+ });
498
+
499
+ export const signInJwtFn = defineFunction('auth.sign_in_jwt', {
500
+ args: [arg('p_email', 'text'), arg('p_password', 'text')],
501
+ returns: 'jsonb',
502
+ language: 'plpgsql',
503
+ security: 'definer',
504
+ searchPath: ['auth', 'public', 'pg_temp'],
505
+ body: sql`
506
+ DECLARE
507
+ v_payload JSONB;
508
+ v_claims JSONB;
509
+ v_token TEXT;
510
+ v_refresh JSONB;
511
+ BEGIN
512
+ v_payload := auth.sign_in(p_email, p_password);
513
+
514
+ v_claims := v_payload || jsonb_build_object(
515
+ 'iat', extract(epoch FROM now())::bigint,
516
+ 'exp', (extract(epoch FROM now()) + 3600)::bigint
517
+ );
518
+ v_token := auth.sign_jwt(v_claims);
519
+
520
+ v_refresh := auth.issue_refresh_token((v_payload->>'sub')::uuid);
521
+
522
+ RETURN jsonb_build_object(
523
+ 'access_token', v_token,
524
+ 'refresh_token', v_refresh->>'token',
525
+ 'user', v_payload
526
+ );
527
+ END;
528
+ `,
529
+ });
530
+
531
+ export const signInSessionFn = defineFunction('auth.sign_in_session', {
532
+ args: [arg('p_email', 'text'), arg('p_password', 'text'), arg('p_ip_address', 'text', { default: sql`NULL::text` }), arg('p_user_agent', 'text', { default: sql`NULL::text` }), arg('p_device_id', 'uuid', { default: sql`NULL::uuid` })],
533
+ returns: 'jsonb',
534
+ language: 'plpgsql',
535
+ security: 'definer',
536
+ searchPath: ['auth', 'public', 'pg_temp'],
537
+ abilities: [can('execute', { role: 'anon' })],
538
+ body: sql`
539
+ DECLARE
540
+ v_user JSONB;
541
+ v_refresh JSONB;
542
+ BEGIN
543
+ -- Raises invalid_credentials (timing-safe) on a bad email/password — fails closed.
544
+ v_user := auth.sign_in(p_email, p_password);
545
+ v_refresh := auth.issue_refresh_token(
546
+ (v_user->>'sub')::uuid, NULL, p_ip_address, p_user_agent, p_device_id
547
+ );
548
+ RETURN v_user || jsonb_build_object('refresh_token', v_refresh->>'token');
549
+ END;
550
+ `,
551
+ });
552
+
553
+ export const signJwtFn = defineFunction('auth.sign_jwt', {
554
+ args: [arg('p_payload', 'jsonb')],
555
+ returns: 'text',
556
+ language: 'plpgsql',
557
+ security: 'definer',
558
+ searchPath: ['auth', 'public', 'pg_temp'],
559
+ body: sql`
560
+ DECLARE
561
+ v_header TEXT;
562
+ v_payload TEXT;
563
+ v_signing_input TEXT;
564
+ v_signature TEXT;
565
+ v_secret TEXT;
566
+ BEGIN
567
+ v_secret := current_setting('app.jwt_secret', true);
568
+ IF v_secret IS NULL OR v_secret = '' THEN
569
+ RAISE EXCEPTION 'app.jwt_secret not set'
570
+ USING HINT = 'Call SET LOCAL app.jwt_secret before auth.sign_jwt';
571
+ END IF;
572
+
573
+ -- Base64url encode header (replace strips MIME newlines from encode())
574
+ v_header := rtrim(translate(
575
+ replace(encode(convert_to('{"alg":"HS256","typ":"JWT"}', 'utf8'), 'base64'), E'\\n', ''),
576
+ '+/=', '-_ '
577
+ ));
578
+
579
+ -- Base64url encode payload
580
+ v_payload := rtrim(translate(
581
+ replace(encode(convert_to(p_payload::text, 'utf8'), 'base64'), E'\\n', ''),
582
+ '+/=', '-_ '
583
+ ));
584
+
585
+ v_signing_input := v_header || '.' || v_payload;
586
+
587
+ -- HMAC-SHA256 signature via pgcrypto
588
+ v_signature := rtrim(translate(
589
+ replace(encode(hmac(v_signing_input, v_secret, 'sha256'), 'base64'), E'\\n', ''),
590
+ '+/=', '-_ '
591
+ ));
592
+
593
+ RETURN v_signing_input || '.' || v_signature;
594
+ END;
595
+ `,
596
+ });
597
+
598
+ export const signUpFn = defineFunction('auth.sign_up', {
599
+ args: [arg('p_email', 'text'), arg('p_password', 'text'), arg('p_user_meta_data', 'jsonb', { default: sql`'{}'::jsonb` })],
600
+ returns: 'jsonb',
601
+ language: 'plpgsql',
602
+ security: 'definer',
603
+ searchPath: ['auth', 'public', 'pg_temp'],
604
+ body: sql`
605
+ DECLARE
606
+ v_user RECORD;
607
+ v_encrypted TEXT;
608
+ BEGIN
609
+ -- Check if email already exists
610
+ SELECT id INTO v_user FROM auth.users
611
+ WHERE email = lower(trim(p_email)) AND deleted_at IS NULL;
612
+
613
+ IF FOUND THEN
614
+ RAISE EXCEPTION 'email_exists' USING ERRCODE = 'P0001';
615
+ END IF;
616
+
617
+ v_encrypted := auth.hash_password(p_password);
618
+
619
+ INSERT INTO auth.users (
620
+ email, encrypted_password, role, aud,
621
+ raw_user_meta_data, raw_app_meta_data
622
+ )
623
+ VALUES (
624
+ lower(trim(p_email)), v_encrypted, 'authenticated', 'authenticated',
625
+ p_user_meta_data, jsonb_build_object('provider', 'email', 'providers', jsonb_build_array('email'))
626
+ )
627
+ RETURNING * INTO v_user;
628
+
629
+ -- Create email identity
630
+ INSERT INTO auth.identities (user_id, provider, provider_id, identity_data, email)
631
+ VALUES (v_user.id, 'email', v_user.id::text, p_user_meta_data, v_user.email);
632
+
633
+ RETURN jsonb_build_object(
634
+ 'sub', v_user.id,
635
+ 'email', v_user.email,
636
+ 'role', v_user.role
637
+ );
638
+ END;
639
+ `,
640
+ });
641
+
642
+ export const signUpJwtFn = defineFunction('auth.sign_up_jwt', {
643
+ args: [arg('p_email', 'text'), arg('p_password', 'text'), arg('p_user_meta_data', 'jsonb', { default: sql`'{}'::jsonb` })],
644
+ returns: 'jsonb',
645
+ language: 'plpgsql',
646
+ security: 'definer',
647
+ searchPath: ['auth', 'public', 'pg_temp'],
648
+ body: sql`
649
+ DECLARE
650
+ v_payload JSONB;
651
+ v_claims JSONB;
652
+ v_token TEXT;
653
+ v_refresh JSONB;
654
+ BEGIN
655
+ v_payload := auth.sign_up(p_email, p_password, p_user_meta_data);
656
+
657
+ v_claims := v_payload || jsonb_build_object(
658
+ 'iat', extract(epoch FROM now())::bigint,
659
+ 'exp', (extract(epoch FROM now()) + 3600)::bigint
660
+ );
661
+ v_token := auth.sign_jwt(v_claims);
662
+
663
+ v_refresh := auth.issue_refresh_token((v_payload->>'sub')::uuid);
664
+
665
+ RETURN jsonb_build_object(
666
+ 'access_token', v_token,
667
+ 'refresh_token', v_refresh->>'token',
668
+ 'user', v_payload
669
+ );
670
+ END;
671
+ `,
672
+ });
673
+
674
+ export const signUpSessionFn = defineFunction('auth.sign_up_session', {
675
+ args: [arg('p_email', 'text'), arg('p_password', 'text'), arg('p_user_meta_data', 'jsonb', { default: sql`'{}'::jsonb` }), arg('p_ip_address', 'text', { default: sql`NULL::text` }), arg('p_user_agent', 'text', { default: sql`NULL::text` }), arg('p_device_id', 'uuid', { default: sql`NULL::uuid` })],
676
+ returns: 'jsonb',
677
+ language: 'plpgsql',
678
+ security: 'definer',
679
+ searchPath: ['auth', 'public', 'pg_temp'],
680
+ abilities: [can('execute', { role: 'anon' })],
681
+ body: sql`
682
+ DECLARE
683
+ v_user JSONB;
684
+ v_refresh JSONB;
685
+ BEGIN
686
+ -- Raises email_exists on a duplicate. Creates auth.users only; the caller creates
687
+ -- the public profile as the authenticated user (invoker INSERT under RLS).
688
+ v_user := auth.sign_up(p_email, p_password, p_user_meta_data);
689
+ v_refresh := auth.issue_refresh_token(
690
+ (v_user->>'sub')::uuid, NULL, p_ip_address, p_user_agent, p_device_id
691
+ );
692
+ RETURN v_user || jsonb_build_object('refresh_token', v_refresh->>'token');
693
+ END;
694
+ `,
695
+ });
696
+
697
+ export const uidFn = defineFunction('auth.uid', {
698
+ returns: 'uuid',
699
+ language: 'sql',
700
+ volatility: 'stable',
701
+ abilities: [can('execute', { role: 'admin' }), can('execute', { role: 'anon' }), can('execute', { role: 'authenticated' })],
702
+ body: sql`
703
+ SELECT COALESCE(
704
+ (nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'sub')::uuid,
705
+ NULL
706
+ );
707
+ `,
708
+ });
709
+
710
+ export const validateRefreshTokenFn = defineFunction('auth.validate_refresh_token', {
711
+ args: [arg('p_token', 'text'), arg('p_device_id', 'uuid', { default: sql`NULL::uuid` }), arg('p_ip_address', 'text', { default: sql`NULL::text` }), arg('p_user_agent', 'text', { default: sql`NULL::text` }), arg('p_ttl', 'interval', { default: sql`'30 days'::interval` })],
712
+ returns: 'jsonb',
713
+ language: 'plpgsql',
714
+ security: 'definer',
715
+ searchPath: ['auth', 'public', 'pg_temp'],
716
+ abilities: [can('execute', { role: 'anon' })],
717
+ body: sql`
718
+ DECLARE
719
+ v_token_hash TEXT;
720
+ v_row RECORD;
721
+ v_user_deleted TIMESTAMPTZ;
722
+ v_new_token JSONB;
723
+ BEGIN
724
+ -- 1. Hash the input token
725
+ v_token_hash := encode(digest(p_token, 'sha256'), 'hex');
726
+
727
+ -- 2. Look up with row lock (prevents concurrent refresh races)
728
+ SELECT id, user_id, family_id, status, expires_at, device_id, repository_id
729
+ INTO v_row
730
+ FROM auth.refresh_tokens
731
+ WHERE token_hash = v_token_hash
732
+ FOR UPDATE;
733
+
734
+ IF NOT FOUND THEN
735
+ RAISE EXCEPTION 'invalid_token' USING ERRCODE = 'P0001';
736
+ END IF;
737
+
738
+ -- 3. Consumed token reuse = theft detected -> revoke entire family
739
+ -- Return error as JSONB instead of RAISE because PostgreSQL rolls back
740
+ -- the DELETE when an exception propagates. Returning normally lets the
741
+ -- family deletion commit. The caller checks for result.error.
742
+ IF v_row.status = 'consumed' THEN
743
+ DELETE FROM auth.refresh_tokens WHERE family_id = v_row.family_id;
744
+ RETURN jsonb_build_object('error', 'token_reused');
745
+ END IF;
746
+
747
+ -- 4. Check expiry
748
+ IF v_row.expires_at < now() THEN
749
+ DELETE FROM auth.refresh_tokens WHERE id = v_row.id;
750
+ RAISE EXCEPTION 'token_expired' USING ERRCODE = 'P0003';
751
+ END IF;
752
+
753
+ -- 5. Check user is not deleted
754
+ SELECT deleted_at INTO v_user_deleted
755
+ FROM auth.users
756
+ WHERE id = v_row.user_id;
757
+
758
+ IF v_user_deleted IS NOT NULL THEN
759
+ DELETE FROM auth.refresh_tokens WHERE user_id = v_row.user_id;
760
+ RAISE EXCEPTION 'user_disabled' USING ERRCODE = 'P0004';
761
+ END IF;
762
+
763
+ -- 6. Device binding: if token was issued to a specific device, verify it
764
+ IF v_row.device_id IS NOT NULL AND p_device_id IS NOT NULL
765
+ AND v_row.device_id IS DISTINCT FROM p_device_id THEN
766
+ DELETE FROM auth.refresh_tokens WHERE family_id = v_row.family_id;
767
+ RAISE EXCEPTION 'device_mismatch' USING ERRCODE = 'P0005';
768
+ END IF;
769
+
770
+ -- 7. Rotate: mark consumed, issue new token in same family
771
+ UPDATE auth.refresh_tokens
772
+ SET status = 'consumed'
773
+ WHERE id = v_row.id;
774
+
775
+ -- Inherit repository_id from the original token
776
+ v_new_token := auth.issue_refresh_token(
777
+ v_row.user_id,
778
+ v_row.family_id,
779
+ p_ip_address,
780
+ p_user_agent,
781
+ v_row.device_id,
782
+ v_row.repository_id,
783
+ p_ttl
784
+ );
785
+
786
+ RETURN v_new_token || jsonb_build_object('userId', v_row.user_id);
787
+ END;
788
+ `,
789
+ });
790
+
791
+ export const verifyJwtFn = defineFunction('auth.verify_jwt', {
792
+ args: [arg('p_token', 'text')],
793
+ returns: 'jsonb',
794
+ language: 'plpgsql',
795
+ security: 'definer',
796
+ searchPath: ['auth', 'public', 'pg_temp'],
797
+ body: sql`
798
+ DECLARE
799
+ v_parts TEXT[];
800
+ v_signing_input TEXT;
801
+ v_expected_sig TEXT;
802
+ v_payload_json JSONB;
803
+ v_exp BIGINT;
804
+ v_secret TEXT;
805
+ BEGIN
806
+ v_secret := current_setting('app.jwt_secret', true);
807
+ IF v_secret IS NULL OR v_secret = '' THEN
808
+ RAISE EXCEPTION 'app.jwt_secret not set'
809
+ USING HINT = 'Call SET LOCAL app.jwt_secret before auth.verify_jwt';
810
+ END IF;
811
+
812
+ v_parts := string_to_array(p_token, '.');
813
+ IF array_length(v_parts, 1) != 3 THEN
814
+ RETURN NULL;
815
+ END IF;
816
+
817
+ v_signing_input := v_parts[1] || '.' || v_parts[2];
818
+
819
+ -- Recompute expected signature (replace strips MIME newlines)
820
+ v_expected_sig := rtrim(translate(
821
+ replace(encode(hmac(v_signing_input, v_secret, 'sha256'), 'base64'), E'\\n', ''),
822
+ '+/=', '-_ '
823
+ ));
824
+
825
+ IF v_parts[3] != v_expected_sig THEN
826
+ RETURN NULL;
827
+ END IF;
828
+
829
+ -- Decode payload: base64url -> base64 -> bytes -> text -> jsonb
830
+ v_payload_json := convert_from(
831
+ decode(
832
+ translate(v_parts[2], '-_', '+/') ||
833
+ repeat('=', (4 - length(v_parts[2]) % 4) % 4),
834
+ 'base64'
835
+ ),
836
+ 'utf8'
837
+ )::jsonb;
838
+
839
+ -- Check expiration
840
+ v_exp := (v_payload_json->>'exp')::bigint;
841
+ IF v_exp IS NOT NULL AND v_exp < extract(epoch FROM now())::bigint THEN
842
+ RETURN NULL;
843
+ END IF;
844
+
845
+ RETURN v_payload_json;
846
+ END;
847
+ `,
848
+ });
849
+
850
+ export const verifyOneTimeTokenFn = defineFunction('auth.verify_one_time_token', {
851
+ args: [arg('p_token', 'text'), arg('p_token_type', 'text')],
852
+ returns: 'jsonb',
853
+ language: 'plpgsql',
854
+ security: 'definer',
855
+ searchPath: ['auth', 'public', 'pg_temp'],
856
+ body: sql`
857
+ DECLARE
858
+ v_token_hash TEXT;
859
+ v_row RECORD;
860
+ BEGIN
861
+ v_token_hash := encode(digest(p_token, 'sha256'), 'hex');
862
+
863
+ SELECT user_id, relates_to INTO v_row
864
+ FROM auth.one_time_tokens
865
+ WHERE token_hash = v_token_hash
866
+ AND token_type = p_token_type
867
+ FOR UPDATE;
868
+
869
+ IF NOT FOUND THEN
870
+ RAISE EXCEPTION 'invalid_token' USING ERRCODE = 'P0001';
871
+ END IF;
872
+
873
+ -- Delete the token (one-time use)
874
+ DELETE FROM auth.one_time_tokens
875
+ WHERE token_hash = v_token_hash AND token_type = p_token_type;
876
+
877
+ RETURN jsonb_build_object(
878
+ 'userId', v_row.user_id,
879
+ 'relatesTo', v_row.relates_to
880
+ );
881
+ END;
882
+ `,
883
+ });
884
+
885
+ export const verifyPasswordFn = defineFunction('auth.verify_password', {
886
+ args: [arg('p_password', 'text'), arg('p_hash', 'text')],
887
+ returns: 'boolean',
888
+ language: 'plpgsql',
889
+ security: 'definer',
890
+ searchPath: ['auth', 'public', 'pg_temp'],
891
+ body: sql`
892
+ DECLARE
893
+ v_normalized TEXT;
894
+ BEGIN
895
+ -- pgcrypto crypt() only supports $2a$ prefix; bcryptjs produces $2b$.
896
+ -- $2a$ and $2b$ are byte-identical algorithms — normalize prefix for compat.
897
+ v_normalized := regexp_replace(p_hash, '^\\$2[by]\\$', '$2a$');
898
+ RETURN crypt(p_password, v_normalized) = v_normalized;
899
+ END;
900
+ `,
901
+ });
902
+
903
+
904
+ // ---------------------------------------------------------------------------
905
+ // The option groups — `generateAuthSQL`'s six switches, as descriptor sets.
906
+ //
907
+ // A thunk could branch at call time; a descriptor array cannot. Rather than make `authModule`
908
+ // a function (which would break every consumer doing `import { authModule }`), the default
909
+ // module stays a static export and `defineAuthModule(opts)` is the parameterized door.
910
+ // ---------------------------------------------------------------------------
911
+
912
+ /** Password hashing + JWT sign/verify. The internals every other function calls. Always on. */
913
+ export const coreFunctions = [hashPasswordFn, verifyPasswordFn, signJwtFn, verifyJwtFn];
914
+
915
+ /** The refresh-token family (RFC 6819 rotation). Always on. */
916
+ export const refreshTokenFunctions = [
917
+ issueRefreshTokenFn, validateRefreshTokenFn, revokeRefreshTokensFn, cleanupRefreshTokensFn,
918
+ ];
919
+
920
+ /** `auth.uid()` / `auth.role()` / `auth.jwt()` — called FROM RLS policies, so granted to the
921
+ * request roles rather than left to the owner. Default on. */
922
+ export const accessorFunctions = [uidFn, roleFn, jwtFn];
923
+
924
+ /** One-time token mint/verify. Default on. */
925
+ export const oneTimeTokenFunctions = [createOneTimeTokenFn, verifyOneTimeTokenFn];
926
+
927
+ /** Token-gated account actions. Default on. */
928
+ export const tokenActionFunctions = [confirmEmailFn, resetPasswordFn, changePasswordFn];
929
+
930
+ /** The anon-safe session wrappers — the only sign-in surface anon may execute. Default on. */
931
+ export const sessionWrapperFunctions = [signInSessionFn, signUpSessionFn];
932
+
933
+ /** The raw sign_in/sign_up/oauth minters. Default on; never granted to anon. */
934
+ export const defaultAuthFunctions = [signUpFn, signInFn, oauthSignInFn];
935
+
936
+ /** JWT convenience wrappers. Default OFF, matching `generateAuthSQL`. */
937
+ export const jwtWrapperFunctions = [signInJwtFn, signUpJwtFn, oauthSignInJwtFn];
938
+
939
+ /** Mirrors {@link AuthSQLOptions} in sql.ts — same defaults, same meanings. */
940
+ export interface AuthFunctionOptions {
941
+ accessors?: boolean;
942
+ oneTimeTokens?: boolean;
943
+ tokenActions?: boolean;
944
+ sessionWrappers?: boolean;
945
+ defaultAuthFunctions?: boolean;
946
+ jwtWrappers?: boolean;
947
+ }
948
+
949
+ /**
950
+ * The declared function set for a given option shape. Same selection `generateAuthSQL(opts)`
951
+ * makes, so the two lanes stay comparable object-for-object — which is what the equivalence
952
+ * test asserts.
953
+ */
954
+ export function authFunctions(opts: AuthFunctionOptions = {}) {
955
+ return [
956
+ ...coreFunctions,
957
+ ...refreshTokenFunctions,
958
+ ...(opts.accessors !== false ? accessorFunctions : []),
959
+ ...(opts.oneTimeTokens !== false ? oneTimeTokenFunctions : []),
960
+ ...(opts.tokenActions !== false ? tokenActionFunctions : []),
961
+ ...(opts.sessionWrappers !== false ? sessionWrapperFunctions : []),
962
+ ...(opts.defaultAuthFunctions !== false ? defaultAuthFunctions : []),
963
+ ...(opts.jwtWrappers === true ? jwtWrapperFunctions : []),
964
+ ];
965
+ }
@@ -17,7 +17,7 @@ export { MfaFactor } from './mfa-factor';
17
17
  export { MfaChallenge } from './mfa-challenge';
18
18
  export { OneTimeToken } from './one-time-token';
19
19
 
20
- import { defineModule } from '@everystack/model';
20
+ import { defineModule, type Module } from '@everystack/model';
21
21
  import { User } from './user';
22
22
  import { Identity } from './identity';
23
23
  import { RefreshToken } from './refresh-token';
@@ -25,19 +25,41 @@ import { Device } from './device';
25
25
  import { MfaFactor } from './mfa-factor';
26
26
  import { MfaChallenge } from './mfa-challenge';
27
27
  import { OneTimeToken } from './one-time-token';
28
- import { generateAuthSQL } from '../sql';
28
+ import { authFunctions, type AuthFunctionOptions } from './functions';
29
+
30
+ export * from './functions';
29
31
 
30
32
  /** All auth-owned models, for the app to spread into its db:generate model list. */
31
33
  export const authModels = [User, Identity, RefreshToken, Device, MfaFactor, MfaChallenge, OneTimeToken];
32
34
 
33
35
  /**
34
- * The auth Module — the package's complete DB slice: the seven tables (schema + authz), the
35
- * `pgcrypto` extension its functions need, and the SECURITY DEFINER functions themselves
36
- * (`generateAuthSQL` — hashing, JWT, refresh tokens, one-time tokens, session/token wrappers,
37
- * the default sign_in/sign_up). The app composes this so `db:generate` emits auth end to end.
36
+ * The auth Module for a given option shape — the package's complete DB slice: the seven tables
37
+ * (schema + authz), the `pgcrypto` extension its functions need, and the SECURITY DEFINER
38
+ * functions as DECLARED derived objects.
39
+ *
40
+ * THE FUNCTIONS MOVED FROM `functions` TO `derived`, AND THAT IS THE WHOLE POINT. The `functions`
41
+ * thunk is read by exactly one command — `db:generate --init` — so an app that adopted brownfield
42
+ * installed none of them and every gate reported clean, because a reconciler can only compare
43
+ * what was declared. Declared as `derived`, they are what `db:reconcile` deploys and audits, they
44
+ * are content-hashed and dependency-ordered, and the compiler revokes PUBLIC on every one of them
45
+ * unconditionally.
46
+ *
47
+ * A function is not expressible as a Model, so this is not the Models growing a new power — it is
48
+ * the package declaring its own compute layer through the vocabulary that already existed for it.
49
+ */
50
+ export function defineAuthModule(opts: AuthFunctionOptions = {}): Module {
51
+ return defineModule({
52
+ models: authModels,
53
+ extensions: ['pgcrypto'],
54
+ derived: authFunctions(opts),
55
+ });
56
+ }
57
+
58
+ /**
59
+ * The auth Module with default options — what an app composes when it wants auth as shipped.
60
+ *
61
+ * Deliberately still a VALUE, not a function call. Consumers write `modules = [appModule,
62
+ * authModule]`; making it `authModule()` would break every one of them for the benefit of the
63
+ * minority who need to change the option set, and those have {@link defineAuthModule}.
38
64
  */
39
- export const authModule = defineModule({
40
- models: authModels,
41
- extensions: ['pgcrypto'],
42
- functions: generateAuthSQL,
43
- });
65
+ export const authModule = defineAuthModule();
package/src/sql.ts CHANGED
@@ -37,7 +37,8 @@ AS $$
37
37
  BEGIN
38
38
  RETURN crypt(p_password, gen_salt('bf', 10));
39
39
  END;
40
- $$;`;
40
+ $$;
41
+ REVOKE EXECUTE ON FUNCTION auth.hash_password FROM PUBLIC;`;
41
42
 
42
43
  const VERIFY_PASSWORD = `\
43
44
  CREATE OR REPLACE FUNCTION auth.verify_password(p_password TEXT, p_hash TEXT)
@@ -54,7 +55,8 @@ BEGIN
54
55
  v_normalized := regexp_replace(p_hash, '^\\$2[by]\\$', '$2a$');
55
56
  RETURN crypt(p_password, v_normalized) = v_normalized;
56
57
  END;
57
- $$;`;
58
+ $$;
59
+ REVOKE EXECUTE ON FUNCTION auth.verify_password FROM PUBLIC;`;
58
60
 
59
61
  const SIGN_JWT = `\
60
62
  CREATE OR REPLACE FUNCTION auth.sign_jwt(p_payload JSONB)
@@ -98,7 +100,8 @@ BEGIN
98
100
 
99
101
  RETURN v_signing_input || '.' || v_signature;
100
102
  END;
101
- $$;`;
103
+ $$;
104
+ REVOKE EXECUTE ON FUNCTION auth.sign_jwt FROM PUBLIC;`;
102
105
 
103
106
  const VERIFY_JWT = `\
104
107
  CREATE OR REPLACE FUNCTION auth.verify_jwt(p_token TEXT)
@@ -156,7 +159,8 @@ BEGIN
156
159
 
157
160
  RETURN v_payload_json;
158
161
  END;
159
- $$;`;
162
+ $$;
163
+ REVOKE EXECUTE ON FUNCTION auth.verify_jwt FROM PUBLIC;`;
160
164
 
161
165
  // =============================================================================
162
166
  // REFRESH TOKEN FUNCTIONS (RFC 6819)
@@ -391,6 +395,11 @@ REVOKE EXECUTE ON FUNCTION auth.cleanup_refresh_tokens FROM PUBLIC;`;
391
395
  // SUPABASE-COMPATIBLE ACCESSOR FUNCTIONS
392
396
  // =============================================================================
393
397
 
398
+ // The three accessors are SECURITY INVOKER and are called FROM RLS POLICY EXPRESSIONS, which
399
+ // evaluate as the querying role. So PUBLIC comes off — no blanket grant — and the everystack
400
+ // role chain is granted back explicitly. A bare `REVOKE ... FROM PUBLIC` with no grant-back
401
+ // would break every policy that calls them, which is exactly why they must not be lumped in
402
+ // with the internal SECDEF helpers below.
394
403
  const AUTH_UID = `\
395
404
  CREATE OR REPLACE FUNCTION auth.uid()
396
405
  RETURNS uuid
@@ -400,7 +409,9 @@ AS $$
400
409
  (nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'sub')::uuid,
401
410
  NULL
402
411
  );
403
- $$;`;
412
+ $$;
413
+ REVOKE EXECUTE ON FUNCTION auth.uid FROM PUBLIC;
414
+ GRANT EXECUTE ON FUNCTION auth.uid() TO anon, authenticated, admin;`;
404
415
 
405
416
  const AUTH_ROLE = `\
406
417
  CREATE OR REPLACE FUNCTION auth.role()
@@ -411,7 +422,9 @@ AS $$
411
422
  nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role',
412
423
  'anon'
413
424
  );
414
- $$;`;
425
+ $$;
426
+ REVOKE EXECUTE ON FUNCTION auth.role FROM PUBLIC;
427
+ GRANT EXECUTE ON FUNCTION auth.role() TO anon, authenticated, admin;`;
415
428
 
416
429
  const AUTH_JWT = `\
417
430
  CREATE OR REPLACE FUNCTION auth.jwt()
@@ -422,7 +435,9 @@ AS $$
422
435
  nullif(current_setting('request.jwt.claims', true), '')::jsonb,
423
436
  '{}'::jsonb
424
437
  );
425
- $$;`;
438
+ $$;
439
+ REVOKE EXECUTE ON FUNCTION auth.jwt FROM PUBLIC;
440
+ GRANT EXECUTE ON FUNCTION auth.jwt() TO anon, authenticated, admin;`;
426
441
 
427
442
  // =============================================================================
428
443
  // ONE-TIME TOKEN FUNCTIONS