@everystack/auth 0.2.5 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/schema.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  /**
2
- * Auth schema — default tables for everystack auth.
2
+ * Auth schema — the default tables for everystack auth, as drizzle-orm runtime tables.
3
+ *
4
+ * DERIVED from the package-owned Models (`./models`) — each Model is the single source
5
+ * of its table's columns, schema, indexes, and authz; this just materializes the
6
+ * drizzle-orm runtime objects the auth handlers query. Edit the Models, not this file.
3
7
  *
4
8
  * Tables:
5
9
  * users — Supabase-compatible user table (batteries included, replaceable)
@@ -12,305 +16,30 @@
12
16
  *
13
17
  * The default schema works out of the box. Apps that need a different user model
14
18
  * replace the postgres functions (sign_in, sign_up, oauth_sign_in) — the function
15
- * signatures are the stable API, not the schema.
16
- *
17
- * Column names match Supabase for zero-friction migration.
19
+ * signatures are the stable API, not the schema. Column names match Supabase for
20
+ * zero-friction migration.
18
21
  */
19
22
 
23
+ import { pgSchema } from 'drizzle-orm/pg-core';
24
+ import { toDrizzleTable } from '@everystack/model/drizzle';
20
25
  import {
21
- pgSchema,
22
- boolean,
23
- text,
24
- timestamp,
25
- uuid,
26
- varchar,
27
- integer,
28
- index,
29
- uniqueIndex,
30
- jsonb,
31
- } from 'drizzle-orm/pg-core';
32
- import { sql } from 'drizzle-orm';
33
-
26
+ User,
27
+ Identity,
28
+ RefreshToken,
29
+ Device,
30
+ MfaFactor,
31
+ MfaChallenge,
32
+ OneTimeToken,
33
+ } from './models/index';
34
+
35
+ /** The `auth` Postgres schema namespace. Kept for consumers that build against it directly. */
34
36
  const authSchema = pgSchema('auth');
35
37
  export { authSchema };
36
38
 
37
- // =============================================================================
38
- // USERS
39
- // =============================================================================
40
-
41
- /**
42
- * Default user table. Supabase-compatible column names for zero-friction migration.
43
- *
44
- * Apps that need a different user model can replace this table entirely —
45
- * the postgres auth functions (sign_in, sign_up, oauth_sign_in) are the real API.
46
- * Replace the functions, keep the primitives (hash_password, sign_jwt, etc.).
47
- */
48
- export const users = authSchema.table(
49
- 'users',
50
- {
51
- id: uuid('id').primaryKey().defaultRandom(),
52
- instanceId: uuid('instance_id'), // Supabase compat
53
- aud: varchar('aud', { length: 255 }).default('authenticated'),
54
- role: varchar('role', { length: 255 }).default('authenticated'),
55
- email: varchar('email', { length: 255 }).unique(),
56
- encryptedPassword: varchar('encrypted_password', { length: 255 }),
57
- emailConfirmedAt: timestamp('email_confirmed_at', { withTimezone: true }),
58
- phone: varchar('phone', { length: 255 }),
59
- phoneConfirmedAt: timestamp('phone_confirmed_at', { withTimezone: true }),
60
- rawAppMetaData: jsonb('raw_app_meta_data'),
61
- rawUserMetaData: jsonb('raw_user_meta_data'),
62
- lastSignInAt: timestamp('last_sign_in_at', { withTimezone: true }),
63
- bannedUntil: timestamp('banned_until', { withTimezone: true }),
64
- deletedAt: timestamp('deleted_at', { withTimezone: true }),
65
- isAnonymous: boolean('is_anonymous').notNull().default(false),
66
- createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
67
- updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
68
- },
69
- (table) => ({
70
- emailIdx: uniqueIndex('users_email_idx').on(table.email),
71
- instanceIdx: index('users_instance_id_idx').on(table.instanceId),
72
- })
73
- );
74
-
75
- // =============================================================================
76
- // IDENTITIES
77
- // =============================================================================
78
-
79
- /**
80
- * OAuth provider links. Supabase-compatible.
81
- *
82
- * One row per (provider, provider_id) pair. A user can have multiple identities
83
- * (email + google + apple). The identity_data jsonb stores provider-specific claims.
84
- */
85
- export const identities = authSchema.table(
86
- 'identities',
87
- {
88
- id: uuid('id').primaryKey().defaultRandom(),
89
- userId: uuid('user_id')
90
- .notNull()
91
- .references(() => users.id, { onDelete: 'cascade' }),
92
- provider: text('provider').notNull(),
93
- providerId: text('provider_id').notNull(),
94
- identityData: jsonb('identity_data'),
95
- email: text('email'),
96
- lastSignInAt: timestamp('last_sign_in_at', { withTimezone: true }),
97
- createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
98
- updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
99
- },
100
- (table) => ({
101
- providerUniqueIdx: uniqueIndex('identities_provider_id_provider_idx').on(
102
- table.providerId,
103
- table.provider
104
- ),
105
- userIdx: index('identities_user_idx').on(table.userId),
106
- })
107
- );
108
-
109
- // =============================================================================
110
- // REFRESH TOKENS
111
- // =============================================================================
112
-
113
- /**
114
- * Dedicated refresh token table.
115
- *
116
- * Security properties:
117
- * - Only SHA-256 hashes stored (never plaintext)
118
- * - familyId groups a rotation chain for theft detection
119
- * - status='consumed' tokens are kept; replay triggers family revocation (RFC 6819)
120
- * - ip_address/user_agent enable "active sessions" display
121
- * - device_id enables token-to-device binding
122
- *
123
- * PostgreSQL path uses SECURITY DEFINER functions with SELECT FOR UPDATE
124
- * for full atomicity. JS path uses consumed-token reuse detection as mitigation.
125
- */
126
- export const refreshTokens = authSchema.table(
127
- 'refresh_tokens',
128
- {
129
- id: uuid('id').primaryKey().defaultRandom(),
130
- repositoryId: uuid('repository_id'),
131
- userId: uuid('user_id')
132
- .notNull()
133
- .references(() => users.id, { onDelete: 'cascade' }),
134
- tokenHash: text('token_hash').notNull().unique(),
135
- familyId: uuid('family_id').notNull().defaultRandom(),
136
- status: text('status').notNull().default('active'), // 'active' | 'consumed'
137
- ipAddress: text('ip_address'),
138
- userAgent: text('user_agent'),
139
- deviceId: uuid('device_id'),
140
- expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
141
- createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
142
- },
143
- (table) => ({
144
- tokenIdx: uniqueIndex('refresh_tokens_token_idx').on(table.tokenHash),
145
- userIdx: index('refresh_tokens_user_idx').on(table.userId),
146
- repoIdx: index('refresh_tokens_repo_idx').on(table.repositoryId),
147
- repoUserIdx: index('refresh_tokens_repo_user_idx').on(table.repositoryId, table.userId),
148
- familyIdx: index('refresh_tokens_family_idx').on(table.familyId),
149
- expiresIdx: index('refresh_tokens_expires_idx').on(table.expiresAt),
150
- activeFamilyIdx: index('refresh_tokens_active_family_idx')
151
- .on(table.familyId)
152
- .where(sql`${table.status} = 'active'`),
153
- })
154
- );
155
-
156
- // =============================================================================
157
- // DEVICES
158
- // =============================================================================
159
-
160
- /**
161
- * Unified device registry + attestation.
162
- *
163
- * Auth-owned fields (device registry):
164
- * deviceId — persistent UUID from SecureStorage, survives reinstalls
165
- * userId — nullable, set when user signs in (anonymous on first launch)
166
- * pushToken — nullable, set when app enables push notifications
167
- * platform — 'ios' | 'android' | 'web'
168
- * name, model, osVersion, appVersion, userAgent — device metadata
169
- *
170
- * Security-consumed fields (attestation):
171
- * attestationType — 'apple_app_attest' | 'play_integrity' | null
172
- * keyId, publicKey, attestationData — hardware-backed keys
173
- * trustScore, attestationCount, lastAttestation — attestation state
174
- * revokedAt, revokedReason — device revocation
175
- */
176
- export const devices = authSchema.table(
177
- 'devices',
178
- {
179
- id: uuid('id').primaryKey().defaultRandom(),
180
- repositoryId: uuid('repository_id'),
181
- userId: uuid('user_id').references(() => users.id, { onDelete: 'set null' }),
182
- deviceId: text('device_id').notNull().unique(),
183
- platform: text('platform').notNull(), // 'ios' | 'android' | 'web'
184
- pushToken: text('push_token'),
185
- name: text('name'),
186
- model: text('model'),
187
- osVersion: text('os_version'),
188
- appVersion: text('app_version'),
189
- userAgent: text('user_agent'),
190
-
191
- // Attestation (consumed by @everystack/security)
192
- attestationType: text('attestation_type'),
193
- keyId: text('key_id'),
194
- publicKey: text('public_key'),
195
- attestationData: jsonb('attestation_data'),
196
- trustScore: integer('trust_score').notNull().default(100),
197
- attestationCount: integer('attestation_count').notNull().default(0),
198
- lastAttestation: timestamp('last_attestation', { withTimezone: true }),
199
-
200
- // Lifecycle
201
- registeredAt: timestamp('registered_at', { withTimezone: true }).defaultNow().notNull(),
202
- lastSeenAt: timestamp('last_seen_at', { withTimezone: true }).defaultNow().notNull(),
203
- revokedAt: timestamp('revoked_at', { withTimezone: true }),
204
- revokedReason: text('revoked_reason'),
205
- },
206
- (table) => ({
207
- repoIdx: index('devices_repo_idx').on(table.repositoryId),
208
- userIdx: index('devices_user_idx').on(table.userId),
209
- repoUserIdx: index('devices_repo_user_idx').on(table.repositoryId, table.userId),
210
- deviceIdIdx: uniqueIndex('devices_device_id_idx').on(table.deviceId),
211
- platformIdx: index('devices_platform_idx').on(table.platform),
212
- })
213
- );
214
-
215
- // =============================================================================
216
- // MFA FACTORS
217
- // =============================================================================
218
-
219
- /**
220
- * Multi-factor authentication factors. TOTP + WebAuthn.
221
- *
222
- * Users enroll factors (e.g. Google Authenticator) which start as 'unverified'.
223
- * After confirming a challenge, the factor becomes 'verified' and is required
224
- * for subsequent sign-ins.
225
- *
226
- * MFA handlers are a follow-up — this schema establishes the tables so apps
227
- * can implement their own MFA flows via postgres functions.
228
- */
229
- export const mfaFactors = authSchema.table(
230
- 'mfa_factors',
231
- {
232
- id: uuid('id').primaryKey().defaultRandom(),
233
- userId: uuid('user_id')
234
- .notNull()
235
- .references(() => users.id, { onDelete: 'cascade' }),
236
- friendlyName: text('friendly_name'),
237
- factorType: text('factor_type').notNull(), // 'totp' | 'webauthn'
238
- status: text('status').notNull().default('unverified'), // 'unverified' | 'verified'
239
- secret: text('secret'), // encrypted TOTP secret
240
- webAuthnCredential: jsonb('web_authn_credential'),
241
- phone: text('phone'), // future: phone MFA
242
- lastChallengedAt: timestamp('last_challenged_at', { withTimezone: true }),
243
- createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
244
- updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
245
- },
246
- (table) => ({
247
- userIdx: index('mfa_factors_user_idx').on(table.userId),
248
- userVerifiedIdx: index('mfa_factors_user_verified_idx')
249
- .on(table.userId)
250
- .where(sql`${table.status} = 'verified'`),
251
- })
252
- );
253
-
254
- // =============================================================================
255
- // MFA CHALLENGES
256
- // =============================================================================
257
-
258
- /**
259
- * Ephemeral MFA challenge state.
260
- *
261
- * Created when a user initiates MFA verification. Verified within a timeout
262
- * window. Can be cleaned up periodically.
263
- */
264
- export const mfaChallenges = authSchema.table(
265
- 'mfa_challenges',
266
- {
267
- id: uuid('id').primaryKey().defaultRandom(),
268
- factorId: uuid('factor_id')
269
- .notNull()
270
- .references(() => mfaFactors.id, { onDelete: 'cascade' }),
271
- ipAddress: text('ip_address'),
272
- otpCode: text('otp_code'),
273
- webAuthnSessionData: jsonb('web_authn_session_data'),
274
- verifiedAt: timestamp('verified_at', { withTimezone: true }),
275
- createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
276
- },
277
- (table) => ({
278
- factorIdx: index('mfa_challenges_factor_idx').on(table.factorId),
279
- })
280
- );
281
-
282
- // =============================================================================
283
- // ONE-TIME TOKENS
284
- // =============================================================================
285
-
286
- /**
287
- * One-time tokens for email confirmation, password reset, magic links.
288
- *
289
- * Each (user_id, token_type) pair is unique — creating a new token of the same
290
- * type replaces the previous one. Tokens are stored as SHA-256 hashes.
291
- *
292
- * Token types: confirmation, recovery, reauthentication,
293
- * email_change_new, email_change_current, phone_change
294
- */
295
- export const oneTimeTokens = authSchema.table(
296
- 'one_time_tokens',
297
- {
298
- id: uuid('id').primaryKey().defaultRandom(),
299
- userId: uuid('user_id')
300
- .notNull()
301
- .references(() => users.id, { onDelete: 'cascade' }),
302
- tokenType: text('token_type').notNull(),
303
- tokenHash: text('token_hash').notNull(),
304
- relatesTo: text('relates_to').notNull(), // email or phone
305
- createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
306
- updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
307
- },
308
- (table) => ({
309
- userTypeUniqueIdx: uniqueIndex('one_time_tokens_user_type_idx').on(
310
- table.userId,
311
- table.tokenType
312
- ),
313
- tokenHashIdx: index('one_time_tokens_token_hash_idx').on(table.tokenHash),
314
- relatesToIdx: index('one_time_tokens_relates_to_idx').on(table.relatesTo),
315
- })
316
- );
39
+ export const users = toDrizzleTable(User);
40
+ export const identities = toDrizzleTable(Identity);
41
+ export const refreshTokens = toDrizzleTable(RefreshToken);
42
+ export const devices = toDrizzleTable(Device);
43
+ export const mfaFactors = toDrizzleTable(MfaFactor);
44
+ export const mfaChallenges = toDrizzleTable(MfaChallenge);
45
+ export const oneTimeTokens = toDrizzleTable(OneTimeToken);
package/src/sql.ts CHANGED
@@ -32,6 +32,7 @@ CREATE OR REPLACE FUNCTION auth.hash_password(p_password TEXT)
32
32
  RETURNS TEXT
33
33
  LANGUAGE plpgsql
34
34
  SECURITY DEFINER
35
+ SET search_path = auth, public, pg_temp
35
36
  AS $$
36
37
  BEGIN
37
38
  RETURN crypt(p_password, gen_salt('bf', 10));
@@ -43,6 +44,7 @@ CREATE OR REPLACE FUNCTION auth.verify_password(p_password TEXT, p_hash TEXT)
43
44
  RETURNS BOOLEAN
44
45
  LANGUAGE plpgsql
45
46
  SECURITY DEFINER
47
+ SET search_path = auth, public, pg_temp
46
48
  AS $$
47
49
  DECLARE
48
50
  v_normalized TEXT;
@@ -59,6 +61,7 @@ CREATE OR REPLACE FUNCTION auth.sign_jwt(p_payload JSONB)
59
61
  RETURNS TEXT
60
62
  LANGUAGE plpgsql
61
63
  SECURITY DEFINER
64
+ SET search_path = auth, public, pg_temp
62
65
  AS $$
63
66
  DECLARE
64
67
  v_header TEXT;
@@ -102,6 +105,7 @@ CREATE OR REPLACE FUNCTION auth.verify_jwt(p_token TEXT)
102
105
  RETURNS JSONB
103
106
  LANGUAGE plpgsql
104
107
  SECURITY DEFINER
108
+ SET search_path = auth, public, pg_temp
105
109
  AS $$
106
110
  DECLARE
107
111
  v_parts TEXT[];
@@ -499,6 +503,118 @@ $$;
499
503
 
500
504
  REVOKE EXECUTE ON FUNCTION auth.verify_one_time_token FROM PUBLIC;`;
501
505
 
506
+ // =============================================================================
507
+ // TOKEN-GATED ACTION WRAPPERS
508
+ //
509
+ // These are the anon-safe entry points for the password-reset and email-confirm
510
+ // flows. They are SECURITY DEFINER (they update auth.users — a locked table), but the
511
+ // CALLER must already hold a valid one-time token, so they are credential-gated the
512
+ // same way auth.sign_in is password-gated: there is nothing to abuse without the token.
513
+ // The token-MINTING function (auth.create_one_time_token) is never granted to anon and
514
+ // runs only on the privileged worker path. These wrappers touch ONLY auth.users — auth
515
+ // columns. Public profile data lives in its own table and is never mixed in here.
516
+ // =============================================================================
517
+
518
+ const CONFIRM_EMAIL = `\
519
+ CREATE OR REPLACE FUNCTION auth.confirm_email(p_token TEXT)
520
+ RETURNS JSONB
521
+ LANGUAGE plpgsql
522
+ SECURITY DEFINER
523
+ SET search_path = auth, public, pg_temp
524
+ AS $$
525
+ DECLARE
526
+ v_result JSONB;
527
+ v_user_id UUID;
528
+ BEGIN
529
+ -- Raises on an invalid/expired/consumed token (fails closed). One-time use.
530
+ v_result := auth.verify_one_time_token(p_token, 'confirmation');
531
+ v_user_id := (v_result->>'userId')::uuid;
532
+
533
+ UPDATE auth.users
534
+ SET email_confirmed_at = now(), updated_at = now()
535
+ WHERE id = v_user_id;
536
+
537
+ RETURN jsonb_build_object('confirmed', true, 'userId', v_user_id);
538
+ END;
539
+ $$;
540
+
541
+ REVOKE EXECUTE ON FUNCTION auth.confirm_email FROM PUBLIC;`;
542
+
543
+ const RESET_PASSWORD = `\
544
+ CREATE OR REPLACE FUNCTION auth.reset_password(p_token TEXT, p_new_password TEXT)
545
+ RETURNS JSONB
546
+ LANGUAGE plpgsql
547
+ SECURITY DEFINER
548
+ SET search_path = auth, public, pg_temp
549
+ AS $$
550
+ DECLARE
551
+ v_result JSONB;
552
+ v_user_id UUID;
553
+ BEGIN
554
+ -- Raises on an invalid/expired/consumed token (fails closed). One-time use.
555
+ v_result := auth.verify_one_time_token(p_token, 'recovery');
556
+ v_user_id := (v_result->>'userId')::uuid;
557
+
558
+ UPDATE auth.users
559
+ SET encrypted_password = auth.hash_password(p_new_password), updated_at = now()
560
+ WHERE id = v_user_id;
561
+
562
+ -- A password reset invalidates every existing session — force re-login everywhere.
563
+ PERFORM auth.revoke_refresh_tokens(v_user_id);
564
+
565
+ RETURN jsonb_build_object('reset', true, 'userId', v_user_id);
566
+ END;
567
+ $$;
568
+
569
+ REVOKE EXECUTE ON FUNCTION auth.reset_password FROM PUBLIC;`;
570
+
571
+ // auth.change_password — the authenticated self-service password change. Not token-gated
572
+ // but credential-gated: the caller must be authenticated (auth.uid() from the verified
573
+ // JWT) AND prove the current password before the new one is set. SECURITY DEFINER because
574
+ // verifying the current password requires reading encrypted_password, which authenticated
575
+ // must never be granted. Granted to authenticated (not anon); called via withRole.
576
+ const CHANGE_PASSWORD = `\
577
+ CREATE OR REPLACE FUNCTION auth.change_password(p_current_password TEXT, p_new_password TEXT)
578
+ RETURNS JSONB
579
+ LANGUAGE plpgsql
580
+ SECURITY DEFINER
581
+ SET search_path = auth, public, pg_temp
582
+ AS $$
583
+ DECLARE
584
+ v_user_id UUID;
585
+ v_hash TEXT;
586
+ BEGIN
587
+ -- The caller is authenticated; their verified subject claim identifies them.
588
+ -- Read the claim inline so this wrapper has no dependency on the accessor functions.
589
+ v_user_id := (nullif(current_setting('request.jwt.claims', true), '')::jsonb->>'sub')::uuid;
590
+ IF v_user_id IS NULL THEN
591
+ RAISE EXCEPTION 'not_authenticated' USING ERRCODE = 'P0001';
592
+ END IF;
593
+
594
+ SELECT encrypted_password INTO v_hash FROM auth.users
595
+ WHERE id = v_user_id AND deleted_at IS NULL;
596
+ IF NOT FOUND OR v_hash IS NULL THEN
597
+ RAISE EXCEPTION 'invalid_credentials' USING ERRCODE = 'P0001';
598
+ END IF;
599
+
600
+ -- Verify the current password before changing it (the gate).
601
+ IF NOT auth.verify_password(p_current_password, v_hash) THEN
602
+ RAISE EXCEPTION 'invalid_credentials' USING ERRCODE = 'P0001';
603
+ END IF;
604
+
605
+ UPDATE auth.users
606
+ SET encrypted_password = auth.hash_password(p_new_password), updated_at = now()
607
+ WHERE id = v_user_id;
608
+
609
+ -- A password change invalidates every existing session — force re-login everywhere.
610
+ PERFORM auth.revoke_refresh_tokens(v_user_id);
611
+
612
+ RETURN jsonb_build_object('changed', true, 'userId', v_user_id);
613
+ END;
614
+ $$;
615
+
616
+ REVOKE EXECUTE ON FUNCTION auth.change_password FROM PUBLIC;`;
617
+
502
618
  // =============================================================================
503
619
  // DEFAULT AUTH FUNCTIONS (work against default auth.users schema)
504
620
  // =============================================================================
@@ -811,6 +927,76 @@ $$;
811
927
 
812
928
  REVOKE EXECUTE ON FUNCTION auth.oauth_sign_in_jwt FROM PUBLIC;`;
813
929
 
930
+ // =============================================================================
931
+ // SESSION WRAPPERS — the anon-safe login/signup entry points for a least-priv API
932
+ //
933
+ // Each is credential-gated (sign_in raises on a bad password; sign_up raises on a dup
934
+ // email), so the caller proves the credential before anything is minted. They verify or
935
+ // create the user AND issue the refresh token internally (as the definer/owner), so the
936
+ // raw minters — issue_refresh_token, sign_jwt — never need a grant to anon and stay
937
+ // REVOKE'd. They return only the caller's OWN identity plus the refresh token; the ACCESS
938
+ // JWT is minted by the handler in JS (so app-level enrichClaims, e.g. the `aud` claim,
939
+ // is preserved). They touch only auth.users; the public profile is created by a trigger.
940
+ // =============================================================================
941
+
942
+ const SIGN_IN_SESSION = `\
943
+ CREATE OR REPLACE FUNCTION auth.sign_in_session(
944
+ p_email TEXT,
945
+ p_password TEXT,
946
+ p_ip_address TEXT DEFAULT NULL,
947
+ p_user_agent TEXT DEFAULT NULL,
948
+ p_device_id UUID DEFAULT NULL
949
+ )
950
+ RETURNS JSONB
951
+ LANGUAGE plpgsql
952
+ SECURITY DEFINER
953
+ SET search_path = auth, public, pg_temp
954
+ AS $$
955
+ DECLARE
956
+ v_user JSONB;
957
+ v_refresh JSONB;
958
+ BEGIN
959
+ -- Raises invalid_credentials (timing-safe) on a bad email/password — fails closed.
960
+ v_user := auth.sign_in(p_email, p_password);
961
+ v_refresh := auth.issue_refresh_token(
962
+ (v_user->>'sub')::uuid, NULL, p_ip_address, p_user_agent, p_device_id
963
+ );
964
+ RETURN v_user || jsonb_build_object('refresh_token', v_refresh->>'token');
965
+ END;
966
+ $$;
967
+
968
+ REVOKE EXECUTE ON FUNCTION auth.sign_in_session FROM PUBLIC;`;
969
+
970
+ const SIGN_UP_SESSION = `\
971
+ CREATE OR REPLACE FUNCTION auth.sign_up_session(
972
+ p_email TEXT,
973
+ p_password TEXT,
974
+ p_user_meta_data JSONB DEFAULT '{}',
975
+ p_ip_address TEXT DEFAULT NULL,
976
+ p_user_agent TEXT DEFAULT NULL,
977
+ p_device_id UUID DEFAULT NULL
978
+ )
979
+ RETURNS JSONB
980
+ LANGUAGE plpgsql
981
+ SECURITY DEFINER
982
+ SET search_path = auth, public, pg_temp
983
+ AS $$
984
+ DECLARE
985
+ v_user JSONB;
986
+ v_refresh JSONB;
987
+ BEGIN
988
+ -- Raises email_exists on a duplicate. Creates auth.users only; the caller creates
989
+ -- the public profile as the authenticated user (invoker INSERT under RLS).
990
+ v_user := auth.sign_up(p_email, p_password, p_user_meta_data);
991
+ v_refresh := auth.issue_refresh_token(
992
+ (v_user->>'sub')::uuid, NULL, p_ip_address, p_user_agent, p_device_id
993
+ );
994
+ RETURN v_user || jsonb_build_object('refresh_token', v_refresh->>'token');
995
+ END;
996
+ $$;
997
+
998
+ REVOKE EXECUTE ON FUNCTION auth.sign_up_session FROM PUBLIC;`;
999
+
814
1000
  // =============================================================================
815
1001
  // COMPOSABLE GENERATORS
816
1002
  // =============================================================================
@@ -843,6 +1029,17 @@ export function generateOneTimeTokenSQL(): string {
843
1029
  .trimEnd() + '\n';
844
1030
  }
845
1031
 
1032
+ /**
1033
+ * auth.confirm_email, auth.reset_password — token-gated action wrappers. The anon-safe
1034
+ * entry points for the email-confirm and password-reset flows (the caller holds a valid
1035
+ * one-time token; the token-minting function stays owner-only on the worker path).
1036
+ */
1037
+ export function generateTokenActionsSQL(): string {
1038
+ return [CONFIRM_EMAIL, '', RESET_PASSWORD, '', CHANGE_PASSWORD]
1039
+ .join('\n')
1040
+ .trimEnd() + '\n';
1041
+ }
1042
+
846
1043
  /** auth.sign_in, auth.sign_up, auth.oauth_sign_in (work against default schema) */
847
1044
  export function generateDefaultAuthSQL(): string {
848
1045
  return [SIGN_UP, '', SIGN_IN, '', OAUTH_SIGN_IN]
@@ -857,6 +1054,45 @@ export function generateJwtWrappersSQL(): string {
857
1054
  .trimEnd() + '\n';
858
1055
  }
859
1056
 
1057
+ /**
1058
+ * auth.sign_in_session, auth.sign_up_session — the anon-safe, credential-gated login/signup
1059
+ * entry points for a least-privilege API. They verify/create the user and issue the refresh
1060
+ * token internally (the raw minters stay owner-only); the handler mints the access JWT in JS
1061
+ * so enrichClaims is preserved.
1062
+ */
1063
+ export function generateSessionWrappersSQL(): string {
1064
+ return [SIGN_IN_SESSION, '', SIGN_UP_SESSION]
1065
+ .join('\n')
1066
+ .trimEnd() + '\n';
1067
+ }
1068
+
1069
+ /**
1070
+ * The role grants the generated functions need to be reachable — schema USAGE plus EXECUTE on
1071
+ * the anon-/authenticated-callable entry points. Kept SEPARATE from the function generators
1072
+ * (which stay role-agnostic and REVOKE EXECUTE FROM PUBLIC) so the package's complete migration
1073
+ * is self-sufficient — `db:generate --init` produces a WORKING auth, not one a consumer must
1074
+ * hand-grant. The raw minters (sign_in, issue_refresh_token, …) are deliberately NOT here: only
1075
+ * the credential-/token-gated wrappers are exposed. Grants the everystack role chain
1076
+ * (anon/authenticated/admin, created by db:provision); emitted after the functions exist.
1077
+ *
1078
+ * `opts` mirrors {@link generateAuthSQL} so only grants for included functions are emitted.
1079
+ */
1080
+ export function generateAuthGrantsSQL(opts: AuthSQLOptions = {}): string {
1081
+ const lines = ['GRANT USAGE ON SCHEMA auth TO anon, authenticated, admin;'];
1082
+ // validate_refresh_token is always generated (the refresh path) and anon-callable.
1083
+ lines.push('GRANT EXECUTE ON FUNCTION auth.validate_refresh_token(text, uuid, text, text, interval) TO anon;');
1084
+ if (opts.tokenActions !== false) {
1085
+ lines.push('GRANT EXECUTE ON FUNCTION auth.confirm_email(text) TO anon;');
1086
+ lines.push('GRANT EXECUTE ON FUNCTION auth.reset_password(text, text) TO anon;');
1087
+ lines.push('GRANT EXECUTE ON FUNCTION auth.change_password(text, text) TO authenticated;');
1088
+ }
1089
+ if (opts.sessionWrappers !== false) {
1090
+ lines.push('GRANT EXECUTE ON FUNCTION auth.sign_in_session(text, text, text, text, uuid) TO anon;');
1091
+ lines.push('GRANT EXECUTE ON FUNCTION auth.sign_up_session(text, text, jsonb, text, text, uuid) TO anon;');
1092
+ }
1093
+ return lines.join('\n') + '\n';
1094
+ }
1095
+
860
1096
  // =============================================================================
861
1097
  // MAIN ENTRY POINT
862
1098
  // =============================================================================
@@ -866,6 +1102,10 @@ export interface AuthSQLOptions {
866
1102
  accessors?: boolean;
867
1103
  /** Include one-time token functions (default: true) */
868
1104
  oneTimeTokens?: boolean;
1105
+ /** Include the token-gated action wrappers confirm_email/reset_password (default: true) */
1106
+ tokenActions?: boolean;
1107
+ /** Include the anon-safe session wrappers sign_in_session/sign_up_session (default: true) */
1108
+ sessionWrappers?: boolean;
869
1109
  /** Include default sign_in/sign_up/oauth_sign_in functions (default: true) */
870
1110
  defaultAuthFunctions?: boolean;
871
1111
  /** Include JWT convenience wrappers (default: false) */
@@ -892,6 +1132,12 @@ export function generateAuthSQL(options?: AuthSQLOptions): string {
892
1132
  if (options?.oneTimeTokens !== false) {
893
1133
  parts.push('', generateOneTimeTokenSQL());
894
1134
  }
1135
+ if (options?.tokenActions !== false) {
1136
+ parts.push('', generateTokenActionsSQL());
1137
+ }
1138
+ if (options?.sessionWrappers !== false) {
1139
+ parts.push('', generateSessionWrappersSQL());
1140
+ }
895
1141
  if (options?.defaultAuthFunctions !== false) {
896
1142
  parts.push('', generateDefaultAuthSQL());
897
1143
  }
@@ -899,5 +1145,10 @@ export function generateAuthSQL(options?: AuthSQLOptions): string {
899
1145
  parts.push('', generateJwtWrappersSQL());
900
1146
  }
901
1147
 
1148
+ // The role grants come LAST — every GRANT EXECUTE target now exists. This is what makes the
1149
+ // generated migration self-sufficient: the functions are reachable by the app role chain
1150
+ // without a hand-written grant migration.
1151
+ parts.push('', generateAuthGrantsSQL(options));
1152
+
902
1153
  return parts.join('\n').trimEnd() + '\n';
903
1154
  }