@yielded/auth-persistence 0.1.0-beta.18 → 0.1.0-beta.19

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 (75) hide show
  1. package/README.md +5 -2
  2. package/dist/Adapter.d.mts +2 -2
  3. package/dist/Adapter.mjs +2 -2
  4. package/dist/internal/configuration.d.mts +4 -3
  5. package/dist/internal/configuration.mjs.map +1 -1
  6. package/dist/internal/crypto.mjs +12 -0
  7. package/dist/internal/crypto.mjs.map +1 -0
  8. package/dist/internal/passkey/admission.mjs +19 -15
  9. package/dist/internal/passkey/admission.mjs.map +1 -1
  10. package/dist/internal/passkey/credentials.mjs +4 -4
  11. package/dist/internal/passkey/credentials.mjs.map +1 -1
  12. package/dist/internal/passkey/enrollment.mjs +6 -6
  13. package/dist/internal/passkey/enrollment.mjs.map +1 -1
  14. package/dist/internal/passkey/flow.mjs +7 -7
  15. package/dist/internal/passkey/flow.mjs.map +1 -1
  16. package/dist/internal/passkey/management.mjs +2 -2
  17. package/dist/internal/passkey/management.mjs.map +1 -1
  18. package/dist/internal/passkey/registration-ceremony.mjs +2 -2
  19. package/dist/internal/passkey/registration-ceremony.mjs.map +1 -1
  20. package/dist/internal/passkey/registration-custody.mjs +1 -1
  21. package/dist/internal/passkey/registration-custody.mjs.map +1 -1
  22. package/dist/internal/passkey/registration-write.mjs +12 -12
  23. package/dist/internal/passkey/registration-write.mjs.map +1 -1
  24. package/dist/internal/passkey/state.d.mts +1 -1
  25. package/dist/internal/passkey/state.mjs +8 -9
  26. package/dist/internal/passkey/state.mjs.map +1 -1
  27. package/dist/internal/passkey/target.d.mts +1 -1
  28. package/dist/internal/passkey/target.mjs.map +1 -1
  29. package/dist/internal/passkey/write-state.mjs +13 -13
  30. package/dist/internal/passkey/write-state.mjs.map +1 -1
  31. package/dist/internal/passkey/write-target.mjs.map +1 -1
  32. package/dist/internal/passkey-kernel.d.mts +512 -56
  33. package/dist/internal/passkeys.mjs +32 -14
  34. package/dist/internal/passkeys.mjs.map +1 -1
  35. package/dist/internal/password-kernel.d.mts +1 -1
  36. package/dist/internal/persistence.d.mts +2 -2
  37. package/dist/internal/persistence.mjs +16 -7
  38. package/dist/internal/persistence.mjs.map +1 -1
  39. package/dist/internal/phone-kernel.d.mts +5 -5
  40. package/dist/internal/phone-kernel.mjs +14 -14
  41. package/dist/internal/phone-kernel.mjs.map +1 -1
  42. package/dist/internal/registration.mjs +8 -7
  43. package/dist/internal/registration.mjs.map +1 -1
  44. package/dist/internal/storage-mapping.d.mts +5 -4
  45. package/dist/internal/storage-mapping.mjs +16 -13
  46. package/dist/internal/storage-mapping.mjs.map +1 -1
  47. package/dist/internal/storage-validation.d.mts +13 -3
  48. package/dist/internal/storage-validation.mjs +128 -68
  49. package/dist/internal/storage-validation.mjs.map +1 -1
  50. package/dist/internal/transaction-execution-kernel.d.mts +4 -4
  51. package/dist/internal/transaction-execution-kernel.mjs +7 -5
  52. package/dist/internal/transaction-execution-kernel.mjs.map +1 -1
  53. package/package.json +1 -1
  54. package/src/Adapter.ts +7 -1
  55. package/src/internal/configuration.ts +4 -2
  56. package/src/internal/crypto.ts +15 -0
  57. package/src/internal/passkey/admission.ts +22 -15
  58. package/src/internal/passkey/credentials.ts +4 -4
  59. package/src/internal/passkey/enrollment.ts +7 -7
  60. package/src/internal/passkey/flow.ts +8 -8
  61. package/src/internal/passkey/management.ts +7 -2
  62. package/src/internal/passkey/registration-ceremony.ts +2 -2
  63. package/src/internal/passkey/registration-custody.ts +1 -1
  64. package/src/internal/passkey/registration-write.ts +12 -12
  65. package/src/internal/passkey/state.ts +10 -13
  66. package/src/internal/passkey/target.ts +16 -8
  67. package/src/internal/passkey/write-state.ts +10 -14
  68. package/src/internal/passkey/write-target.ts +11 -3
  69. package/src/internal/passkeys.ts +121 -70
  70. package/src/internal/persistence.ts +28 -11
  71. package/src/internal/phone-kernel.ts +26 -17
  72. package/src/internal/registration.ts +10 -8
  73. package/src/internal/storage-mapping.ts +21 -13
  74. package/src/internal/storage-validation.ts +219 -139
  75. package/src/internal/transaction-execution-kernel.ts +30 -11
@@ -26,7 +26,7 @@ import {
26
26
  snapshotPasskeySync,
27
27
  } from "@yielded/auth/Passkey";
28
28
  import { SubjectId } from "@yielded/auth/Schema";
29
- import { Effect, Schema } from "effect";
29
+ import { type Crypto, Effect, Schema } from "effect";
30
30
  import type { Statement } from "effect/sql/Statement";
31
31
 
32
32
  import type { PersistenceMappingError } from "../mapping-error";
@@ -130,7 +130,7 @@ export const makePasskeyTargetKernel = (
130
130
  ): Effect.fn.Return<
131
131
  PasskeyExecution,
132
132
  PasskeyConfigurationError,
133
- LifecycleHooks | NativeDatabase
133
+ LifecycleHooks | NativeDatabase | Crypto.Crypto
134
134
  > {
135
135
  const hooks = yield* LifecycleHooks;
136
136
 
@@ -437,7 +437,7 @@ export const makePasskeyTargetKernel = (
437
437
  ): Effect.Effect<
438
438
  PasskeyCredentialServices,
439
439
  PasskeyConfigurationError | PersistenceMappingError,
440
- RSetup | NativeDatabase
440
+ RSetup | NativeDatabase | Crypto.Crypto
441
441
  > =>
442
442
  Effect.gen(function* () {
443
443
  const mapping = yield* capturedMapping(source, "read", configuration);
@@ -455,7 +455,7 @@ export const makePasskeyTargetKernel = (
455
455
  ): Effect.Effect<
456
456
  PasskeyEnrollmentContextServices,
457
457
  PasskeyConfigurationError | PersistenceMappingError,
458
- RSetup | NativeDatabase
458
+ RSetup | NativeDatabase | Crypto.Crypto
459
459
  > =>
460
460
  Effect.gen(function* () {
461
461
  const mapping = yield* capturedMapping(source, "context", configuration);
@@ -473,7 +473,7 @@ export const makePasskeyTargetKernel = (
473
473
  ): Effect.Effect<
474
474
  PasskeyPersistenceServices,
475
475
  PasskeyConfigurationError | PersistenceMappingError,
476
- RSetup | LifecycleHooks | NativeDatabase
476
+ RSetup | LifecycleHooks | NativeDatabase | Crypto.Crypto
477
477
  > =>
478
478
  Effect.gen(function* () {
479
479
  const mapping = yield* capturedMapping(source, "assertion", configuration);
@@ -490,7 +490,7 @@ export const makePasskeyTargetKernel = (
490
490
  ): Effect.Effect<
491
491
  PasskeyRegistrationCeremonyServices,
492
492
  PasskeyConfigurationError | PersistenceMappingError,
493
- RSetup | LifecycleHooks | NativeDatabase
493
+ RSetup | LifecycleHooks | NativeDatabase | Crypto.Crypto
494
494
  > =>
495
495
  Effect.gen(function* () {
496
496
  const mapping = yield* capturedMapping(source, "registration", configuration);
@@ -508,7 +508,11 @@ export const makePasskeyTargetKernel = (
508
508
  services: PasskeyPersistenceServices,
509
509
  append: (statement: Statement<unknown>) => void,
510
510
  ) => Effect.Effect<A, E, R>,
511
- ): Effect.Effect<A, PasskeyCoordinatorError<E>, R | RSetup | LifecycleHooks | NativeDatabase> =>
511
+ ): Effect.Effect<
512
+ A,
513
+ PasskeyCoordinatorError<E>,
514
+ R | RSetup | LifecycleHooks | NativeDatabase | Crypto.Crypto
515
+ > =>
512
516
  Effect.flatMap(capturedMapping(source, "assertion", configuration), (mapping) =>
513
517
  Effect.flatMap(LifecycleHooks, (hooks) =>
514
518
  coordinateTransactionOwner(
@@ -545,7 +549,11 @@ export const makePasskeyTargetKernel = (
545
549
  services: PasskeyRegistrationCeremonyServices,
546
550
  append: (statement: Statement<unknown>) => void,
547
551
  ) => Effect.Effect<A, E, R>,
548
- ): Effect.Effect<A, PasskeyCoordinatorError<E>, R | RSetup | LifecycleHooks | NativeDatabase> =>
552
+ ): Effect.Effect<
553
+ A,
554
+ PasskeyCoordinatorError<E>,
555
+ R | RSetup | LifecycleHooks | NativeDatabase | Crypto.Crypto
556
+ > =>
549
557
  Effect.flatMap(capturedMapping(source, "registration", configuration), (mapping) =>
550
558
  Effect.flatMap(LifecycleHooks, (hooks) =>
551
559
  coordinateTransactionOwner(
@@ -1,7 +1,5 @@
1
1
  /* oxlint-disable no-explicit-any -- existing mapped-row bridge; public adapters preserve table, ID, and Effect types. */
2
2
 
3
- // oxlint-disable-next-line import/extensions -- Noble exposes explicit .js subpaths.
4
- import { sha256 } from "@noble/hashes/sha2.js";
5
3
  import {
6
4
  type PasskeyActionAuthorization,
7
5
  type PasskeyCeremony,
@@ -18,11 +16,11 @@ import {
18
16
  PasskeyManagementPolicy,
19
17
  snapshotPasskeySync,
20
18
  } from "@yielded/auth/Passkey";
21
- import { TokenDigest, type SubjectId } from "@yielded/auth/Schema";
19
+ import { type SubjectId } from "@yielded/auth/Schema";
22
20
  import { SessionInvalidationWindow } from "@yielded/auth/Sessions";
23
21
  import { DateTime, Effect, Schema } from "effect";
24
- import { Base64Url } from "effect/encoding";
25
22
 
23
+ import { digest as digestText } from "../crypto";
26
24
  import type { PersistenceMappingError } from "../mapping-error";
27
25
  import type { QueryOperations } from "../query-operations";
28
26
  import type { makeTransactionKernel } from "../transaction-kernel";
@@ -85,10 +83,7 @@ export const makePasskeyWriteStateKernel = (
85
83
  const digest = <S extends Schema.Codec<unknown, unknown, never, never>>(
86
84
  schema: S,
87
85
  value: S["Type"],
88
- ) =>
89
- TokenDigest.make(
90
- Base64Url.encode(sha256(new TextEncoder().encode(jsonStorage(schema).encode(value)))),
91
- );
86
+ ) => digestText(jsonStorage(schema).encode(value));
92
87
 
93
88
  const currentSubject = Effect.fn("passkey.currentWriteSubject")(function* (
94
89
  mapping: any,
@@ -205,7 +200,7 @@ export const makePasskeyWriteStateKernel = (
205
200
  read.subjectIds.equals(table.decodeSubjectId(copiedRow(row)), subject.nativeId),
206
201
  );
207
202
  const ownership = read.credentialOwnership;
208
- const key = credentialKey(decoded.rpId, decoded.protocolCredentialId);
203
+ const key = yield* credentialKey(decoded.rpId, decoded.protocolCredentialId);
209
204
 
210
205
  const tuple = (yield* owner.read(
211
206
  ownership.table,
@@ -226,7 +221,7 @@ export const makePasskeyWriteStateKernel = (
226
221
  row[table.credentialKey] === key,
227
222
  );
228
223
  const handle = read.handleOwnership;
229
- const hashedHandle = handleKey(decoded.rpId, decoded.userHandle);
224
+ const hashedHandle = yield* handleKey(decoded.rpId, decoded.userHandle);
230
225
 
231
226
  const held = (yield* owner.read(
232
227
  handle.table,
@@ -470,9 +465,10 @@ export const makePasskeyWriteStateKernel = (
470
465
  }
471
466
  owner.postconditions.push(mapping.invalidation.postcondition(input));
472
467
  const flow = mapping.flow;
468
+ const scope = yield* subjectScope(subject.subjectId);
473
469
 
474
470
  const where = both(
475
- equal(flow.table, { [flow.subjectScope]: subjectScope(subject.subjectId) }),
471
+ equal(flow.table, { [flow.subjectScope]: scope }),
476
472
  sql`${col(flow.table, flow.state)} in (${flow.states.Pending}, ${flow.states.Claimed})`,
477
473
  );
478
474
 
@@ -485,7 +481,7 @@ export const makePasskeyWriteStateKernel = (
485
481
  for (const observation of owner.observations)
486
482
  if (observation.table === flow.table)
487
483
  observation.rows = observation.rows.map((row) =>
488
- row[flow.subjectScope] === subjectScope(subject.subjectId) &&
484
+ row[flow.subjectScope] === scope &&
489
485
  [flow.states.Pending, flow.states.Claimed].includes(row[flow.state])
490
486
  ? { ...row, [flow.state]: flow.states.Rejected, [flow.version]: owner.marker }
491
487
  : row,
@@ -559,8 +555,8 @@ export const makePasskeyWriteStateKernel = (
559
555
  });
560
556
 
561
557
  const input = { subjectId: subject.nativeId, credential, summary: safe, marker: owner.marker };
562
- const tupleKey = credentialKey(credential.rpId, credential.protocolCredentialId);
563
- const hashedHandle = handleKey(credential.rpId, credential.userHandle);
558
+ const tupleKey = yield* credentialKey(credential.rpId, credential.protocolCredentialId);
559
+ const hashedHandle = yield* handleKey(credential.rpId, credential.userHandle);
564
560
  const tuple = read.credentialOwnership;
565
561
 
566
562
  const absent = yield* owner.read(
@@ -10,7 +10,7 @@ import {
10
10
  import * as M from "@yielded/auth/Passkey";
11
11
  import { SubjectId } from "@yielded/auth/Schema";
12
12
  import { SessionInvalidationWindow } from "@yielded/auth/Sessions";
13
- import { Effect, Layer, Schema } from "effect";
13
+ import { type Crypto, Effect, Layer, Schema } from "effect";
14
14
  import type { Statement } from "effect/sql/Statement";
15
15
 
16
16
  import type { PasskeyMappingSource } from "../models/passkey-model";
@@ -371,7 +371,11 @@ export const makePasskeyWriteTargetKernel = (
371
371
  services: PasskeyManagementServices,
372
372
  append: (statement: Statement<unknown>) => void,
373
373
  ) => Effect.Effect<A, E, R>,
374
- ): Effect.Effect<A, PasskeyCoordinatorError<E>, R | RSetup | LifecycleHooks | NativeDatabase> =>
374
+ ): Effect.Effect<
375
+ A,
376
+ PasskeyCoordinatorError<E>,
377
+ R | RSetup | LifecycleHooks | NativeDatabase | Crypto.Crypto
378
+ > =>
375
379
  Effect.gen(function* () {
376
380
  const mapping = yield* writeMapping(source, configuration, false);
377
381
  const hooks = yield* LifecycleHooks;
@@ -406,7 +410,11 @@ export const makePasskeyWriteTargetKernel = (
406
410
  services: PasskeyRegistrationServices<Value>,
407
411
  append: (statement: Statement<unknown>) => void,
408
412
  ) => Effect.Effect<A, E, R>,
409
- ): Effect.Effect<A, PasskeyCoordinatorError<E>, R | RSetup | LifecycleHooks | NativeDatabase> =>
413
+ ): Effect.Effect<
414
+ A,
415
+ PasskeyCoordinatorError<E>,
416
+ R | RSetup | LifecycleHooks | NativeDatabase | Crypto.Crypto
417
+ > =>
410
418
  Effect.gen(function* () {
411
419
  const mapping = yield* writeMapping(source, configuration, true);
412
420
  const hooks = yield* LifecycleHooks;
@@ -1,4 +1,3 @@
1
- import { digest } from "@yielded/auth-crypto";
2
1
  import {
3
2
  PasskeyCredentials,
4
3
  PasskeyCredential,
@@ -19,6 +18,7 @@ import {
19
18
  type MappingInput,
20
19
  type PasskeyFeature,
21
20
  } from "./configuration";
21
+ import { digest } from "./crypto";
22
22
  import { PersistenceMappingError } from "./mapping-error";
23
23
  import {
24
24
  requiredPasskeyCredentialConstraints,
@@ -54,6 +54,13 @@ const ModuleRow = Schema.Struct({
54
54
  policyRevision: Schema.String,
55
55
  });
56
56
 
57
+ const ConfiguredModuleRow = Schema.Struct({
58
+ moduleId: Schema.String,
59
+ active: Schema.Boolean,
60
+ policyRevision: Schema.String,
61
+ admitted: Schema.Literals(["present", "missing"]),
62
+ });
63
+
57
64
  const credentialData = PasskeyCredential.mapFields(
58
65
  ({ revision: _revision, active: _active, ...fields }) => fields,
59
66
  );
@@ -390,7 +397,21 @@ export const makeComposedPasskeys = (
390
397
  const client = yield* SqlClient.SqlClient;
391
398
 
392
399
  const policies = yield* Effect.forEach(features, (feature) =>
393
- Effect.map(feature.policy, (policy) => ({ feature, policy })),
400
+ Effect.gen(function* () {
401
+ const policy = yield* feature.policy;
402
+ const encoded = yield* Schema.encodeEffect(policyJson)(policy);
403
+
404
+ const policyRevision = yield* digest(
405
+ yield* Schema.encodeEffect(seedJson)({
406
+ policy,
407
+ ...(feature.managementPolicy === undefined
408
+ ? {}
409
+ : { management: feature.managementPolicy }),
410
+ }),
411
+ );
412
+
413
+ return { feature, policy, encoded, policyRevision };
414
+ }),
394
415
  );
395
416
 
396
417
  const m = yield* Effect.try({
@@ -413,82 +434,112 @@ export const makeComposedPasskeys = (
413
434
  };
414
435
 
415
436
  const native = yield* CurrentProofSql;
437
+ const module = m.table("passkeyModules");
438
+ const columns = getTableColumns(module);
439
+ const admission = m.table("passkeyAdmissions");
440
+ const ac = getTableColumns(admission);
441
+
442
+ // Read live configuration on every acquisition. Matching modules and
443
+ // admission ownership need no initialization writes or advisory lock.
444
+ const configured = yield* native
445
+ .select({
446
+ moduleId: columns.moduleId,
447
+ active: columns.active,
448
+ policyRevision: columns.policyRevision,
449
+ // Predicate SQL preserves qualifiers through single-table projections.
450
+ admitted: sql`case when exists(select 1 from ${admission}
451
+ where ${and(eq(ac.authorityScope, namespace), eq(ac.moduleId, columns.moduleId))})
452
+ then 'present' else 'missing' end`,
453
+ })
454
+ .from(module)
455
+ .where(
456
+ inArray(
457
+ columns.moduleId,
458
+ policies.map(({ feature }) => feature.moduleId),
459
+ ),
460
+ )
461
+ .pipe(
462
+ Effect.flatMap(Schema.decodeUnknownEffect(Schema.Array(ConfiguredModuleRow))),
463
+ Effect.map((rows) =>
464
+ policies.every(({ feature, policyRevision }) =>
465
+ rows.some(
466
+ (row) =>
467
+ row.moduleId === feature.moduleId &&
468
+ row.active &&
469
+ row.admitted === "present" &&
470
+ row.policyRevision === policyRevision,
471
+ ),
472
+ ),
473
+ ),
474
+ );
416
475
 
417
- yield* client.withTransaction(
418
- Effect.gen(function* () {
419
- if (dialect === "pg")
420
- yield* client`select pg_advisory_xact_lock(hashtext(${`${namespace}/passkeys`}))`;
421
- for (const { feature, policy } of policies) {
422
- const encoded = yield* Schema.encodeEffect(policyJson)(policy);
423
-
424
- const policyRevision = digest(
425
- yield* Schema.encodeEffect(seedJson)({
426
- policy,
427
- ...(feature.managementPolicy === undefined
428
- ? {}
429
- : { management: feature.managementPolicy }),
430
- }),
431
- );
432
-
433
- const module = m.table("passkeyModules");
434
- const columns = getTableColumns(module);
435
-
436
- yield* native
437
- .insert(module)
438
- .values({ moduleId: feature.moduleId, active: true, policy: encoded, policyRevision })
439
- .onConflictDoNothing();
440
-
441
- const rows = yield* native
442
- .select()
443
- .from(module)
444
- .where(eq(columns.moduleId, feature.moduleId));
445
-
446
- const row = yield* Schema.decodeUnknownEffect(ModuleRow)(rows[0]);
447
-
448
- if (!row.active)
449
- return yield* configurationError(`Passkey module ${feature.moduleId} is disabled`);
450
- if (row.policyRevision !== policyRevision) {
451
- const previous = yield* Schema.decodeEffect(policyJson)(row.policy);
452
-
453
- if (policy.generation <= previous.generation)
454
- return yield* configurationError(
455
- `Increase the passkey policy generation to change ${feature.moduleId}`,
456
- );
476
+ if (!configured)
477
+ yield* client.withTransaction(
478
+ Effect.gen(function* () {
479
+ if (dialect === "pg")
480
+ yield* client`select pg_advisory_xact_lock(hashtext(${`${namespace}/passkeys`}))`;
481
+ for (const { feature, policy, encoded, policyRevision } of policies) {
457
482
  yield* native
458
- .update(module)
459
- .set({ policy: encoded, policyRevision })
460
- .where(
461
- and(
462
- eq(columns.moduleId, feature.moduleId),
463
- eq(columns.policyRevision, row.policyRevision),
464
- eq(columns.active, true),
465
- ),
466
- );
467
-
468
- const updated = yield* native
483
+ .insert(module)
484
+ .values({
485
+ moduleId: feature.moduleId,
486
+ active: true,
487
+ policy: encoded,
488
+ policyRevision,
489
+ })
490
+ .onConflictDoNothing();
491
+
492
+ const rows = yield* native
469
493
  .select()
470
494
  .from(module)
471
495
  .where(eq(columns.moduleId, feature.moduleId));
472
496
 
473
- const applied = yield* Schema.decodeUnknownEffect(ModuleRow)(updated[0]);
497
+ const row = yield* Schema.decodeUnknownEffect(ModuleRow)(rows[0]);
474
498
 
475
- if (!applied.active || applied.policyRevision !== policyRevision)
476
- return yield* configurationError(
477
- `Passkey policy changed while configuring ${feature.moduleId}`,
478
- );
499
+ if (!row.active)
500
+ return yield* configurationError(`Passkey module ${feature.moduleId} is disabled`);
501
+ if (row.policyRevision !== policyRevision) {
502
+ const previous = yield* Schema.decodeEffect(policyJson)(row.policy);
503
+
504
+ if (policy.generation <= previous.generation)
505
+ return yield* configurationError(
506
+ `Increase the passkey policy generation to change ${feature.moduleId}`,
507
+ );
508
+ yield* native
509
+ .update(module)
510
+ .set({ policy: encoded, policyRevision })
511
+ .where(
512
+ and(
513
+ eq(columns.moduleId, feature.moduleId),
514
+ eq(columns.policyRevision, row.policyRevision),
515
+ eq(columns.active, true),
516
+ ),
517
+ );
518
+
519
+ const updated = yield* native
520
+ .select()
521
+ .from(module)
522
+ .where(eq(columns.moduleId, feature.moduleId));
523
+
524
+ const applied = yield* Schema.decodeUnknownEffect(ModuleRow)(updated[0]);
525
+
526
+ if (!applied.active || applied.policyRevision !== policyRevision)
527
+ return yield* configurationError(
528
+ `Passkey policy changed while configuring ${feature.moduleId}`,
529
+ );
530
+ }
531
+ yield* native
532
+ .insert(m.table("passkeyAdmissions"))
533
+ .values({
534
+ authorityScope: namespace,
535
+ moduleId: feature.moduleId,
536
+ version: policyRevision,
537
+ ownerMarker: policyRevision,
538
+ })
539
+ .onConflictDoNothing();
479
540
  }
480
- yield* native
481
- .insert(m.table("passkeyAdmissions"))
482
- .values({
483
- authorityScope: namespace,
484
- moduleId: feature.moduleId,
485
- version: policyRevision,
486
- ownerMarker: policyRevision,
487
- })
488
- .onConflictDoNothing();
489
- }
490
- }),
491
- );
541
+ }),
542
+ );
492
543
 
493
544
  const credential = yield* makeTargetPasskeyCredentials<ReadMapping, never>(
494
545
  m.read,
@@ -1,4 +1,3 @@
1
- import { digest, randomId } from "@yielded/auth-crypto";
2
1
  import { EmailAddressPersistence, EmailUnavailable } from "@yielded/auth/Email";
3
2
  import { LifecycleHooks } from "@yielded/auth/Hooks";
4
3
  import type { PasskeyConfig } from "@yielded/auth/Passkey";
@@ -7,7 +6,7 @@ import { hooksLayer } from "@yielded/auth/Persistence";
7
6
  import { PhoneAdmission, PhoneSignInTargets, PhoneOtpUnavailable } from "@yielded/auth/PhoneOtp";
8
7
  import { ProofPersistence, ProofUnavailable } from "@yielded/auth/Proofs";
9
8
  import { AuthenticationAuthority, SessionUnavailable } from "@yielded/auth/Sessions";
10
- import { Context, Effect, Layer, Schema } from "effect";
9
+ import { Context, type Crypto, Effect, Layer, Schema } from "effect";
11
10
  import { SqlClient } from "effect/sql";
12
11
 
13
12
  import {
@@ -27,6 +26,7 @@ import {
27
26
  type PasskeyRequirement,
28
27
  type MappingInput,
29
28
  } from "./configuration";
29
+ import { randomId } from "./crypto";
30
30
  import { makeEmailKernel, CurrentEmailSql, type EmailSqlDatabase } from "./email-kernel";
31
31
  import { PersistenceMappingError } from "./mapping-error";
32
32
  import {
@@ -52,7 +52,11 @@ import {
52
52
  type StorageRole,
53
53
  type StorageTable,
54
54
  } from "./storage-tables";
55
- import { validateStorage } from "./storage-validation";
55
+ import {
56
+ validateStorageBatch,
57
+ withStorageValidation,
58
+ type StorageValidation,
59
+ } from "./storage-validation";
56
60
  import { makeTransactionExecutionKernel } from "./transaction-execution-kernel";
57
61
  import {
58
62
  makeTransactionKernel,
@@ -74,7 +78,12 @@ export interface Backend<T extends object, R> {
74
78
  }) => Effect.Effect<
75
79
  Context.Context<never>,
76
80
  PersistenceConfigurationError,
77
- PasskeyConfig | LifecycleHooks | SqlClient.SqlClient | NativeDatabase | CurrentProofSql
81
+ | PasskeyConfig
82
+ | LifecycleHooks
83
+ | SqlClient.SqlClient
84
+ | NativeDatabase
85
+ | CurrentProofSql
86
+ | Crypto.Crypto
78
87
  >;
79
88
  }
80
89
 
@@ -208,8 +217,10 @@ export const createPersistence = <T extends object, R>(
208
217
  if (idColumn === undefined || idColumn.type === "boolean")
209
218
  throw configError("Subject ID must map a text or integer column");
210
219
 
211
- const prefix =
212
- options.prefix ?? `auth_${digest(auth.namespace).slice(0, 12).replaceAll("-", "_")}`;
220
+ const prefix = options.prefix;
221
+
222
+ if (managed && (prefix === undefined || !/^[A-Za-z_][A-Za-z0-9_]{0,100}$/.test(prefix)))
223
+ throw configError("Managed persistence requires an explicit SQL table prefix");
213
224
 
214
225
  const schema: Partial<Record<StorageRole, T>> = { ...options.tables };
215
226
 
@@ -310,6 +321,8 @@ export const createPersistence = <T extends object, R>(
310
321
  return yield* configError(
311
322
  "Persistence configuration belongs to a different Auth definition",
312
323
  );
324
+ const validations: StorageValidation[] = [];
325
+
313
326
  for (const role of roles) {
314
327
  const table = (storage.schema as Partial<Record<StorageRole, T>>)[role];
315
328
 
@@ -321,12 +334,14 @@ export const createPersistence = <T extends object, R>(
321
334
  if (columns[name] === undefined)
322
335
  return yield* configError(`Missing ${role}.${name} column`);
323
336
  }
324
- yield* validateStorage(dialect, description, storageTables[role].unique);
337
+ validations.push({ table: description, required: storageTables[role].unique });
325
338
  }
326
- yield* validateStorage(dialect, backend.describe(storage.subjects.table as T), [
327
- [storage.subjects.id],
328
- ]);
329
- const mappings = makeMappings(storage);
339
+ validations.push({
340
+ table: backend.describe(storage.subjects.table as T),
341
+ required: [[storage.subjects.id]],
342
+ });
343
+ yield* validateStorageBatch(dialect, validations);
344
+ const mappings = yield* makeMappings(storage);
330
345
 
331
346
  const standalone = <E>(error: () => E) =>
332
347
  requireStandalone(error, client.transactionService);
@@ -564,6 +579,7 @@ export const createPersistence = <T extends object, R>(
564
579
  Context.Context<never>,
565
580
  PersistenceConfigurationError,
566
581
  | PasskeyRequirement<A>
582
+ | Crypto.Crypto
567
583
  | LifecycleHooks
568
584
  | SqlClient.SqlClient
569
585
  | NativeDatabase
@@ -582,6 +598,7 @@ export const createPersistence = <T extends object, R>(
582
598
  // The checked capability metadata above determines exactly these service keys.
583
599
  return context as Context.Context<Ports<C, Id, A>>;
584
600
  }).pipe(
601
+ withStorageValidation,
585
602
  Effect.mapError((error) =>
586
603
  Schema.is(PersistenceConfigurationError)(error)
587
604
  ? error
@@ -1,4 +1,3 @@
1
- import { digest, randomId } from "@yielded/auth-crypto";
2
1
  import {
3
2
  PhoneCustody,
4
3
  PhoneCommandId,
@@ -14,6 +13,7 @@ import type { SubjectId } from "@yielded/auth/Schema";
14
13
  import { AuthenticationRequirement, SecurityRevision } from "@yielded/auth/Sessions";
15
14
  import { Context, Effect, Option, Schema } from "effect";
16
15
 
16
+ import { digest, randomId } from "./crypto";
17
17
  /* oxlint-disable no-explicit-any -- existing storage kernels erase foreign table shapes; domain errors remain typed. */
18
18
  import type { SqlExpression as SQL } from "./query-operations";
19
19
  import type { QueryOperations, SqlFragment, SqlColumn } from "./query-operations";
@@ -100,7 +100,7 @@ export const makePhoneKernel = <
100
100
  moduleId: mapping.moduleId,
101
101
  record: value,
102
102
  }),
103
- [mapping.state.version]: randomId(),
103
+ [mapping.state.version]: yield* randomId,
104
104
  };
105
105
 
106
106
  if (observed.rows.length === 0) {
@@ -128,7 +128,7 @@ export const makePhoneKernel = <
128
128
  mapping: any,
129
129
  phoneNumber: string,
130
130
  ) {
131
- const key = scope(mapping.moduleId, "custody", phoneNumber),
131
+ const key = yield* scope(mapping.moduleId, "custody", phoneNumber),
132
132
  observation = yield* stateRead(mapping, key);
133
133
 
134
134
  const custody =
@@ -327,7 +327,7 @@ export const makePhoneKernel = <
327
327
  now = yield* owner.now(mapping),
328
328
  policy = mapping.admission;
329
329
 
330
- const key = scope(mapping.moduleId, "admission", input.action + "/" + input.requestId),
330
+ const key = yield* scope(mapping.moduleId, "admission", input.action + "/" + input.requestId),
331
331
  receipt = yield* stateRead(mapping, key);
332
332
 
333
333
  if (receipt.rows.length !== 0) {
@@ -338,7 +338,7 @@ export const makePhoneKernel = <
338
338
 
339
339
  return (
340
340
  saved.fingerprint === input.fingerprint &&
341
- saved.network === digest(input.networkKey) &&
341
+ saved.network === (yield* digest(input.networkKey)) &&
342
342
  saved.accepted === true &&
343
343
  saved.expiresAtMillis > now
344
344
  );
@@ -347,11 +347,20 @@ export const makePhoneKernel = <
347
347
  const window = Math.floor(now / policy.windowMillis),
348
348
  entries = [
349
349
  {
350
- key: scope(mapping.moduleId, "network", input.action + "/" + digest(input.networkKey)),
350
+ key: yield* scope(
351
+ mapping.moduleId,
352
+ "network",
353
+ input.action + "/" + (yield* digest(input.networkKey)),
354
+ ),
351
355
  limit: input.action === "request" ? policy.networkRequests : policy.networkAttempts,
352
356
  },
353
357
  ...(input.action === "request"
354
- ? [{ key: scope(mapping.moduleId, "messages", "global"), limit: policy.maximumMessages }]
358
+ ? [
359
+ {
360
+ key: yield* scope(mapping.moduleId, "messages", "global"),
361
+ limit: policy.maximumMessages,
362
+ },
363
+ ]
355
364
  : []),
356
365
  ].sort((a, b) => a.key.localeCompare(b.key));
357
366
 
@@ -383,7 +392,7 @@ export const makePhoneKernel = <
383
392
  key,
384
393
  {
385
394
  fingerprint: input.fingerprint,
386
- network: digest(input.networkKey),
395
+ network: yield* digest(input.networkKey),
387
396
  accepted,
388
397
  expiresAtMillis: now + Math.min(policy.requestRetentionMillis, input.replayLifetimeMillis),
389
398
  },
@@ -446,7 +455,7 @@ export const makePhoneKernel = <
446
455
  )
447
456
  return { decision: rejected };
448
457
 
449
- const commandKey = scope(mapping.moduleId, "command", input.commandId),
458
+ const commandKey = yield* scope(mapping.moduleId, "command", input.commandId),
450
459
  command = yield* stateRead(mapping, commandKey);
451
460
 
452
461
  if (command.rows.length !== 0) return { decision: rejected };
@@ -564,10 +573,10 @@ export const makePhoneKernel = <
564
573
  ? mapping.subjectIds.toSubject(mapping.subjectIds.allocate())
565
574
  : captured.target.revision!.subjectId,
566
575
  nativeId = mapping.subjectIds.toNative(subjectId),
567
- securityRevision = SecurityRevision.make(randomId()),
568
- credentialRevision = SecurityRevision.make(randomId()),
569
- custodyRevision = SecurityRevision.make(randomId()),
570
- credentialId = captured.target.custody?.credentialId ?? randomId();
576
+ securityRevision = SecurityRevision.make(yield* randomId),
577
+ credentialRevision = SecurityRevision.make(yield* randomId),
578
+ custodyRevision = SecurityRevision.make(yield* randomId),
579
+ credentialId = captured.target.custody?.credentialId ?? (yield* randomId);
571
580
 
572
581
  invariant(mapping.subjectIds.toSubject(nativeId) === subjectId);
573
582
 
@@ -647,12 +656,12 @@ export const makePhoneKernel = <
647
656
  yield* owner.update(
648
657
  i.table,
649
658
  { [i.namespace]: "phone", [i.value]: old.phoneNumber },
650
- { [i.status]: i.encodeStatus(false), [i.revision]: randomId() },
659
+ { [i.status]: i.encodeStatus(false), [i.revision]: yield* randomId },
651
660
  );
652
661
  yield* owner.update(
653
662
  c.table,
654
663
  { [c.id]: old.credentialId, [c.subjectId]: nativeId },
655
- { [c.status]: c.encodeStatus(false), [c.revision]: randomId() },
664
+ { [c.status]: c.encodeStatus(false), [c.revision]: yield* randomId },
656
665
  );
657
666
  yield* stateWrite(
658
667
  mapping,
@@ -660,8 +669,8 @@ export const makePhoneKernel = <
660
669
  {
661
670
  ...old,
662
671
  state: "retired",
663
- custodyRevision: SecurityRevision.make(randomId()),
664
- credentialRevision: SecurityRevision.make(randomId()),
672
+ custodyRevision: SecurityRevision.make(yield* randomId),
673
+ credentialRevision: SecurityRevision.make(yield* randomId),
665
674
  },
666
675
  captured.source.observation,
667
676
  );