@yielded/auth-persistence 0.1.0-beta.31 → 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 (113) hide show
  1. package/README.md +9 -2
  2. package/dist/Adapter.mjs +8 -8
  3. package/dist/OAuthPersistence.mjs +10 -5
  4. package/dist/OAuthPersistence.mjs.map +1 -1
  5. package/dist/internal/configuration.d.mts +31 -5
  6. package/dist/internal/configuration.mjs.map +1 -1
  7. package/dist/internal/email-native.mjs +10 -2
  8. package/dist/internal/email-native.mjs.map +1 -1
  9. package/dist/internal/email-policy.mjs +3 -2
  10. package/dist/internal/email-policy.mjs.map +1 -1
  11. package/dist/internal/models/oauth-model.d.mts +4 -2
  12. package/dist/internal/models/oauth-model.mjs.map +1 -1
  13. package/dist/internal/oauth/native-accounts.d.mts +1 -1
  14. package/dist/internal/oauth/native-accounts.mjs +6 -6
  15. package/dist/internal/oauth/native-accounts.mjs.map +1 -1
  16. package/dist/internal/oauth/native-connected.d.mts +1 -1
  17. package/dist/internal/oauth/native-connected.mjs +1 -1
  18. package/dist/internal/oauth/native-registration.mjs +19 -8
  19. package/dist/internal/oauth/native-registration.mjs.map +1 -1
  20. package/dist/internal/oauth/native-sign-in.d.mts +1 -1
  21. package/dist/internal/oauth/native-sign-in.mjs +1 -1
  22. package/dist/internal/oauth/native-state.mjs +21 -11
  23. package/dist/internal/oauth/native-state.mjs.map +1 -1
  24. package/dist/internal/oauth/state.d.mts +1 -1
  25. package/dist/internal/oauth/state.mjs +1 -1
  26. package/dist/internal/oauth-managed.mjs +182 -0
  27. package/dist/internal/oauth-managed.mjs.map +1 -0
  28. package/dist/internal/passkey-managed.mjs +1 -1
  29. package/dist/internal/passkey-native-registration.mjs +1 -1
  30. package/dist/internal/passkey-native-state.mjs +33 -23
  31. package/dist/internal/passkey-native-state.mjs.map +1 -1
  32. package/dist/internal/passkey-native-write.mjs +3 -3
  33. package/dist/internal/passkey-native-write.mjs.map +1 -1
  34. package/dist/internal/passkey-native.d.mts +15 -7
  35. package/dist/internal/passkey-native.mjs +2 -1
  36. package/dist/internal/passkey-native.mjs.map +1 -1
  37. package/dist/internal/passkey-policy.mjs +9 -7
  38. package/dist/internal/passkey-policy.mjs.map +1 -1
  39. package/dist/internal/password-native.mjs +9 -6
  40. package/dist/internal/password-native.mjs.map +1 -1
  41. package/dist/internal/password-policy.mjs +7 -6
  42. package/dist/internal/password-policy.mjs.map +1 -1
  43. package/dist/internal/persistence.mjs +96 -37
  44. package/dist/internal/persistence.mjs.map +1 -1
  45. package/dist/internal/phone-native.mjs +1 -1
  46. package/dist/internal/phone-native.mjs.map +1 -1
  47. package/dist/internal/session-native-authority.mjs +6 -5
  48. package/dist/internal/session-native-authority.mjs.map +1 -1
  49. package/dist/internal/session-native-login.d.mts +2 -2
  50. package/dist/internal/session-native-login.mjs +2 -1
  51. package/dist/internal/session-native-login.mjs.map +1 -1
  52. package/dist/internal/session-native-pending-read.mjs +26 -20
  53. package/dist/internal/session-native-pending-read.mjs.map +1 -1
  54. package/dist/internal/session-native-record.mjs +6 -4
  55. package/dist/internal/session-native-record.mjs.map +1 -1
  56. package/dist/internal/session-native-state.mjs +3 -0
  57. package/dist/internal/session-native-state.mjs.map +1 -1
  58. package/dist/internal/session-native-stateful.d.mts +2 -2
  59. package/dist/internal/session-native-stateful.mjs +65 -37
  60. package/dist/internal/session-native-stateful.mjs.map +1 -1
  61. package/dist/internal/session-native-step-up.d.mts +2 -2
  62. package/dist/internal/session-native-step-up.mjs +14 -18
  63. package/dist/internal/session-native-step-up.mjs.map +1 -1
  64. package/dist/internal/session-native-validity.mjs +3 -1
  65. package/dist/internal/session-native-validity.mjs.map +1 -1
  66. package/dist/internal/sql-authority.mjs +5 -1
  67. package/dist/internal/sql-authority.mjs.map +1 -1
  68. package/dist/internal/sql-storage-validation.mjs +2 -1
  69. package/dist/internal/sql-storage-validation.mjs.map +1 -1
  70. package/dist/internal/sql-table.d.mts +1 -0
  71. package/dist/internal/sql-table.mjs.map +1 -1
  72. package/dist/internal/step-up-state.mjs +19 -6
  73. package/dist/internal/step-up-state.mjs.map +1 -1
  74. package/dist/internal/storage-mapping.mjs +1 -1
  75. package/dist/internal/storage-tables.d.mts +237 -0
  76. package/dist/internal/storage-tables.mjs +68 -0
  77. package/dist/internal/storage-tables.mjs.map +1 -1
  78. package/dist/internal/storage-validation.d.mts +3 -1
  79. package/dist/internal/storage-validation.mjs +70 -17
  80. package/dist/internal/storage-validation.mjs.map +1 -1
  81. package/dist/internal/totp-native.mjs +1 -1
  82. package/package.json +1 -1
  83. package/src/OAuthPersistence.ts +7 -2
  84. package/src/internal/configuration.ts +46 -7
  85. package/src/internal/email-native.ts +16 -6
  86. package/src/internal/email-policy.ts +12 -3
  87. package/src/internal/models/oauth-model.ts +7 -1
  88. package/src/internal/oauth/native-accounts.ts +3 -3
  89. package/src/internal/oauth/native-registration.ts +17 -6
  90. package/src/internal/oauth/native-state.ts +38 -18
  91. package/src/internal/oauth-managed.ts +277 -0
  92. package/src/internal/passkey-native-state.ts +59 -52
  93. package/src/internal/passkey-native-write.ts +2 -2
  94. package/src/internal/passkey-native.ts +4 -2
  95. package/src/internal/passkey-policy.ts +13 -9
  96. package/src/internal/password-native.ts +17 -9
  97. package/src/internal/password-policy.ts +16 -8
  98. package/src/internal/persistence.ts +216 -70
  99. package/src/internal/phone-native.ts +1 -1
  100. package/src/internal/session-native-authority.ts +23 -7
  101. package/src/internal/session-native-login.ts +6 -3
  102. package/src/internal/session-native-pending-read.ts +46 -33
  103. package/src/internal/session-native-record.ts +6 -4
  104. package/src/internal/session-native-state.ts +3 -0
  105. package/src/internal/session-native-stateful.ts +144 -51
  106. package/src/internal/session-native-step-up.ts +27 -23
  107. package/src/internal/session-native-validity.ts +3 -1
  108. package/src/internal/sql-authority.ts +5 -1
  109. package/src/internal/sql-storage-validation.ts +53 -46
  110. package/src/internal/sql-table.ts +1 -0
  111. package/src/internal/step-up-state.ts +43 -6
  112. package/src/internal/storage-tables.ts +76 -0
  113. package/src/internal/storage-validation.ts +127 -27
@@ -8,6 +8,7 @@ import {
8
8
  OAuthConnectedRevocations,
9
9
  OAuthUnavailable,
10
10
  } from "@yielded/auth/OAuth";
11
+ import { reportAuthDiagnostic } from "@yielded/auth/Persistence";
11
12
  import { type Context, type Crypto, Effect, Predicate, Schema } from "effect";
12
13
  import { SqlClient } from "effect/sql";
13
14
 
@@ -49,6 +50,7 @@ import { validateSqlStorage } from "./internal/sql-storage-validation";
49
50
  import { Table } from "./internal/sql-table";
50
51
  import { requireStandalone } from "./internal/standalone";
51
52
  import type { StorageTable } from "./internal/storage-tables";
53
+ import { withStorageValidation } from "./internal/storage-validation";
52
54
  export * from "./internal/sql-oauth-model";
53
55
  export * from "./internal/sql-oauth-connected-model";
54
56
  export * from "./internal/oauth/native-layers";
@@ -77,10 +79,13 @@ const acquire = Effect.gen(function* () {
77
79
  if (
78
80
  !client.onDialectOrElse({ pg: () => true, sqlite: () => true, orElse: () => false }) ||
79
81
  Predicate.hasProperty(client, "~@effect/sql-d1/D1Client")
80
- )
82
+ ) {
83
+ yield* reportAuthDiagnostic("persistence-validation", "configuration");
84
+
81
85
  return yield* PersistenceConfigurationError.make({
82
86
  reason: "OAuth persistence requires PostgreSQL or SQLite with interactive transactions",
83
87
  });
88
+ }
84
89
 
85
90
  return { client, tables: makeNativeSqlTables(client) };
86
91
  });
@@ -96,7 +101,7 @@ const using = <M, A, E, R>(
96
101
  yield* validateSqlStorage(mapping).pipe(Effect.mapError(() => OAuthUnavailable.make({})));
97
102
 
98
103
  return yield* make(target.tables, mapping);
99
- });
104
+ }).pipe(withStorageValidation);
100
105
 
101
106
  const coordinate = <S, A, E, R, ES, RS>(
102
107
  make: Effect.Effect<S, ES, RS>,
@@ -1,5 +1,11 @@
1
1
  import type { EmailAction, EmailAddressPersistence } from "@yielded/auth/Email";
2
2
  import type { LoginIdentifier } from "@yielded/auth/Identity";
3
+ import type {
4
+ OAuthConnectedPersistence,
5
+ OAuthConnectedPolicy,
6
+ OAuthConnectedRevocations,
7
+ OAuthSignInPersistence,
8
+ } from "@yielded/auth/OAuth";
3
9
  import type {
4
10
  PasskeyActionChallenge,
5
11
  PasskeyConfig,
@@ -44,10 +50,19 @@ export interface PasskeyFeature {
44
50
  readonly managementPolicy?: PasskeyManagementPolicy;
45
51
  }
46
52
 
53
+ export interface OAuthFeature {
54
+ readonly kind: "oauth";
55
+ readonly moduleId: string;
56
+ readonly signIn: boolean;
57
+ readonly connected?: OAuthConnectedPolicy;
58
+ }
59
+
47
60
  export interface Strategy {
48
61
  readonly strategy: object;
62
+ /** Absent metadata leaves the strategy's persistence services application-supplied. */
49
63
  readonly persistence?:
50
64
  | PasskeyFeature
65
+ | OAuthFeature
51
66
  | {
52
67
  readonly kind: "password" | "phone" | "email";
53
68
  readonly moduleId: string;
@@ -69,7 +84,7 @@ export interface Definition<C extends ClaimsCodec, Id extends string> {
69
84
  type KeyId<T> = T extends Context.Key<infer Id, infer _Service> ? Id : never;
70
85
  type Enabled<
71
86
  A extends { readonly strategies: Readonly<Record<string, Strategy>> },
72
- Kind extends "password" | "phone" | "email" | "passkey",
87
+ Kind extends "password" | "phone" | "email" | "passkey" | "oauth",
73
88
  > = Extract<A["strategies"][keyof A["strategies"]], { persistence: { kind: Kind } }>;
74
89
 
75
90
  type ManagedPassword<A extends { readonly strategies: Readonly<Record<string, Strategy>> }> =
@@ -82,6 +97,14 @@ export type PasskeyRequirement<
82
97
  A extends { readonly strategies: Readonly<Record<string, Strategy>> },
83
98
  > = Enabled<A, "passkey"> extends never ? never : PasskeyConfig;
84
99
 
100
+ type OAuthSignIn<A extends { readonly strategies: Readonly<Record<string, Strategy>> }> = Extract<
101
+ Enabled<A, "oauth">,
102
+ { persistence: { signIn: true } }
103
+ >;
104
+
105
+ type OAuthConnected<A extends { readonly strategies: Readonly<Record<string, Strategy>> }> =
106
+ Extract<Enabled<A, "oauth">, { persistence: { connected: OAuthConnectedPolicy } }>;
107
+
85
108
  type RegistrationKey<S> = S extends { readonly RegistrationAuthority: infer K } ? K : never;
86
109
  type Registration<S> =
87
110
  RegistrationKey<S> extends Context.Key<infer _Id, infer Service>
@@ -141,8 +164,11 @@ type UsesProofs<A extends { readonly strategies: Readonly<Record<string, Strateg
141
164
 
142
165
  export type Ports<C extends ClaimsCodec, Id extends string, A extends Definition<C, Id>> =
143
166
  | AuthenticationAuthority
144
- | KeyId<A["sessions"]["StatefulSessionPersistence"]>
145
- | KeyId<A["sessions"]["SessionRepository"]>
167
+ | ("stateful" extends A["sessionMode"]
168
+ ?
169
+ | KeyId<A["sessions"]["StatefulSessionPersistence"]>
170
+ | KeyId<A["sessions"]["SessionRepository"]>
171
+ : never)
146
172
  | KeyId<A["sessions"]["PendingAuthentication"]>
147
173
  | KeyId<A["sessions"]["SessionStepUpPersistence"]>
148
174
  | KeyId<A["sessions"]["SessionCleanup"]>
@@ -152,17 +178,26 @@ export type Ports<C extends ClaimsCodec, Id extends string, A extends Definition
152
178
  | (ManagedPasskey<A> extends never ? never : PasskeyManagementPersistence)
153
179
  | (Enabled<A, "email"> extends never ? never : EmailAddressPersistence)
154
180
  | (UsesProofs<A> extends never ? never : ProofPersistence)
155
- | (Enabled<A, "phone"> extends never ? never : PhoneSignInTargets);
181
+ | (Enabled<A, "phone"> extends never ? never : PhoneSignInTargets)
182
+ | (OAuthSignIn<A> extends never ? never : OAuthSignInPersistence)
183
+ | (OAuthConnected<A> extends never
184
+ ? never
185
+ : OAuthConnectedPersistence | OAuthConnectedRevocations);
156
186
 
157
187
  export type Roles<C extends ClaimsCodec, Id extends string, A extends Definition<C, Id>> =
158
188
  | "identifiers"
159
189
  | "credentials"
160
- | "sessions"
190
+ | ("stateful" extends A["sessionMode"] ? "sessions" : never)
161
191
  | "pending"
162
192
  | (Enabled<A, "password"> extends never ? never : "passwords")
163
193
  | (Enabled<A, "email"> extends never ? never : "emailCredentials")
164
194
  | (Enabled<A, "passkey"> extends never ? never : "passkeyCredentials" | "passkeyFlows")
165
- | (UsesProofs<A> extends never ? never : ProofRoles);
195
+ | (UsesProofs<A> extends never ? never : ProofRoles)
196
+ | (Enabled<A, "oauth"> extends never ? never : "oauthIdentities" | "oauthCredentials")
197
+ | (OAuthSignIn<A> extends never ? never : "oauthSignInFlows")
198
+ | (OAuthConnected<A> extends never
199
+ ? never
200
+ : "oauthConnectedFlows" | "oauthConnectedGrants" | "oauthConnectedRevocations");
166
201
 
167
202
  export interface SubjectOptions<T extends object, NativeId> {
168
203
  readonly table: T;
@@ -240,9 +275,13 @@ export interface BoundPersistence<
240
275
  * or perform external side effects while preparing a subject.
241
276
  */
242
277
  readonly Provisioning: Context.Service<ProvisioningRequirement<A>, Provisioning<A>>;
243
- /** Capture dependencies at acquisition; validate SQL metadata and configure storage
278
+ /** Stateful sessions and OAuth-only stateless sessions are supported. Other session
279
+ * configurations fail acquisition with PersistenceConfigurationError.
280
+ * Capture dependencies at acquisition; validate SQL metadata and configure storage
244
281
  * once on the first service operation. Initialization failures become that port's
245
282
  * unavailable error. Failure or interruption remains cached until layer reacquisition.
283
+ * Managed OAuth metadata/token-use authorizations bind policyRevision to the subject's
284
+ * securityRevision; changes to application authorization policy must advance it.
246
285
  */
247
286
  readonly layer: Layer.Layer<
248
287
  Ports<C, Id, A>,
@@ -250,11 +250,19 @@ export const makeNativeEmailAddressServices = Effect.fnUntraced(function* (
250
250
  .filter((time) => time > current.now)
251
251
  .sort((a, b) => a - b);
252
252
 
253
- const deadline = boundaries.find((time) =>
254
- requirements.some(
255
- (r) => !passwordEvidenceSatisfiedAt(input.authorization.evidence, r, time),
256
- ),
257
- );
253
+ let deadline: number | undefined;
254
+
255
+ for (const time of boundaries) {
256
+ for (const requirement of requirements) {
257
+ if (
258
+ !(yield* passwordEvidenceSatisfiedAt(input.authorization.evidence, requirement, time))
259
+ ) {
260
+ deadline = time;
261
+ break;
262
+ }
263
+ }
264
+ if (deadline !== undefined) break;
265
+ }
258
266
 
259
267
  ensure(deadline !== undefined);
260
268
  const freshness = sql`${now} >= ${current.now} and ${now} < ${deadline}`;
@@ -470,7 +478,9 @@ export const makeNativeEmailAddressServices = Effect.fnUntraced(function* (
470
478
  );
471
479
  const policy = yield* s.decodeActionRequirement(final.subject, action);
472
480
 
473
- ensure(passwordEvidenceSatisfiedAt(input.authorization.evidence, policy, final.now));
481
+ ensure(
482
+ yield* passwordEvidenceSatisfiedAt(input.authorization.evidence, policy, final.now),
483
+ );
474
484
  if (source !== undefined)
475
485
  ensure(
476
486
  final.sourceIdentifier !== undefined &&
@@ -14,7 +14,7 @@ import {
14
14
  type AuthenticationRequirement,
15
15
  type SecurityRevision,
16
16
  } from "@yielded/auth/Sessions";
17
- import { Effect } from "effect";
17
+ import { DateTime, Effect } from "effect";
18
18
 
19
19
  import { PersistenceMappingError } from "./mapping-error";
20
20
  import type { AnyEmailAddressMapping } from "./models/email-model";
@@ -171,12 +171,21 @@ export const validateEmailMutation = Effect.fn("EmailAddressPersistence.validate
171
171
 
172
172
  const currentRequirement = yield* current.requirement;
173
173
 
174
+ // Address custody retains strict future-proof rejection.
175
+ if (
176
+ input.authorization.evidence.proofs.some(
177
+ (proof) => DateTime.toEpochMillis(proof.verifiedAt) > now,
178
+ )
179
+ )
180
+ return false;
181
+
174
182
  return (
175
- passwordEvidenceSatisfiedAt(
183
+ (yield* passwordEvidenceSatisfiedAt(
176
184
  input.authorization.evidence,
177
185
  input.authorization.requirement,
178
186
  now,
179
- ) && passwordEvidenceSatisfiedAt(input.authorization.evidence, currentRequirement, now)
187
+ )) &&
188
+ (yield* passwordEvidenceSatisfiedAt(input.authorization.evidence, currentRequirement, now))
180
189
  );
181
190
  },
182
191
  );
@@ -47,7 +47,11 @@ export interface OAuthSubjectReadTable<
47
47
  readonly securityRevision: Column<S>;
48
48
  readonly isActiveStatus: (value: unknown) => boolean;
49
49
  readonly activeCondition: Expression;
50
- readonly decodeAuthenticationRequirement: (row: S["select"]) => AuthenticationRequirement;
50
+ readonly decodeAuthenticationRequirement: (
51
+ row: S["select"],
52
+ ) =>
53
+ | AuthenticationRequirement
54
+ | Effect.Effect<AuthenticationRequirement, PersistenceMappingError>;
51
55
  }
52
56
 
53
57
  export interface OAuthSubjectTable<
@@ -465,6 +469,8 @@ export interface OAuthAccountsMapping<
465
469
  }
466
470
 
467
471
  export interface OAuthRegistrationAuthority<Registration> {
472
+ /** Only the first confirmed registration supplies its private authentication handoff. */
473
+ readonly authentication?: "first-confirmed-registration";
468
474
  readonly read: (
469
475
  access: OAuthRegistrationAccess,
470
476
  ) => Effect.Effect<OAuthRegistrationInspection | undefined, OAuthUnavailable>;
@@ -14,7 +14,7 @@ import type { SqlExpression, TableModel } from "../table-model";
14
14
  import { makeOAuthNativeFlow } from "./native-flow";
15
15
  import { makeOAuthNativeMutation } from "./native-mutation";
16
16
  import { prepareOAuthNative } from "./native-sign-in";
17
- import { makeOAuthNativeState } from "./native-state";
17
+ import { decodeOAuthRequirement, makeOAuthNativeState } from "./native-state";
18
18
  import { invariant, oauthIdentityKey, sameRevision, satisfies, unavailable } from "./state";
19
19
 
20
20
  // Physical metadata is validated by the adapter; schema values remain typed.
@@ -341,7 +341,7 @@ export const makeNativeOAuthAccountsServices = Effect.fnUntraced(function* (
341
341
  credentialId,
342
342
  credentialRevision,
343
343
  revision,
344
- requirement: mapping.subject.decodeAuthenticationRequirement(current.subject),
344
+ requirement: yield* decodeOAuthRequirement(mapping.subject, current.subject),
345
345
  });
346
346
 
347
347
  return yield* prepareOAuthNative(
@@ -495,7 +495,7 @@ export const makeNativeOAuthAccountsServices = Effect.fnUntraced(function* (
495
495
  }
496
496
  const methods = [...eligible.values()];
497
497
  const facts = methods.map((entry) => entry.fact);
498
- const requirement = mapping.subject.decodeAuthenticationRequirement(current.subject);
498
+ const requirement = yield* decodeOAuthRequirement(mapping.subject, current.subject);
499
499
 
500
500
  if (!facts.some((fact) => fact.usablePrimary) || !satisfies(facts, requirement))
501
501
  return yield* prepareOAuthNative({ _tag: "LastSignInMethod" }, prepare);
@@ -293,6 +293,7 @@ export const makeNativeOAuthRegistrationServices = Effect.fnUntraced(function* <
293
293
  executor.read(Effect.provideService(effect, Crypto.Crypto, crypto));
294
294
 
295
295
  const service: OAuthRegistrationAuthority<Registration> = {
296
+ authentication: "first-confirmed-registration",
296
297
  read: (access) =>
297
298
  read(
298
299
  Effect.map(
@@ -399,6 +400,7 @@ export const makeNativeOAuthRegistrationServices = Effect.fnUntraced(function* <
399
400
  (batch === undefined || (yield* sql`select 1 where ${eligible}`).length === 1);
400
401
 
401
402
  let final: Fragment = horizon(expected);
403
+ let authentication: M.OAuthRegistrationAuthentication | undefined;
402
404
 
403
405
  if (admitted) {
404
406
  const nativeId = yield* mapping.allocateSubjectId;
@@ -488,14 +490,20 @@ export const makeNativeOAuthRegistrationServices = Effect.fnUntraced(function* <
488
490
 
489
491
  invariant(a.isActiveStatus(factor[a.status]));
490
492
  yield* state.change(authority.insert(factor));
491
- final = sql.and([
492
- horizon(expected),
493
- tables.expression(mapping.eligibility.postcondition(policy)),
494
- mutation.authorityCondition(nativeId, {
493
+ authentication = M.snapshotOAuthSync(M.OAuthRegistrationAuthentication, {
494
+ intent: expected,
495
+ credentialId,
496
+ credentialRevision: revision,
497
+ revision: {
495
498
  subjectId,
496
499
  securityRevision: revision,
497
500
  credentials: [{ credentialId, revision }],
498
- }),
501
+ },
502
+ });
503
+ final = sql.and([
504
+ horizon(expected),
505
+ tables.expression(mapping.eligibility.postcondition(policy)),
506
+ mutation.authorityCondition(nativeId, authentication.revision),
499
507
  sql`exists(select 1 from ${credential.name} where ${credential.column(c.subjectId)} = ${credential.value(c.subjectId, nativeId)} and ${exactSqlText(sql, credential.column(c.credentialId), credential.value(c.credentialId, credentialId))} and ${exactSqlText(sql, credential.column(c.credentialRevision), credential.value(c.credentialRevision, revision))} and ${exactSqlText(sql, credential.column(c.identityKey), credential.value(c.identityKey, found.identityKey))} and ${tables.expression(c.activeCondition)})`,
500
508
  sql`exists(select 1 from ${mutation.ownership.name} where ${mutation.ownerCondition(found.identityKey, nativeId)})`,
501
509
  ]);
@@ -520,8 +528,11 @@ export const makeNativeOAuthRegistrationServices = Effect.fnUntraced(function* <
520
528
  ]),
521
529
  );
522
530
 
531
+ if (!admitted) return yield* rejected();
532
+ invariant(authentication !== undefined);
533
+
523
534
  return yield* prepareOAuthNative(
524
- admitted ? { _tag: "Registered", replayed: false } : { _tag: "Rejected" },
535
+ { _tag: "Registered", replayed: false, authentication },
525
536
  prepare,
526
537
  );
527
538
  }),
@@ -5,15 +5,18 @@ import {
5
5
  type OAuthCredentialKey,
6
6
  } from "@yielded/auth/OAuth";
7
7
  import type { SubjectId } from "@yielded/auth/Schema";
8
+ import type { AuthenticationRequirement } from "@yielded/auth/Sessions";
8
9
  import { Effect, Schema } from "effect";
9
10
  import { SqlClient } from "effect/sql";
10
11
  import type { Fragment } from "effect/sql/Statement";
11
12
 
12
- import type { OAuthSignInMapping } from "../models/oauth-model";
13
+ import type { PersistenceMappingError } from "../mapping-error";
14
+ import type { OAuthSignInMapping, OAuthSubjectReadTable } from "../models/oauth-model";
13
15
  import type { NativeSqlTables } from "../native-sql-table";
14
16
  import { exactSqlText } from "../sql-change";
17
+ import { canJoinTextColumns, withStorageValidation } from "../storage-validation";
15
18
  import type { SqlExpression, TableModel } from "../table-model";
16
- import { invariant, oauthIdentityKey } from "./state";
19
+ import { invariant, oauthIdentityKey, unavailable } from "./state";
17
20
 
18
21
  // Expression handles are checked by the physical compiler, not domain codecs.
19
22
  export type OAuthNativeReadMapping = OAuthSignInMapping<
@@ -28,6 +31,15 @@ export type OAuthNativeReadMapping = OAuthSignInMapping<
28
31
 
29
32
  type Row = Readonly<Record<string, unknown>>;
30
33
 
34
+ export const decodeOAuthRequirement = Effect.fnUntraced(function* (
35
+ subject: OAuthSubjectReadTable<TableModel>,
36
+ row: Row,
37
+ ): Effect.fn.Return<AuthenticationRequirement, PersistenceMappingError> {
38
+ const requirement = subject.decodeAuthenticationRequirement(row);
39
+
40
+ return yield* Effect.isEffect(requirement) ? requirement : Effect.succeed(requirement);
41
+ });
42
+
31
43
  /** Unlocked snapshots and explicit subject-first reads share decoding rules.
32
44
  * No observation registry or discovered-identity placeholder is retained. */
33
45
  export const makeOAuthNativeAuthority = Effect.fnUntraced(function* (
@@ -197,19 +209,31 @@ export const makeOAuthNativeState = Effect.fnUntraced(function* (
197
209
  return sql`(select * from ${physical.name} where ${tables.expression(descriptor.activeCondition)}) as ${sql(alias)}`;
198
210
  };
199
211
 
200
- const joinedIds =
201
- ownership.unencodedTextColumn?.(mapping.ownership.subjectId) !== undefined &&
202
- subject.unencodedTextColumn?.(mapping.subject.id) !== undefined &&
203
- credential.unencodedTextColumn?.(mapping.credential.subjectId) !== undefined &&
204
- authority.unencodedTextColumn?.(mapping.authority.subjectId) !== undefined;
212
+ const [joinedIds, joinedIdentityKeys] = yield* Effect.forEach(
213
+ [
214
+ [
215
+ ownership.unencodedTextColumn?.(mapping.ownership.subjectId),
216
+ subject.unencodedTextColumn?.(mapping.subject.id),
217
+ credential.unencodedTextColumn?.(mapping.credential.subjectId),
218
+ authority.unencodedTextColumn?.(mapping.authority.subjectId),
219
+ ],
220
+ [
221
+ credential.unencodedTextColumn?.(mapping.credential.identityKey),
222
+ ownership.unencodedTextColumn?.(mapping.ownership.identityKey),
223
+ ],
224
+ ],
225
+ canJoinTextColumns,
226
+ ).pipe(withStorageValidation, Effect.mapError(unavailable));
205
227
 
206
228
  const decodeSnapshot = Effect.fnUntraced(function* (
207
229
  rows: ReadonlyArray<Row>,
208
230
  moduleId: string,
209
231
  nativeId?: unknown,
210
232
  ) {
211
- if (rows.length === 0) return undefined;
212
- const owned = o.decode(rows[0]!, "o_");
233
+ const first = rows[0];
234
+
235
+ if (first === undefined) return undefined;
236
+ const owned = o.decode(first, "o_");
213
237
 
214
238
  const identity = yield* Schema.decodeUnknownEffect(OAuthExternalIdentity)({
215
239
  provider: owned[mapping.ownership.provider],
@@ -223,14 +247,14 @@ export const makeOAuthNativeState = Effect.fnUntraced(function* (
223
247
  const ownerId = mapping.ownership.decodeSubjectId(owned);
224
248
 
225
249
  if (nativeId !== undefined) invariant(mapping.subjectId.equals(ownerId, nativeId));
226
- const login = c.decode(rows[0]!, "c_");
250
+ const login = c.decode(first, "c_");
227
251
 
228
252
  invariant(mapping.subjectId.equals(login[mapping.credential.subjectId], ownerId));
229
253
  invariant(login[mapping.credential.identityKey] === identityKey);
230
254
  invariant(mapping.credential.isActiveStatus(login[mapping.credential.status]));
231
255
 
232
256
  const current = yield* revision(
233
- s.decode(rows[0]!, "s_"),
257
+ s.decode(first, "s_"),
234
258
  rows.map((row) => a.decode(row, "a_")),
235
259
  ownerId,
236
260
  );
@@ -251,7 +275,7 @@ export const makeOAuthNativeState = Effect.fnUntraced(function* (
251
275
  credentialId,
252
276
  credentialRevision,
253
277
  revision: current,
254
- requirement: mapping.subject.decodeAuthenticationRequirement(s.decode(rows[0]!, "s_")),
278
+ requirement: yield* decodeOAuthRequirement(mapping.subject, s.decode(first, "s_")),
255
279
  });
256
280
  });
257
281
 
@@ -313,11 +337,7 @@ export const makeOAuthNativeState = Effect.fnUntraced(function* (
313
337
  const nativeId = yield* mapping.subjectId.toNative(input.subjectId);
314
338
  let identityKey: unknown;
315
339
 
316
- const direct =
317
- credential.unencodedTextColumn?.(mapping.credential.identityKey) !== undefined &&
318
- ownership.unencodedTextColumn?.(mapping.ownership.identityKey) !== undefined;
319
-
320
- if (!direct) {
340
+ if (!joinedIdentityKeys) {
321
341
  const selected = yield* sql`select ${credential.fields("c_")} from ${credential.name}
322
342
  where ${exact(credential.column(mapping.credential.moduleId), credential.value(mapping.credential.moduleId, input.moduleId))}
323
343
  and ${exact(credential.column(mapping.credential.credentialId), credential.value(mapping.credential.credentialId, input.credentialId))}
@@ -334,7 +354,7 @@ export const makeOAuthNativeState = Effect.fnUntraced(function* (
334
354
  const rows =
335
355
  yield* sql`select ${o.fields("o_")}, ${s.fields("s_")}, ${c.fields("c_")}, ${a.fields("a_")}
336
356
  from ${active("credential", "oauth_credential")}
337
- join ${o.name} on ${direct ? exact(o.column(mapping.ownership.identityKey), c.column(mapping.credential.identityKey)) : exact(o.column(mapping.ownership.identityKey), o.value(mapping.ownership.identityKey, identityKey))}
357
+ join ${o.name} on ${joinedIdentityKeys ? exact(o.column(mapping.ownership.identityKey), c.column(mapping.credential.identityKey)) : exact(o.column(mapping.ownership.identityKey), o.value(mapping.ownership.identityKey, identityKey))}
338
358
  and ${owner(o, mapping.ownership.subjectId)}
339
359
  join ${active("subject", "oauth_subject")} on ${owner(s, mapping.subject.id)}
340
360
  join ${active("authority", "oauth_authority")} on ${owner(a, mapping.authority.subjectId)}