@yielded/auth-persistence 0.1.0-beta.21 → 0.1.0-beta.23

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 (48) hide show
  1. package/dist/Adapter.d.mts +4 -2
  2. package/dist/Adapter.mjs +3 -1
  3. package/dist/internal/authority-snapshot.mjs +53 -0
  4. package/dist/internal/authority-snapshot.mjs.map +1 -0
  5. package/dist/internal/configuration.d.mts +1 -1
  6. package/dist/internal/configuration.mjs.map +1 -1
  7. package/dist/internal/models/password-model.d.mts +3 -53
  8. package/dist/internal/models/password-model.mjs +0 -2
  9. package/dist/internal/models/password-model.mjs.map +1 -1
  10. package/dist/internal/native-sql-table.d.mts +30 -0
  11. package/dist/internal/native-sql-table.mjs +104 -0
  12. package/dist/internal/native-sql-table.mjs.map +1 -0
  13. package/dist/internal/passkey/target.d.mts +1 -1
  14. package/dist/internal/passkeys.mjs +1 -1
  15. package/dist/internal/password-attempts.d.mts +12 -0
  16. package/dist/internal/password-attempts.mjs +197 -0
  17. package/dist/internal/password-attempts.mjs.map +1 -0
  18. package/dist/internal/password-kernel.d.mts +15 -12
  19. package/dist/internal/password-kernel.mjs +39 -202
  20. package/dist/internal/password-kernel.mjs.map +1 -1
  21. package/dist/internal/persistence.d.mts +2 -0
  22. package/dist/internal/persistence.mjs +4 -4
  23. package/dist/internal/persistence.mjs.map +1 -1
  24. package/dist/internal/session-kernel.d.mts +3 -1
  25. package/dist/internal/session-kernel.mjs +22 -12
  26. package/dist/internal/session-kernel.mjs.map +1 -1
  27. package/dist/internal/sql-persistence.mjs +3 -1
  28. package/dist/internal/sql-persistence.mjs.map +1 -1
  29. package/dist/internal/storage-mapping.mjs +1 -30
  30. package/dist/internal/storage-mapping.mjs.map +1 -1
  31. package/dist/internal/storage-tables.d.mts +0 -43
  32. package/dist/internal/storage-tables.mjs +0 -26
  33. package/dist/internal/storage-tables.mjs.map +1 -1
  34. package/dist/internal/transaction-execution-kernel.d.mts +1 -1
  35. package/dist/internal/transaction-kernel.d.mts +1 -1
  36. package/package.json +1 -1
  37. package/src/Adapter.ts +9 -4
  38. package/src/internal/authority-snapshot.ts +91 -0
  39. package/src/internal/configuration.ts +1 -6
  40. package/src/internal/models/password-model.ts +0 -64
  41. package/src/internal/native-sql-table.ts +164 -0
  42. package/src/internal/password-attempts.ts +466 -0
  43. package/src/internal/password-kernel.ts +102 -623
  44. package/src/internal/persistence.ts +5 -10
  45. package/src/internal/session-kernel.ts +89 -27
  46. package/src/internal/sql-persistence.ts +2 -0
  47. package/src/internal/storage-mapping.ts +0 -29
  48. package/src/internal/storage-tables.ts +0 -15
@@ -16,10 +16,7 @@ import {
16
16
  snapshotPasswordRequirement,
17
17
  snapshotPasswordRevision,
18
18
  type PasswordAction,
19
- type PasswordAttemptDecision,
20
- type PasswordCredentialSnapshot,
21
19
  type PasswordMutationDecision,
22
- type PasswordAttemptPolicy,
23
20
  } from "@yielded/auth/Password";
24
21
  import { reportPersistenceFailure } from "@yielded/auth/Persistence";
25
22
  import {
@@ -34,16 +31,23 @@ import {
34
31
  type SecurityRevision,
35
32
  } from "@yielded/auth/Sessions";
36
33
  import { Cause, Context, DateTime, Effect, Option, Redacted, Schema } from "effect";
34
+ import type { SqlClient } from "effect/sql/SqlClient";
37
35
  import type * as SqlError from "effect/sql/SqlError";
38
36
 
39
37
  import { PersistenceMappingError } from "./mapping-error";
40
38
  import {
41
39
  type AnyPasswordPersistenceMapping,
42
- type PasswordAttemptAction,
43
- type PasswordRateScopeKind,
44
- type PasswordScopeKeys,
45
40
  requiredPasswordConstraints,
46
41
  } from "./models/password-model";
42
+ import { makeNativeSqlTables, sqlMapping, type NativeSqlTables } from "./native-sql-table";
43
+ import {
44
+ makePasswordAttempts,
45
+ samePasswordCredentialSnapshot as sameCredentialSnapshot,
46
+ } from "./password-attempts";
47
+ import {
48
+ CurrentPasswordPreparedTransaction,
49
+ PasswordPreparedPostconditions,
50
+ } from "./PasswordPreparedPostconditions";
47
51
  import {
48
52
  CurrentProofSql,
49
53
  type ProofSqlConfiguration,
@@ -53,7 +57,7 @@ import {
53
57
  /* oxlint-disable no-explicit-any -- existing storage kernels erase foreign table shapes; domain errors remain typed. */
54
58
  import type { QueryFailure } from "./query-operations";
55
59
  import type { QueryOperations, SqlFragment, SqlColumn } from "./query-operations";
56
- import type { NativeDatabase } from "./transaction-kernel";
60
+ import { NativeDatabase } from "./transaction-kernel";
57
61
 
58
62
  type AdapterFailure = QueryFailure | PersistenceMappingError | SqlError.SqlError;
59
63
 
@@ -104,23 +108,18 @@ export interface PasswordSqlConfiguration {
104
108
 
105
109
  type Database = PasswordSqlDatabase;
106
110
 
107
- interface ScopeEntry {
108
- readonly kind: PasswordRateScopeKind;
109
- readonly key: string;
110
- readonly limit: number;
111
- readonly windowMillis: number;
112
- }
113
-
114
111
  export const makePasswordKernel = <
115
112
  Fragment extends SqlFragment = SqlFragment,
116
113
  Column extends SqlColumn = SqlColumn,
117
114
  >(
118
115
  operations: QueryOperations<Fragment, Column>,
119
116
  proofs: Pick<ReturnType<typeof makeProofKernel>, "completeProofPlanIn">,
117
+ nativeTables: (client: SqlClient, database: object) => NativeSqlTables = (client) =>
118
+ makeNativeSqlTables(client),
120
119
  ) => {
121
120
  type Mapping = AnyPasswordPersistenceMapping<Fragment>;
122
121
 
123
- const { and, eq, gt, gte, inArray, lte, notExists, sql, column, updateValues } = operations;
122
+ const { and, eq, inArray, lte, column, updateValues } = operations;
124
123
  const { completeProofPlanIn } = proofs;
125
124
  const unavailable = () => PasswordUnavailable.make({});
126
125
 
@@ -200,23 +199,6 @@ export const makePasswordKernel = <
200
199
  state: column(mapping.attempt.table, mapping.attempt.state),
201
200
  });
202
201
 
203
- const scopeColumns = (mapping: Mapping) => ({
204
- moduleId: column(mapping.rateScope.table, mapping.rateScope.moduleId),
205
- action: column(mapping.rateScope.table, mapping.rateScope.action),
206
- scopeKind: column(mapping.rateScope.table, mapping.rateScope.scopeKind),
207
- scopeKey: column(mapping.rateScope.table, mapping.rateScope.scopeKey),
208
- });
209
-
210
- const chargeColumns = (mapping: Mapping) => ({
211
- moduleId: column(mapping.charge.table, mapping.charge.moduleId),
212
- action: column(mapping.charge.table, mapping.charge.action),
213
- scopeKind: column(mapping.charge.table, mapping.charge.scopeKind),
214
- scopeKey: column(mapping.charge.table, mapping.charge.scopeKey),
215
- attemptId: column(mapping.charge.table, mapping.charge.attemptId),
216
- occurredAt: column(mapping.charge.table, mapping.charge.occurredAt),
217
- retentionUntil: column(mapping.charge.table, mapping.charge.retentionUntil),
218
- });
219
-
220
202
  const commandColumns = (mapping: Mapping) => ({
221
203
  moduleId: column(mapping.command.table, mapping.command.moduleId),
222
204
  commandId: column(mapping.command.table, mapping.command.commandId),
@@ -270,28 +252,6 @@ export const makePasswordKernel = <
270
252
  const sameIdentifier = (left: LoginIdentifier, right: LoginIdentifier) =>
271
253
  left.namespace === right.namespace && left.value === right.value;
272
254
 
273
- const sameCredentialSnapshot = (
274
- left: PasswordCredentialSnapshot,
275
- right: PasswordCredentialSnapshot,
276
- ) =>
277
- left.moduleId === right.moduleId &&
278
- left.revision.subjectId === right.revision.subjectId &&
279
- left.revision.securityRevision === right.revision.securityRevision &&
280
- left.credentialId === right.credentialId &&
281
- left.credentialRevision === right.credentialRevision &&
282
- left.verifierVersion === right.verifierVersion &&
283
- left.normalization === right.normalization &&
284
- left.identifierVerifiedAtMillis === right.identifierVerifiedAtMillis &&
285
- left.revision.credentials.length === right.revision.credentials.length &&
286
- left.revision.credentials.every((entry) =>
287
- right.revision.credentials.some(
288
- (other) => other.credentialId === entry.credentialId && other.revision === entry.revision,
289
- ),
290
- ) &&
291
- left.identifierBindingRevision === right.identifierBindingRevision &&
292
- sameIdentifier(left.identifier, right.identifier) &&
293
- Redacted.value(left.verifier) === Redacted.value(right.verifier);
294
-
295
255
  const evidenceSatisfiedAt = (
296
256
  evidence: AuthenticationEvidence,
297
257
  requirement: AuthenticationRequirement,
@@ -482,145 +442,6 @@ export const makePasswordKernel = <
482
442
  };
483
443
  });
484
444
 
485
- const scopeEntries = (
486
- keys: PasswordScopeKeys,
487
- policy: PasswordAttemptPolicy,
488
- ): ReadonlyArray<ScopeEntry> => {
489
- const values: ScopeEntry[] = [
490
- { kind: "action", key: keys.action, ...policy.action },
491
- { kind: "identifier", key: keys.identifier, ...policy.identifier },
492
- ];
493
-
494
- if (keys.subject !== undefined)
495
- values.push({ kind: "subject", key: keys.subject, ...policy.subject });
496
-
497
- return values;
498
- };
499
-
500
- const lockScope = Effect.fn("DrizzlePassword.lockScope")(function* (
501
- mapping: Mapping,
502
- configuration: PasswordSqlConfiguration,
503
- moduleId: string,
504
- action: PasswordAttemptAction,
505
- entry: ScopeEntry,
506
- ) {
507
- const database = yield* CurrentPasswordSql;
508
-
509
- const query = database.insert(mapping.rateScope.table).values(
510
- mapping.rateScope.encodeInsert({
511
- moduleId,
512
- action,
513
- scopeKind: entry.kind,
514
- scopeKey: entry.key,
515
- }),
516
- );
517
-
518
- yield* configuration.insertIfAbsent(query, mapping.rateScope.scopeKey, entry.key);
519
- const c = scopeColumns(mapping);
520
-
521
- yield* selectRows(
522
- database
523
- .select()
524
- .from(mapping.rateScope.table)
525
- .where(
526
- and(
527
- eq(c.moduleId, moduleId),
528
- eq(c.action, action),
529
- eq(c.scopeKind, entry.kind),
530
- eq(c.scopeKey, entry.key),
531
- ),
532
- )
533
- .limit(1),
534
- configuration.locking,
535
- );
536
- });
537
-
538
- const scopeAdmits = Effect.fn("DrizzlePassword.scopeAdmits")(function* (
539
- mapping: Mapping,
540
- moduleId: string,
541
- action: PasswordAttemptAction,
542
- entry: ScopeEntry,
543
- policy: PasswordAttemptPolicy,
544
- identifier: LoginIdentifier,
545
- nativeSubjectId: unknown | undefined,
546
- now: number,
547
- ) {
548
- const database = yield* CurrentPasswordSql;
549
-
550
- const c = chargeColumns(mapping);
551
-
552
- const charges = yield* selectRows(
553
- database
554
- .select({ attemptId: c.attemptId })
555
- .from(mapping.charge.table)
556
- .where(
557
- and(
558
- eq(c.moduleId, moduleId),
559
- eq(c.action, action),
560
- eq(c.scopeKind, entry.kind),
561
- eq(c.scopeKey, entry.key),
562
- gte(c.occurredAt, mapping.encodeInstant(now - entry.windowMillis)),
563
- ),
564
- )
565
- .limit(entry.limit),
566
- true,
567
- );
568
-
569
- if (charges.length >= entry.limit) return false;
570
- const a = attemptColumns(mapping);
571
-
572
- // Retention is the pending horizon. Expired rows stay until cleanup, but they
573
- // no longer consume the admission budget after that horizon.
574
- const pendingWhere = and(
575
- entry.kind === "action"
576
- ? and(eq(a.moduleId, moduleId), eq(a.action, action))
577
- : entry.kind === "identifier"
578
- ? and(
579
- eq(a.moduleId, moduleId),
580
- eq(a.action, action),
581
- eq(a.identifierNamespace, identifier.namespace),
582
- eq(a.identifierValue, identifier.value),
583
- )
584
- : and(eq(a.moduleId, moduleId), eq(a.action, action), eq(a.subjectId, nativeSubjectId)),
585
- eq(a.state, "pending"),
586
- gt(a.retentionUntil, mapping.encodeInstant(now)),
587
- );
588
-
589
- const pending = yield* selectRows(
590
- database
591
- .select({ attemptId: a.attemptId })
592
- .from(mapping.attempt.table)
593
- .where(pendingWhere)
594
- .limit(policy.maximumPending),
595
- true,
596
- );
597
-
598
- return pending.length < policy.maximumPending;
599
- });
600
-
601
- const insertCharge = Effect.fn("Drizzle.insertCharge")(function* (
602
- mapping: Mapping,
603
- moduleId: string,
604
- action: PasswordAttemptAction,
605
- entry: ScopeEntry,
606
- attemptId: any,
607
- now: number,
608
- ) {
609
- const database = yield* CurrentPasswordSql;
610
-
611
- return yield* database.insert(mapping.charge.table).values(
612
- mapping.charge.encodeInsert({
613
- moduleId,
614
- action,
615
- scopeKind: entry.kind,
616
- scopeKey: entry.key,
617
- attemptId,
618
- occurredAtMillis: now,
619
- retentionUntilMillis: now + entry.windowMillis,
620
- }),
621
- );
622
- });
623
-
624
445
  const owned = <A, E, R>(
625
446
  database: Database,
626
447
  mapping: Mapping,
@@ -650,6 +471,25 @@ export const makePasswordKernel = <
650
471
  : run;
651
472
  };
652
473
 
474
+ // A coordinator that accepts postconditions re-runs this check against its
475
+ // current transaction after application work, before commit.
476
+ const registerFinal = Effect.fnUntraced(function* <E>(
477
+ check: Effect.Effect<boolean, E, CurrentPasswordSql>,
478
+ ) {
479
+ const postconditions = yield* Effect.serviceOption(PasswordPreparedPostconditions);
480
+
481
+ if (Option.isNone(postconditions)) return;
482
+
483
+ const postcondition = Effect.gen(function* () {
484
+ const transaction = yield* CurrentPasswordPreparedTransaction;
485
+
486
+ if (!(yield* check.pipe(Effect.provideService(CurrentPasswordSql, transaction))))
487
+ return yield* unavailable();
488
+ }).pipe(translateFailure);
489
+
490
+ if (!postconditions.value.register(postcondition)) return yield* unavailable();
491
+ });
492
+
653
493
  const safeRead = <A, E, R>(
654
494
  database: Database,
655
495
  configuration: PasswordSqlConfiguration,
@@ -1067,55 +907,16 @@ export const makePasswordKernel = <
1067
907
  );
1068
908
  yield* insertCommand(mapping, input, "add-password", commandNow ?? authority.now);
1069
909
 
1070
- const inserted = (yield* readPasswordCredential(
910
+ const applied = checkMutationApplied(
1071
911
  mapping,
1072
- input.moduleId,
912
+ input,
1073
913
  authority.nativeSubjectId,
1074
- false,
1075
- ))[0];
1076
-
1077
- const authorityInserted = (yield* database
1078
- .select()
1079
- .from(mapping.authorityCredential.table)
1080
- .where(
1081
- and(
1082
- eq(authorityCredentialColumns(mapping).subjectId, authority.nativeSubjectId),
1083
- eq(authorityCredentialColumns(mapping).credentialId, credentialId),
1084
- eq(authorityCredentialColumns(mapping).revision, credentialRevision),
1085
- ),
1086
- )
1087
- .limit(1))[0];
1088
-
1089
- const subjectUpdated = (yield* database
1090
- .select()
1091
- .from(mapping.subject.table)
1092
- .where(
1093
- and(
1094
- eq(subjectColumns(mapping).id, authority.nativeSubjectId),
1095
- eq(subjectColumns(mapping).securityRevision, nextSecurityRevision),
1096
- ),
1097
- )
1098
- .limit(1))[0];
914
+ revisions,
915
+ commandNow ?? authority.now,
916
+ );
1099
917
 
1100
- if (
1101
- inserted === undefined ||
1102
- inserted[mapping.credential.credentialId] !== credentialId ||
1103
- inserted[mapping.credential.credentialRevision] !== credentialRevision ||
1104
- inserted[mapping.credential.verifierVersion] !== verifierVersion ||
1105
- inserted[mapping.credential.verifier] !== Redacted.value(input.replacement.verifier) ||
1106
- inserted[mapping.credential.normalization] !== input.replacement.normalization ||
1107
- authorityInserted === undefined ||
1108
- subjectUpdated === undefined ||
1109
- !(yield* mutationPostconditions(
1110
- mapping,
1111
- input,
1112
- authority.nativeSubjectId,
1113
- subjectUpdated,
1114
- authorityInserted,
1115
- commandNow ?? authority.now,
1116
- ))
1117
- )
1118
- return yield* unavailable();
918
+ if (!(yield* applied)) return yield* unavailable();
919
+ yield* registerFinal(applied);
1119
920
 
1120
921
  return true;
1121
922
  });
@@ -1139,267 +940,19 @@ export const makePasswordKernel = <
1139
940
  ).pipe(Effect.mapError(unavailable));
1140
941
  const hooks = yield* LifecycleHooks;
1141
942
 
1142
- return PasswordPersistence.of({
1143
- admitAttempt: (input, prepare) =>
1144
- Effect.gen(function* () {
1145
- const attemptId = yield* allocate(
1146
- configuration.mode,
1147
- mapping.allocateAttemptId,
1148
- mapping.allocateAttemptIdSync,
1149
- );
1150
-
1151
- return yield* owned(
1152
- database,
1153
- mapping,
1154
- configuration,
1155
- Effect.gen(function* () {
1156
- const transaction = yield* CurrentPasswordSql;
1157
- const journal = yield* CurrentCommitJournal;
1158
-
1159
- const resolved = yield* resolveCredential(
1160
- mapping,
1161
- input.moduleId,
1162
- input.identifier,
1163
- input.subjectId,
1164
- configuration.locking,
1165
- );
1166
-
1167
- const keys = mapping.scopeKeys({
1168
- moduleId: input.moduleId,
1169
- action: input.action,
1170
- identifier: input.identifier,
1171
- ...(resolved?.snapshot === undefined
1172
- ? {}
1173
- : { subjectId: resolved.snapshot.revision.subjectId }),
1174
- });
1175
-
1176
- const scopes = scopeEntries(keys, input.policy);
1177
- const admitted: ScopeEntry[] = [];
1178
-
1179
- yield* lockScope(mapping, configuration, input.moduleId, input.action, scopes[0]!);
1180
- let currentNow = yield* nowMillis;
943
+ if (!validConstraints(mapping)) return yield* unavailable();
944
+ const root = yield* NativeDatabase;
1181
945
 
1182
- if (
1183
- yield* scopeAdmits(
1184
- mapping,
1185
- input.moduleId,
1186
- input.action,
1187
- scopes[0]!,
1188
- input.policy,
1189
- input.identifier,
1190
- resolved?.nativeSubjectId,
1191
- currentNow,
1192
- )
1193
- )
1194
- admitted.push(scopes[0]!);
1195
- if (admitted.length === 1) {
1196
- for (const entry of scopes.slice(1)) {
1197
- yield* lockScope(mapping, configuration, input.moduleId, input.action, entry);
1198
- currentNow = yield* nowMillis;
1199
- if (
1200
- yield* scopeAdmits(
1201
- mapping,
1202
- input.moduleId,
1203
- input.action,
1204
- entry,
1205
- input.policy,
1206
- input.identifier,
1207
- resolved?.nativeSubjectId,
1208
- currentNow,
1209
- )
1210
- )
1211
- admitted.push(entry);
1212
- }
1213
- }
1214
- const now = yield* nowMillis;
1215
-
1216
- for (const entry of admitted)
1217
- yield* insertCharge(mapping, input.moduleId, input.action, entry, attemptId, now);
1218
- if (admitted.length !== scopes.length)
1219
- return prepare({ _tag: "Denied" as const }, journal);
1220
- const snapshot = resolved?.snapshot;
1221
- const deadline = now + input.policy.attemptLifetimeMillis;
1222
-
1223
- const retentionUntil = Math.max(
1224
- deadline,
1225
- ...scopes.map((entry) => now + entry.windowMillis),
1226
- );
1227
-
1228
- yield* transaction.insert(mapping.attempt.table).values(
1229
- mapping.attempt.encodeInsert(
1230
- {
1231
- moduleId: input.moduleId,
1232
- action: input.action,
1233
- attemptId,
1234
- identifier: input.identifier,
1235
- ...(snapshot === undefined
1236
- ? {}
1237
- : {
1238
- subjectId: snapshot.revision.subjectId,
1239
- credentialId: snapshot.credentialId,
1240
- securityRevision: snapshot.revision.securityRevision,
1241
- credentialRevision: snapshot.credentialRevision,
1242
- verifierVersion: snapshot.verifierVersion,
1243
- identifierBindingRevision: snapshot.identifierBindingRevision,
1244
- }),
1245
- admittedAtMillis: now,
1246
- deadlineMillis: deadline,
1247
- retentionUntilMillis: retentionUntil,
1248
- },
1249
- {
1250
- ...(resolved?.nativeSubjectId === undefined
1251
- ? {}
1252
- : { nativeSubjectId: resolved.nativeSubjectId }),
1253
- state: "pending",
1254
- },
1255
- ),
1256
- );
1257
-
1258
- return prepare(
1259
- {
1260
- _tag: "Admitted" as const,
1261
- attemptId,
1262
- ...(snapshot === undefined ? {} : { credential: snapshot }),
1263
- },
1264
- journal,
1265
- );
1266
- }),
1267
- );
1268
- }).pipe(
1269
- Effect.provideService(CurrentPasswordSql, database),
1270
- Effect.provideService(LifecycleHooks, hooks),
1271
- translateFailure,
1272
- ),
1273
- settleAttempt: (uncaptured, prepare) =>
1274
- Effect.gen(function* () {
1275
- const captured =
1276
- uncaptured.captured === undefined
1277
- ? undefined
1278
- : yield* snapshotPasswordCredential(uncaptured.captured);
1279
-
1280
- const input = { ...uncaptured, ...(captured === undefined ? {} : { captured }) };
1281
-
1282
- return yield* owned(
1283
- database,
1284
- mapping,
1285
- configuration,
1286
- Effect.gen(function* () {
1287
- const transaction = yield* CurrentPasswordSql;
1288
- const journal = yield* CurrentCommitJournal;
1289
-
1290
- const c = attemptColumns(mapping);
1291
-
1292
- const discovered = yield* selectRows(
1293
- transaction
1294
- .select()
1295
- .from(mapping.attempt.table)
1296
- .where(and(eq(c.moduleId, input.moduleId), eq(c.attemptId, input.attemptId)))
1297
- .limit(1),
1298
- false,
1299
- );
1300
-
1301
- let row = discovered[0];
1302
-
1303
- if (row === undefined || row[mapping.attempt.state] !== "pending")
1304
- return prepare("rejected", journal);
1305
- let decision: PasswordAttemptDecision = "rejected";
1306
- let current: any;
1307
- let attemptMatches = false;
1308
-
1309
- if (input.outcome === "verified" && input.captured !== undefined) {
1310
- current = yield* resolveCredential(
1311
- mapping,
1312
- input.moduleId,
1313
- input.captured.identifier,
1314
- input.captured.revision.subjectId,
1315
- configuration.locking,
1316
- );
1317
- attemptMatches =
1318
- input.captured.moduleId === input.moduleId &&
1319
- row[mapping.attempt.identifierNamespace] ===
1320
- input.captured.identifier.namespace &&
1321
- row[mapping.attempt.identifierValue] === input.captured.identifier.value &&
1322
- mapping.subjectId.equals(
1323
- row[mapping.attempt.subjectId],
1324
- current?.nativeSubjectId,
1325
- ) &&
1326
- row[mapping.attempt.credentialId] === input.captured.credentialId &&
1327
- row[mapping.attempt.securityRevision] ===
1328
- input.captured.revision.securityRevision &&
1329
- row[mapping.attempt.credentialRevision] === input.captured.credentialRevision &&
1330
- row[mapping.attempt.verifierVersion] === input.captured.verifierVersion &&
1331
- row[mapping.attempt.identifierBindingRevision] ===
1332
- input.captured.identifierBindingRevision;
1333
- }
1334
- row = (yield* selectRows(
1335
- transaction
1336
- .select()
1337
- .from(mapping.attempt.table)
1338
- .where(and(eq(c.moduleId, input.moduleId), eq(c.attemptId, input.attemptId)))
1339
- .limit(1),
1340
- configuration.locking,
1341
- ))[0];
1342
- if (row === undefined || row[mapping.attempt.state] !== "pending")
1343
- return prepare("rejected", journal);
1344
- const now = yield* nowMillis;
1345
- const deadline = yield* mapping.decodeInstant(row[mapping.attempt.deadline]);
1346
-
1347
- if (
1348
- attemptMatches &&
1349
- current?.snapshot !== undefined &&
1350
- input.captured !== undefined &&
1351
- deadline > now &&
1352
- sameCredentialSnapshot(current.snapshot, input.captured)
1353
- )
1354
- decision = "verified";
1355
- const prepared = prepare(decision, journal);
1356
-
1357
- yield* transaction
1358
- .update(mapping.attempt.table)
1359
- .set(updateValues([[mapping.attempt.state, decision]]))
1360
- .where(
1361
- and(
1362
- eq(c.moduleId, input.moduleId),
1363
- eq(c.attemptId, input.attemptId),
1364
- eq(c.state, "pending"),
1365
- ),
1366
- );
1367
- if (
1368
- decision === "verified" &&
1369
- input.rehash !== undefined &&
1370
- current?.snapshot !== undefined
1371
- ) {
1372
- const cc = credentialColumns(mapping);
1373
-
1374
- const nextVersion = yield* allocate(
1375
- configuration.mode,
1376
- mapping.allocateRevision,
1377
- mapping.allocateRevisionSync,
1378
- );
1379
-
1380
- yield* transaction
1381
- .update(mapping.credential.table)
1382
- .set(mapping.credential.encodeVerifier(input.rehash.nextVerifier, nextVersion))
1383
- .where(
1384
- and(
1385
- eq(cc.moduleId, input.moduleId),
1386
- eq(cc.subjectId, current.nativeSubjectId),
1387
- eq(cc.credentialId, current.snapshot.credentialId),
1388
- eq(cc.credentialRevision, current.snapshot.credentialRevision),
1389
- eq(cc.verifierVersion, input.rehash.expectedVersion),
1390
- eq(cc.verifier, Redacted.value(input.rehash.expectedVerifier)),
1391
- ),
1392
- );
1393
- }
946
+ const attempts = yield* makePasswordAttempts(
947
+ yield* sqlMapping(() => nativeTables(root.$client.withoutTransforms(), root)).pipe(
948
+ translateFailure,
949
+ ),
950
+ mapping,
951
+ configuration,
952
+ );
1394
953
 
1395
- return prepared;
1396
- }),
1397
- );
1398
- }).pipe(
1399
- Effect.provideService(CurrentPasswordSql, database),
1400
- Effect.provideService(LifecycleHooks, hooks),
1401
- translateFailure,
1402
- ),
954
+ return PasswordPersistence.of({
955
+ ...attempts,
1403
956
  readForSubject: (input) =>
1404
957
  safeRead(
1405
958
  database,
@@ -1691,13 +1244,30 @@ export const makePasswordKernel = <
1691
1244
  configuration.proof!.configuration,
1692
1245
  input.completion,
1693
1246
  Effect.gen(function* () {
1694
- return yield* writeReplacement(
1247
+ const revisions = {
1248
+ credentialRevision,
1249
+ verifierVersion,
1250
+ nextSecurityRevision,
1251
+ };
1252
+
1253
+ const applied = yield* writeReplacement(
1695
1254
  mapping,
1696
1255
  input,
1697
1256
  authority.nativeSubjectId,
1698
- { credentialRevision, verifierVersion, nextSecurityRevision },
1257
+ revisions,
1699
1258
  authority.now,
1700
1259
  );
1260
+
1261
+ if (applied)
1262
+ yield* registerReplacement(
1263
+ mapping,
1264
+ input,
1265
+ authority.nativeSubjectId,
1266
+ revisions,
1267
+ authority.now,
1268
+ );
1269
+
1270
+ return applied;
1701
1271
  }),
1702
1272
  (decision) => {
1703
1273
  passwordReceipt = prepare(
@@ -1729,103 +1299,19 @@ export const makePasswordKernel = <
1729
1299
 
1730
1300
  const now = yield* nowMillis;
1731
1301
  const nativeNow = mapping.encodeInstant(now);
1732
- let remaining = input.limit;
1733
- let hasMore = false;
1734
1302
  const a = attemptColumns(mapping);
1735
- const c = chargeColumns(mapping);
1736
1303
 
1737
1304
  const attempts = yield* selectRows(
1738
1305
  transaction
1739
1306
  .select({ attemptId: a.attemptId })
1740
1307
  .from(mapping.attempt.table)
1741
1308
  .where(and(eq(a.moduleId, input.moduleId), lte(a.retentionUntil, nativeNow)))
1742
- .limit(remaining + 1),
1309
+ .limit(input.limit + 1),
1743
1310
  false,
1744
1311
  );
1745
1312
 
1746
- if (attempts.length > remaining) hasMore = true;
1747
- const attemptCandidates = attempts.slice(0, remaining);
1748
-
1749
- remaining -= attemptCandidates.length;
1750
-
1751
- const charges = yield* selectRows(
1752
- transaction
1753
- .select({
1754
- action: c.action,
1755
- scopeKind: c.scopeKind,
1756
- scopeKey: c.scopeKey,
1757
- attemptId: c.attemptId,
1758
- })
1759
- .from(mapping.charge.table)
1760
- .where(and(eq(c.moduleId, input.moduleId), lte(c.retentionUntil, nativeNow)))
1761
- .limit(remaining + 1),
1762
- false,
1763
- );
1764
-
1765
- if (charges.length > remaining) hasMore = true;
1766
- const chargeCandidates = charges.slice(0, remaining);
1767
-
1768
- remaining -= chargeCandidates.length;
1769
- const sc = scopeColumns(mapping);
1770
-
1771
- const scopes = yield* selectRows(
1772
- transaction
1773
- .select({ action: sc.action, scopeKind: sc.scopeKind, scopeKey: sc.scopeKey })
1774
- .from(mapping.rateScope.table)
1775
- .where(
1776
- and(
1777
- eq(sc.moduleId, input.moduleId),
1778
- notExists(
1779
- transaction
1780
- .select({ one: sql`1` })
1781
- .from(mapping.charge.table)
1782
- .where(
1783
- and(
1784
- eq(c.moduleId, sc.moduleId),
1785
- eq(c.action, sc.action),
1786
- eq(c.scopeKind, sc.scopeKind),
1787
- eq(c.scopeKey, sc.scopeKey),
1788
- ),
1789
- ),
1790
- ),
1791
- ),
1792
- )
1793
- .limit(remaining + 1),
1794
- remaining === 0 ? false : configuration.locking,
1795
- );
1796
-
1797
- if (scopes.length > remaining) hasMore = true;
1798
- const selectedScopes = scopes.slice(0, remaining);
1799
-
1800
- remaining -= selectedScopes.length;
1801
- const selectedCharges: typeof chargeCandidates = [];
1802
-
1803
- for (const candidate of chargeCandidates) {
1804
- const locked = yield* selectRows(
1805
- transaction
1806
- .select({
1807
- action: c.action,
1808
- scopeKind: c.scopeKind,
1809
- scopeKey: c.scopeKey,
1810
- attemptId: c.attemptId,
1811
- })
1812
- .from(mapping.charge.table)
1813
- .where(
1814
- and(
1815
- eq(c.moduleId, input.moduleId),
1816
- eq(c.action, candidate.action),
1817
- eq(c.scopeKind, candidate.scopeKind),
1818
- eq(c.scopeKey, candidate.scopeKey),
1819
- eq(c.attemptId, candidate.attemptId),
1820
- lte(c.retentionUntil, nativeNow),
1821
- ),
1822
- )
1823
- .limit(1),
1824
- configuration.locking,
1825
- );
1826
-
1827
- if (locked[0] !== undefined) selectedCharges.push(locked[0] as any);
1828
- }
1313
+ const hasMore = attempts.length > input.limit;
1314
+ const attemptCandidates = attempts.slice(0, input.limit);
1829
1315
  const selectedAttempts: typeof attemptCandidates = [];
1830
1316
 
1831
1317
  for (const candidate of attemptCandidates) {
@@ -1849,25 +1335,12 @@ export const makePasswordKernel = <
1849
1335
 
1850
1336
  const prepared = prepare(
1851
1337
  {
1852
- removed: selectedAttempts.length + selectedCharges.length + selectedScopes.length,
1338
+ removed: selectedAttempts.length,
1853
1339
  hasMore,
1854
1340
  },
1855
1341
  journal,
1856
1342
  );
1857
1343
 
1858
- for (const row of selectedCharges)
1859
- yield* transaction
1860
- .delete(mapping.charge.table)
1861
- .where(
1862
- and(
1863
- eq(c.moduleId, input.moduleId),
1864
- eq(c.action, row.action),
1865
- eq(c.scopeKind, row.scopeKind),
1866
- eq(c.scopeKey, row.scopeKey),
1867
- eq(c.attemptId, row.attemptId),
1868
- lte(c.retentionUntil, nativeNow),
1869
- ),
1870
- );
1871
1344
  if (selectedAttempts.length > 0)
1872
1345
  yield* transaction.delete(mapping.attempt.table).where(
1873
1346
  and(
@@ -1879,28 +1352,6 @@ export const makePasswordKernel = <
1879
1352
  lte(a.retentionUntil, nativeNow),
1880
1353
  ),
1881
1354
  );
1882
- for (const row of selectedScopes)
1883
- yield* transaction.delete(mapping.rateScope.table).where(
1884
- and(
1885
- eq(sc.moduleId, input.moduleId),
1886
- eq(sc.action, row.action),
1887
- eq(sc.scopeKind, row.scopeKind),
1888
- eq(sc.scopeKey, row.scopeKey),
1889
- notExists(
1890
- transaction
1891
- .select({ one: sql`1` })
1892
- .from(mapping.charge.table)
1893
- .where(
1894
- and(
1895
- eq(c.moduleId, sc.moduleId),
1896
- eq(c.action, sc.action),
1897
- eq(c.scopeKind, sc.scopeKind),
1898
- eq(c.scopeKey, sc.scopeKey),
1899
- ),
1900
- ),
1901
- ),
1902
- ),
1903
- );
1904
1355
 
1905
1356
  return prepared;
1906
1357
  }),
@@ -1987,8 +1438,14 @@ export const makePasswordKernel = <
1987
1438
  )
1988
1439
  .limit(1))[0];
1989
1440
 
1441
+ // SQL equality follows column collation; compare the persisted values exactly.
1990
1442
  return (
1991
1443
  credential !== undefined &&
1444
+ credential[mapping.credential.credentialId] === revisions.credentialId &&
1445
+ credential[mapping.credential.credentialRevision] === revisions.credentialRevision &&
1446
+ credential[mapping.credential.verifierVersion] === revisions.verifierVersion &&
1447
+ credential[mapping.credential.verifier] === Redacted.value(input.replacement.verifier) &&
1448
+ credential[mapping.credential.normalization] === input.replacement.normalization &&
1992
1449
  (yield* mutationPostconditions(
1993
1450
  mapping,
1994
1451
  input,
@@ -2069,6 +1526,27 @@ export const makePasswordKernel = <
2069
1526
  );
2070
1527
  });
2071
1528
 
1529
+ const registerReplacement = (
1530
+ mapping: Mapping,
1531
+ input: PasswordMutationInput,
1532
+ nativeSubjectId: unknown,
1533
+ revisions: {
1534
+ readonly credentialRevision: SecurityRevision;
1535
+ readonly verifierVersion: SecurityRevision;
1536
+ readonly nextSecurityRevision: SecurityRevision;
1537
+ },
1538
+ now: number,
1539
+ ) =>
1540
+ registerFinal(
1541
+ checkMutationApplied(
1542
+ mapping,
1543
+ input,
1544
+ nativeSubjectId,
1545
+ { ...revisions, credentialId: input.credential!.credentialId },
1546
+ now,
1547
+ ),
1548
+ );
1549
+
2072
1550
  const replaceIn = Effect.fn("Drizzle.replaceIn")(function* <A>(
2073
1551
  mapping: Mapping,
2074
1552
  configuration: PasswordSqlConfiguration,
@@ -2102,6 +1580,7 @@ export const makePasswordKernel = <
2102
1580
  ))
2103
1581
  )
2104
1582
  return yield* unavailable();
1583
+ yield* registerReplacement(mapping, input, authority.nativeSubjectId, revisions, authority.now);
2105
1584
 
2106
1585
  return prepared;
2107
1586
  });