@yielded/auth-persistence 0.1.0-beta.30 → 0.1.0-beta.32

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.
Files changed (130) hide show
  1. package/README.md +11 -7
  2. package/dist/Adapter.d.mts +1 -1
  3. package/dist/Adapter.mjs +8 -8
  4. package/dist/OAuthPersistence.mjs +10 -5
  5. package/dist/OAuthPersistence.mjs.map +1 -1
  6. package/dist/internal/configuration.d.mts +49 -13
  7. package/dist/internal/configuration.mjs.map +1 -1
  8. package/dist/internal/email-native.mjs +10 -2
  9. package/dist/internal/email-native.mjs.map +1 -1
  10. package/dist/internal/email-policy.mjs +3 -2
  11. package/dist/internal/email-policy.mjs.map +1 -1
  12. package/dist/internal/models/oauth-model.d.mts +4 -2
  13. package/dist/internal/models/oauth-model.mjs.map +1 -1
  14. package/dist/internal/models/password-model.d.mts +2 -1
  15. package/dist/internal/models/password-model.mjs.map +1 -1
  16. package/dist/internal/oauth/native-accounts.d.mts +1 -1
  17. package/dist/internal/oauth/native-accounts.mjs +6 -6
  18. package/dist/internal/oauth/native-accounts.mjs.map +1 -1
  19. package/dist/internal/oauth/native-connected.d.mts +1 -1
  20. package/dist/internal/oauth/native-connected.mjs +1 -1
  21. package/dist/internal/oauth/native-registration.mjs +19 -8
  22. package/dist/internal/oauth/native-registration.mjs.map +1 -1
  23. package/dist/internal/oauth/native-sign-in.d.mts +1 -1
  24. package/dist/internal/oauth/native-sign-in.mjs +1 -1
  25. package/dist/internal/oauth/native-state.mjs +21 -11
  26. package/dist/internal/oauth/native-state.mjs.map +1 -1
  27. package/dist/internal/oauth/state.d.mts +1 -1
  28. package/dist/internal/oauth/state.mjs +1 -1
  29. package/dist/internal/oauth-managed.mjs +182 -0
  30. package/dist/internal/oauth-managed.mjs.map +1 -0
  31. package/dist/internal/passkey-managed.mjs +5 -3
  32. package/dist/internal/passkey-managed.mjs.map +1 -1
  33. package/dist/internal/passkey-native-registration.mjs +1 -1
  34. package/dist/internal/passkey-native-state.mjs +33 -23
  35. package/dist/internal/passkey-native-state.mjs.map +1 -1
  36. package/dist/internal/passkey-native-write.mjs +3 -3
  37. package/dist/internal/passkey-native-write.mjs.map +1 -1
  38. package/dist/internal/passkey-native.d.mts +15 -7
  39. package/dist/internal/passkey-native.mjs +2 -1
  40. package/dist/internal/passkey-native.mjs.map +1 -1
  41. package/dist/internal/passkey-policy.mjs +9 -7
  42. package/dist/internal/passkey-policy.mjs.map +1 -1
  43. package/dist/internal/password-native.mjs +22 -5
  44. package/dist/internal/password-native.mjs.map +1 -1
  45. package/dist/internal/password-policy.mjs +7 -6
  46. package/dist/internal/password-policy.mjs.map +1 -1
  47. package/dist/internal/password-registration-native.d.mts +1 -1
  48. package/dist/internal/persistence.d.mts +3 -0
  49. package/dist/internal/persistence.mjs +99 -42
  50. package/dist/internal/persistence.mjs.map +1 -1
  51. package/dist/internal/phone-native.mjs +1 -1
  52. package/dist/internal/phone-native.mjs.map +1 -1
  53. package/dist/internal/registration.mjs +17 -8
  54. package/dist/internal/registration.mjs.map +1 -1
  55. package/dist/internal/session-native-authority.mjs +8 -6
  56. package/dist/internal/session-native-authority.mjs.map +1 -1
  57. package/dist/internal/session-native-login.d.mts +2 -2
  58. package/dist/internal/session-native-login.mjs +4 -2
  59. package/dist/internal/session-native-login.mjs.map +1 -1
  60. package/dist/internal/session-native-pending-read.mjs +26 -20
  61. package/dist/internal/session-native-pending-read.mjs.map +1 -1
  62. package/dist/internal/session-native-record.mjs +6 -4
  63. package/dist/internal/session-native-record.mjs.map +1 -1
  64. package/dist/internal/session-native-state.mjs +24 -8
  65. package/dist/internal/session-native-state.mjs.map +1 -1
  66. package/dist/internal/session-native-stateful.d.mts +2 -2
  67. package/dist/internal/session-native-stateful.mjs +67 -38
  68. package/dist/internal/session-native-stateful.mjs.map +1 -1
  69. package/dist/internal/session-native-step-up.d.mts +2 -2
  70. package/dist/internal/session-native-step-up.mjs +17 -19
  71. package/dist/internal/session-native-step-up.mjs.map +1 -1
  72. package/dist/internal/session-native-validity.mjs +3 -1
  73. package/dist/internal/session-native-validity.mjs.map +1 -1
  74. package/dist/internal/sql-authority.mjs +5 -1
  75. package/dist/internal/sql-authority.mjs.map +1 -1
  76. package/dist/internal/sql-storage-validation.mjs +2 -1
  77. package/dist/internal/sql-storage-validation.mjs.map +1 -1
  78. package/dist/internal/sql-table.d.mts +1 -0
  79. package/dist/internal/sql-table.mjs.map +1 -1
  80. package/dist/internal/sqlite-policy-guard.mjs +23 -0
  81. package/dist/internal/sqlite-policy-guard.mjs.map +1 -0
  82. package/dist/internal/step-up-state.mjs +19 -6
  83. package/dist/internal/step-up-state.mjs.map +1 -1
  84. package/dist/internal/storage-mapping.d.mts +9 -5
  85. package/dist/internal/storage-mapping.mjs +17 -5
  86. package/dist/internal/storage-mapping.mjs.map +1 -1
  87. package/dist/internal/storage-tables.d.mts +237 -0
  88. package/dist/internal/storage-tables.mjs +68 -0
  89. package/dist/internal/storage-tables.mjs.map +1 -1
  90. package/dist/internal/storage-validation.d.mts +3 -1
  91. package/dist/internal/storage-validation.mjs +70 -17
  92. package/dist/internal/storage-validation.mjs.map +1 -1
  93. package/dist/internal/totp-native.mjs +1 -1
  94. package/package.json +1 -1
  95. package/src/OAuthPersistence.ts +7 -2
  96. package/src/internal/configuration.ts +72 -15
  97. package/src/internal/email-native.ts +16 -6
  98. package/src/internal/email-policy.ts +12 -3
  99. package/src/internal/models/oauth-model.ts +7 -1
  100. package/src/internal/models/password-model.ts +2 -1
  101. package/src/internal/oauth/native-accounts.ts +3 -3
  102. package/src/internal/oauth/native-registration.ts +17 -6
  103. package/src/internal/oauth/native-state.ts +38 -18
  104. package/src/internal/oauth-managed.ts +277 -0
  105. package/src/internal/passkey-managed.ts +8 -2
  106. package/src/internal/passkey-native-state.ts +59 -52
  107. package/src/internal/passkey-native-write.ts +2 -2
  108. package/src/internal/passkey-native.ts +4 -2
  109. package/src/internal/passkey-policy.ts +13 -9
  110. package/src/internal/password-native.ts +45 -11
  111. package/src/internal/password-policy.ts +16 -8
  112. package/src/internal/persistence.ts +360 -204
  113. package/src/internal/phone-native.ts +1 -1
  114. package/src/internal/registration.ts +53 -40
  115. package/src/internal/session-native-authority.ts +26 -8
  116. package/src/internal/session-native-login.ts +9 -4
  117. package/src/internal/session-native-pending-read.ts +46 -33
  118. package/src/internal/session-native-record.ts +6 -4
  119. package/src/internal/session-native-state.ts +32 -10
  120. package/src/internal/session-native-stateful.ts +147 -52
  121. package/src/internal/session-native-step-up.ts +31 -24
  122. package/src/internal/session-native-validity.ts +3 -1
  123. package/src/internal/sql-authority.ts +5 -1
  124. package/src/internal/sql-storage-validation.ts +53 -46
  125. package/src/internal/sql-table.ts +1 -0
  126. package/src/internal/sqlite-policy-guard.ts +34 -0
  127. package/src/internal/step-up-state.ts +43 -6
  128. package/src/internal/storage-mapping.ts +40 -4
  129. package/src/internal/storage-tables.ts +76 -0
  130. package/src/internal/storage-validation.ts +127 -27
@@ -1,6 +1,12 @@
1
1
  import { EmailAddressPersistence, EmailUnavailable } from "@yielded/auth/Email";
2
2
  import type { LifecycleHooks } from "@yielded/auth/Hooks";
3
3
  import { hasCommitScope } from "@yielded/auth/Hooks";
4
+ import {
5
+ OAuthConnectedPersistence,
6
+ OAuthConnectedRevocations,
7
+ OAuthSignInPersistence,
8
+ OAuthUnavailable,
9
+ } from "@yielded/auth/OAuth";
4
10
  import {
5
11
  PasskeyCredentials,
6
12
  PasskeyManagementPersistence,
@@ -8,7 +14,7 @@ import {
8
14
  PasskeyUnavailable,
9
15
  } from "@yielded/auth/Passkey";
10
16
  import { PasswordPersistence, PasswordUnavailable } from "@yielded/auth/Password";
11
- import { hooksLayer } from "@yielded/auth/Persistence";
17
+ import { hooksLayer, reportAuthDiagnostic } from "@yielded/auth/Persistence";
12
18
  import { PhoneSignInTargets, PhoneOtpUnavailable } from "@yielded/auth/PhoneOtp";
13
19
  import { ProofPersistence, ProofUnavailable } from "@yielded/auth/Proofs";
14
20
  import { AuthenticationAuthority, SessionUnavailable } from "@yielded/auth/Sessions";
@@ -30,10 +36,12 @@ import {
30
36
  type SubjectOptions,
31
37
  type PasskeyFeature,
32
38
  type PasskeyRequirement,
39
+ type OAuthFeature,
33
40
  } from "./configuration";
34
41
  import { makeNativeEmailAddressServices } from "./email-native";
35
42
  import { PersistenceMappingError } from "./mapping-error";
36
43
  import type { NativeSqlTables } from "./native-sql-table";
44
+ import { makeManagedOAuth } from "./oauth-managed";
37
45
  import { makeManagedPasskeys } from "./passkey-managed";
38
46
  import { makeNativePasswordServices } from "./password-native";
39
47
  import { makeComposedPhoneTargets } from "./phone-composed";
@@ -65,6 +73,8 @@ export interface Backend<T extends object, R, Database extends object = object>
65
73
  readonly describe: (table: T) => StorageTable;
66
74
  readonly acquire: Effect.Effect<Database, PersistenceConfigurationError, R | SqlClient.SqlClient>;
67
75
  readonly nativeTables: (database: Database) => NativeSqlTables;
76
+ /** Fixed atomic batches replace interactive transactions on backends such as D1. */
77
+ readonly batch?: (database: Database) => NonNullable<SqlBatchCommit["Service"]>;
68
78
  }
69
79
 
70
80
  const configError = (reason: string) => PersistenceConfigurationError.make({ reason });
@@ -86,6 +96,11 @@ const timestampKeys = new Set([
86
96
  "deadline",
87
97
  "occurredAt",
88
98
  "dedupUntil",
99
+ "binderExpiresAt",
100
+ "refreshClaimedAt",
101
+ "refreshClaimExpiresAt",
102
+ "claimedAt",
103
+ "claimExpiresAt",
89
104
  ]);
90
105
 
91
106
  export const createPersistence = <T extends object, R, Database extends object = object>(
@@ -94,27 +109,46 @@ export const createPersistence = <T extends object, R, Database extends object =
94
109
  const make = <C extends ClaimsCodec, const Id extends string, const A extends Definition<C, Id>>(
95
110
  auth: A & Definition<C, Id>,
96
111
  ) => {
97
- const features = Object.values(auth.strategies).map((strategy) => strategy.persistence);
98
- const phone = features.some((feature) => feature?.kind === "phone");
99
- const password = features.some((feature) => feature?.kind === "password");
112
+ const features = Object.values(auth.strategies).flatMap((strategy) =>
113
+ strategy.persistence === undefined ? [] : [strategy.persistence],
114
+ );
115
+
116
+ const phone = features.some((feature) => feature.kind === "phone");
117
+ const password = features.some((feature) => feature.kind === "password");
100
118
 
101
119
  const passkeys = features.filter(
102
- (feature): feature is PasskeyFeature => feature?.kind === "passkey",
120
+ (feature): feature is PasskeyFeature => feature.kind === "passkey",
103
121
  );
104
122
 
123
+ const oauth = features.filter((feature): feature is OAuthFeature => feature?.kind === "oauth");
124
+ const oauthSignIn = oauth.some((feature) => feature.signIn);
125
+ const oauthConnected = oauth.some((feature) => feature.connected !== undefined);
126
+ const stateful = auth.sessionMode === "stateful";
127
+
128
+ const stateless =
129
+ auth.sessionMode === "stateless" &&
130
+ features.length > 0 &&
131
+ features.every((feature) => feature?.kind === "oauth");
132
+
105
133
  const management = features.some(
106
- (feature) => feature?.kind === "password" && feature.management,
134
+ (feature) => feature.kind === "password" && feature.management,
107
135
  );
108
136
 
109
- const email = features.some((feature) => feature?.kind === "email" && feature.addresses);
137
+ const email = features.some((feature) => feature.kind === "email" && feature.addresses);
110
138
  const proofs = phone || management || email;
111
139
 
112
- const roles: StorageRole[] = ["identifiers", "credentials", "sessions", "pending"];
140
+ const roles: StorageRole[] = ["identifiers", "credentials"];
113
141
 
142
+ if (stateful) roles.push("sessions");
143
+ roles.push("pending");
114
144
  if (password) roles.push("passwords");
115
145
  if (email) roles.push("emailCredentials");
116
146
  if (passkeys.length > 0) roles.push("passkeyCredentials", "passkeyFlows");
117
147
  if (proofs) roles.push("proofs");
148
+ if (oauth.length > 0) roles.push("oauthIdentities", "oauthCredentials");
149
+ if (oauthSignIn) roles.push("oauthSignInFlows");
150
+ if (oauthConnected)
151
+ roles.push("oauthConnectedFlows", "oauthConnectedGrants", "oauthConnectedRevocations");
118
152
 
119
153
  const ConfigKey = Context.Service<
120
154
  ConfigId<A["namespace"]>,
@@ -233,205 +267,229 @@ export const createPersistence = <T extends object, R, Database extends object =
233
267
  };
234
268
 
235
269
  const services = Layer.effectContext(
236
- Effect.gen(function* () {
237
- const storage = yield* ConfigKey;
238
- const client = yield* SqlClient.SqlClient;
239
- const database = yield* backend.acquire;
240
-
241
- const dialect = client.onDialectOrElse({
242
- pg: () => "pg" as const,
243
- sqlite: () => "sqlite" as const,
244
- orElse: () => undefined,
245
- });
270
+ Effect.flatMap(backend.acquire, (database) =>
271
+ Effect.gen(function* () {
272
+ const storage = yield* ConfigKey;
273
+ const client = yield* SqlClient.SqlClient;
274
+
275
+ const dialect = client.onDialectOrElse({
276
+ pg: () => "pg" as const,
277
+ sqlite: () => "sqlite" as const,
278
+ orElse: () => undefined,
279
+ });
246
280
 
247
- if (dialect === undefined)
248
- return yield* configError("Use an explicit adapter for this SQL dialect");
249
- if (auth.sessionMode !== "stateful")
250
- return yield* configError("The composed layer currently requires stateful sessions");
251
- if (
252
- features.some(
253
- (feature) =>
254
- feature === undefined ||
255
- ("lifecycle" in feature && feature.lifecycle) ||
256
- (feature.kind === "email" && !feature.addresses),
281
+ if (dialect === undefined)
282
+ return yield* configError("Use an explicit adapter for this SQL dialect");
283
+ if (
284
+ features.some(
285
+ (feature) =>
286
+ ("lifecycle" in feature && feature.lifecycle) ||
287
+ (feature.kind === "email" && !feature.addresses),
288
+ )
257
289
  )
258
- )
259
- return yield* configError(
260
- "Use explicit services for strategies without composed persistence support",
261
- );
262
- if (storage.namespace !== auth.namespace)
263
- return yield* configError(
264
- "Persistence configuration belongs to a different Auth definition",
265
- );
266
- const validations: StorageValidation[] = [];
290
+ return yield* configError(
291
+ "Use explicit services for strategies without composed persistence support",
292
+ );
293
+ if (storage.namespace !== auth.namespace)
294
+ return yield* configError(
295
+ "Persistence configuration belongs to a different Auth definition",
296
+ );
297
+ const validations: StorageValidation[] = [];
267
298
 
268
- for (const role of roles) {
269
- const table = (storage.schema as Partial<Record<StorageRole, T>>)[role];
299
+ for (const role of roles) {
300
+ const table = (storage.schema as Partial<Record<StorageRole, T>>)[role];
270
301
 
271
- if (table === undefined) return yield* configError(`Missing ${role} table`);
272
- const description = backend.describe(table);
273
- const columns = description.columns;
302
+ if (table === undefined) return yield* configError(`Missing ${role} table`);
303
+ const description = backend.describe(table);
304
+ const columns = description.columns;
274
305
 
275
- for (const name of Object.keys(storageTables[role].columns)) {
276
- if (columns[name] === undefined)
277
- return yield* configError(`Missing ${role}.${name} column`);
306
+ for (const name of Object.keys(storageTables[role].columns)) {
307
+ if (columns[name] === undefined)
308
+ return yield* configError(`Missing ${role}.${name} column`);
309
+ }
310
+ validations.push({ table: description, required: storageTables[role].unique });
278
311
  }
279
- validations.push({ table: description, required: storageTables[role].unique });
280
- }
281
- validations.push({
282
- table: backend.describe(storage.subjects.table as T),
283
- required: [[storage.subjects.id]],
284
- });
285
- yield* validateStorageBatch(dialect, validations);
286
- const mappings = yield* makeMappings(storage);
287
-
288
- const nativeTables = backend.nativeTables(database);
289
- const sessionMapping = mappings.sessions(auth.claims, auth.sessions.moduleId);
290
- const pendingMapping = mappings.pending(auth.claims, auth.sessions.moduleId);
291
-
292
- const sessionServices = yield* makeNativeStatefulSessionServices(
293
- nativeTables,
294
- sessionMapping,
295
- );
312
+ validations.push({
313
+ table: backend.describe(storage.subjects.table as T),
314
+ required: [[storage.subjects.id]],
315
+ });
316
+ yield* validateStorageBatch(dialect, validations);
317
+ const nativeTables = backend.nativeTables(database);
318
+ const mappings = yield* makeMappings(storage, nativeTables);
296
319
 
297
- const { authenticationAuthority } = yield* makeNativeAuthenticationAuthorityServices(
298
- nativeTables,
299
- sessionMapping,
300
- );
320
+ const sessionMapping = stateful
321
+ ? mappings.sessions(auth.claims, auth.sessions.moduleId)
322
+ : undefined;
301
323
 
302
- const { pendingAuthentication } = yield* makeNativePendingAuthenticationServices(
303
- nativeTables,
304
- pendingMapping,
305
- );
324
+ const pendingMapping = mappings.pending(auth.claims, auth.sessions.moduleId);
306
325
 
307
- const { sessionStepUpPersistence } = yield* makeNativeSessionStepUpServices(nativeTables, {
308
- ...pendingMapping,
309
- source: {
310
- kind: "Stateful",
311
- session: sessionMapping.session,
312
- sessionId: sessionMapping.sessionId,
313
- constraints: { sessionDigest: "unique(session.digest)" },
314
- },
315
- });
326
+ const { authenticationAuthority } = yield* makeNativeAuthenticationAuthorityServices(
327
+ nativeTables,
328
+ sessionMapping ?? {
329
+ ...pendingMapping,
330
+ pending: { pending: pendingMapping.pending, login: pendingMapping.login },
331
+ },
332
+ );
316
333
 
317
- const { sessionCleanup } = yield* makeNativeSessionCleanupServices(
318
- nativeTables,
319
- pendingMapping,
320
- );
334
+ const { pendingAuthentication } = yield* makeNativePendingAuthenticationServices(
335
+ nativeTables,
336
+ pendingMapping,
337
+ );
321
338
 
322
- let context: Context.Context<never> = Context.make(
323
- AuthenticationAuthority,
324
- authenticationAuthority,
325
- ).pipe(
326
- Context.add(
327
- auth.sessions.StatefulSessionPersistence,
328
- sessionServices.statefulSessionPersistence,
329
- ),
330
- Context.add(auth.sessions.SessionRepository, sessionServices.sessionRepository),
331
- Context.add(auth.sessions.PendingAuthentication, pendingAuthentication),
332
- Context.add(auth.sessions.SessionStepUpPersistence, sessionStepUpPersistence),
333
- Context.add(auth.sessions.SessionCleanup, sessionCleanup),
334
- );
339
+ const { sessionStepUpPersistence } = yield* makeNativeSessionStepUpServices(
340
+ nativeTables,
341
+ {
342
+ ...pendingMapping,
343
+ source:
344
+ sessionMapping === undefined
345
+ ? { kind: "StatelessSigned" }
346
+ : {
347
+ kind: "Stateful",
348
+ session: sessionMapping.session,
349
+ sessionId: sessionMapping.sessionId,
350
+ constraints: { sessionDigest: "unique(session.digest)" },
351
+ },
352
+ },
353
+ );
335
354
 
336
- const proofMapping = proofs ? mappings.proofs() : undefined;
355
+ const { sessionCleanup } = yield* makeNativeSessionCleanupServices(
356
+ nativeTables,
357
+ pendingMapping,
358
+ );
337
359
 
338
- if (proofMapping !== undefined) {
339
- const services = yield* makeNativeProofServices(
340
- backend.nativeTables(database),
341
- proofMapping,
360
+ let context: Context.Context<never> = Context.make(
361
+ AuthenticationAuthority,
362
+ authenticationAuthority,
363
+ ).pipe(
364
+ Context.add(auth.sessions.PendingAuthentication, pendingAuthentication),
365
+ Context.add(auth.sessions.SessionStepUpPersistence, sessionStepUpPersistence),
366
+ Context.add(auth.sessions.SessionCleanup, sessionCleanup),
342
367
  );
343
368
 
344
- context = Context.add(context, ProofPersistence, services.proofPersistence);
345
- }
369
+ if (sessionMapping !== undefined) {
370
+ const sessionServices = yield* makeNativeStatefulSessionServices(
371
+ nativeTables,
372
+ sessionMapping,
373
+ );
346
374
 
347
- if (password) {
348
- const mapping = mappings.passwords();
375
+ context = context.pipe(
376
+ Context.add(
377
+ auth.sessions.StatefulSessionPersistence,
378
+ sessionServices.statefulSessionPersistence,
379
+ ),
380
+ Context.add(auth.sessions.SessionRepository, sessionServices.sessionRepository),
381
+ );
382
+ }
349
383
 
350
- const services = yield* makeNativePasswordServices(
351
- backend.nativeTables(database),
352
- mapping,
353
- proofMapping,
354
- );
384
+ const proofMapping = proofs ? mappings.proofs() : undefined;
355
385
 
356
- context = Context.add(context, PasswordPersistence, services.passwordPersistence);
357
- }
358
- for (const [name, strategy] of Object.entries(auth.strategies)) {
359
- if (strategy.persistence?.kind !== "password" || !strategy.persistence.management)
360
- continue;
361
- const key = strategy.RegistrationAuthority;
386
+ if (proofMapping !== undefined) {
387
+ const services = yield* makeNativeProofServices(
388
+ backend.nativeTables(database),
389
+ proofMapping,
390
+ );
362
391
 
363
- if (key === undefined)
364
- return yield* configError(`Missing subject provisioning for ${name}`);
392
+ context = Context.add(context, ProofPersistence, services.proofPersistence);
393
+ }
365
394
 
366
- const registration = yield* makeRegistrationAuthority(
367
- backend.nativeTables(database),
368
- mappings.passwords(),
369
- ProvisioningKey,
370
- name,
371
- );
395
+ if (password) {
396
+ const mapping = mappings.passwords();
372
397
 
373
- context = Context.add(context, key, registration);
374
- }
375
- if (email)
376
- context = Context.add(
377
- context,
378
- EmailAddressPersistence,
379
- (yield* makeNativeEmailAddressServices(
398
+ const services = yield* makeNativePasswordServices(
380
399
  backend.nativeTables(database),
381
- mappings.emails(),
400
+ mapping,
382
401
  proofMapping,
383
- )).emailAddressPersistence,
384
- );
402
+ );
385
403
 
386
- if (phone)
387
- context = Context.add(
388
- context,
389
- PhoneSignInTargets,
390
- yield* makeComposedPhoneTargets(
404
+ context = Context.add(context, PasswordPersistence, services.passwordPersistence);
405
+ }
406
+ for (const [name, strategy] of Object.entries(auth.strategies)) {
407
+ if (strategy.persistence?.kind !== "password" || !strategy.persistence.management)
408
+ continue;
409
+ const key = strategy.RegistrationAuthority;
410
+
411
+ if (key === undefined)
412
+ return yield* configError(`Missing subject provisioning for ${name}`);
413
+
414
+ const registration = yield* makeRegistrationAuthority(
391
415
  backend.nativeTables(database),
392
- storage,
393
- mappings.proofs(),
394
- features.flatMap((feature) => (feature?.kind === "phone" ? [feature.moduleId] : [])),
395
- ),
396
- );
416
+ mappings.passwords(),
417
+ ProvisioningKey,
418
+ name,
419
+ );
397
420
 
398
- if (passkeys.length > 0) {
399
- // Auth capability metadata determines whether PasskeyConfig is required.
400
- const services = makeManagedPasskeys(
401
- {
402
- storage,
403
- namespace: auth.namespace,
404
- dialect,
405
- features: passkeys,
406
- passwordModules: features.flatMap((feature) =>
407
- feature?.kind === "password" ? [feature.moduleId] : [],
421
+ context = Context.add(context, key, registration);
422
+ }
423
+ if (email)
424
+ context = Context.add(
425
+ context,
426
+ EmailAddressPersistence,
427
+ (yield* makeNativeEmailAddressServices(
428
+ backend.nativeTables(database),
429
+ mappings.emails(),
430
+ proofMapping,
431
+ )).emailAddressPersistence,
432
+ );
433
+
434
+ if (phone)
435
+ context = Context.add(
436
+ context,
437
+ PhoneSignInTargets,
438
+ yield* makeComposedPhoneTargets(
439
+ backend.nativeTables(database),
440
+ storage,
441
+ mappings.proofs(),
442
+ features.flatMap((feature) => (feature.kind === "phone" ? [feature.moduleId] : [])),
408
443
  ),
409
- },
410
- backend.nativeTables(database),
411
- ) as Effect.Effect<
412
- Context.Context<never>,
413
- PersistenceConfigurationError,
414
- PasskeyRequirement<A> | Crypto.Crypto | LifecycleHooks | SqlClient.SqlClient
415
- >;
416
-
417
- context = Context.merge(context, yield* services);
418
- }
444
+ );
419
445
 
420
- // The checked capability metadata above determines exactly these service keys.
421
- return context as Context.Context<Ports<C, Id, A>>;
422
- }).pipe(
423
- Effect.provideService(SqlBatchCommit, undefined),
424
- withStorageValidation,
425
- Effect.mapError((error) =>
426
- Schema.is(PersistenceConfigurationError)(error)
427
- ? error
428
- : configError("Cannot acquire SQL persistence"),
429
- ),
430
- ),
446
+ if (passkeys.length > 0) {
447
+ // Auth capability metadata determines whether PasskeyConfig is required.
448
+ const services = makeManagedPasskeys(
449
+ {
450
+ storage,
451
+ namespace: auth.namespace,
452
+ dialect,
453
+ features: passkeys,
454
+ passwordModules: features.flatMap((feature) =>
455
+ feature.kind === "password" ? [feature.moduleId] : [],
456
+ ),
457
+ },
458
+ backend.nativeTables(database),
459
+ ) as Effect.Effect<
460
+ Context.Context<never>,
461
+ PersistenceConfigurationError,
462
+ | PasskeyRequirement<A>
463
+ | Crypto.Crypto
464
+ | LifecycleHooks
465
+ | SqlClient.SqlClient
466
+ | SqlBatchCommit
467
+ >;
468
+
469
+ context = Context.merge(context, yield* services);
470
+ }
471
+
472
+ if (oauth.length > 0)
473
+ context = Context.merge(context, yield* makeManagedOAuth(storage, nativeTables, oauth));
474
+
475
+ // The checked capability metadata above determines exactly these service keys.
476
+ return context as Context.Context<Ports<C, Id, A>>;
477
+ }).pipe(Effect.provideService(SqlBatchCommit, backend.batch?.(database))),
478
+ ).pipe(withStorageValidation),
431
479
  ).pipe(Layer.provide(hooksLayer));
432
480
 
433
481
  const layer = Layer.effectContext(
434
482
  Effect.gen(function* () {
483
+ if (!stateful && !stateless) {
484
+ if (auth.sessionMode === "stateless")
485
+ return yield* configError(
486
+ "Composed stateless sessions support only OAuth strategies; use explicit persistence for other strategies",
487
+ );
488
+
489
+ return yield* configError(
490
+ "Composed persistence supports stateful or OAuth-only stateless sessions; state-assisted validity and other session modes require explicit persistence",
491
+ );
492
+ }
435
493
  const scope = yield* Effect.scope;
436
494
  const captured = yield* Effect.context<Layer.Services<typeof services>>();
437
495
  const client = Context.get(captured, SqlClient.SqlClient);
@@ -455,7 +513,22 @@ export const createPersistence = <T extends object, R, Database extends object =
455
513
  if (yield* hasCommitScope) return yield* Effect.fail(unavailable());
456
514
  yield* requireStandalone(unavailable, client.transactionService);
457
515
  }
458
- const acquired = yield* initialize.pipe(Effect.mapError(unavailable));
516
+
517
+ // Observe the cached failure in this invocation, not the captured setup context.
518
+ const acquired = yield* initialize.pipe(
519
+ Effect.tapError((error) =>
520
+ reportAuthDiagnostic(
521
+ "persistence-initialization",
522
+ Schema.is(PersistenceConfigurationError)(error)
523
+ ? "configuration"
524
+ : Schema.is(PersistenceMappingError)(error)
525
+ ? "mapping"
526
+ : "unavailable",
527
+ ),
528
+ ),
529
+ Effect.mapError(unavailable),
530
+ );
531
+
459
532
  const value = Context.getOrUndefined(acquired, key);
460
533
 
461
534
  return value === undefined ? yield* Effect.fail(unavailable()) : value;
@@ -465,18 +538,6 @@ export const createPersistence = <T extends object, R, Database extends object =
465
538
  const authority = service(AuthenticationAuthority, sessionUnavailable, false);
466
539
  const authorityMutation = service(AuthenticationAuthority, sessionUnavailable);
467
540
 
468
- const sessions = service(
469
- auth.sessions.StatefulSessionPersistence,
470
- sessionUnavailable,
471
- false,
472
- );
473
-
474
- const sessionMutations = service(
475
- auth.sessions.StatefulSessionPersistence,
476
- sessionUnavailable,
477
- );
478
-
479
- const repository = service(auth.sessions.SessionRepository, sessionUnavailable, false);
480
541
  const pendingRead = service(auth.sessions.PendingAuthentication, sessionUnavailable, false);
481
542
  const pendingMutation = service(auth.sessions.PendingAuthentication, sessionUnavailable);
482
543
 
@@ -496,22 +557,6 @@ export const createPersistence = <T extends object, R, Database extends object =
496
557
  approve: (input, prepare) =>
497
558
  Effect.flatMap(authorityMutation, (s) => s.approve(input, prepare)),
498
559
  }).pipe(
499
- Context.add(auth.sessions.StatefulSessionPersistence, {
500
- establish: (input, prepare) =>
501
- Effect.flatMap(sessionMutations, (s) => s.establish(input, prepare)),
502
- verify: (input) => Effect.flatMap(sessions, (s) => s.verify(input)),
503
- rotate: (input, prepare) =>
504
- Effect.flatMap(sessionMutations, (s) => s.rotate(input, prepare)),
505
- revokeDigest: (digest, prepare) =>
506
- Effect.flatMap(sessionMutations, (s) => s.revokeDigest(digest, prepare)),
507
- revoke: (input, prepare) =>
508
- Effect.flatMap(sessionMutations, (s) => s.revoke(input, prepare)),
509
- revokeAll: (input, prepare) =>
510
- Effect.flatMap(sessionMutations, (s) => s.revokeAll(input, prepare)),
511
- }),
512
- Context.add(auth.sessions.SessionRepository, {
513
- list: (input) => Effect.flatMap(repository, (s) => s.list(input)),
514
- }),
515
560
  Context.add(auth.sessions.PendingAuthentication, {
516
561
  create: (input, now, prepare) =>
517
562
  Effect.flatMap(pendingMutation, (s) => s.create(input, now, prepare)),
@@ -533,6 +578,40 @@ export const createPersistence = <T extends object, R, Database extends object =
533
578
  }),
534
579
  );
535
580
 
581
+ if (stateful) {
582
+ const sessions = service(
583
+ auth.sessions.StatefulSessionPersistence,
584
+ sessionUnavailable,
585
+ false,
586
+ );
587
+
588
+ const sessionMutations = service(
589
+ auth.sessions.StatefulSessionPersistence,
590
+ sessionUnavailable,
591
+ );
592
+
593
+ const repository = service(auth.sessions.SessionRepository, sessionUnavailable, false);
594
+
595
+ context = context.pipe(
596
+ Context.add(auth.sessions.StatefulSessionPersistence, {
597
+ establish: (input, prepare) =>
598
+ Effect.flatMap(sessionMutations, (s) => s.establish(input, prepare)),
599
+ verify: (input) => Effect.flatMap(sessions, (s) => s.verify(input)),
600
+ rotate: (input, prepare) =>
601
+ Effect.flatMap(sessionMutations, (s) => s.rotate(input, prepare)),
602
+ revokeDigest: (digest, prepare) =>
603
+ Effect.flatMap(sessionMutations, (s) => s.revokeDigest(digest, prepare)),
604
+ revoke: (input, prepare) =>
605
+ Effect.flatMap(sessionMutations, (s) => s.revoke(input, prepare)),
606
+ revokeAll: (input, prepare) =>
607
+ Effect.flatMap(sessionMutations, (s) => s.revokeAll(input, prepare)),
608
+ }),
609
+ Context.add(auth.sessions.SessionRepository, {
610
+ list: (input) => Effect.flatMap(repository, (s) => s.list(input)),
611
+ }),
612
+ );
613
+ }
614
+
536
615
  if (proofs) {
537
616
  const persistence = service(ProofPersistence, () => ProofUnavailable.make({}));
538
617
 
@@ -643,6 +722,83 @@ export const createPersistence = <T extends object, R, Database extends object =
643
722
  }
644
723
  }
645
724
 
725
+ const oauthUnavailable = () => OAuthUnavailable.make({});
726
+
727
+ function oauthService<I, S>(
728
+ key: Context.Key<I, S>,
729
+ moduleId: string,
730
+ capability: "signIn" | "connected",
731
+ ): Effect.Effect<S, OAuthUnavailable> {
732
+ const enabled = oauth.some(
733
+ (feature) =>
734
+ feature.moduleId === moduleId &&
735
+ (capability === "signIn" ? feature.signIn : feature.connected !== undefined),
736
+ );
737
+
738
+ return enabled ? service(key, oauthUnavailable) : Effect.fail(oauthUnavailable());
739
+ }
740
+
741
+ if (oauthSignIn) {
742
+ const persistence = (moduleId: string) =>
743
+ oauthService(OAuthSignInPersistence, moduleId, "signIn");
744
+
745
+ context = Context.add(context, OAuthSignInPersistence, {
746
+ issue: (input, prepare) =>
747
+ Effect.flatMap(persistence(input.context.moduleId), (s) => s.issue(input, prepare)),
748
+ consume: (input, prepare) =>
749
+ Effect.flatMap(persistence(input.moduleId), (s) => s.consume(input, prepare)),
750
+ resolve: (input) =>
751
+ Effect.flatMap(persistence(input.moduleId), (s) => s.resolve(input)),
752
+ cleanup: (input, prepare) =>
753
+ Effect.flatMap(persistence(input.moduleId), (s) => s.cleanup(input, prepare)),
754
+ });
755
+ }
756
+ if (oauthConnected) {
757
+ const persistence = (moduleId: string) =>
758
+ oauthService(OAuthConnectedPersistence, moduleId, "connected");
759
+
760
+ const revocations = (moduleId: string) =>
761
+ oauthService(OAuthConnectedRevocations, moduleId, "connected");
762
+
763
+ context = context.pipe(
764
+ Context.add(OAuthConnectedPersistence, {
765
+ read: (input) => Effect.flatMap(persistence(input.moduleId), (s) => s.read(input)),
766
+ issue: (input, prepare) =>
767
+ Effect.flatMap(persistence(input.context.moduleId), (s) => s.issue(input, prepare)),
768
+ consume: (input, prepare) =>
769
+ Effect.flatMap(persistence(input.moduleId), (s) => s.consume(input, prepare)),
770
+ settle: (input, prepare) =>
771
+ Effect.flatMap(persistence(input.grant.context.moduleId), (s) =>
772
+ s.settle(input, prepare),
773
+ ),
774
+ list: (input) =>
775
+ Effect.flatMap(persistence(input.authorization.moduleId), (s) => s.list(input)),
776
+ disconnect: (input, prepare) =>
777
+ Effect.flatMap(persistence(input.key.moduleId), (s) =>
778
+ s.disconnect(input, prepare),
779
+ ),
780
+ claimRefresh: (input, prepare) =>
781
+ Effect.flatMap(persistence(input.grant.context.moduleId), (s) =>
782
+ s.claimRefresh(input, prepare),
783
+ ),
784
+ settleRefresh: (input, prepare) =>
785
+ Effect.flatMap(persistence(input.claim.grant.context.moduleId), (s) =>
786
+ s.settleRefresh(input, prepare),
787
+ ),
788
+ cleanup: (input, prepare) =>
789
+ Effect.flatMap(persistence(input.moduleId), (s) => s.cleanup(input, prepare)),
790
+ }),
791
+ Context.add(OAuthConnectedRevocations, {
792
+ claim: (input, prepare) =>
793
+ Effect.flatMap(revocations(input.moduleId), (s) => s.claim(input, prepare)),
794
+ settle: (input, prepare) =>
795
+ Effect.flatMap(revocations(input.claim.job.grant.context.moduleId), (s) =>
796
+ s.settle(input, prepare),
797
+ ),
798
+ }),
799
+ );
800
+ }
801
+
646
802
  // Match the same capability-selected service keys as the acquired graph.
647
803
  return context as Context.Context<Ports<C, Id, A>>;
648
804
  }),