@yielded/auth-persistence 0.1.0-beta.10

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 (182) hide show
  1. package/README.md +27 -0
  2. package/dist/Adapter.d.mts +30 -0
  3. package/dist/Adapter.mjs +25 -0
  4. package/dist/index.d.mts +7 -0
  5. package/dist/index.mjs +7 -0
  6. package/dist/internal/PasswordPreparedPostconditions.d.mts +22 -0
  7. package/dist/internal/PasswordPreparedPostconditions.mjs +12 -0
  8. package/dist/internal/PasswordPreparedPostconditions.mjs.map +1 -0
  9. package/dist/internal/configuration.d.mts +174 -0
  10. package/dist/internal/configuration.mjs +7 -0
  11. package/dist/internal/configuration.mjs.map +1 -0
  12. package/dist/internal/email-kernel.d.mts +275 -0
  13. package/dist/internal/email-kernel.mjs +487 -0
  14. package/dist/internal/email-kernel.mjs.map +1 -0
  15. package/dist/internal/mapping-error.d.mts +10 -0
  16. package/dist/internal/mapping-error.mjs +30 -0
  17. package/dist/internal/mapping-error.mjs.map +1 -0
  18. package/dist/internal/models/common.d.mts +12 -0
  19. package/dist/internal/models/email-model.d.mts +353 -0
  20. package/dist/internal/models/email-model.mjs +21 -0
  21. package/dist/internal/models/email-model.mjs.map +1 -0
  22. package/dist/internal/models/passkey-model.d.mts +266 -0
  23. package/dist/internal/models/passkey-model.mjs +34 -0
  24. package/dist/internal/models/passkey-model.mjs.map +1 -0
  25. package/dist/internal/models/passkey-registration-ceremony-model.d.mts +49 -0
  26. package/dist/internal/models/passkey-registration-ceremony-model.mjs +18 -0
  27. package/dist/internal/models/passkey-registration-ceremony-model.mjs.map +1 -0
  28. package/dist/internal/models/passkey-write-model.d.mts +189 -0
  29. package/dist/internal/models/passkey-write-model.mjs +13 -0
  30. package/dist/internal/models/passkey-write-model.mjs.map +1 -0
  31. package/dist/internal/models/password-model.d.mts +328 -0
  32. package/dist/internal/models/password-model.mjs +23 -0
  33. package/dist/internal/models/password-model.mjs.map +1 -0
  34. package/dist/internal/models/proof-model.d.mts +285 -0
  35. package/dist/internal/models/proof-model.mjs +17 -0
  36. package/dist/internal/models/proof-model.mjs.map +1 -0
  37. package/dist/internal/models/session-model.d.mts +199 -0
  38. package/dist/internal/models/session-model.mjs +20 -0
  39. package/dist/internal/models/session-model.mjs.map +1 -0
  40. package/dist/internal/models/step-up-model.d.mts +66 -0
  41. package/dist/internal/models/step-up-model.mjs +9 -0
  42. package/dist/internal/models/step-up-model.mjs.map +1 -0
  43. package/dist/internal/oauth-app.d.mts +11 -0
  44. package/dist/internal/oauth-app.mjs +57 -0
  45. package/dist/internal/oauth-app.mjs.map +1 -0
  46. package/dist/internal/oauth-server.d.mts +10 -0
  47. package/dist/internal/oauth-server.mjs +66 -0
  48. package/dist/internal/oauth-server.mjs.map +1 -0
  49. package/dist/internal/passkey/admission.d.mts +6 -0
  50. package/dist/internal/passkey/admission.mjs +216 -0
  51. package/dist/internal/passkey/admission.mjs.map +1 -0
  52. package/dist/internal/passkey/credentials.d.mts +14 -0
  53. package/dist/internal/passkey/credentials.mjs +249 -0
  54. package/dist/internal/passkey/credentials.mjs.map +1 -0
  55. package/dist/internal/passkey/enrollment.mjs +138 -0
  56. package/dist/internal/passkey/enrollment.mjs.map +1 -0
  57. package/dist/internal/passkey/flow.d.mts +21 -0
  58. package/dist/internal/passkey/flow.mjs +452 -0
  59. package/dist/internal/passkey/flow.mjs.map +1 -0
  60. package/dist/internal/passkey/management.mjs +231 -0
  61. package/dist/internal/passkey/management.mjs.map +1 -0
  62. package/dist/internal/passkey/registration-ceremony.d.mts +9 -0
  63. package/dist/internal/passkey/registration-ceremony.mjs +129 -0
  64. package/dist/internal/passkey/registration-ceremony.mjs.map +1 -0
  65. package/dist/internal/passkey/registration-custody.d.mts +2 -0
  66. package/dist/internal/passkey/registration-custody.mjs +52 -0
  67. package/dist/internal/passkey/registration-custody.mjs.map +1 -0
  68. package/dist/internal/passkey/registration-write.mjs +211 -0
  69. package/dist/internal/passkey/registration-write.mjs.map +1 -0
  70. package/dist/internal/passkey/state.d.mts +10 -0
  71. package/dist/internal/passkey/state.mjs +234 -0
  72. package/dist/internal/passkey/state.mjs.map +1 -0
  73. package/dist/internal/passkey/target.d.mts +19 -0
  74. package/dist/internal/passkey/target.mjs +190 -0
  75. package/dist/internal/passkey/target.mjs.map +1 -0
  76. package/dist/internal/passkey/write-state.d.mts +17 -0
  77. package/dist/internal/passkey/write-state.mjs +345 -0
  78. package/dist/internal/passkey/write-state.mjs.map +1 -0
  79. package/dist/internal/passkey/write-target.mjs +192 -0
  80. package/dist/internal/passkey/write-target.mjs.map +1 -0
  81. package/dist/internal/passkey-kernel.d.mts +5592 -0
  82. package/dist/internal/passkey-kernel.mjs +49 -0
  83. package/dist/internal/passkey-kernel.mjs.map +1 -0
  84. package/dist/internal/passkeys.d.mts +7 -0
  85. package/dist/internal/passkeys.mjs +368 -0
  86. package/dist/internal/passkeys.mjs.map +1 -0
  87. package/dist/internal/password-kernel.d.mts +290 -0
  88. package/dist/internal/password-kernel.mjs +704 -0
  89. package/dist/internal/password-kernel.mjs.map +1 -0
  90. package/dist/internal/persistence.d.mts +25 -0
  91. package/dist/internal/persistence.mjs +291 -0
  92. package/dist/internal/persistence.mjs.map +1 -0
  93. package/dist/internal/phone-kernel.d.mts +162 -0
  94. package/dist/internal/phone-kernel.mjs +434 -0
  95. package/dist/internal/phone-kernel.mjs.map +1 -0
  96. package/dist/internal/proof-kernel.d.mts +93 -0
  97. package/dist/internal/proof-kernel.mjs +619 -0
  98. package/dist/internal/proof-kernel.mjs.map +1 -0
  99. package/dist/internal/query-operations.d.mts +61 -0
  100. package/dist/internal/registration-contract.d.mts +16 -0
  101. package/dist/internal/registration.mjs +66 -0
  102. package/dist/internal/registration.mjs.map +1 -0
  103. package/dist/internal/session-kernel.d.mts +48 -0
  104. package/dist/internal/session-kernel.mjs +737 -0
  105. package/dist/internal/session-kernel.mjs.map +1 -0
  106. package/dist/internal/sql-persistence.d.mts +10 -0
  107. package/dist/internal/sql-persistence.mjs +37 -0
  108. package/dist/internal/sql-persistence.mjs.map +1 -0
  109. package/dist/internal/sql-query.mjs +207 -0
  110. package/dist/internal/sql-query.mjs.map +1 -0
  111. package/dist/internal/sql-table.d.mts +36 -0
  112. package/dist/internal/sql-table.mjs +81 -0
  113. package/dist/internal/sql-table.mjs.map +1 -0
  114. package/dist/internal/standalone.d.mts +9 -0
  115. package/dist/internal/standalone.mjs +13 -0
  116. package/dist/internal/standalone.mjs.map +1 -0
  117. package/dist/internal/step-up-state.d.mts +49 -0
  118. package/dist/internal/step-up-state.mjs +32 -0
  119. package/dist/internal/step-up-state.mjs.map +1 -0
  120. package/dist/internal/storage-mapping.mjs +686 -0
  121. package/dist/internal/storage-mapping.mjs.map +1 -0
  122. package/dist/internal/storage-tables.d.mts +913 -0
  123. package/dist/internal/storage-tables.mjs +387 -0
  124. package/dist/internal/storage-tables.mjs.map +1 -0
  125. package/dist/internal/storage-validation.mjs +58 -0
  126. package/dist/internal/storage-validation.mjs.map +1 -0
  127. package/dist/internal/transaction-execution-kernel.d.mts +38 -0
  128. package/dist/internal/transaction-execution-kernel.mjs +116 -0
  129. package/dist/internal/transaction-execution-kernel.mjs.map +1 -0
  130. package/dist/internal/transaction-kernel.d.mts +77 -0
  131. package/dist/internal/transaction-kernel.mjs +260 -0
  132. package/dist/internal/transaction-kernel.mjs.map +1 -0
  133. package/package.json +1 -0
  134. package/src/Adapter.ts +291 -0
  135. package/src/index.ts +6 -0
  136. package/src/internal/PasswordPreparedPostconditions.ts +30 -0
  137. package/src/internal/configuration.ts +282 -0
  138. package/src/internal/email-kernel.ts +1535 -0
  139. package/src/internal/mapping-error.ts +36 -0
  140. package/src/internal/models/common.ts +11 -0
  141. package/src/internal/models/email-model.ts +559 -0
  142. package/src/internal/models/passkey-model.ts +390 -0
  143. package/src/internal/models/passkey-registration-ceremony-model.ts +72 -0
  144. package/src/internal/models/passkey-write-model.ts +297 -0
  145. package/src/internal/models/password-model.ts +513 -0
  146. package/src/internal/models/proof-model.ts +424 -0
  147. package/src/internal/models/session-model.ts +375 -0
  148. package/src/internal/models/step-up-model.ts +135 -0
  149. package/src/internal/oauth-app.ts +82 -0
  150. package/src/internal/oauth-server.ts +89 -0
  151. package/src/internal/passkey/admission.ts +361 -0
  152. package/src/internal/passkey/credentials.ts +511 -0
  153. package/src/internal/passkey/enrollment.ts +326 -0
  154. package/src/internal/passkey/flow.ts +1062 -0
  155. package/src/internal/passkey/management.ts +421 -0
  156. package/src/internal/passkey/registration-ceremony.ts +283 -0
  157. package/src/internal/passkey/registration-custody.ts +91 -0
  158. package/src/internal/passkey/registration-write.ts +426 -0
  159. package/src/internal/passkey/state.ts +353 -0
  160. package/src/internal/passkey/target.ts +586 -0
  161. package/src/internal/passkey/write-state.ts +681 -0
  162. package/src/internal/passkey/write-target.ts +452 -0
  163. package/src/internal/passkey-kernel.ts +104 -0
  164. package/src/internal/passkeys.ts +606 -0
  165. package/src/internal/password-kernel.ts +2124 -0
  166. package/src/internal/persistence.ts +588 -0
  167. package/src/internal/phone-kernel.ts +846 -0
  168. package/src/internal/proof-kernel.ts +1954 -0
  169. package/src/internal/query-operations.ts +81 -0
  170. package/src/internal/registration-contract.ts +22 -0
  171. package/src/internal/registration.ts +161 -0
  172. package/src/internal/session-kernel.ts +2217 -0
  173. package/src/internal/sql-persistence.ts +45 -0
  174. package/src/internal/sql-query.ts +298 -0
  175. package/src/internal/sql-table.ts +104 -0
  176. package/src/internal/standalone.ts +30 -0
  177. package/src/internal/step-up-state.ts +139 -0
  178. package/src/internal/storage-mapping.ts +708 -0
  179. package/src/internal/storage-tables.ts +408 -0
  180. package/src/internal/storage-validation.ts +117 -0
  181. package/src/internal/transaction-execution-kernel.ts +313 -0
  182. package/src/internal/transaction-kernel.ts +593 -0
@@ -0,0 +1,2217 @@
1
+ import {
2
+ coordinateCommit,
3
+ CurrentCommitJournal,
4
+ hasCommitScope,
5
+ LifecycleHooks,
6
+ } from "@yielded/auth/Hooks";
7
+ import { reportPersistenceFailure } from "@yielded/auth/Persistence";
8
+ import type { SubjectId, TokenDigest } from "@yielded/auth/Schema";
9
+ import {
10
+ assessAuthentication,
11
+ snapshotSessionAuthenticationProvenance,
12
+ PendingAuthenticationInvalid,
13
+ SessionConflict,
14
+ SessionInvalid,
15
+ SessionStepUpInvalid,
16
+ SessionUnavailable,
17
+ StaleAuthentication,
18
+ type AuthenticationEvidence,
19
+ type AuthenticationRevision,
20
+ type PendingConsumption,
21
+ type SecurityRevision,
22
+ type SessionInspection,
23
+ SessionMetadata,
24
+ pendingAuthenticationContext,
25
+ snapshotPendingAuthenticationContext,
26
+ type PendingAuthentication,
27
+ type PendingAuthenticationRecord,
28
+ type PendingAuthenticationState,
29
+ type AuthenticationAuthority,
30
+ type PrepareSessionCommit,
31
+ type SessionRepository,
32
+ type SignedSessionValidity,
33
+ type StatefulSessionPersistence,
34
+ type StatefulSessionRecord,
35
+ type SessionStepUpIntent,
36
+ type SessionStepUpPersistence,
37
+ } from "@yielded/auth/Sessions";
38
+ import { Cause, Context, DateTime, Effect, Option, Schema } from "effect";
39
+ import type * as SqlError from "effect/unstable/sql/SqlError";
40
+
41
+ import { PersistenceMappingError, isMappedConstraintConflict } from "./mapping-error";
42
+ import type {
43
+ AuthenticationAuthorityMapping,
44
+ PendingAuthenticationTables,
45
+ PendingAuthenticationMapping,
46
+ SessionAuthorityTables,
47
+ SessionFlowTables,
48
+ SessionSubjectTables,
49
+ SignedSessionValidityMapping,
50
+ StatefulSessionMapping,
51
+ } from "./models/session-model";
52
+ import type { SessionStepUpMapping } from "./models/step-up-model";
53
+ /* oxlint-disable no-explicit-any -- existing storage kernels erase foreign table shapes; domain errors remain typed. */
54
+ import type { QueryFailure } from "./query-operations";
55
+ import type { QueryOperations } from "./query-operations";
56
+ import {
57
+ decodeStepUpIntent,
58
+ encodeStepUpIntent,
59
+ sameStepUpRevision,
60
+ stepUpIntentLive,
61
+ validateStepUpPlan,
62
+ stepUpRotationMatches,
63
+ } from "./step-up-state";
64
+
65
+ type CommitMode = "interactive" | "synchronous";
66
+
67
+ type AnySubjectMapping = SessionSubjectTables<any, any>;
68
+
69
+ type AnyAuthorityMapping = SessionAuthorityTables<any, any, any>;
70
+
71
+ type AnyFlowMapping = SessionFlowTables<any, any>;
72
+
73
+ type AnyPendingMapping = AnyAuthorityMapping &
74
+ AnyFlowMapping &
75
+ PendingAuthenticationTables<any, any, any, any>;
76
+
77
+ type AnyStepUpMapping = SessionStepUpMapping<any, any, any, any, any, any, any, any>;
78
+
79
+ interface SessionSqlQuery<A = ReadonlyArray<any>> extends Effect.Effect<
80
+ A,
81
+ QueryFailure | SqlError.SqlError
82
+ > {
83
+ readonly from: (...args: ReadonlyArray<any>) => SessionSqlQuery<A>;
84
+ readonly where: (...args: ReadonlyArray<any>) => SessionSqlQuery<A>;
85
+ readonly limit: (...args: ReadonlyArray<any>) => SessionSqlQuery<A>;
86
+ readonly orderBy: (...args: ReadonlyArray<any>) => SessionSqlQuery<A>;
87
+ readonly for: (...args: ReadonlyArray<any>) => SessionSqlQuery<A>;
88
+ readonly set: (...args: ReadonlyArray<any>) => SessionSqlQuery<A>;
89
+ readonly values: (...args: ReadonlyArray<any>) => SessionSqlQuery<A>;
90
+ }
91
+
92
+ export interface SessionSqlDatabase {
93
+ readonly select: (...args: ReadonlyArray<any>) => SessionSqlQuery;
94
+ readonly insert: (...args: ReadonlyArray<any>) => SessionSqlQuery;
95
+ readonly update: (...args: ReadonlyArray<any>) => SessionSqlQuery;
96
+ readonly delete: (...args: ReadonlyArray<any>) => SessionSqlQuery;
97
+ readonly transaction: <A, E, R>(
98
+ body: (transaction: SessionSqlDatabase) => Effect.Effect<A, E, R>,
99
+ ) => Effect.Effect<A, E | SqlError.SqlError, R>;
100
+ }
101
+
102
+ type Database = SessionSqlDatabase;
103
+
104
+ /** Native SQL authority for the current transaction or savepoint. */
105
+ export class CurrentSessionSql extends Context.Service<CurrentSessionSql, SessionSqlDatabase>()(
106
+ "effect-auth/drizzle/CurrentSessionSql",
107
+ ) {}
108
+
109
+ type Guard = Effect.Effect<void, SessionUnavailable>;
110
+
111
+ export interface SessionSqlOptions {
112
+ readonly mode: CommitMode;
113
+ readonly locking: boolean;
114
+ /** Target-specific ambient transaction preflight. It runs before IDs or writes. */
115
+ readonly standaloneGuard: Guard;
116
+ }
117
+
118
+ type SessionDomainError =
119
+ | SessionStepUpInvalid
120
+ | PendingAuthenticationInvalid
121
+ | SessionConflict
122
+ | SessionInvalid
123
+ | SessionUnavailable
124
+ | StaleAuthentication;
125
+
126
+ type AdapterFailure =
127
+ | QueryFailure
128
+ | PersistenceMappingError
129
+ | Schema.SchemaError
130
+ | SqlError.SqlError;
131
+
132
+ type NormalizedSessionFailure<E> = E extends SessionDomainError ? E : SessionUnavailable;
133
+
134
+ type PendingProjection = "record" | "context";
135
+
136
+ type SqlStepUpRejectAbsence = SessionStepUpInvalid | StaleAuthentication;
137
+
138
+ type SqlStepUpReadFailure = AdapterFailure | SqlStepUpRejectAbsence | SessionUnavailable;
139
+
140
+ export const makeSessionKernel = (operations: QueryOperations) => {
141
+ const { and, eq, gt, lte, inArray, or, sql, column, updateValues } = operations;
142
+ const unavailable = () => SessionUnavailable.make({});
143
+
144
+ const stale = () => StaleAuthentication.make({});
145
+
146
+ const invalidPending = () => PendingAuthenticationInvalid.make({});
147
+
148
+ const invalidSession = () => SessionInvalid.make({});
149
+
150
+ const mapFailureCause = <A, E, R, E2>(
151
+ effect: Effect.Effect<A, E, R>,
152
+ map: (error: E) => E2,
153
+ ): Effect.Effect<A, E2, R> =>
154
+ Effect.catchCause(effect, (cause) => Effect.failCause(Cause.map(cause, map)));
155
+
156
+ const isExpectedMutationError = (error: unknown): error is SessionDomainError => {
157
+ const tag =
158
+ typeof error === "object" && error !== null ? Reflect.get(error, "_tag") : undefined;
159
+
160
+ return (
161
+ tag === "SessionStepUpInvalid" ||
162
+ tag === "StaleAuthentication" ||
163
+ tag === "PendingAuthenticationInvalid" ||
164
+ tag === "SessionConflict" ||
165
+ tag === "SessionInvalid" ||
166
+ tag === "SessionUnavailable"
167
+ );
168
+ };
169
+
170
+ const normalizeMutation =
171
+ (mapping: any) =>
172
+ <A, E, R>(effect: Effect.Effect<A, E, R>) =>
173
+ effect.pipe(
174
+ Effect.catchCause((cause) =>
175
+ Effect.failCause(
176
+ Cause.map(cause, (error) =>
177
+ isMappedConstraintConflict(mapping.isConstraintConflict, error)
178
+ ? SessionConflict.make({})
179
+ : error,
180
+ ),
181
+ ),
182
+ ),
183
+ );
184
+
185
+ const safeTransaction = <A, E, R>(
186
+ effect: Effect.Effect<A, E, R>,
187
+ ): Effect.Effect<A, NormalizedSessionFailure<E> | SessionUnavailable, R> =>
188
+ reportPersistenceFailure(effect, isExpectedMutationError).pipe(
189
+ Effect.catchCause((cause) =>
190
+ Effect.failCause(
191
+ Cause.map(
192
+ cause,
193
+ (error): NormalizedSessionFailure<E> =>
194
+ (isExpectedMutationError(error)
195
+ ? error
196
+ : unavailable()) as NormalizedSessionFailure<E>,
197
+ ),
198
+ ),
199
+ ),
200
+ );
201
+
202
+ const selectRows = (
203
+ query: SessionSqlQuery,
204
+ locking: boolean,
205
+ ): Effect.Effect<ReadonlyArray<any>, AdapterFailure> =>
206
+ locking && typeof query.for === "function" ? query.for("update") : query;
207
+
208
+ const inTransaction = <A, E, R>(
209
+ database: Database,
210
+ effect: Effect.Effect<A, E, R | CurrentSessionSql>,
211
+ ): Effect.Effect<A, E | SqlError.SqlError, Exclude<R, CurrentSessionSql>> =>
212
+ Effect.suspend(() =>
213
+ database.transaction((current) =>
214
+ effect.pipe(Effect.provideService(CurrentSessionSql, current)),
215
+ ),
216
+ );
217
+
218
+ const owned = <A, E, R>(
219
+ database: Database,
220
+ options: SessionSqlOptions,
221
+ isConstraintConflict: (cause: unknown) => boolean,
222
+ body: Effect.Effect<A, E, R | CurrentSessionSql | CurrentCommitJournal>,
223
+ ): Effect.Effect<
224
+ A,
225
+ NormalizedSessionFailure<E> | SessionUnavailable,
226
+ Exclude<Exclude<R, CurrentSessionSql>, CurrentCommitJournal> | LifecycleHooks
227
+ > => {
228
+ const run = coordinateCommit(
229
+ () =>
230
+ database.transaction((current) =>
231
+ body.pipe(Effect.provideService(CurrentSessionSql, current)),
232
+ ),
233
+ { mode: options.mode },
234
+ ).pipe(
235
+ (effect) =>
236
+ reportPersistenceFailure(
237
+ effect,
238
+ (error) =>
239
+ isExpectedMutationError(error) ||
240
+ isMappedConstraintConflict(isConstraintConflict, error),
241
+ ),
242
+ Effect.map((result) => result.value),
243
+ Effect.catchCause((cause) =>
244
+ Effect.failCause(
245
+ Cause.map(cause, (error): NormalizedSessionFailure<E> | SessionUnavailable =>
246
+ isExpectedMutationError(error) ? (error as NormalizedSessionFailure<E>) : unavailable(),
247
+ ),
248
+ ),
249
+ ),
250
+ );
251
+
252
+ return Effect.gen(function* () {
253
+ const current = yield* Effect.serviceOption(CurrentSessionSql);
254
+ const transactionBound = Option.isSome(current) && current.value === database;
255
+
256
+ if (!transactionBound) {
257
+ // A root service must never commit one database underneath another owner.
258
+ if (yield* hasCommitScope) return yield* unavailable();
259
+ yield* options.standaloneGuard;
260
+ }
261
+
262
+ // A tx-bound service still owns a savepoint and child journal. If the caller
263
+ // catches its failure, both partial writes and prepared values are discarded.
264
+ return yield* run;
265
+ });
266
+ };
267
+
268
+ const uniqueStrings = (values: ReadonlyArray<string>): ReadonlyArray<string> =>
269
+ [...new Set(values)].sort();
270
+
271
+ const preservesRevision = (
272
+ completed: AuthenticationEvidence,
273
+ original: AuthenticationEvidence,
274
+ ): boolean => {
275
+ const current = new Map(
276
+ completed.revision.credentials.map((item) => [item.credentialId, item.revision]),
277
+ );
278
+
279
+ return (
280
+ completed.flowId === original.flowId &&
281
+ completed.bindingDigest === original.bindingDigest &&
282
+ completed.revision.subjectId === original.revision.subjectId &&
283
+ completed.revision.securityRevision === original.revision.securityRevision &&
284
+ original.revision.credentials.every(
285
+ (item) => current.get(item.credentialId) === item.revision,
286
+ ) &&
287
+ original.proofs.every((expected) =>
288
+ completed.proofs.some(
289
+ (proof) =>
290
+ proof.method === expected.method &&
291
+ proof.credentialId === expected.credentialId &&
292
+ proof.userVerified === expected.userVerified &&
293
+ proof.phishingResistant === expected.phishingResistant &&
294
+ DateTime.toEpochMillis(proof.verifiedAt) ===
295
+ DateTime.toEpochMillis(expected.verifiedAt) &&
296
+ proof.factors.length === expected.factors.length &&
297
+ proof.factors.every((factor, index) => factor === expected.factors[index]),
298
+ ),
299
+ )
300
+ );
301
+ };
302
+
303
+ const authorityColumns = (mapping: any) => ({
304
+ subjectId: column(mapping.subject.table, mapping.subject.id),
305
+ subjectStatus: column(mapping.subject.table, mapping.subject.status),
306
+ subjectRevision: column(mapping.subject.table, mapping.subject.securityRevision),
307
+ credentialSubjectId: column(mapping.credential.table, mapping.credential.subjectId),
308
+ credentialId: column(mapping.credential.table, mapping.credential.credentialId),
309
+ credentialRevision: column(mapping.credential.table, mapping.credential.revision),
310
+ ...(mapping.credential.status === undefined
311
+ ? {}
312
+ : { credentialStatus: column(mapping.credential.table, mapping.credential.status) }),
313
+ });
314
+
315
+ const subjectColumns = (mapping: any) => ({
316
+ subjectId: column(mapping.subject.table, mapping.subject.id),
317
+ subjectStatus: column(mapping.subject.table, mapping.subject.status),
318
+ subjectRevision: column(mapping.subject.table, mapping.subject.securityRevision),
319
+ });
320
+
321
+ const readSubject = Effect.fn("DrizzleSession.readSubject")(function* (
322
+ mapping: AnySubjectMapping,
323
+ nativeSubjectId: unknown,
324
+ locking: boolean,
325
+ ) {
326
+ const database = yield* CurrentSessionSql;
327
+ const columns = subjectColumns(mapping);
328
+
329
+ return yield* selectRows(
330
+ database
331
+ .select()
332
+ .from(mapping.subject.table)
333
+ .where(eq(columns.subjectId, nativeSubjectId))
334
+ .limit(1),
335
+ locking,
336
+ );
337
+ });
338
+
339
+ const captureDetailsIn = Effect.fn("DrizzleSession.captureDetails")(function* (
340
+ mapping: AnyAuthorityMapping,
341
+ subjectId: SubjectId,
342
+ credentialIds: ReadonlyArray<string>,
343
+ locking: boolean,
344
+ ) {
345
+ const database = yield* CurrentSessionSql;
346
+ const requested = uniqueStrings(credentialIds);
347
+
348
+ if (requested.length !== credentialIds.length) return yield* stale();
349
+ const nativeSubjectId = yield* mapping.subjectId.toNative(subjectId);
350
+ const subjectRows = yield* readSubject(mapping, nativeSubjectId, locking);
351
+ const subject = subjectRows[0];
352
+ const columns = authorityColumns(mapping);
353
+
354
+ if (subject === undefined || !mapping.subject.isActiveStatus(subject[mapping.subject.status]))
355
+ return yield* stale();
356
+
357
+ const query = database
358
+ .select()
359
+ .from(mapping.credential.table)
360
+ .where(
361
+ and(
362
+ eq(columns.credentialSubjectId, nativeSubjectId),
363
+ requested.length === 0 ? sql`false` : inArray(columns.credentialId, requested),
364
+ ),
365
+ )
366
+ .orderBy(columns.credentialId);
367
+
368
+ const credentialRows = requested.length === 0 ? [] : yield* selectRows(query, locking);
369
+
370
+ if (credentialRows.length !== requested.length) return yield* stale();
371
+
372
+ const credentials = credentialRows
373
+ .map((row: any) => ({
374
+ credentialId: row[mapping.credential.credentialId] as string,
375
+ revision: row[mapping.credential.revision] as SecurityRevision,
376
+ active:
377
+ mapping.credential.status === undefined ||
378
+ mapping.credential.isActiveStatus?.(row[mapping.credential.status]) === true,
379
+ }))
380
+ .sort((left: any, right: any) => left.credentialId.localeCompare(right.credentialId));
381
+
382
+ if (credentials.some((item: any) => !item.active)) return yield* stale();
383
+
384
+ return {
385
+ subject,
386
+ revision: {
387
+ subjectId,
388
+ securityRevision: subject[mapping.subject.securityRevision] as SecurityRevision,
389
+ credentials: credentials.map(({ credentialId, revision }: any) => ({
390
+ credentialId,
391
+ revision,
392
+ })),
393
+ } satisfies AuthenticationRevision,
394
+ };
395
+ });
396
+
397
+ const captureIn = Effect.fn("DrizzleSession.capture")(function* (
398
+ mapping: AnyAuthorityMapping,
399
+ subjectId: SubjectId,
400
+ credentialIds: ReadonlyArray<string>,
401
+ locking: boolean,
402
+ ) {
403
+ return (yield* captureDetailsIn(mapping, subjectId, credentialIds, locking)).revision;
404
+ });
405
+
406
+ const validateEvidenceIn = Effect.fn("DrizzleSession.validateEvidence")(function* (
407
+ mapping: AnyAuthorityMapping,
408
+ evidence: AuthenticationEvidence,
409
+ locking: boolean,
410
+ ) {
411
+ const captured = yield* captureDetailsIn(
412
+ mapping,
413
+ evidence.revision.subjectId,
414
+ evidence.revision.credentials.map((item) => item.credentialId),
415
+ locking,
416
+ );
417
+
418
+ if (
419
+ captured.revision.securityRevision !== evidence.revision.securityRevision ||
420
+ captured.revision.credentials.length !== evidence.revision.credentials.length ||
421
+ captured.revision.credentials.some(
422
+ (item: { credentialId: string; revision: SecurityRevision }, index: number) => {
423
+ const expected = [...evidence.revision.credentials].sort((a, b) =>
424
+ a.credentialId.localeCompare(b.credentialId),
425
+ )[index];
426
+
427
+ return (
428
+ expected === undefined ||
429
+ expected.credentialId !== item.credentialId ||
430
+ expected.revision !== item.revision
431
+ );
432
+ },
433
+ )
434
+ )
435
+ return yield* stale();
436
+ const nativeSubjectId = yield* mapping.subjectId.toNative(evidence.revision.subjectId);
437
+ const requirement = yield* mapping.subject.decodeRequirement(captured.subject);
438
+
439
+ return { nativeSubjectId, requirement };
440
+ });
441
+
442
+ const flowColumns = (mapping: any) => ({
443
+ flowId: column(mapping.flow.table, mapping.flow.flowId),
444
+ subjectId: column(mapping.flow.table, mapping.flow.subjectId),
445
+ state: column(mapping.flow.table, mapping.flow.state),
446
+ pendingDigest: column(mapping.flow.table, mapping.flow.pendingDigest),
447
+ dedupUntil: column(mapping.flow.table, mapping.flow.dedupUntil),
448
+ });
449
+
450
+ const readFlow = Effect.fn("DrizzleSession.readFlow")(function* (
451
+ mapping: AnyFlowMapping,
452
+ flowId: string,
453
+ locking: boolean,
454
+ ) {
455
+ const database = yield* CurrentSessionSql;
456
+ const columns = flowColumns(mapping);
457
+
458
+ return yield* selectRows(
459
+ database.select().from(mapping.flow.table).where(eq(columns.flowId, flowId)).limit(1),
460
+ locking,
461
+ );
462
+ });
463
+
464
+ const ensureDirectFlowAvailable = Effect.fn("DrizzleSession.ensureFlowAvailable")(function* (
465
+ mapping: AnyAuthorityMapping &
466
+ AnyFlowMapping & {
467
+ readonly pending?: PendingAuthenticationTables<any, any, any, any>["pending"];
468
+ },
469
+ evidence: AuthenticationEvidence,
470
+ nativeSubjectId: unknown,
471
+ dedupUntil: DateTime.Utc,
472
+ ) {
473
+ const database = yield* CurrentSessionSql;
474
+ const rows = yield* readFlow(mapping, evidence.flowId, true);
475
+ const existing = rows[0];
476
+
477
+ if (existing !== undefined) {
478
+ const now = yield* freshNow;
479
+
480
+ if (
481
+ DateTime.toEpochMillis(now) <
482
+ DateTime.toEpochMillis(yield* mapping.flow.decodeInstant(existing[mapping.flow.dedupUntil]))
483
+ )
484
+ return yield* SessionConflict.make({});
485
+ if (mapping.pending !== undefined)
486
+ yield* database
487
+ .delete(mapping.pending.table)
488
+ .where(eq(column(mapping.pending.table, mapping.pending.flowId), evidence.flowId));
489
+ yield* database
490
+ .delete(mapping.flow.table)
491
+ .where(eq(flowColumns(mapping).flowId, evidence.flowId));
492
+ }
493
+
494
+ return mapping.flow.encodeEstablishedInsert({
495
+ evidence,
496
+ subjectId: nativeSubjectId,
497
+ dedupUntil,
498
+ });
499
+ });
500
+
501
+ const pendingColumns = (mapping: any) => ({
502
+ digest: column(mapping.pending.table, mapping.pending.digest),
503
+ version: column(mapping.pending.table, mapping.pending.version),
504
+ flowId: column(mapping.pending.table, mapping.pending.flowId),
505
+ subjectId: column(mapping.pending.table, mapping.pending.subjectId),
506
+ bindingDigest: column(mapping.pending.table, mapping.pending.bindingDigest),
507
+ expiresAt: column(mapping.pending.table, mapping.pending.expiresAt),
508
+ attemptLimit: column(mapping.pending.table, mapping.pending.attemptLimit),
509
+ failedAttempts: column(mapping.pending.table, mapping.pending.failedAttempts),
510
+ consumed: column(mapping.pending.table, mapping.pending.consumed),
511
+ });
512
+
513
+ const readPending = Effect.fn("DrizzleSession.readPending")(function* (
514
+ mapping: AnyPendingMapping,
515
+ digest: TokenDigest,
516
+ locking: boolean,
517
+ ) {
518
+ const database = yield* CurrentSessionSql;
519
+
520
+ return yield* selectRows(
521
+ database
522
+ .select()
523
+ .from(mapping.pending.table)
524
+ .where(eq(pendingColumns(mapping).digest, digest))
525
+ .limit(1),
526
+ locking,
527
+ );
528
+ });
529
+
530
+ const validatePending = Effect.fn("DrizzleSession.validatePending")(function* <
531
+ Record extends PendingAuthenticationState = PendingAuthenticationRecord<unknown>,
532
+ >(
533
+ mapping: AnyPendingMapping,
534
+ input: PendingConsumption,
535
+ projection: PendingProjection = "record",
536
+ ) {
537
+ const rows = yield* readPending(mapping, input.digest, true);
538
+ const row = rows[0];
539
+
540
+ if (row === undefined) return yield* invalidPending();
541
+
542
+ const decode = (
543
+ projection === "context" ? mapping.pending.decodeContext : mapping.pending.decode
544
+ ) as (row: any) => Effect.Effect<Record, PersistenceMappingError>;
545
+
546
+ const record = yield* decode(row);
547
+ const failedAttempts = Number(row[mapping.pending.failedAttempts]);
548
+
549
+ if (
550
+ record.version !== input.version ||
551
+ record.evidence.flowId !== input.flowId ||
552
+ record.evidence.bindingDigest !== input.bindingDigest ||
553
+ row[mapping.pending.consumed] !== false ||
554
+ failedAttempts >= record.attemptLimit
555
+ )
556
+ return yield* invalidPending();
557
+ const flows = yield* readFlow(mapping, input.flowId, true);
558
+ const flow = flows[0];
559
+
560
+ if (
561
+ flow === undefined ||
562
+ flow[mapping.flow.state] !== mapping.flow.pendingStateValue ||
563
+ flow[mapping.flow.pendingDigest] !== input.digest
564
+ )
565
+ return yield* invalidPending();
566
+ if (DateTime.toEpochMillis(yield* freshNow) >= DateTime.toEpochMillis(record.expiresAt))
567
+ return yield* invalidPending();
568
+
569
+ return record;
570
+ });
571
+
572
+ const consumePending = Effect.fn("DrizzleSession.consumePending")(function* (
573
+ mapping: AnyPendingMapping,
574
+ input: PendingConsumption,
575
+ until: DateTime.Utc,
576
+ ) {
577
+ const database = yield* CurrentSessionSql;
578
+
579
+ yield* Effect.all([
580
+ database
581
+ .update(mapping.pending.table)
582
+ .set(updateValues([[mapping.pending.consumed, true]]))
583
+ .where(
584
+ and(
585
+ eq(pendingColumns(mapping).digest, input.digest),
586
+ eq(pendingColumns(mapping).version, input.version),
587
+ eq(pendingColumns(mapping).consumed, false),
588
+ ),
589
+ ),
590
+
591
+ database
592
+ .update(mapping.flow.table)
593
+ .set(
594
+ updateValues([
595
+ [mapping.flow.state, mapping.flow.establishedStateValue],
596
+ [mapping.flow.pendingDigest, null],
597
+ [mapping.flow.dedupUntil, mapping.flow.encodeInstant(until)],
598
+ ]),
599
+ )
600
+ .where(eq(flowColumns(mapping).flowId, input.flowId)),
601
+ ]);
602
+ });
603
+
604
+ const freshNow = DateTime.now;
605
+
606
+ const allocate = <A>(
607
+ mode: CommitMode,
608
+ asynchronous: Effect.Effect<A, PersistenceMappingError> | undefined,
609
+ synchronous: (() => A) | undefined,
610
+ ): Effect.Effect<A, PersistenceMappingError | SessionUnavailable> => {
611
+ if (mode === "synchronous")
612
+ return synchronous === undefined
613
+ ? Effect.fail(unavailable())
614
+ : Effect.try({
615
+ try: synchronous,
616
+ catch: (cause) => PersistenceMappingError.make({ operation: "allocate", cause }),
617
+ });
618
+ if (asynchronous !== undefined) return asynchronous;
619
+
620
+ return synchronous === undefined
621
+ ? Effect.fail(unavailable())
622
+ : Effect.try({
623
+ try: synchronous,
624
+ catch: (cause) => PersistenceMappingError.make({ operation: "allocate", cause }),
625
+ });
626
+ };
627
+
628
+ const makeSqlAuthenticationAuthority = <Claims>(
629
+ database: Database,
630
+ mapping: AuthenticationAuthorityMapping<Claims, any, any, any, any, any>,
631
+ options: SessionSqlOptions,
632
+ ): Effect.Effect<AuthenticationAuthority["Service"], never, LifecycleHooks> =>
633
+ Effect.map(LifecycleHooks, (hooks) => {
634
+ const service = {
635
+ capture: (subjectId: SubjectId, credentialIds: ReadonlyArray<string>) =>
636
+ safeTransaction(
637
+ inTransaction(database, captureIn(mapping, subjectId, credentialIds, true)),
638
+ ),
639
+ requirements: (evidence: AuthenticationEvidence) =>
640
+ safeTransaction(
641
+ inTransaction(
642
+ database,
643
+ validateEvidenceIn(mapping, evidence, true).pipe(
644
+ Effect.map((result) => result.requirement),
645
+ ),
646
+ ),
647
+ ),
648
+ approve: <A>(
649
+ input: Parameters<AuthenticationAuthority["Service"]["approve"]>[0],
650
+ prepare: PrepareSessionCommit<void, A>,
651
+ ) =>
652
+ owned(
653
+ database,
654
+ options,
655
+ mapping.isConstraintConflict,
656
+ Effect.gen(function* () {
657
+ const journal = yield* CurrentCommitJournal;
658
+ const { requirement } = yield* validateEvidenceIn(mapping, input.evidence, true);
659
+
660
+ if (input.pending === undefined) {
661
+ const assessed = yield* assessAuthentication(input.evidence, requirement).pipe(
662
+ Effect.mapError(unavailable),
663
+ );
664
+
665
+ const now = yield* freshNow;
666
+
667
+ if (
668
+ !assessed.satisfied ||
669
+ DateTime.toEpochMillis(now) >= DateTime.toEpochMillis(input.expiresAt) ||
670
+ DateTime.toEpochMillis(input.expiresAt) >
671
+ DateTime.toEpochMillis(input.absoluteExpiresAt)
672
+ )
673
+ return yield* stale();
674
+
675
+ return prepare(undefined, journal);
676
+ }
677
+ if (mapping.pending === undefined) return yield* invalidPending();
678
+ const pendingMapping = { ...mapping, ...mapping.pending };
679
+ const pending = yield* validatePending(pendingMapping, input.pending);
680
+
681
+ if (!preservesRevision(input.evidence, pending.evidence))
682
+ return yield* invalidPending();
683
+
684
+ const assessed = yield* assessAuthentication(input.evidence, requirement).pipe(
685
+ Effect.mapError(unavailable),
686
+ );
687
+
688
+ const now = yield* freshNow;
689
+
690
+ if (
691
+ !assessed.satisfied ||
692
+ DateTime.toEpochMillis(now) >= DateTime.toEpochMillis(pending.expiresAt) ||
693
+ DateTime.toEpochMillis(now) >= DateTime.toEpochMillis(input.expiresAt) ||
694
+ DateTime.toEpochMillis(input.expiresAt) >
695
+ DateTime.toEpochMillis(input.absoluteExpiresAt)
696
+ )
697
+ return yield* stale();
698
+ const receipt = prepare(undefined, journal);
699
+
700
+ yield* consumePending(pendingMapping, input.pending, input.absoluteExpiresAt);
701
+
702
+ return receipt;
703
+ }),
704
+ ),
705
+ };
706
+
707
+ return {
708
+ capture: (subjectId, credentialIds) =>
709
+ service
710
+ .capture(subjectId, credentialIds)
711
+ .pipe(Effect.provideService(LifecycleHooks, hooks)),
712
+ requirements: (evidence) =>
713
+ service.requirements(evidence).pipe(Effect.provideService(LifecycleHooks, hooks)),
714
+ approve: (input, prepare) =>
715
+ service.approve(input, prepare).pipe(Effect.provideService(LifecycleHooks, hooks)),
716
+ } satisfies AuthenticationAuthority["Service"];
717
+ });
718
+
719
+ /** The same locked security checks serve full consumption reads and the
720
+ * claims-free additional-factor context. No lock spans proof verification. */
721
+ const readAuthenticatedPending = Effect.fn("DrizzleSession.readAuthenticatedPending")(function* <
722
+ Record extends PendingAuthenticationState,
723
+ >(
724
+ mapping: AnyPendingMapping,
725
+ input: { readonly digest: TokenDigest; readonly bindingDigest?: TokenDigest },
726
+ projection: PendingProjection,
727
+ ) {
728
+ const initial = (yield* readPending(mapping, input.digest, false))[0];
729
+
730
+ if (initial === undefined) return yield* invalidPending();
731
+ const initialNativeSubjectId = initial[mapping.pending.subjectId];
732
+ const subject = (yield* readSubject(mapping, initialNativeSubjectId, true))[0];
733
+
734
+ if (subject === undefined) return yield* invalidPending();
735
+ const current = (yield* readPending(mapping, input.digest, true))[0];
736
+
737
+ if (
738
+ current === undefined ||
739
+ !mapping.subjectId.equals(current[mapping.pending.subjectId], initialNativeSubjectId)
740
+ )
741
+ return yield* invalidPending();
742
+
743
+ const decode = (
744
+ projection === "context" ? mapping.pending.decodeContext : mapping.pending.decode
745
+ ) as (row: any) => Effect.Effect<Record, PersistenceMappingError>;
746
+
747
+ const record = yield* decode(current);
748
+ const nativeSubjectId = yield* mapping.subjectId.toNative(record.evidence.revision.subjectId);
749
+ const failedAttempts = Number(current[mapping.pending.failedAttempts]);
750
+
751
+ if (
752
+ !mapping.subjectId.equals(nativeSubjectId, initialNativeSubjectId) ||
753
+ (input.bindingDigest !== undefined &&
754
+ record.evidence.bindingDigest !== input.bindingDigest) ||
755
+ current[mapping.pending.version] !== record.version ||
756
+ current[mapping.pending.flowId] !== record.evidence.flowId ||
757
+ current[mapping.pending.bindingDigest] !== record.evidence.bindingDigest ||
758
+ current[mapping.pending.consumed] !== false ||
759
+ failedAttempts >= record.attemptLimit
760
+ )
761
+ return yield* invalidPending();
762
+ const flow = (yield* readFlow(mapping, record.evidence.flowId, true))[0];
763
+
764
+ if (
765
+ flow === undefined ||
766
+ flow[mapping.flow.state] !== mapping.flow.pendingStateValue ||
767
+ flow[mapping.flow.pendingDigest] !== input.digest
768
+ )
769
+ return yield* invalidPending();
770
+
771
+ const { requirement } = yield* mapFailureCause(
772
+ validateEvidenceIn(mapping, record.evidence, true),
773
+ (error) => (Schema.is(StaleAuthentication)(error) ? invalidPending() : error),
774
+ );
775
+
776
+ yield* assessAuthentication(record.evidence, requirement).pipe(
777
+ Effect.mapError(() => invalidPending()),
778
+ );
779
+ if (DateTime.toEpochMillis(yield* freshNow) >= DateTime.toEpochMillis(record.expiresAt))
780
+ return yield* invalidPending();
781
+
782
+ return record;
783
+ });
784
+
785
+ const makeSqlPendingAuthentication = <Claims>(
786
+ database: Database,
787
+ mapping: PendingAuthenticationMapping<Claims, any, any, any, any, any>,
788
+ options: SessionSqlOptions,
789
+ ): Effect.Effect<PendingAuthentication<Claims>, never, LifecycleHooks> =>
790
+ Effect.map(LifecycleHooks, (hooks) => {
791
+ const service = {
792
+ create: <A>(
793
+ input: Omit<PendingAuthenticationRecord<Claims>, "version">,
794
+ _inputNow: DateTime.Utc,
795
+ prepare: PrepareSessionCommit<PendingAuthenticationRecord<Claims>, A>,
796
+ ) =>
797
+ owned(
798
+ database,
799
+ options,
800
+ mapping.isConstraintConflict,
801
+ Effect.gen(function* () {
802
+ const transaction = yield* CurrentSessionSql;
803
+ const journal = yield* CurrentCommitJournal;
804
+
805
+ const { nativeSubjectId, requirement } = yield* validateEvidenceIn(
806
+ mapping,
807
+ input.evidence,
808
+ true,
809
+ );
810
+
811
+ const existing = (yield* readFlow(mapping, input.evidence.flowId, true))[0];
812
+
813
+ if (existing !== undefined) {
814
+ const dedupUntil = yield* mapping.flow.decodeInstant(
815
+ existing[mapping.flow.dedupUntil],
816
+ );
817
+
818
+ if (DateTime.toEpochMillis(yield* freshNow) < DateTime.toEpochMillis(dedupUntil))
819
+ return yield* SessionConflict.make({});
820
+ yield* transaction
821
+ .delete(mapping.pending.table)
822
+ .where(eq(pendingColumns(mapping).flowId, input.evidence.flowId));
823
+ yield* transaction
824
+ .delete(mapping.flow.table)
825
+ .where(eq(flowColumns(mapping).flowId, input.evidence.flowId));
826
+ }
827
+
828
+ const version = yield* allocate(
829
+ options.mode,
830
+ mapping.pending.allocateVersion,
831
+ mapping.pending.allocateVersionSync,
832
+ );
833
+
834
+ yield* assessAuthentication(input.evidence, requirement);
835
+ const now = yield* freshNow;
836
+
837
+ if (DateTime.toEpochMillis(now) >= DateTime.toEpochMillis(input.expiresAt))
838
+ return yield* stale();
839
+ const record = { ...input, version };
840
+ const receipt = prepare(record, journal);
841
+
842
+ yield* transaction.insert(mapping.flow.table).values(
843
+ mapping.flow.encodePendingInsert({
844
+ evidence: input.evidence,
845
+ subjectId: nativeSubjectId,
846
+ pendingDigest: input.digest,
847
+ dedupUntil: input.expiresAt,
848
+ }),
849
+ );
850
+ yield* transaction.insert(mapping.pending.table).values(
851
+ mapping.pending.encodeInsert(record, {
852
+ subjectId: nativeSubjectId,
853
+ failedAttempts: 0,
854
+ consumed: false,
855
+ }),
856
+ );
857
+
858
+ return receipt;
859
+ }).pipe(normalizeMutation(mapping)),
860
+ ),
861
+ context: (input: { readonly digest: TokenDigest; readonly now: DateTime.Utc }) =>
862
+ safeTransaction(
863
+ inTransaction(
864
+ database,
865
+ readAuthenticatedPending<PendingAuthenticationState>(mapping, input, "context"),
866
+ ),
867
+ ).pipe(Effect.flatMap(pendingAuthenticationContext)),
868
+ read: (input: {
869
+ readonly digest: TokenDigest;
870
+ readonly bindingDigest: TokenDigest;
871
+ readonly now: DateTime.Utc;
872
+ }) =>
873
+ safeTransaction(
874
+ inTransaction(
875
+ database,
876
+ readAuthenticatedPending<PendingAuthenticationRecord<Claims>>(
877
+ mapping,
878
+ input,
879
+ "record",
880
+ ),
881
+ ),
882
+ ).pipe(Effect.map((record) => record as PendingAuthenticationRecord<Claims>)),
883
+ reject: <A>(
884
+ input: Parameters<PendingAuthentication<Claims>["reject"]>[0],
885
+ prepare: PrepareSessionCommit<{ readonly _tag: "Rejected" }, A>,
886
+ ) =>
887
+ owned(
888
+ database,
889
+ options,
890
+ mapping.isConstraintConflict,
891
+ Effect.gen(function* () {
892
+ const transaction = yield* CurrentSessionSql;
893
+ const journal = yield* CurrentCommitJournal;
894
+ const initial = (yield* readPending(mapping, input.digest, false))[0];
895
+
896
+ if (initial === undefined) return prepare({ _tag: "Rejected" }, journal);
897
+ const record = yield* mapping.pending.decodeContext(initial);
898
+
899
+ const nativeSubjectId = yield* mapping.subjectId.toNative(
900
+ record.evidence.revision.subjectId,
901
+ );
902
+
903
+ const subject = (yield* readSubject(mapping, nativeSubjectId, true))[0];
904
+ const current = (yield* readPending(mapping, input.digest, true))[0];
905
+ const now = yield* freshNow;
906
+ const receipt = prepare({ _tag: "Rejected" }, journal);
907
+
908
+ if (
909
+ subject !== undefined &&
910
+ mapping.subject.isActiveStatus(subject[mapping.subject.status]) &&
911
+ subject[mapping.subject.securityRevision] ===
912
+ record.evidence.revision.securityRevision &&
913
+ current !== undefined &&
914
+ current[mapping.pending.consumed] === false &&
915
+ record.evidence.bindingDigest === input.bindingDigest &&
916
+ DateTime.toEpochMillis(now) < DateTime.toEpochMillis(record.expiresAt)
917
+ ) {
918
+ yield* transaction
919
+ .update(mapping.pending.table)
920
+ .set(
921
+ updateValues([
922
+ [
923
+ mapping.pending.failedAttempts,
924
+ sql`case when ${pendingColumns(mapping).failedAttempts} < ${pendingColumns(mapping).attemptLimit} then ${pendingColumns(mapping).failedAttempts} + 1 else ${pendingColumns(mapping).failedAttempts} end`,
925
+ ],
926
+ ]),
927
+ )
928
+ .where(eq(pendingColumns(mapping).digest, input.digest));
929
+ }
930
+
931
+ return receipt;
932
+ }),
933
+ ),
934
+ };
935
+
936
+ return {
937
+ create: (input, now, prepare) =>
938
+ service.create(input, now, prepare).pipe(Effect.provideService(LifecycleHooks, hooks)),
939
+ context: (input) =>
940
+ service.context(input).pipe(Effect.provideService(LifecycleHooks, hooks)),
941
+ read: (input) => service.read(input).pipe(Effect.provideService(LifecycleHooks, hooks)),
942
+ reject: (input, prepare) =>
943
+ service.reject(input, prepare).pipe(Effect.provideService(LifecycleHooks, hooks)),
944
+ } satisfies PendingAuthentication<Claims>;
945
+ });
946
+
947
+ const sessionColumns = (mapping: any) => ({
948
+ sessionId: column(mapping.session.table, mapping.session.sessionId),
949
+ subjectId: column(mapping.session.table, mapping.session.subjectId),
950
+ digest: column(mapping.session.table, mapping.session.digest),
951
+ version: column(mapping.session.table, mapping.session.version),
952
+ securityRevision: column(mapping.session.table, mapping.session.securityRevision),
953
+ issuedAt: column(mapping.session.table, mapping.session.issuedAt),
954
+ expiresAt: column(mapping.session.table, mapping.session.expiresAt),
955
+ absoluteExpiresAt: column(mapping.session.table, mapping.session.absoluteExpiresAt),
956
+ });
957
+
958
+ const makeSqlStatefulSessions = <Claims>(
959
+ database: Database,
960
+ mapping: StatefulSessionMapping<Claims, any, any, any, any, any, any, any>,
961
+ options: SessionSqlOptions,
962
+ ): Effect.Effect<
963
+ {
964
+ readonly statefulSessionPersistence: StatefulSessionPersistence<Claims>;
965
+ readonly sessionRepository: SessionRepository;
966
+ },
967
+ never,
968
+ LifecycleHooks
969
+ > =>
970
+ Effect.map(LifecycleHooks, (hooks) => {
971
+ const c = sessionColumns(mapping);
972
+
973
+ const persistence = {
974
+ establish: <A>(
975
+ input: Parameters<StatefulSessionPersistence<Claims>["establish"]>[0],
976
+ prepare: PrepareSessionCommit<StatefulSessionRecord<Claims>, A>,
977
+ ) =>
978
+ owned(
979
+ database,
980
+ options,
981
+ mapping.isConstraintConflict,
982
+ Effect.gen(function* () {
983
+ const transaction = yield* CurrentSessionSql;
984
+ const journal = yield* CurrentCommitJournal;
985
+
986
+ const { nativeSubjectId, requirement } = yield* validateEvidenceIn(
987
+ mapping,
988
+ input.evidence,
989
+ true,
990
+ );
991
+
992
+ let flowInsert: unknown | undefined;
993
+ let pendingExpiresAt: DateTime.Utc | undefined;
994
+
995
+ if (input.pending === undefined)
996
+ flowInsert = yield* ensureDirectFlowAvailable(
997
+ mapping,
998
+ input.evidence,
999
+ nativeSubjectId,
1000
+ input.session.absoluteExpiresAt,
1001
+ );
1002
+ else {
1003
+ if (mapping.pending === undefined) return yield* invalidPending();
1004
+ const pending = yield* validatePending(mapping, input.pending);
1005
+
1006
+ if (!preservesRevision(input.evidence, pending.evidence))
1007
+ return yield* invalidPending();
1008
+ pendingExpiresAt = pending.expiresAt;
1009
+ }
1010
+
1011
+ const nativeSessionId = yield* allocate(
1012
+ options.mode,
1013
+ mapping.session.allocateId,
1014
+ mapping.session.allocateIdSync,
1015
+ );
1016
+
1017
+ const sessionId = yield* mapping.sessionId.toSession(nativeSessionId);
1018
+
1019
+ const version = yield* allocate(
1020
+ options.mode,
1021
+ mapping.session.allocateVersion,
1022
+ mapping.session.allocateVersionSync,
1023
+ );
1024
+
1025
+ const assessed = yield* assessAuthentication(input.evidence, requirement);
1026
+ const now = yield* freshNow;
1027
+
1028
+ if (
1029
+ !assessed.satisfied ||
1030
+ (pendingExpiresAt !== undefined &&
1031
+ DateTime.toEpochMillis(now) >= DateTime.toEpochMillis(pendingExpiresAt)) ||
1032
+ DateTime.toEpochMillis(now) >= DateTime.toEpochMillis(input.session.expiresAt) ||
1033
+ DateTime.toEpochMillis(input.session.expiresAt) >
1034
+ DateTime.toEpochMillis(input.session.absoluteExpiresAt)
1035
+ )
1036
+ return yield* stale();
1037
+
1038
+ const record = {
1039
+ ...input.session,
1040
+ sessionId,
1041
+ version,
1042
+ subjectId: input.evidence.revision.subjectId,
1043
+ securityRevision: input.evidence.revision.securityRevision,
1044
+ assurance: assessed.assurance,
1045
+ provenance: yield* snapshotSessionAuthenticationProvenance({
1046
+ evidence: input.evidence,
1047
+ }),
1048
+ issuedAt: now,
1049
+ };
1050
+
1051
+ const receipt = prepare(record, journal);
1052
+
1053
+ if (flowInsert !== undefined)
1054
+ yield* transaction.insert(mapping.flow.table).values(flowInsert);
1055
+ else
1056
+ yield* consumePending(
1057
+ { ...mapping, pending: mapping.pending! },
1058
+ input.pending!,
1059
+ input.session.absoluteExpiresAt,
1060
+ );
1061
+ yield* transaction.insert(mapping.session.table).values(
1062
+ mapping.session.encodeInsert(record, {
1063
+ subjectId: nativeSubjectId,
1064
+ sessionId: nativeSessionId,
1065
+ }),
1066
+ );
1067
+
1068
+ return receipt;
1069
+ }).pipe(normalizeMutation(mapping)),
1070
+ ),
1071
+ verify: (input: any) =>
1072
+ safeTransaction(
1073
+ inTransaction(
1074
+ database,
1075
+ Effect.gen(function* () {
1076
+ const read = yield* CurrentSessionSql;
1077
+
1078
+ const rows = yield* read
1079
+ .select()
1080
+ .from(mapping.session.table)
1081
+ .where(eq(c.digest, input.digest))
1082
+ .limit(1);
1083
+
1084
+ const row = rows[0];
1085
+
1086
+ if (row === undefined) return yield* invalidSession();
1087
+ const record = yield* mapping.session.decode(row);
1088
+ const nativeSubjectId = yield* mapping.subjectId.toNative(record.subjectId);
1089
+ const subjects = yield* readSubject(mapping, nativeSubjectId, false);
1090
+ const subject = subjects[0];
1091
+ const now = yield* freshNow;
1092
+
1093
+ if (
1094
+ subject === undefined ||
1095
+ !mapping.subject.isActiveStatus(subject[mapping.subject.status]) ||
1096
+ subject[mapping.subject.securityRevision] !== record.securityRevision ||
1097
+ DateTime.toEpochMillis(now) >= DateTime.toEpochMillis(record.expiresAt) ||
1098
+ DateTime.toEpochMillis(now) >= DateTime.toEpochMillis(record.absoluteExpiresAt)
1099
+ )
1100
+ return yield* invalidSession();
1101
+
1102
+ return record;
1103
+ }),
1104
+ ),
1105
+ ),
1106
+ rotate: <A>(
1107
+ input: Parameters<StatefulSessionPersistence<Claims>["rotate"]>[0],
1108
+ prepare: PrepareSessionCommit<StatefulSessionRecord<Claims>, A>,
1109
+ ) =>
1110
+ owned(
1111
+ database,
1112
+ options,
1113
+ mapping.isConstraintConflict,
1114
+ Effect.gen(function* () {
1115
+ const transaction = yield* CurrentSessionSql;
1116
+ const journal = yield* CurrentCommitJournal;
1117
+ const nativeSessionId = yield* mapping.sessionId.toNative(input.sessionId);
1118
+
1119
+ const initial = (yield* transaction
1120
+ .select()
1121
+ .from(mapping.session.table)
1122
+ .where(eq(c.sessionId, nativeSessionId))
1123
+ .limit(1))[0];
1124
+
1125
+ if (initial === undefined) return yield* SessionConflict.make({});
1126
+ const initialRecord = yield* mapping.session.decode(initial);
1127
+ const nativeSubjectId = yield* mapping.subjectId.toNative(initialRecord.subjectId);
1128
+ const subject = (yield* readSubject(mapping, nativeSubjectId, true))[0];
1129
+
1130
+ const row = (yield* selectRows(
1131
+ transaction
1132
+ .select()
1133
+ .from(mapping.session.table)
1134
+ .where(eq(c.sessionId, nativeSessionId))
1135
+ .limit(1),
1136
+ options.locking,
1137
+ ))[0];
1138
+
1139
+ if (row === undefined) return yield* SessionConflict.make({});
1140
+ const record = yield* mapping.session.decode(row);
1141
+
1142
+ const version = yield* allocate(
1143
+ options.mode,
1144
+ mapping.session.allocateVersion,
1145
+ mapping.session.allocateVersionSync,
1146
+ );
1147
+
1148
+ const now = yield* freshNow;
1149
+
1150
+ if (
1151
+ subject === undefined ||
1152
+ !mapping.subject.isActiveStatus(subject[mapping.subject.status]) ||
1153
+ subject[mapping.subject.securityRevision] !== input.expectedSecurityRevision ||
1154
+ record.securityRevision !== input.expectedSecurityRevision ||
1155
+ record.digest !== input.expectedDigest ||
1156
+ record.version !== input.expectedVersion ||
1157
+ DateTime.toEpochMillis(now) >= DateTime.toEpochMillis(record.expiresAt) ||
1158
+ DateTime.toEpochMillis(now) >= DateTime.toEpochMillis(record.absoluteExpiresAt) ||
1159
+ DateTime.toEpochMillis(input.nextExpiresAt) >
1160
+ DateTime.toEpochMillis(record.absoluteExpiresAt) ||
1161
+ DateTime.toEpochMillis(input.nextExpiresAt) <= DateTime.toEpochMillis(now)
1162
+ )
1163
+ return yield* SessionConflict.make({});
1164
+
1165
+ const next = {
1166
+ ...record,
1167
+ digest: input.nextDigest,
1168
+ credentialVersion: input.nextCredentialVersion,
1169
+ version,
1170
+ issuedAt: now,
1171
+ expiresAt: input.nextExpiresAt,
1172
+ };
1173
+
1174
+ const receipt = prepare(next, journal);
1175
+
1176
+ yield* transaction
1177
+ .update(mapping.session.table)
1178
+ .set(mapping.session.encodeRotation(next))
1179
+ .where(
1180
+ and(
1181
+ eq(c.sessionId, nativeSessionId),
1182
+ eq(c.digest, input.expectedDigest),
1183
+ eq(c.version, input.expectedVersion),
1184
+ ),
1185
+ );
1186
+
1187
+ return receipt;
1188
+ }),
1189
+ ),
1190
+ revokeDigest: <A>(digest: TokenDigest, prepare: PrepareSessionCommit<boolean, A>) =>
1191
+ owned(
1192
+ database,
1193
+ options,
1194
+ mapping.isConstraintConflict,
1195
+ Effect.gen(function* () {
1196
+ const transaction = yield* CurrentSessionSql;
1197
+ const journal = yield* CurrentCommitJournal;
1198
+
1199
+ const initial = (yield* transaction
1200
+ .select()
1201
+ .from(mapping.session.table)
1202
+ .where(eq(c.digest, digest))
1203
+ .limit(1))[0];
1204
+
1205
+ if (initial === undefined) return prepare(false, journal);
1206
+ const record = yield* mapping.session.decode(initial);
1207
+ const nativeSubjectId = yield* mapping.subjectId.toNative(record.subjectId);
1208
+
1209
+ yield* readSubject(mapping, nativeSubjectId, true);
1210
+
1211
+ const row = (yield* selectRows(
1212
+ transaction
1213
+ .select()
1214
+ .from(mapping.session.table)
1215
+ .where(eq(c.digest, digest))
1216
+ .limit(1),
1217
+ options.locking,
1218
+ ))[0];
1219
+
1220
+ const receipt = prepare(row !== undefined, journal);
1221
+
1222
+ if (row !== undefined)
1223
+ yield* transaction.delete(mapping.session.table).where(eq(c.digest, digest));
1224
+
1225
+ return receipt;
1226
+ }),
1227
+ ),
1228
+ revoke: <A>(
1229
+ input: Parameters<StatefulSessionPersistence<Claims>["revoke"]>[0],
1230
+ prepare: PrepareSessionCommit<void, A>,
1231
+ ) =>
1232
+ owned(
1233
+ database,
1234
+ options,
1235
+ mapping.isConstraintConflict,
1236
+ Effect.gen(function* () {
1237
+ const transaction = yield* CurrentSessionSql;
1238
+ const journal = yield* CurrentCommitJournal;
1239
+ const nativeSubjectId = yield* mapping.subjectId.toNative(input.subjectId);
1240
+ const subject = (yield* readSubject(mapping, nativeSubjectId, true))[0];
1241
+
1242
+ if (
1243
+ subject === undefined ||
1244
+ !mapping.subject.isActiveStatus(subject[mapping.subject.status]) ||
1245
+ subject[mapping.subject.securityRevision] !== input.expectedSecurityRevision
1246
+ )
1247
+ return yield* stale();
1248
+ const nativeSessionId = yield* mapping.sessionId.toNative(input.sessionId);
1249
+ const receipt = prepare(undefined, journal);
1250
+
1251
+ yield* transaction
1252
+ .delete(mapping.session.table)
1253
+ .where(and(eq(c.subjectId, nativeSubjectId), eq(c.sessionId, nativeSessionId)));
1254
+
1255
+ return receipt;
1256
+ }),
1257
+ ),
1258
+ revokeAll: <A>(
1259
+ input: Parameters<StatefulSessionPersistence<Claims>["revokeAll"]>[0],
1260
+ prepare: PrepareSessionCommit<void, A>,
1261
+ ) =>
1262
+ owned(
1263
+ database,
1264
+ options,
1265
+ mapping.isConstraintConflict,
1266
+ Effect.gen(function* () {
1267
+ const transaction = yield* CurrentSessionSql;
1268
+ const journal = yield* CurrentCommitJournal;
1269
+ const nativeSubjectId = yield* mapping.subjectId.toNative(input.subjectId);
1270
+ const subject = (yield* readSubject(mapping, nativeSubjectId, true))[0];
1271
+
1272
+ if (
1273
+ subject === undefined ||
1274
+ !mapping.subject.isActiveStatus(subject[mapping.subject.status]) ||
1275
+ subject[mapping.subject.securityRevision] !== input.expectedSecurityRevision
1276
+ )
1277
+ return yield* stale();
1278
+ const nextRevisionSync = mapping.subject.nextSecurityRevisionSync;
1279
+
1280
+ const next = yield* allocate(
1281
+ options.mode,
1282
+ mapping.subject.nextSecurityRevision?.(input.expectedSecurityRevision),
1283
+ nextRevisionSync === undefined
1284
+ ? undefined
1285
+ : () => nextRevisionSync(input.expectedSecurityRevision),
1286
+ );
1287
+
1288
+ const receipt = prepare(undefined, journal);
1289
+
1290
+ yield* transaction
1291
+ .update(mapping.subject.table)
1292
+ .set(updateValues([[mapping.subject.securityRevision, next]]))
1293
+ .where(
1294
+ and(
1295
+ eq(authorityColumns(mapping).subjectId, nativeSubjectId),
1296
+ eq(authorityColumns(mapping).subjectRevision, input.expectedSecurityRevision),
1297
+ ),
1298
+ );
1299
+ yield* transaction
1300
+ .delete(mapping.session.table)
1301
+ .where(eq(c.subjectId, nativeSubjectId));
1302
+
1303
+ return receipt;
1304
+ }),
1305
+ ),
1306
+ };
1307
+
1308
+ const repository = {
1309
+ list: (input: any) =>
1310
+ safeTransaction(
1311
+ inTransaction(
1312
+ database,
1313
+ Effect.gen(function* () {
1314
+ const read = yield* CurrentSessionSql;
1315
+ const nativeSubjectId = yield* mapping.subjectId.toNative(input.subjectId);
1316
+ const subject = (yield* readSubject(mapping, nativeSubjectId, false))[0];
1317
+
1318
+ if (
1319
+ subject === undefined ||
1320
+ !mapping.subject.isActiveStatus(subject[mapping.subject.status])
1321
+ )
1322
+ return { sessions: [] };
1323
+
1324
+ const cursor =
1325
+ input.cursor === undefined
1326
+ ? undefined
1327
+ : yield* mapping.sessionId.toNative(input.cursor as any);
1328
+
1329
+ const now = yield* freshNow;
1330
+
1331
+ const rows = yield* read
1332
+ .select()
1333
+ .from(mapping.session.table)
1334
+ .where(
1335
+ and(
1336
+ eq(c.subjectId, nativeSubjectId),
1337
+ eq(c.securityRevision, subject[mapping.subject.securityRevision]),
1338
+ gt(c.expiresAt, mapping.session.encodeInstant(now)),
1339
+ gt(c.absoluteExpiresAt, mapping.session.encodeInstant(now)),
1340
+ cursor === undefined ? undefined : gt(c.sessionId, cursor),
1341
+ ),
1342
+ )
1343
+ .orderBy(c.sessionId)
1344
+ .limit(input.limit + 1);
1345
+
1346
+ const decoded = yield* Effect.forEach(rows, (row: any) =>
1347
+ mapping.session.decode(row),
1348
+ );
1349
+
1350
+ const codec = Schema.toCodecJson(Schema.toType(SessionMetadata));
1351
+
1352
+ const sessions = yield* Effect.forEach(
1353
+ decoded.slice(0, input.limit),
1354
+ (record: any) =>
1355
+ Schema.encodeEffect(codec)(record).pipe(
1356
+ Effect.flatMap(Schema.decodeEffect(codec)),
1357
+ ),
1358
+ );
1359
+
1360
+ const extra = decoded[input.limit];
1361
+
1362
+ return {
1363
+ sessions,
1364
+ ...(extra === undefined ? {} : { nextCursor: sessions.at(-1)?.sessionId }),
1365
+ };
1366
+ }),
1367
+ ),
1368
+ ),
1369
+ };
1370
+
1371
+ return {
1372
+ statefulSessionPersistence: {
1373
+ establish: (input, prepare) =>
1374
+ persistence
1375
+ .establish(input, prepare)
1376
+ .pipe(Effect.provideService(LifecycleHooks, hooks)),
1377
+ verify: (input) =>
1378
+ persistence.verify(input).pipe(Effect.provideService(LifecycleHooks, hooks)),
1379
+ rotate: (input, prepare) =>
1380
+ persistence.rotate(input, prepare).pipe(Effect.provideService(LifecycleHooks, hooks)),
1381
+ revokeDigest: (digest, prepare) =>
1382
+ persistence
1383
+ .revokeDigest(digest, prepare)
1384
+ .pipe(Effect.provideService(LifecycleHooks, hooks)),
1385
+ revoke: (input, prepare) =>
1386
+ persistence.revoke(input, prepare).pipe(Effect.provideService(LifecycleHooks, hooks)),
1387
+ revokeAll: (input, prepare) =>
1388
+ persistence
1389
+ .revokeAll(input, prepare)
1390
+ .pipe(Effect.provideService(LifecycleHooks, hooks)),
1391
+ } satisfies StatefulSessionPersistence<Claims>,
1392
+ sessionRepository: {
1393
+ list: (input) =>
1394
+ repository.list(input).pipe(Effect.provideService(LifecycleHooks, hooks)),
1395
+ } satisfies SessionRepository,
1396
+ };
1397
+ });
1398
+
1399
+ const makeSqlSignedValidity = (
1400
+ database: Database,
1401
+ mapping: SignedSessionValidityMapping<any, any, any, any>,
1402
+ options: SessionSqlOptions,
1403
+ ): Effect.Effect<SignedSessionValidity, never, LifecycleHooks> =>
1404
+ Effect.map(LifecycleHooks, (hooks) => {
1405
+ const subject = subjectColumns(mapping);
1406
+
1407
+ const tombstone = {
1408
+ subjectId: column(mapping.tombstone.table, mapping.tombstone.subjectId),
1409
+ sessionId: column(mapping.tombstone.table, mapping.tombstone.sessionId),
1410
+ absoluteExpiresAt: column(mapping.tombstone.table, mapping.tombstone.absoluteExpiresAt),
1411
+ };
1412
+
1413
+ const service = {
1414
+ verify: (session: SessionMetadata, _now: DateTime.Utc) =>
1415
+ safeTransaction(
1416
+ inTransaction(
1417
+ database,
1418
+ Effect.gen(function* () {
1419
+ const read = yield* CurrentSessionSql;
1420
+ const nativeSubjectId = yield* mapping.subjectId.toNative(session.subjectId);
1421
+ const nativeSessionId = yield* mapping.sessionId.toNative(session.sessionId);
1422
+ const subjects = yield* readSubject(mapping, nativeSubjectId, false);
1423
+ const row = subjects[0];
1424
+
1425
+ if (
1426
+ row === undefined ||
1427
+ !mapping.subject.isActiveStatus(row[mapping.subject.status]) ||
1428
+ row[mapping.subject.securityRevision] !== session.securityRevision
1429
+ )
1430
+ return yield* invalidSession();
1431
+ const authoritativeNow = yield* freshNow;
1432
+
1433
+ if (
1434
+ DateTime.toEpochMillis(authoritativeNow) >=
1435
+ DateTime.toEpochMillis(session.expiresAt) ||
1436
+ DateTime.toEpochMillis(authoritativeNow) >=
1437
+ DateTime.toEpochMillis(session.absoluteExpiresAt)
1438
+ )
1439
+ return yield* invalidSession();
1440
+
1441
+ const revoked = yield* read
1442
+ .select({ sessionId: tombstone.sessionId })
1443
+ .from(mapping.tombstone.table)
1444
+ .where(
1445
+ and(
1446
+ eq(tombstone.subjectId, nativeSubjectId),
1447
+ eq(tombstone.sessionId, nativeSessionId),
1448
+ gt(
1449
+ tombstone.absoluteExpiresAt,
1450
+ mapping.tombstone.encodeInstant(authoritativeNow),
1451
+ ),
1452
+ ),
1453
+ )
1454
+ .limit(1);
1455
+
1456
+ if (revoked.length > 0) return yield* invalidSession();
1457
+ }),
1458
+ ),
1459
+ ),
1460
+ revoke: <A>(
1461
+ input: Parameters<SignedSessionValidity["revoke"]>[0],
1462
+ prepare: PrepareSessionCommit<void, A>,
1463
+ ) =>
1464
+ owned(
1465
+ database,
1466
+ options,
1467
+ mapping.isConstraintConflict,
1468
+ Effect.gen(function* () {
1469
+ const transaction = yield* CurrentSessionSql;
1470
+ const journal = yield* CurrentCommitJournal;
1471
+ const nativeSubjectId = yield* mapping.subjectId.toNative(input.subjectId);
1472
+ const row = (yield* readSubject(mapping, nativeSubjectId, true))[0];
1473
+
1474
+ if (
1475
+ row === undefined ||
1476
+ !mapping.subject.isActiveStatus(row[mapping.subject.status]) ||
1477
+ row[mapping.subject.securityRevision] !== input.expectedSecurityRevision
1478
+ )
1479
+ return yield* stale();
1480
+ const nativeSessionId = yield* mapping.sessionId.toNative(input.sessionId);
1481
+
1482
+ const existing = yield* transaction
1483
+ .select()
1484
+ .from(mapping.tombstone.table)
1485
+ .where(
1486
+ and(
1487
+ eq(tombstone.subjectId, nativeSubjectId),
1488
+ eq(tombstone.sessionId, nativeSessionId),
1489
+ ),
1490
+ )
1491
+ .limit(1);
1492
+
1493
+ const receipt = prepare(undefined, journal);
1494
+
1495
+ if (existing[0] === undefined)
1496
+ yield* transaction.insert(mapping.tombstone.table).values(
1497
+ mapping.tombstone.encodeInsert({
1498
+ subjectId: nativeSubjectId,
1499
+ sessionId: nativeSessionId,
1500
+ absoluteExpiresAt: input.absoluteExpiresAt,
1501
+ }),
1502
+ );
1503
+ else {
1504
+ const existingExpiry = yield* mapping.tombstone.decodeInstant(
1505
+ existing[0][mapping.tombstone.absoluteExpiresAt],
1506
+ );
1507
+
1508
+ if (
1509
+ DateTime.toEpochMillis(input.absoluteExpiresAt) >
1510
+ DateTime.toEpochMillis(existingExpiry)
1511
+ )
1512
+ yield* transaction
1513
+ .update(mapping.tombstone.table)
1514
+ .set(
1515
+ updateValues([
1516
+ [
1517
+ mapping.tombstone.absoluteExpiresAt,
1518
+ mapping.tombstone.encodeInstant(input.absoluteExpiresAt),
1519
+ ],
1520
+ ]),
1521
+ )
1522
+ .where(
1523
+ and(
1524
+ eq(tombstone.subjectId, nativeSubjectId),
1525
+ eq(tombstone.sessionId, nativeSessionId),
1526
+ ),
1527
+ );
1528
+ }
1529
+
1530
+ return receipt;
1531
+ }),
1532
+ ),
1533
+ revokeAll: <A>(
1534
+ input: Parameters<SignedSessionValidity["revokeAll"]>[0],
1535
+ prepare: PrepareSessionCommit<void, A>,
1536
+ ) =>
1537
+ owned(
1538
+ database,
1539
+ options,
1540
+ mapping.isConstraintConflict,
1541
+ Effect.gen(function* () {
1542
+ const transaction = yield* CurrentSessionSql;
1543
+ const journal = yield* CurrentCommitJournal;
1544
+ const nativeSubjectId = yield* mapping.subjectId.toNative(input.subjectId);
1545
+ const row = (yield* readSubject(mapping, nativeSubjectId, true))[0];
1546
+
1547
+ if (
1548
+ row === undefined ||
1549
+ !mapping.subject.isActiveStatus(row[mapping.subject.status]) ||
1550
+ row[mapping.subject.securityRevision] !== input.expectedSecurityRevision
1551
+ )
1552
+ return yield* stale();
1553
+ const nextRevisionSync = mapping.subject.nextSecurityRevisionSync;
1554
+
1555
+ const next = yield* allocate(
1556
+ options.mode,
1557
+ mapping.subject.nextSecurityRevision?.(input.expectedSecurityRevision),
1558
+ nextRevisionSync === undefined
1559
+ ? undefined
1560
+ : () => nextRevisionSync(input.expectedSecurityRevision),
1561
+ );
1562
+
1563
+ const receipt = prepare(undefined, journal);
1564
+
1565
+ yield* transaction
1566
+ .update(mapping.subject.table)
1567
+ .set(updateValues([[mapping.subject.securityRevision, next]]))
1568
+ .where(
1569
+ and(
1570
+ eq(subject.subjectId, nativeSubjectId),
1571
+ eq(subject.subjectRevision, input.expectedSecurityRevision),
1572
+ ),
1573
+ );
1574
+
1575
+ return receipt;
1576
+ }),
1577
+ ),
1578
+ };
1579
+
1580
+ return {
1581
+ verify: (session, now) =>
1582
+ service.verify(session, now).pipe(Effect.provideService(LifecycleHooks, hooks)),
1583
+ revoke: (input, prepare) =>
1584
+ service.revoke(input, prepare).pipe(Effect.provideService(LifecycleHooks, hooks)),
1585
+ revokeAll: (input, prepare) =>
1586
+ service.revokeAll(input, prepare).pipe(Effect.provideService(LifecycleHooks, hooks)),
1587
+ } satisfies SignedSessionValidity;
1588
+ });
1589
+
1590
+ const stepUpColumns = (mapping: any) =>
1591
+ Object.fromEntries(
1592
+ [
1593
+ "digest",
1594
+ "version",
1595
+ "flowId",
1596
+ "subjectId",
1597
+ "bindingDigest",
1598
+ "snapshot",
1599
+ "expiresAt",
1600
+ "attemptLimit",
1601
+ "failedAttempts",
1602
+ "consumed",
1603
+ ].map((key) => [key, column(mapping.intent.table, mapping.intent[key])]),
1604
+ );
1605
+
1606
+ const stepUpLocator = Effect.fn("DrizzleStepUp.locator")(function* (
1607
+ mapping: AnyStepUpMapping,
1608
+ digest: TokenDigest,
1609
+ locking: boolean,
1610
+ expiresAt?: DateTime.Utc,
1611
+ ) {
1612
+ const database = yield* CurrentSessionSql;
1613
+
1614
+ return yield* selectRows(
1615
+ database
1616
+ .select()
1617
+ .from(mapping.intent.table)
1618
+ .where(
1619
+ and(
1620
+ eq(stepUpColumns(mapping).digest, digest),
1621
+ expiresAt === undefined
1622
+ ? undefined
1623
+ : eq(stepUpColumns(mapping).expiresAt, mapping.intent.encodeInstant(expiresAt)),
1624
+ ),
1625
+ )
1626
+ .limit(1),
1627
+ locking,
1628
+ ).pipe(Effect.map((rows) => rows[0]));
1629
+ });
1630
+
1631
+ const stepUpAuthority = Effect.fn("DrizzleStepUp.authority")(function* (
1632
+ mapping: AnyStepUpMapping,
1633
+ revision: AuthenticationRevision,
1634
+ locking: boolean,
1635
+ ) {
1636
+ const current = yield* captureDetailsIn(
1637
+ mapping,
1638
+ revision.subjectId,
1639
+ revision.credentials.map((c) => c.credentialId),
1640
+ locking,
1641
+ );
1642
+
1643
+ if (!sameStepUpRevision(current.revision, revision)) return yield* stale();
1644
+ const requirement = yield* mapping.subject.decodeRequirement(current.subject);
1645
+
1646
+ return {
1647
+ nativeSubjectId: yield* mapping.subjectId.toNative(revision.subjectId),
1648
+ requirement,
1649
+ };
1650
+ });
1651
+
1652
+ const stepUpSource = Effect.fn("DrizzleStepUp.source")(function* (
1653
+ mapping: AnyStepUpMapping,
1654
+ intent: SessionStepUpIntent,
1655
+ nativeSubjectId: unknown,
1656
+ now: DateTime.Utc,
1657
+ locking: boolean,
1658
+ ) {
1659
+ const database = yield* CurrentSessionSql;
1660
+
1661
+ if (!stepUpIntentLive(intent, mapping.source.kind, now))
1662
+ return yield* SessionStepUpInvalid.make({});
1663
+ const source = mapping.source;
1664
+
1665
+ if (source.kind === "StatelessSigned") return { nativeSessionId: undefined, row: undefined };
1666
+ const nativeSessionId = yield* source.sessionId.toNative(intent.sourceSessionId);
1667
+
1668
+ if (source.kind === "StateAssistedSigned") {
1669
+ const t = source.tombstone;
1670
+
1671
+ const found = yield* selectRows(
1672
+ database
1673
+ .select()
1674
+ .from(t.table)
1675
+ .where(
1676
+ and(
1677
+ eq(column(t.table, t.subjectId), nativeSubjectId),
1678
+ eq(column(t.table, t.sessionId), nativeSessionId),
1679
+ gt(column(t.table, t.absoluteExpiresAt), t.encodeInstant(now)),
1680
+ ),
1681
+ )
1682
+ .limit(1),
1683
+ locking,
1684
+ );
1685
+
1686
+ if (found.length > 0) return yield* SessionStepUpInvalid.make({});
1687
+
1688
+ return { nativeSessionId, row: undefined };
1689
+ }
1690
+
1691
+ const s = source.session,
1692
+ c = sessionColumns(source);
1693
+
1694
+ const row = (yield* selectRows(
1695
+ database
1696
+ .select()
1697
+ .from(s.table)
1698
+ .where(
1699
+ and(
1700
+ eq(c.sessionId, nativeSessionId),
1701
+ eq(c.subjectId, nativeSubjectId),
1702
+ eq(c.securityRevision, intent.revision.securityRevision),
1703
+ eq(column(s.table, s.credentialVersion), intent.sourceCredentialVersion),
1704
+ eq(column(s.table, s.authenticatedAt), s.encodeInstant(intent.sourceAuthenticatedAt)),
1705
+ eq(c.expiresAt, s.encodeInstant(intent.sourceExpiresAt)),
1706
+ eq(c.absoluteExpiresAt, s.encodeInstant(intent.sourceAbsoluteExpiresAt)),
1707
+ gt(c.expiresAt, s.encodeInstant(now)),
1708
+ gt(c.absoluteExpiresAt, s.encodeInstant(now)),
1709
+ ),
1710
+ )
1711
+ .limit(1),
1712
+ locking,
1713
+ ))[0];
1714
+
1715
+ if (row === undefined) return yield* SessionStepUpInvalid.make({});
1716
+
1717
+ return { nativeSessionId, row };
1718
+ });
1719
+
1720
+ const readSqlStepUp = Effect.fn("DrizzleStepUp.read")(function* (
1721
+ mapping: AnyStepUpMapping,
1722
+ digest: TokenDigest,
1723
+ locking: boolean,
1724
+ ) {
1725
+ const first = yield* stepUpLocator(mapping, digest, false);
1726
+
1727
+ if (first === undefined) return yield* SessionStepUpInvalid.make({});
1728
+ const intent = yield* decodeStepUpIntent(first[mapping.intent.snapshot]);
1729
+ const authority = yield* stepUpAuthority(mapping, intent.revision, locking);
1730
+
1731
+ const source = yield* stepUpSource(
1732
+ mapping,
1733
+ intent,
1734
+ authority.nativeSubjectId,
1735
+ yield* freshNow,
1736
+ locking,
1737
+ );
1738
+
1739
+ const row = yield* stepUpLocator(mapping, digest, locking, intent.expiresAt);
1740
+ const m = mapping.intent;
1741
+
1742
+ if (
1743
+ row === undefined ||
1744
+ row[m.snapshot] !== first[m.snapshot] ||
1745
+ row[m.version] !== intent.version ||
1746
+ row[m.digest] !== intent.digest ||
1747
+ digest !== intent.digest ||
1748
+ row[m.flowId] !== intent.flowId ||
1749
+ row[m.bindingDigest] !== intent.bindingDigest ||
1750
+ !mapping.subjectId.equals(row[m.subjectId], authority.nativeSubjectId) ||
1751
+ Boolean(row[m.consumed]) ||
1752
+ !Schema.is(Schema.Natural)(row[m.failedAttempts]) ||
1753
+ row[m.failedAttempts] >= row[m.attemptLimit] ||
1754
+ row[m.attemptLimit] !== intent.attemptLimit ||
1755
+ !stepUpIntentLive(intent, mapping.source.kind, yield* freshNow)
1756
+ )
1757
+ return yield* SessionStepUpInvalid.make({});
1758
+
1759
+ return { row, intent, source, ...authority };
1760
+ });
1761
+
1762
+ const isSqlStepUpRejectAbsence = (error: unknown): error is SqlStepUpRejectAbsence => {
1763
+ const tag =
1764
+ typeof error === "object" && error !== null ? Reflect.get(error, "_tag") : undefined;
1765
+
1766
+ return tag === "SessionStepUpInvalid" || tag === "StaleAuthentication";
1767
+ };
1768
+
1769
+ const recoverSqlStepUpRejectAbsence = <A, B, R>(
1770
+ effect: Effect.Effect<A, SqlStepUpReadFailure, R>,
1771
+ rejected: B,
1772
+ ): Effect.Effect<A | B, AdapterFailure | SessionUnavailable, R> =>
1773
+ effect.pipe(
1774
+ Effect.catchCause((cause) => {
1775
+ if (
1776
+ cause.reasons.length > 0 &&
1777
+ cause.reasons.every(
1778
+ (reason) => Cause.isFailReason(reason) && isSqlStepUpRejectAbsence(reason.error),
1779
+ )
1780
+ )
1781
+ return Effect.succeed(rejected);
1782
+
1783
+ return Effect.failCause(
1784
+ Cause.map(cause, (error) => (isSqlStepUpRejectAbsence(error) ? unavailable() : error)),
1785
+ );
1786
+ }),
1787
+ );
1788
+
1789
+ /** Shared implementation; public driver entrypoints restore concrete tables/IDs. */
1790
+ const makeSqlSessionStepUp = <Claims>(
1791
+ database: Database,
1792
+ mapping: SessionStepUpMapping<Claims, any, any, any, any, any, any, any>,
1793
+ options: SessionSqlOptions,
1794
+ ): Effect.Effect<SessionStepUpPersistence<Claims>, never, LifecycleHooks> =>
1795
+ Effect.map(LifecycleHooks, (hooks) => {
1796
+ const m = mapping.intent,
1797
+ c = stepUpColumns(mapping);
1798
+
1799
+ const read = (digest: TokenDigest) =>
1800
+ mapFailureCause(
1801
+ safeTransaction(inTransaction(database, readSqlStepUp(mapping, digest, options.locking))),
1802
+ (error) => (error._tag === "StaleAuthentication" ? SessionStepUpInvalid.make({}) : error),
1803
+ );
1804
+
1805
+ const service = {
1806
+ create: <A>(
1807
+ input: Omit<SessionStepUpIntent, "version">,
1808
+ _now: DateTime.Utc,
1809
+ prepare: PrepareSessionCommit<SessionStepUpIntent, A>,
1810
+ ) =>
1811
+ owned(
1812
+ database,
1813
+ options,
1814
+ mapping.isConstraintConflict,
1815
+ Effect.gen(function* () {
1816
+ const tx = yield* CurrentSessionSql;
1817
+ const journal = yield* CurrentCommitJournal;
1818
+ const authority = yield* stepUpAuthority(mapping, input.revision, options.locking);
1819
+
1820
+ const version = yield* allocate(
1821
+ options.mode,
1822
+ m.allocateVersion,
1823
+ m.allocateVersionSync,
1824
+ );
1825
+
1826
+ const record: SessionStepUpIntent = { ...input, version };
1827
+
1828
+ yield* stepUpSource(
1829
+ mapping,
1830
+ record,
1831
+ authority.nativeSubjectId,
1832
+ yield* freshNow,
1833
+ options.locking,
1834
+ );
1835
+
1836
+ const existing = yield* selectRows(
1837
+ tx
1838
+ .select()
1839
+ .from(m.table)
1840
+ .where(or(eq(c.digest, input.digest), eq(c.flowId, input.flowId)))
1841
+ .limit(1),
1842
+ options.locking,
1843
+ );
1844
+
1845
+ if (existing.length > 0) return yield* SessionConflict.make({});
1846
+ const snapshot = yield* encodeStepUpIntent(record);
1847
+
1848
+ const values = {
1849
+ ...m.encodeInsert(record, { subjectId: authority.nativeSubjectId }),
1850
+ ...updateValues([
1851
+ [m.digest, record.digest],
1852
+ [m.version, version],
1853
+ [m.flowId, record.flowId],
1854
+ [m.subjectId, authority.nativeSubjectId],
1855
+ [m.bindingDigest, record.bindingDigest],
1856
+ [m.snapshot, snapshot],
1857
+ [m.expiresAt, m.encodeInstant(record.expiresAt)],
1858
+ [m.attemptLimit, record.attemptLimit],
1859
+ [m.failedAttempts, 0],
1860
+ [m.consumed, false],
1861
+ ]),
1862
+ };
1863
+
1864
+ const receipt = prepare(record, journal);
1865
+
1866
+ yield* tx.insert(m.table).values(values).pipe(normalizeMutation(mapping));
1867
+
1868
+ const inserted = (yield* tx
1869
+ .select()
1870
+ .from(m.table)
1871
+ .where(
1872
+ and(
1873
+ eq(c.digest, record.digest),
1874
+ eq(c.version, version),
1875
+ eq(c.flowId, record.flowId),
1876
+ eq(c.subjectId, authority.nativeSubjectId),
1877
+ eq(c.bindingDigest, record.bindingDigest),
1878
+ eq(c.snapshot, snapshot),
1879
+ eq(c.expiresAt, m.encodeInstant(record.expiresAt)),
1880
+ eq(c.attemptLimit, record.attemptLimit),
1881
+ eq(c.failedAttempts, 0),
1882
+ eq(c.consumed, false),
1883
+ ),
1884
+ )
1885
+ .limit(1))[0];
1886
+
1887
+ if (inserted === undefined) return yield* unavailable();
1888
+
1889
+ return receipt;
1890
+ }),
1891
+ ).pipe((effect) =>
1892
+ mapFailureCause(effect, (error) =>
1893
+ error._tag === "SessionStepUpInvalid" ? stale() : error,
1894
+ ),
1895
+ ),
1896
+ context: (input: any) =>
1897
+ mapFailureCause(
1898
+ read(input.digest).pipe(
1899
+ Effect.flatMap(({ intent }) =>
1900
+ snapshotPendingAuthenticationContext({
1901
+ flowId: intent.flowId,
1902
+ bindingDigest: intent.bindingDigest,
1903
+ revision: intent.revision,
1904
+ expiresAtMillis: DateTime.toEpochMillis(intent.expiresAt),
1905
+ }),
1906
+ ),
1907
+ ),
1908
+ (error) => (error._tag === "PendingAuthenticationInvalid" ? unavailable() : error),
1909
+ ),
1910
+ read: (input: any) =>
1911
+ read(input.digest).pipe(
1912
+ Effect.flatMap((value) =>
1913
+ value.intent.bindingDigest === input.bindingDigest
1914
+ ? Effect.succeed(value.intent)
1915
+ : Effect.fail(SessionStepUpInvalid.make({})),
1916
+ ),
1917
+ ),
1918
+ reject: <A>(
1919
+ input: Parameters<SessionStepUpPersistence<Claims>["reject"]>[0],
1920
+ prepare: PrepareSessionCommit<{ readonly _tag: "Rejected" }, A>,
1921
+ ) =>
1922
+ owned(
1923
+ database,
1924
+ options,
1925
+ mapping.isConstraintConflict,
1926
+ Effect.gen(function* () {
1927
+ const tx = yield* CurrentSessionSql;
1928
+ const journal = yield* CurrentCommitJournal;
1929
+
1930
+ const selected = yield* recoverSqlStepUpRejectAbsence(
1931
+ readSqlStepUp(mapping, input.digest, options.locking).pipe(
1932
+ Effect.map((value) => ({ _tag: "Found" as const, value })),
1933
+ ),
1934
+ { _tag: "Rejected" as const },
1935
+ );
1936
+
1937
+ if (selected._tag === "Rejected") return prepare({ _tag: "Rejected" }, journal);
1938
+ const { row, intent } = selected.value;
1939
+ const receipt = prepare({ _tag: "Rejected" }, journal);
1940
+
1941
+ yield* tx
1942
+ .update(m.table)
1943
+ .set(updateValues([[m.failedAttempts, sql`${c.failedAttempts} + 1`]]))
1944
+ .where(
1945
+ and(
1946
+ eq(c.digest, input.digest),
1947
+ eq(c.snapshot, row[m.snapshot]),
1948
+ eq(c.version, intent.version),
1949
+ eq(c.consumed, false),
1950
+ sql`${c.failedAttempts} < ${c.attemptLimit}`,
1951
+ ),
1952
+ );
1953
+ const written = yield* stepUpLocator(mapping, input.digest, false);
1954
+
1955
+ if (
1956
+ written === undefined ||
1957
+ written[m.version] !== intent.version ||
1958
+ written[m.snapshot] !== row[m.snapshot] ||
1959
+ written[m.failedAttempts] !== row[m.failedAttempts] + 1
1960
+ )
1961
+ return yield* unavailable();
1962
+
1963
+ return receipt;
1964
+ }),
1965
+ ),
1966
+ complete: <A>(
1967
+ plan: Parameters<SessionStepUpPersistence<Claims>["complete"]>[0],
1968
+ prepare: PrepareSessionCommit<SessionInspection<Claims>, A>,
1969
+ ) =>
1970
+ owned(
1971
+ database,
1972
+ options,
1973
+ mapping.isConstraintConflict,
1974
+ Effect.gen(function* () {
1975
+ const tx = yield* CurrentSessionSql;
1976
+ const journal = yield* CurrentCommitJournal;
1977
+
1978
+ yield* validateStepUpPlan(plan);
1979
+
1980
+ // Lock the entire combined vector before source/intent, including added factors.
1981
+ const current = yield* stepUpAuthority(
1982
+ mapping,
1983
+ plan.evidence.revision,
1984
+ options.locking,
1985
+ );
1986
+
1987
+ const stored = yield* readSqlStepUp(mapping, plan.intent.digest, options.locking);
1988
+
1989
+ if (
1990
+ (yield* encodeStepUpIntent(stored.intent)) !==
1991
+ (yield* encodeStepUpIntent(plan.intent))
1992
+ )
1993
+ return yield* SessionStepUpInvalid.make({});
1994
+
1995
+ const base = yield* assessAuthentication(plan.evidence, current.requirement).pipe(
1996
+ Effect.mapError(() => stale()),
1997
+ );
1998
+
1999
+ const profile = yield* assessAuthentication(
2000
+ plan.evidence,
2001
+ stored.intent.requirement,
2002
+ ).pipe(Effect.mapError(() => stale()));
2003
+
2004
+ if (!base.satisfied || !profile.satisfied) return yield* stale();
2005
+
2006
+ const replacement = plan.replacement,
2007
+ now = yield* freshNow;
2008
+
2009
+ if (
2010
+ DateTime.toEpochMillis(replacement.inspection.session.expiresAt) >
2011
+ DateTime.toEpochMillis(stored.intent.sourceAbsoluteExpiresAt) ||
2012
+ DateTime.toEpochMillis(now) <
2013
+ DateTime.toEpochMillis(replacement.inspection.session.issuedAt) ||
2014
+ DateTime.toEpochMillis(now) >=
2015
+ Math.min(
2016
+ DateTime.toEpochMillis(replacement.inspection.session.expiresAt),
2017
+ DateTime.toEpochMillis(stored.intent.expiresAt),
2018
+ )
2019
+ )
2020
+ return yield* SessionStepUpInvalid.make({});
2021
+ let rotation: any;
2022
+
2023
+ if (replacement._tag === "Stateful") {
2024
+ if (mapping.source.kind !== "Stateful") return yield* SessionStepUpInvalid.make({});
2025
+
2026
+ const s = mapping.source.session,
2027
+ row = stored.source.row;
2028
+
2029
+ if (
2030
+ row[s.digest] !== replacement.expectedDigest ||
2031
+ row[s.version] !== replacement.expectedRowVersion
2032
+ )
2033
+ return yield* SessionConflict.make({});
2034
+
2035
+ const version = yield* allocate(
2036
+ options.mode,
2037
+ s.allocateVersion,
2038
+ s.allocateVersionSync,
2039
+ );
2040
+
2041
+ const next: StatefulSessionRecord<Claims> = {
2042
+ ...replacement.inspection.session,
2043
+ provenance: replacement.inspection.provenance,
2044
+ credentialVersion: replacement.inspection.credentialVersion,
2045
+ digest: replacement.nextDigest,
2046
+ version,
2047
+ };
2048
+
2049
+ rotation = {
2050
+ ...s.encodeRotation(next),
2051
+ ...updateValues([
2052
+ [s.version, version],
2053
+ [s.digest, next.digest],
2054
+ [s.credentialVersion, next.credentialVersion],
2055
+ [s.authenticatedAt, s.encodeInstant(next.assurance.authenticatedAt)],
2056
+ [s.issuedAt, s.encodeInstant(next.issuedAt)],
2057
+ [s.expiresAt, s.encodeInstant(next.expiresAt)],
2058
+ [s.absoluteExpiresAt, s.encodeInstant(next.absoluteExpiresAt)],
2059
+ ]),
2060
+ };
2061
+ }
2062
+
2063
+ const completionVersion = yield* allocate(
2064
+ options.mode,
2065
+ m.allocateVersion,
2066
+ m.allocateVersionSync,
2067
+ );
2068
+
2069
+ if (completionVersion === stored.intent.version) return yield* unavailable();
2070
+
2071
+ const completionSnapshot = yield* encodeStepUpIntent({
2072
+ ...stored.intent,
2073
+ version: completionVersion,
2074
+ });
2075
+
2076
+ const receipt = prepare(replacement.inspection, journal);
2077
+
2078
+ if (replacement._tag === "Stateful" && mapping.source.kind === "Stateful") {
2079
+ const s = mapping.source.session,
2080
+ sc = sessionColumns(mapping.source);
2081
+
2082
+ yield* tx
2083
+ .update(s.table)
2084
+ .set(rotation)
2085
+ .where(
2086
+ and(
2087
+ eq(sc.sessionId, stored.source.nativeSessionId),
2088
+ eq(sc.digest, replacement.expectedDigest),
2089
+ eq(sc.version, replacement.expectedRowVersion),
2090
+ ),
2091
+ );
2092
+
2093
+ const written = (yield* tx
2094
+ .select()
2095
+ .from(s.table)
2096
+ .where(eq(sc.sessionId, stored.source.nativeSessionId))
2097
+ .limit(1))[0];
2098
+
2099
+ if (written === undefined || written[s.version] !== rotation[s.version])
2100
+ return yield* unavailable();
2101
+ const decoded = yield* s.decode(written);
2102
+
2103
+ if (
2104
+ decoded.version !== rotation[s.version] ||
2105
+ !stepUpRotationMatches(plan, decoded)
2106
+ )
2107
+ return yield* unavailable();
2108
+ } else if (
2109
+ replacement._tag === "StateAssistedSigned" &&
2110
+ mapping.source.kind === "StateAssistedSigned"
2111
+ ) {
2112
+ const t = mapping.source.tombstone;
2113
+
2114
+ const owner = column(t.table, t.subjectId),
2115
+ id = column(t.table, t.sessionId),
2116
+ expiry = column(t.table, t.absoluteExpiresAt);
2117
+
2118
+ yield* tx
2119
+ .delete(t.table)
2120
+ .where(
2121
+ and(
2122
+ eq(owner, stored.nativeSubjectId),
2123
+ eq(id, stored.source.nativeSessionId),
2124
+ lte(expiry, t.encodeInstant(yield* freshNow)),
2125
+ ),
2126
+ );
2127
+
2128
+ const values = t.encodeInsert({
2129
+ subjectId: stored.nativeSubjectId,
2130
+ sessionId: stored.source.nativeSessionId,
2131
+ absoluteExpiresAt: replacement.tombstoneUntil,
2132
+ });
2133
+
2134
+ yield* tx.insert(t.table).values(values).pipe(normalizeMutation(mapping));
2135
+
2136
+ const inserted = (yield* tx
2137
+ .select()
2138
+ .from(t.table)
2139
+ .where(
2140
+ and(
2141
+ eq(owner, stored.nativeSubjectId),
2142
+ eq(id, stored.source.nativeSessionId),
2143
+ eq(expiry, t.encodeInstant(replacement.tombstoneUntil)),
2144
+ ),
2145
+ )
2146
+ .limit(1))[0];
2147
+
2148
+ if (inserted === undefined) return yield* unavailable();
2149
+ }
2150
+ yield* tx
2151
+ .update(m.table)
2152
+ .set(
2153
+ updateValues([
2154
+ [m.consumed, true],
2155
+ [m.version, completionVersion],
2156
+ [m.snapshot, completionSnapshot],
2157
+ ]),
2158
+ )
2159
+ .where(
2160
+ and(
2161
+ eq(c.digest, stored.intent.digest),
2162
+ eq(c.version, stored.intent.version),
2163
+ eq(c.snapshot, stored.row[m.snapshot]),
2164
+ eq(c.consumed, false),
2165
+ eq(c.failedAttempts, stored.row[m.failedAttempts]),
2166
+ ),
2167
+ );
2168
+ const completed = yield* stepUpLocator(mapping, stored.intent.digest, false);
2169
+
2170
+ if (
2171
+ completed === undefined ||
2172
+ !completed[m.consumed] ||
2173
+ completed[m.version] !== completionVersion ||
2174
+ completed[m.snapshot] !== completionSnapshot
2175
+ )
2176
+ return yield* unavailable();
2177
+ const commitNow = yield* freshNow;
2178
+
2179
+ if (
2180
+ !stepUpIntentLive(stored.intent, mapping.source.kind, commitNow) ||
2181
+ DateTime.toEpochMillis(commitNow) >=
2182
+ DateTime.toEpochMillis(replacement.inspection.session.expiresAt) ||
2183
+ !(yield* assessAuthentication(plan.evidence, current.requirement).pipe(
2184
+ Effect.mapError(() => stale()),
2185
+ )).satisfied ||
2186
+ !(yield* assessAuthentication(plan.evidence, stored.intent.requirement).pipe(
2187
+ Effect.mapError(() => stale()),
2188
+ )).satisfied
2189
+ )
2190
+ return yield* stale();
2191
+
2192
+ return receipt;
2193
+ }),
2194
+ ),
2195
+ };
2196
+
2197
+ return {
2198
+ create: (input, now, prepare) =>
2199
+ service.create(input, now, prepare).pipe(Effect.provideService(LifecycleHooks, hooks)),
2200
+ context: (input) =>
2201
+ service.context(input).pipe(Effect.provideService(LifecycleHooks, hooks)),
2202
+ read: (input) => service.read(input).pipe(Effect.provideService(LifecycleHooks, hooks)),
2203
+ reject: (input, prepare) =>
2204
+ service.reject(input, prepare).pipe(Effect.provideService(LifecycleHooks, hooks)),
2205
+ complete: (plan, prepare) =>
2206
+ service.complete(plan, prepare).pipe(Effect.provideService(LifecycleHooks, hooks)),
2207
+ } satisfies SessionStepUpPersistence<Claims>;
2208
+ });
2209
+
2210
+ return {
2211
+ makeSqlAuthenticationAuthority,
2212
+ makeSqlPendingAuthentication,
2213
+ makeSqlStatefulSessions,
2214
+ makeSqlSignedValidity,
2215
+ makeSqlSessionStepUp,
2216
+ };
2217
+ };