@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/LICENSE +681 -0
- package/README.md +5 -1
- package/package.json +25 -10
- package/src/index.ts +23 -6
- package/src/models/device.ts +51 -0
- package/src/models/identity.ts +32 -0
- package/src/models/index.ts +43 -0
- package/src/models/mfa-challenge.ts +25 -0
- package/src/models/mfa-factor.ts +33 -0
- package/src/models/one-time-token.ts +30 -0
- package/src/models/refresh-token.ts +39 -0
- package/src/models/user.ts +59 -0
- package/src/postgres.ts +458 -151
- package/src/schema.ts +26 -297
- package/src/sql.ts +251 -0
package/README.md
CHANGED
|
@@ -247,4 +247,8 @@ Part of [everystack](https://github.com/scalable-technology/everystack) — a se
|
|
|
247
247
|
|
|
248
248
|
## License
|
|
249
249
|
|
|
250
|
-
|
|
250
|
+
[AGPL-3.0-only](https://www.gnu.org/licenses/agpl-3.0.html) © Scalable Technology, Inc.
|
|
251
|
+
|
|
252
|
+
A commercial license is available for organizations that cannot or do not wish to
|
|
253
|
+
comply with the AGPL-3.0 terms. For commercial licensing, contact
|
|
254
|
+
licensing@scalable.technology.
|
package/package.json
CHANGED
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/auth",
|
|
3
|
-
"version": "0.
|
|
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
|
+
"author": "Scalable Technology, Inc. <licensing@scalable.technology>",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/scalable-technology/everystack.git",
|
|
10
|
+
"directory": "packages/auth"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/scalable-technology/everystack#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/scalable-technology/everystack/issues"
|
|
15
|
+
},
|
|
6
16
|
"publishConfig": {
|
|
7
17
|
"access": "public"
|
|
8
18
|
},
|
|
@@ -27,6 +37,10 @@
|
|
|
27
37
|
"types": "./src/schema.ts",
|
|
28
38
|
"default": "./src/schema.ts"
|
|
29
39
|
},
|
|
40
|
+
"./models": {
|
|
41
|
+
"types": "./src/models/index.ts",
|
|
42
|
+
"default": "./src/models/index.ts"
|
|
43
|
+
},
|
|
30
44
|
"./sql": {
|
|
31
45
|
"types": "./src/sql.ts",
|
|
32
46
|
"default": "./src/sql.ts"
|
|
@@ -75,13 +89,9 @@
|
|
|
75
89
|
"default": "./src/testing.ts"
|
|
76
90
|
}
|
|
77
91
|
},
|
|
78
|
-
"scripts": {
|
|
79
|
-
"test": "jest",
|
|
80
|
-
"build": "tsc --build",
|
|
81
|
-
"lint": "tsc --noEmit"
|
|
82
|
-
},
|
|
83
92
|
"dependencies": {
|
|
84
|
-
"bcryptjs": "3.0.3"
|
|
93
|
+
"bcryptjs": "3.0.3",
|
|
94
|
+
"@everystack/model": "0.3.3"
|
|
85
95
|
},
|
|
86
96
|
"peerDependencies": {
|
|
87
97
|
"@everystack/api": ">=0.1.0",
|
|
@@ -101,7 +111,6 @@
|
|
|
101
111
|
}
|
|
102
112
|
},
|
|
103
113
|
"devDependencies": {
|
|
104
|
-
"@everystack/api": "workspace:*",
|
|
105
114
|
"@types/bcryptjs": "3.0.0",
|
|
106
115
|
"@types/jest": "29.5.14",
|
|
107
116
|
"@types/node": "22.19.18",
|
|
@@ -110,6 +119,12 @@
|
|
|
110
119
|
"jest": "29.7.0",
|
|
111
120
|
"postgres": "3.4.9",
|
|
112
121
|
"ts-jest": "29.4.9",
|
|
113
|
-
"typescript": "5.9.3"
|
|
122
|
+
"typescript": "5.9.3",
|
|
123
|
+
"@everystack/api": "0.2.9"
|
|
124
|
+
},
|
|
125
|
+
"scripts": {
|
|
126
|
+
"test": "jest",
|
|
127
|
+
"build": "tsc --build",
|
|
128
|
+
"lint": "tsc --noEmit"
|
|
114
129
|
}
|
|
115
|
-
}
|
|
130
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -147,10 +147,22 @@ export interface AuthOptions {
|
|
|
147
147
|
/** One-time token operations for email confirmation and password recovery.
|
|
148
148
|
* Required for confirmEmail, forgotPassword, and resetPassword handlers. */
|
|
149
149
|
tokens?: TokenOperations;
|
|
150
|
-
/** Called after successful signup when tokens are configured. App sends confirmation email.
|
|
150
|
+
/** Called after successful signup when tokens are configured. App sends confirmation email.
|
|
151
|
+
* Receives a token MINTED IN THE API — owner-only, so this breaks under least-privilege.
|
|
152
|
+
* Postgres least-priv path uses `onConfirmationRequested` instead (mint on the worker). */
|
|
151
153
|
onConfirmationRequired?: (user: { id: string; email: string }, token: string) => Promise<void>;
|
|
152
|
-
/** Called on forgot-password
|
|
154
|
+
/** Called on forgot-password with a token MINTED IN THE API (owner-only — breaks under
|
|
155
|
+
* least-privilege). Postgres least-priv path uses `onRecoveryRequested` instead. */
|
|
153
156
|
onRecoveryRequired?: (user: { id: string; email: string }, token: string) => Promise<void>;
|
|
157
|
+
/** Least-privilege confirmation: called after signup with only the new user's identity.
|
|
158
|
+
* The API does NOT mint a token — the app enqueues a job; the privileged worker mints the
|
|
159
|
+
* one-time token and sends the email. Takes precedence over onConfirmationRequired. */
|
|
160
|
+
onConfirmationRequested?: (user: { id: string; email: string }) => Promise<void>;
|
|
161
|
+
/** Least-privilege recovery: called on forgot-password with ONLY the email. The API does
|
|
162
|
+
* not read the user table or mint a token — it enqueues a job; the privileged worker
|
|
163
|
+
* looks up the user, mints the recovery token, and sends the email. Always responds the
|
|
164
|
+
* same to the client (no user enumeration). Takes precedence over onRecoveryRequired. */
|
|
165
|
+
onRecoveryRequested?: (email: string) => Promise<void>;
|
|
154
166
|
/** Called after email confirmed via one-time token — app updates user record. */
|
|
155
167
|
onEmailConfirmed?: (userId: string) => Promise<void>;
|
|
156
168
|
}
|
|
@@ -209,6 +221,11 @@ function assertSafeCookieConfig(cookies: CookieConfig | undefined): void {
|
|
|
209
221
|
/**
|
|
210
222
|
* Add mode-independent email confirmation and password management handlers.
|
|
211
223
|
* Called after base handlers are created (works with both node and postgres modes).
|
|
224
|
+
*
|
|
225
|
+
* Uses `??=` so a handler already provided by the base (e.g. the least-privilege
|
|
226
|
+
* postgres confirmEmail/resetPassword that run via SECDEF wrappers under withRole)
|
|
227
|
+
* is NOT overwritten by the generic owner-connection version. Node mode provides
|
|
228
|
+
* none of these, so it gets the generic implementations.
|
|
212
229
|
*/
|
|
213
230
|
function addEmailPasswordFlows(
|
|
214
231
|
handlers: AuthHandlers,
|
|
@@ -217,7 +234,7 @@ function addEmailPasswordFlows(
|
|
|
217
234
|
options?: AuthOptions,
|
|
218
235
|
): void {
|
|
219
236
|
if (options?.tokens) {
|
|
220
|
-
handlers.confirmEmail
|
|
237
|
+
handlers.confirmEmail ??= async (request: Request): Promise<Response> => {
|
|
221
238
|
const csrfError = csrfCheck(request, options?.cookies);
|
|
222
239
|
if (csrfError) return csrfError;
|
|
223
240
|
|
|
@@ -244,7 +261,7 @@ function addEmailPasswordFlows(
|
|
|
244
261
|
}
|
|
245
262
|
};
|
|
246
263
|
|
|
247
|
-
handlers.forgotPassword
|
|
264
|
+
handlers.forgotPassword ??= async (request: Request): Promise<Response> => {
|
|
248
265
|
const csrfError = csrfCheck(request, options?.cookies);
|
|
249
266
|
if (csrfError) return csrfError;
|
|
250
267
|
|
|
@@ -279,7 +296,7 @@ function addEmailPasswordFlows(
|
|
|
279
296
|
return Response.json({ sent: true });
|
|
280
297
|
};
|
|
281
298
|
|
|
282
|
-
handlers.resetPassword
|
|
299
|
+
handlers.resetPassword ??= async (request: Request): Promise<Response> => {
|
|
283
300
|
const csrfError = csrfCheck(request, options?.cookies);
|
|
284
301
|
if (csrfError) return csrfError;
|
|
285
302
|
|
|
@@ -313,7 +330,7 @@ function addEmailPasswordFlows(
|
|
|
313
330
|
};
|
|
314
331
|
}
|
|
315
332
|
|
|
316
|
-
handlers.changePassword
|
|
333
|
+
handlers.changePassword ??= async (request: Request): Promise<Response> => {
|
|
317
334
|
const csrfError = csrfCheck(request, options?.cookies);
|
|
318
335
|
if (csrfError) return csrfError;
|
|
319
336
|
|
|
@@ -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
|
+
});
|