@yielded/auth-persistence 0.1.0-beta.22 → 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 +14 -11
  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
@@ -29,6 +29,7 @@ import {
29
29
  import { randomId } from "./crypto";
30
30
  import { makeEmailKernel, CurrentEmailSql, type EmailSqlDatabase } from "./email-kernel";
31
31
  import { PersistenceMappingError } from "./mapping-error";
32
+ import type { NativeSqlTables } from "./native-sql-table";
32
33
  import {
33
34
  makePasswordKernel,
34
35
  CurrentPasswordSql,
@@ -69,6 +70,7 @@ export interface Backend<T extends object, R> {
69
70
  readonly describe: (table: T) => StorageTable;
70
71
  readonly acquire: Effect.Effect<object, PersistenceConfigurationError, R | SqlClient.SqlClient>;
71
72
  readonly operations: QueryOperations;
73
+ readonly nativeTables: (client: SqlClient.SqlClient, database: object) => NativeSqlTables;
72
74
  readonly passkeys: (input: {
73
75
  readonly storage: MappingInput;
74
76
  readonly namespace: string;
@@ -112,9 +114,9 @@ export const createPersistence = <T extends object, R>(
112
114
  backend: Backend<T, R>,
113
115
  ): PersistenceApi<T, R> => {
114
116
  const proofKernel = makeProofKernel(backend.operations);
115
- const passwordKernel = makePasswordKernel(backend.operations, proofKernel);
117
+ const passwordKernel = makePasswordKernel(backend.operations, proofKernel, backend.nativeTables);
116
118
  const emailKernel = makeEmailKernel(backend.operations, proofKernel);
117
- const sessionKernel = makeSessionKernel(backend.operations);
119
+ const sessionKernel = makeSessionKernel(backend.operations, backend.nativeTables);
118
120
  const transactionKernel = makeTransactionKernel(backend.operations);
119
121
  const executionKernel = makeTransactionExecutionKernel(transactionKernel);
120
122
  const phoneKernel = makePhoneKernel(backend.operations, transactionKernel);
@@ -139,14 +141,7 @@ export const createPersistence = <T extends object, R>(
139
141
 
140
142
  const roles: StorageRole[] = ["identifiers", "credentials", "sessions", "sessionFlows"];
141
143
 
142
- if (password)
143
- roles.push(
144
- "passwords",
145
- "passwordAttempts",
146
- "passwordScopes",
147
- "passwordCharges",
148
- "passwordCommands",
149
- );
144
+ if (password) roles.push("passwords", "passwordAttempts", "passwordCommands");
150
145
  if (management) roles.push("passwordRegistrations");
151
146
  if (email) roles.push("emailCredentials", "emailCommands");
152
147
  if (phone) roles.push("phoneState");
@@ -36,8 +36,10 @@ import {
36
36
  type SessionStepUpPersistence,
37
37
  } from "@yielded/auth/Sessions";
38
38
  import { Cause, Context, DateTime, Effect, Option, Schema } from "effect";
39
+ import type { SqlClient } from "effect/sql/SqlClient";
39
40
  import type * as SqlError from "effect/sql/SqlError";
40
41
 
42
+ import { makeAuthoritySnapshot } from "./authority-snapshot";
41
43
  import { PersistenceMappingError, isMappedConstraintConflict } from "./mapping-error";
42
44
  import type {
43
45
  AuthenticationAuthorityMapping,
@@ -50,6 +52,7 @@ import type {
50
52
  StatefulSessionMapping,
51
53
  } from "./models/session-model";
52
54
  import type { SessionStepUpMapping } from "./models/step-up-model";
55
+ import { makeNativeSqlTables, sqlMapping, type NativeSqlTables } from "./native-sql-table";
53
56
  /* oxlint-disable no-explicit-any -- existing storage kernels erase foreign table shapes; domain errors remain typed. */
54
57
  import type { QueryFailure } from "./query-operations";
55
58
  import type { QueryOperations } from "./query-operations";
@@ -61,7 +64,7 @@ import {
61
64
  validateStepUpPlan,
62
65
  stepUpRotationMatches,
63
66
  } from "./step-up-state";
64
- import type { NativeDatabase } from "./transaction-kernel";
67
+ import { NativeDatabase } from "./transaction-kernel";
65
68
 
66
69
  type CommitMode = "interactive" | "synchronous";
67
70
 
@@ -139,7 +142,11 @@ type SqlStepUpRejectAbsence = SessionStepUpInvalid | StaleAuthentication;
139
142
 
140
143
  type SqlStepUpReadFailure = AdapterFailure | SqlStepUpRejectAbsence | SessionUnavailable;
141
144
 
142
- export const makeSessionKernel = (operations: QueryOperations) => {
145
+ export const makeSessionKernel = (
146
+ operations: QueryOperations,
147
+ nativeTables: (client: SqlClient, database: object) => NativeSqlTables = (client) =>
148
+ makeNativeSqlTables(client),
149
+ ) => {
143
150
  const { and, eq, gt, lte, inArray, or, sql, column, updateValues } = operations;
144
151
  const unavailable = () => SessionUnavailable.make({});
145
152
 
@@ -396,15 +403,6 @@ export const makeSessionKernel = (operations: QueryOperations) => {
396
403
  };
397
404
  });
398
405
 
399
- const captureIn = Effect.fn("DrizzleSession.capture")(function* (
400
- mapping: AnyAuthorityMapping,
401
- subjectId: SubjectId,
402
- credentialIds: ReadonlyArray<string>,
403
- locking: boolean,
404
- ) {
405
- return (yield* captureDetailsIn(mapping, subjectId, credentialIds, locking)).revision;
406
- });
407
-
408
406
  const validateEvidenceIn = Effect.fn("DrizzleSession.validateEvidence")(function* (
409
407
  mapping: AnyAuthorityMapping,
410
408
  evidence: AuthenticationEvidence,
@@ -417,6 +415,17 @@ export const makeSessionKernel = (operations: QueryOperations) => {
417
415
  locking,
418
416
  );
419
417
 
418
+ return yield* validateCapturedEvidence(mapping, evidence, captured);
419
+ });
420
+
421
+ const validateCapturedEvidence = Effect.fnUntraced(function* (
422
+ mapping: AnyAuthorityMapping,
423
+ evidence: AuthenticationEvidence,
424
+ captured: {
425
+ readonly subject: Record<string, unknown>;
426
+ readonly revision: AuthenticationRevision;
427
+ },
428
+ ) {
420
429
  if (
421
430
  captured.revision.securityRevision !== evidence.revision.securityRevision ||
422
431
  captured.revision.credentials.length !== evidence.revision.credentials.length ||
@@ -643,18 +652,28 @@ export const makeSessionKernel = (operations: QueryOperations) => {
643
652
  );
644
653
  const hooks = yield* LifecycleHooks;
645
654
 
655
+ const root = yield* NativeDatabase;
656
+ const sqlClient = root.$client.withoutTransforms();
657
+
658
+ const tables = yield* sqlMapping(() => nativeTables(sqlClient, root)).pipe(
659
+ Effect.mapError(unavailable),
660
+ );
661
+
662
+ const readSnapshot = makeAuthoritySnapshot(tables, mapping);
663
+
646
664
  const service = {
647
665
  capture: (subjectId: SubjectId, credentialIds: ReadonlyArray<string>) =>
648
666
  safeTransaction(
649
- inTransaction(database, captureIn(mapping, subjectId, credentialIds, true)),
667
+ readSnapshot(subjectId, credentialIds).pipe(Effect.map((value) => value.revision)),
650
668
  ),
651
669
  requirements: (evidence: AuthenticationEvidence) =>
652
670
  safeTransaction(
653
- inTransaction(
654
- database,
655
- validateEvidenceIn(mapping, evidence, true).pipe(
656
- Effect.map((result) => result.requirement),
657
- ),
671
+ readSnapshot(
672
+ evidence.revision.subjectId,
673
+ evidence.revision.credentials.map((item) => item.credentialId),
674
+ ).pipe(
675
+ Effect.flatMap((captured) => validateCapturedEvidence(mapping, evidence, captured)),
676
+ Effect.map((value) => value.requirement),
658
677
  ),
659
678
  ),
660
679
  approve: <A>(
@@ -667,7 +686,20 @@ export const makeSessionKernel = (operations: QueryOperations) => {
667
686
  mapping.isConstraintConflict,
668
687
  Effect.gen(function* () {
669
688
  const journal = yield* CurrentCommitJournal;
670
- const { requirement } = yield* validateEvidenceIn(mapping, input.evidence, true);
689
+
690
+ // With row locks, lock the subject before its credentials in separate
691
+ // reads: a joined FOR UPDATE does not fix that order on every table
692
+ // layout, and a coordinated application may already hold the subject
693
+ // lock. Without row locks one native snapshot is already serialized.
694
+ const { requirement } = yield* options.locking
695
+ ? validateEvidenceIn(mapping, input.evidence, true)
696
+ : Effect.flatMap(
697
+ readSnapshot(
698
+ input.evidence.revision.subjectId,
699
+ input.evidence.revision.credentials.map((item) => item.credentialId),
700
+ ),
701
+ (captured) => validateCapturedEvidence(mapping, input.evidence, captured),
702
+ );
671
703
 
672
704
  if (input.pending === undefined) {
673
705
  const assessed = yield* assessAuthentication(input.evidence, requirement).pipe(
@@ -720,11 +752,24 @@ export const makeSessionKernel = (operations: QueryOperations) => {
720
752
  capture: (subjectId, credentialIds) =>
721
753
  service
722
754
  .capture(subjectId, credentialIds)
723
- .pipe(Effect.provideService(LifecycleHooks, hooks)),
755
+ .pipe(
756
+ Effect.provideService(NativeDatabase, root),
757
+ Effect.provideService(LifecycleHooks, hooks),
758
+ ),
724
759
  requirements: (evidence) =>
725
- service.requirements(evidence).pipe(Effect.provideService(LifecycleHooks, hooks)),
760
+ service
761
+ .requirements(evidence)
762
+ .pipe(
763
+ Effect.provideService(NativeDatabase, root),
764
+ Effect.provideService(LifecycleHooks, hooks),
765
+ ),
726
766
  approve: (input, prepare) =>
727
- service.approve(input, prepare).pipe(Effect.provideService(LifecycleHooks, hooks)),
767
+ service
768
+ .approve(input, prepare)
769
+ .pipe(
770
+ Effect.provideService(NativeDatabase, root),
771
+ Effect.provideService(LifecycleHooks, hooks),
772
+ ),
728
773
  } satisfies AuthenticationAuthority["Service"];
729
774
  });
730
775
 
@@ -991,6 +1036,13 @@ export const makeSessionKernel = (operations: QueryOperations) => {
991
1036
  );
992
1037
  const hooks = yield* LifecycleHooks;
993
1038
 
1039
+ const root = yield* NativeDatabase;
1040
+
1041
+ const tables = yield* sqlMapping(() =>
1042
+ nativeTables(root.$client.withoutTransforms(), root),
1043
+ ).pipe(Effect.mapError(unavailable));
1044
+
1045
+ const readSnapshot = makeAuthoritySnapshot(tables, mapping);
994
1046
  const c = sessionColumns(mapping);
995
1047
 
996
1048
  const persistence = {
@@ -1006,11 +1058,16 @@ export const makeSessionKernel = (operations: QueryOperations) => {
1006
1058
  const transaction = yield* CurrentSessionSql;
1007
1059
  const journal = yield* CurrentCommitJournal;
1008
1060
 
1009
- const { nativeSubjectId, requirement } = yield* validateEvidenceIn(
1010
- mapping,
1011
- input.evidence,
1012
- true,
1013
- );
1061
+ // Ordered locks with row locks; one serialized snapshot without (see approve).
1062
+ const { nativeSubjectId, requirement } = yield* options.locking
1063
+ ? validateEvidenceIn(mapping, input.evidence, true)
1064
+ : Effect.flatMap(
1065
+ readSnapshot(
1066
+ input.evidence.revision.subjectId,
1067
+ input.evidence.revision.credentials.map((item) => item.credentialId),
1068
+ ),
1069
+ (captured) => validateCapturedEvidence(mapping, input.evidence, captured),
1070
+ );
1014
1071
 
1015
1072
  let flowInsert: unknown | undefined;
1016
1073
  let pendingExpiresAt: DateTime.Utc | undefined;
@@ -1403,7 +1460,12 @@ export const makeSessionKernel = (operations: QueryOperations) => {
1403
1460
  return {
1404
1461
  statefulSessionPersistence: {
1405
1462
  establish: (input, prepare) =>
1406
- persistence.establish(input, prepare).pipe(Effect.provideService(LifecycleHooks, hooks)),
1463
+ persistence
1464
+ .establish(input, prepare)
1465
+ .pipe(
1466
+ Effect.provideService(NativeDatabase, root),
1467
+ Effect.provideService(LifecycleHooks, hooks),
1468
+ ),
1407
1469
  verify: (input) =>
1408
1470
  persistence.verify(input).pipe(Effect.provideService(LifecycleHooks, hooks)),
1409
1471
  rotate: (input, prepare) =>
@@ -2,6 +2,7 @@ import { Effect } from "effect";
2
2
  import { SqlClient } from "effect/sql";
3
3
 
4
4
  import { PersistenceConfigurationError, type PersistenceApi } from "./configuration";
5
+ import { makeNativeSqlTables } from "./native-sql-table";
5
6
  import { makeComposedPasskeys } from "./passkeys";
6
7
  import { createPersistence } from "./persistence";
7
8
  import { makeSqlDatabase, sqlQueryOperations } from "./sql-query";
@@ -23,6 +24,7 @@ export const AuthPersistence: PersistenceApi<Table> & { readonly table: typeof t
23
24
  unique: table.unique,
24
25
  }),
25
26
  operations: sqlQueryOperations,
27
+ nativeTables: (client) => makeNativeSqlTables(client),
26
28
  passkeys: makeComposedPasskeys(sqlQueryOperations),
27
29
  acquire: Effect.gen(function* () {
28
30
  const sql = yield* SqlClient.SqlClient;
@@ -373,12 +373,6 @@ export const makeMappings = Effect.fnUntraced(function* (input: MappingInput) {
373
373
  authorityCredential: authorityCredential(),
374
374
  constraints: requiredPasswordConstraints,
375
375
  isCommandConflict: () => false,
376
- isRateScopeConflict: () => false,
377
- scopeKeys: ({ moduleId, action, identifier, subjectId }) => ({
378
- action: tuple([moduleId, action]),
379
- identifier: tuple([identifier.namespace, identifier.value]),
380
- ...(subjectId === undefined ? {} : { subject: subjectId }),
381
- }),
382
376
  encodeInstant: instant,
383
377
  decodeInstant: readInstant,
384
378
  allocateAttemptId: allocate.pipe(Effect.map(PasswordAttemptId.make)),
@@ -485,29 +479,6 @@ export const makeMappings = Effect.fnUntraced(function* (input: MappingInput) {
485
479
  state,
486
480
  }),
487
481
  },
488
- rateScope: {
489
- ...mapped("passwordScopes"),
490
- moduleId: "moduleId",
491
- action: "action",
492
- scopeKind: "scopeKind",
493
- scopeKey: "scopeKey",
494
- encodeInsert: (row) => ({ ...row }),
495
- },
496
- charge: {
497
- ...mapped("passwordCharges"),
498
- moduleId: "moduleId",
499
- action: "action",
500
- scopeKind: "scopeKind",
501
- scopeKey: "scopeKey",
502
- attemptId: "attemptId",
503
- occurredAt: "occurredAt",
504
- retentionUntil: "retentionUntil",
505
- encodeInsert: (row) => ({
506
- ...row,
507
- occurredAt: instant(row.occurredAtMillis),
508
- retentionUntil: instant(row.retentionUntilMillis),
509
- }),
510
- },
511
482
  command: {
512
483
  ...mapped("passwordCommands"),
513
484
  moduleId: "moduleId",
@@ -78,21 +78,6 @@ export const storageTables = {
78
78
  },
79
79
  [["moduleId", "attemptId"]],
80
80
  ),
81
- passwordScopes: spec({ moduleId: text, action: text, scopeKind: text, scopeKey: text }, [
82
- ["moduleId", "action", "scopeKind", "scopeKey"],
83
- ]),
84
- passwordCharges: spec(
85
- {
86
- moduleId: text,
87
- action: text,
88
- scopeKind: text,
89
- scopeKey: text,
90
- attemptId: text,
91
- occurredAt: integer,
92
- retentionUntil: integer,
93
- },
94
- [["moduleId", "action", "scopeKind", "scopeKey", "attemptId"]],
95
- ),
96
81
  passwordCommands: spec(
97
82
  {
98
83
  moduleId: text,