@yielded/auth-persistence 0.1.0-beta.31 → 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 (113) hide show
  1. package/README.md +9 -2
  2. package/dist/Adapter.mjs +8 -8
  3. package/dist/OAuthPersistence.mjs +10 -5
  4. package/dist/OAuthPersistence.mjs.map +1 -1
  5. package/dist/internal/configuration.d.mts +31 -5
  6. package/dist/internal/configuration.mjs.map +1 -1
  7. package/dist/internal/email-native.mjs +10 -2
  8. package/dist/internal/email-native.mjs.map +1 -1
  9. package/dist/internal/email-policy.mjs +3 -2
  10. package/dist/internal/email-policy.mjs.map +1 -1
  11. package/dist/internal/models/oauth-model.d.mts +4 -2
  12. package/dist/internal/models/oauth-model.mjs.map +1 -1
  13. package/dist/internal/oauth/native-accounts.d.mts +1 -1
  14. package/dist/internal/oauth/native-accounts.mjs +6 -6
  15. package/dist/internal/oauth/native-accounts.mjs.map +1 -1
  16. package/dist/internal/oauth/native-connected.d.mts +1 -1
  17. package/dist/internal/oauth/native-connected.mjs +1 -1
  18. package/dist/internal/oauth/native-registration.mjs +19 -8
  19. package/dist/internal/oauth/native-registration.mjs.map +1 -1
  20. package/dist/internal/oauth/native-sign-in.d.mts +1 -1
  21. package/dist/internal/oauth/native-sign-in.mjs +1 -1
  22. package/dist/internal/oauth/native-state.mjs +21 -11
  23. package/dist/internal/oauth/native-state.mjs.map +1 -1
  24. package/dist/internal/oauth/state.d.mts +1 -1
  25. package/dist/internal/oauth/state.mjs +1 -1
  26. package/dist/internal/oauth-managed.mjs +182 -0
  27. package/dist/internal/oauth-managed.mjs.map +1 -0
  28. package/dist/internal/passkey-managed.mjs +1 -1
  29. package/dist/internal/passkey-native-registration.mjs +1 -1
  30. package/dist/internal/passkey-native-state.mjs +33 -23
  31. package/dist/internal/passkey-native-state.mjs.map +1 -1
  32. package/dist/internal/passkey-native-write.mjs +3 -3
  33. package/dist/internal/passkey-native-write.mjs.map +1 -1
  34. package/dist/internal/passkey-native.d.mts +15 -7
  35. package/dist/internal/passkey-native.mjs +2 -1
  36. package/dist/internal/passkey-native.mjs.map +1 -1
  37. package/dist/internal/passkey-policy.mjs +9 -7
  38. package/dist/internal/passkey-policy.mjs.map +1 -1
  39. package/dist/internal/password-native.mjs +9 -6
  40. package/dist/internal/password-native.mjs.map +1 -1
  41. package/dist/internal/password-policy.mjs +7 -6
  42. package/dist/internal/password-policy.mjs.map +1 -1
  43. package/dist/internal/persistence.mjs +96 -37
  44. package/dist/internal/persistence.mjs.map +1 -1
  45. package/dist/internal/phone-native.mjs +1 -1
  46. package/dist/internal/phone-native.mjs.map +1 -1
  47. package/dist/internal/session-native-authority.mjs +6 -5
  48. package/dist/internal/session-native-authority.mjs.map +1 -1
  49. package/dist/internal/session-native-login.d.mts +2 -2
  50. package/dist/internal/session-native-login.mjs +2 -1
  51. package/dist/internal/session-native-login.mjs.map +1 -1
  52. package/dist/internal/session-native-pending-read.mjs +26 -20
  53. package/dist/internal/session-native-pending-read.mjs.map +1 -1
  54. package/dist/internal/session-native-record.mjs +6 -4
  55. package/dist/internal/session-native-record.mjs.map +1 -1
  56. package/dist/internal/session-native-state.mjs +3 -0
  57. package/dist/internal/session-native-state.mjs.map +1 -1
  58. package/dist/internal/session-native-stateful.d.mts +2 -2
  59. package/dist/internal/session-native-stateful.mjs +65 -37
  60. package/dist/internal/session-native-stateful.mjs.map +1 -1
  61. package/dist/internal/session-native-step-up.d.mts +2 -2
  62. package/dist/internal/session-native-step-up.mjs +14 -18
  63. package/dist/internal/session-native-step-up.mjs.map +1 -1
  64. package/dist/internal/session-native-validity.mjs +3 -1
  65. package/dist/internal/session-native-validity.mjs.map +1 -1
  66. package/dist/internal/sql-authority.mjs +5 -1
  67. package/dist/internal/sql-authority.mjs.map +1 -1
  68. package/dist/internal/sql-storage-validation.mjs +2 -1
  69. package/dist/internal/sql-storage-validation.mjs.map +1 -1
  70. package/dist/internal/sql-table.d.mts +1 -0
  71. package/dist/internal/sql-table.mjs.map +1 -1
  72. package/dist/internal/step-up-state.mjs +19 -6
  73. package/dist/internal/step-up-state.mjs.map +1 -1
  74. package/dist/internal/storage-mapping.mjs +1 -1
  75. package/dist/internal/storage-tables.d.mts +237 -0
  76. package/dist/internal/storage-tables.mjs +68 -0
  77. package/dist/internal/storage-tables.mjs.map +1 -1
  78. package/dist/internal/storage-validation.d.mts +3 -1
  79. package/dist/internal/storage-validation.mjs +70 -17
  80. package/dist/internal/storage-validation.mjs.map +1 -1
  81. package/dist/internal/totp-native.mjs +1 -1
  82. package/package.json +1 -1
  83. package/src/OAuthPersistence.ts +7 -2
  84. package/src/internal/configuration.ts +46 -7
  85. package/src/internal/email-native.ts +16 -6
  86. package/src/internal/email-policy.ts +12 -3
  87. package/src/internal/models/oauth-model.ts +7 -1
  88. package/src/internal/oauth/native-accounts.ts +3 -3
  89. package/src/internal/oauth/native-registration.ts +17 -6
  90. package/src/internal/oauth/native-state.ts +38 -18
  91. package/src/internal/oauth-managed.ts +277 -0
  92. package/src/internal/passkey-native-state.ts +59 -52
  93. package/src/internal/passkey-native-write.ts +2 -2
  94. package/src/internal/passkey-native.ts +4 -2
  95. package/src/internal/passkey-policy.ts +13 -9
  96. package/src/internal/password-native.ts +17 -9
  97. package/src/internal/password-policy.ts +16 -8
  98. package/src/internal/persistence.ts +216 -70
  99. package/src/internal/phone-native.ts +1 -1
  100. package/src/internal/session-native-authority.ts +23 -7
  101. package/src/internal/session-native-login.ts +6 -3
  102. package/src/internal/session-native-pending-read.ts +46 -33
  103. package/src/internal/session-native-record.ts +6 -4
  104. package/src/internal/session-native-state.ts +3 -0
  105. package/src/internal/session-native-stateful.ts +144 -51
  106. package/src/internal/session-native-step-up.ts +27 -23
  107. package/src/internal/session-native-validity.ts +3 -1
  108. package/src/internal/sql-authority.ts +5 -1
  109. package/src/internal/sql-storage-validation.ts +53 -46
  110. package/src/internal/sql-table.ts +1 -0
  111. package/src/internal/step-up-state.ts +43 -6
  112. package/src/internal/storage-tables.ts +76 -0
  113. 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";
@@ -88,6 +96,11 @@ const timestampKeys = new Set([
88
96
  "deadline",
89
97
  "occurredAt",
90
98
  "dedupUntil",
99
+ "binderExpiresAt",
100
+ "refreshClaimedAt",
101
+ "refreshClaimExpiresAt",
102
+ "claimedAt",
103
+ "claimExpiresAt",
91
104
  ]);
92
105
 
93
106
  export const createPersistence = <T extends object, R, Database extends object = object>(
@@ -96,27 +109,46 @@ export const createPersistence = <T extends object, R, Database extends object =
96
109
  const make = <C extends ClaimsCodec, const Id extends string, const A extends Definition<C, Id>>(
97
110
  auth: A & Definition<C, Id>,
98
111
  ) => {
99
- const features = Object.values(auth.strategies).map((strategy) => strategy.persistence);
100
- const phone = features.some((feature) => feature?.kind === "phone");
101
- 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");
102
118
 
103
119
  const passkeys = features.filter(
104
- (feature): feature is PasskeyFeature => feature?.kind === "passkey",
120
+ (feature): feature is PasskeyFeature => feature.kind === "passkey",
105
121
  );
106
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
+
107
133
  const management = features.some(
108
- (feature) => feature?.kind === "password" && feature.management,
134
+ (feature) => feature.kind === "password" && feature.management,
109
135
  );
110
136
 
111
- const email = features.some((feature) => feature?.kind === "email" && feature.addresses);
137
+ const email = features.some((feature) => feature.kind === "email" && feature.addresses);
112
138
  const proofs = phone || management || email;
113
139
 
114
- const roles: StorageRole[] = ["identifiers", "credentials", "sessions", "pending"];
140
+ const roles: StorageRole[] = ["identifiers", "credentials"];
115
141
 
142
+ if (stateful) roles.push("sessions");
143
+ roles.push("pending");
116
144
  if (password) roles.push("passwords");
117
145
  if (email) roles.push("emailCredentials");
118
146
  if (passkeys.length > 0) roles.push("passkeyCredentials", "passkeyFlows");
119
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");
120
152
 
121
153
  const ConfigKey = Context.Service<
122
154
  ConfigId<A["namespace"]>,
@@ -248,12 +280,9 @@ export const createPersistence = <T extends object, R, Database extends object =
248
280
 
249
281
  if (dialect === undefined)
250
282
  return yield* configError("Use an explicit adapter for this SQL dialect");
251
- if (auth.sessionMode !== "stateful")
252
- return yield* configError("The composed layer currently requires stateful sessions");
253
283
  if (
254
284
  features.some(
255
285
  (feature) =>
256
- feature === undefined ||
257
286
  ("lifecycle" in feature && feature.lifecycle) ||
258
287
  (feature.kind === "email" && !feature.addresses),
259
288
  )
@@ -287,17 +316,19 @@ export const createPersistence = <T extends object, R, Database extends object =
287
316
  yield* validateStorageBatch(dialect, validations);
288
317
  const nativeTables = backend.nativeTables(database);
289
318
  const mappings = yield* makeMappings(storage, nativeTables);
290
- const sessionMapping = mappings.sessions(auth.claims, auth.sessions.moduleId);
291
- const pendingMapping = mappings.pending(auth.claims, auth.sessions.moduleId);
292
319
 
293
- const sessionServices = yield* makeNativeStatefulSessionServices(
294
- nativeTables,
295
- sessionMapping,
296
- );
320
+ const sessionMapping = stateful
321
+ ? mappings.sessions(auth.claims, auth.sessions.moduleId)
322
+ : undefined;
323
+
324
+ const pendingMapping = mappings.pending(auth.claims, auth.sessions.moduleId);
297
325
 
298
326
  const { authenticationAuthority } = yield* makeNativeAuthenticationAuthorityServices(
299
327
  nativeTables,
300
- sessionMapping,
328
+ sessionMapping ?? {
329
+ ...pendingMapping,
330
+ pending: { pending: pendingMapping.pending, login: pendingMapping.login },
331
+ },
301
332
  );
302
333
 
303
334
  const { pendingAuthentication } = yield* makeNativePendingAuthenticationServices(
@@ -309,12 +340,15 @@ export const createPersistence = <T extends object, R, Database extends object =
309
340
  nativeTables,
310
341
  {
311
342
  ...pendingMapping,
312
- source: {
313
- kind: "Stateful",
314
- session: sessionMapping.session,
315
- sessionId: sessionMapping.sessionId,
316
- constraints: { sessionDigest: "unique(session.digest)" },
317
- },
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
+ },
318
352
  },
319
353
  );
320
354
 
@@ -327,16 +361,26 @@ export const createPersistence = <T extends object, R, Database extends object =
327
361
  AuthenticationAuthority,
328
362
  authenticationAuthority,
329
363
  ).pipe(
330
- Context.add(
331
- auth.sessions.StatefulSessionPersistence,
332
- sessionServices.statefulSessionPersistence,
333
- ),
334
- Context.add(auth.sessions.SessionRepository, sessionServices.sessionRepository),
335
364
  Context.add(auth.sessions.PendingAuthentication, pendingAuthentication),
336
365
  Context.add(auth.sessions.SessionStepUpPersistence, sessionStepUpPersistence),
337
366
  Context.add(auth.sessions.SessionCleanup, sessionCleanup),
338
367
  );
339
368
 
369
+ if (sessionMapping !== undefined) {
370
+ const sessionServices = yield* makeNativeStatefulSessionServices(
371
+ nativeTables,
372
+ sessionMapping,
373
+ );
374
+
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
+ }
383
+
340
384
  const proofMapping = proofs ? mappings.proofs() : undefined;
341
385
 
342
386
  if (proofMapping !== undefined) {
@@ -395,9 +439,7 @@ export const createPersistence = <T extends object, R, Database extends object =
395
439
  backend.nativeTables(database),
396
440
  storage,
397
441
  mappings.proofs(),
398
- features.flatMap((feature) =>
399
- feature?.kind === "phone" ? [feature.moduleId] : [],
400
- ),
442
+ features.flatMap((feature) => (feature.kind === "phone" ? [feature.moduleId] : [])),
401
443
  ),
402
444
  );
403
445
 
@@ -410,7 +452,7 @@ export const createPersistence = <T extends object, R, Database extends object =
410
452
  dialect,
411
453
  features: passkeys,
412
454
  passwordModules: features.flatMap((feature) =>
413
- feature?.kind === "password" ? [feature.moduleId] : [],
455
+ feature.kind === "password" ? [feature.moduleId] : [],
414
456
  ),
415
457
  },
416
458
  backend.nativeTables(database),
@@ -427,21 +469,27 @@ export const createPersistence = <T extends object, R, Database extends object =
427
469
  context = Context.merge(context, yield* services);
428
470
  }
429
471
 
472
+ if (oauth.length > 0)
473
+ context = Context.merge(context, yield* makeManagedOAuth(storage, nativeTables, oauth));
474
+
430
475
  // The checked capability metadata above determines exactly these service keys.
431
476
  return context as Context.Context<Ports<C, Id, A>>;
432
477
  }).pipe(Effect.provideService(SqlBatchCommit, backend.batch?.(database))),
433
- ).pipe(
434
- withStorageValidation,
435
- Effect.mapError((error) =>
436
- Schema.is(PersistenceConfigurationError)(error)
437
- ? error
438
- : configError("Cannot acquire SQL persistence"),
439
- ),
440
- ),
478
+ ).pipe(withStorageValidation),
441
479
  ).pipe(Layer.provide(hooksLayer));
442
480
 
443
481
  const layer = Layer.effectContext(
444
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
+ }
445
493
  const scope = yield* Effect.scope;
446
494
  const captured = yield* Effect.context<Layer.Services<typeof services>>();
447
495
  const client = Context.get(captured, SqlClient.SqlClient);
@@ -465,7 +513,22 @@ export const createPersistence = <T extends object, R, Database extends object =
465
513
  if (yield* hasCommitScope) return yield* Effect.fail(unavailable());
466
514
  yield* requireStandalone(unavailable, client.transactionService);
467
515
  }
468
- 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
+
469
532
  const value = Context.getOrUndefined(acquired, key);
470
533
 
471
534
  return value === undefined ? yield* Effect.fail(unavailable()) : value;
@@ -475,18 +538,6 @@ export const createPersistence = <T extends object, R, Database extends object =
475
538
  const authority = service(AuthenticationAuthority, sessionUnavailable, false);
476
539
  const authorityMutation = service(AuthenticationAuthority, sessionUnavailable);
477
540
 
478
- const sessions = service(
479
- auth.sessions.StatefulSessionPersistence,
480
- sessionUnavailable,
481
- false,
482
- );
483
-
484
- const sessionMutations = service(
485
- auth.sessions.StatefulSessionPersistence,
486
- sessionUnavailable,
487
- );
488
-
489
- const repository = service(auth.sessions.SessionRepository, sessionUnavailable, false);
490
541
  const pendingRead = service(auth.sessions.PendingAuthentication, sessionUnavailable, false);
491
542
  const pendingMutation = service(auth.sessions.PendingAuthentication, sessionUnavailable);
492
543
 
@@ -506,22 +557,6 @@ export const createPersistence = <T extends object, R, Database extends object =
506
557
  approve: (input, prepare) =>
507
558
  Effect.flatMap(authorityMutation, (s) => s.approve(input, prepare)),
508
559
  }).pipe(
509
- Context.add(auth.sessions.StatefulSessionPersistence, {
510
- establish: (input, prepare) =>
511
- Effect.flatMap(sessionMutations, (s) => s.establish(input, prepare)),
512
- verify: (input) => Effect.flatMap(sessions, (s) => s.verify(input)),
513
- rotate: (input, prepare) =>
514
- Effect.flatMap(sessionMutations, (s) => s.rotate(input, prepare)),
515
- revokeDigest: (digest, prepare) =>
516
- Effect.flatMap(sessionMutations, (s) => s.revokeDigest(digest, prepare)),
517
- revoke: (input, prepare) =>
518
- Effect.flatMap(sessionMutations, (s) => s.revoke(input, prepare)),
519
- revokeAll: (input, prepare) =>
520
- Effect.flatMap(sessionMutations, (s) => s.revokeAll(input, prepare)),
521
- }),
522
- Context.add(auth.sessions.SessionRepository, {
523
- list: (input) => Effect.flatMap(repository, (s) => s.list(input)),
524
- }),
525
560
  Context.add(auth.sessions.PendingAuthentication, {
526
561
  create: (input, now, prepare) =>
527
562
  Effect.flatMap(pendingMutation, (s) => s.create(input, now, prepare)),
@@ -543,6 +578,40 @@ export const createPersistence = <T extends object, R, Database extends object =
543
578
  }),
544
579
  );
545
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
+
546
615
  if (proofs) {
547
616
  const persistence = service(ProofPersistence, () => ProofUnavailable.make({}));
548
617
 
@@ -653,6 +722,83 @@ export const createPersistence = <T extends object, R, Database extends object =
653
722
  }
654
723
  }
655
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
+
656
802
  // Match the same capability-selected service keys as the acquired graph.
657
803
  return context as Context.Context<Ports<C, Id, A>>;
658
804
  }),
@@ -428,7 +428,7 @@ export const makeNativePhoneServices = Effect.fnUntraced(function* (
428
428
  const requirement =
429
429
  yield* Schema.decodeEffect(AuthenticationRequirement)(originalRequirement);
430
430
 
431
- if (!passwordEvidenceSatisfiedAt(evidence, requirement, captured.now))
431
+ if (!(yield* passwordEvidenceSatisfiedAt(evidence, requirement, captured.now)))
432
432
  return yield* rejected();
433
433
  }
434
434
  if (evidence.proofs.length === 0) return yield* rejected();
@@ -1,4 +1,5 @@
1
1
  import { type LifecycleHooks } from "@yielded/auth/Hooks";
2
+ import { AuthenticationClock } from "@yielded/auth/Operations";
2
3
  import {
3
4
  type AuthenticationAuthority,
4
5
  StaleAuthentication,
@@ -51,11 +52,14 @@ export const sessionEvidenceDeadline = Effect.fnUntraced(function* (
51
52
  if (!assessed.satisfied) return yield* StaleAuthentication.make({});
52
53
 
53
54
  const boundaries = [
54
- ...new Set(
55
- evidence.proofs.map(
55
+ ...new Set([
56
+ ...evidence.proofs.map(
56
57
  (proof) => DateTime.toEpochMillis(proof.verifiedAt) + requirement.maximumAgeMillis,
57
58
  ),
58
- ),
59
+ ...(evidence.completionExpiresAt === undefined
60
+ ? []
61
+ : [DateTime.toEpochMillis(evidence.completionExpiresAt)]),
62
+ ]),
59
63
  ]
60
64
  .filter((n) => n > now)
61
65
  .sort((a, b) => a - b);
@@ -139,11 +143,12 @@ export const makeNativeAuthenticationAuthorityServices = Effect.fnUntraced(funct
139
143
  });
140
144
 
141
145
  const native = <A, E, R>(work: Effect.Effect<A, E, R>) =>
142
- batch === undefined
146
+ (batch === undefined
143
147
  ? executor.operation(normalizeSessionOperation(work))
144
148
  : executor
145
149
  .operationBatch(normalizeSessionOperation(work))
146
- .pipe(Effect.provideService(SqlBatchCommit, batch));
150
+ .pipe(Effect.provideService(SqlBatchCommit, batch))
151
+ ).pipe(Effect.provideService(AuthenticationClock, state.clockPolicy));
147
152
 
148
153
  const authenticationAuthority: AuthenticationAuthority["Service"] = {
149
154
  capture: (subjectId, credentialIds) =>
@@ -183,10 +188,20 @@ export const makeNativeAuthenticationAuthorityServices = Effect.fnUntraced(funct
183
188
  current.now,
184
189
  );
185
190
 
186
- const expires = DateTime.toEpochMillis(input.expiresAt),
191
+ const issued = DateTime.toEpochMillis(input.issuedAt),
192
+ expires = DateTime.toEpochMillis(input.expiresAt),
187
193
  absolute = DateTime.toEpochMillis(input.absoluteExpiresAt);
188
194
 
189
- if (current.now >= expires || expires > absolute)
195
+ if (
196
+ Math.max(issued, DateTime.toEpochMillis(input.now)) - current.now >
197
+ state.clockPolicy.futureToleranceMillis ||
198
+ input.evidence.proofs.some(
199
+ (proof) => DateTime.toEpochMillis(proof.verifiedAt) > issued,
200
+ ) ||
201
+ issued >= expires ||
202
+ current.now >= expires ||
203
+ expires > absolute
204
+ )
190
205
  return yield* StaleAuthentication.make({});
191
206
 
192
207
  const pendingExpiry =
@@ -226,6 +241,7 @@ export const makeNativeAuthenticationAuthorityServices = Effect.fnUntraced(funct
226
241
  if (rows.length !== 1) return yield* PendingAuthenticationInvalid.make({});
227
242
  }
228
243
  }).pipe(
244
+ Effect.provideService(AuthenticationClock, state.clockPolicy),
229
245
  Effect.mapError((cause) =>
230
246
  PersistenceMappingError.make({ operation: "decode", cause }),
231
247
  ),
@@ -1,10 +1,12 @@
1
1
  import type { LifecycleHooks } from "@yielded/auth/Hooks";
2
+ import { AuthenticationClock } from "@yielded/auth/Operations";
2
3
  import {
3
4
  PendingAuthenticationInvalid,
4
5
  StaleAuthentication,
5
6
  SessionConflict,
6
7
  type PendingAuthentication,
7
8
  type PendingAuthenticationRecord,
9
+ type SessionUnavailable,
8
10
  } from "@yielded/auth/Sessions";
9
11
  import { DateTime, Effect, Option } from "effect";
10
12
  import type { SqlClient } from "effect/sql/SqlClient";
@@ -46,7 +48,7 @@ export const makeNativePendingAuthenticationServices = Effect.fnUntraced(functio
46
48
  mapping: NativePendingAuthenticationMapping<Claims>,
47
49
  ): Effect.fn.Return<
48
50
  { readonly pendingAuthentication: PendingAuthentication<Claims> },
49
- never,
51
+ SessionUnavailable,
50
52
  SqlClient | LifecycleHooks | SqlBatchCommit
51
53
  > {
52
54
  const batch = yield* SqlBatchCommit;
@@ -67,11 +69,12 @@ export const makeNativePendingAuthenticationServices = Effect.fnUntraced(functio
67
69
  work: Effect.Effect<A, E, R>,
68
70
  mode: "transaction" | "statement" = "transaction",
69
71
  ) =>
70
- batch === undefined
72
+ (batch === undefined
71
73
  ? executor.operation(normalizeSessionOperation(work), mode)
72
74
  : executor
73
75
  .operationBatch(normalizeSessionOperation(work))
74
- .pipe(Effect.provideService(SqlBatchCommit, batch));
76
+ .pipe(Effect.provideService(SqlBatchCommit, batch))
77
+ ).pipe(Effect.provideService(AuthenticationClock, state.clockPolicy));
75
78
 
76
79
  const verifyPayload = Effect.fnUntraced(function* (
77
80
  stored: import("./session-native-pending").StoredSessionPending,
@@ -1,4 +1,4 @@
1
- import type { TokenDigest } from "@yielded/auth/Schema";
1
+ import type { SubjectId, TokenDigest } from "@yielded/auth/Schema";
2
2
  import type { PendingAuthenticationKind } from "@yielded/auth/Sessions";
3
3
  import { Effect } from "effect";
4
4
 
@@ -6,13 +6,15 @@ import type { NativeSqlTables } from "./native-sql-table";
6
6
  import { makeNativeSessionPending } from "./session-native-pending";
7
7
  import {
8
8
  makeNativeSessionAuthorityState,
9
+ sessionInvariant,
10
+ sessionUnavailable,
9
11
  type NativeSessionAuthorityMapping,
10
12
  } from "./session-native-state";
11
13
  import { exactSqlText } from "./sql-change";
14
+ import { canJoinTextColumns } from "./storage-validation";
12
15
 
13
- /** Both semantic pending ports use the same kind-bound physical snapshot.
14
- * Distinct native ID encodings use their own bound reads; joined rows still
15
- * validate each decoded owner before returning current policy. */
16
+ /** Both ports re-read the pending row with current authority in one snapshot.
17
+ * Discovery only selects the independently bound owner for incompatible IDs. */
16
18
  export const makeNativeSessionPendingReader = Effect.fnUntraced(function* (
17
19
  tables: NativeSqlTables,
18
20
  mapping: NativeSessionAuthorityMapping & Parameters<typeof makeNativeSessionPending>[1],
@@ -21,47 +23,58 @@ export const makeNativeSessionPendingReader = Effect.fnUntraced(function* (
21
23
  const state = yield* makeNativeSessionAuthorityState(tables, mapping, batch);
22
24
  const pending = yield* makeNativeSessionPending(tables, mapping, batch);
23
25
 
26
+ const joinedIds = yield* canJoinTextColumns([
27
+ pending.table.unencodedTextColumn?.(pending.p.subjectId),
28
+ state.subject.unencodedTextColumn?.(mapping.subject.id),
29
+ state.credential.unencodedTextColumn?.(mapping.credential.subjectId),
30
+ ]).pipe(Effect.mapError(sessionUnavailable));
31
+
24
32
  const read = Effect.fnUntraced(function* (kind: PendingAuthenticationKind, digest: TokenDigest) {
25
33
  const { sql, now } = state,
26
34
  { p, table } = pending;
27
35
 
28
- const compatible =
29
- table.unencodedTextColumn?.(p.subjectId) !== undefined &&
30
- state.subject.unencodedTextColumn?.(mapping.subject.id) !== undefined &&
31
- state.credential.unencodedTextColumn?.(mapping.credential.subjectId) !== undefined;
36
+ let discoveredSubjectId: SubjectId | undefined;
37
+ let nativeId: unknown;
32
38
 
33
- if (compatible) {
34
- const pt = table.as("authentication_pending"),
35
- st = state.joinedSubject,
36
- ct = state.joinedCredential,
37
- s = mapping.subject,
38
- c = mapping.credential;
39
+ if (!joinedIds) {
40
+ const selected = yield* pending.read(kind, digest);
39
41
 
40
- const rows =
41
- yield* sql`select ${pt.fields("p_")}, ${st.fields("s_")}, ${ct.fields("c_")}, ${now} as engine_now from ${pt.name} join ${st.name} on ${exactSqlText(sql, st.column(s.id), pt.column(p.subjectId))} and ${state.activeSubject(st)} left join ${ct.name} on ${exactSqlText(sql, ct.column(c.subjectId), pt.column(p.subjectId))} and ${state.activeCredential(ct)} where ${pending.predicate(pt, kind, digest)} limit 65`;
42
+ if (selected === undefined) return undefined;
43
+ discoveredSubjectId = selected.record.subjectId;
44
+ nativeId = yield* mapping.subjectId.toNative(discoveredSubjectId);
45
+ }
42
46
 
43
- if (rows[0] === undefined) return undefined;
44
- const record = yield* pending.decode(kind, pt.decode(rows[0], "p_"));
45
- const owner = yield* mapping.subjectId.toNative(record.subjectId);
47
+ const pt = table.as("authentication_pending"),
48
+ st = state.joinedSubject,
49
+ ct = state.joinedCredential,
50
+ s = mapping.subject,
51
+ c = mapping.credential;
46
52
 
47
- const authority = yield* state.decode(
48
- record.subjectId,
49
- owner,
50
- rows[0],
51
- rows.map((row) => ct.decode(row, "c_")),
52
- rows[0].engine_now,
53
- );
53
+ const bindOwner = (table: typeof pt, key: string) =>
54
+ joinedIds
55
+ ? exactSqlText(sql, table.column(key), pt.column(p.subjectId))
56
+ : state.id(table, key, nativeId);
54
57
 
55
- return authority === undefined ? undefined : { record, authority };
56
- }
57
- const selected = yield* pending.read(kind, digest);
58
+ const rows =
59
+ yield* sql`select ${pt.fields("p_")}, ${st.fields("s_")}, ${ct.fields("c_")}, ${now} as engine_now from ${pt.name} join ${st.name} on ${bindOwner(st, s.id)} and ${state.activeSubject(st)} left join ${ct.name} on ${bindOwner(ct, c.subjectId)} and ${state.activeCredential(ct)} where ${pending.predicate(pt, kind, digest)}${joinedIds ? sql`` : sql` and ${bindOwner(pt, p.subjectId)}`} limit 65`;
60
+
61
+ if (rows[0] === undefined) return undefined;
62
+ const record = yield* pending.decode(kind, pt.decode(rows[0], "p_"));
63
+
64
+ sessionInvariant(joinedIds || record.subjectId === discoveredSubjectId);
65
+ const owner = yield* mapping.subjectId.toNative(record.subjectId);
58
66
 
59
- if (selected === undefined) return undefined;
60
- const authority = yield* state.read(selected.record.subjectId);
67
+ const authority = yield* state.decode(
68
+ record.subjectId,
69
+ owner,
70
+ rows[0],
71
+ rows.map((row) => ct.decode(row, "c_")),
72
+ rows[0].engine_now,
73
+ );
61
74
 
62
- return authority === undefined || authority.now >= selected.record.expiresAtMillis
75
+ return authority === undefined || authority.now >= record.expiresAtMillis
63
76
  ? undefined
64
- : { record: selected.record, authority };
77
+ : { record, authority };
65
78
  });
66
79
 
67
80
  return { state, pending, read };