@capxul/sdk 0.0.0-rc-2cdddbb91
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.
- package/README.md +417 -0
- package/dist/access-CNha7G9T.mjs +25089 -0
- package/dist/index-Czjrh-Fo.d.mts +39793 -0
- package/dist/index.d.mts +2 -0
- package/dist/index.mjs +348 -0
- package/dist/node/index.d.mts +50 -0
- package/dist/node/index.mjs +147 -0
- package/dist/organizations-BMEWnFEC.mjs +12702 -0
- package/dist/permission-approvals-Bk-zuWEQ.mjs +138 -0
- package/dist/production-Bcjowmxk.mjs +5021 -0
- package/dist/testing/index.d.mts +151 -0
- package/dist/testing/index.mjs +1159 -0
- package/package.json +103 -0
|
@@ -0,0 +1,1159 @@
|
|
|
1
|
+
import { D as convexCallErrorFromCapxul, Di as InMemoryAuthCacheAdapter, Et as deriveCapxulSafeAddress, Fi as toEmail, Fr as toChainId, Ft as CONFIGURED_MONEY_ASSETS, Ii as toJwtToken, Ir as toWeiAmount, Li as toSessionToken, Lr as absurd, Mi as toEpochMs, Ni as toEpochSeconds, Pi as toAuthUserId, Pr as toAddress, Qn as toOrgId, Ri as Errors, Si as authClientPortFromPromiseAdapter, X as assetAmountFromRaw, _ as accountFromAccountPositions, bi as toAppId, ci as readClockNow, f as smartAccountErrorFromCapxul, fn as toCountryCode, fr as toAccountId, gn as toHandle, j as wireChainId, ji as toDurationMs, oi as ClockError, pn as toKycTier, si as ClockPortTag, tt as toRaw, vn as validateHandle, w as accountReadErrorFromCapxul, xi as toPublishableKey, yi as toAllowedOrigin, yn as redactTelemetryEvent, zi as CapxulError } from "../access-CNha7G9T.mjs";
|
|
2
|
+
import { a as cloneTelemetryEvent, c as identityErrorFromCapxul, l as bootstrapErrorFromCapxul, n as createCapxulClientWithSignerControls, o as cloneTelemetryProps, r as assembleCapxulClient, s as IdentityPortTag } from "../production-Bcjowmxk.mjs";
|
|
3
|
+
import { Effect, Layer, Result, Semaphore } from "effect";
|
|
4
|
+
import { getFunctionName } from "convex/server";
|
|
5
|
+
//#region src/testing/production-client.ts
|
|
6
|
+
/** Create the one production client with per-client controls at its signing boundaries. */
|
|
7
|
+
function createCapxulProductionTestClient(input, controls) {
|
|
8
|
+
return createCapxulClientWithSignerControls(input, controls);
|
|
9
|
+
}
|
|
10
|
+
//#endregion
|
|
11
|
+
//#region src/testing/signer-faults.ts
|
|
12
|
+
const FAULT_CODES = {
|
|
13
|
+
"missing-signer": "MISSING_SIGNER",
|
|
14
|
+
"user-rejected": 4001,
|
|
15
|
+
unexpected: void 0
|
|
16
|
+
};
|
|
17
|
+
/** The value a controlled fault throws. Named so a test can assert its origin. */
|
|
18
|
+
var ControlledSignerFault = class extends Error {
|
|
19
|
+
kind;
|
|
20
|
+
/** Provider code the SDK classifiers read; absent for `unexpected`. */
|
|
21
|
+
code;
|
|
22
|
+
constructor(kind) {
|
|
23
|
+
super(`controlled signer fault (${kind})`);
|
|
24
|
+
this.name = "ControlledSignerFault";
|
|
25
|
+
this.kind = kind;
|
|
26
|
+
this.code = FAULT_CODES[kind];
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
/** Build one fault value without the harness, for a caller that writes its own control. */
|
|
30
|
+
function signerFaultCause(kind) {
|
|
31
|
+
return new ControlledSignerFault(kind);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Arm one-shot faults on the production embedded signer.
|
|
35
|
+
*
|
|
36
|
+
* ```ts
|
|
37
|
+
* const seam = armSignerFaults({ signOnce: "user-rejected" });
|
|
38
|
+
* const client = await createCapxulProductionTestClient(input, seam.controls);
|
|
39
|
+
* const result = await client.org(orgId).permissions.create(command);
|
|
40
|
+
* // result.error.code === "SIGNER_REJECTED"; nothing was submitted.
|
|
41
|
+
* // seam.records() names the boundary that refused.
|
|
42
|
+
* ```
|
|
43
|
+
*
|
|
44
|
+
* An empty plan installs no controls, which composes the ordinary client.
|
|
45
|
+
*/
|
|
46
|
+
function armSignerFaults(plan = {}) {
|
|
47
|
+
const records = [];
|
|
48
|
+
let pendingFault = plan.signOnce;
|
|
49
|
+
let pendingReset = plan.resetSessionBeforeNextSign === true;
|
|
50
|
+
let resetCalls = 0;
|
|
51
|
+
let embeddedCalls = 0;
|
|
52
|
+
function record(entry) {
|
|
53
|
+
records.push({
|
|
54
|
+
...entry,
|
|
55
|
+
atMs: Date.now()
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
const resetThenSign = async (hash, signer) => {
|
|
59
|
+
if (pendingReset) {
|
|
60
|
+
pendingReset = false;
|
|
61
|
+
resetCalls += 1;
|
|
62
|
+
if (signer.resetSession === void 0) throw new Error("armSignerFaults: resetSessionBeforeNextSign needs the embedded browser signer; this client composed a signer with no session");
|
|
63
|
+
signer.resetSession();
|
|
64
|
+
record({
|
|
65
|
+
boundary: "reset-session",
|
|
66
|
+
attempt: resetCalls,
|
|
67
|
+
hash
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
return signer.signUserOpHash(hash);
|
|
71
|
+
};
|
|
72
|
+
const faultThenDelegate = async (hash, delegate) => {
|
|
73
|
+
embeddedCalls += 1;
|
|
74
|
+
const fault = pendingFault;
|
|
75
|
+
if (fault === void 0) {
|
|
76
|
+
record({
|
|
77
|
+
boundary: "embedded-sign",
|
|
78
|
+
attempt: embeddedCalls,
|
|
79
|
+
hash
|
|
80
|
+
});
|
|
81
|
+
return delegate(hash);
|
|
82
|
+
}
|
|
83
|
+
pendingFault = void 0;
|
|
84
|
+
record({
|
|
85
|
+
boundary: "embedded-sign",
|
|
86
|
+
attempt: embeddedCalls,
|
|
87
|
+
hash,
|
|
88
|
+
fault
|
|
89
|
+
});
|
|
90
|
+
throw signerFaultCause(fault);
|
|
91
|
+
};
|
|
92
|
+
return {
|
|
93
|
+
controls: {
|
|
94
|
+
...plan.resetSessionBeforeNextSign === true ? { signUserOpHash: resetThenSign } : {},
|
|
95
|
+
...plan.signOnce === void 0 ? {} : { signEmbeddedUserOpHash: faultThenDelegate }
|
|
96
|
+
},
|
|
97
|
+
records: () => records.slice(),
|
|
98
|
+
spent: () => pendingFault === void 0 && !pendingReset
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
//#endregion
|
|
102
|
+
//#region src/testing/organization-setup.ts
|
|
103
|
+
/** Exact backend setup shape for SDK tests. */
|
|
104
|
+
function organizationSetupFixture(orgId, status = "ready", overrides = {}) {
|
|
105
|
+
return {
|
|
106
|
+
orgId: toOrgId(orgId),
|
|
107
|
+
status,
|
|
108
|
+
completedAt: status === "ready" || status === "rechecking" ? 100 : null,
|
|
109
|
+
lastCheckedAt: null,
|
|
110
|
+
nextCheckAt: null,
|
|
111
|
+
issue: null,
|
|
112
|
+
lastSendIssue: null,
|
|
113
|
+
recovery: status === "awaitingAuthorization" ? "authorize" : "none",
|
|
114
|
+
...overrides
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/** In-memory setup transport for packed consumer tests. */
|
|
118
|
+
function createTestOrganizationSetup() {
|
|
119
|
+
const rows = /* @__PURE__ */ new Map();
|
|
120
|
+
const handles = /* @__PURE__ */ new Map();
|
|
121
|
+
const listeners = /* @__PURE__ */ new Map();
|
|
122
|
+
const set = (setup) => {
|
|
123
|
+
rows.set(String(setup.orgId), setup);
|
|
124
|
+
for (const listener of listeners.get(String(setup.orgId)) ?? []) listener({
|
|
125
|
+
status: "value",
|
|
126
|
+
value: setup
|
|
127
|
+
});
|
|
128
|
+
};
|
|
129
|
+
const get = async (orgId) => {
|
|
130
|
+
const value = rows.get(String(orgId));
|
|
131
|
+
return value === void 0 ? {
|
|
132
|
+
ok: false,
|
|
133
|
+
error: Errors.invalidInput("orgId", "Organization setup was not found")
|
|
134
|
+
} : {
|
|
135
|
+
ok: true,
|
|
136
|
+
value
|
|
137
|
+
};
|
|
138
|
+
};
|
|
139
|
+
return {
|
|
140
|
+
set,
|
|
141
|
+
ops: {
|
|
142
|
+
listMine: async (input = {}) => {
|
|
143
|
+
const all = [...rows.values()].filter((row) => row.completedAt === null);
|
|
144
|
+
const offset = input.cursor === void 0 ? 0 : Number(input.cursor);
|
|
145
|
+
const limit = input.limit ?? 50;
|
|
146
|
+
if (!Number.isSafeInteger(offset) || offset < 0 || !Number.isSafeInteger(limit) || limit < 1 || limit > 50) return {
|
|
147
|
+
ok: false,
|
|
148
|
+
error: Errors.invalidInput("organizationSetupPage", "Invalid cursor or limit")
|
|
149
|
+
};
|
|
150
|
+
const page = all.slice(offset, offset + limit);
|
|
151
|
+
const next = offset + page.length;
|
|
152
|
+
return {
|
|
153
|
+
ok: true,
|
|
154
|
+
value: {
|
|
155
|
+
items: page,
|
|
156
|
+
nextCursor: next < all.length ? String(next) : null
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
},
|
|
160
|
+
begin: async (draft) => {
|
|
161
|
+
const orgId = handles.get(draft.handle) ?? toOrgId(`org_test_${draft.handle}`);
|
|
162
|
+
handles.set(draft.handle, orgId);
|
|
163
|
+
const setup = rows.get(String(orgId)) ?? organizationSetupFixture(orgId, "awaitingAuthorization");
|
|
164
|
+
set(setup);
|
|
165
|
+
return {
|
|
166
|
+
ok: true,
|
|
167
|
+
value: {
|
|
168
|
+
orgId,
|
|
169
|
+
setup
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
},
|
|
173
|
+
get,
|
|
174
|
+
resume: get,
|
|
175
|
+
authorize: async (orgId) => {
|
|
176
|
+
const current = await get(orgId);
|
|
177
|
+
if (!current.ok || current.value.status !== "awaitingAuthorization") return current;
|
|
178
|
+
const setup = organizationSetupFixture(orgId, "processing", { nextCheckAt: 1 });
|
|
179
|
+
set(setup);
|
|
180
|
+
return {
|
|
181
|
+
ok: true,
|
|
182
|
+
value: setup
|
|
183
|
+
};
|
|
184
|
+
},
|
|
185
|
+
subscribe: async (orgId, listener) => {
|
|
186
|
+
const key = String(orgId);
|
|
187
|
+
const group = listeners.get(key) ?? /* @__PURE__ */ new Set();
|
|
188
|
+
listeners.set(key, group);
|
|
189
|
+
group.add(listener);
|
|
190
|
+
listener({ status: "loading" });
|
|
191
|
+
const current = rows.get(key);
|
|
192
|
+
if (current !== void 0) listener({
|
|
193
|
+
status: "value",
|
|
194
|
+
value: current
|
|
195
|
+
});
|
|
196
|
+
let active = true;
|
|
197
|
+
return {
|
|
198
|
+
ok: true,
|
|
199
|
+
value: () => {
|
|
200
|
+
if (!active) return;
|
|
201
|
+
active = false;
|
|
202
|
+
group.delete(listener);
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
},
|
|
206
|
+
invalidateSubscriptions: () => {
|
|
207
|
+
for (const group of listeners.values()) group.clear();
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
//#endregion
|
|
213
|
+
//#region src/testing/account/InMemoryAccountReadAdapter.ts
|
|
214
|
+
function fixtureAsset(symbol) {
|
|
215
|
+
const asset = CONFIGURED_MONEY_ASSETS.find((row) => row.symbol === symbol);
|
|
216
|
+
if (asset === void 0) throw new Error(`no configured settlement asset ${symbol}`);
|
|
217
|
+
return asset;
|
|
218
|
+
}
|
|
219
|
+
var InMemoryAccountReadAdapter = class {
|
|
220
|
+
#deps;
|
|
221
|
+
#rawBalance;
|
|
222
|
+
constructor(deps = {}) {
|
|
223
|
+
this.#deps = deps;
|
|
224
|
+
this.#rawBalance = deps.rawBalance ?? "0";
|
|
225
|
+
}
|
|
226
|
+
#asset() {
|
|
227
|
+
return fixtureAsset(this.#deps.assetSymbol ?? "USDC");
|
|
228
|
+
}
|
|
229
|
+
readBalance(_input) {
|
|
230
|
+
const scriptedFailure = this.#deps.failures?.readBalance;
|
|
231
|
+
if (scriptedFailure !== void 0) return Effect.fail(accountReadErrorFromCapxul("readBalance", scriptedFailure));
|
|
232
|
+
if (this.#deps.account !== void 0) return Effect.succeed(cloneAccount(this.#deps.account));
|
|
233
|
+
const asset = this.#asset();
|
|
234
|
+
const balance = assetAmountFromRaw(toWeiAmount(this.#rawBalance), asset).value;
|
|
235
|
+
return Effect.succeed(accountFromAccountPositions(toAccountId(this.#deps.accountId ?? "account_01TEST000000000000000000"), [{
|
|
236
|
+
asset: asset.assetId,
|
|
237
|
+
symbol: asset.symbol,
|
|
238
|
+
decimals: asset.decimals,
|
|
239
|
+
balance,
|
|
240
|
+
available: balance
|
|
241
|
+
}]));
|
|
242
|
+
}
|
|
243
|
+
fundFromFaucet(input) {
|
|
244
|
+
const scriptedFailure = this.#deps.failures?.fundFromFaucet;
|
|
245
|
+
if (scriptedFailure !== void 0) return Effect.fail(accountReadErrorFromCapxul("fundFromFaucet", scriptedFailure));
|
|
246
|
+
const asset = this.#asset();
|
|
247
|
+
if (input.amount.asset !== asset.assetId) return Effect.fail(accountReadErrorFromCapxul("fundFromFaucet", Errors.invalidInput("amount.asset", "must match adapter config")));
|
|
248
|
+
const minted = toRaw(input.amount, asset);
|
|
249
|
+
this.#rawBalance = (BigInt(this.#rawBalance) + minted).toString();
|
|
250
|
+
return Effect.succeed({ txHash: "0x" + "f".repeat(64) });
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
function cloneAccount(account) {
|
|
254
|
+
return {
|
|
255
|
+
id: account.id,
|
|
256
|
+
balances: account.balances.map((position) => ({ ...position })),
|
|
257
|
+
balance: account.balance === null ? null : { ...account.balance },
|
|
258
|
+
available: account.available === null ? null : { ...account.available }
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
//#endregion
|
|
262
|
+
//#region src/testing/clock/sleep-queue.ts
|
|
263
|
+
var SleepQueueClock = class {
|
|
264
|
+
currentTime = 0;
|
|
265
|
+
pending = [];
|
|
266
|
+
seqCounter = 0;
|
|
267
|
+
now = Effect.sync(() => toEpochMs(this.currentTime));
|
|
268
|
+
sleep(duration) {
|
|
269
|
+
return Effect.promise(() => new Promise((resolve) => {
|
|
270
|
+
this.enqueue({
|
|
271
|
+
deadline: this.currentTime + duration,
|
|
272
|
+
resolve,
|
|
273
|
+
seq: this.seqCounter++
|
|
274
|
+
});
|
|
275
|
+
}));
|
|
276
|
+
}
|
|
277
|
+
/** Move time forward by `ms`, releasing every sleep that falls due. */
|
|
278
|
+
advance(ms) {
|
|
279
|
+
return Effect.runPromise(this.advanceEffect(toDurationMs(ms)));
|
|
280
|
+
}
|
|
281
|
+
/** This clock as the cross-adapter conformance harness drives it. */
|
|
282
|
+
underTest() {
|
|
283
|
+
return {
|
|
284
|
+
clock: this,
|
|
285
|
+
advance: (ms) => this.advance(ms)
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
enqueue(entry) {
|
|
289
|
+
this.pending.push(entry);
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
/** The clock in context when it is a `kind`; otherwise a ClockError naming the layer `helper` needs. */
|
|
293
|
+
function requireClock(kind, helper, layer) {
|
|
294
|
+
return Effect.gen(function* () {
|
|
295
|
+
const clock = yield* ClockPortTag;
|
|
296
|
+
if (clock instanceof kind) return clock;
|
|
297
|
+
return yield* new ClockError({
|
|
298
|
+
operation: "sleep",
|
|
299
|
+
cause: /* @__PURE__ */ new Error(`${helper} requires ${layer}`)
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
//#endregion
|
|
304
|
+
//#region src/testing/clock/ManualClockAdapter.ts
|
|
305
|
+
/**
|
|
306
|
+
* Event-discipline test clock. Time moves only when a release primitive is
|
|
307
|
+
* invoked. `tickOne()` releases EXACTLY ONE pending sleep (the earliest
|
|
308
|
+
* deadline, FIFO at ties) and snaps `now()` to that deadline. `advance(ms)`
|
|
309
|
+
* composes repeated `tickOne()` calls over the resulting window — used to
|
|
310
|
+
* keep the cross-adapter conformance harness uniform with the distance model
|
|
311
|
+
* of `VirtualClockAdapter`. See `packages/sdk/docs/architecture.md` and the
|
|
312
|
+
* slice spec `docs/slices/clock-adapters.md`.
|
|
313
|
+
*/
|
|
314
|
+
var ManualClockAdapter = class ManualClockAdapter extends SleepQueueClock {
|
|
315
|
+
constructor(deps) {
|
|
316
|
+
super();
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Release exactly one pending sleep — the earliest deadline, FIFO at ties.
|
|
320
|
+
* Snaps `now()` forward to that sleep's deadline. No-op if the queue is
|
|
321
|
+
* empty (preserves the empty-tick safety property).
|
|
322
|
+
*/
|
|
323
|
+
tickOne() {
|
|
324
|
+
return Effect.runPromise(this.tickOneEffect());
|
|
325
|
+
}
|
|
326
|
+
tickOneEffect() {
|
|
327
|
+
return Effect.gen({ self: this }, function* () {
|
|
328
|
+
if (this.pending.length === 0) return;
|
|
329
|
+
const next = this.pending.splice(this.findEarliestIndex(), 1)[0];
|
|
330
|
+
if (next.deadline > this.currentTime) this.currentTime = next.deadline;
|
|
331
|
+
next.resolve();
|
|
332
|
+
yield* Effect.yieldNow;
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Releases every sleep whose deadline falls inside
|
|
337
|
+
* `[currentTime, currentTime + ms]` via repeated `tickOne()`, then snaps
|
|
338
|
+
* `now()` to `currentTime + ms`. The composition is the seam that lets the
|
|
339
|
+
* harness drive Manual and Virtual identically.
|
|
340
|
+
*/
|
|
341
|
+
advanceEffect(duration) {
|
|
342
|
+
return Effect.gen({ self: this }, function* () {
|
|
343
|
+
yield* Effect.yieldNow;
|
|
344
|
+
const target = this.currentTime + duration;
|
|
345
|
+
while (this.pending.length > 0 && this.earliestDeadline() <= target) yield* this.tickOneEffect();
|
|
346
|
+
this.currentTime = target;
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
findEarliestIndex() {
|
|
350
|
+
let bestIndex = 0;
|
|
351
|
+
let best = this.pending[0];
|
|
352
|
+
for (let i = 1; i < this.pending.length; i++) {
|
|
353
|
+
const candidate = this.pending[i];
|
|
354
|
+
if (candidate.deadline < best.deadline || candidate.deadline === best.deadline && candidate.seq < best.seq) {
|
|
355
|
+
best = candidate;
|
|
356
|
+
bestIndex = i;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return bestIndex;
|
|
360
|
+
}
|
|
361
|
+
earliestDeadline() {
|
|
362
|
+
return this.pending[this.findEarliestIndex()].deadline;
|
|
363
|
+
}
|
|
364
|
+
static forConformance() {
|
|
365
|
+
return new ManualClockAdapter({}).underTest();
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
Layer.sync(ClockPortTag, () => new ManualClockAdapter({}));
|
|
369
|
+
requireClock(ManualClockAdapter, "tickManualClockOnce", "ManualClockLayer").pipe(Effect.flatMap((clock) => clock.tickOneEffect()));
|
|
370
|
+
//#endregion
|
|
371
|
+
//#region src/testing/auth-client/InMemoryAuthClientAdapter.ts
|
|
372
|
+
const DEFAULT_OTP_TTL_MS = toDurationMs(3e5);
|
|
373
|
+
const DEFAULT_SESSION_TTL_MS = toDurationMs(864e5);
|
|
374
|
+
const DEFAULT_JWT_TTL_MS = toDurationMs(9e5);
|
|
375
|
+
/**
|
|
376
|
+
* Hermetic OTP + session state machine implementing `AuthClientPort`. Wires
|
|
377
|
+
* through `AuthCachePort` for persistence (clause A11) and `ClockPort`
|
|
378
|
+
* for TTL + expiry math. OTPs are deterministic per-email counters (`"000000"`,
|
|
379
|
+
* `"000001"`, …) so test assertions remain stable. Session tokens are
|
|
380
|
+
* deterministic per-adapter counters (`"tok_0"`, `"tok_1"`, …). Returning users
|
|
381
|
+
* (same email post-signOut) get the same `AuthUserId`.
|
|
382
|
+
*
|
|
383
|
+
* Never throws — every failure path returns `{ ok: false, error: CapxulError }`.
|
|
384
|
+
* Honors `options.signal` via a pre-check (clause A10): an aborted controller
|
|
385
|
+
* before method entry yields `{ ok: false, error: Errors.cancelled({ operation }) }`
|
|
386
|
+
* without any state change.
|
|
387
|
+
*/
|
|
388
|
+
var InMemoryAuthClientAdapter = class {
|
|
389
|
+
#pendingOtps = /* @__PURE__ */ new Map();
|
|
390
|
+
#otpCountersByEmail = /* @__PURE__ */ new Map();
|
|
391
|
+
#authUserIdByEmail = /* @__PURE__ */ new Map();
|
|
392
|
+
#allocatedAuthUserIds = /* @__PURE__ */ new Set();
|
|
393
|
+
#authCache;
|
|
394
|
+
#clock;
|
|
395
|
+
#otpTtlMs;
|
|
396
|
+
#failures;
|
|
397
|
+
#authUserIdCounter = 0;
|
|
398
|
+
#tokenCounter = 0;
|
|
399
|
+
#jwtCounter = 0;
|
|
400
|
+
constructor(deps) {
|
|
401
|
+
this.#authCache = deps.authCache;
|
|
402
|
+
this.#clock = deps.clock;
|
|
403
|
+
this.#otpTtlMs = deps.otpTtlMs ?? DEFAULT_OTP_TTL_MS;
|
|
404
|
+
this.#failures = deps.failures ?? {};
|
|
405
|
+
}
|
|
406
|
+
/** Test affordance: peek the OTP for an email, simulating "user reads email." */
|
|
407
|
+
peekOtpForTesting(email) {
|
|
408
|
+
return this.#pendingOtps.get(email)?.otp;
|
|
409
|
+
}
|
|
410
|
+
/** Test affordance: make a seeded identity own the session minted for its email. */
|
|
411
|
+
bindAuthUserIdForTesting(email, authUserId) {
|
|
412
|
+
this.#authUserIdByEmail.set(email, authUserId);
|
|
413
|
+
this.#allocatedAuthUserIds.add(authUserId);
|
|
414
|
+
}
|
|
415
|
+
async canSendOtp(input, options) {
|
|
416
|
+
if (options?.signal?.aborted) return {
|
|
417
|
+
ok: false,
|
|
418
|
+
error: Errors.cancelled({ operation: "canSendOtp" })
|
|
419
|
+
};
|
|
420
|
+
if (this.#failures.canSendOtp !== void 0) return {
|
|
421
|
+
ok: false,
|
|
422
|
+
error: this.#failures.canSendOtp
|
|
423
|
+
};
|
|
424
|
+
const pending = this.#pendingOtps.get(input.email);
|
|
425
|
+
if (pending === void 0) return {
|
|
426
|
+
ok: true,
|
|
427
|
+
value: {
|
|
428
|
+
allowed: true,
|
|
429
|
+
cooldownMs: toDurationMs(0)
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
const nowResult = await readClockNow(this.#clock);
|
|
433
|
+
if (!nowResult.ok) return nowResult;
|
|
434
|
+
const elapsedMs = nowResult.value - pending.issuedAt;
|
|
435
|
+
const cooldownMs = Math.max(0, this.#otpTtlMs - elapsedMs);
|
|
436
|
+
if (cooldownMs === 0) {
|
|
437
|
+
this.#pendingOtps.delete(input.email);
|
|
438
|
+
return {
|
|
439
|
+
ok: true,
|
|
440
|
+
value: {
|
|
441
|
+
allowed: true,
|
|
442
|
+
cooldownMs: toDurationMs(0)
|
|
443
|
+
}
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
return {
|
|
447
|
+
ok: true,
|
|
448
|
+
value: {
|
|
449
|
+
allowed: false,
|
|
450
|
+
cooldownMs: toDurationMs(cooldownMs)
|
|
451
|
+
}
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
async sendOtp(input, options) {
|
|
455
|
+
if (options?.signal?.aborted) return {
|
|
456
|
+
ok: false,
|
|
457
|
+
error: Errors.cancelled({ operation: "sendOtp" })
|
|
458
|
+
};
|
|
459
|
+
if (this.#failures.sendOtp !== void 0) return {
|
|
460
|
+
ok: false,
|
|
461
|
+
error: this.#failures.sendOtp
|
|
462
|
+
};
|
|
463
|
+
const issuedAt = await readClockNow(this.#clock);
|
|
464
|
+
if (!issuedAt.ok) return issuedAt;
|
|
465
|
+
const counter = this.#otpCountersByEmail.get(input.email) ?? 0;
|
|
466
|
+
this.#otpCountersByEmail.set(input.email, counter + 1);
|
|
467
|
+
const otp = counter.toString().padStart(6, "0");
|
|
468
|
+
this.#pendingOtps.set(input.email, {
|
|
469
|
+
otp,
|
|
470
|
+
issuedAt: issuedAt.value
|
|
471
|
+
});
|
|
472
|
+
return {
|
|
473
|
+
ok: true,
|
|
474
|
+
value: void 0
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
async verifyOtp(input, options) {
|
|
478
|
+
if (options?.signal?.aborted) return {
|
|
479
|
+
ok: false,
|
|
480
|
+
error: Errors.cancelled({ operation: "verifyOtp" })
|
|
481
|
+
};
|
|
482
|
+
if (this.#failures.verifyOtp !== void 0) return {
|
|
483
|
+
ok: false,
|
|
484
|
+
error: this.#failures.verifyOtp
|
|
485
|
+
};
|
|
486
|
+
const pending = this.#pendingOtps.get(input.email);
|
|
487
|
+
if (pending === void 0) return {
|
|
488
|
+
ok: false,
|
|
489
|
+
error: Errors.invalidInput("email", "no OTP issued for this email")
|
|
490
|
+
};
|
|
491
|
+
const nowResult = await readClockNow(this.#clock);
|
|
492
|
+
if (!nowResult.ok) return nowResult;
|
|
493
|
+
const now = nowResult.value;
|
|
494
|
+
if (now - pending.issuedAt > this.#otpTtlMs) {
|
|
495
|
+
this.#pendingOtps.delete(input.email);
|
|
496
|
+
return {
|
|
497
|
+
ok: false,
|
|
498
|
+
error: Errors.otpExpired()
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
if (input.otp !== pending.otp) return {
|
|
502
|
+
ok: false,
|
|
503
|
+
error: Errors.invalidInput("otp", "incorrect OTP")
|
|
504
|
+
};
|
|
505
|
+
const authUserId = this.#getOrIssueAuthUserId(input.email);
|
|
506
|
+
const token = toSessionToken(`tok_${this.#tokenCounter++}`);
|
|
507
|
+
const expiresAt = toEpochMs(now + DEFAULT_SESSION_TTL_MS);
|
|
508
|
+
const session = {
|
|
509
|
+
authUserId,
|
|
510
|
+
email: input.email,
|
|
511
|
+
token,
|
|
512
|
+
expiresAt
|
|
513
|
+
};
|
|
514
|
+
const cacheWrite = await runAuthCacheEffect(this.#authCache.setSession(session), "setSession");
|
|
515
|
+
if (!cacheWrite.ok) return cacheWrite;
|
|
516
|
+
this.#pendingOtps.delete(input.email);
|
|
517
|
+
return {
|
|
518
|
+
ok: true,
|
|
519
|
+
value: session
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
async getSession(options) {
|
|
523
|
+
if (options?.signal?.aborted) return {
|
|
524
|
+
ok: false,
|
|
525
|
+
error: Errors.cancelled({ operation: "getSession" })
|
|
526
|
+
};
|
|
527
|
+
if (this.#failures.getSession !== void 0) return {
|
|
528
|
+
ok: false,
|
|
529
|
+
error: this.#failures.getSession
|
|
530
|
+
};
|
|
531
|
+
const cacheRead = await runAuthCacheEffect(this.#authCache.getSession, "getSession");
|
|
532
|
+
if (!cacheRead.ok) return cacheRead;
|
|
533
|
+
const persisted = cacheRead.value;
|
|
534
|
+
if (persisted === null) return {
|
|
535
|
+
ok: true,
|
|
536
|
+
value: null
|
|
537
|
+
};
|
|
538
|
+
const nowResult = await readClockNow(this.#clock);
|
|
539
|
+
if (!nowResult.ok) return nowResult;
|
|
540
|
+
const now = nowResult.value;
|
|
541
|
+
if (persisted.expiresAt <= now) {
|
|
542
|
+
const cacheClear = await runAuthCacheEffect(this.#authCache.clearSession, "clearSession");
|
|
543
|
+
if (!cacheClear.ok) return cacheClear;
|
|
544
|
+
return {
|
|
545
|
+
ok: true,
|
|
546
|
+
value: null
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
return {
|
|
550
|
+
ok: true,
|
|
551
|
+
value: persisted
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
async signOut(options) {
|
|
555
|
+
if (options?.signal?.aborted) return {
|
|
556
|
+
ok: false,
|
|
557
|
+
error: Errors.cancelled({ operation: "signOut" })
|
|
558
|
+
};
|
|
559
|
+
if (this.#failures.signOut !== void 0) return {
|
|
560
|
+
ok: false,
|
|
561
|
+
error: this.#failures.signOut
|
|
562
|
+
};
|
|
563
|
+
return runAuthCacheEffect(this.#authCache.clearSession, "clearSession");
|
|
564
|
+
}
|
|
565
|
+
async getConvexJwt(options) {
|
|
566
|
+
if (options?.signal?.aborted) return {
|
|
567
|
+
ok: false,
|
|
568
|
+
error: Errors.cancelled({ operation: "getConvexJwt" })
|
|
569
|
+
};
|
|
570
|
+
if (this.#failures.getConvexJwt !== void 0) return {
|
|
571
|
+
ok: false,
|
|
572
|
+
error: this.#failures.getConvexJwt
|
|
573
|
+
};
|
|
574
|
+
if (options?.forceRefresh !== true) {
|
|
575
|
+
const cacheRead = await runAuthCacheEffect(this.#authCache.getJwt, "getJwt");
|
|
576
|
+
if (!cacheRead.ok) return cacheRead;
|
|
577
|
+
if (cacheRead.value !== null) return {
|
|
578
|
+
ok: true,
|
|
579
|
+
value: cacheRead.value
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
const now = await readClockNow(this.#clock);
|
|
583
|
+
if (!now.ok) return now;
|
|
584
|
+
const jwt = {
|
|
585
|
+
token: toJwtToken(`in-memory-jwt-${this.#jwtCounter++}`),
|
|
586
|
+
expEpochSeconds: toEpochSeconds(Math.floor(now.value / 1e3 + DEFAULT_JWT_TTL_MS / 1e3))
|
|
587
|
+
};
|
|
588
|
+
const cacheWrite = await runAuthCacheEffect(this.#authCache.setJwt(jwt), "setJwt");
|
|
589
|
+
if (!cacheWrite.ok) return cacheWrite;
|
|
590
|
+
return {
|
|
591
|
+
ok: true,
|
|
592
|
+
value: jwt
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
#getOrIssueAuthUserId(email) {
|
|
596
|
+
const existing = this.#authUserIdByEmail.get(email);
|
|
597
|
+
if (existing !== void 0) return existing;
|
|
598
|
+
let fresh;
|
|
599
|
+
do
|
|
600
|
+
fresh = toAuthUserId(`user_${this.#authUserIdCounter++}`);
|
|
601
|
+
while (this.#allocatedAuthUserIds.has(fresh));
|
|
602
|
+
this.#authUserIdByEmail.set(email, fresh);
|
|
603
|
+
this.#allocatedAuthUserIds.add(fresh);
|
|
604
|
+
return fresh;
|
|
605
|
+
}
|
|
606
|
+
};
|
|
607
|
+
async function runAuthCacheEffect(effect, operation) {
|
|
608
|
+
const result = await Effect.runPromise(Effect.result(effect));
|
|
609
|
+
if (Result.isFailure(result)) return {
|
|
610
|
+
ok: false,
|
|
611
|
+
error: Errors.providerError("auth-cache", operation, result.failure)
|
|
612
|
+
};
|
|
613
|
+
return {
|
|
614
|
+
ok: true,
|
|
615
|
+
value: result.success
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
//#endregion
|
|
619
|
+
//#region src/testing/bootstrap/BootstrapStubAdapter.ts
|
|
620
|
+
var BootstrapStubAdapter = class {
|
|
621
|
+
#script;
|
|
622
|
+
constructor(deps) {
|
|
623
|
+
this.#script = deps.script;
|
|
624
|
+
}
|
|
625
|
+
resolve(input) {
|
|
626
|
+
const key = bootstrapStubScriptKey(input);
|
|
627
|
+
const failure = this.#script.failures?.get(key);
|
|
628
|
+
if (failure !== void 0) return fail$2(kindFromCapxulError(failure), failure);
|
|
629
|
+
const resolution = this.#script.resolutions?.get(key);
|
|
630
|
+
if (resolution !== void 0) return Effect.succeed(resolution);
|
|
631
|
+
const defaultFailure = this.#script.defaultFailure ?? Errors.notAuthenticated();
|
|
632
|
+
return fail$2(kindFromCapxulError(defaultFailure), defaultFailure);
|
|
633
|
+
}
|
|
634
|
+
};
|
|
635
|
+
function bootstrapStubScriptKey(input) {
|
|
636
|
+
return `${String(input.publishableKey)}::${input.origin ?? ""}`;
|
|
637
|
+
}
|
|
638
|
+
function kindFromCapxulError(error) {
|
|
639
|
+
if (error.code === "NOT_AUTHENTICATED") return "notAuthenticated";
|
|
640
|
+
if (error.code === "NETWORK_ERROR") return "network";
|
|
641
|
+
if (error.code === "RATE_LIMITED") return "rateLimited";
|
|
642
|
+
if (error.code === "INVALID_INPUT") return "invalidInput";
|
|
643
|
+
return "provider";
|
|
644
|
+
}
|
|
645
|
+
function fail$2(kind, error) {
|
|
646
|
+
return Effect.fail(bootstrapErrorFromCapxul(kind, error));
|
|
647
|
+
}
|
|
648
|
+
//#endregion
|
|
649
|
+
//#region src/testing/convex-call/subscribers.ts
|
|
650
|
+
/**
|
|
651
|
+
* Register `subscriber` under `path` and return its unsubscribe, which removes
|
|
652
|
+
* it once (a second call does nothing) and drops a path left with no one.
|
|
653
|
+
*/
|
|
654
|
+
function subscribeAt(subscribers, path, subscriber) {
|
|
655
|
+
let subs = subscribers.get(path);
|
|
656
|
+
if (subs === void 0) {
|
|
657
|
+
subs = /* @__PURE__ */ new Set();
|
|
658
|
+
subscribers.set(path, subs);
|
|
659
|
+
}
|
|
660
|
+
subs.add(subscriber);
|
|
661
|
+
let active = true;
|
|
662
|
+
return () => {
|
|
663
|
+
if (!active) return;
|
|
664
|
+
active = false;
|
|
665
|
+
const live = subscribers.get(path);
|
|
666
|
+
if (live === void 0) return;
|
|
667
|
+
live.delete(subscriber);
|
|
668
|
+
if (live.size === 0) subscribers.delete(path);
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
//#endregion
|
|
672
|
+
//#region src/testing/convex-call/wire-reply.ts
|
|
673
|
+
/**
|
|
674
|
+
* The Convex wire, as the in-memory test doubles stand in for it. A scripted or
|
|
675
|
+
* registered value crosses where a real reply would, and the caller's function
|
|
676
|
+
* reference names its type, exactly as `ConvexClient` types a reply it cannot
|
|
677
|
+
* check. These are the only places the doubles make that claim.
|
|
678
|
+
*/
|
|
679
|
+
function wireReply(_fn, value) {
|
|
680
|
+
return value;
|
|
681
|
+
}
|
|
682
|
+
/** A scripted snapshot for a subscription on `fn`: only an `ok` value carries the reply type. */
|
|
683
|
+
function wireSnapshot(fn, snapshot) {
|
|
684
|
+
return snapshot.status === "ok" ? {
|
|
685
|
+
status: "ok",
|
|
686
|
+
value: wireReply(fn, snapshot.value)
|
|
687
|
+
} : snapshot;
|
|
688
|
+
}
|
|
689
|
+
//#endregion
|
|
690
|
+
//#region src/testing/convex-call/ConvexCallStubAdapter.ts
|
|
691
|
+
var ConvexCallStubAdapter = class {
|
|
692
|
+
#script;
|
|
693
|
+
#subscribers = /* @__PURE__ */ new Map();
|
|
694
|
+
constructor(deps) {
|
|
695
|
+
this.#script = deps.script;
|
|
696
|
+
}
|
|
697
|
+
query(fn, _args) {
|
|
698
|
+
return this.#resolve(fn);
|
|
699
|
+
}
|
|
700
|
+
mutation(fn, _args) {
|
|
701
|
+
return this.#resolve(fn);
|
|
702
|
+
}
|
|
703
|
+
action(fn, _args) {
|
|
704
|
+
return this.#resolve(fn);
|
|
705
|
+
}
|
|
706
|
+
subscribe(fn, _args, callback) {
|
|
707
|
+
return Effect.sync(() => {
|
|
708
|
+
const path = getFunctionName(fn);
|
|
709
|
+
const cb = (snapshot) => callback(wireSnapshot(fn, snapshot));
|
|
710
|
+
cb({ status: "loading" });
|
|
711
|
+
const sequence = this.#script.subscriptions?.get(path);
|
|
712
|
+
const subscriber = {
|
|
713
|
+
callback: cb,
|
|
714
|
+
cursor: 0
|
|
715
|
+
};
|
|
716
|
+
const unsubscribe = subscribeAt(this.#subscribers, path, subscriber);
|
|
717
|
+
if (sequence !== void 0 && sequence.length > 0) {
|
|
718
|
+
cb(sequence[0]);
|
|
719
|
+
subscriber.cursor = 1;
|
|
720
|
+
}
|
|
721
|
+
return unsubscribe;
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
advanceSubscription(path) {
|
|
725
|
+
const sequence = this.#script.subscriptions?.get(path);
|
|
726
|
+
if (sequence === void 0) return;
|
|
727
|
+
const subs = this.#subscribers.get(path);
|
|
728
|
+
if (subs === void 0) return;
|
|
729
|
+
for (const sub of subs) {
|
|
730
|
+
if (sub.cursor >= sequence.length) continue;
|
|
731
|
+
const next = sequence[sub.cursor];
|
|
732
|
+
sub.cursor += 1;
|
|
733
|
+
sub.callback(next);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
#resolve(fn) {
|
|
737
|
+
const path = getFunctionName(fn);
|
|
738
|
+
const failure = this.#script.failures?.get(path);
|
|
739
|
+
if (failure !== void 0) return Effect.fail(mapFailure(path, failure));
|
|
740
|
+
if (this.#script.responses?.has(path)) return Effect.succeed(wireReply(fn, this.#script.responses.get(path)));
|
|
741
|
+
return Effect.fail(convexCallErrorFromCapxul(path, Errors.providerError("convex", path, "unknown function")));
|
|
742
|
+
}
|
|
743
|
+
};
|
|
744
|
+
function mapFailure(operation, failure) {
|
|
745
|
+
if (failure instanceof CapxulError) return convexCallErrorFromCapxul(operation, failure);
|
|
746
|
+
if (failure instanceof Error) return convexCallErrorFromCapxul(operation, Errors.providerError("convex", operation, failure));
|
|
747
|
+
return convexCallErrorFromCapxul(operation, Errors.providerError("convex", operation, new Error(String(failure))));
|
|
748
|
+
}
|
|
749
|
+
//#endregion
|
|
750
|
+
//#region src/testing/identity/InMemoryIdentityAdapter.ts
|
|
751
|
+
var InMemoryIdentityAdapter = class {
|
|
752
|
+
#identities = /* @__PURE__ */ new Map();
|
|
753
|
+
#clock;
|
|
754
|
+
constructor(deps) {
|
|
755
|
+
this.#clock = deps.clock;
|
|
756
|
+
for (const [authUserId, profile] of deps.profiles ?? []) this.#identities.set(authUserId, cloneProfile(profile));
|
|
757
|
+
}
|
|
758
|
+
loadByAuthUserId(authUserId) {
|
|
759
|
+
return Effect.sync(() => cloneProfileOrNull(this.#identities.get(authUserId) ?? null));
|
|
760
|
+
}
|
|
761
|
+
create(input) {
|
|
762
|
+
return Effect.gen({ self: this }, function* () {
|
|
763
|
+
if (this.#identities.has(input.authUserId)) return yield* fail$1("create", Errors.invalidInput("authUserId", "already exists"));
|
|
764
|
+
const now = yield* this.#now("create.clock");
|
|
765
|
+
const profile = {
|
|
766
|
+
authUserId: input.authUserId,
|
|
767
|
+
email: input.email,
|
|
768
|
+
displayName: input.displayName ?? null,
|
|
769
|
+
country: input.country ?? null,
|
|
770
|
+
onboarded: false,
|
|
771
|
+
withdrawalAddress: null,
|
|
772
|
+
handle: null,
|
|
773
|
+
imageUrl: null,
|
|
774
|
+
kycTier: toKycTier(0),
|
|
775
|
+
createdAt: now,
|
|
776
|
+
updatedAt: now
|
|
777
|
+
};
|
|
778
|
+
this.#identities.set(input.authUserId, cloneProfile(profile));
|
|
779
|
+
return cloneProfile(profile);
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
update(input) {
|
|
783
|
+
return Effect.gen({ self: this }, function* () {
|
|
784
|
+
const existing = this.#identities.get(input.authUserId);
|
|
785
|
+
if (existing === void 0) return yield* fail$1("update", Errors.profileNotFound(input.authUserId));
|
|
786
|
+
const now = yield* this.#now("update.clock");
|
|
787
|
+
const updated = {
|
|
788
|
+
authUserId: existing.authUserId,
|
|
789
|
+
email: existing.email,
|
|
790
|
+
kycTier: existing.kycTier,
|
|
791
|
+
createdAt: existing.createdAt,
|
|
792
|
+
displayName: input.displayName !== void 0 ? input.displayName : existing.displayName,
|
|
793
|
+
country: input.country !== void 0 ? input.country : existing.country,
|
|
794
|
+
onboarded: existing.onboarded,
|
|
795
|
+
withdrawalAddress: existing.withdrawalAddress,
|
|
796
|
+
handle: existing.handle,
|
|
797
|
+
imageUrl: existing.imageUrl,
|
|
798
|
+
updatedAt: now
|
|
799
|
+
};
|
|
800
|
+
this.#identities.set(input.authUserId, cloneProfile(updated));
|
|
801
|
+
return cloneProfile(updated);
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
completeOnboarding(input) {
|
|
805
|
+
return Effect.gen({ self: this }, function* () {
|
|
806
|
+
const now = yield* this.#now("completeOnboarding.clock");
|
|
807
|
+
const existing = this.#identities.get(input.authUserId);
|
|
808
|
+
const profile = {
|
|
809
|
+
authUserId: input.authUserId,
|
|
810
|
+
email: existing?.email ?? input.email,
|
|
811
|
+
displayName: input.displayName,
|
|
812
|
+
country: input.country,
|
|
813
|
+
onboarded: true,
|
|
814
|
+
withdrawalAddress: input.withdrawalAddress ?? existing?.withdrawalAddress ?? null,
|
|
815
|
+
handle: toHandle(validateHandle(input.handle)),
|
|
816
|
+
imageUrl: existing?.imageUrl ?? null,
|
|
817
|
+
kycTier: existing?.kycTier ?? toKycTier(0),
|
|
818
|
+
createdAt: existing?.createdAt ?? now,
|
|
819
|
+
updatedAt: now
|
|
820
|
+
};
|
|
821
|
+
this.#identities.set(input.authUserId, cloneProfile(profile));
|
|
822
|
+
return cloneProfile(profile);
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
#now(operation) {
|
|
826
|
+
if (this.#clock === void 0) return Effect.succeed(toEpochMs(0));
|
|
827
|
+
return this.#clock.now.pipe(Effect.mapError((cause) => identityErrorFromCapxul(operation, Errors.providerError("clock", "now", cause), cause)));
|
|
828
|
+
}
|
|
829
|
+
};
|
|
830
|
+
Layer.effect(IdentityPortTag, Effect.map(ClockPortTag, (clock) => new InMemoryIdentityAdapter({ clock })));
|
|
831
|
+
function fail$1(operation, error) {
|
|
832
|
+
return Effect.fail(identityErrorFromCapxul(operation, error));
|
|
833
|
+
}
|
|
834
|
+
function cloneProfile(profile) {
|
|
835
|
+
return {
|
|
836
|
+
authUserId: profile.authUserId,
|
|
837
|
+
email: profile.email,
|
|
838
|
+
displayName: profile.displayName,
|
|
839
|
+
country: profile.country,
|
|
840
|
+
onboarded: profile.onboarded,
|
|
841
|
+
withdrawalAddress: profile.withdrawalAddress,
|
|
842
|
+
handle: profile.handle,
|
|
843
|
+
imageUrl: profile.imageUrl,
|
|
844
|
+
kycTier: profile.kycTier,
|
|
845
|
+
createdAt: profile.createdAt,
|
|
846
|
+
updatedAt: profile.updatedAt
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
function cloneProfileOrNull(profile) {
|
|
850
|
+
return profile === null ? null : cloneProfile(profile);
|
|
851
|
+
}
|
|
852
|
+
//#endregion
|
|
853
|
+
//#region src/testing/smart-account/smart-account-derivation.ts
|
|
854
|
+
const SUPPORTED_CHAIN_IDS = /* @__PURE__ */ new Set([84532, 8453]);
|
|
855
|
+
function isSupportedSmartAccountChain(chainId) {
|
|
856
|
+
return SUPPORTED_CHAIN_IDS.has(wireChainId(chainId));
|
|
857
|
+
}
|
|
858
|
+
//#endregion
|
|
859
|
+
//#region src/testing/smart-account/InMemorySmartAccountAdapter.ts
|
|
860
|
+
/**
|
|
861
|
+
* Hermetic backend stand-in (PRD #462 / derivation v2): `provision` derives
|
|
862
|
+
* the Safe from the authenticated email via the REAL canonical derivation
|
|
863
|
+
* (`deriveCapxulSafeAddress({ email })` — owner is the pinned bootstrap
|
|
864
|
+
* constant). No signer exists at provision; `claim` installs the signer and
|
|
865
|
+
* marks the row deployed, mirroring the backend's one-userOp claim lane.
|
|
866
|
+
*/
|
|
867
|
+
var InMemorySmartAccountAdapter = class {
|
|
868
|
+
#accounts = /* @__PURE__ */ new Map();
|
|
869
|
+
#clock;
|
|
870
|
+
#failures;
|
|
871
|
+
#provisionMutex = Semaphore.makeUnsafe(1);
|
|
872
|
+
constructor(deps) {
|
|
873
|
+
this.#clock = deps.clock;
|
|
874
|
+
this.#failures = deps.failures;
|
|
875
|
+
for (const [authUserId, account] of deps.accounts ?? []) this.#accounts.set(authUserId, cloneSmartAccount(account));
|
|
876
|
+
}
|
|
877
|
+
loadByAuthUserId(authUserId) {
|
|
878
|
+
const scriptedFailure = this.#failures?.loadByAuthUserId;
|
|
879
|
+
if (scriptedFailure !== void 0) return fail("loadByAuthUserId", scriptedFailure);
|
|
880
|
+
return Effect.sync(() => cloneSmartAccountOrNull(this.#accounts.get(authUserId) ?? null));
|
|
881
|
+
}
|
|
882
|
+
loadBySmartAccountAddress(address) {
|
|
883
|
+
const scriptedFailure = this.#failures?.loadBySmartAccountAddress;
|
|
884
|
+
if (scriptedFailure !== void 0) return fail("loadBySmartAccountAddress", scriptedFailure);
|
|
885
|
+
return Effect.sync(() => {
|
|
886
|
+
const needle = toAddress(address);
|
|
887
|
+
for (const account of this.#accounts.values()) if (account.smartAccountAddress === needle) return cloneSmartAccount(account);
|
|
888
|
+
return null;
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
provision(input) {
|
|
892
|
+
const scriptedFailure = this.#failures?.provision;
|
|
893
|
+
if (scriptedFailure !== void 0) return fail("provision", scriptedFailure);
|
|
894
|
+
return Effect.gen({ self: this }, function* () {
|
|
895
|
+
if (!isSupportedSmartAccountChain(input.chainId)) return yield* fail("provision", Errors.invalidInput("chainId", `unsupported chainId ${String(input.chainId)}`));
|
|
896
|
+
const existing = this.#accounts.get(input.authUserId);
|
|
897
|
+
if (existing !== void 0) return cloneSmartAccount(existing);
|
|
898
|
+
const now = yield* this.#now("provision.clock");
|
|
899
|
+
const account = {
|
|
900
|
+
authUserId: input.authUserId,
|
|
901
|
+
signerAddress: null,
|
|
902
|
+
smartAccountAddress: toAddress(deriveCapxulSafeAddress({ email: input.email })),
|
|
903
|
+
chainId: input.chainId,
|
|
904
|
+
deployedAt: null,
|
|
905
|
+
claimedAt: null,
|
|
906
|
+
createdAt: now
|
|
907
|
+
};
|
|
908
|
+
this.#accounts.set(input.authUserId, cloneSmartAccount(account));
|
|
909
|
+
return cloneSmartAccount(account);
|
|
910
|
+
}).pipe(this.#provisionMutex.withPermits(1));
|
|
911
|
+
}
|
|
912
|
+
promoteDeployedAt(authUserId, deployedAt) {
|
|
913
|
+
const existing = this.#accounts.get(authUserId);
|
|
914
|
+
if (existing === void 0) return;
|
|
915
|
+
if (existing.deployedAt !== null) return;
|
|
916
|
+
this.#accounts.set(authUserId, {
|
|
917
|
+
...existing,
|
|
918
|
+
deployedAt
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
confirmDeployment(input) {
|
|
922
|
+
const scriptedFailure = this.#failures?.confirmDeployment;
|
|
923
|
+
if (scriptedFailure !== void 0) return fail("confirmDeployment", scriptedFailure);
|
|
924
|
+
return Effect.gen({ self: this }, function* () {
|
|
925
|
+
const existing = this.#accounts.get(input.authUserId);
|
|
926
|
+
if (existing === void 0) return yield* fail("confirmDeployment", Errors.profileNotFound(String(input.authUserId)));
|
|
927
|
+
if (existing.smartAccountAddress !== toAddress(input.safeAddress)) return yield* fail("confirmDeployment", Errors.invalidInput("safeAddress", "does not match backend smart-account row"));
|
|
928
|
+
if (existing.deployedAt !== null) return cloneSmartAccount(existing);
|
|
929
|
+
const now = yield* this.#now("confirmDeployment.clock");
|
|
930
|
+
const updated = {
|
|
931
|
+
...existing,
|
|
932
|
+
deployedAt: now
|
|
933
|
+
};
|
|
934
|
+
this.#accounts.set(input.authUserId, cloneSmartAccount(updated));
|
|
935
|
+
return cloneSmartAccount(updated);
|
|
936
|
+
}).pipe(this.#provisionMutex.withPermits(1));
|
|
937
|
+
}
|
|
938
|
+
claim(input) {
|
|
939
|
+
const scriptedFailure = this.#failures?.claim;
|
|
940
|
+
if (scriptedFailure !== void 0) return fail("claim", scriptedFailure);
|
|
941
|
+
return Effect.gen({ self: this }, function* () {
|
|
942
|
+
const existing = this.#accounts.get(input.authUserId);
|
|
943
|
+
if (existing === void 0) return yield* fail("claim", Errors.invalidInput("account", "no provisioned smart account for this user"));
|
|
944
|
+
if (existing.chainId !== input.chainId) return yield* fail("claim", Errors.invalidInput("chainId", "does not match backend smart-account row"));
|
|
945
|
+
const newOwner = toAddress(input.signerAddress);
|
|
946
|
+
if (existing.claimedAt !== null) {
|
|
947
|
+
if (existing.signerAddress !== newOwner) return yield* fail("claim", Errors.invalidInput("signerAddress", "account already claimed by a different signer"));
|
|
948
|
+
return cloneSmartAccount(existing);
|
|
949
|
+
}
|
|
950
|
+
const now = yield* this.#now("claim.clock");
|
|
951
|
+
const updated = {
|
|
952
|
+
...existing,
|
|
953
|
+
signerAddress: newOwner,
|
|
954
|
+
claimedAt: now,
|
|
955
|
+
deployedAt: existing.deployedAt ?? now
|
|
956
|
+
};
|
|
957
|
+
this.#accounts.set(input.authUserId, cloneSmartAccount(updated));
|
|
958
|
+
return cloneSmartAccount(updated);
|
|
959
|
+
}).pipe(this.#provisionMutex.withPermits(1));
|
|
960
|
+
}
|
|
961
|
+
#now(operation) {
|
|
962
|
+
if (this.#clock === void 0) return Effect.succeed(toEpochMs(0));
|
|
963
|
+
return this.#clock.now.pipe(Effect.mapError((cause) => smartAccountErrorFromCapxul(operation, Errors.providerError("clock", "now", cause), cause)));
|
|
964
|
+
}
|
|
965
|
+
};
|
|
966
|
+
function fail(operation, error) {
|
|
967
|
+
return Effect.fail(smartAccountErrorFromCapxul(operation, error));
|
|
968
|
+
}
|
|
969
|
+
function cloneSmartAccount(account) {
|
|
970
|
+
return {
|
|
971
|
+
authUserId: account.authUserId,
|
|
972
|
+
signerAddress: account.signerAddress,
|
|
973
|
+
smartAccountAddress: account.smartAccountAddress,
|
|
974
|
+
chainId: account.chainId,
|
|
975
|
+
deployedAt: account.deployedAt,
|
|
976
|
+
claimedAt: account.claimedAt,
|
|
977
|
+
createdAt: account.createdAt
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
function cloneSmartAccountOrNull(account) {
|
|
981
|
+
return account === null ? null : cloneSmartAccount(account);
|
|
982
|
+
}
|
|
983
|
+
//#endregion
|
|
984
|
+
//#region src/testing/telemetry/RecordingTelemetryAdapter.ts
|
|
985
|
+
var RecordingTelemetryAdapter = class {
|
|
986
|
+
#events = [];
|
|
987
|
+
#operations = [];
|
|
988
|
+
#rawMode;
|
|
989
|
+
constructor(options = {}) {
|
|
990
|
+
this.#rawMode = options.rawMode === true;
|
|
991
|
+
}
|
|
992
|
+
get events() {
|
|
993
|
+
return this.#events.map(cloneTelemetryEvent);
|
|
994
|
+
}
|
|
995
|
+
get operations() {
|
|
996
|
+
return this.#operations.map(cloneOperation);
|
|
997
|
+
}
|
|
998
|
+
emit(event) {
|
|
999
|
+
return Effect.sync(() => {
|
|
1000
|
+
const recorded = redactTelemetryEvent(event, { rawMode: this.#rawMode });
|
|
1001
|
+
this.#events.push(recorded);
|
|
1002
|
+
this.#operations.push({
|
|
1003
|
+
type: "emit",
|
|
1004
|
+
event: cloneTelemetryEvent(recorded)
|
|
1005
|
+
});
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
identify(input) {
|
|
1009
|
+
return Effect.sync(() => {
|
|
1010
|
+
this.#operations.push({
|
|
1011
|
+
type: "identify",
|
|
1012
|
+
input: cloneIdentifyInput(input)
|
|
1013
|
+
});
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
group(input) {
|
|
1017
|
+
return Effect.sync(() => {
|
|
1018
|
+
this.#operations.push({
|
|
1019
|
+
type: "group",
|
|
1020
|
+
input: cloneGroupInput(input)
|
|
1021
|
+
});
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
reset() {
|
|
1025
|
+
return Effect.sync(() => {
|
|
1026
|
+
this.#operations.push({ type: "reset" });
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
clear() {
|
|
1030
|
+
this.#events.length = 0;
|
|
1031
|
+
this.#operations.length = 0;
|
|
1032
|
+
}
|
|
1033
|
+
};
|
|
1034
|
+
function cloneOperation(operation) {
|
|
1035
|
+
switch (operation.type) {
|
|
1036
|
+
case "emit": return {
|
|
1037
|
+
type: "emit",
|
|
1038
|
+
event: cloneTelemetryEvent(operation.event)
|
|
1039
|
+
};
|
|
1040
|
+
case "identify": return {
|
|
1041
|
+
type: "identify",
|
|
1042
|
+
input: cloneIdentifyInput(operation.input)
|
|
1043
|
+
};
|
|
1044
|
+
case "group": return {
|
|
1045
|
+
type: "group",
|
|
1046
|
+
input: cloneGroupInput(operation.input)
|
|
1047
|
+
};
|
|
1048
|
+
case "reset": return { type: "reset" };
|
|
1049
|
+
default: return absurd(operation);
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
function cloneIdentifyInput(input) {
|
|
1053
|
+
return {
|
|
1054
|
+
distinctId: input.distinctId,
|
|
1055
|
+
...input.anonDistinctId === void 0 ? {} : { anonDistinctId: input.anonDistinctId },
|
|
1056
|
+
...input.traits === void 0 ? {} : { traits: cloneTelemetryProps(input.traits) },
|
|
1057
|
+
...input.properties === void 0 ? {} : { properties: cloneTelemetryProps(input.properties) }
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
function cloneGroupInput(input) {
|
|
1061
|
+
return input.properties === void 0 ? {
|
|
1062
|
+
groupType: input.groupType,
|
|
1063
|
+
groupKey: input.groupKey
|
|
1064
|
+
} : {
|
|
1065
|
+
groupType: input.groupType,
|
|
1066
|
+
groupKey: input.groupKey,
|
|
1067
|
+
properties: cloneTelemetryProps(input.properties)
|
|
1068
|
+
};
|
|
1069
|
+
}
|
|
1070
|
+
//#endregion
|
|
1071
|
+
//#region src/testing/index.ts
|
|
1072
|
+
const TEST_BOOTSTRAP = {
|
|
1073
|
+
applicationId: toAppId("app_01HX0000000000000000000000"),
|
|
1074
|
+
chainId: toChainId(84532),
|
|
1075
|
+
sessionToken: toSessionToken("bootstrap-test-token"),
|
|
1076
|
+
issuedAt: toEpochMs(0),
|
|
1077
|
+
expiresIn: toDurationMs(6e4),
|
|
1078
|
+
authBaseUrl: "https://auth.example.test",
|
|
1079
|
+
convexUrl: "https://convex.example.test",
|
|
1080
|
+
siteBaseUrl: "https://example.test",
|
|
1081
|
+
openfortPublishableKey: "pk_test_openfort_fixture",
|
|
1082
|
+
shieldPublishableKey: "shield_pk_test_fixture"
|
|
1083
|
+
};
|
|
1084
|
+
/**
|
|
1085
|
+
* Assemble the real public client over deterministic in-memory ports.
|
|
1086
|
+
* Unconfigured backend calls fail locally; no adapter owns a network exit.
|
|
1087
|
+
*/
|
|
1088
|
+
function createCapxulTestClient(options = {}) {
|
|
1089
|
+
const clock = new ManualClockAdapter({});
|
|
1090
|
+
const authCache = new InMemoryAuthCacheAdapter();
|
|
1091
|
+
const authClient = new InMemoryAuthClientAdapter({
|
|
1092
|
+
authCache,
|
|
1093
|
+
clock
|
|
1094
|
+
});
|
|
1095
|
+
const identity = new InMemoryIdentityAdapter({ clock });
|
|
1096
|
+
const telemetry = new RecordingTelemetryAdapter();
|
|
1097
|
+
const organizationSetup = createTestOrganizationSetup();
|
|
1098
|
+
const ports = {
|
|
1099
|
+
authClient: authClientPortFromPromiseAdapter(authClient),
|
|
1100
|
+
authCache,
|
|
1101
|
+
identity,
|
|
1102
|
+
smartAccount: new InMemorySmartAccountAdapter({ clock }),
|
|
1103
|
+
accountRead: new InMemoryAccountReadAdapter({ rawBalance: "0" }),
|
|
1104
|
+
bootstrap: new BootstrapStubAdapter({ script: { resolutions: /* @__PURE__ */ new Map([[`${toPublishableKey("cap_pk_test_0123456789ABCDEFGHJKMNPQRSTVWXYZ")}::${toAllowedOrigin("https://example.test")}`, TEST_BOOTSTRAP]]) } }),
|
|
1105
|
+
clock,
|
|
1106
|
+
telemetry,
|
|
1107
|
+
convexCall: new ConvexCallStubAdapter({ script: {} })
|
|
1108
|
+
};
|
|
1109
|
+
const client = assembleCapxulClient({
|
|
1110
|
+
ports,
|
|
1111
|
+
bootstrap: TEST_BOOTSTRAP,
|
|
1112
|
+
authCache,
|
|
1113
|
+
requirement: options.requirement ?? "none",
|
|
1114
|
+
organizationSetup: organizationSetup.ops,
|
|
1115
|
+
...options.failureObservation === void 0 ? {} : { failureObservation: options.failureObservation }
|
|
1116
|
+
});
|
|
1117
|
+
const transitions = [];
|
|
1118
|
+
const unsubscribe = client._internal.identity.subscribeTransitions((record) => {
|
|
1119
|
+
transitions.push(record);
|
|
1120
|
+
});
|
|
1121
|
+
let closed = false;
|
|
1122
|
+
return {
|
|
1123
|
+
client,
|
|
1124
|
+
clock: Object.freeze({ advance: (ms) => clock.advance(ms) }),
|
|
1125
|
+
observation: {
|
|
1126
|
+
emit: (event) => Effect.runPromise(telemetry.emit(event)),
|
|
1127
|
+
identify: (input) => Effect.runPromise(telemetry.identify(input)),
|
|
1128
|
+
group: (input) => Effect.runPromise(telemetry.group(input)),
|
|
1129
|
+
reset: () => Effect.runPromise(telemetry.reset())
|
|
1130
|
+
},
|
|
1131
|
+
get transitions() {
|
|
1132
|
+
return transitions.slice();
|
|
1133
|
+
},
|
|
1134
|
+
get observations() {
|
|
1135
|
+
return telemetry.operations;
|
|
1136
|
+
},
|
|
1137
|
+
peekOtp: (email) => authClient.peekOtpForTesting(toEmail(email)),
|
|
1138
|
+
organizationSetup: { set: organizationSetup.set },
|
|
1139
|
+
seedIdentity: async (input) => {
|
|
1140
|
+
const authUserId = toAuthUserId(input.authUserId);
|
|
1141
|
+
const email = toEmail(input.email);
|
|
1142
|
+
await Effect.runPromise(identity.create({
|
|
1143
|
+
authUserId,
|
|
1144
|
+
email,
|
|
1145
|
+
...input.displayName === void 0 ? {} : { displayName: input.displayName },
|
|
1146
|
+
...input.country === void 0 ? {} : { country: toCountryCode(input.country) }
|
|
1147
|
+
}));
|
|
1148
|
+
authClient.bindAuthUserIdForTesting(email, authUserId);
|
|
1149
|
+
},
|
|
1150
|
+
close: async () => {
|
|
1151
|
+
if (closed) return;
|
|
1152
|
+
closed = true;
|
|
1153
|
+
unsubscribe();
|
|
1154
|
+
await client._internal.close?.();
|
|
1155
|
+
}
|
|
1156
|
+
};
|
|
1157
|
+
}
|
|
1158
|
+
//#endregion
|
|
1159
|
+
export { ControlledSignerFault, armSignerFaults, createCapxulProductionTestClient, createCapxulTestClient, organizationSetupFixture, signerFaultCause };
|