@yielded/auth-persistence 0.1.0-beta.30 → 0.1.0-beta.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/README.md +11 -7
  2. package/dist/Adapter.d.mts +1 -1
  3. package/dist/Adapter.mjs +8 -8
  4. package/dist/OAuthPersistence.mjs +10 -5
  5. package/dist/OAuthPersistence.mjs.map +1 -1
  6. package/dist/internal/configuration.d.mts +49 -13
  7. package/dist/internal/configuration.mjs.map +1 -1
  8. package/dist/internal/email-native.mjs +10 -2
  9. package/dist/internal/email-native.mjs.map +1 -1
  10. package/dist/internal/email-policy.mjs +3 -2
  11. package/dist/internal/email-policy.mjs.map +1 -1
  12. package/dist/internal/models/oauth-model.d.mts +4 -2
  13. package/dist/internal/models/oauth-model.mjs.map +1 -1
  14. package/dist/internal/models/password-model.d.mts +2 -1
  15. package/dist/internal/models/password-model.mjs.map +1 -1
  16. package/dist/internal/oauth/native-accounts.d.mts +1 -1
  17. package/dist/internal/oauth/native-accounts.mjs +6 -6
  18. package/dist/internal/oauth/native-accounts.mjs.map +1 -1
  19. package/dist/internal/oauth/native-connected.d.mts +1 -1
  20. package/dist/internal/oauth/native-connected.mjs +1 -1
  21. package/dist/internal/oauth/native-registration.mjs +19 -8
  22. package/dist/internal/oauth/native-registration.mjs.map +1 -1
  23. package/dist/internal/oauth/native-sign-in.d.mts +1 -1
  24. package/dist/internal/oauth/native-sign-in.mjs +1 -1
  25. package/dist/internal/oauth/native-state.mjs +21 -11
  26. package/dist/internal/oauth/native-state.mjs.map +1 -1
  27. package/dist/internal/oauth/state.d.mts +1 -1
  28. package/dist/internal/oauth/state.mjs +1 -1
  29. package/dist/internal/oauth-managed.mjs +182 -0
  30. package/dist/internal/oauth-managed.mjs.map +1 -0
  31. package/dist/internal/passkey-managed.mjs +5 -3
  32. package/dist/internal/passkey-managed.mjs.map +1 -1
  33. package/dist/internal/passkey-native-registration.mjs +1 -1
  34. package/dist/internal/passkey-native-state.mjs +33 -23
  35. package/dist/internal/passkey-native-state.mjs.map +1 -1
  36. package/dist/internal/passkey-native-write.mjs +3 -3
  37. package/dist/internal/passkey-native-write.mjs.map +1 -1
  38. package/dist/internal/passkey-native.d.mts +15 -7
  39. package/dist/internal/passkey-native.mjs +2 -1
  40. package/dist/internal/passkey-native.mjs.map +1 -1
  41. package/dist/internal/passkey-policy.mjs +9 -7
  42. package/dist/internal/passkey-policy.mjs.map +1 -1
  43. package/dist/internal/password-native.mjs +22 -5
  44. package/dist/internal/password-native.mjs.map +1 -1
  45. package/dist/internal/password-policy.mjs +7 -6
  46. package/dist/internal/password-policy.mjs.map +1 -1
  47. package/dist/internal/password-registration-native.d.mts +1 -1
  48. package/dist/internal/persistence.d.mts +3 -0
  49. package/dist/internal/persistence.mjs +99 -42
  50. package/dist/internal/persistence.mjs.map +1 -1
  51. package/dist/internal/phone-native.mjs +1 -1
  52. package/dist/internal/phone-native.mjs.map +1 -1
  53. package/dist/internal/registration.mjs +17 -8
  54. package/dist/internal/registration.mjs.map +1 -1
  55. package/dist/internal/session-native-authority.mjs +8 -6
  56. package/dist/internal/session-native-authority.mjs.map +1 -1
  57. package/dist/internal/session-native-login.d.mts +2 -2
  58. package/dist/internal/session-native-login.mjs +4 -2
  59. package/dist/internal/session-native-login.mjs.map +1 -1
  60. package/dist/internal/session-native-pending-read.mjs +26 -20
  61. package/dist/internal/session-native-pending-read.mjs.map +1 -1
  62. package/dist/internal/session-native-record.mjs +6 -4
  63. package/dist/internal/session-native-record.mjs.map +1 -1
  64. package/dist/internal/session-native-state.mjs +24 -8
  65. package/dist/internal/session-native-state.mjs.map +1 -1
  66. package/dist/internal/session-native-stateful.d.mts +2 -2
  67. package/dist/internal/session-native-stateful.mjs +67 -38
  68. package/dist/internal/session-native-stateful.mjs.map +1 -1
  69. package/dist/internal/session-native-step-up.d.mts +2 -2
  70. package/dist/internal/session-native-step-up.mjs +17 -19
  71. package/dist/internal/session-native-step-up.mjs.map +1 -1
  72. package/dist/internal/session-native-validity.mjs +3 -1
  73. package/dist/internal/session-native-validity.mjs.map +1 -1
  74. package/dist/internal/sql-authority.mjs +5 -1
  75. package/dist/internal/sql-authority.mjs.map +1 -1
  76. package/dist/internal/sql-storage-validation.mjs +2 -1
  77. package/dist/internal/sql-storage-validation.mjs.map +1 -1
  78. package/dist/internal/sql-table.d.mts +1 -0
  79. package/dist/internal/sql-table.mjs.map +1 -1
  80. package/dist/internal/sqlite-policy-guard.mjs +23 -0
  81. package/dist/internal/sqlite-policy-guard.mjs.map +1 -0
  82. package/dist/internal/step-up-state.mjs +19 -6
  83. package/dist/internal/step-up-state.mjs.map +1 -1
  84. package/dist/internal/storage-mapping.d.mts +9 -5
  85. package/dist/internal/storage-mapping.mjs +17 -5
  86. package/dist/internal/storage-mapping.mjs.map +1 -1
  87. package/dist/internal/storage-tables.d.mts +237 -0
  88. package/dist/internal/storage-tables.mjs +68 -0
  89. package/dist/internal/storage-tables.mjs.map +1 -1
  90. package/dist/internal/storage-validation.d.mts +3 -1
  91. package/dist/internal/storage-validation.mjs +70 -17
  92. package/dist/internal/storage-validation.mjs.map +1 -1
  93. package/dist/internal/totp-native.mjs +1 -1
  94. package/package.json +1 -1
  95. package/src/OAuthPersistence.ts +7 -2
  96. package/src/internal/configuration.ts +72 -15
  97. package/src/internal/email-native.ts +16 -6
  98. package/src/internal/email-policy.ts +12 -3
  99. package/src/internal/models/oauth-model.ts +7 -1
  100. package/src/internal/models/password-model.ts +2 -1
  101. package/src/internal/oauth/native-accounts.ts +3 -3
  102. package/src/internal/oauth/native-registration.ts +17 -6
  103. package/src/internal/oauth/native-state.ts +38 -18
  104. package/src/internal/oauth-managed.ts +277 -0
  105. package/src/internal/passkey-managed.ts +8 -2
  106. package/src/internal/passkey-native-state.ts +59 -52
  107. package/src/internal/passkey-native-write.ts +2 -2
  108. package/src/internal/passkey-native.ts +4 -2
  109. package/src/internal/passkey-policy.ts +13 -9
  110. package/src/internal/password-native.ts +45 -11
  111. package/src/internal/password-policy.ts +16 -8
  112. package/src/internal/persistence.ts +360 -204
  113. package/src/internal/phone-native.ts +1 -1
  114. package/src/internal/registration.ts +53 -40
  115. package/src/internal/session-native-authority.ts +26 -8
  116. package/src/internal/session-native-login.ts +9 -4
  117. package/src/internal/session-native-pending-read.ts +46 -33
  118. package/src/internal/session-native-record.ts +6 -4
  119. package/src/internal/session-native-state.ts +32 -10
  120. package/src/internal/session-native-stateful.ts +147 -52
  121. package/src/internal/session-native-step-up.ts +31 -24
  122. package/src/internal/session-native-validity.ts +3 -1
  123. package/src/internal/sql-authority.ts +5 -1
  124. package/src/internal/sql-storage-validation.ts +53 -46
  125. package/src/internal/sql-table.ts +1 -0
  126. package/src/internal/sqlite-policy-guard.ts +34 -0
  127. package/src/internal/step-up-state.ts +43 -6
  128. package/src/internal/storage-mapping.ts +40 -4
  129. package/src/internal/storage-tables.ts +76 -0
  130. package/src/internal/storage-validation.ts +127 -27
@@ -428,7 +428,7 @@ export const makeNativePhoneServices = Effect.fnUntraced(function* (
428
428
  const requirement =
429
429
  yield* Schema.decodeEffect(AuthenticationRequirement)(originalRequirement);
430
430
 
431
- if (!passwordEvidenceSatisfiedAt(evidence, requirement, captured.now))
431
+ if (!(yield* passwordEvidenceSatisfiedAt(evidence, requirement, captured.now)))
432
432
  return yield* rejected();
433
433
  }
434
434
  if (evidence.proofs.length === 0) return yield* rejected();
@@ -1,12 +1,10 @@
1
1
  import type { LifecycleHooks } from "@yielded/auth/Hooks";
2
- import type { LoginIdentifier } from "@yielded/auth/Identity";
3
2
  import { PasswordUnavailable } from "@yielded/auth/Password";
4
- import type { SubjectId } from "@yielded/auth/Schema";
5
3
  import { SecurityRevision } from "@yielded/auth/Sessions";
6
4
  import { type Context, Crypto, Effect, Schema, Option, Cause } from "effect";
7
5
  import { SqlClient } from "effect/sql/SqlClient";
8
6
 
9
- import { PersistenceConfigurationError } from "./configuration";
7
+ import { PersistenceConfigurationError, type SubjectProvisioning } from "./configuration";
10
8
  import { randomId } from "./crypto";
11
9
  import type { AnyPasswordPersistenceMapping } from "./models/password-model";
12
10
  import type { NativeSqlTables } from "./native-sql-table";
@@ -15,13 +13,9 @@ import {
15
13
  PasswordIdentifierTaken,
16
14
  } from "./password-registration-native";
17
15
  import type { PasswordRegistrationAuthority } from "./registration-contract";
18
- import { makeSqlCommitExecutor, CurrentSqlCommit } from "./sql-commit";
16
+ import { makeSqlCommitExecutor, CurrentSqlCommit, SqlBatchCommit } from "./sql-commit";
19
17
 
20
- type CreateSubject = (input: {
21
- readonly requestId: string;
22
- readonly identifier: LoginIdentifier;
23
- readonly registration: unknown;
24
- }) => Effect.Effect<SubjectId, PasswordUnavailable>;
18
+ const SubjectValues = Schema.Record(Schema.String, Schema.Unknown);
25
19
 
26
20
  /** Application provisioning and the unverified identifier/password binding share
27
21
  * one physical owner. Identifier uniqueness arbitrates registration; correlation
@@ -34,49 +28,56 @@ export const makeRegistrationAuthority = Effect.fnUntraced(function* <R>(
34
28
  ): Effect.fn.Return<
35
29
  PasswordRegistrationAuthority<unknown>,
36
30
  PersistenceConfigurationError,
37
- LifecycleHooks | SqlClient | Crypto.Crypto | R
31
+ LifecycleHooks | SqlClient | Crypto.Crypto | SqlBatchCommit | R
38
32
  > {
39
33
  const sql = (yield* SqlClient).withoutTransforms();
40
34
  const crypto = yield* Crypto.Crypto;
41
35
  const executor = yield* makeSqlCommitExecutor(() => PasswordUnavailable.make({}));
42
36
  const parent = yield* Effect.serviceOption(CurrentSqlCommit);
43
- const store = yield* makeNativePasswordRegistrationStore(tables, mapping);
37
+ const batch = yield* SqlBatchCommit;
38
+ const store = yield* makeNativePasswordRegistrationStore(tables, mapping, batch !== undefined);
44
39
  // The strategy has already decoded its registration Schema. Only its callback
45
40
  // signature is erased by the heterogeneous strategy service map.
46
- const creators = (yield* provisioning) as Readonly<Record<string, CreateSubject>>;
41
+ const creators = (yield* provisioning) as Readonly<Record<string, SubjectProvisioning<unknown>>>;
47
42
  const createSubject = creators[strategy];
48
43
 
49
44
  if (createSubject === undefined)
50
45
  return yield* PersistenceConfigurationError.make({
51
46
  reason: `Missing subject provisioning for ${strategy}`,
52
47
  });
48
+ if (batch !== undefined && typeof createSubject === "function")
49
+ return yield* PersistenceConfigurationError.make({
50
+ reason: `D1 subject provisioning for ${strategy} requires { values }; return the new subject row instead of inserting it`,
51
+ });
53
52
  const s = mapping.subject;
54
53
  const subject = tables(s.table);
55
54
  const lock = sql.onDialectOrElse({ sqlite: () => sql``, orElse: () => sql`for update` });
56
55
 
57
56
  const run = <A, E, R2>(work: Effect.Effect<A, E, R2>, suppressed: Effect.Effect<A, E, R2>) =>
58
- Option.isSome(parent)
59
- ? executor.run(work)
60
- : executor
61
- .operation(
62
- work.pipe(
63
- Effect.mapError((error) =>
64
- error instanceof PasswordIdentifierTaken ? error : PasswordUnavailable.make({}),
57
+ batch !== undefined
58
+ ? executor.batch(work).pipe(Effect.provideService(SqlBatchCommit, batch))
59
+ : Option.isSome(parent)
60
+ ? executor.run(work)
61
+ : executor
62
+ .operation(
63
+ work.pipe(
64
+ Effect.mapError((error) =>
65
+ error instanceof PasswordIdentifierTaken ? error : PasswordUnavailable.make({}),
66
+ ),
65
67
  ),
66
- ),
67
- )
68
- .pipe(
69
- Effect.catchCause((cause) =>
70
- cause.reasons.length > 0 &&
71
- cause.reasons.every(
72
- (reason) =>
73
- Cause.isFailReason(reason) && reason.error instanceof PasswordIdentifierTaken,
74
- )
75
- ? executor.run(suppressed, "statement")
76
- : Effect.failCause(cause),
77
- ),
78
- Effect.mapError(() => PasswordUnavailable.make({})),
79
- );
68
+ )
69
+ .pipe(
70
+ Effect.catchCause((cause) =>
71
+ cause.reasons.length > 0 &&
72
+ cause.reasons.every(
73
+ (reason) =>
74
+ Cause.isFailReason(reason) && reason.error instanceof PasswordIdentifierTaken,
75
+ )
76
+ ? executor.run(suppressed, "statement")
77
+ : Effect.failCause(cause),
78
+ ),
79
+ Effect.mapError(() => PasswordUnavailable.make({})),
80
+ );
80
81
 
81
82
  return {
82
83
  register: (input, project) =>
@@ -86,26 +87,38 @@ export const makeRegistrationAuthority = Effect.fnUntraced(function* <R>(
86
87
  ? executor.run(store.prepare({ _tag: "Suppressed" }, project), "statement")
87
88
  : run(
88
89
  Effect.gen(function* () {
89
- const subjectId = yield* createSubject({
90
+ const provisioningInput = {
90
91
  requestId: input.requestId,
91
92
  identifier: input.identifier,
92
93
  registration: input.registration,
93
- });
94
+ };
94
95
 
95
- const nativeId = yield* mapping.subjectId.toNative(subjectId);
96
+ const row = yield* Effect.gen(function* () {
97
+ if (typeof createSubject !== "function")
98
+ return yield* createSubject
99
+ .values(provisioningInput)
100
+ .pipe(Effect.flatMap(Schema.decodeEffect(SubjectValues)));
101
+ const subjectId = yield* createSubject(provisioningInput);
102
+ const nativeId = yield* mapping.subjectId.toNative(subjectId);
96
103
 
97
- const rows =
98
- yield* sql`select ${subject.fields("registration_subject_")} from ${subject.name} where ${subject.column(s.id)} = ${subject.value(s.id, nativeId)} limit 2 ${lock}`;
104
+ const rows =
105
+ yield* sql`select ${subject.fields("registration_subject_")} from ${subject.name} where ${subject.column(s.id)} = ${subject.value(s.id, nativeId)} limit 2 ${lock}`;
99
106
 
100
- if (rows.length !== 1) return yield* PasswordUnavailable.make({});
101
- const row = subject.decode(rows[0]!, "registration_subject_");
107
+ if (rows.length !== 1) return yield* PasswordUnavailable.make({});
108
+
109
+ return subject.decode(rows[0]!, "registration_subject_");
110
+ });
102
111
 
103
112
  if (!s.isActiveStatus(row[s.status])) return yield* PasswordUnavailable.make({});
113
+ const subjectId = yield* mapping.subjectId.toSubject(row[s.id]);
104
114
 
105
115
  const securityRevision = yield* Schema.decodeUnknownEffect(SecurityRevision)(
106
116
  row[s.securityRevision],
107
117
  );
108
118
 
119
+ if (typeof createSubject !== "function")
120
+ yield* store.stage(sql`${subject.insert(row)}`);
121
+
109
122
  const identifierRevision = SecurityRevision.make(yield* randomId);
110
123
  const credentialId = yield* randomId;
111
124
  const credentialRevision = SecurityRevision.make(yield* randomId);
@@ -1,4 +1,5 @@
1
1
  import { type LifecycleHooks } from "@yielded/auth/Hooks";
2
+ import { AuthenticationClock } from "@yielded/auth/Operations";
2
3
  import {
3
4
  type AuthenticationAuthority,
4
5
  StaleAuthentication,
@@ -51,11 +52,14 @@ export const sessionEvidenceDeadline = Effect.fnUntraced(function* (
51
52
  if (!assessed.satisfied) return yield* StaleAuthentication.make({});
52
53
 
53
54
  const boundaries = [
54
- ...new Set(
55
- evidence.proofs.map(
55
+ ...new Set([
56
+ ...evidence.proofs.map(
56
57
  (proof) => DateTime.toEpochMillis(proof.verifiedAt) + requirement.maximumAgeMillis,
57
58
  ),
58
- ),
59
+ ...(evidence.completionExpiresAt === undefined
60
+ ? []
61
+ : [DateTime.toEpochMillis(evidence.completionExpiresAt)]),
62
+ ]),
59
63
  ]
60
64
  .filter((n) => n > now)
61
65
  .sort((a, b) => a - b);
@@ -139,11 +143,12 @@ export const makeNativeAuthenticationAuthorityServices = Effect.fnUntraced(funct
139
143
  });
140
144
 
141
145
  const native = <A, E, R>(work: Effect.Effect<A, E, R>) =>
142
- batch === undefined
146
+ (batch === undefined
143
147
  ? executor.operation(normalizeSessionOperation(work))
144
148
  : executor
145
149
  .operationBatch(normalizeSessionOperation(work))
146
- .pipe(Effect.provideService(SqlBatchCommit, batch));
150
+ .pipe(Effect.provideService(SqlBatchCommit, batch))
151
+ ).pipe(Effect.provideService(AuthenticationClock, state.clockPolicy));
147
152
 
148
153
  const authenticationAuthority: AuthenticationAuthority["Service"] = {
149
154
  capture: (subjectId, credentialIds) =>
@@ -183,10 +188,20 @@ export const makeNativeAuthenticationAuthorityServices = Effect.fnUntraced(funct
183
188
  current.now,
184
189
  );
185
190
 
186
- const expires = DateTime.toEpochMillis(input.expiresAt),
191
+ const issued = DateTime.toEpochMillis(input.issuedAt),
192
+ expires = DateTime.toEpochMillis(input.expiresAt),
187
193
  absolute = DateTime.toEpochMillis(input.absoluteExpiresAt);
188
194
 
189
- if (current.now >= expires || expires > absolute)
195
+ if (
196
+ Math.max(issued, DateTime.toEpochMillis(input.now)) - current.now >
197
+ state.clockPolicy.futureToleranceMillis ||
198
+ input.evidence.proofs.some(
199
+ (proof) => DateTime.toEpochMillis(proof.verifiedAt) > issued,
200
+ ) ||
201
+ issued >= expires ||
202
+ current.now >= expires ||
203
+ expires > absolute
204
+ )
190
205
  return yield* StaleAuthentication.make({});
191
206
 
192
207
  const pendingExpiry =
@@ -195,7 +210,9 @@ export const makeNativeAuthenticationAuthorityServices = Effect.fnUntraced(funct
195
210
  : yield* consume(input.pending, input.evidence, current.native);
196
211
 
197
212
  const deadline = Math.min(assessment.validUntil, expires, pendingExpiry ?? Infinity);
198
- const condition = sql`${state.authorityCondition(current.revision, current.native)} and ${state.policyCondition(current.row, current.native)} and ${now} >= ${current.now} and ${now} < ${deadline}`;
213
+
214
+ yield* state.guardPolicy(current);
215
+ const condition = sql`${state.authorityCondition(current.revision, current.native)} and ${now} >= ${current.now} and ${now} < ${deadline}`;
199
216
 
200
217
  if (batch !== undefined) {
201
218
  yield* appendSqlBatchStatement(sqlBatchAssertion(sql, condition));
@@ -224,6 +241,7 @@ export const makeNativeAuthenticationAuthorityServices = Effect.fnUntraced(funct
224
241
  if (rows.length !== 1) return yield* PendingAuthenticationInvalid.make({});
225
242
  }
226
243
  }).pipe(
244
+ Effect.provideService(AuthenticationClock, state.clockPolicy),
227
245
  Effect.mapError((cause) =>
228
246
  PersistenceMappingError.make({ operation: "decode", cause }),
229
247
  ),
@@ -1,10 +1,12 @@
1
1
  import type { LifecycleHooks } from "@yielded/auth/Hooks";
2
+ import { AuthenticationClock } from "@yielded/auth/Operations";
2
3
  import {
3
4
  PendingAuthenticationInvalid,
4
5
  StaleAuthentication,
5
6
  SessionConflict,
6
7
  type PendingAuthentication,
7
8
  type PendingAuthenticationRecord,
9
+ type SessionUnavailable,
8
10
  } from "@yielded/auth/Sessions";
9
11
  import { DateTime, Effect, Option } from "effect";
10
12
  import type { SqlClient } from "effect/sql/SqlClient";
@@ -46,7 +48,7 @@ export const makeNativePendingAuthenticationServices = Effect.fnUntraced(functio
46
48
  mapping: NativePendingAuthenticationMapping<Claims>,
47
49
  ): Effect.fn.Return<
48
50
  { readonly pendingAuthentication: PendingAuthentication<Claims> },
49
- never,
51
+ SessionUnavailable,
50
52
  SqlClient | LifecycleHooks | SqlBatchCommit
51
53
  > {
52
54
  const batch = yield* SqlBatchCommit;
@@ -67,11 +69,12 @@ export const makeNativePendingAuthenticationServices = Effect.fnUntraced(functio
67
69
  work: Effect.Effect<A, E, R>,
68
70
  mode: "transaction" | "statement" = "transaction",
69
71
  ) =>
70
- batch === undefined
72
+ (batch === undefined
71
73
  ? executor.operation(normalizeSessionOperation(work), mode)
72
74
  : executor
73
75
  .operationBatch(normalizeSessionOperation(work))
74
- .pipe(Effect.provideService(SqlBatchCommit, batch));
76
+ .pipe(Effect.provideService(SqlBatchCommit, batch))
77
+ ).pipe(Effect.provideService(AuthenticationClock, state.clockPolicy));
75
78
 
76
79
  const verifyPayload = Effect.fnUntraced(function* (
77
80
  stored: import("./session-native-pending").StoredSessionPending,
@@ -129,7 +132,9 @@ export const makeNativePendingAuthenticationServices = Effect.fnUntraced(functio
129
132
 
130
133
  const record: PendingAuthenticationRecord<Claims> = { ...input, version };
131
134
  const snapshot = yield* mapping.login.encode(record);
132
- const condition = sql`${state.authorityCondition(current.revision, current.native)} and ${state.policyCondition(current.row, current.native)} and ${now} >= ${current.now} and ${now} < ${expires}`;
135
+
136
+ yield* state.guardPolicy(current);
137
+ const condition = sql`${state.authorityCondition(current.revision, current.native)} and ${now} >= ${current.now} and ${now} < ${expires}`;
133
138
 
134
139
  if (batch !== undefined)
135
140
  yield* appendSqlBatchStatement(sqlBatchAssertion(sql, condition));
@@ -1,4 +1,4 @@
1
- import type { TokenDigest } from "@yielded/auth/Schema";
1
+ import type { SubjectId, TokenDigest } from "@yielded/auth/Schema";
2
2
  import type { PendingAuthenticationKind } from "@yielded/auth/Sessions";
3
3
  import { Effect } from "effect";
4
4
 
@@ -6,13 +6,15 @@ import type { NativeSqlTables } from "./native-sql-table";
6
6
  import { makeNativeSessionPending } from "./session-native-pending";
7
7
  import {
8
8
  makeNativeSessionAuthorityState,
9
+ sessionInvariant,
10
+ sessionUnavailable,
9
11
  type NativeSessionAuthorityMapping,
10
12
  } from "./session-native-state";
11
13
  import { exactSqlText } from "./sql-change";
14
+ import { canJoinTextColumns } from "./storage-validation";
12
15
 
13
- /** Both semantic pending ports use the same kind-bound physical snapshot.
14
- * Distinct native ID encodings use their own bound reads; joined rows still
15
- * validate each decoded owner before returning current policy. */
16
+ /** Both ports re-read the pending row with current authority in one snapshot.
17
+ * Discovery only selects the independently bound owner for incompatible IDs. */
16
18
  export const makeNativeSessionPendingReader = Effect.fnUntraced(function* (
17
19
  tables: NativeSqlTables,
18
20
  mapping: NativeSessionAuthorityMapping & Parameters<typeof makeNativeSessionPending>[1],
@@ -21,47 +23,58 @@ export const makeNativeSessionPendingReader = Effect.fnUntraced(function* (
21
23
  const state = yield* makeNativeSessionAuthorityState(tables, mapping, batch);
22
24
  const pending = yield* makeNativeSessionPending(tables, mapping, batch);
23
25
 
26
+ const joinedIds = yield* canJoinTextColumns([
27
+ pending.table.unencodedTextColumn?.(pending.p.subjectId),
28
+ state.subject.unencodedTextColumn?.(mapping.subject.id),
29
+ state.credential.unencodedTextColumn?.(mapping.credential.subjectId),
30
+ ]).pipe(Effect.mapError(sessionUnavailable));
31
+
24
32
  const read = Effect.fnUntraced(function* (kind: PendingAuthenticationKind, digest: TokenDigest) {
25
33
  const { sql, now } = state,
26
34
  { p, table } = pending;
27
35
 
28
- const compatible =
29
- table.unencodedTextColumn?.(p.subjectId) !== undefined &&
30
- state.subject.unencodedTextColumn?.(mapping.subject.id) !== undefined &&
31
- state.credential.unencodedTextColumn?.(mapping.credential.subjectId) !== undefined;
36
+ let discoveredSubjectId: SubjectId | undefined;
37
+ let nativeId: unknown;
32
38
 
33
- if (compatible) {
34
- const pt = table.as("authentication_pending"),
35
- st = state.joinedSubject,
36
- ct = state.joinedCredential,
37
- s = mapping.subject,
38
- c = mapping.credential;
39
+ if (!joinedIds) {
40
+ const selected = yield* pending.read(kind, digest);
39
41
 
40
- const rows =
41
- yield* sql`select ${pt.fields("p_")}, ${st.fields("s_")}, ${ct.fields("c_")}, ${now} as engine_now from ${pt.name} join ${st.name} on ${exactSqlText(sql, st.column(s.id), pt.column(p.subjectId))} and ${state.activeSubject(st)} left join ${ct.name} on ${exactSqlText(sql, ct.column(c.subjectId), pt.column(p.subjectId))} and ${state.activeCredential(ct)} where ${pending.predicate(pt, kind, digest)} limit 65`;
42
+ if (selected === undefined) return undefined;
43
+ discoveredSubjectId = selected.record.subjectId;
44
+ nativeId = yield* mapping.subjectId.toNative(discoveredSubjectId);
45
+ }
42
46
 
43
- if (rows[0] === undefined) return undefined;
44
- const record = yield* pending.decode(kind, pt.decode(rows[0], "p_"));
45
- const owner = yield* mapping.subjectId.toNative(record.subjectId);
47
+ const pt = table.as("authentication_pending"),
48
+ st = state.joinedSubject,
49
+ ct = state.joinedCredential,
50
+ s = mapping.subject,
51
+ c = mapping.credential;
46
52
 
47
- const authority = yield* state.decode(
48
- record.subjectId,
49
- owner,
50
- st.decode(rows[0], "s_"),
51
- rows.map((row) => ct.decode(row, "c_")),
52
- rows[0].engine_now,
53
- );
53
+ const bindOwner = (table: typeof pt, key: string) =>
54
+ joinedIds
55
+ ? exactSqlText(sql, table.column(key), pt.column(p.subjectId))
56
+ : state.id(table, key, nativeId);
54
57
 
55
- return authority === undefined ? undefined : { record, authority };
56
- }
57
- const selected = yield* pending.read(kind, digest);
58
+ const rows =
59
+ yield* sql`select ${pt.fields("p_")}, ${st.fields("s_")}, ${ct.fields("c_")}, ${now} as engine_now from ${pt.name} join ${st.name} on ${bindOwner(st, s.id)} and ${state.activeSubject(st)} left join ${ct.name} on ${bindOwner(ct, c.subjectId)} and ${state.activeCredential(ct)} where ${pending.predicate(pt, kind, digest)}${joinedIds ? sql`` : sql` and ${bindOwner(pt, p.subjectId)}`} limit 65`;
60
+
61
+ if (rows[0] === undefined) return undefined;
62
+ const record = yield* pending.decode(kind, pt.decode(rows[0], "p_"));
63
+
64
+ sessionInvariant(joinedIds || record.subjectId === discoveredSubjectId);
65
+ const owner = yield* mapping.subjectId.toNative(record.subjectId);
58
66
 
59
- if (selected === undefined) return undefined;
60
- const authority = yield* state.read(selected.record.subjectId);
67
+ const authority = yield* state.decode(
68
+ record.subjectId,
69
+ owner,
70
+ rows[0],
71
+ rows.map((row) => ct.decode(row, "c_")),
72
+ rows[0].engine_now,
73
+ );
61
74
 
62
- return authority === undefined || authority.now >= selected.record.expiresAtMillis
75
+ return authority === undefined || authority.now >= record.expiresAtMillis
63
76
  ? undefined
64
- : { record: selected.record, authority };
77
+ : { record, authority };
65
78
  });
66
79
 
67
80
  return { state, pending, read };
@@ -1,3 +1,4 @@
1
+ import { AuthenticationClock } from "@yielded/auth/Operations";
1
2
  import { TokenDigest } from "@yielded/auth/Schema";
2
3
  import {
3
4
  SessionId,
@@ -50,14 +51,14 @@ export const makeConditionalSqlInsert = Effect.fnUntraced(function* () {
50
51
  };
51
52
  });
52
53
 
53
- /** Security columns identify the row and own its live time bounds. In particular
54
- * issuedAt may be assigned by UPDATE's database clock without rewriting a JSON
55
- * copy of prepared metadata. Claims/provenance decoding remains consumer-owned. */
54
+ /** Security columns own live time bounds and commit-time issuedAt adjustments,
55
+ * independently of prepared JSON metadata. Claims/provenance decoding remains consumer-owned. */
56
56
  export const makeNativeSessionRecords = Effect.fnUntraced(function* <Claims>(
57
57
  tables: NativeSqlTables,
58
58
  mapping: NativeSessionRecordMapping<Claims>,
59
59
  ) {
60
60
  const sql = (yield* SqlClient).withoutTransforms();
61
+ const { futureToleranceMillis } = yield* AuthenticationClock;
61
62
 
62
63
  const s = mapping.session,
63
64
  table = tables(s.table),
@@ -71,7 +72,8 @@ export const makeNativeSessionRecords = Effect.fnUntraced(function* <Claims>(
71
72
 
72
73
  const live = (t: SqlTable) =>
73
74
  sql.and([
74
- sql`${millis(t, s.issuedAt)} <= ${now}`,
75
+ sql`${millis(t, s.issuedAt)} <= ${now} + ${futureToleranceMillis}`,
76
+ sql`${millis(t, s.issuedAt)} < ${millis(t, s.expiresAt)}`,
75
77
  sql`${millis(t, s.expiresAt)} > ${now}`,
76
78
  sql`${millis(t, s.absoluteExpiresAt)} >= ${millis(t, s.expiresAt)}`,
77
79
  ]);
@@ -1,3 +1,4 @@
1
+ import { AuthenticationClock } from "@yielded/auth/Operations";
1
2
  import { reportAuthFailure } from "@yielded/auth/Persistence";
2
3
  import type { SubjectId } from "@yielded/auth/Schema";
3
4
  import {
@@ -14,12 +15,13 @@ import {
14
15
  } from "@yielded/auth/Sessions";
15
16
  import { Cause, Effect, Schema, DateTime } from "effect";
16
17
  import { SqlClient } from "effect/sql/SqlClient";
17
- import type { Fragment } from "effect/sql/Statement";
18
+ import { fragment, parameter, type Fragment } from "effect/sql/Statement";
18
19
 
19
20
  import { PersistenceMappingError } from "./mapping-error";
20
21
  import type { SessionAuthorityTables, SessionExecution } from "./models/session-model";
21
- import type { NativeSqlTables, SqlTable } from "./native-sql-table";
22
+ import { nativeSqlAlias, type NativeSqlTables, type SqlTable } from "./native-sql-table";
22
23
  import { anySqlCondition, exactSqlText } from "./sql-change";
24
+ import { guardSqlitePolicy, type SqlitePolicyValue } from "./sqlite-policy-guard";
23
25
  import type { AnyTableModel } from "./table-model";
24
26
 
25
27
  export const sessionUnavailable = () => SessionUnavailable.make({});
@@ -72,6 +74,7 @@ export type NativeSessionAuthorityMapping = SessionAuthorityTables<
72
74
  export interface NativeSessionAuthority {
73
75
  readonly native: unknown;
74
76
  readonly row: Record<string, unknown>;
77
+ readonly policyValues: ReadonlyArray<SqlitePolicyValue>;
75
78
  readonly revision: AuthenticationRevision;
76
79
  readonly requirement: AuthenticationRequirement;
77
80
  readonly now: number;
@@ -96,6 +99,7 @@ export const makeNativeSessionAuthorityState = Effect.fnUntraced(function* (
96
99
  batch = false,
97
100
  ) {
98
101
  const sql = (yield* SqlClient).withoutTransforms();
102
+ const clockPolicy = yield* AuthenticationClock;
99
103
 
100
104
  const s = mapping.subject,
101
105
  c = mapping.credential;
@@ -133,13 +137,15 @@ export const makeNativeSessionAuthorityState = Effect.fnUntraced(function* (
133
137
  const decode = Effect.fnUntraced(function* (
134
138
  subjectId: SubjectId,
135
139
  native: unknown,
136
- row: Record<string, unknown>,
140
+ projected: Record<string, unknown>,
137
141
  factors: ReadonlyArray<Record<string, unknown>>,
138
142
  instant: unknown,
139
143
  ): Effect.fn.Return<
140
144
  NativeSessionAuthority | undefined,
141
145
  PersistenceMappingError | Schema.SchemaError
142
146
  > {
147
+ const row = subject.decode(projected, "s_");
148
+
143
149
  sessionInvariant((yield* mapping.subjectId.toSubject(row[s.id])) === subjectId);
144
150
  if (!s.isActiveStatus(row[s.status])) return undefined;
145
151
  const credentials: { credentialId: string; revision: string }[] = [];
@@ -161,6 +167,16 @@ export const makeNativeSessionAuthorityState = Effect.fnUntraced(function* (
161
167
  return {
162
168
  native,
163
169
  row,
170
+ // Compare the selected SQL representation, without round-tripping through
171
+ // application codecs (for example, JSON whitespace or date precision).
172
+ policyValues: batch
173
+ ? (s.requirementColumns ?? []).map((key) => ({
174
+ column: subject.selectedColumn(key),
175
+ value: fragment([
176
+ parameter(projected[nativeSqlAlias("s_", subject.keys.indexOf(key))]),
177
+ ]),
178
+ }))
179
+ : [],
164
180
  revision: yield* Schema.decodeUnknownEffect(AuthenticationRevision)({
165
181
  subjectId,
166
182
  securityRevision: row[s.securityRevision],
@@ -188,7 +204,7 @@ export const makeNativeSessionAuthorityState = Effect.fnUntraced(function* (
188
204
  return yield* decode(
189
205
  subjectId,
190
206
  native,
191
- subject.decode(owners[0], "s_"),
207
+ owners[0],
192
208
  factors.map((row) => credential.decode(row, "c_")),
193
209
  owners[0].engine_now,
194
210
  );
@@ -202,18 +218,23 @@ export const makeNativeSessionAuthorityState = Effect.fnUntraced(function* (
202
218
  return yield* decode(
203
219
  subjectId,
204
220
  native,
205
- joinedSubject.decode(rows[0], "s_"),
221
+ rows[0],
206
222
  rows.map((row) => joinedCredential.decode(row, "c_")),
207
223
  rows[0].engine_now,
208
224
  );
209
225
  });
210
226
 
211
- const policyCondition = (row: Record<string, unknown>, native: unknown): Fragment => {
212
- if (!batch) return sql`1 = 1`;
227
+ const guardPolicy = Effect.fnUntraced(function* (current: NativeSessionAuthority) {
228
+ if (!batch) return;
213
229
  sessionInvariant(s.requirementColumns !== undefined);
214
230
 
215
- return sql`exists(select 1 from ${subject.name} where ${id(subject, s.id, native)} and ${sql.and(s.requirementColumns.map((key) => (row[key] === null ? sql`${subject.column(key)} is null` : typeof row[key] === "string" ? exact(subject, key, row[key]) : id(subject, key, row[key]))))})`;
216
- };
231
+ yield* guardSqlitePolicy({
232
+ name: "session-subject-policy",
233
+ table: subject.name,
234
+ owner: id(subject, s.id, current.native),
235
+ values: current.policyValues,
236
+ });
237
+ });
217
238
 
218
239
  const authorityCondition = (revision: AuthenticationRevision, native: unknown): Fragment =>
219
240
  sql.and([
@@ -227,6 +248,7 @@ export const makeNativeSessionAuthorityState = Effect.fnUntraced(function* (
227
248
 
228
249
  return {
229
250
  sql,
251
+ clockPolicy,
230
252
  subject,
231
253
  credential,
232
254
  joinedSubject,
@@ -242,7 +264,7 @@ export const makeNativeSessionAuthorityState = Effect.fnUntraced(function* (
242
264
  decode,
243
265
  read,
244
266
  authorityCondition,
245
- policyCondition,
267
+ guardPolicy,
246
268
  };
247
269
  });
248
270