@yielded/auth-persistence 0.1.0-beta.29 → 0.1.0-beta.31

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 (65) hide show
  1. package/README.md +6 -6
  2. package/dist/Adapter.d.mts +1 -1
  3. package/dist/Testing.d.mts +44 -0
  4. package/dist/Testing.mjs +310 -0
  5. package/dist/Testing.mjs.map +1 -0
  6. package/dist/internal/configuration.d.mts +18 -8
  7. package/dist/internal/configuration.mjs.map +1 -1
  8. package/dist/internal/models/oauth-model.d.mts +10 -1
  9. package/dist/internal/models/oauth-model.mjs.map +1 -1
  10. package/dist/internal/models/password-model.d.mts +2 -1
  11. package/dist/internal/models/password-model.mjs.map +1 -1
  12. package/dist/internal/oauth/native-accounts.mjs +42 -1
  13. package/dist/internal/oauth/native-accounts.mjs.map +1 -1
  14. package/dist/internal/oauth/native-state.d.mts +1 -0
  15. package/dist/internal/oauth/native-state.mjs +2 -2
  16. package/dist/internal/oauth/native-state.mjs.map +1 -1
  17. package/dist/internal/passkey-managed.mjs +4 -2
  18. package/dist/internal/passkey-managed.mjs.map +1 -1
  19. package/dist/internal/password-native.mjs +14 -0
  20. package/dist/internal/password-native.mjs.map +1 -1
  21. package/dist/internal/password-registration-native.d.mts +1 -1
  22. package/dist/internal/persistence.d.mts +3 -0
  23. package/dist/internal/persistence.mjs +4 -6
  24. package/dist/internal/persistence.mjs.map +1 -1
  25. package/dist/internal/registration.mjs +17 -8
  26. package/dist/internal/registration.mjs.map +1 -1
  27. package/dist/internal/session-native-authority.mjs +2 -1
  28. package/dist/internal/session-native-authority.mjs.map +1 -1
  29. package/dist/internal/session-native-login.mjs +2 -1
  30. package/dist/internal/session-native-login.mjs.map +1 -1
  31. package/dist/internal/session-native-pending-read.mjs +1 -1
  32. package/dist/internal/session-native-pending-read.mjs.map +1 -1
  33. package/dist/internal/session-native-state.mjs +21 -8
  34. package/dist/internal/session-native-state.mjs.map +1 -1
  35. package/dist/internal/session-native-stateful.mjs +2 -1
  36. package/dist/internal/session-native-stateful.mjs.map +1 -1
  37. package/dist/internal/session-native-step-up.mjs +4 -2
  38. package/dist/internal/session-native-step-up.mjs.map +1 -1
  39. package/dist/internal/sql-change.mjs +11 -1
  40. package/dist/internal/sql-change.mjs.map +1 -1
  41. package/dist/internal/sqlite-policy-guard.mjs +23 -0
  42. package/dist/internal/sqlite-policy-guard.mjs.map +1 -0
  43. package/dist/internal/storage-mapping.d.mts +9 -5
  44. package/dist/internal/storage-mapping.mjs +16 -4
  45. package/dist/internal/storage-mapping.mjs.map +1 -1
  46. package/package.json +1 -1
  47. package/src/Testing.ts +652 -0
  48. package/src/internal/configuration.ts +26 -8
  49. package/src/internal/models/oauth-model.ts +10 -0
  50. package/src/internal/models/password-model.ts +2 -1
  51. package/src/internal/oauth/native-accounts.ts +72 -1
  52. package/src/internal/oauth/native-state.ts +6 -2
  53. package/src/internal/passkey-managed.ts +8 -2
  54. package/src/internal/password-native.ts +28 -2
  55. package/src/internal/persistence.ts +174 -164
  56. package/src/internal/registration.ts +53 -40
  57. package/src/internal/session-native-authority.ts +3 -1
  58. package/src/internal/session-native-login.ts +3 -1
  59. package/src/internal/session-native-pending-read.ts +1 -1
  60. package/src/internal/session-native-state.ts +29 -10
  61. package/src/internal/session-native-stateful.ts +3 -1
  62. package/src/internal/session-native-step-up.ts +5 -2
  63. package/src/internal/sql-change.ts +14 -1
  64. package/src/internal/sqlite-policy-guard.ts +34 -0
  65. package/src/internal/storage-mapping.ts +40 -4
@@ -97,6 +97,27 @@ export interface ProvisioningId<Id extends string> {
97
97
  readonly service: "PersistenceProvisioning";
98
98
  }
99
99
 
100
+ export interface SubjectProvisioningInput<Registration> {
101
+ readonly requestId: string;
102
+ readonly identifier: LoginIdentifier;
103
+ readonly registration: Registration;
104
+ }
105
+
106
+ /** Return application subject values for insertion by the auth commit owner.
107
+ * Include the allocated native ID, active status, and initial security revision.
108
+ * Preparing values may allocate IDs, but must not write to the database or perform
109
+ * external side effects. Interactive drivers also accept an existing insert callback.
110
+ */
111
+ export type SubjectProvisioning<Registration> =
112
+ | {
113
+ readonly values: (
114
+ input: SubjectProvisioningInput<Registration>,
115
+ ) => Effect.Effect<Readonly<Record<string, unknown>>, PasswordUnavailable>;
116
+ }
117
+ | ((
118
+ input: SubjectProvisioningInput<Registration>,
119
+ ) => Effect.Effect<SubjectId, PasswordUnavailable>);
120
+
100
121
  export type Provisioning<A extends { readonly strategies: Readonly<Record<string, Strategy>> }> = {
101
122
  readonly [
102
123
  K in keyof A["strategies"] as A["strategies"][K] extends {
@@ -104,11 +125,7 @@ export type Provisioning<A extends { readonly strategies: Readonly<Record<string
104
125
  }
105
126
  ? K
106
127
  : never
107
- ]: (input: {
108
- readonly requestId: string;
109
- readonly identifier: LoginIdentifier;
110
- readonly registration: Registration<A["strategies"][K]>;
111
- }) => Effect.Effect<SubjectId, PasswordUnavailable>;
128
+ ]: SubjectProvisioning<Registration<A["strategies"][K]>>;
112
129
  };
113
130
 
114
131
  export type ProvisioningRequirement<
@@ -217,9 +234,10 @@ export interface BoundPersistence<
217
234
  value: StorageLayout<T, Roles<C, Id, A>>,
218
235
  ) => Layer.Layer<ConfigId<A["namespace"]>>;
219
236
  };
220
- /** Create only the application subject. Runs inside the owning auth SQL transaction;
221
- * use that SqlClient (including Drizzle over it), and return the new subject ID.
222
- * Do not perform external side effects or open an independent transaction here.
237
+ /** Supply subject values through `{ values }` on every driver, including D1.
238
+ * Interactive drivers also accept callbacks that insert through the owning
239
+ * SqlClient and return the new subject ID. Never open an independent transaction
240
+ * or perform external side effects while preparing a subject.
223
241
  */
224
242
  readonly Provisioning: Context.Service<ProvisioningRequirement<A>, Provisioning<A>>;
225
243
  /** Capture dependencies at acquisition; validate SQL metadata and configure storage
@@ -2,6 +2,7 @@ import type { PreparedCommit } from "@yielded/auth/Hooks";
2
2
  import type {
3
3
  OAuthActionAuthorization,
4
4
  OAuthAccountRevision,
5
+ OAuthLinkedAccountsRead,
5
6
  OAuthRegistrationIntent,
6
7
  OAuthExternalIdentity,
7
8
  OAuthRegistrationAccess,
@@ -442,6 +443,15 @@ export interface OAuthAccountsMapping<
442
443
  readonly subjectId: SubjectIdCodec<N>;
443
444
  readonly clock: OAuthClock<Expression>;
444
445
  readonly constraints: typeof requiredOAuthSignInConstraints;
446
+ /** Self-contained SQL predicate rechecking this invocation's current permission
447
+ * to view login metadata. Evaluated in every releasing read, alongside active
448
+ * subject/credential authority and ownership. Do not trust public assurance as
449
+ * credential provenance; bind session policy to application-owned rows as needed. */
450
+ readonly metadataAccess: (input: {
451
+ readonly invocation: OAuthLinkedAccountsRead["invocation"];
452
+ readonly moduleId: string;
453
+ readonly subjectId: N;
454
+ }) => Expression;
445
455
  readonly eligibility: ReadonlyArray<OAuthEligibilityDescriptor<N, Expression, Descriptor>>;
446
456
  readonly cleanup: ReadonlyArray<OAuthCleanupDescriptor<N, Expression, Descriptor>>;
447
457
  readonly sessionInvalidation: "same-authority-immediate" | "original-absolute-expiry";
@@ -23,7 +23,8 @@ export interface PasswordSubjectTable<Subject extends Table, _NativeSubjectId> {
23
23
  readonly decodeRequirement: (
24
24
  row: Subject["select"],
25
25
  ) => Effect.Effect<AuthenticationRequirement, PersistenceMappingError>;
26
- /** Current policy for this exact credential mutation, decoded under the subject lock. */
26
+ /** Current policy for this exact credential mutation. Interactive drivers hold
27
+ * the subject lock; D1 guards the physical application columns through commit. */
27
28
  readonly decodeActionRequirement: (
28
29
  row: Subject["select"],
29
30
  action: PasswordAction,
@@ -8,7 +8,7 @@ import type { Fragment } from "effect/sql/Statement";
8
8
  import { type OAuthAccountsMapping, OAuthEligibilityFact } from "../models/oauth-model";
9
9
  import type { NativeSqlTables } from "../native-sql-table";
10
10
  import { makeConditionalSqlInsert } from "../session-native-record";
11
- import { anySqlCondition, exactSqlText } from "../sql-change";
11
+ import { anySqlCondition, exactSqlText, sqlTextBytes } from "../sql-change";
12
12
  import { makeSqlCommitExecutor, SqlBatchCommit, CurrentSqlCommit } from "../sql-commit";
13
13
  import type { SqlExpression, TableModel } from "../table-model";
14
14
  import { makeOAuthNativeFlow } from "./native-flow";
@@ -86,6 +86,77 @@ export const makeNativeOAuthAccountsServices = Effect.fnUntraced(function* (
86
86
  ]);
87
87
 
88
88
  const service: M.OAuthAccountsPersistence["Service"] = {
89
+ list: (original) =>
90
+ read(
91
+ Effect.gen(function* () {
92
+ const input = M.snapshotOAuthSync(M.OAuthLinkedAccountsRead, original);
93
+ const nativeId = yield* mapping.subjectId.toNative(input.invocation.subjectId);
94
+
95
+ const metadataAccess = tables.expression(
96
+ mapping.metadataAccess({
97
+ invocation: input.invocation,
98
+ moduleId: input.moduleId,
99
+ subjectId: nativeId,
100
+ }),
101
+ );
102
+
103
+ const s = mapping.subject;
104
+ const orderedId = yield* sqlTextBytes(credential.column(c.credentialId));
105
+
106
+ const cursorCondition =
107
+ input.cursor === undefined
108
+ ? sql``
109
+ : sql`and ${orderedId} > ${yield* sqlTextBytes(credential.value(c.credentialId, input.cursor))}`;
110
+
111
+ // Bound candidate work before resolving identities. Each final read uses
112
+ // its columns' own codecs and rechecks live ownership/authority/policy.
113
+ // A removed candidate can leave a short page; the cursor still advances.
114
+ // Match exact credential identity even when the column's collation does not.
115
+ const rows = yield* sql`select ${credential.fields("page_")} from ${credential.name}
116
+ where ${exactSqlText(sql, credential.column(c.moduleId), credential.value(c.moduleId, input.moduleId))}
117
+ and ${credential.column(c.subjectId)} = ${credential.value(c.subjectId, nativeId)}
118
+ and ${tables.expression(c.activeCondition)} and ${metadataAccess}
119
+ and exists(select 1 from ${state.subject.name}
120
+ where ${state.subject.column(s.id)} = ${state.subject.value(s.id, nativeId)}
121
+ and ${tables.expression(s.activeCondition)})
122
+ ${cursorCondition}
123
+ order by ${orderedId} limit ${input.limit + 1}`;
124
+
125
+ const selected = rows.slice(0, input.limit);
126
+ const items: Array<M.OAuthLinkedAccount> = [];
127
+ let cursor: string | undefined;
128
+
129
+ for (const row of selected) {
130
+ const credentialId = yield* Schema.decodeUnknownEffect(
131
+ M.OAuthLinkedAccount.fields.credentialId,
132
+ )(credential.decode(row, "page_")[c.credentialId]);
133
+
134
+ cursor = credentialId;
135
+
136
+ const current = yield* state.readCredential(
137
+ {
138
+ moduleId: input.moduleId,
139
+ subjectId: input.invocation.subjectId,
140
+ credentialId,
141
+ },
142
+ metadataAccess,
143
+ );
144
+
145
+ if (current !== undefined)
146
+ items.push(
147
+ M.OAuthLinkedAccount.make({
148
+ credentialId: current.credentialId,
149
+ ...current.identity,
150
+ }),
151
+ );
152
+ }
153
+
154
+ return M.snapshotOAuthSync(M.OAuthLinkedAccountsListResult, {
155
+ items,
156
+ ...(rows.length > input.limit && cursor !== undefined ? { cursor } : {}),
157
+ });
158
+ }),
159
+ ),
89
160
  capture: (input) =>
90
161
  read(Effect.map(state.capture(input.subjectId, false), (current) => current?.revision)),
91
162
  issue: (original, prepare) =>
@@ -7,6 +7,7 @@ import {
7
7
  import type { SubjectId } from "@yielded/auth/Schema";
8
8
  import { Effect, Schema } from "effect";
9
9
  import { SqlClient } from "effect/sql";
10
+ import type { Fragment } from "effect/sql/Statement";
10
11
 
11
12
  import type { OAuthSignInMapping } from "../models/oauth-model";
12
13
  import type { NativeSqlTables } from "../native-sql-table";
@@ -305,7 +306,10 @@ export const makeOAuthNativeState = Effect.fnUntraced(function* (
305
306
  return result;
306
307
  });
307
308
 
308
- const readCredential = Effect.fnUntraced(function* (input: typeof OAuthCredentialKey.Type) {
309
+ const readCredential = Effect.fnUntraced(function* (
310
+ input: typeof OAuthCredentialKey.Type,
311
+ metadataAccess: Fragment = sql`true`,
312
+ ) {
309
313
  const nativeId = yield* mapping.subjectId.toNative(input.subjectId);
310
314
  let identityKey: unknown;
311
315
 
@@ -334,7 +338,7 @@ export const makeOAuthNativeState = Effect.fnUntraced(function* (
334
338
  and ${owner(o, mapping.ownership.subjectId)}
335
339
  join ${active("subject", "oauth_subject")} on ${owner(s, mapping.subject.id)}
336
340
  join ${active("authority", "oauth_authority")} on ${owner(a, mapping.authority.subjectId)}
337
- where ${owner(c, mapping.credential.subjectId)}
341
+ where ${metadataAccess} and ${owner(c, mapping.credential.subjectId)}
338
342
  and ${exact(c.column(mapping.credential.moduleId), c.value(mapping.credential.moduleId, input.moduleId))}
339
343
  and ${exact(c.column(mapping.credential.credentialId), c.value(mapping.credential.credentialId, input.credentialId))}
340
344
  order by ${a.column(mapping.authority.credentialId)} limit 65`;
@@ -14,13 +14,14 @@ import {
14
14
  requiredPasskeyPersistenceConstraints,
15
15
  } from "./models/passkey-model";
16
16
  import type { NativeSqlTables } from "./native-sql-table";
17
- import { makeNativePasskeyServices } from "./passkey-native";
17
+ import { makeBatchPasskeyServices, makeNativePasskeyServices } from "./passkey-native";
18
18
  import type { PasskeyNativeMapping, PasskeyNativeRead } from "./passkey-native-state";
19
19
  import {
20
20
  makePasskeyNativeManagement,
21
21
  type NativePasskeyManagementMapping,
22
22
  } from "./passkey-native-write";
23
23
  import { anySqlCondition } from "./sql-change";
24
+ import { SqlBatchCommit } from "./sql-commit";
24
25
  import { storageTables, type StorageRole } from "./storage-tables";
25
26
 
26
27
  export interface ComposedPasskeyInput {
@@ -255,6 +256,8 @@ const mappings = Effect.fnUntraced(function* (
255
256
  /** Managed and mapped tables feed the same native passkey statements. */
256
257
  export const makeManagedPasskeys = Effect.fnUntraced(
257
258
  function* (input: ComposedPasskeyInput, tables: NativeSqlTables) {
259
+ const batch = yield* SqlBatchCommit;
260
+
258
261
  const policies = yield* Effect.forEach(input.features, (feature) =>
259
262
  Effect.map(feature.policy, (policy) => ({ feature, policy })),
260
263
  );
@@ -275,7 +278,10 @@ export const makeManagedPasskeys = Effect.fnUntraced(
275
278
 
276
279
  for (const feature of input.features) {
277
280
  const selected = feature.management ? mapping.management(feature) : mapping.base(feature);
278
- const base = yield* makeNativePasskeyServices(tables, selected);
281
+
282
+ const base = yield* batch === undefined
283
+ ? makeNativePasskeyServices(tables, selected)
284
+ : makeBatchPasskeyServices(tables, selected);
279
285
 
280
286
  services.set(feature.moduleId, base);
281
287
  if (feature.management)
@@ -6,13 +6,13 @@ import type { SubjectId } from "@yielded/auth/Schema";
6
6
  import { SecurityRevision, type AuthenticationRequirement } from "@yielded/auth/Sessions";
7
7
  import { DateTime, Effect, Option, Redacted, Schema, Crypto } from "effect";
8
8
  import { SqlClient } from "effect/sql/SqlClient";
9
- import type { Fragment } from "effect/sql/Statement";
9
+ import { fragment, parameter, type Fragment } from "effect/sql/Statement";
10
10
 
11
11
  import { sqlBatchAssertion } from "./d1-planning";
12
12
  import { PersistenceMappingError } from "./mapping-error";
13
13
  import type { AnyPasswordPersistenceMapping } from "./models/password-model";
14
14
  import type { AnyProofPersistenceMapping } from "./models/proof-model";
15
- import type { NativeSqlTables, SqlTable } from "./native-sql-table";
15
+ import { nativeSqlAlias, type NativeSqlTables, type SqlTable } from "./native-sql-table";
16
16
  import { makePasswordCredentials, samePasswordCredentialSnapshot } from "./password-credentials";
17
17
  import {
18
18
  allocatePasswordNextSecurityRevision,
@@ -33,6 +33,7 @@ import {
33
33
  registerSqlPostcondition,
34
34
  SqlBatchCommit,
35
35
  } from "./sql-commit";
36
+ import { guardSqlitePolicy } from "./sqlite-policy-guard";
36
37
  import { requireStandalone } from "./standalone";
37
38
 
38
39
  const unavailable = () => P.PasswordUnavailable.make({});
@@ -70,6 +71,12 @@ export const makeNativePasswordServices = Effect.fnUntraced(function* (
70
71
  rawPassword = tables(c.table),
71
72
  rawAuthority = tables(a.table);
72
73
 
74
+ // Action policy may inspect any application column. Authority columns have
75
+ // separate checks and securityRevision intentionally changes in this mutation.
76
+ const policyColumns = rawSubject.keys.filter(
77
+ (key) => key !== s.id && key !== s.status && key !== s.securityRevision,
78
+ );
79
+
73
80
  const now = tables.expression(mapping.clock.engineNowMillis);
74
81
  const lock = sql.onDialectOrElse({ sqlite: () => sql``, orElse: () => sql`for update` });
75
82
 
@@ -193,6 +200,17 @@ export const makeNativePasswordServices = Effect.fnUntraced(function* (
193
200
  return {
194
201
  nativeId,
195
202
  subjectRow,
203
+ policyValues:
204
+ batch === undefined
205
+ ? []
206
+ : policyColumns.map((key) => ({
207
+ column: rawSubject.selectedColumn(key),
208
+ value: fragment([
209
+ parameter(
210
+ rows[0][nativeSqlAlias("password_subject_", rawSubject.keys.indexOf(key))],
211
+ ),
212
+ ]),
213
+ })),
196
214
  identifierRow,
197
215
  passwordRow,
198
216
  revision,
@@ -344,6 +362,14 @@ export const makeNativePasswordServices = Effect.fnUntraced(function* (
344
362
  );
345
363
  };
346
364
 
365
+ if (batch !== undefined)
366
+ yield* guardSqlitePolicy({
367
+ name: "password-subject-policy",
368
+ table: rawSubject.name,
369
+ owner: subjectKey(rawSubject, s.id, nativeId),
370
+ values: state.policyValues,
371
+ });
372
+
347
373
  const requestedDeadline = freshUntil(input.authorization.requirement),
348
374
  currentDeadline = freshUntil(requirement);
349
375