@stll/auth-model 0.0.1-placeholder.0 → 0.1.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/README.md ADDED
@@ -0,0 +1,60 @@
1
+ # `@stll/auth-model`
2
+
3
+ Portable Better Auth contracts for applications that need identical identity,
4
+ session, account, verification, organization, membership, invitation, and role
5
+ semantics. The package has no database or framework runtime dependency.
6
+
7
+ The parity manifest pins:
8
+
9
+ - the Better Auth version and singular core model names;
10
+ - PostgreSQL, snake-case mappings, singular model names, transactional adapters,
11
+ application-generated string IDs, database-backed session/verification, and
12
+ timezone-aware timestamps;
13
+ - separate logical and physical field semantics: required/not-null, defaults,
14
+ update behavior, input/output ownership, sorting, indexes, uniqueness,
15
+ references, physical names/types, primary keys, and exact index predicates;
16
+ - the organization plugin options and core role grants.
17
+
18
+ Hosts own runtime tables, adapters, secrets, email delivery, lifecycle hooks, and
19
+ product permissions. Every additional field, index, or plugin model must be
20
+ declared explicitly when comparing the normalized host schema.
21
+
22
+ ```ts
23
+ import {
24
+ BETTER_AUTH_ADAPTER_OPTIONS,
25
+ BETTER_AUTH_ORGANIZATION_OPTIONS,
26
+ BETTER_AUTH_ORGANIZATION_ROLE_GRANTS,
27
+ BETTER_AUTH_ORGANIZATION_STATEMENTS,
28
+ } from "@stll/auth-model";
29
+ import { createAccessControl } from "better-auth/plugins/access";
30
+
31
+ const statements = {
32
+ ...BETTER_AUTH_ORGANIZATION_STATEMENTS,
33
+ document: ["read", "create", "update", "delete"],
34
+ } as const;
35
+ const ac = createAccessControl(statements);
36
+
37
+ const owner = ac.newRole({
38
+ ...BETTER_AUTH_ORGANIZATION_ROLE_GRANTS.owner,
39
+ document: ["read", "create", "update", "delete"],
40
+ });
41
+
42
+ const adapterOptions = {
43
+ provider: "pg",
44
+ schema,
45
+ ...BETTER_AUTH_ADAPTER_OPTIONS,
46
+ };
47
+ const organizationOptions = {
48
+ ...BETTER_AUTH_ORGANIZATION_OPTIONS,
49
+ ac,
50
+ roles: { owner },
51
+ };
52
+ ```
53
+
54
+ `compareBetterAuthSchema` accepts a normalized schema and an exact extension
55
+ allowlist. It reports `incompatible` for missing core models or fields, semantic
56
+ drift, undeclared additions, and stale extension declarations. Record and index
57
+ declaration order do not affect the result; index column order remains semantic.
58
+
59
+ The package intentionally does not export runtime tables, secrets, email
60
+ implementations, lifecycle hooks, or product-specific resources.
@@ -0,0 +1,414 @@
1
+ //#region src/contract.d.ts
2
+ /** Better Auth version whose logical schema this contract describes. */
3
+ declare const BETTER_AUTH_CONTRACT_VERSION = "1.6.26";
4
+ declare const ORGANIZATION_ROLE_NAMES: readonly ["owner", "admin", "member", "intern", "external"];
5
+ type OrganizationRoleName = (typeof ORGANIZATION_ROLE_NAMES)[number];
6
+ /** Better Auth organization resources. Hosts extend this with product grants. */
7
+ declare const BETTER_AUTH_ORGANIZATION_STATEMENTS: {
8
+ readonly organization: readonly ["update", "delete"];
9
+ readonly member: readonly ["create", "update", "delete"];
10
+ readonly invitation: readonly ["create", "cancel"];
11
+ readonly team: readonly ["create", "update", "delete"];
12
+ readonly ac: readonly ["create", "read", "update", "delete"];
13
+ };
14
+ type OrganizationPermissionMap = { [Resource in keyof typeof BETTER_AUTH_ORGANIZATION_STATEMENTS]: (typeof BETTER_AUTH_ORGANIZATION_STATEMENTS)[Resource][number][]; };
15
+ type BetterAuthOrganizationRoleGrants = Record<OrganizationRoleName, OrganizationPermissionMap>;
16
+ /** Core grants shared by every application; product grants remain host-owned. */
17
+ declare const BETTER_AUTH_ORGANIZATION_ROLE_GRANTS: {
18
+ owner: {
19
+ organization: ("update" | "delete")[];
20
+ member: ("update" | "delete" | "create")[];
21
+ invitation: ("create" | "cancel")[];
22
+ team: ("update" | "delete" | "create")[];
23
+ ac: ("update" | "delete" | "create" | "read")[];
24
+ };
25
+ admin: {
26
+ organization: "update"[];
27
+ member: ("update" | "delete" | "create")[];
28
+ invitation: ("create" | "cancel")[];
29
+ team: ("update" | "delete" | "create")[];
30
+ ac: ("update" | "delete" | "create" | "read")[];
31
+ };
32
+ member: {
33
+ organization: never[];
34
+ member: never[];
35
+ invitation: never[];
36
+ team: never[];
37
+ ac: "read"[];
38
+ };
39
+ intern: {
40
+ organization: never[];
41
+ member: never[];
42
+ invitation: never[];
43
+ team: never[];
44
+ ac: never[];
45
+ };
46
+ external: {
47
+ organization: never[];
48
+ member: never[];
49
+ invitation: never[];
50
+ team: never[];
51
+ ac: never[];
52
+ };
53
+ };
54
+ /**
55
+ * Values that change Better Auth's organization authorization semantics.
56
+ * Hooks, adapters, email delivery, and product-specific options stay local.
57
+ */
58
+ declare const BETTER_AUTH_ORGANIZATION_OPTIONS: {
59
+ readonly allowUserToCreateOrganization: true;
60
+ readonly cancelPendingInvitationsOnReInvite: false;
61
+ readonly creatorRole: "owner";
62
+ readonly invitationExpiresIn: number;
63
+ readonly membershipLimit: 500;
64
+ };
65
+ /** Adapter switches which must match in every host. */
66
+ declare const BETTER_AUTH_ADAPTER_OPTIONS: {
67
+ readonly camelCase: false;
68
+ readonly provider: "pg";
69
+ readonly transaction: true;
70
+ readonly usePlural: false;
71
+ };
72
+ declare const BETTER_AUTH_CORE_MODEL_NAMES: readonly ["user", "session", "account", "verification", "organization", "member", "invitation"];
73
+ type BetterAuthCoreModelName = (typeof BETTER_AUTH_CORE_MODEL_NAMES)[number];
74
+ type BetterAuthFieldType = "boolean" | "date" | "string";
75
+ type BetterAuthFieldDefault = {
76
+ kind: "none";
77
+ } | {
78
+ kind: "runtime";
79
+ } | {
80
+ kind: "literal";
81
+ value: boolean | number | string;
82
+ };
83
+ type BetterAuthFieldReference = {
84
+ field: "id";
85
+ model: "organization" | "user";
86
+ onDelete: "cascade" | null;
87
+ };
88
+ type BetterAuthLogicalFieldContract = {
89
+ default: BetterAuthFieldDefault;
90
+ indexed: boolean;
91
+ input: "allowed" | "server-managed";
92
+ onUpdate: boolean;
93
+ reference: BetterAuthFieldReference | null;
94
+ required: boolean;
95
+ returned: boolean;
96
+ sortable: boolean;
97
+ type: BetterAuthFieldType;
98
+ unique: boolean;
99
+ };
100
+ type BetterAuthDatabaseFieldContract = {
101
+ columnName: string;
102
+ columnType: "boolean" | "text" | "timestamptz";
103
+ default: BetterAuthFieldDefault;
104
+ notNull: boolean;
105
+ onUpdate: boolean;
106
+ precision: number | null;
107
+ reference: BetterAuthFieldReference | null;
108
+ storage: "timestamp-with-time-zone" | null;
109
+ unique: boolean;
110
+ };
111
+ /** Better Auth behavior and physical database guarantees, kept separate. */
112
+ type BetterAuthFieldContract = {
113
+ database: BetterAuthDatabaseFieldContract;
114
+ logical: BetterAuthLogicalFieldContract;
115
+ };
116
+ type BetterAuthIndexContract = {
117
+ fields: readonly string[];
118
+ predicate: string | null;
119
+ unique: boolean;
120
+ };
121
+ type BetterAuthModelContract = {
122
+ fields: Record<string, BetterAuthFieldContract>;
123
+ indexes: readonly BetterAuthIndexContract[];
124
+ primaryKey: readonly string[];
125
+ tableName: string;
126
+ };
127
+ /**
128
+ * Normalized Better Auth core plus the organization plugin's shared models and
129
+ * `activeOrganizationId` session extension.
130
+ */
131
+ declare const BETTER_AUTH_CORE_SCHEMA: {
132
+ readonly user: {
133
+ readonly tableName: "user";
134
+ readonly primaryKey: readonly ["id"];
135
+ readonly fields: Record<string, BetterAuthFieldContract>;
136
+ readonly indexes: readonly [];
137
+ };
138
+ readonly session: {
139
+ readonly tableName: "session";
140
+ readonly primaryKey: readonly ["id"];
141
+ readonly fields: Record<string, BetterAuthFieldContract>;
142
+ readonly indexes: readonly [{
143
+ readonly fields: readonly ["userId", "activeOrganizationId"];
144
+ readonly predicate: null;
145
+ readonly unique: false;
146
+ }];
147
+ };
148
+ readonly account: {
149
+ readonly tableName: "account";
150
+ readonly primaryKey: readonly ["id"];
151
+ readonly fields: Record<string, BetterAuthFieldContract>;
152
+ readonly indexes: readonly [{
153
+ readonly fields: readonly ["userId"];
154
+ readonly predicate: null;
155
+ readonly unique: false;
156
+ }];
157
+ };
158
+ readonly verification: {
159
+ readonly tableName: "verification";
160
+ readonly primaryKey: readonly ["id"];
161
+ readonly fields: Record<string, BetterAuthFieldContract>;
162
+ readonly indexes: readonly [{
163
+ readonly fields: readonly ["identifier"];
164
+ readonly predicate: null;
165
+ readonly unique: false;
166
+ }];
167
+ };
168
+ readonly organization: {
169
+ readonly tableName: "organization";
170
+ readonly primaryKey: readonly ["id"];
171
+ readonly fields: Record<string, BetterAuthFieldContract>;
172
+ readonly indexes: readonly [{
173
+ readonly fields: readonly ["slug"];
174
+ readonly predicate: null;
175
+ readonly unique: true;
176
+ }];
177
+ };
178
+ readonly member: {
179
+ readonly tableName: "member";
180
+ readonly primaryKey: readonly ["id"];
181
+ readonly fields: Record<string, BetterAuthFieldContract>;
182
+ readonly indexes: readonly [{
183
+ readonly fields: readonly ["organizationId"];
184
+ readonly predicate: null;
185
+ readonly unique: false;
186
+ }, {
187
+ readonly fields: readonly ["userId"];
188
+ readonly predicate: null;
189
+ readonly unique: false;
190
+ }, {
191
+ readonly fields: readonly ["organizationId", "userId"];
192
+ readonly predicate: null;
193
+ readonly unique: true;
194
+ }];
195
+ };
196
+ readonly invitation: {
197
+ readonly tableName: "invitation";
198
+ readonly primaryKey: readonly ["id"];
199
+ readonly fields: Record<string, BetterAuthFieldContract>;
200
+ readonly indexes: readonly [{
201
+ readonly fields: readonly ["organizationId"];
202
+ readonly predicate: null;
203
+ readonly unique: false;
204
+ }, {
205
+ readonly fields: readonly ["email"];
206
+ readonly predicate: null;
207
+ readonly unique: false;
208
+ }];
209
+ };
210
+ };
211
+ type BetterAuthAdapterContract = {
212
+ camelCase: boolean;
213
+ dateStorage: "timestamp-with-time-zone";
214
+ idGeneration: "application-string";
215
+ modelNames: Record<BetterAuthCoreModelName, string>;
216
+ provider: "pg";
217
+ sessionStorage: "database";
218
+ transaction: boolean;
219
+ usePlural: boolean;
220
+ verificationStorage: "database";
221
+ };
222
+ declare const BETTER_AUTH_ADAPTER_INVARIANTS: {
223
+ readonly dateStorage: "timestamp-with-time-zone";
224
+ readonly idGeneration: "application-string";
225
+ readonly sessionStorage: "database";
226
+ readonly verificationStorage: "database";
227
+ readonly modelNames: {
228
+ readonly user: "user";
229
+ readonly session: "session";
230
+ readonly account: "account";
231
+ readonly verification: "verification";
232
+ readonly organization: "organization";
233
+ readonly member: "member";
234
+ readonly invitation: "invitation";
235
+ };
236
+ readonly camelCase: false;
237
+ readonly provider: "pg";
238
+ readonly transaction: true;
239
+ readonly usePlural: false;
240
+ };
241
+ type BetterAuthSchemaCandidate = {
242
+ adapter: BetterAuthAdapterContract;
243
+ models: Record<string, BetterAuthModelContract>;
244
+ };
245
+ type BetterAuthSchemaExtensions = {
246
+ fields?: Partial<Record<BetterAuthCoreModelName, Record<string, BetterAuthFieldContract>>>;
247
+ indexes?: Partial<Record<BetterAuthCoreModelName, readonly BetterAuthIndexContract[]>>;
248
+ models?: readonly string[];
249
+ };
250
+ type BetterAuthSchemaParityIssue = {
251
+ actual: unknown;
252
+ expected: unknown;
253
+ path: string;
254
+ };
255
+ type BetterAuthSchemaParityResult = {
256
+ status: "compatible";
257
+ } | {
258
+ issues: BetterAuthSchemaParityIssue[];
259
+ status: "incompatible";
260
+ };
261
+ /**
262
+ * Compares a normalized host schema with the complete shared core. Extensions
263
+ * are exact allowlists: missing, stale, and undeclared additions all fail.
264
+ */
265
+ declare const compareBetterAuthSchema: (candidate: BetterAuthSchemaCandidate, extensions?: BetterAuthSchemaExtensions) => BetterAuthSchemaParityResult;
266
+ declare const BETTER_AUTH_PARITY_MANIFEST: {
267
+ readonly adapterInvariants: {
268
+ readonly dateStorage: "timestamp-with-time-zone";
269
+ readonly idGeneration: "application-string";
270
+ readonly sessionStorage: "database";
271
+ readonly verificationStorage: "database";
272
+ readonly modelNames: {
273
+ readonly user: "user";
274
+ readonly session: "session";
275
+ readonly account: "account";
276
+ readonly verification: "verification";
277
+ readonly organization: "organization";
278
+ readonly member: "member";
279
+ readonly invitation: "invitation";
280
+ };
281
+ readonly camelCase: false;
282
+ readonly provider: "pg";
283
+ readonly transaction: true;
284
+ readonly usePlural: false;
285
+ };
286
+ readonly betterAuthVersion: "1.6.26";
287
+ readonly options: {
288
+ readonly allowUserToCreateOrganization: true;
289
+ readonly cancelPendingInvitationsOnReInvite: false;
290
+ readonly creatorRole: "owner";
291
+ readonly invitationExpiresIn: number;
292
+ readonly membershipLimit: 500;
293
+ };
294
+ readonly roleGrants: {
295
+ owner: {
296
+ organization: ("update" | "delete")[];
297
+ member: ("update" | "delete" | "create")[];
298
+ invitation: ("create" | "cancel")[];
299
+ team: ("update" | "delete" | "create")[];
300
+ ac: ("update" | "delete" | "create" | "read")[];
301
+ };
302
+ admin: {
303
+ organization: "update"[];
304
+ member: ("update" | "delete" | "create")[];
305
+ invitation: ("create" | "cancel")[];
306
+ team: ("update" | "delete" | "create")[];
307
+ ac: ("update" | "delete" | "create" | "read")[];
308
+ };
309
+ member: {
310
+ organization: never[];
311
+ member: never[];
312
+ invitation: never[];
313
+ team: never[];
314
+ ac: "read"[];
315
+ };
316
+ intern: {
317
+ organization: never[];
318
+ member: never[];
319
+ invitation: never[];
320
+ team: never[];
321
+ ac: never[];
322
+ };
323
+ external: {
324
+ organization: never[];
325
+ member: never[];
326
+ invitation: never[];
327
+ team: never[];
328
+ ac: never[];
329
+ };
330
+ };
331
+ readonly roleNames: readonly ["owner", "admin", "member", "intern", "external"];
332
+ readonly schema: {
333
+ readonly user: {
334
+ readonly tableName: "user";
335
+ readonly primaryKey: readonly ["id"];
336
+ readonly fields: Record<string, BetterAuthFieldContract>;
337
+ readonly indexes: readonly [];
338
+ };
339
+ readonly session: {
340
+ readonly tableName: "session";
341
+ readonly primaryKey: readonly ["id"];
342
+ readonly fields: Record<string, BetterAuthFieldContract>;
343
+ readonly indexes: readonly [{
344
+ readonly fields: readonly ["userId", "activeOrganizationId"];
345
+ readonly predicate: null;
346
+ readonly unique: false;
347
+ }];
348
+ };
349
+ readonly account: {
350
+ readonly tableName: "account";
351
+ readonly primaryKey: readonly ["id"];
352
+ readonly fields: Record<string, BetterAuthFieldContract>;
353
+ readonly indexes: readonly [{
354
+ readonly fields: readonly ["userId"];
355
+ readonly predicate: null;
356
+ readonly unique: false;
357
+ }];
358
+ };
359
+ readonly verification: {
360
+ readonly tableName: "verification";
361
+ readonly primaryKey: readonly ["id"];
362
+ readonly fields: Record<string, BetterAuthFieldContract>;
363
+ readonly indexes: readonly [{
364
+ readonly fields: readonly ["identifier"];
365
+ readonly predicate: null;
366
+ readonly unique: false;
367
+ }];
368
+ };
369
+ readonly organization: {
370
+ readonly tableName: "organization";
371
+ readonly primaryKey: readonly ["id"];
372
+ readonly fields: Record<string, BetterAuthFieldContract>;
373
+ readonly indexes: readonly [{
374
+ readonly fields: readonly ["slug"];
375
+ readonly predicate: null;
376
+ readonly unique: true;
377
+ }];
378
+ };
379
+ readonly member: {
380
+ readonly tableName: "member";
381
+ readonly primaryKey: readonly ["id"];
382
+ readonly fields: Record<string, BetterAuthFieldContract>;
383
+ readonly indexes: readonly [{
384
+ readonly fields: readonly ["organizationId"];
385
+ readonly predicate: null;
386
+ readonly unique: false;
387
+ }, {
388
+ readonly fields: readonly ["userId"];
389
+ readonly predicate: null;
390
+ readonly unique: false;
391
+ }, {
392
+ readonly fields: readonly ["organizationId", "userId"];
393
+ readonly predicate: null;
394
+ readonly unique: true;
395
+ }];
396
+ };
397
+ readonly invitation: {
398
+ readonly tableName: "invitation";
399
+ readonly primaryKey: readonly ["id"];
400
+ readonly fields: Record<string, BetterAuthFieldContract>;
401
+ readonly indexes: readonly [{
402
+ readonly fields: readonly ["organizationId"];
403
+ readonly predicate: null;
404
+ readonly unique: false;
405
+ }, {
406
+ readonly fields: readonly ["email"];
407
+ readonly predicate: null;
408
+ readonly unique: false;
409
+ }];
410
+ };
411
+ };
412
+ };
413
+ //#endregion
414
+ export { BETTER_AUTH_ADAPTER_INVARIANTS, BETTER_AUTH_ADAPTER_OPTIONS, BETTER_AUTH_CONTRACT_VERSION, BETTER_AUTH_CORE_MODEL_NAMES, BETTER_AUTH_CORE_SCHEMA, BETTER_AUTH_ORGANIZATION_OPTIONS, BETTER_AUTH_ORGANIZATION_ROLE_GRANTS, BETTER_AUTH_ORGANIZATION_STATEMENTS, BETTER_AUTH_PARITY_MANIFEST, BetterAuthAdapterContract, BetterAuthCoreModelName, BetterAuthDatabaseFieldContract, BetterAuthFieldContract, BetterAuthFieldDefault, BetterAuthFieldReference, BetterAuthFieldType, BetterAuthIndexContract, BetterAuthLogicalFieldContract, BetterAuthModelContract, BetterAuthOrganizationRoleGrants, BetterAuthSchemaCandidate, BetterAuthSchemaExtensions, BetterAuthSchemaParityIssue, BetterAuthSchemaParityResult, ORGANIZATION_ROLE_NAMES, OrganizationRoleName, compareBetterAuthSchema };
@@ -0,0 +1,535 @@
1
+ //#region src/contract.ts
2
+ /** Better Auth version whose logical schema this contract describes. */
3
+ const BETTER_AUTH_CONTRACT_VERSION = "1.6.26";
4
+ const ORGANIZATION_ROLE_NAMES = [
5
+ "owner",
6
+ "admin",
7
+ "member",
8
+ "intern",
9
+ "external"
10
+ ];
11
+ /** Better Auth organization resources. Hosts extend this with product grants. */
12
+ const BETTER_AUTH_ORGANIZATION_STATEMENTS = {
13
+ organization: ["update", "delete"],
14
+ member: [
15
+ "create",
16
+ "update",
17
+ "delete"
18
+ ],
19
+ invitation: ["create", "cancel"],
20
+ team: [
21
+ "create",
22
+ "update",
23
+ "delete"
24
+ ],
25
+ ac: [
26
+ "create",
27
+ "read",
28
+ "update",
29
+ "delete"
30
+ ]
31
+ };
32
+ /** Core grants shared by every application; product grants remain host-owned. */
33
+ const BETTER_AUTH_ORGANIZATION_ROLE_GRANTS = {
34
+ owner: {
35
+ organization: ["update", "delete"],
36
+ member: [
37
+ "create",
38
+ "update",
39
+ "delete"
40
+ ],
41
+ invitation: ["create", "cancel"],
42
+ team: [
43
+ "create",
44
+ "update",
45
+ "delete"
46
+ ],
47
+ ac: [
48
+ "create",
49
+ "read",
50
+ "update",
51
+ "delete"
52
+ ]
53
+ },
54
+ admin: {
55
+ organization: ["update"],
56
+ member: [
57
+ "create",
58
+ "update",
59
+ "delete"
60
+ ],
61
+ invitation: ["create", "cancel"],
62
+ team: [
63
+ "create",
64
+ "update",
65
+ "delete"
66
+ ],
67
+ ac: [
68
+ "create",
69
+ "read",
70
+ "update",
71
+ "delete"
72
+ ]
73
+ },
74
+ member: {
75
+ organization: [],
76
+ member: [],
77
+ invitation: [],
78
+ team: [],
79
+ ac: ["read"]
80
+ },
81
+ intern: {
82
+ organization: [],
83
+ member: [],
84
+ invitation: [],
85
+ team: [],
86
+ ac: []
87
+ },
88
+ external: {
89
+ organization: [],
90
+ member: [],
91
+ invitation: [],
92
+ team: [],
93
+ ac: []
94
+ }
95
+ };
96
+ /**
97
+ * Values that change Better Auth's organization authorization semantics.
98
+ * Hooks, adapters, email delivery, and product-specific options stay local.
99
+ */
100
+ const BETTER_AUTH_ORGANIZATION_OPTIONS = {
101
+ allowUserToCreateOrganization: true,
102
+ cancelPendingInvitationsOnReInvite: false,
103
+ creatorRole: "owner",
104
+ invitationExpiresIn: 172800,
105
+ membershipLimit: 500
106
+ };
107
+ /** Adapter switches which must match in every host. */
108
+ const BETTER_AUTH_ADAPTER_OPTIONS = {
109
+ camelCase: false,
110
+ provider: "pg",
111
+ transaction: true,
112
+ usePlural: false
113
+ };
114
+ const BETTER_AUTH_CORE_MODEL_NAMES = [
115
+ "user",
116
+ "session",
117
+ "account",
118
+ "verification",
119
+ "organization",
120
+ "member",
121
+ "invitation"
122
+ ];
123
+ const noDefault = { kind: "none" };
124
+ const runtimeDefault = { kind: "runtime" };
125
+ const field = (type, options = {}) => ({
126
+ database: {
127
+ default: options.database?.default ?? noDefault,
128
+ notNull: options.database?.notNull ?? options.required ?? false,
129
+ onUpdate: options.database?.onUpdate ?? false,
130
+ reference: options.database?.reference ?? (options.reference?.onDelete === "cascade" ? options.reference : null),
131
+ storage: type === "date" ? "timestamp-with-time-zone" : null,
132
+ unique: options.database?.unique ?? options.unique ?? false
133
+ },
134
+ logical: {
135
+ default: options.default ?? noDefault,
136
+ indexed: options.indexed ?? false,
137
+ input: options.input ?? "allowed",
138
+ onUpdate: options.onUpdate ?? false,
139
+ reference: options.reference ?? null,
140
+ required: options.required ?? false,
141
+ returned: options.returned ?? true,
142
+ sortable: options.sortable ?? false,
143
+ type,
144
+ unique: options.unique ?? false
145
+ }
146
+ });
147
+ const toSnakeCase = (value) => value.replaceAll(/[A-Z]/gu, (letter) => `_${letter.toLowerCase()}`);
148
+ const databaseColumnType = (type) => {
149
+ if (type === "date") return "timestamptz";
150
+ if (type === "boolean") return "boolean";
151
+ return "text";
152
+ };
153
+ const withPhysicalNames = (drafts) => {
154
+ const result = {};
155
+ for (const [name, draft] of Object.entries(drafts)) {
156
+ const type = draft.logical.type;
157
+ result[name] = {
158
+ ...draft,
159
+ database: {
160
+ ...draft.database,
161
+ columnName: toSnakeCase(name),
162
+ columnType: databaseColumnType(type),
163
+ precision: null
164
+ }
165
+ };
166
+ }
167
+ return result;
168
+ };
169
+ const idField = field("string", {
170
+ default: runtimeDefault,
171
+ input: "server-managed",
172
+ required: true
173
+ });
174
+ const runtimeDate = field("date", {
175
+ default: runtimeDefault,
176
+ required: true,
177
+ database: { default: runtimeDefault }
178
+ });
179
+ const runtimeUpdatedDate = field("date", {
180
+ default: runtimeDefault,
181
+ onUpdate: true,
182
+ required: true,
183
+ database: {
184
+ default: runtimeDefault,
185
+ onUpdate: true
186
+ }
187
+ });
188
+ const updatedDate = field("date", {
189
+ onUpdate: true,
190
+ required: true,
191
+ database: { onUpdate: true }
192
+ });
193
+ const userReference = {
194
+ field: "id",
195
+ model: "user",
196
+ onDelete: "cascade"
197
+ };
198
+ const organizationReference = {
199
+ field: "id",
200
+ model: "organization",
201
+ onDelete: null
202
+ };
203
+ const organizationDatabaseReference = {
204
+ ...organizationReference,
205
+ onDelete: "cascade"
206
+ };
207
+ const userPluginReference = {
208
+ ...userReference,
209
+ onDelete: null
210
+ };
211
+ /**
212
+ * Normalized Better Auth core plus the organization plugin's shared models and
213
+ * `activeOrganizationId` session extension.
214
+ */
215
+ const BETTER_AUTH_CORE_SCHEMA = {
216
+ user: {
217
+ tableName: "user",
218
+ primaryKey: ["id"],
219
+ fields: withPhysicalNames({
220
+ id: idField,
221
+ name: field("string", {
222
+ required: true,
223
+ sortable: true
224
+ }),
225
+ email: field("string", {
226
+ required: true,
227
+ sortable: true,
228
+ unique: true
229
+ }),
230
+ emailVerified: field("boolean", {
231
+ default: {
232
+ kind: "literal",
233
+ value: false
234
+ },
235
+ input: "server-managed",
236
+ required: true,
237
+ database: { default: {
238
+ kind: "literal",
239
+ value: false
240
+ } }
241
+ }),
242
+ image: field("string"),
243
+ createdAt: runtimeDate,
244
+ updatedAt: runtimeUpdatedDate
245
+ }),
246
+ indexes: []
247
+ },
248
+ session: {
249
+ tableName: "session",
250
+ primaryKey: ["id"],
251
+ fields: withPhysicalNames({
252
+ id: idField,
253
+ expiresAt: field("date", { required: true }),
254
+ token: field("string", {
255
+ required: true,
256
+ unique: true
257
+ }),
258
+ createdAt: runtimeDate,
259
+ updatedAt: updatedDate,
260
+ ipAddress: field("string"),
261
+ userAgent: field("string"),
262
+ userId: field("string", {
263
+ indexed: true,
264
+ reference: userReference,
265
+ required: true
266
+ }),
267
+ activeOrganizationId: field("string", { input: "server-managed" })
268
+ }),
269
+ indexes: [{
270
+ fields: ["userId", "activeOrganizationId"],
271
+ predicate: null,
272
+ unique: false
273
+ }]
274
+ },
275
+ account: {
276
+ tableName: "account",
277
+ primaryKey: ["id"],
278
+ fields: withPhysicalNames({
279
+ id: idField,
280
+ accountId: field("string", { required: true }),
281
+ providerId: field("string", { required: true }),
282
+ userId: field("string", {
283
+ indexed: true,
284
+ reference: userReference,
285
+ required: true
286
+ }),
287
+ accessToken: field("string", { returned: false }),
288
+ refreshToken: field("string", { returned: false }),
289
+ idToken: field("string", { returned: false }),
290
+ accessTokenExpiresAt: field("date", { returned: false }),
291
+ refreshTokenExpiresAt: field("date", { returned: false }),
292
+ scope: field("string"),
293
+ password: field("string", { returned: false }),
294
+ createdAt: runtimeDate,
295
+ updatedAt: updatedDate
296
+ }),
297
+ indexes: [{
298
+ fields: ["userId"],
299
+ predicate: null,
300
+ unique: false
301
+ }]
302
+ },
303
+ verification: {
304
+ tableName: "verification",
305
+ primaryKey: ["id"],
306
+ fields: withPhysicalNames({
307
+ id: idField,
308
+ identifier: field("string", {
309
+ indexed: true,
310
+ required: true
311
+ }),
312
+ value: field("string", { required: true }),
313
+ expiresAt: field("date", { required: true }),
314
+ createdAt: runtimeDate,
315
+ updatedAt: runtimeUpdatedDate
316
+ }),
317
+ indexes: [{
318
+ fields: ["identifier"],
319
+ predicate: null,
320
+ unique: false
321
+ }]
322
+ },
323
+ organization: {
324
+ tableName: "organization",
325
+ primaryKey: ["id"],
326
+ fields: withPhysicalNames({
327
+ id: idField,
328
+ name: field("string", {
329
+ required: true,
330
+ sortable: true
331
+ }),
332
+ slug: field("string", {
333
+ indexed: true,
334
+ required: true,
335
+ sortable: true,
336
+ unique: true
337
+ }),
338
+ logo: field("string"),
339
+ createdAt: field("date", { required: true }),
340
+ metadata: field("string")
341
+ }),
342
+ indexes: [{
343
+ fields: ["slug"],
344
+ predicate: null,
345
+ unique: true
346
+ }]
347
+ },
348
+ member: {
349
+ tableName: "member",
350
+ primaryKey: ["id"],
351
+ fields: withPhysicalNames({
352
+ id: idField,
353
+ organizationId: field("string", {
354
+ database: { reference: organizationDatabaseReference },
355
+ indexed: true,
356
+ reference: organizationReference,
357
+ required: true
358
+ }),
359
+ userId: field("string", {
360
+ database: { reference: userReference },
361
+ indexed: true,
362
+ reference: userPluginReference,
363
+ required: true
364
+ }),
365
+ role: field("string", {
366
+ default: {
367
+ kind: "literal",
368
+ value: "member"
369
+ },
370
+ required: true,
371
+ sortable: true,
372
+ database: { default: {
373
+ kind: "literal",
374
+ value: "member"
375
+ } }
376
+ }),
377
+ createdAt: field("date", { required: true })
378
+ }),
379
+ indexes: [
380
+ {
381
+ fields: ["organizationId"],
382
+ predicate: null,
383
+ unique: false
384
+ },
385
+ {
386
+ fields: ["userId"],
387
+ predicate: null,
388
+ unique: false
389
+ },
390
+ {
391
+ fields: ["organizationId", "userId"],
392
+ predicate: null,
393
+ unique: true
394
+ }
395
+ ]
396
+ },
397
+ invitation: {
398
+ tableName: "invitation",
399
+ primaryKey: ["id"],
400
+ fields: withPhysicalNames({
401
+ id: idField,
402
+ organizationId: field("string", {
403
+ database: { reference: organizationDatabaseReference },
404
+ indexed: true,
405
+ reference: organizationReference,
406
+ required: true
407
+ }),
408
+ email: field("string", {
409
+ indexed: true,
410
+ required: true,
411
+ sortable: true
412
+ }),
413
+ role: field("string", { sortable: true }),
414
+ status: field("string", {
415
+ default: {
416
+ kind: "literal",
417
+ value: "pending"
418
+ },
419
+ required: true,
420
+ sortable: true,
421
+ database: { default: {
422
+ kind: "literal",
423
+ value: "pending"
424
+ } }
425
+ }),
426
+ expiresAt: field("date", { required: true }),
427
+ createdAt: runtimeDate,
428
+ inviterId: field("string", {
429
+ database: { reference: userReference },
430
+ reference: userPluginReference,
431
+ required: true
432
+ })
433
+ }),
434
+ indexes: [{
435
+ fields: ["organizationId"],
436
+ predicate: null,
437
+ unique: false
438
+ }, {
439
+ fields: ["email"],
440
+ predicate: null,
441
+ unique: false
442
+ }]
443
+ }
444
+ };
445
+ const BETTER_AUTH_ADAPTER_INVARIANTS = {
446
+ ...BETTER_AUTH_ADAPTER_OPTIONS,
447
+ dateStorage: "timestamp-with-time-zone",
448
+ idGeneration: "application-string",
449
+ sessionStorage: "database",
450
+ verificationStorage: "database",
451
+ modelNames: {
452
+ user: "user",
453
+ session: "session",
454
+ account: "account",
455
+ verification: "verification",
456
+ organization: "organization",
457
+ member: "member",
458
+ invitation: "invitation"
459
+ }
460
+ };
461
+ const compareCodeUnits = (left, right) => {
462
+ if (left === right) return 0;
463
+ return left < right ? -1 : 1;
464
+ };
465
+ const canonicalize = (value) => {
466
+ if (Array.isArray(value)) return value.map(canonicalize);
467
+ if (value === null || typeof value !== "object") return value;
468
+ return Object.fromEntries(Object.entries(value).toSorted(([left], [right]) => compareCodeUnits(left, right)).map(([key, child]) => [key, canonicalize(child)]));
469
+ };
470
+ const canonical = (value) => JSON.stringify(canonicalize(value));
471
+ const sortIndexes = (indexes) => indexes.toSorted((left, right) => compareCodeUnits(canonical(left), canonical(right)));
472
+ const compare = (issues, path, actual, expected) => {
473
+ if (canonical(actual) === canonical(expected)) return;
474
+ issues.push({
475
+ actual,
476
+ expected,
477
+ path
478
+ });
479
+ };
480
+ /**
481
+ * Compares a normalized host schema with the complete shared core. Extensions
482
+ * are exact allowlists: missing, stale, and undeclared additions all fail.
483
+ */
484
+ const compareBetterAuthSchema = (candidate, extensions = {}) => {
485
+ const issues = [];
486
+ compare(issues, "adapter", candidate.adapter, BETTER_AUTH_ADAPTER_INVARIANTS);
487
+ const coreModelNames = new Set(BETTER_AUTH_CORE_MODEL_NAMES);
488
+ for (const model of extensions.models ?? []) if (coreModelNames.has(model)) issues.push({
489
+ actual: "core model",
490
+ expected: "new extension model",
491
+ path: `extensions.models.${model}`
492
+ });
493
+ const expectedModelNames = [...BETTER_AUTH_CORE_MODEL_NAMES, ...extensions.models ?? []].toSorted();
494
+ compare(issues, "models", Object.keys(candidate.models).toSorted(), expectedModelNames);
495
+ for (const model of BETTER_AUTH_CORE_MODEL_NAMES) {
496
+ const actual = candidate.models[model];
497
+ const core = BETTER_AUTH_CORE_SCHEMA[model];
498
+ if (!actual) {
499
+ issues.push({
500
+ actual: void 0,
501
+ expected: "core model",
502
+ path: model
503
+ });
504
+ continue;
505
+ }
506
+ compare(issues, `${model}.tableName`, actual.tableName, core.tableName);
507
+ compare(issues, `${model}.primaryKey`, actual.primaryKey, core.primaryKey);
508
+ const extensionFields = extensions.fields?.[model] ?? {};
509
+ for (const fieldName of Object.keys(extensionFields)) if (fieldName in core.fields) issues.push({
510
+ actual: "core field",
511
+ expected: "new extension field",
512
+ path: `extensions.${model}.${fieldName}`
513
+ });
514
+ compare(issues, `${model}.fields`, actual.fields, {
515
+ ...extensionFields,
516
+ ...core.fields
517
+ });
518
+ compare(issues, `${model}.indexes`, sortIndexes(actual.indexes), sortIndexes([...core.indexes, ...extensions.indexes?.[model] ?? []]));
519
+ }
520
+ if (issues.length === 0) return { status: "compatible" };
521
+ return {
522
+ issues,
523
+ status: "incompatible"
524
+ };
525
+ };
526
+ const BETTER_AUTH_PARITY_MANIFEST = {
527
+ adapterInvariants: BETTER_AUTH_ADAPTER_INVARIANTS,
528
+ betterAuthVersion: BETTER_AUTH_CONTRACT_VERSION,
529
+ options: BETTER_AUTH_ORGANIZATION_OPTIONS,
530
+ roleGrants: BETTER_AUTH_ORGANIZATION_ROLE_GRANTS,
531
+ roleNames: ORGANIZATION_ROLE_NAMES,
532
+ schema: BETTER_AUTH_CORE_SCHEMA
533
+ };
534
+ //#endregion
535
+ export { BETTER_AUTH_ADAPTER_INVARIANTS, BETTER_AUTH_ADAPTER_OPTIONS, BETTER_AUTH_CONTRACT_VERSION, BETTER_AUTH_CORE_MODEL_NAMES, BETTER_AUTH_CORE_SCHEMA, BETTER_AUTH_ORGANIZATION_OPTIONS, BETTER_AUTH_ORGANIZATION_ROLE_GRANTS, BETTER_AUTH_ORGANIZATION_STATEMENTS, BETTER_AUTH_PARITY_MANIFEST, ORGANIZATION_ROLE_NAMES, compareBetterAuthSchema };
package/package.json CHANGED
@@ -1,6 +1,56 @@
1
1
  {
2
2
  "name": "@stll/auth-model",
3
- "version": "0.0.1-placeholder.0",
4
- "description": "Placeholder to bootstrap npm trusted publishing. Real releases are published from stella/stella via GitHub Actions.",
5
- "license": "Apache-2.0"
3
+ "version": "0.1.0",
4
+ "description": "Portable Better Auth core contract and parity checks.",
5
+ "keywords": [
6
+ "authentication",
7
+ "better-auth",
8
+ "multitenancy",
9
+ "organizations",
10
+ "typescript"
11
+ ],
12
+ "homepage": "https://github.com/stella/stella/tree/main/packages/auth-model",
13
+ "bugs": {
14
+ "url": "https://github.com/stella/stella/issues"
15
+ },
16
+ "license": "Apache-2.0",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "https://github.com/stella/stella.git",
20
+ "directory": "packages/auth-model"
21
+ },
22
+ "files": [
23
+ "dist",
24
+ "README.md"
25
+ ],
26
+ "type": "module",
27
+ "sideEffects": false,
28
+ "exports": {
29
+ ".": {
30
+ "types": "./dist/contract.d.ts",
31
+ "import": "./dist/contract.js"
32
+ }
33
+ },
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "scripts": {
38
+ "clean": "git clean -xdf dist .cache .turbo node_modules",
39
+ "build": "tsdown",
40
+ "pack:dry-run": "bun pm pack --dry-run",
41
+ "test": "bun test src",
42
+ "typecheck": "bun ../../packages/scripts/src/tsc-native.ts --noEmit",
43
+ "lint": "cd ../.. && bun --bun oxlint -c oxlint.config.ts --report-unused-disable-directives-severity=error --deny-warnings --type-aware packages/auth-model",
44
+ "lint:fix": "cd ../.. && bun --bun oxlint -c oxlint.config.ts --type-aware --fix packages/auth-model",
45
+ "format": "oxfmt .",
46
+ "prepack": "bun run build"
47
+ },
48
+ "devDependencies": {
49
+ "@stll/typescript-config": "0.0.0",
50
+ "better-auth": "1.6.26",
51
+ "bun-types": "1.4.0",
52
+ "tsdown": "0.22.14"
53
+ },
54
+ "main": "./dist/contract.js",
55
+ "types": "./dist/contract.d.ts"
6
56
  }