@yielded/auth-persistence 0.1.0-beta.22 → 0.1.0-beta.24

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 +11 -0
  2. package/dist/Adapter.d.mts +13 -5
  3. package/dist/Adapter.mjs +9 -2
  4. package/dist/OAuthPersistence.d.mts +87 -0
  5. package/dist/OAuthPersistence.mjs +125 -0
  6. package/dist/OAuthPersistence.mjs.map +1 -0
  7. package/dist/_virtual/_rolldown/runtime.mjs +13 -0
  8. package/dist/index.d.mts +3 -1
  9. package/dist/index.mjs +3 -1
  10. package/dist/internal/authority-snapshot.mjs +53 -0
  11. package/dist/internal/authority-snapshot.mjs.map +1 -0
  12. package/dist/internal/configuration.d.mts +1 -1
  13. package/dist/internal/configuration.mjs.map +1 -1
  14. package/dist/internal/models/oauth-connected-model.d.mts +294 -0
  15. package/dist/internal/models/oauth-connected-model.mjs +22 -0
  16. package/dist/internal/models/oauth-connected-model.mjs.map +1 -0
  17. package/dist/internal/models/oauth-model.d.mts +467 -0
  18. package/dist/internal/models/oauth-model.mjs +49 -0
  19. package/dist/internal/models/oauth-model.mjs.map +1 -0
  20. package/dist/internal/models/password-model.d.mts +4 -97
  21. package/dist/internal/models/password-model.mjs +0 -3
  22. package/dist/internal/models/password-model.mjs.map +1 -1
  23. package/dist/internal/native-sql-table.d.mts +34 -0
  24. package/dist/internal/native-sql-table.mjs +111 -0
  25. package/dist/internal/native-sql-table.mjs.map +1 -0
  26. package/dist/internal/oauth/accounts.d.mts +8 -0
  27. package/dist/internal/oauth/accounts.mjs +379 -0
  28. package/dist/internal/oauth/accounts.mjs.map +1 -0
  29. package/dist/internal/oauth/connected-access.d.mts +9 -0
  30. package/dist/internal/oauth/connected-access.mjs +229 -0
  31. package/dist/internal/oauth/connected-access.mjs.map +1 -0
  32. package/dist/internal/oauth/connected-collection.d.mts +7 -0
  33. package/dist/internal/oauth/connected-collection.mjs +214 -0
  34. package/dist/internal/oauth/connected-collection.mjs.map +1 -0
  35. package/dist/internal/oauth/connected-custody.d.mts +6 -0
  36. package/dist/internal/oauth/connected-custody.mjs +165 -0
  37. package/dist/internal/oauth/connected-custody.mjs.map +1 -0
  38. package/dist/internal/oauth/connected-flow.d.mts +7 -0
  39. package/dist/internal/oauth/connected-flow.mjs +324 -0
  40. package/dist/internal/oauth/connected-flow.mjs.map +1 -0
  41. package/dist/internal/oauth/connected-input.mjs +122 -0
  42. package/dist/internal/oauth/connected-input.mjs.map +1 -0
  43. package/dist/internal/oauth/connected-keys.d.mts +2 -0
  44. package/dist/internal/oauth/connected-keys.mjs +22 -0
  45. package/dist/internal/oauth/connected-keys.mjs.map +1 -0
  46. package/dist/internal/oauth/connected-maintenance.d.mts +9 -0
  47. package/dist/internal/oauth/connected-maintenance.mjs +226 -0
  48. package/dist/internal/oauth/connected-maintenance.mjs.map +1 -0
  49. package/dist/internal/oauth/connected-management.d.mts +9 -0
  50. package/dist/internal/oauth/connected-management.mjs +243 -0
  51. package/dist/internal/oauth/connected-management.mjs.map +1 -0
  52. package/dist/internal/oauth/connected-reference.mjs +63 -0
  53. package/dist/internal/oauth/connected-reference.mjs.map +1 -0
  54. package/dist/internal/oauth/connected-settlement.d.mts +8 -0
  55. package/dist/internal/oauth/connected-settlement.mjs +141 -0
  56. package/dist/internal/oauth/connected-settlement.mjs.map +1 -0
  57. package/dist/internal/oauth/connected-sign-in.d.mts +10 -0
  58. package/dist/internal/oauth/connected-sign-in.mjs +281 -0
  59. package/dist/internal/oauth/connected-sign-in.mjs.map +1 -0
  60. package/dist/internal/oauth/connected-state.d.mts +14 -0
  61. package/dist/internal/oauth/connected-state.mjs +469 -0
  62. package/dist/internal/oauth/connected-state.mjs.map +1 -0
  63. package/dist/internal/oauth/connected-target.d.mts +21 -0
  64. package/dist/internal/oauth/connected-target.mjs +117 -0
  65. package/dist/internal/oauth/connected-target.mjs.map +1 -0
  66. package/dist/internal/oauth/flow.d.mts +5 -0
  67. package/dist/internal/oauth/flow.mjs +279 -0
  68. package/dist/internal/oauth/flow.mjs.map +1 -0
  69. package/dist/internal/oauth/input.mjs +127 -0
  70. package/dist/internal/oauth/input.mjs.map +1 -0
  71. package/dist/internal/oauth/owner.d.mts +10 -0
  72. package/dist/internal/oauth/owner.mjs +10 -0
  73. package/dist/internal/oauth/owner.mjs.map +1 -0
  74. package/dist/internal/oauth/query-compiler.d.mts +7 -0
  75. package/dist/internal/oauth/query-compiler.mjs +9 -0
  76. package/dist/internal/oauth/query-compiler.mjs.map +1 -0
  77. package/dist/internal/oauth/registration.d.mts +7 -0
  78. package/dist/internal/oauth/registration.mjs +393 -0
  79. package/dist/internal/oauth/registration.mjs.map +1 -0
  80. package/dist/internal/oauth/state.mjs +107 -0
  81. package/dist/internal/oauth/state.mjs.map +1 -0
  82. package/dist/internal/oauth/target.d.mts +31 -0
  83. package/dist/internal/oauth/target.mjs +273 -0
  84. package/dist/internal/oauth/target.mjs.map +1 -0
  85. package/dist/internal/oauth-kernel.d.mts +1423 -0
  86. package/dist/internal/oauth-kernel.mjs +187 -0
  87. package/dist/internal/oauth-kernel.mjs.map +1 -0
  88. package/dist/internal/oauth-proxy.d.mts +31 -0
  89. package/dist/internal/oauth-proxy.mjs +129 -0
  90. package/dist/internal/oauth-proxy.mjs.map +1 -0
  91. package/dist/internal/passkey/target.d.mts +1 -1
  92. package/dist/internal/passkey-kernel.d.mts +5 -5
  93. package/dist/internal/passkeys.mjs +1 -1
  94. package/dist/internal/password-credentials.d.mts +11 -0
  95. package/dist/internal/password-credentials.mjs +121 -0
  96. package/dist/internal/password-credentials.mjs.map +1 -0
  97. package/dist/internal/password-kernel.d.mts +15 -28
  98. package/dist/internal/password-kernel.mjs +35 -236
  99. package/dist/internal/password-kernel.mjs.map +1 -1
  100. package/dist/internal/persistence.d.mts +2 -0
  101. package/dist/internal/persistence.mjs +5 -5
  102. package/dist/internal/persistence.mjs.map +1 -1
  103. package/dist/internal/query-operations.d.mts +2 -0
  104. package/dist/internal/session-kernel.d.mts +5 -3
  105. package/dist/internal/session-kernel.mjs +95 -29
  106. package/dist/internal/session-kernel.mjs.map +1 -1
  107. package/dist/internal/sql-oauth-connected-model.d.mts +29 -0
  108. package/dist/internal/sql-oauth-connected-model.mjs +7 -0
  109. package/dist/internal/sql-oauth-connected-model.mjs.map +1 -0
  110. package/dist/internal/sql-oauth-model.d.mts +60 -0
  111. package/dist/internal/sql-oauth-model.mjs +10 -0
  112. package/dist/internal/sql-oauth-model.mjs.map +1 -0
  113. package/dist/internal/sql-persistence.mjs +3 -1
  114. package/dist/internal/sql-persistence.mjs.map +1 -1
  115. package/dist/internal/sql-query.d.mts +12 -0
  116. package/dist/internal/sql-query.mjs +15 -18
  117. package/dist/internal/sql-query.mjs.map +1 -1
  118. package/dist/internal/sql-storage-validation.mjs +54 -0
  119. package/dist/internal/sql-storage-validation.mjs.map +1 -0
  120. package/dist/internal/storage-mapping.mjs +2 -67
  121. package/dist/internal/storage-mapping.mjs.map +1 -1
  122. package/dist/internal/storage-plans.d.mts +7 -0
  123. package/dist/internal/storage-plans.mjs +189 -0
  124. package/dist/internal/storage-plans.mjs.map +1 -0
  125. package/dist/internal/storage-tables.d.mts +0 -99
  126. package/dist/internal/storage-tables.mjs +0 -43
  127. package/dist/internal/storage-tables.mjs.map +1 -1
  128. package/dist/internal/storage-validation.d.mts +6 -1
  129. package/dist/internal/storage-validation.mjs +42 -1
  130. package/dist/internal/storage-validation.mjs.map +1 -1
  131. package/dist/internal/transaction-execution-kernel.d.mts +1 -1
  132. package/dist/internal/transaction-kernel.d.mts +1 -1
  133. package/dist/internal/transaction-kernel.mjs +3 -2
  134. package/dist/internal/transaction-kernel.mjs.map +1 -1
  135. package/package.json +1 -1
  136. package/src/Adapter.ts +97 -8
  137. package/src/OAuthPersistence.ts +427 -0
  138. package/src/index.ts +3 -0
  139. package/src/internal/authority-snapshot.ts +91 -0
  140. package/src/internal/configuration.ts +1 -8
  141. package/src/internal/models/oauth-connected-model.ts +396 -0
  142. package/src/internal/models/oauth-model.ts +708 -0
  143. package/src/internal/models/password-model.ts +0 -120
  144. package/src/internal/native-sql-table.ts +178 -0
  145. package/src/internal/oauth/accounts.ts +838 -0
  146. package/src/internal/oauth/connected-access.ts +388 -0
  147. package/src/internal/oauth/connected-collection.ts +345 -0
  148. package/src/internal/oauth/connected-custody.ts +308 -0
  149. package/src/internal/oauth/connected-flow.ts +562 -0
  150. package/src/internal/oauth/connected-input.ts +147 -0
  151. package/src/internal/oauth/connected-keys.ts +31 -0
  152. package/src/internal/oauth/connected-maintenance.ts +379 -0
  153. package/src/internal/oauth/connected-management.ts +391 -0
  154. package/src/internal/oauth/connected-reference.ts +135 -0
  155. package/src/internal/oauth/connected-settlement.ts +278 -0
  156. package/src/internal/oauth/connected-sign-in.ts +472 -0
  157. package/src/internal/oauth/connected-state.ts +924 -0
  158. package/src/internal/oauth/connected-target.ts +307 -0
  159. package/src/internal/oauth/flow.ts +505 -0
  160. package/src/internal/oauth/input.ts +201 -0
  161. package/src/internal/oauth/owner.ts +22 -0
  162. package/src/internal/oauth/query-compiler.ts +15 -0
  163. package/src/internal/oauth/registration.ts +676 -0
  164. package/src/internal/oauth/state.ts +218 -0
  165. package/src/internal/oauth/target.ts +803 -0
  166. package/src/internal/oauth-kernel.ts +294 -0
  167. package/src/internal/oauth-proxy.ts +176 -0
  168. package/src/internal/password-credentials.ts +245 -0
  169. package/src/internal/password-kernel.ts +98 -712
  170. package/src/internal/persistence.ts +5 -10
  171. package/src/internal/query-operations.ts +2 -0
  172. package/src/internal/session-kernel.ts +231 -81
  173. package/src/internal/sql-oauth-connected-model.ts +145 -0
  174. package/src/internal/sql-oauth-model.ts +263 -0
  175. package/src/internal/sql-persistence.ts +2 -0
  176. package/src/internal/sql-query.ts +16 -13
  177. package/src/internal/sql-storage-validation.ts +64 -0
  178. package/src/internal/storage-mapping.ts +0 -66
  179. package/src/internal/storage-plans.ts +218 -0
  180. package/src/internal/storage-tables.ts +0 -35
  181. package/src/internal/storage-validation.ts +58 -0
  182. package/src/internal/transaction-kernel.ts +6 -2
@@ -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", "passwordCommands");
150
145
  if (management) roles.push("passwordRegistrations");
151
146
  if (email) roles.push("emailCredentials", "emailCommands");
152
147
  if (phone) roles.push("phoneState");
@@ -60,10 +60,12 @@ export interface QueryOperations<
60
60
  readonly balancedD1And: Predicate<Fragment>;
61
61
  readonly asc: (value: unknown) => Fragment;
62
62
  readonly eq: Comparison<Fragment>;
63
+ readonly ne: Comparison<Fragment>;
63
64
  readonly gt: Comparison<Fragment>;
64
65
  readonly gte: Comparison<Fragment>;
65
66
  readonly lte: Comparison<Fragment>;
66
67
  readonly inArray: (value: unknown, values: ReadonlyArray<unknown>) => Fragment;
68
+ readonly isNotNull: (value: unknown) => Fragment;
67
69
  readonly isNull: (value: unknown) => Fragment;
68
70
  readonly notExists: (value: SqlExpression) => Fragment;
69
71
  readonly sql: {
@@ -8,6 +8,7 @@ import { reportPersistenceFailure } from "@yielded/auth/Persistence";
8
8
  import type { SubjectId, TokenDigest } from "@yielded/auth/Schema";
9
9
  import {
10
10
  assessAuthentication,
11
+ AuthenticationCapture,
11
12
  snapshotSessionAuthenticationProvenance,
12
13
  PendingAuthenticationInvalid,
13
14
  SessionConflict,
@@ -36,8 +37,10 @@ import {
36
37
  type SessionStepUpPersistence,
37
38
  } from "@yielded/auth/Sessions";
38
39
  import { Cause, Context, DateTime, Effect, Option, Schema } from "effect";
40
+ import type { SqlClient } from "effect/sql/SqlClient";
39
41
  import type * as SqlError from "effect/sql/SqlError";
40
42
 
43
+ import { makeAuthoritySnapshot } from "./authority-snapshot";
41
44
  import { PersistenceMappingError, isMappedConstraintConflict } from "./mapping-error";
42
45
  import type {
43
46
  AuthenticationAuthorityMapping,
@@ -50,6 +53,7 @@ import type {
50
53
  StatefulSessionMapping,
51
54
  } from "./models/session-model";
52
55
  import type { SessionStepUpMapping } from "./models/step-up-model";
56
+ import { makeNativeSqlTables, sqlMapping, type NativeSqlTables } from "./native-sql-table";
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 } from "./query-operations";
@@ -61,7 +65,21 @@ import {
61
65
  validateStepUpPlan,
62
66
  stepUpRotationMatches,
63
67
  } from "./step-up-state";
64
- import type { NativeDatabase } from "./transaction-kernel";
68
+ import { canJoinTextColumns } from "./storage-validation";
69
+ import { NativeDatabase } from "./transaction-kernel";
70
+
71
+ const decodeRevocationFlag = Schema.decodeUnknownEffect(Schema.Literals([0, 1]));
72
+
73
+ /** Reads need no transaction of their own. A caller-owned transaction still
74
+ * needs a savepoint so catching an SQL failure does not leave it aborted. */
75
+ const withVerificationSavepoint = Effect.fnUntraced(function* <A, E, R>(
76
+ effect: Effect.Effect<A, E, R>,
77
+ ): Effect.fn.Return<A, E | SqlError.SqlError, R | NativeDatabase> {
78
+ const { $client } = yield* NativeDatabase;
79
+ const transaction = yield* Effect.serviceOption($client.transactionService);
80
+
81
+ return yield* Option.isSome(transaction) ? $client.withTransaction(effect) : effect;
82
+ });
65
83
 
66
84
  type CommitMode = "interactive" | "synchronous";
67
85
 
@@ -139,7 +157,11 @@ type SqlStepUpRejectAbsence = SessionStepUpInvalid | StaleAuthentication;
139
157
 
140
158
  type SqlStepUpReadFailure = AdapterFailure | SqlStepUpRejectAbsence | SessionUnavailable;
141
159
 
142
- export const makeSessionKernel = (operations: QueryOperations) => {
160
+ export const makeSessionKernel = (
161
+ operations: QueryOperations,
162
+ nativeTables: (client: SqlClient, database: object) => NativeSqlTables = (client) =>
163
+ makeNativeSqlTables(client),
164
+ ) => {
143
165
  const { and, eq, gt, lte, inArray, or, sql, column, updateValues } = operations;
144
166
  const unavailable = () => SessionUnavailable.make({});
145
167
 
@@ -396,15 +418,6 @@ export const makeSessionKernel = (operations: QueryOperations) => {
396
418
  };
397
419
  });
398
420
 
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
421
  const validateEvidenceIn = Effect.fn("DrizzleSession.validateEvidence")(function* (
409
422
  mapping: AnyAuthorityMapping,
410
423
  evidence: AuthenticationEvidence,
@@ -417,6 +430,17 @@ export const makeSessionKernel = (operations: QueryOperations) => {
417
430
  locking,
418
431
  );
419
432
 
433
+ return yield* validateCapturedEvidence(mapping, evidence, captured);
434
+ });
435
+
436
+ const validateCapturedEvidence = Effect.fnUntraced(function* (
437
+ mapping: AnyAuthorityMapping,
438
+ evidence: AuthenticationEvidence,
439
+ captured: {
440
+ readonly subject: Record<string, unknown>;
441
+ readonly revision: AuthenticationRevision;
442
+ },
443
+ ) {
420
444
  if (
421
445
  captured.revision.securityRevision !== evidence.revision.securityRevision ||
422
446
  captured.revision.credentials.length !== evidence.revision.credentials.length ||
@@ -643,18 +667,37 @@ export const makeSessionKernel = (operations: QueryOperations) => {
643
667
  );
644
668
  const hooks = yield* LifecycleHooks;
645
669
 
670
+ const root = yield* NativeDatabase;
671
+ const sqlClient = root.$client.withoutTransforms();
672
+
673
+ const tables = yield* sqlMapping(() => nativeTables(sqlClient, root)).pipe(
674
+ Effect.mapError(unavailable),
675
+ );
676
+
677
+ const readSnapshot = makeAuthoritySnapshot(tables, mapping);
678
+
646
679
  const service = {
647
680
  capture: (subjectId: SubjectId, credentialIds: ReadonlyArray<string>) =>
648
681
  safeTransaction(
649
- inTransaction(database, captureIn(mapping, subjectId, credentialIds, true)),
682
+ readSnapshot(subjectId, credentialIds).pipe(
683
+ Effect.flatMap((value) =>
684
+ Effect.flatMap(mapping.subject.decodeRequirement(value.subject), (requirement) =>
685
+ Schema.decodeEffect(AuthenticationCapture)({
686
+ revision: value.revision,
687
+ requirement,
688
+ }),
689
+ ),
690
+ ),
691
+ ),
650
692
  ),
651
693
  requirements: (evidence: AuthenticationEvidence) =>
652
694
  safeTransaction(
653
- inTransaction(
654
- database,
655
- validateEvidenceIn(mapping, evidence, true).pipe(
656
- Effect.map((result) => result.requirement),
657
- ),
695
+ readSnapshot(
696
+ evidence.revision.subjectId,
697
+ evidence.revision.credentials.map((item) => item.credentialId),
698
+ ).pipe(
699
+ Effect.flatMap((captured) => validateCapturedEvidence(mapping, evidence, captured)),
700
+ Effect.map((value) => value.requirement),
658
701
  ),
659
702
  ),
660
703
  approve: <A>(
@@ -667,7 +710,20 @@ export const makeSessionKernel = (operations: QueryOperations) => {
667
710
  mapping.isConstraintConflict,
668
711
  Effect.gen(function* () {
669
712
  const journal = yield* CurrentCommitJournal;
670
- const { requirement } = yield* validateEvidenceIn(mapping, input.evidence, true);
713
+
714
+ // With row locks, lock the subject before its credentials in separate
715
+ // reads: a joined FOR UPDATE does not fix that order on every table
716
+ // layout, and a coordinated application may already hold the subject
717
+ // lock. Without row locks one native snapshot is already serialized.
718
+ const { requirement } = yield* options.locking
719
+ ? validateEvidenceIn(mapping, input.evidence, true)
720
+ : Effect.flatMap(
721
+ readSnapshot(
722
+ input.evidence.revision.subjectId,
723
+ input.evidence.revision.credentials.map((item) => item.credentialId),
724
+ ),
725
+ (captured) => validateCapturedEvidence(mapping, input.evidence, captured),
726
+ );
671
727
 
672
728
  if (input.pending === undefined) {
673
729
  const assessed = yield* assessAuthentication(input.evidence, requirement).pipe(
@@ -720,11 +776,24 @@ export const makeSessionKernel = (operations: QueryOperations) => {
720
776
  capture: (subjectId, credentialIds) =>
721
777
  service
722
778
  .capture(subjectId, credentialIds)
723
- .pipe(Effect.provideService(LifecycleHooks, hooks)),
779
+ .pipe(
780
+ Effect.provideService(NativeDatabase, root),
781
+ Effect.provideService(LifecycleHooks, hooks),
782
+ ),
724
783
  requirements: (evidence) =>
725
- service.requirements(evidence).pipe(Effect.provideService(LifecycleHooks, hooks)),
784
+ service
785
+ .requirements(evidence)
786
+ .pipe(
787
+ Effect.provideService(NativeDatabase, root),
788
+ Effect.provideService(LifecycleHooks, hooks),
789
+ ),
726
790
  approve: (input, prepare) =>
727
- service.approve(input, prepare).pipe(Effect.provideService(LifecycleHooks, hooks)),
791
+ service
792
+ .approve(input, prepare)
793
+ .pipe(
794
+ Effect.provideService(NativeDatabase, root),
795
+ Effect.provideService(LifecycleHooks, hooks),
796
+ ),
728
797
  } satisfies AuthenticationAuthority["Service"];
729
798
  });
730
799
 
@@ -991,8 +1060,33 @@ export const makeSessionKernel = (operations: QueryOperations) => {
991
1060
  );
992
1061
  const hooks = yield* LifecycleHooks;
993
1062
 
1063
+ const root = yield* NativeDatabase;
1064
+
1065
+ const tables = yield* sqlMapping(() =>
1066
+ nativeTables(root.$client.withoutTransforms(), root),
1067
+ ).pipe(Effect.mapError(unavailable));
1068
+
1069
+ const readSnapshot = makeAuthoritySnapshot(tables, mapping);
994
1070
  const c = sessionColumns(mapping);
995
1071
 
1072
+ const verification = yield* safeTransaction(
1073
+ Effect.gen(function* () {
1074
+ const { session, subject } = yield* sqlMapping(() => ({
1075
+ session: tables(mapping.session.table).as("auth_session"),
1076
+ subject: tables(mapping.subject.table).as("auth_subject"),
1077
+ }));
1078
+
1079
+ const [sessionOwner, subjectOwner] = yield* sqlMapping(() => [
1080
+ session.unencodedTextColumn?.(mapping.session.subjectId),
1081
+ subject.unencodedTextColumn?.(mapping.subject.id),
1082
+ ]);
1083
+
1084
+ const canJoinOwner = yield* canJoinTextColumns(sessionOwner, subjectOwner);
1085
+
1086
+ return { session, subject, canJoinOwner };
1087
+ }),
1088
+ );
1089
+
996
1090
  const persistence = {
997
1091
  establish: <A>(
998
1092
  input: Parameters<StatefulSessionPersistence<Claims>["establish"]>[0],
@@ -1006,23 +1100,29 @@ export const makeSessionKernel = (operations: QueryOperations) => {
1006
1100
  const transaction = yield* CurrentSessionSql;
1007
1101
  const journal = yield* CurrentCommitJournal;
1008
1102
 
1009
- const { nativeSubjectId, requirement } = yield* validateEvidenceIn(
1010
- mapping,
1011
- input.evidence,
1012
- true,
1013
- );
1103
+ // Ordered locks with row locks; one serialized snapshot without (see approve).
1104
+ const { nativeSubjectId, requirement } = yield* options.locking
1105
+ ? validateEvidenceIn(mapping, input.evidence, true)
1106
+ : Effect.flatMap(
1107
+ readSnapshot(
1108
+ input.evidence.revision.subjectId,
1109
+ input.evidence.revision.credentials.map((item) => item.credentialId),
1110
+ ),
1111
+ (captured) => validateCapturedEvidence(mapping, input.evidence, captured),
1112
+ );
1014
1113
 
1015
1114
  let flowInsert: unknown | undefined;
1016
1115
  let pendingExpiresAt: DateTime.Utc | undefined;
1017
1116
 
1018
- if (input.pending === undefined)
1019
- flowInsert = yield* ensureDirectFlowAvailable(
1020
- mapping,
1021
- input.evidence,
1022
- nativeSubjectId,
1023
- input.session.absoluteExpiresAt,
1024
- );
1025
- else {
1117
+ if (input.pending === undefined) {
1118
+ if (input.fresh !== true || input.handoffSourceSessionId !== undefined)
1119
+ flowInsert = yield* ensureDirectFlowAvailable(
1120
+ mapping,
1121
+ input.evidence,
1122
+ nativeSubjectId,
1123
+ input.session.absoluteExpiresAt,
1124
+ );
1125
+ } else {
1026
1126
  if (mapping.pending === undefined) return yield* invalidPending();
1027
1127
  const pending = yield* validatePending(mapping, input.pending);
1028
1128
 
@@ -1080,7 +1180,7 @@ export const makeSessionKernel = (operations: QueryOperations) => {
1080
1180
 
1081
1181
  if (flowInsert !== undefined)
1082
1182
  yield* transaction.insert(mapping.flow.table).values(flowInsert);
1083
- else
1183
+ else if (input.pending !== undefined)
1084
1184
  yield* consumePending(
1085
1185
  { ...mapping, pending: mapping.pending! },
1086
1186
  input.pending!,
@@ -1107,32 +1207,55 @@ export const makeSessionKernel = (operations: QueryOperations) => {
1107
1207
  return receipt;
1108
1208
  }).pipe(normalizeMutation(mapping)),
1109
1209
  ),
1110
- verify: (input: any) =>
1210
+ verify: (input: Parameters<StatefulSessionPersistence<Claims>["verify"]>[0]) =>
1111
1211
  safeTransaction(
1112
- inTransaction(
1113
- database,
1212
+ withVerificationSavepoint(
1114
1213
  Effect.gen(function* () {
1115
- const read = yield* CurrentSessionSql;
1214
+ const sql = root.$client.withoutTransforms();
1215
+ const { session, subject, canJoinOwner } = verification;
1116
1216
 
1117
- const rows = yield* read
1118
- .select()
1119
- .from(mapping.session.table)
1120
- .where(eq(c.digest, input.digest))
1121
- .limit(1);
1217
+ // A digest carries no owner. Custom codecs require decoding that
1218
+ // native ID first; never compare differently encoded ID columns.
1219
+ const owner = canJoinOwner
1220
+ ? undefined
1221
+ : (yield* database
1222
+ .select({ subjectId: c.subjectId })
1223
+ .from(mapping.session.table)
1224
+ .where(eq(c.digest, input.digest))
1225
+ .limit(1))[0];
1226
+
1227
+ if (!canJoinOwner && owner === undefined) return yield* invalidSession();
1228
+
1229
+ // The final statement reads both rows even after owner discovery,
1230
+ // so deletion, rotation or owner changes cannot reuse the first read.
1231
+ const rows = yield* sqlMapping(
1232
+ () => sql<
1233
+ Record<string, unknown>
1234
+ >`select ${session.fields("v_")}, ${subject.fields("s_")}
1235
+ from ${session.name} inner join ${subject.name} on
1236
+ ${subject.column(mapping.subject.id)} = ${
1237
+ owner === undefined
1238
+ ? session.column(mapping.session.subjectId)
1239
+ : subject.value(mapping.subject.id, owner.subjectId)
1240
+ }
1241
+ where ${session.column(mapping.session.digest)} = ${session.value(mapping.session.digest, input.digest)}
1242
+ limit 1`,
1243
+ ).pipe(Effect.flatten);
1122
1244
 
1123
1245
  const row = rows[0];
1124
1246
 
1125
1247
  if (row === undefined) return yield* invalidSession();
1126
- const record = yield* mapping.session.decode(row);
1248
+ const sessionRow = yield* sqlMapping(() => session.decode(row, "v_"));
1249
+ const subjectRow = yield* sqlMapping(() => subject.decode(row, "s_"));
1250
+ const record = yield* mapping.session.decode(sessionRow);
1127
1251
  const nativeSubjectId = yield* mapping.subjectId.toNative(record.subjectId);
1128
- const subjects = yield* readSubject(mapping, nativeSubjectId, false);
1129
- const subject = subjects[0];
1130
1252
  const now = yield* freshNow;
1131
1253
 
1132
1254
  if (
1133
- subject === undefined ||
1134
- !mapping.subject.isActiveStatus(subject[mapping.subject.status]) ||
1135
- subject[mapping.subject.securityRevision] !== record.securityRevision ||
1255
+ !mapping.subjectId.equals(nativeSubjectId, sessionRow[mapping.session.subjectId]) ||
1256
+ !mapping.subjectId.equals(nativeSubjectId, subjectRow[mapping.subject.id]) ||
1257
+ !mapping.subject.isActiveStatus(subjectRow[mapping.subject.status]) ||
1258
+ subjectRow[mapping.subject.securityRevision] !== record.securityRevision ||
1136
1259
  DateTime.toEpochMillis(now) >= DateTime.toEpochMillis(record.expiresAt) ||
1137
1260
  DateTime.toEpochMillis(now) >= DateTime.toEpochMillis(record.absoluteExpiresAt)
1138
1261
  )
@@ -1403,9 +1526,19 @@ export const makeSessionKernel = (operations: QueryOperations) => {
1403
1526
  return {
1404
1527
  statefulSessionPersistence: {
1405
1528
  establish: (input, prepare) =>
1406
- persistence.establish(input, prepare).pipe(Effect.provideService(LifecycleHooks, hooks)),
1529
+ persistence
1530
+ .establish(input, prepare)
1531
+ .pipe(
1532
+ Effect.provideService(NativeDatabase, root),
1533
+ Effect.provideService(LifecycleHooks, hooks),
1534
+ ),
1407
1535
  verify: (input) =>
1408
- persistence.verify(input).pipe(Effect.provideService(LifecycleHooks, hooks)),
1536
+ persistence
1537
+ .verify(input)
1538
+ .pipe(
1539
+ Effect.provideService(NativeDatabase, root),
1540
+ Effect.provideService(LifecycleHooks, hooks),
1541
+ ),
1409
1542
  rotate: (input, prepare) =>
1410
1543
  persistence.rotate(input, prepare).pipe(Effect.provideService(LifecycleHooks, hooks)),
1411
1544
  revokeDigest: (digest, prepare) =>
@@ -1447,50 +1580,62 @@ export const makeSessionKernel = (operations: QueryOperations) => {
1447
1580
  absoluteExpiresAt: column(mapping.tombstone.table, mapping.tombstone.absoluteExpiresAt),
1448
1581
  };
1449
1582
 
1583
+ const root = yield* NativeDatabase;
1584
+
1585
+ const tables = yield* sqlMapping(() =>
1586
+ nativeTables(root.$client.withoutTransforms(), root),
1587
+ ).pipe(Effect.mapError(unavailable));
1588
+
1450
1589
  const service = {
1451
1590
  verify: (session: SessionMetadata, _now: DateTime.Utc) =>
1452
1591
  safeTransaction(
1453
- inTransaction(
1454
- database,
1592
+ withVerificationSavepoint(
1455
1593
  Effect.gen(function* () {
1456
- const read = yield* CurrentSessionSql;
1594
+ const sql = root.$client.withoutTransforms();
1457
1595
  const nativeSubjectId = yield* mapping.subjectId.toNative(session.subjectId);
1458
1596
  const nativeSessionId = yield* mapping.sessionId.toNative(session.sessionId);
1459
- const subjects = yield* readSubject(mapping, nativeSubjectId, false);
1460
- const row = subjects[0];
1597
+ const readNow = yield* freshNow;
1598
+
1599
+ // Independent bound IDs preserve each column's physical type and
1600
+ // codec. Both authority and revocation come from this statement.
1601
+ const { subjectTable, rows } = yield* sqlMapping(() => {
1602
+ const subjectTable = tables(mapping.subject.table).as("auth_subject");
1603
+ const revoked = tables(mapping.tombstone.table).as("auth_revocation");
1604
+
1605
+ return {
1606
+ subjectTable,
1607
+ rows: sql<Record<string, unknown>>`
1608
+ select ${subjectTable.fields("s_")},
1609
+ case when exists (
1610
+ select 1 from ${revoked.name}
1611
+ where ${revoked.column(mapping.tombstone.subjectId)} = ${revoked.value(mapping.tombstone.subjectId, nativeSubjectId)}
1612
+ and ${revoked.column(mapping.tombstone.sessionId)} = ${revoked.value(mapping.tombstone.sessionId, nativeSessionId)}
1613
+ and ${revoked.column(mapping.tombstone.absoluteExpiresAt)} > ${revoked.value(mapping.tombstone.absoluteExpiresAt, mapping.tombstone.encodeInstant(readNow))}
1614
+ ) then 1 else 0 end as auth_revoked
1615
+ from ${subjectTable.name}
1616
+ where ${subjectTable.column(mapping.subject.id)} = ${subjectTable.value(mapping.subject.id, nativeSubjectId)}
1617
+ limit 1`,
1618
+ };
1619
+ });
1461
1620
 
1462
- if (
1463
- row === undefined ||
1464
- !mapping.subject.isActiveStatus(row[mapping.subject.status]) ||
1465
- row[mapping.subject.securityRevision] !== session.securityRevision
1466
- )
1467
- return yield* invalidSession();
1621
+ const result = (yield* rows)[0];
1622
+
1623
+ if (result === undefined) return yield* invalidSession();
1624
+ const row = yield* sqlMapping(() => subjectTable.decode(result, "s_"));
1625
+ const revoked = yield* decodeRevocationFlag(result.auth_revoked);
1468
1626
  const authoritativeNow = yield* freshNow;
1469
1627
 
1470
1628
  if (
1629
+ !mapping.subjectId.equals(nativeSubjectId, row[mapping.subject.id]) ||
1630
+ !mapping.subject.isActiveStatus(row[mapping.subject.status]) ||
1631
+ row[mapping.subject.securityRevision] !== session.securityRevision ||
1632
+ revoked === 1 ||
1471
1633
  DateTime.toEpochMillis(authoritativeNow) >=
1472
1634
  DateTime.toEpochMillis(session.expiresAt) ||
1473
1635
  DateTime.toEpochMillis(authoritativeNow) >=
1474
1636
  DateTime.toEpochMillis(session.absoluteExpiresAt)
1475
1637
  )
1476
1638
  return yield* invalidSession();
1477
-
1478
- const revoked = yield* read
1479
- .select({ sessionId: tombstone.sessionId })
1480
- .from(mapping.tombstone.table)
1481
- .where(
1482
- and(
1483
- eq(tombstone.subjectId, nativeSubjectId),
1484
- eq(tombstone.sessionId, nativeSessionId),
1485
- gt(
1486
- tombstone.absoluteExpiresAt,
1487
- mapping.tombstone.encodeInstant(authoritativeNow),
1488
- ),
1489
- ),
1490
- )
1491
- .limit(1);
1492
-
1493
- if (revoked.length > 0) return yield* invalidSession();
1494
1639
  }),
1495
1640
  ),
1496
1641
  ),
@@ -1616,7 +1761,12 @@ export const makeSessionKernel = (operations: QueryOperations) => {
1616
1761
 
1617
1762
  return {
1618
1763
  verify: (session, now) =>
1619
- service.verify(session, now).pipe(Effect.provideService(LifecycleHooks, hooks)),
1764
+ service
1765
+ .verify(session, now)
1766
+ .pipe(
1767
+ Effect.provideService(NativeDatabase, root),
1768
+ Effect.provideService(LifecycleHooks, hooks),
1769
+ ),
1620
1770
  revoke: (input, prepare) =>
1621
1771
  service.revoke(input, prepare).pipe(Effect.provideService(LifecycleHooks, hooks)),
1622
1772
  revokeAll: (input, prepare) =>
@@ -0,0 +1,145 @@
1
+ import * as Shared from "./models/oauth-connected-model";
2
+ import type { SqlTableModel } from "./sql-oauth-model";
3
+ import type { Fragment as SQL, Table } from "./sql-table";
4
+
5
+ export {
6
+ type OAuthConnectedAction,
7
+ type OAuthConnectedOrderCodec,
8
+ type OAuthConnectedPolicyInput,
9
+ requiredOAuthConnectedConstraints,
10
+ requiredOAuthConnectedRevocationConstraints,
11
+ } from "./models/oauth-connected-model";
12
+
13
+ export type OAuthConnectedSubjectTable<S extends Table> = Shared.OAuthConnectedSubjectTable<
14
+ SqlTableModel<S>,
15
+ SQL
16
+ >;
17
+
18
+ export type OAuthConnectedFlowTable<F extends Table, N> = Shared.OAuthConnectedFlowTable<
19
+ SqlTableModel<F>,
20
+ N
21
+ >;
22
+
23
+ export type OAuthConnectedGrantTable<G extends Table, N> = Shared.OAuthConnectedGrantTable<
24
+ SqlTableModel<G>,
25
+ N
26
+ >;
27
+
28
+ /** Also stores a permanent provider/issuer scope anchor. clientKey must fit 52
29
+ * ASCII characters. Empty clientRegistrationId is reserved for that anchor;
30
+ * actual profile registrations are nonempty. Scope counter stays zero. */
31
+ export type OAuthConnectedClientRegistrationTable<C extends Table> =
32
+ Shared.OAuthConnectedClientRegistrationTable<SqlTableModel<C>>;
33
+
34
+ /** Remote authority: no local-subject column or subject-scoped key. Retain this
35
+ * anchor/cutoff when a legitimately released tuple gains a different local owner. */
36
+ export type OAuthConnectedCohortTable<C extends Table> = Shared.OAuthConnectedCohortTable<
37
+ SqlTableModel<C>
38
+ >;
39
+
40
+ export type OAuthConnectedAdmissionTable<A extends Table, N> = Shared.OAuthConnectedAdmissionTable<
41
+ SqlTableModel<A>,
42
+ N
43
+ >;
44
+
45
+ export type OAuthConnectedCommandTable<C extends Table, N> = Shared.OAuthConnectedCommandTable<
46
+ SqlTableModel<C>,
47
+ N
48
+ >;
49
+
50
+ export type OAuthConnectedRevocationJobTable<
51
+ J extends Table,
52
+ N,
53
+ > = Shared.OAuthConnectedRevocationJobTable<SqlTableModel<J>, N>;
54
+
55
+ export type OAuthConnectedPolicyGuardTable<
56
+ T extends Table,
57
+ N,
58
+ > = Shared.OAuthConnectedPolicyGuardTable<SqlTableModel<T>, N, SQL>;
59
+
60
+ export type OAuthConnectedPolicyGuard<N> = Shared.OAuthConnectedPolicyGuard<N, SQL, Table>;
61
+
62
+ export const oauthConnectedPolicyGuard = <T extends Table, N>(
63
+ input: OAuthConnectedPolicyGuardTable<T, N>,
64
+ ): OAuthConnectedPolicyGuard<N> =>
65
+ Shared.oauthConnectedPolicyGuard<SqlTableModel<T>, N, SQL>(input);
66
+
67
+ export type OAuthConnectedSqlPolicy<N> = Shared.OAuthConnectedSqlPolicy<N, SQL, Table>;
68
+
69
+ /** Existing ownership only; workers never allocate or acquire a tuple. */
70
+ export type OAuthConnectedOwnershipRead<
71
+ T extends Table,
72
+ O extends Table,
73
+ N,
74
+ > = Shared.OAuthConnectedOwnershipRead<SqlTableModel<T>, SqlTableModel<O>, N, SQL>;
75
+
76
+ export type OAuthConnectedAuthorityMapping<
77
+ T extends Table,
78
+ O extends Table,
79
+ F extends Table,
80
+ G extends Table,
81
+ C extends Table,
82
+ H extends Table,
83
+ N,
84
+ > = Shared.OAuthConnectedAuthorityMapping<
85
+ SqlTableModel<T>,
86
+ SqlTableModel<O>,
87
+ SqlTableModel<F>,
88
+ SqlTableModel<G>,
89
+ SqlTableModel<C>,
90
+ SqlTableModel<H>,
91
+ N,
92
+ SQL
93
+ >;
94
+
95
+ export type OAuthConnectedMapping<
96
+ S extends Table,
97
+ AC extends Table,
98
+ T extends Table,
99
+ O extends Table,
100
+ F extends Table,
101
+ G extends Table,
102
+ C extends Table,
103
+ H extends Table,
104
+ A extends Table,
105
+ D extends Table,
106
+ N,
107
+ J extends Table = never,
108
+ > = Shared.OAuthConnectedMapping<
109
+ SqlTableModel<S>,
110
+ SqlTableModel<AC>,
111
+ SqlTableModel<T>,
112
+ SqlTableModel<O>,
113
+ SqlTableModel<F>,
114
+ SqlTableModel<G>,
115
+ SqlTableModel<C>,
116
+ SqlTableModel<H>,
117
+ SqlTableModel<A>,
118
+ SqlTableModel<D>,
119
+ N,
120
+ SqlTableModel<J>,
121
+ SQL,
122
+ Table,
123
+ SqlTableModel<Table>
124
+ >;
125
+
126
+ export type OAuthConnectedRevocationMapping<
127
+ T extends Table,
128
+ O extends Table,
129
+ F extends Table,
130
+ G extends Table,
131
+ C extends Table,
132
+ H extends Table,
133
+ J extends Table,
134
+ N,
135
+ > = Shared.OAuthConnectedRevocationMapping<
136
+ SqlTableModel<T>,
137
+ SqlTableModel<O>,
138
+ SqlTableModel<F>,
139
+ SqlTableModel<G>,
140
+ SqlTableModel<C>,
141
+ SqlTableModel<H>,
142
+ SqlTableModel<J>,
143
+ N,
144
+ SQL
145
+ >;