@yielded/auth-persistence 0.1.0-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. package/README.md +27 -0
  2. package/dist/Adapter.d.mts +30 -0
  3. package/dist/Adapter.mjs +25 -0
  4. package/dist/index.d.mts +7 -0
  5. package/dist/index.mjs +7 -0
  6. package/dist/internal/PasswordPreparedPostconditions.d.mts +22 -0
  7. package/dist/internal/PasswordPreparedPostconditions.mjs +12 -0
  8. package/dist/internal/PasswordPreparedPostconditions.mjs.map +1 -0
  9. package/dist/internal/configuration.d.mts +174 -0
  10. package/dist/internal/configuration.mjs +7 -0
  11. package/dist/internal/configuration.mjs.map +1 -0
  12. package/dist/internal/email-kernel.d.mts +275 -0
  13. package/dist/internal/email-kernel.mjs +487 -0
  14. package/dist/internal/email-kernel.mjs.map +1 -0
  15. package/dist/internal/mapping-error.d.mts +10 -0
  16. package/dist/internal/mapping-error.mjs +30 -0
  17. package/dist/internal/mapping-error.mjs.map +1 -0
  18. package/dist/internal/models/common.d.mts +12 -0
  19. package/dist/internal/models/email-model.d.mts +353 -0
  20. package/dist/internal/models/email-model.mjs +21 -0
  21. package/dist/internal/models/email-model.mjs.map +1 -0
  22. package/dist/internal/models/passkey-model.d.mts +266 -0
  23. package/dist/internal/models/passkey-model.mjs +34 -0
  24. package/dist/internal/models/passkey-model.mjs.map +1 -0
  25. package/dist/internal/models/passkey-registration-ceremony-model.d.mts +49 -0
  26. package/dist/internal/models/passkey-registration-ceremony-model.mjs +18 -0
  27. package/dist/internal/models/passkey-registration-ceremony-model.mjs.map +1 -0
  28. package/dist/internal/models/passkey-write-model.d.mts +189 -0
  29. package/dist/internal/models/passkey-write-model.mjs +13 -0
  30. package/dist/internal/models/passkey-write-model.mjs.map +1 -0
  31. package/dist/internal/models/password-model.d.mts +328 -0
  32. package/dist/internal/models/password-model.mjs +23 -0
  33. package/dist/internal/models/password-model.mjs.map +1 -0
  34. package/dist/internal/models/proof-model.d.mts +285 -0
  35. package/dist/internal/models/proof-model.mjs +17 -0
  36. package/dist/internal/models/proof-model.mjs.map +1 -0
  37. package/dist/internal/models/session-model.d.mts +199 -0
  38. package/dist/internal/models/session-model.mjs +20 -0
  39. package/dist/internal/models/session-model.mjs.map +1 -0
  40. package/dist/internal/models/step-up-model.d.mts +66 -0
  41. package/dist/internal/models/step-up-model.mjs +9 -0
  42. package/dist/internal/models/step-up-model.mjs.map +1 -0
  43. package/dist/internal/oauth-app.d.mts +11 -0
  44. package/dist/internal/oauth-app.mjs +57 -0
  45. package/dist/internal/oauth-app.mjs.map +1 -0
  46. package/dist/internal/oauth-server.d.mts +10 -0
  47. package/dist/internal/oauth-server.mjs +66 -0
  48. package/dist/internal/oauth-server.mjs.map +1 -0
  49. package/dist/internal/passkey/admission.d.mts +6 -0
  50. package/dist/internal/passkey/admission.mjs +216 -0
  51. package/dist/internal/passkey/admission.mjs.map +1 -0
  52. package/dist/internal/passkey/credentials.d.mts +14 -0
  53. package/dist/internal/passkey/credentials.mjs +249 -0
  54. package/dist/internal/passkey/credentials.mjs.map +1 -0
  55. package/dist/internal/passkey/enrollment.mjs +138 -0
  56. package/dist/internal/passkey/enrollment.mjs.map +1 -0
  57. package/dist/internal/passkey/flow.d.mts +21 -0
  58. package/dist/internal/passkey/flow.mjs +452 -0
  59. package/dist/internal/passkey/flow.mjs.map +1 -0
  60. package/dist/internal/passkey/management.mjs +231 -0
  61. package/dist/internal/passkey/management.mjs.map +1 -0
  62. package/dist/internal/passkey/registration-ceremony.d.mts +9 -0
  63. package/dist/internal/passkey/registration-ceremony.mjs +129 -0
  64. package/dist/internal/passkey/registration-ceremony.mjs.map +1 -0
  65. package/dist/internal/passkey/registration-custody.d.mts +2 -0
  66. package/dist/internal/passkey/registration-custody.mjs +52 -0
  67. package/dist/internal/passkey/registration-custody.mjs.map +1 -0
  68. package/dist/internal/passkey/registration-write.mjs +211 -0
  69. package/dist/internal/passkey/registration-write.mjs.map +1 -0
  70. package/dist/internal/passkey/state.d.mts +10 -0
  71. package/dist/internal/passkey/state.mjs +234 -0
  72. package/dist/internal/passkey/state.mjs.map +1 -0
  73. package/dist/internal/passkey/target.d.mts +19 -0
  74. package/dist/internal/passkey/target.mjs +190 -0
  75. package/dist/internal/passkey/target.mjs.map +1 -0
  76. package/dist/internal/passkey/write-state.d.mts +17 -0
  77. package/dist/internal/passkey/write-state.mjs +345 -0
  78. package/dist/internal/passkey/write-state.mjs.map +1 -0
  79. package/dist/internal/passkey/write-target.mjs +192 -0
  80. package/dist/internal/passkey/write-target.mjs.map +1 -0
  81. package/dist/internal/passkey-kernel.d.mts +5592 -0
  82. package/dist/internal/passkey-kernel.mjs +49 -0
  83. package/dist/internal/passkey-kernel.mjs.map +1 -0
  84. package/dist/internal/passkeys.d.mts +7 -0
  85. package/dist/internal/passkeys.mjs +368 -0
  86. package/dist/internal/passkeys.mjs.map +1 -0
  87. package/dist/internal/password-kernel.d.mts +290 -0
  88. package/dist/internal/password-kernel.mjs +704 -0
  89. package/dist/internal/password-kernel.mjs.map +1 -0
  90. package/dist/internal/persistence.d.mts +25 -0
  91. package/dist/internal/persistence.mjs +291 -0
  92. package/dist/internal/persistence.mjs.map +1 -0
  93. package/dist/internal/phone-kernel.d.mts +162 -0
  94. package/dist/internal/phone-kernel.mjs +434 -0
  95. package/dist/internal/phone-kernel.mjs.map +1 -0
  96. package/dist/internal/proof-kernel.d.mts +93 -0
  97. package/dist/internal/proof-kernel.mjs +619 -0
  98. package/dist/internal/proof-kernel.mjs.map +1 -0
  99. package/dist/internal/query-operations.d.mts +61 -0
  100. package/dist/internal/registration-contract.d.mts +16 -0
  101. package/dist/internal/registration.mjs +66 -0
  102. package/dist/internal/registration.mjs.map +1 -0
  103. package/dist/internal/session-kernel.d.mts +48 -0
  104. package/dist/internal/session-kernel.mjs +737 -0
  105. package/dist/internal/session-kernel.mjs.map +1 -0
  106. package/dist/internal/sql-persistence.d.mts +10 -0
  107. package/dist/internal/sql-persistence.mjs +37 -0
  108. package/dist/internal/sql-persistence.mjs.map +1 -0
  109. package/dist/internal/sql-query.mjs +207 -0
  110. package/dist/internal/sql-query.mjs.map +1 -0
  111. package/dist/internal/sql-table.d.mts +36 -0
  112. package/dist/internal/sql-table.mjs +81 -0
  113. package/dist/internal/sql-table.mjs.map +1 -0
  114. package/dist/internal/standalone.d.mts +9 -0
  115. package/dist/internal/standalone.mjs +13 -0
  116. package/dist/internal/standalone.mjs.map +1 -0
  117. package/dist/internal/step-up-state.d.mts +49 -0
  118. package/dist/internal/step-up-state.mjs +32 -0
  119. package/dist/internal/step-up-state.mjs.map +1 -0
  120. package/dist/internal/storage-mapping.mjs +686 -0
  121. package/dist/internal/storage-mapping.mjs.map +1 -0
  122. package/dist/internal/storage-tables.d.mts +913 -0
  123. package/dist/internal/storage-tables.mjs +387 -0
  124. package/dist/internal/storage-tables.mjs.map +1 -0
  125. package/dist/internal/storage-validation.mjs +58 -0
  126. package/dist/internal/storage-validation.mjs.map +1 -0
  127. package/dist/internal/transaction-execution-kernel.d.mts +38 -0
  128. package/dist/internal/transaction-execution-kernel.mjs +116 -0
  129. package/dist/internal/transaction-execution-kernel.mjs.map +1 -0
  130. package/dist/internal/transaction-kernel.d.mts +77 -0
  131. package/dist/internal/transaction-kernel.mjs +260 -0
  132. package/dist/internal/transaction-kernel.mjs.map +1 -0
  133. package/package.json +1 -0
  134. package/src/Adapter.ts +291 -0
  135. package/src/index.ts +6 -0
  136. package/src/internal/PasswordPreparedPostconditions.ts +30 -0
  137. package/src/internal/configuration.ts +282 -0
  138. package/src/internal/email-kernel.ts +1535 -0
  139. package/src/internal/mapping-error.ts +36 -0
  140. package/src/internal/models/common.ts +11 -0
  141. package/src/internal/models/email-model.ts +559 -0
  142. package/src/internal/models/passkey-model.ts +390 -0
  143. package/src/internal/models/passkey-registration-ceremony-model.ts +72 -0
  144. package/src/internal/models/passkey-write-model.ts +297 -0
  145. package/src/internal/models/password-model.ts +513 -0
  146. package/src/internal/models/proof-model.ts +424 -0
  147. package/src/internal/models/session-model.ts +375 -0
  148. package/src/internal/models/step-up-model.ts +135 -0
  149. package/src/internal/oauth-app.ts +82 -0
  150. package/src/internal/oauth-server.ts +89 -0
  151. package/src/internal/passkey/admission.ts +361 -0
  152. package/src/internal/passkey/credentials.ts +511 -0
  153. package/src/internal/passkey/enrollment.ts +326 -0
  154. package/src/internal/passkey/flow.ts +1062 -0
  155. package/src/internal/passkey/management.ts +421 -0
  156. package/src/internal/passkey/registration-ceremony.ts +283 -0
  157. package/src/internal/passkey/registration-custody.ts +91 -0
  158. package/src/internal/passkey/registration-write.ts +426 -0
  159. package/src/internal/passkey/state.ts +353 -0
  160. package/src/internal/passkey/target.ts +586 -0
  161. package/src/internal/passkey/write-state.ts +681 -0
  162. package/src/internal/passkey/write-target.ts +452 -0
  163. package/src/internal/passkey-kernel.ts +104 -0
  164. package/src/internal/passkeys.ts +606 -0
  165. package/src/internal/password-kernel.ts +2124 -0
  166. package/src/internal/persistence.ts +588 -0
  167. package/src/internal/phone-kernel.ts +846 -0
  168. package/src/internal/proof-kernel.ts +1954 -0
  169. package/src/internal/query-operations.ts +81 -0
  170. package/src/internal/registration-contract.ts +22 -0
  171. package/src/internal/registration.ts +161 -0
  172. package/src/internal/session-kernel.ts +2217 -0
  173. package/src/internal/sql-persistence.ts +45 -0
  174. package/src/internal/sql-query.ts +298 -0
  175. package/src/internal/sql-table.ts +104 -0
  176. package/src/internal/standalone.ts +30 -0
  177. package/src/internal/step-up-state.ts +139 -0
  178. package/src/internal/storage-mapping.ts +708 -0
  179. package/src/internal/storage-tables.ts +408 -0
  180. package/src/internal/storage-validation.ts +117 -0
  181. package/src/internal/transaction-execution-kernel.ts +313 -0
  182. package/src/internal/transaction-kernel.ts +593 -0
@@ -0,0 +1,846 @@
1
+ import { digest, randomId } from "@yielded/auth-crypto";
2
+ import {
3
+ PhoneCustody,
4
+ PhoneCommandId,
5
+ PhoneLifecycleAction,
6
+ PhoneLifecycleTarget,
7
+ PhoneLifecyclePolicy,
8
+ type PhoneMutationDecision,
9
+ PhoneOtpUnavailable,
10
+ PhoneCredentialSnapshot,
11
+ type PhoneMutation,
12
+ } from "@yielded/auth/PhoneOtp";
13
+ import type { SubjectId } from "@yielded/auth/Schema";
14
+ import { AuthenticationRequirement, SecurityRevision } from "@yielded/auth/Sessions";
15
+ import { Context, Effect, Option, Schema } from "effect";
16
+
17
+ /* oxlint-disable no-explicit-any -- existing storage kernels erase foreign table shapes; domain errors remain typed. */
18
+ import type { SqlExpression as SQL } from "./query-operations";
19
+ import type { QueryOperations, SqlFragment, SqlColumn } from "./query-operations";
20
+ import { type TransactionOwner, type makeTransactionKernel } from "./transaction-kernel";
21
+
22
+ export class CurrentPhoneTransaction extends Context.Service<
23
+ CurrentPhoneTransaction,
24
+ TransactionOwner<PhoneOtpUnavailable>
25
+ >()("effect-auth/drizzle/CurrentPhoneTransaction") {}
26
+
27
+ export const makePhoneKernel = <
28
+ Fragment extends SqlFragment = SqlFragment,
29
+ Column extends SqlColumn = SqlColumn,
30
+ >(
31
+ operations: QueryOperations<Fragment, Column>,
32
+ transactions: Pick<ReturnType<typeof makeTransactionKernel>, "both" | "makeTransactionRows">,
33
+ ) => {
34
+ const { sql } = operations;
35
+ const { both, makeTransactionRows } = transactions;
36
+ const unavailable = () => PhoneOtpUnavailable.make({});
37
+
38
+ const invariant: (value: unknown) => asserts value = (value) => {
39
+ if (!value) throw unavailable();
40
+ };
41
+
42
+ const { equal, copiedRow, col } = makeTransactionRows(unavailable);
43
+
44
+ const AdmissionReceipt = Schema.Struct({
45
+ fingerprint: Schema.NonEmptyString,
46
+ network: Schema.NonEmptyString,
47
+ accepted: Schema.Boolean,
48
+ expiresAtMillis: Schema.Natural,
49
+ });
50
+
51
+ const AdmissionCounter = Schema.Struct({ window: Schema.Natural, count: Schema.Natural });
52
+
53
+ const CommandRecord = Schema.Struct({ commandId: PhoneCommandId, action: PhoneLifecycleAction });
54
+
55
+ const State = Schema.Union([PhoneCustody, AdmissionReceipt, AdmissionCounter, CommandRecord]);
56
+
57
+ const storageCodec = Schema.fromJsonString(
58
+ Schema.Struct({ moduleId: Schema.NonEmptyString, record: State }),
59
+ );
60
+
61
+ const readStored = <A extends Schema.Top>(
62
+ mapping: any,
63
+ payload: string,
64
+ schema: A,
65
+ ): A["Type"] => {
66
+ const stored = Schema.decodeSync(storageCodec)(payload);
67
+
68
+ invariant(stored.moduleId === mapping.moduleId && Schema.is(schema)(stored.record));
69
+
70
+ return stored.record;
71
+ };
72
+
73
+ const stringTuple = Schema.fromJsonString(Schema.Array(Schema.String));
74
+
75
+ const scope = (moduleId: string, kind: string, key: string) =>
76
+ digest(Schema.encodeSync(stringTuple)(["effect-auth/phone/v1", moduleId, kind, key]));
77
+
78
+ const rejected: PhoneMutationDecision = { _tag: "Rejected" };
79
+
80
+ const stateRead = Effect.fn("PhoneNative.stateRead")(function* (mapping: any, key: string) {
81
+ const owner = yield* CurrentPhoneTransaction;
82
+
83
+ return yield* owner.read(
84
+ mapping.state.table,
85
+ equal(mapping.state.table, { [mapping.state.scope]: key }),
86
+ { limit: 1 },
87
+ );
88
+ });
89
+
90
+ const stateWrite = Effect.fn("PhoneNative.stateWrite")(function* (
91
+ mapping: any,
92
+ key: string,
93
+ value: typeof State.Type,
94
+ observed: any,
95
+ ) {
96
+ const owner = yield* CurrentPhoneTransaction;
97
+
98
+ const values = {
99
+ [mapping.state.state]: Schema.encodeSync(storageCodec)({
100
+ moduleId: mapping.moduleId,
101
+ record: value,
102
+ }),
103
+ [mapping.state.version]: randomId(),
104
+ };
105
+
106
+ if (observed.rows.length === 0) {
107
+ const inserted = yield* owner.insert(
108
+ mapping.state.table,
109
+ {
110
+ ...copiedRow(
111
+ mapping.state.encodeInsert({
112
+ scope: key,
113
+ state: values[mapping.state.state],
114
+ version: values[mapping.state.version],
115
+ }),
116
+ ),
117
+ [mapping.state.scope]: key,
118
+ ...values,
119
+ },
120
+ { [mapping.state.scope]: key },
121
+ );
122
+
123
+ observed.rows = inserted.rows;
124
+ } else yield* owner.update(mapping.state.table, { [mapping.state.scope]: key }, values);
125
+ });
126
+
127
+ const custodyRead = Effect.fn("PhoneNative.custodyRead")(function* (
128
+ mapping: any,
129
+ phoneNumber: string,
130
+ ) {
131
+ const key = scope(mapping.moduleId, "custody", phoneNumber),
132
+ observation = yield* stateRead(mapping, key);
133
+
134
+ const custody =
135
+ observation.rows.length === 0
136
+ ? null
137
+ : readStored(mapping, observation.rows[0]![mapping.state.state], PhoneCustody);
138
+
139
+ invariant(custody === null || custody.phoneNumber === phoneNumber);
140
+
141
+ return { key, observation, custody };
142
+ });
143
+
144
+ const capturePhone = Effect.fn("PhoneNative.capture")(function* (
145
+ mapping: any,
146
+ input: {
147
+ readonly action: "register" | "verify" | "change";
148
+ readonly phoneNumber: any;
149
+ readonly subjectId?: SubjectId;
150
+ readonly sourcePhoneNumber?: any;
151
+ },
152
+ ) {
153
+ const owner = yield* CurrentPhoneTransaction;
154
+ const destination = yield* custodyRead(mapping, input.phoneNumber);
155
+
156
+ const source =
157
+ input.sourcePhoneNumber === undefined
158
+ ? null
159
+ : yield* custodyRead(mapping, input.sourcePhoneNumber);
160
+
161
+ const id = input.subjectId ?? destination.custody?.subjectId;
162
+
163
+ const subject =
164
+ id === undefined
165
+ ? null
166
+ : yield* owner.read(
167
+ mapping.subject.table,
168
+ equal(mapping.subject.table, { [mapping.subject.id]: mapping.subjectIds.toNative(id) }),
169
+ { limit: 1 },
170
+ );
171
+
172
+ const row = subject?.rows[0];
173
+
174
+ const active =
175
+ row !== undefined &&
176
+ (yield* owner.check(
177
+ sql`exists(select 1 from ${mapping.subject.table} where ${both(owner.exact(mapping.subject.table, row), mapping.subject.activeCondition)})`,
178
+ ));
179
+
180
+ const targetIdentifier = yield* owner.read(
181
+ mapping.identifier.table,
182
+ equal(mapping.identifier.table, {
183
+ [mapping.identifier.namespace]: "phone",
184
+ [mapping.identifier.value]: input.phoneNumber,
185
+ }),
186
+ { limit: 1 },
187
+ );
188
+
189
+ let eligible =
190
+ input.action === "register"
191
+ ? destination.custody === null && targetIdentifier.rows.length === 0
192
+ : active &&
193
+ input.subjectId === id &&
194
+ ((destination.custody === null && targetIdentifier.rows.length === 0) ||
195
+ (destination.custody?.subjectId === id && destination.custody?.state === "unverified"));
196
+
197
+ if (input.action === "change")
198
+ eligible =
199
+ eligible &&
200
+ input.sourcePhoneNumber !== input.phoneNumber &&
201
+ source?.custody?.subjectId === id &&
202
+ source?.custody?.state === "verified";
203
+ const credentials = [];
204
+
205
+ for (const custody of [destination.custody, source?.custody])
206
+ if (
207
+ custody !== null &&
208
+ custody !== undefined &&
209
+ custody.state === "verified" &&
210
+ custody.subjectId === id
211
+ ) {
212
+ const c = mapping.credential;
213
+
214
+ const found = yield* owner.read(
215
+ c.table,
216
+ equal(c.table, {
217
+ [c.id]: custody.credentialId,
218
+ [c.subjectId]: mapping.subjectIds.toNative(id),
219
+ }),
220
+ { limit: 1 },
221
+ );
222
+
223
+ const cr = found.rows[0];
224
+
225
+ if (
226
+ cr === undefined ||
227
+ cr[c.revision] !== custody.credentialRevision ||
228
+ !(yield* owner.check(
229
+ sql`exists(select 1 from ${c.table} where ${both(owner.exact(c.table, cr), c.activeCondition)})`,
230
+ ))
231
+ )
232
+ eligible = false;
233
+ else
234
+ credentials.push({
235
+ credentialId: custody.credentialId,
236
+ revision: custody.credentialRevision,
237
+ });
238
+ }
239
+
240
+ const target: PhoneLifecycleTarget = {
241
+ phoneNumber: input.phoneNumber,
242
+ custody: destination.custody,
243
+ source: source?.custody ?? null,
244
+ revision: active
245
+ ? {
246
+ subjectId: id!,
247
+ securityRevision: SecurityRevision.make(row![mapping.subject.securityRevision]),
248
+ credentials,
249
+ }
250
+ : null,
251
+ eligible,
252
+ };
253
+
254
+ return { target, destination, source, subject, subjectRow: row, targetIdentifier };
255
+ });
256
+
257
+ const lookupPhone = Effect.fn("PhoneNative.lookup")(function* (
258
+ mapping: any,
259
+ input: { readonly moduleId: string; readonly phoneNumber: any },
260
+ ) {
261
+ invariant(input.moduleId === mapping.moduleId);
262
+
263
+ const captured = yield* capturePhone(mapping, {
264
+ action: "verify",
265
+ phoneNumber: input.phoneNumber,
266
+ });
267
+
268
+ const { custody, revision } = captured.target;
269
+
270
+ if (
271
+ custody === null ||
272
+ custody.state !== "verified" ||
273
+ custody.verifiedAtMillis === null ||
274
+ revision === null ||
275
+ !revision.credentials.some((c) => c.credentialId === custody.credentialId)
276
+ )
277
+ return Option.none();
278
+
279
+ const owner = yield* CurrentPhoneTransaction,
280
+ i = mapping.identifier,
281
+ row = captured.targetIdentifier.rows[0];
282
+
283
+ if (
284
+ row === undefined ||
285
+ row[i.revision] !== custody.custodyRevision ||
286
+ !(yield* owner.check(
287
+ sql`exists(select 1 from ${i.table} where ${both(owner.exact(i.table, row), equal(i.table, { [i.subjectId]: mapping.subjectIds.toNative(custody.subjectId) }), i.activeCondition)})`,
288
+ ))
289
+ )
290
+ return Option.none();
291
+
292
+ return Option.some(
293
+ Schema.decodeSync(PhoneCredentialSnapshot)({
294
+ moduleId: mapping.moduleId,
295
+ phoneNumber: input.phoneNumber,
296
+ custodyRevision: custody.custodyRevision,
297
+ verifiedAtMillis: custody.verifiedAtMillis,
298
+ credentialId: custody.credentialId,
299
+ credentialRevision: custody.credentialRevision,
300
+ revision,
301
+ }),
302
+ );
303
+ });
304
+
305
+ const admitPhone = Effect.fn("PhoneNative.admit")(function* (
306
+ mapping: any,
307
+ input: {
308
+ readonly moduleId: string;
309
+ readonly action: "request" | "attempt";
310
+ readonly requestId: string;
311
+ readonly fingerprint: string;
312
+ readonly networkKey: string;
313
+ readonly replayLifetimeMillis: number;
314
+ },
315
+ ) {
316
+ invariant(
317
+ input.moduleId === mapping.moduleId &&
318
+ Number.isSafeInteger(input.replayLifetimeMillis) &&
319
+ input.replayLifetimeMillis >= 0 &&
320
+ input.networkKey.length > 0 &&
321
+ input.networkKey.length <= 1024 &&
322
+ input.requestId.length > 0 &&
323
+ input.requestId.length <= 256,
324
+ );
325
+
326
+ const owner = yield* CurrentPhoneTransaction,
327
+ now = yield* owner.now(mapping),
328
+ policy = mapping.admission;
329
+
330
+ const key = scope(mapping.moduleId, "admission", input.action + "/" + input.requestId),
331
+ receipt = yield* stateRead(mapping, key);
332
+
333
+ if (receipt.rows.length !== 0) {
334
+ const saved = readStored(mapping, receipt.rows[0]![mapping.state.state], AdmissionReceipt);
335
+
336
+ if (saved.expiresAtMillis > now)
337
+ owner.postconditions.push(sql`${mapping.engineNowMillis} < ${saved.expiresAtMillis}`);
338
+
339
+ return (
340
+ saved.fingerprint === input.fingerprint &&
341
+ saved.network === digest(input.networkKey) &&
342
+ saved.accepted === true &&
343
+ saved.expiresAtMillis > now
344
+ );
345
+ }
346
+
347
+ const window = Math.floor(now / policy.windowMillis),
348
+ entries = [
349
+ {
350
+ key: scope(mapping.moduleId, "network", input.action + "/" + digest(input.networkKey)),
351
+ limit: input.action === "request" ? policy.networkRequests : policy.networkAttempts,
352
+ },
353
+ ...(input.action === "request"
354
+ ? [{ key: scope(mapping.moduleId, "messages", "global"), limit: policy.maximumMessages }]
355
+ : []),
356
+ ].sort((a, b) => a.key.localeCompare(b.key));
357
+
358
+ const updates = [];
359
+ let accepted = true;
360
+
361
+ for (const entry of entries) {
362
+ const observed = yield* stateRead(mapping, entry.key),
363
+ previous =
364
+ observed.rows.length === 0
365
+ ? undefined
366
+ : readStored(mapping, observed.rows[0]![mapping.state.state], AdmissionCounter);
367
+
368
+ const count = previous?.window === window ? previous.count : 0;
369
+
370
+ if (count >= entry.limit) accepted = false;
371
+ updates.push({ ...entry, observed, count });
372
+ }
373
+ if (accepted)
374
+ for (const update of updates)
375
+ yield* stateWrite(
376
+ mapping,
377
+ update.key,
378
+ { window, count: update.count + 1 },
379
+ update.observed,
380
+ );
381
+ yield* stateWrite(
382
+ mapping,
383
+ key,
384
+ {
385
+ fingerprint: input.fingerprint,
386
+ network: digest(input.networkKey),
387
+ accepted,
388
+ expiresAtMillis: now + Math.min(policy.requestRetentionMillis, input.replayLifetimeMillis),
389
+ },
390
+ receipt,
391
+ );
392
+ owner.postconditions.push(
393
+ sql`${mapping.engineNowMillis} >= ${window * policy.windowMillis} and ${mapping.engineNowMillis} < ${(window + 1) * policy.windowMillis}`,
394
+ );
395
+
396
+ return accepted;
397
+ });
398
+
399
+ const preparePhoneMutation = Effect.fn("PhoneNative.prepareMutation")(function* (
400
+ mapping: any,
401
+ input: PhoneMutation,
402
+ ) {
403
+ const owner = yield* CurrentPhoneTransaction;
404
+
405
+ invariant(
406
+ input.moduleId === mapping.moduleId &&
407
+ Schema.encodeSync(Schema.fromJsonString(PhoneLifecyclePolicy))(input.policy) ===
408
+ Schema.encodeSync(Schema.fromJsonString(PhoneLifecyclePolicy))(mapping.policy),
409
+ );
410
+
411
+ const captured = yield* capturePhone(mapping, {
412
+ action: input.action,
413
+ phoneNumber: input.target.phoneNumber,
414
+ ...(input.target.revision === null ? {} : { subjectId: input.target.revision.subjectId }),
415
+ ...(input.target.source === null
416
+ ? {}
417
+ : { sourcePhoneNumber: input.target.source.phoneNumber }),
418
+ });
419
+
420
+ if (
421
+ !captured.target.eligible ||
422
+ Schema.encodeSync(Schema.fromJsonString(PhoneLifecycleTarget))(captured.target) !==
423
+ Schema.encodeSync(Schema.fromJsonString(PhoneLifecycleTarget))(input.target)
424
+ )
425
+ return { decision: rejected };
426
+ const binding = input.completion.input.binding;
427
+
428
+ if (
429
+ input.completion.input.moduleId !== `${mapping.moduleId}/lifecycle` ||
430
+ input.completion.input.purpose !== "phone-lifecycle" ||
431
+ binding.identifier.namespace !== "phone" ||
432
+ binding.identifier.value !== input.target.phoneNumber
433
+ )
434
+ return { decision: rejected };
435
+ if (
436
+ input.action === "register"
437
+ ? binding._tag !== "Identifier"
438
+ : binding._tag === "Identifier" ||
439
+ binding.revision.subjectId !== input.target.revision?.subjectId ||
440
+ binding.revision.securityRevision !== input.target.revision?.securityRevision
441
+ )
442
+ return { decision: rejected };
443
+ if (
444
+ (input.action === "verify" && binding._tag !== "Subject") ||
445
+ (input.action === "change" && binding._tag !== "IdentifierChange")
446
+ )
447
+ return { decision: rejected };
448
+
449
+ const commandKey = scope(mapping.moduleId, "command", input.commandId),
450
+ command = yield* stateRead(mapping, commandKey);
451
+
452
+ if (command.rows.length !== 0) return { decision: rejected };
453
+
454
+ const now = yield* owner.now(mapping),
455
+ guards: SQL[] = [];
456
+
457
+ const credentialGuards: { id: string; condition: SQL }[] = [];
458
+
459
+ if (input.action !== "register") {
460
+ const authorization = input.authorization;
461
+
462
+ if (authorization === undefined) return { decision: rejected };
463
+ const { challenge, evidence } = authorization;
464
+ const revision = captured.target.revision!;
465
+
466
+ if (
467
+ challenge.moduleId !== mapping.moduleId ||
468
+ challenge.action !== input.action ||
469
+ challenge.commandId !== input.commandId ||
470
+ challenge.phoneNumber !== input.target.phoneNumber ||
471
+ challenge.sourcePhoneNumber !== input.target.source?.phoneNumber ||
472
+ challenge.flowId !== binding.flowId ||
473
+ challenge.bindingDigest !== binding.contextDigest ||
474
+ challenge.flowId !== evidence.flowId ||
475
+ challenge.bindingDigest !== evidence.bindingDigest ||
476
+ challenge.revision.subjectId !== revision.subjectId ||
477
+ challenge.revision.securityRevision !== revision.securityRevision ||
478
+ evidence.revision.subjectId !== revision.subjectId ||
479
+ evidence.revision.securityRevision !== revision.securityRevision
480
+ )
481
+ return { decision: rejected };
482
+ const c = mapping.credential;
483
+
484
+ for (const item of [...evidence.revision.credentials].sort((a, b) =>
485
+ a.credentialId.localeCompare(b.credentialId),
486
+ )) {
487
+ const found = yield* owner.read(
488
+ c.table,
489
+ equal(c.table, {
490
+ [c.id]: item.credentialId,
491
+ [c.subjectId]: mapping.subjectIds.toNative(revision.subjectId),
492
+ }),
493
+ { limit: 1 },
494
+ ),
495
+ row = found.rows[0];
496
+
497
+ if (row === undefined || row[c.revision] !== item.revision) return { decision: rejected };
498
+ const condition = sql`exists(select 1 from ${c.table} where ${both(owner.exact(c.table, row), c.activeCondition)})`;
499
+
500
+ if (!(yield* owner.check(condition))) return { decision: rejected };
501
+ credentialGuards.push({ id: item.credentialId, condition });
502
+ }
503
+ const proofs = evidence.proofs;
504
+
505
+ if (
506
+ proofs.length === 0 ||
507
+ proofs.some(
508
+ (p) => !evidence.revision.credentials.some((c) => c.credentialId === p.credentialId),
509
+ )
510
+ )
511
+ return { decision: rejected };
512
+
513
+ const requirements = [
514
+ authorization.requirement,
515
+ authorization.actionRequirement,
516
+ ...(mapping.subject.decodeActionRequirement === undefined
517
+ ? []
518
+ : [
519
+ Schema.decodeSync(AuthenticationRequirement)(
520
+ mapping.subject.decodeActionRequirement(
521
+ copiedRow(captured.subjectRow!),
522
+ input.action,
523
+ ),
524
+ ),
525
+ ]),
526
+ Schema.decodeSync(AuthenticationRequirement)(
527
+ mapping.subject.decodeRequirement(copiedRow(captured.subjectRow!)),
528
+ ),
529
+ ];
530
+
531
+ const age = Math.min(
532
+ input.policy.maximumEvidenceAgeMillis,
533
+ ...requirements.map((r) => r.maximumAgeMillis),
534
+ ),
535
+ factors = new Set(proofs.flatMap((p) => p.factors)),
536
+ credentials = new Set(proofs.map((p) => p.credentialId));
537
+
538
+ for (const requirement of requirements)
539
+ if (
540
+ !requirement.alternatives.some(
541
+ (a) =>
542
+ a.factors.every((f) => factors.has(f)) &&
543
+ credentials.size >= a.minimumCredentials &&
544
+ proofs.some(
545
+ (p) =>
546
+ (!a.userVerified || p.userVerified) &&
547
+ (!a.phishingResistant || p.phishingResistant),
548
+ ),
549
+ )
550
+ )
551
+ return { decision: rejected };
552
+ for (const proof of proofs) {
553
+ const at = proof.verifiedAt.epochMilliseconds;
554
+
555
+ if (at > now || now - at >= age) return { decision: rejected };
556
+ guards.push(
557
+ sql`${mapping.engineNowMillis} >= ${at} and ${mapping.engineNowMillis} < ${at + age}`,
558
+ );
559
+ }
560
+ }
561
+
562
+ const subjectId =
563
+ input.action === "register"
564
+ ? mapping.subjectIds.toSubject(mapping.subjectIds.allocate())
565
+ : captured.target.revision!.subjectId,
566
+ 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();
571
+
572
+ invariant(mapping.subjectIds.toSubject(nativeId) === subjectId);
573
+
574
+ const custody: PhoneCustody = {
575
+ phoneNumber: input.target.phoneNumber,
576
+ subjectId,
577
+ credentialId,
578
+ credentialRevision,
579
+ custodyRevision,
580
+ verifiedAtMillis: now,
581
+ state: "verified",
582
+ };
583
+
584
+ const decision: PhoneMutationDecision = {
585
+ _tag: "Accepted",
586
+ credential: {
587
+ moduleId: mapping.moduleId,
588
+ phoneNumber: input.target.phoneNumber,
589
+ custodyRevision,
590
+ verifiedAtMillis: now,
591
+ credentialId,
592
+ credentialRevision,
593
+ revision: {
594
+ subjectId,
595
+ securityRevision,
596
+ credentials: [{ credentialId, revision: credentialRevision }],
597
+ },
598
+ },
599
+ };
600
+
601
+ const mutate = Effect.gen(function* () {
602
+ if (input.action === "register")
603
+ yield* owner.insert(
604
+ mapping.subject.table,
605
+ {
606
+ ...copiedRow(
607
+ mapping.subject.encodeInsert({
608
+ id: nativeId,
609
+ securityRevision,
610
+ phoneNumber: input.target.phoneNumber,
611
+ }),
612
+ ),
613
+ [mapping.subject.id]: nativeId,
614
+ [mapping.subject.securityRevision]: securityRevision,
615
+ },
616
+ { [mapping.subject.id]: nativeId },
617
+ );
618
+ else
619
+ yield* owner.update(
620
+ mapping.subject.table,
621
+ { [mapping.subject.id]: nativeId },
622
+ { [mapping.subject.securityRevision]: securityRevision },
623
+ );
624
+
625
+ const i = mapping.identifier,
626
+ c = mapping.credential;
627
+
628
+ if (captured.source?.custody) {
629
+ const old = captured.source.custody;
630
+
631
+ const ir = yield* owner.read(
632
+ i.table,
633
+ equal(i.table, {
634
+ [i.namespace]: "phone",
635
+ [i.value]: old.phoneNumber,
636
+ [i.subjectId]: nativeId,
637
+ }),
638
+ { limit: 1 },
639
+ );
640
+
641
+ invariant(ir.rows.length === 1 && ir.rows[0]![i.revision] === old.custodyRevision);
642
+ invariant(
643
+ yield* owner.check(
644
+ sql`exists(select 1 from ${i.table} where ${both(owner.exact(i.table, ir.rows[0]!), i.activeCondition)})`,
645
+ ),
646
+ );
647
+ yield* owner.update(
648
+ i.table,
649
+ { [i.namespace]: "phone", [i.value]: old.phoneNumber },
650
+ { [i.status]: i.encodeStatus(false), [i.revision]: randomId() },
651
+ );
652
+ yield* owner.update(
653
+ c.table,
654
+ { [c.id]: old.credentialId, [c.subjectId]: nativeId },
655
+ { [c.status]: c.encodeStatus(false), [c.revision]: randomId() },
656
+ );
657
+ yield* stateWrite(
658
+ mapping,
659
+ captured.source.key,
660
+ {
661
+ ...old,
662
+ state: "retired",
663
+ custodyRevision: SecurityRevision.make(randomId()),
664
+ credentialRevision: SecurityRevision.make(randomId()),
665
+ },
666
+ captured.source.observation,
667
+ );
668
+ }
669
+ if (captured.targetIdentifier.rows.length === 0) {
670
+ const inserted = yield* owner.insert(
671
+ i.table,
672
+ {
673
+ ...copiedRow(
674
+ i.encodeInsert({
675
+ phoneNumber: input.target.phoneNumber,
676
+ subjectId: nativeId,
677
+ revision: custodyRevision,
678
+ verifiedAtMillis: now,
679
+ active: true,
680
+ }),
681
+ ),
682
+ [i.namespace]: "phone",
683
+ [i.value]: input.target.phoneNumber,
684
+ [i.subjectId]: nativeId,
685
+ [i.revision]: custodyRevision,
686
+ [i.verifiedAt]: mapping.encodeInstant(now),
687
+ [i.status]: i.encodeStatus(true),
688
+ },
689
+ { [i.namespace]: "phone", [i.value]: input.target.phoneNumber },
690
+ );
691
+
692
+ captured.targetIdentifier.rows = inserted.rows;
693
+ } else {
694
+ const row = captured.targetIdentifier.rows[0]!;
695
+
696
+ invariant(
697
+ yield* owner.check(
698
+ sql`exists(select 1 from ${i.table} where ${both(owner.exact(i.table, row), equal(i.table, { [i.subjectId]: nativeId }))})`,
699
+ ),
700
+ );
701
+ yield* owner.update(
702
+ i.table,
703
+ { [i.namespace]: "phone", [i.value]: input.target.phoneNumber },
704
+ {
705
+ [i.revision]: custodyRevision,
706
+ [i.verifiedAt]: mapping.encodeInstant(now),
707
+ [i.status]: i.encodeStatus(true),
708
+ },
709
+ );
710
+ }
711
+
712
+ const credential = yield* owner.read(c.table, equal(c.table, { [c.id]: credentialId }), {
713
+ limit: 1,
714
+ });
715
+
716
+ if (credential.rows.length === 0) {
717
+ const inserted = yield* owner.insert(
718
+ c.table,
719
+ {
720
+ ...copiedRow(
721
+ c.encodeInsert({
722
+ credentialId,
723
+ subjectId: nativeId,
724
+ revision: credentialRevision,
725
+ active: true,
726
+ }),
727
+ ),
728
+ [c.id]: credentialId,
729
+ [c.subjectId]: nativeId,
730
+ [c.revision]: credentialRevision,
731
+ [c.status]: c.encodeStatus(true),
732
+ },
733
+ { [c.id]: credentialId },
734
+ );
735
+
736
+ credential.rows = inserted.rows;
737
+ } else {
738
+ invariant(
739
+ yield* owner.check(
740
+ sql`exists(select 1 from ${c.table} where ${both(owner.exact(c.table, credential.rows[0]!), equal(c.table, { [c.subjectId]: nativeId }))})`,
741
+ ),
742
+ );
743
+ yield* owner.update(
744
+ c.table,
745
+ { [c.id]: credentialId },
746
+ { [c.revision]: credentialRevision, [c.status]: c.encodeStatus(true) },
747
+ );
748
+ }
749
+ yield* stateWrite(
750
+ mapping,
751
+ captured.destination.key,
752
+ custody,
753
+ captured.destination.observation,
754
+ );
755
+ yield* stateWrite(
756
+ mapping,
757
+ commandKey,
758
+ { commandId: input.commandId, action: input.action },
759
+ command,
760
+ );
761
+ owner.postconditions.push(
762
+ ...guards,
763
+ sql`exists(select 1 from ${i.table} where ${both(equal(i.table, { [i.namespace]: "phone", [i.value]: input.target.phoneNumber, [i.subjectId]: nativeId, [i.revision]: custodyRevision, [i.verifiedAt]: mapping.encodeInstant(now) }), i.activeCondition)})`,
764
+ ...(captured.source?.custody === null || captured.source?.custody === undefined
765
+ ? []
766
+ : [
767
+ sql`not exists(select 1 from ${i.table} where ${both(equal(i.table, { [i.namespace]: "phone", [i.value]: captured.source.custody.phoneNumber }), i.activeCondition)})`,
768
+ sql`not exists(select 1 from ${c.table} where ${both(equal(c.table, { [c.id]: captured.source.custody.credentialId }), c.activeCondition)})`,
769
+ ]),
770
+ ...credentialGuards
771
+ .filter((g) => g.id !== captured.source?.custody?.credentialId && g.id !== credentialId)
772
+ .map((g) => g.condition),
773
+ sql`exists(select 1 from ${c.table} where ${both(equal(c.table, { [c.id]: credentialId, [c.subjectId]: nativeId, [c.revision]: credentialRevision }), c.activeCondition)})`,
774
+ sql`exists(select 1 from ${mapping.subject.table} where ${both(equal(mapping.subject.table, { [mapping.subject.id]: nativeId, [mapping.subject.securityRevision]: securityRevision }), mapping.subject.activeCondition)})`,
775
+ );
776
+
777
+ return true;
778
+ });
779
+
780
+ return {
781
+ decision,
782
+ mutate,
783
+ appliedCondition: sql`exists(select 1 from ${mapping.state.table} where ${equal(mapping.state.table, { [mapping.state.scope]: commandKey })})`,
784
+ };
785
+ });
786
+
787
+ /** Bounded cursor cleanup never removes custody or semantic-command tombstones. */
788
+ const cleanupPhoneAdmission = Effect.fn("PhoneNative.cleanupAdmission")(function* (
789
+ mapping: any,
790
+ input: { readonly moduleId: string; readonly limit: number; readonly after?: string },
791
+ ) {
792
+ invariant(
793
+ input.moduleId === mapping.moduleId &&
794
+ Number.isSafeInteger(input.limit) &&
795
+ input.limit >= 1 &&
796
+ input.limit <= 1000,
797
+ );
798
+
799
+ const owner = yield* CurrentPhoneTransaction,
800
+ now = yield* owner.now(mapping),
801
+ state = mapping.state;
802
+
803
+ const observed = yield* owner.read(
804
+ state.table,
805
+ input.after === undefined ? sql`1=1` : sql`${col(state.table, state.scope)} > ${input.after}`,
806
+ { limit: input.limit, takeOnly: true, orderBy: col(state.table, state.scope) },
807
+ );
808
+
809
+ let deleted = 0;
810
+ const rows = [...observed.rows];
811
+
812
+ for (const row of rows) {
813
+ const stored = Schema.decodeSync(storageCodec)(row[state.state]);
814
+
815
+ if (stored.moduleId !== mapping.moduleId) continue;
816
+ const record = stored.record;
817
+
818
+ if (
819
+ (Schema.is(AdmissionReceipt)(record) && record.expiresAtMillis <= now) ||
820
+ (Schema.is(AdmissionCounter)(record) &&
821
+ (record.window + 1) * mapping.admission.windowMillis <= now)
822
+ ) {
823
+ yield* owner.remove(state.table, { [state.scope]: row[state.scope] });
824
+ deleted++;
825
+ }
826
+ }
827
+
828
+ return {
829
+ deleted,
830
+ nextCursor: rows.length === input.limit ? String(rows[rows.length - 1]![state.scope]) : null,
831
+ };
832
+ });
833
+
834
+ return {
835
+ unavailable,
836
+ invariant,
837
+ equal,
838
+ copiedRow,
839
+ col,
840
+ capturePhone,
841
+ lookupPhone,
842
+ admitPhone,
843
+ preparePhoneMutation,
844
+ cleanupPhoneAdmission,
845
+ };
846
+ };