@everystack/auth 0.2.6 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/auth",
3
- "version": "0.2.6",
3
+ "version": "0.3.0",
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>",
@@ -37,6 +37,10 @@
37
37
  "types": "./src/schema.ts",
38
38
  "default": "./src/schema.ts"
39
39
  },
40
+ "./models": {
41
+ "types": "./src/models/index.ts",
42
+ "default": "./src/models/index.ts"
43
+ },
40
44
  "./sql": {
41
45
  "types": "./src/sql.ts",
42
46
  "default": "./src/sql.ts"
@@ -86,7 +90,8 @@
86
90
  }
87
91
  },
88
92
  "dependencies": {
89
- "bcryptjs": "3.0.3"
93
+ "bcryptjs": "3.0.3",
94
+ "@everystack/model": "0.3.3"
90
95
  },
91
96
  "peerDependencies": {
92
97
  "@everystack/api": ">=0.1.0",
@@ -0,0 +1,51 @@
1
+ /**
2
+ * auth.devices — unified device registry + attestation. Auth owns the registry fields
3
+ * (persistent deviceId, push token, platform/metadata); @everystack/security consumes
4
+ * the attestation fields (keyId, publicKey, trustScore …). Written on the privileged
5
+ * device/attestation paths, so `writtenBy: 'functions'` (ENABLE-not-FORCE); admin
6
+ * manages; private.
7
+ */
8
+
9
+ import { defineModel, field, can, index } from '@everystack/model';
10
+ import { User } from './user';
11
+
12
+ export const Device = defineModel('devices', {
13
+ schema: 'auth',
14
+ writtenBy: 'functions',
15
+ private: true,
16
+ fields: {
17
+ id: field.uuid().primaryKey().defaultRandom(),
18
+ repositoryId: field.uuid().nullable(),
19
+ userId: field.uuid().references(() => User, { onDelete: 'set null' }),
20
+ deviceId: field.text().notNull().unique(),
21
+ platform: field.text().notNull(), // 'ios' | 'android' | 'web'
22
+ pushToken: field.text().nullable(),
23
+ name: field.text().nullable(),
24
+ model: field.text().nullable(),
25
+ osVersion: field.text().nullable(),
26
+ appVersion: field.text().nullable(),
27
+ userAgent: field.text().nullable(),
28
+
29
+ // Attestation (consumed by @everystack/security)
30
+ attestationType: field.text().nullable(),
31
+ keyId: field.text().nullable(),
32
+ publicKey: field.text().nullable(),
33
+ attestationData: field.jsonb().nullable(),
34
+ trustScore: field.integer().notNull().default(100),
35
+ attestationCount: field.integer().notNull().default(0),
36
+ lastAttestation: field.timestamptz().nullable(),
37
+
38
+ // Lifecycle
39
+ registeredAt: field.timestamptz().defaultNow().notNull(),
40
+ lastSeenAt: field.timestamptz().defaultNow().notNull(),
41
+ revokedAt: field.timestamptz().nullable(),
42
+ revokedReason: field.text().nullable(),
43
+ },
44
+ constraints: [
45
+ index('repositoryId'),
46
+ index('userId'),
47
+ index('repositoryId', 'userId'),
48
+ index('platform'),
49
+ ],
50
+ abilities: [can('manage', { role: 'admin' })],
51
+ });
@@ -0,0 +1,32 @@
1
+ /**
2
+ * auth.identities — OAuth provider links (Supabase-compatible). One row per
3
+ * (provider, provider_id). Written only by the SECDEF auth functions (oauth_sign_in,
4
+ * sign_up), so `writtenBy: 'functions'` (ENABLE-not-FORCE); admin manages; private.
5
+ *
6
+ * Schema + authz derive from this Model; `../schema` materializes the runtime table.
7
+ */
8
+
9
+ import { defineModel, field, can, index } from '@everystack/model';
10
+ import { User } from './user';
11
+
12
+ export const Identity = defineModel('identities', {
13
+ schema: 'auth',
14
+ writtenBy: 'functions',
15
+ private: true,
16
+ fields: {
17
+ id: field.uuid().primaryKey().defaultRandom(),
18
+ userId: field.uuid().notNull().references(() => User, { onDelete: 'cascade' }),
19
+ provider: field.text().notNull(),
20
+ providerId: field.text().notNull(),
21
+ identityData: field.jsonb().nullable(),
22
+ email: field.text().nullable(),
23
+ lastSignInAt: field.timestamptz().nullable(),
24
+ createdAt: field.timestamptz().defaultNow().notNull(),
25
+ updatedAt: field.timestamptz().defaultNow().notNull(),
26
+ },
27
+ constraints: [
28
+ index('providerId', 'provider').unique(),
29
+ index('userId'),
30
+ ],
31
+ abilities: [can('manage', { role: 'admin' })],
32
+ });
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Auth's package-owned models. A consuming app composes these into its own model list
3
+ * so `db:generate` emits the auth schema + authz alongside the app's tables:
4
+ * `models = [...appModels, ...authModels]`.
5
+ *
6
+ * All seven are `schema: 'auth'`, `writtenBy: 'functions'` (SECDEF-managed →
7
+ * ENABLE-not-FORCE), and `private` (reached through the auth handlers, never the generic
8
+ * API). The SECURITY DEFINER functions that write them stay package-owned SQL (sql.ts) —
9
+ * not Model-expressible; the Model declares the table authz, the package ships the functions.
10
+ */
11
+
12
+ export { User } from './user';
13
+ export { Identity } from './identity';
14
+ export { RefreshToken } from './refresh-token';
15
+ export { Device } from './device';
16
+ export { MfaFactor } from './mfa-factor';
17
+ export { MfaChallenge } from './mfa-challenge';
18
+ export { OneTimeToken } from './one-time-token';
19
+
20
+ import { defineModule } from '@everystack/model';
21
+ import { User } from './user';
22
+ import { Identity } from './identity';
23
+ import { RefreshToken } from './refresh-token';
24
+ import { Device } from './device';
25
+ import { MfaFactor } from './mfa-factor';
26
+ import { MfaChallenge } from './mfa-challenge';
27
+ import { OneTimeToken } from './one-time-token';
28
+ import { generateAuthSQL } from '../sql';
29
+
30
+ /** All auth-owned models, for the app to spread into its db:generate model list. */
31
+ export const authModels = [User, Identity, RefreshToken, Device, MfaFactor, MfaChallenge, OneTimeToken];
32
+
33
+ /**
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.
38
+ */
39
+ export const authModule = defineModule({
40
+ models: authModels,
41
+ extensions: ['pgcrypto'],
42
+ functions: generateAuthSQL,
43
+ });
@@ -0,0 +1,25 @@
1
+ /**
2
+ * auth.mfa_challenges — ephemeral MFA challenge state, verified within a timeout window.
3
+ * Written on the privileged MFA path (SECDEF), so `writtenBy: 'functions'`
4
+ * (ENABLE-not-FORCE); admin manages; private.
5
+ */
6
+
7
+ import { defineModel, field, can, index } from '@everystack/model';
8
+ import { MfaFactor } from './mfa-factor';
9
+
10
+ export const MfaChallenge = defineModel('mfa_challenges', {
11
+ schema: 'auth',
12
+ writtenBy: 'functions',
13
+ private: true,
14
+ fields: {
15
+ id: field.uuid().primaryKey().defaultRandom(),
16
+ factorId: field.uuid().notNull().references(() => MfaFactor, { onDelete: 'cascade' }),
17
+ ipAddress: field.text().nullable(),
18
+ otpCode: field.text().nullable(),
19
+ webAuthnSessionData: field.jsonb().nullable(),
20
+ verifiedAt: field.timestamptz().nullable(),
21
+ createdAt: field.timestamptz().defaultNow().notNull(),
22
+ },
23
+ constraints: [index('factorId')],
24
+ abilities: [can('manage', { role: 'admin' })],
25
+ });
@@ -0,0 +1,33 @@
1
+ /**
2
+ * auth.mfa_factors — TOTP + WebAuthn factors. Enrolled 'unverified', become 'verified'
3
+ * after a challenge. Written on the privileged MFA path (SECDEF), so
4
+ * `writtenBy: 'functions'` (ENABLE-not-FORCE); admin manages; private.
5
+ */
6
+
7
+ import { defineModel, field, can, index, sql } from '@everystack/model';
8
+ import { User } from './user';
9
+
10
+ export const MfaFactor = defineModel('mfa_factors', {
11
+ schema: 'auth',
12
+ writtenBy: 'functions',
13
+ private: true,
14
+ fields: {
15
+ id: field.uuid().primaryKey().defaultRandom(),
16
+ userId: field.uuid().notNull().references(() => User, { onDelete: 'cascade' }),
17
+ friendlyName: field.text().nullable(),
18
+ factorType: field.text().notNull(), // 'totp' | 'webauthn'
19
+ status: field.text().notNull().default('unverified'), // 'unverified' | 'verified'
20
+ secret: field.text().nullable(), // encrypted TOTP secret
21
+ webAuthnCredential: field.jsonb().nullable(),
22
+ phone: field.text().nullable(),
23
+ lastChallengedAt: field.timestamptz().nullable(),
24
+ createdAt: field.timestamptz().defaultNow().notNull(),
25
+ updatedAt: field.timestamptz().defaultNow().notNull(),
26
+ },
27
+ constraints: [
28
+ index('userId'),
29
+ // Partial index: a user's verified factors (predicate in deparser normal form).
30
+ index('userId').where(sql`status = 'verified'::text`),
31
+ ],
32
+ abilities: [can('manage', { role: 'admin' })],
33
+ });
@@ -0,0 +1,30 @@
1
+ /**
2
+ * auth.one_time_tokens — email confirmation, password reset, magic links. One row per
3
+ * (user_id, token_type) — a new token of the same type replaces the previous. Stored as
4
+ * SHA-256 hashes. Minted/verified only by SECDEF functions (create/verify_one_time_token),
5
+ * so `writtenBy: 'functions'` (ENABLE-not-FORCE); admin manages; private.
6
+ */
7
+
8
+ import { defineModel, field, can, index } from '@everystack/model';
9
+ import { User } from './user';
10
+
11
+ export const OneTimeToken = defineModel('one_time_tokens', {
12
+ schema: 'auth',
13
+ writtenBy: 'functions',
14
+ private: true,
15
+ fields: {
16
+ id: field.uuid().primaryKey().defaultRandom(),
17
+ userId: field.uuid().notNull().references(() => User, { onDelete: 'cascade' }),
18
+ tokenType: field.text().notNull(),
19
+ tokenHash: field.text().notNull(),
20
+ relatesTo: field.text().notNull(), // email or phone
21
+ createdAt: field.timestamptz().defaultNow().notNull(),
22
+ updatedAt: field.timestamptz().defaultNow().notNull(),
23
+ },
24
+ constraints: [
25
+ index('userId', 'tokenType').unique(),
26
+ index('tokenHash'),
27
+ index('relatesTo'),
28
+ ],
29
+ abilities: [can('manage', { role: 'admin' })],
30
+ });
@@ -0,0 +1,39 @@
1
+ /**
2
+ * auth.refresh_tokens — RFC 6819 family-based rotation with reuse detection. Only
3
+ * SHA-256 hashes are stored. Every operation goes through a SECDEF function
4
+ * (issue/validate/revoke/cleanup_refresh_tokens) running as owner, so
5
+ * `writtenBy: 'functions'` (ENABLE-not-FORCE). 0015 revoked the direct authenticated
6
+ * grant: admin manages, the functions do the rest.
7
+ */
8
+
9
+ import { defineModel, field, can, index, sql } from '@everystack/model';
10
+ import { User } from './user';
11
+
12
+ export const RefreshToken = defineModel('refresh_tokens', {
13
+ schema: 'auth',
14
+ writtenBy: 'functions',
15
+ private: true,
16
+ fields: {
17
+ id: field.uuid().primaryKey().defaultRandom(),
18
+ repositoryId: field.uuid().nullable(),
19
+ userId: field.uuid().notNull().references(() => User, { onDelete: 'cascade' }),
20
+ tokenHash: field.text().notNull().unique(),
21
+ familyId: field.uuid().notNull().defaultRandom(),
22
+ status: field.text().notNull().default('active'), // 'active' | 'consumed'
23
+ ipAddress: field.text().nullable(),
24
+ userAgent: field.text().nullable(),
25
+ deviceId: field.uuid().nullable(),
26
+ expiresAt: field.timestamptz().notNull(),
27
+ createdAt: field.timestamptz().defaultNow().notNull(),
28
+ },
29
+ constraints: [
30
+ index('userId'),
31
+ index('repositoryId'),
32
+ index('repositoryId', 'userId'),
33
+ index('familyId'),
34
+ index('expiresAt'),
35
+ // Partial index: the active token of a family (predicate in deparser normal form).
36
+ index('familyId').where(sql`status = 'active'::text`),
37
+ ],
38
+ abilities: [can('manage', { role: 'admin' })],
39
+ });
@@ -0,0 +1,59 @@
1
+ /**
2
+ * auth.users — the default user table (Supabase-compatible column names for
3
+ * zero-friction migration).
4
+ *
5
+ * The v3 Model that owns this table's schema AND its authorization. The drizzle
6
+ * runtime table (`../schema`) derives from it via `toDrizzleTable`, and `db:generate`
7
+ * reads it to emit the DDL + RLS + grants — the package, not the consuming app, owns
8
+ * auth.users end to end.
9
+ *
10
+ * Authz shape (matches the live 0014 migration):
11
+ * - `writtenBy: 'functions'` — every write goes through the SECURITY DEFINER auth
12
+ * functions (sign_up / sign_in / reset_password …), which run as the table owner.
13
+ * The owner must keep its bypass (read encrypted_password, insert users), so RLS is
14
+ * ENABLE-not-FORCE. `db:doctor` additionally asserts those functions exist with a
15
+ * pinned search_path.
16
+ * - `can('manage', { role: 'admin' })` — admin (operator) gets CRUD + the `users_admin`
17
+ * policy.
18
+ * - `can('read', { owner: 'id', columns: ['id', 'email', 'role'] })` — column-scoped:
19
+ * `authenticated` may read only id/email/role of its OWN row (the refresh path mints
20
+ * the next access token from them), never the password hash. Compiles to a column
21
+ * grant + the `users_select_self` policy, not a table grant.
22
+ * - `private: true` — reached through the auth handlers, never the generic data API.
23
+ *
24
+ * Apps that need a different user model replace the postgres functions (the stable API);
25
+ * the schema is the batteries-included default.
26
+ */
27
+
28
+ import { defineModel, field, can, index } from '@everystack/model';
29
+
30
+ export const User = defineModel('users', {
31
+ schema: 'auth',
32
+ writtenBy: 'functions',
33
+ private: true,
34
+ fields: {
35
+ id: field.uuid().primaryKey().defaultRandom(),
36
+ instanceId: field.uuid().nullable(), // Supabase compat
37
+ aud: field.varchar(255).default('authenticated'),
38
+ role: field.varchar(255).default('authenticated'),
39
+ email: field.varchar(255).unique(),
40
+ encryptedPassword: field.varchar(255).nullable(),
41
+ emailConfirmedAt: field.timestamptz().nullable(),
42
+ phone: field.varchar(255).nullable(),
43
+ phoneConfirmedAt: field.timestamptz().nullable(),
44
+ rawAppMetaData: field.jsonb().nullable(),
45
+ rawUserMetaData: field.jsonb().nullable(),
46
+ lastSignInAt: field.timestamptz().nullable(),
47
+ bannedUntil: field.timestamptz().nullable(),
48
+ deletedAt: field.timestamptz().nullable(),
49
+ isAnonymous: field.boolean().notNull().default(false),
50
+ createdAt: field.timestamptz().defaultNow().notNull(),
51
+ updatedAt: field.timestamptz().defaultNow().notNull(),
52
+ },
53
+ constraints: [index('instanceId')],
54
+ abilities: [
55
+ can('manage', { role: 'admin' }),
56
+ // Self-read, column-scoped: id/email/role of your own row, never the hash.
57
+ can('read', { owner: 'id', columns: ['id', 'email', 'role'] }),
58
+ ],
59
+ });
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
@@ -1066,6 +1066,33 @@ export function generateSessionWrappersSQL(): string {
1066
1066
  .trimEnd() + '\n';
1067
1067
  }
1068
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
+
1069
1096
  // =============================================================================
1070
1097
  // MAIN ENTRY POINT
1071
1098
  // =============================================================================
@@ -1118,5 +1145,10 @@ export function generateAuthSQL(options?: AuthSQLOptions): string {
1118
1145
  parts.push('', generateJwtWrappersSQL());
1119
1146
  }
1120
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
+
1121
1153
  return parts.join('\n').trimEnd() + '\n';
1122
1154
  }