@capxul/sdk-react 1.0.0-alpha.9 → 1.1.0
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 +60 -0
- package/dist/controllers-BU11km12.mjs +647 -0
- package/dist/controllers-BU11km12.mjs.map +1 -0
- package/dist/controllers-DuHYSiw1.d.mts +207 -0
- package/dist/controllers-DuHYSiw1.d.mts.map +1 -0
- package/dist/index.d.mts +156 -175
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +420 -395
- package/dist/index.mjs.map +1 -1
- package/dist/testing/index.d.mts +21 -0
- package/dist/testing/index.d.mts.map +1 -0
- package/dist/testing/index.mjs +24 -0
- package/dist/testing/index.mjs.map +1 -0
- package/package.json +10 -11
package/README.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# `@capxul/sdk-react`
|
|
2
|
+
|
|
3
|
+
The React projection over `@capxul/sdk`. It owns provider lifecycle, the one
|
|
4
|
+
canonical identity projection, renderer-neutral identity controllers, TanStack
|
|
5
|
+
Query bindings for rich data, and authenticated-cache cleanup.
|
|
6
|
+
|
|
7
|
+
```tsx
|
|
8
|
+
"use client";
|
|
9
|
+
|
|
10
|
+
import { CapxulProvider } from "@capxul/sdk-react";
|
|
11
|
+
|
|
12
|
+
export function Providers({ children }: { children: React.ReactNode }) {
|
|
13
|
+
return (
|
|
14
|
+
<CapxulProvider publishableKey={process.env.NEXT_PUBLIC_CAPXUL_PUBLISHABLE_KEY!}>
|
|
15
|
+
{children}
|
|
16
|
+
</CapxulProvider>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Public shape
|
|
22
|
+
|
|
23
|
+
- `useCapxulIdentity`, `useCapxulDestination`, and `useCapxulTransitions`
|
|
24
|
+
expose the Core SDK identity actor without a parallel React state model.
|
|
25
|
+
- `useCapxulSend` carries caller-owned invocation controls to that actor.
|
|
26
|
+
- `useCapxulAuth` is the stable six-verb application facade.
|
|
27
|
+
- `CapxulAuthenticationController` and `CapxulOnboardingController` select
|
|
28
|
+
app-owned slots and render no SDK-owned DOM.
|
|
29
|
+
- Rich-data hooks such as `useCapxulProfile`, `useCapxulOrgs`, members, roles,
|
|
30
|
+
treasury, and money hooks remain TanStack Query projections.
|
|
31
|
+
|
|
32
|
+
The packed npm package exposes `@capxul/sdk-react` and
|
|
33
|
+
`@capxul/sdk-react/testing`. The workspace-only `@capxul/sdk-react/headless`
|
|
34
|
+
subpath is not published.
|
|
35
|
+
|
|
36
|
+
```tsx
|
|
37
|
+
import { createCapxulReactTestHarness } from "@capxul/sdk-react/testing";
|
|
38
|
+
|
|
39
|
+
const harness = createCapxulReactTestHarness();
|
|
40
|
+
const tree = harness.provider(harness.authentication(slots));
|
|
41
|
+
await harness.testing.close();
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The testing harness uses the real public provider and controllers and imports
|
|
45
|
+
its client factory only through `@capxul/sdk/testing`; it owns no renderer or
|
|
46
|
+
parallel identity state.
|
|
47
|
+
|
|
48
|
+
## Documentation
|
|
49
|
+
|
|
50
|
+
- [Maintainer context](./CONTEXT.md)
|
|
51
|
+
- [Architecture](./docs/architecture.md)
|
|
52
|
+
- [Proof map](./docs/proof.md)
|
|
53
|
+
|
|
54
|
+
## Development
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
vp run --filter @capxul/sdk-react check-types
|
|
58
|
+
vp run --filter @capxul/sdk-react test
|
|
59
|
+
(cd packages/sdk-react && vp pack)
|
|
60
|
+
```
|
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
2
|
+
import { QueryClient, QueryClientProvider, useQueryClient } from "@tanstack/react-query";
|
|
3
|
+
import { createCapxulClient, isCapxulError, resolveIdentityDestination, toCountryCode } from "@capxul/sdk";
|
|
4
|
+
import { jsx } from "react/jsx-runtime";
|
|
5
|
+
//#region src/internal/capxul-bootstrap-context.tsx
|
|
6
|
+
const CapxulBootstrapContext = createContext(null);
|
|
7
|
+
function CapxulBootstrapProvider({ value, children }) {
|
|
8
|
+
return /* @__PURE__ */ jsx(CapxulBootstrapContext.Provider, {
|
|
9
|
+
value,
|
|
10
|
+
children
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
function useCapxul() {
|
|
14
|
+
const state = useContext(CapxulBootstrapContext);
|
|
15
|
+
if (state === null) throw new Error("useCapxul must be used within <CapxulProvider>");
|
|
16
|
+
return state;
|
|
17
|
+
}
|
|
18
|
+
//#endregion
|
|
19
|
+
//#region src/internal/capxul-client-context.tsx
|
|
20
|
+
const MISSING_CAPXUL_CLIENT_PROVIDER = Symbol("MISSING_CAPXUL_CLIENT_PROVIDER");
|
|
21
|
+
const CapxulClientContext = createContext(MISSING_CAPXUL_CLIENT_PROVIDER);
|
|
22
|
+
function CapxulClientProvider({ client, children }) {
|
|
23
|
+
return /* @__PURE__ */ jsx(CapxulClientContext.Provider, {
|
|
24
|
+
value: client,
|
|
25
|
+
children
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Returns the client, or `null` while `<CapxulProvider>` is still bootstrapping.
|
|
30
|
+
* Data hooks use this so they can sit in `isPending` (disabled query) until the
|
|
31
|
+
* client resolves, rather than throwing during bootstrap.
|
|
32
|
+
*/
|
|
33
|
+
function useCapxulClientOrNull() {
|
|
34
|
+
const client = useContext(CapxulClientContext);
|
|
35
|
+
if (client === MISSING_CAPXUL_CLIENT_PROVIDER) throw new Error("useCapxulClient must be used within <CapxulProvider>");
|
|
36
|
+
return client;
|
|
37
|
+
}
|
|
38
|
+
//#endregion
|
|
39
|
+
//#region src/internal/reactivity-keys.ts
|
|
40
|
+
const capxulKeys = {
|
|
41
|
+
root: ["capxul"],
|
|
42
|
+
profile: ["capxul", "profile"],
|
|
43
|
+
usernameAvailability: (username) => [
|
|
44
|
+
"capxul",
|
|
45
|
+
"profile",
|
|
46
|
+
"username-availability",
|
|
47
|
+
username
|
|
48
|
+
],
|
|
49
|
+
account: ["capxul", "account"],
|
|
50
|
+
provisioning: ["capxul", "provisioning"],
|
|
51
|
+
binding: ["capxul", "binding"],
|
|
52
|
+
accountBalance: ["capxul", "accountBalance"],
|
|
53
|
+
subAccounts: (accountId) => [
|
|
54
|
+
"capxul",
|
|
55
|
+
"subAccounts",
|
|
56
|
+
accountId ?? "pending"
|
|
57
|
+
],
|
|
58
|
+
orgs: ["capxul", "orgs"],
|
|
59
|
+
org: (orgId) => [
|
|
60
|
+
"capxul",
|
|
61
|
+
"org",
|
|
62
|
+
orgId ?? "pending"
|
|
63
|
+
],
|
|
64
|
+
orgMembers: (orgId) => [
|
|
65
|
+
"capxul",
|
|
66
|
+
"org",
|
|
67
|
+
orgId ?? "pending",
|
|
68
|
+
"members"
|
|
69
|
+
],
|
|
70
|
+
orgRoles: (orgId) => [
|
|
71
|
+
"capxul",
|
|
72
|
+
"org",
|
|
73
|
+
orgId ?? "pending",
|
|
74
|
+
"roles"
|
|
75
|
+
],
|
|
76
|
+
orgTreasury: (orgId) => [
|
|
77
|
+
"capxul",
|
|
78
|
+
"org",
|
|
79
|
+
orgId ?? "pending",
|
|
80
|
+
"treasury"
|
|
81
|
+
],
|
|
82
|
+
payments: ["capxul", "payments"],
|
|
83
|
+
payment: (paymentId) => [
|
|
84
|
+
"capxul",
|
|
85
|
+
"payments",
|
|
86
|
+
paymentId ?? "pending"
|
|
87
|
+
]
|
|
88
|
+
};
|
|
89
|
+
//#endregion
|
|
90
|
+
//#region src/identity.tsx
|
|
91
|
+
const MISSING_IDENTITY_PROVIDER = Symbol("MISSING_IDENTITY_PROVIDER");
|
|
92
|
+
const IdentityContext = createContext(MISSING_IDENTITY_PROVIDER);
|
|
93
|
+
function controls(options) {
|
|
94
|
+
if (options === void 0) return void 0;
|
|
95
|
+
return {
|
|
96
|
+
...options.signal === void 0 ? {} : { signal: options.signal },
|
|
97
|
+
...options.timeoutMs === void 0 ? {} : { timeoutMs: options.timeoutMs },
|
|
98
|
+
...options.deadlineMs === void 0 ? {} : { deadlineMs: options.deadlineMs },
|
|
99
|
+
...options.correlationId === void 0 ? {} : { correlation_id: options.correlationId },
|
|
100
|
+
...options.journeyId === void 0 ? {} : { journey_id: options.journeyId }
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
const failure = (result) => result.ok ? null : {
|
|
104
|
+
ok: false,
|
|
105
|
+
reason: result.refused
|
|
106
|
+
};
|
|
107
|
+
async function guarded(runtime, verb, options, run) {
|
|
108
|
+
const invocation = controls(options);
|
|
109
|
+
try {
|
|
110
|
+
return await (runtime.runFacade?.(verb, invocation, run) ?? run(invocation));
|
|
111
|
+
} catch {
|
|
112
|
+
return {
|
|
113
|
+
ok: false,
|
|
114
|
+
reason: "UNKNOWN"
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const ORGANIZATION_HANDLE = /^[a-z0-9-]{3,32}$/;
|
|
119
|
+
function normalizeOrganization(organization) {
|
|
120
|
+
const name = typeof organization.name === "string" ? organization.name.trim() : "";
|
|
121
|
+
const handle = typeof organization.handle === "string" ? organization.handle.trim().toLowerCase() : "";
|
|
122
|
+
if (name.length === 0 || !ORGANIZATION_HANDLE.test(handle)) return null;
|
|
123
|
+
if (organization.bio !== void 0 && typeof organization.bio !== "string") return null;
|
|
124
|
+
if (organization.size !== void 0 && typeof organization.size !== "string") return null;
|
|
125
|
+
try {
|
|
126
|
+
return {
|
|
127
|
+
name,
|
|
128
|
+
handle,
|
|
129
|
+
country: toCountryCode(organization.country),
|
|
130
|
+
...organization.bio === void 0 ? {} : { bio: organization.bio },
|
|
131
|
+
...organization.size === void 0 ? {} : { size: organization.size }
|
|
132
|
+
};
|
|
133
|
+
} catch {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function createAuth(client, clearAuthenticatedQueries) {
|
|
138
|
+
const runtime = client._internal.identity;
|
|
139
|
+
const read = async (invocation) => {
|
|
140
|
+
return failure(await runtime.send({ _tag: "ReadSession" }, invocation)) ?? { ok: true };
|
|
141
|
+
};
|
|
142
|
+
const ensureAccount = async (invocation) => {
|
|
143
|
+
return failure(await runtime.send({ _tag: "EnsureAccount" }, invocation)) ?? { ok: true };
|
|
144
|
+
};
|
|
145
|
+
const reachClaimed = async (invocation) => {
|
|
146
|
+
let state = runtime.snapshot();
|
|
147
|
+
if (state.phase !== "authenticated" || state.account.at === "unknown") {
|
|
148
|
+
const result = await runtime.send({ _tag: "EnsureAccount" }, invocation);
|
|
149
|
+
const refused = failure(result);
|
|
150
|
+
if (refused !== null) return refused;
|
|
151
|
+
state = result.state;
|
|
152
|
+
}
|
|
153
|
+
if (state.phase !== "authenticated") return {
|
|
154
|
+
ok: false,
|
|
155
|
+
reason: "WRONG_STATE"
|
|
156
|
+
};
|
|
157
|
+
if (state.account.at === "claimed") return { ok: true };
|
|
158
|
+
const event = state.account.at === "failed" ? { _tag: "RetryAccount" } : state.account.at === "counterfactual" ? { _tag: "ClaimAccount" } : { _tag: "EnsureAccount" };
|
|
159
|
+
const result = await runtime.send(event, invocation);
|
|
160
|
+
const refused = failure(result);
|
|
161
|
+
if (refused !== null) return refused;
|
|
162
|
+
const next = result.state;
|
|
163
|
+
return next.phase === "authenticated" && next.account.at === "claimed" ? { ok: true } : {
|
|
164
|
+
ok: false,
|
|
165
|
+
reason: "WRONG_STATE"
|
|
166
|
+
};
|
|
167
|
+
};
|
|
168
|
+
const completeProfile = async (profile, invocation) => {
|
|
169
|
+
const result = await runtime.completeProfile(profile, invocation);
|
|
170
|
+
return result.ok ? { ok: true } : result;
|
|
171
|
+
};
|
|
172
|
+
return {
|
|
173
|
+
requestCode: (email, options) => guarded(runtime, "requestCode", options, async (invocation) => {
|
|
174
|
+
const result = await client.auth.signIn({ email }, invocation);
|
|
175
|
+
if (!result.ok) return {
|
|
176
|
+
ok: false,
|
|
177
|
+
reason: result.error.code
|
|
178
|
+
};
|
|
179
|
+
const state = runtime.snapshot();
|
|
180
|
+
return state.phase === "otp_pending" ? {
|
|
181
|
+
ok: true,
|
|
182
|
+
requestedAt: state.requestedAt
|
|
183
|
+
} : {
|
|
184
|
+
ok: false,
|
|
185
|
+
reason: state.phase === "faulted" ? state.failure.code : "UNKNOWN"
|
|
186
|
+
};
|
|
187
|
+
}),
|
|
188
|
+
verifyCode: (otp, options) => guarded(runtime, "verifyCode", options, async (invocation) => {
|
|
189
|
+
const state = runtime.snapshot();
|
|
190
|
+
const email = state.phase === "otp_pending" ? state.email : state.phase === "faulted" && state.resume !== null ? state.resume.email : "";
|
|
191
|
+
if (!/^\d{6}$/.test(otp)) return failure(await runtime.send({
|
|
192
|
+
_tag: "VerifyOtp",
|
|
193
|
+
email,
|
|
194
|
+
otp,
|
|
195
|
+
now: Date.now()
|
|
196
|
+
}, invocation)) ?? {
|
|
197
|
+
ok: false,
|
|
198
|
+
reason: "UNKNOWN"
|
|
199
|
+
};
|
|
200
|
+
const result = await client.auth.verifyOtp({
|
|
201
|
+
email,
|
|
202
|
+
code: otp
|
|
203
|
+
}, invocation);
|
|
204
|
+
if (!result.ok) return {
|
|
205
|
+
ok: false,
|
|
206
|
+
reason: result.error.code
|
|
207
|
+
};
|
|
208
|
+
const next = runtime.snapshot();
|
|
209
|
+
return next.phase === "authenticated" ? {
|
|
210
|
+
ok: true,
|
|
211
|
+
authUserId: next.session.authUserId,
|
|
212
|
+
profileComplete: next.profileComplete
|
|
213
|
+
} : {
|
|
214
|
+
ok: false,
|
|
215
|
+
reason: next.phase === "faulted" ? next.failure.code : "UNKNOWN"
|
|
216
|
+
};
|
|
217
|
+
}),
|
|
218
|
+
signOut: (options) => guarded(runtime, "signOut", options, async (invocation) => {
|
|
219
|
+
const result = await client.auth.signOut(invocation);
|
|
220
|
+
if (!result.ok) return {
|
|
221
|
+
ok: false,
|
|
222
|
+
reason: result.error.code
|
|
223
|
+
};
|
|
224
|
+
await clearAuthenticatedQueries();
|
|
225
|
+
return { ok: true };
|
|
226
|
+
}),
|
|
227
|
+
completePersonal: (profile, options) => guarded(runtime, "completePersonal", options, async (invocation) => {
|
|
228
|
+
const completed = await completeProfile(profile, invocation);
|
|
229
|
+
if (!completed.ok) return completed;
|
|
230
|
+
const refreshed = await read(invocation);
|
|
231
|
+
if (!refreshed.ok) return refreshed;
|
|
232
|
+
return ensureAccount(invocation);
|
|
233
|
+
}),
|
|
234
|
+
createOrganization: (submission, options) => guarded(runtime, "createOrganization", options, async (invocation) => {
|
|
235
|
+
const organization = normalizeOrganization(submission.organization);
|
|
236
|
+
if (organization === null) return {
|
|
237
|
+
ok: false,
|
|
238
|
+
reason: "INVALID_INPUT"
|
|
239
|
+
};
|
|
240
|
+
const completed = await completeProfile(submission.profileDetails, invocation);
|
|
241
|
+
if (!completed.ok) return completed;
|
|
242
|
+
const refreshed = await read(invocation);
|
|
243
|
+
if (!refreshed.ok) return refreshed;
|
|
244
|
+
const claimed = await reachClaimed(invocation);
|
|
245
|
+
if (!claimed.ok) return claimed;
|
|
246
|
+
const created = await runtime.send({
|
|
247
|
+
_tag: "CreateOrganization",
|
|
248
|
+
draft: organization
|
|
249
|
+
}, invocation);
|
|
250
|
+
const refused = failure(created);
|
|
251
|
+
if (refused !== null) return refused;
|
|
252
|
+
const state = created.state;
|
|
253
|
+
const org = state.phase === "authenticated" && state.account.at === "claimed" ? state.account.org : null;
|
|
254
|
+
return org !== null && org.at !== "creating" && org.orgId !== null ? {
|
|
255
|
+
ok: true,
|
|
256
|
+
orgId: org.orgId
|
|
257
|
+
} : {
|
|
258
|
+
ok: false,
|
|
259
|
+
reason: "UNKNOWN"
|
|
260
|
+
};
|
|
261
|
+
}),
|
|
262
|
+
retry: (options) => guarded(runtime, "retry", options, async (invocation) => {
|
|
263
|
+
const state = runtime.snapshot();
|
|
264
|
+
const event = state.phase === "authenticated" && state.account.at === "claimed" ? { _tag: "RetryOrganization" } : { _tag: "RetryAccount" };
|
|
265
|
+
return failure(await runtime.send(event, invocation)) ?? { ok: true };
|
|
266
|
+
})
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
function CapxulIdentityProvider({ client, children }) {
|
|
270
|
+
const queryClient = useQueryClient();
|
|
271
|
+
const runtime = client?._internal.identity ?? null;
|
|
272
|
+
const listeners = useRef(/* @__PURE__ */ new Set());
|
|
273
|
+
useEffect(() => {
|
|
274
|
+
if (client === null) return;
|
|
275
|
+
Promise.resolve().then(() => client.auth.getSession()).catch(() => void 0);
|
|
276
|
+
}, [client]);
|
|
277
|
+
useEffect(() => {
|
|
278
|
+
if (runtime === null) return;
|
|
279
|
+
return runtime.subscribeTransitions((record) => {
|
|
280
|
+
const state = runtime.snapshot();
|
|
281
|
+
for (const listener of listeners.current) try {
|
|
282
|
+
listener(record, state);
|
|
283
|
+
} catch {
|
|
284
|
+
listeners.current.delete(listener);
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
}, [runtime]);
|
|
288
|
+
const addTransitionListener = useCallback((listener) => {
|
|
289
|
+
listeners.current.add(listener);
|
|
290
|
+
return () => listeners.current.delete(listener);
|
|
291
|
+
}, []);
|
|
292
|
+
const value = useMemo(() => {
|
|
293
|
+
if (client === null || runtime === null) return null;
|
|
294
|
+
const send = (event, options) => runtime.send(event, controls(options));
|
|
295
|
+
const clearAuthenticatedQueries = async () => {
|
|
296
|
+
await queryClient.cancelQueries({ queryKey: capxulKeys.root });
|
|
297
|
+
await queryClient.resetQueries({ queryKey: capxulKeys.root });
|
|
298
|
+
};
|
|
299
|
+
return {
|
|
300
|
+
runtime,
|
|
301
|
+
send,
|
|
302
|
+
auth: createAuth(client, clearAuthenticatedQueries),
|
|
303
|
+
addTransitionListener
|
|
304
|
+
};
|
|
305
|
+
}, [
|
|
306
|
+
client,
|
|
307
|
+
runtime,
|
|
308
|
+
addTransitionListener,
|
|
309
|
+
queryClient
|
|
310
|
+
]);
|
|
311
|
+
return /* @__PURE__ */ jsx(IdentityContext.Provider, {
|
|
312
|
+
value,
|
|
313
|
+
children
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
function useIdentityContext() {
|
|
317
|
+
const value = useContext(IdentityContext);
|
|
318
|
+
if (value === MISSING_IDENTITY_PROVIDER) throw new Error("identity hooks must be used within <CapxulProvider>");
|
|
319
|
+
if (value === null) throw new Error("identity hooks require a ready <CapxulProvider>");
|
|
320
|
+
return value;
|
|
321
|
+
}
|
|
322
|
+
const noSubscribe = () => () => void 0;
|
|
323
|
+
const noState = () => null;
|
|
324
|
+
function useCapxulIdentityOrNull() {
|
|
325
|
+
const value = useContext(IdentityContext);
|
|
326
|
+
if (value === MISSING_IDENTITY_PROVIDER) throw new Error("identity hooks must be used within <CapxulProvider>");
|
|
327
|
+
return useSyncExternalStore(value?.runtime.subscribe ?? noSubscribe, value?.runtime.snapshot ?? noState, value?.runtime.snapshot ?? noState);
|
|
328
|
+
}
|
|
329
|
+
function useCapxulIdentity() {
|
|
330
|
+
const state = useCapxulIdentityOrNull();
|
|
331
|
+
if (state === null) throw new Error("identity hooks require a ready <CapxulProvider>");
|
|
332
|
+
return state;
|
|
333
|
+
}
|
|
334
|
+
function useCapxulSend() {
|
|
335
|
+
return useIdentityContext().send;
|
|
336
|
+
}
|
|
337
|
+
function useCapxulAuth() {
|
|
338
|
+
return useIdentityContext().auth;
|
|
339
|
+
}
|
|
340
|
+
function useCapxulDestination() {
|
|
341
|
+
const next = resolveIdentityDestination(useCapxulIdentity());
|
|
342
|
+
const held = useRef(null);
|
|
343
|
+
const key = JSON.stringify(next);
|
|
344
|
+
if (held.current?.key !== key) held.current = {
|
|
345
|
+
key,
|
|
346
|
+
value: next
|
|
347
|
+
};
|
|
348
|
+
return held.current.value;
|
|
349
|
+
}
|
|
350
|
+
function useCapxulTransitions(listener) {
|
|
351
|
+
const { addTransitionListener } = useIdentityContext();
|
|
352
|
+
useEffect(() => addTransitionListener(listener), [addTransitionListener, listener]);
|
|
353
|
+
}
|
|
354
|
+
const entered = (record, target) => record.outcome === "applied" && record.from !== target && record.to === target;
|
|
355
|
+
//#endregion
|
|
356
|
+
//#region src/provider.tsx
|
|
357
|
+
/**
|
|
358
|
+
* Transient failure codes worth a retry — network blips, rate limits, and
|
|
359
|
+
* upstream provider/unknown hiccups that a later attempt may clear. Everything
|
|
360
|
+
* else (including any future code) is deterministic and NOT retried: retrying a
|
|
361
|
+
* deterministic failure only multiplies the failed backend actions. A fresh
|
|
362
|
+
* user with no Safe yet hits `SMART_ACCOUNT_MISSING` on every attempt, so the
|
|
363
|
+
* old blanket `retry: 2` tripled that (and every other deterministic) failed
|
|
364
|
+
* action for zero benefit (#1031).
|
|
365
|
+
*/
|
|
366
|
+
const RETRYABLE_QUERY_ERROR_CODES = new Set([
|
|
367
|
+
"NETWORK_ERROR",
|
|
368
|
+
"RATE_LIMITED",
|
|
369
|
+
"PROVIDER_ERROR",
|
|
370
|
+
"UNKNOWN"
|
|
371
|
+
]);
|
|
372
|
+
/** Matches the previous `retry: 2` budget (initial attempt + up to 2 retries). */
|
|
373
|
+
const MAX_CAPXUL_QUERY_RETRIES = 2;
|
|
374
|
+
/**
|
|
375
|
+
* TanStack `retry` predicate: `failureCount` is 0-indexed and checked before
|
|
376
|
+
* increment, so `< MAX` reproduces the old numeric budget for retryable codes.
|
|
377
|
+
* Exported for direct unit coverage of the deterministic-vs-transient split.
|
|
378
|
+
*/
|
|
379
|
+
function shouldRetryCapxulQuery(failureCount, error) {
|
|
380
|
+
if (failureCount >= MAX_CAPXUL_QUERY_RETRIES) return false;
|
|
381
|
+
return isCapxulError(error) && RETRYABLE_QUERY_ERROR_CODES.has(error.code);
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* The default query client used when the host injects none. Exported so a test
|
|
385
|
+
* can pin that `queries.retry` is wired to `shouldRetryCapxulQuery` — reverting
|
|
386
|
+
* it to the old blanket `retry: 2` must fail a test (#1031).
|
|
387
|
+
*/
|
|
388
|
+
function makeDefaultQueryClient() {
|
|
389
|
+
return new QueryClient({ defaultOptions: {
|
|
390
|
+
queries: {
|
|
391
|
+
retry: shouldRetryCapxulQuery,
|
|
392
|
+
staleTime: 3e4
|
|
393
|
+
},
|
|
394
|
+
mutations: { retry: 0 }
|
|
395
|
+
} });
|
|
396
|
+
}
|
|
397
|
+
function isCapxulQueryKey(queryKey) {
|
|
398
|
+
return queryKey[0] === "capxul";
|
|
399
|
+
}
|
|
400
|
+
function clearClientScopedQueries(queryClient, ownsQueryClient) {
|
|
401
|
+
if (ownsQueryClient) {
|
|
402
|
+
queryClient.clear();
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
queryClient.removeQueries({ predicate: (query) => isCapxulQueryKey(query.queryKey) });
|
|
406
|
+
}
|
|
407
|
+
function CapxulProvider(props) {
|
|
408
|
+
const { publishableKey, client: injectedClient, requirement, signer, observation, telemetry, queryClient, children } = props;
|
|
409
|
+
const [resolvedQueryClient] = useState(() => queryClient ?? makeDefaultQueryClient());
|
|
410
|
+
const [ownsQueryClient] = useState(() => queryClient === void 0);
|
|
411
|
+
const [attempt, setAttempt] = useState(0);
|
|
412
|
+
const retry = useCallback(() => {
|
|
413
|
+
setAttempt((n) => n + 1);
|
|
414
|
+
}, []);
|
|
415
|
+
const bootstrapInput = useMemo(() => publishableKey === void 0 ? null : {
|
|
416
|
+
publishableKey,
|
|
417
|
+
...requirement === void 0 ? {} : { requirement },
|
|
418
|
+
...signer === void 0 ? {} : { signer },
|
|
419
|
+
...observation === void 0 ? {} : { observation },
|
|
420
|
+
...telemetry === void 0 ? {} : { telemetry }
|
|
421
|
+
}, [
|
|
422
|
+
publishableKey,
|
|
423
|
+
requirement,
|
|
424
|
+
signer,
|
|
425
|
+
observation,
|
|
426
|
+
telemetry,
|
|
427
|
+
attempt
|
|
428
|
+
]);
|
|
429
|
+
const [ownedBootstrap, setOwnedBootstrap] = useState(null);
|
|
430
|
+
const activeOwnedBootstrap = bootstrapInput !== null && ownedBootstrap?.input === bootstrapInput ? ownedBootstrap : null;
|
|
431
|
+
const client = injectedClient ?? activeOwnedBootstrap?.client ?? null;
|
|
432
|
+
const previousClientRef = useRef(injectedClient ?? null);
|
|
433
|
+
useEffect(() => {
|
|
434
|
+
if (bootstrapInput === null) return;
|
|
435
|
+
let cancelled = false;
|
|
436
|
+
let created = null;
|
|
437
|
+
setOwnedBootstrap({
|
|
438
|
+
input: bootstrapInput,
|
|
439
|
+
client: null,
|
|
440
|
+
status: "bootstrapping",
|
|
441
|
+
error: null
|
|
442
|
+
});
|
|
443
|
+
(async () => {
|
|
444
|
+
const result = await createCapxulClient(bootstrapInput);
|
|
445
|
+
if (cancelled) {
|
|
446
|
+
if (result.ok) await result.value._internal.close?.();
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
if (result.ok) {
|
|
450
|
+
created = result.value;
|
|
451
|
+
setOwnedBootstrap({
|
|
452
|
+
input: bootstrapInput,
|
|
453
|
+
client: result.value,
|
|
454
|
+
status: "ready",
|
|
455
|
+
error: null
|
|
456
|
+
});
|
|
457
|
+
} else setOwnedBootstrap({
|
|
458
|
+
input: bootstrapInput,
|
|
459
|
+
client: null,
|
|
460
|
+
status: "error",
|
|
461
|
+
error: result.error
|
|
462
|
+
});
|
|
463
|
+
})();
|
|
464
|
+
return () => {
|
|
465
|
+
cancelled = true;
|
|
466
|
+
created?._internal.close?.();
|
|
467
|
+
};
|
|
468
|
+
}, [bootstrapInput]);
|
|
469
|
+
useEffect(() => {
|
|
470
|
+
const previous = previousClientRef.current;
|
|
471
|
+
if (previous !== null && previous !== client) clearClientScopedQueries(resolvedQueryClient, ownsQueryClient);
|
|
472
|
+
previousClientRef.current = client;
|
|
473
|
+
}, [
|
|
474
|
+
client,
|
|
475
|
+
ownsQueryClient,
|
|
476
|
+
resolvedQueryClient
|
|
477
|
+
]);
|
|
478
|
+
const bootstrapState = useMemo(() => ({
|
|
479
|
+
status: injectedClient === void 0 ? activeOwnedBootstrap?.status ?? "bootstrapping" : "ready",
|
|
480
|
+
error: injectedClient === void 0 ? activeOwnedBootstrap?.error ?? null : null,
|
|
481
|
+
retry
|
|
482
|
+
}), [
|
|
483
|
+
activeOwnedBootstrap,
|
|
484
|
+
injectedClient,
|
|
485
|
+
retry
|
|
486
|
+
]);
|
|
487
|
+
if (publishableKey === void 0 === (injectedClient === void 0)) throw new Error("CapxulProvider requires exactly one of `publishableKey` or `client`");
|
|
488
|
+
return /* @__PURE__ */ jsx(QueryClientProvider, {
|
|
489
|
+
client: resolvedQueryClient,
|
|
490
|
+
children: /* @__PURE__ */ jsx(CapxulBootstrapProvider, {
|
|
491
|
+
value: bootstrapState,
|
|
492
|
+
children: /* @__PURE__ */ jsx(CapxulClientProvider, {
|
|
493
|
+
client,
|
|
494
|
+
children: /* @__PURE__ */ jsx(CapxulIdentityProvider, {
|
|
495
|
+
client,
|
|
496
|
+
children
|
|
497
|
+
})
|
|
498
|
+
})
|
|
499
|
+
})
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
//#endregion
|
|
503
|
+
//#region src/controllers.tsx
|
|
504
|
+
const action = async (send, event) => {
|
|
505
|
+
const result = await send(event);
|
|
506
|
+
return result.ok ? { ok: true } : {
|
|
507
|
+
ok: false,
|
|
508
|
+
reason: result.refused
|
|
509
|
+
};
|
|
510
|
+
};
|
|
511
|
+
function CapxulAuthenticationController({ slots }) {
|
|
512
|
+
const state = useCapxulIdentity();
|
|
513
|
+
const destination = useCapxulDestination();
|
|
514
|
+
const auth = useCapxulAuth();
|
|
515
|
+
const send = useCapxulSend();
|
|
516
|
+
switch (state.phase) {
|
|
517
|
+
case "signed_out": return slots.email({
|
|
518
|
+
state,
|
|
519
|
+
requestCode: auth.requestCode
|
|
520
|
+
});
|
|
521
|
+
case "otp_pending": return slots.otp({
|
|
522
|
+
state,
|
|
523
|
+
verifyCode: auth.verifyCode,
|
|
524
|
+
back: () => action(send, { _tag: "Reset" })
|
|
525
|
+
});
|
|
526
|
+
case "otp_sending":
|
|
527
|
+
case "otp_verifying":
|
|
528
|
+
case "signing_out": return slots.pending({ state });
|
|
529
|
+
case "faulted": return slots.failure({
|
|
530
|
+
state,
|
|
531
|
+
recover: () => action(send, state.resume === null ? { _tag: "Reset" } : {
|
|
532
|
+
_tag: "ResumeOtpEntry",
|
|
533
|
+
now: Date.now()
|
|
534
|
+
}),
|
|
535
|
+
back: () => action(send, { _tag: "Reset" })
|
|
536
|
+
});
|
|
537
|
+
case "authenticated": return slots.success({
|
|
538
|
+
state,
|
|
539
|
+
destination
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
function ready(destination) {
|
|
544
|
+
return destination?.to === "dashboardPersonal" || destination?.to === "dashboardOrganization";
|
|
545
|
+
}
|
|
546
|
+
function CapxulOnboardingController(props) {
|
|
547
|
+
const state = useCapxulIdentity();
|
|
548
|
+
const destination = useCapxulDestination();
|
|
549
|
+
const auth = useCapxulAuth();
|
|
550
|
+
if (state.phase !== "authenticated") return null;
|
|
551
|
+
const { slots, navigation } = props;
|
|
552
|
+
if (props.intent === null) return slots.intent({
|
|
553
|
+
state,
|
|
554
|
+
select: props.onIntent,
|
|
555
|
+
back: navigation.selectorBack
|
|
556
|
+
});
|
|
557
|
+
if (props.intent === "personal" && !state.profileComplete) return slots.profile({
|
|
558
|
+
intent: "personal",
|
|
559
|
+
state,
|
|
560
|
+
completePersonal: auth.completePersonal,
|
|
561
|
+
cancel: navigation.profileCancel
|
|
562
|
+
});
|
|
563
|
+
const submission = props.submittedOrganization;
|
|
564
|
+
if (props.intent === "organization" && props.organizationProfile === null && submission === null) return slots.profile({
|
|
565
|
+
intent: "organization",
|
|
566
|
+
state,
|
|
567
|
+
continueOrganization: props.onOrganizationProfile,
|
|
568
|
+
cancel: navigation.profileCancel
|
|
569
|
+
});
|
|
570
|
+
if (props.intent === "organization" && props.organizationProfile !== null && submission === null) return organizationForm(props, state, auth, null);
|
|
571
|
+
if (submission !== null && state.account.at !== "claimed") {
|
|
572
|
+
if (state.account.at === "failed") {
|
|
573
|
+
const retry = state.account.retryable ? (options) => auth.createOrganization(submission, options) : void 0;
|
|
574
|
+
return slots.accountFailure({
|
|
575
|
+
state,
|
|
576
|
+
account: state.account,
|
|
577
|
+
...retry === void 0 ? {} : { retry }
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
return slots.accountProgress({
|
|
581
|
+
state,
|
|
582
|
+
account: state.account
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
if (submission !== null && state.account.at === "claimed") {
|
|
586
|
+
const org = state.account.org;
|
|
587
|
+
if (org === null) return organizationForm(props, state, auth, submission);
|
|
588
|
+
if (org.at === "failed") {
|
|
589
|
+
const retry = org.retryable ? org.orgId === null ? (options) => auth.createOrganization(submission, options) : auth.retry : void 0;
|
|
590
|
+
return slots.organizationFailure({
|
|
591
|
+
state,
|
|
592
|
+
org,
|
|
593
|
+
...retry === void 0 ? {} : { retry }
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
if (org.at !== "ready") return slots.organizationProgress({
|
|
597
|
+
state,
|
|
598
|
+
org
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
if (state.account.at === "failed") return slots.accountFailure({
|
|
602
|
+
state,
|
|
603
|
+
account: state.account,
|
|
604
|
+
...state.account.retryable ? { retry: auth.retry } : {}
|
|
605
|
+
});
|
|
606
|
+
if (state.account.at !== "claimed") return slots.accountProgress({
|
|
607
|
+
state,
|
|
608
|
+
account: state.account
|
|
609
|
+
});
|
|
610
|
+
return ready(destination) ? slots.ready({
|
|
611
|
+
state,
|
|
612
|
+
destination
|
|
613
|
+
}) : null;
|
|
614
|
+
}
|
|
615
|
+
function organizationForm(props, state, auth, submitted) {
|
|
616
|
+
const profileDetails = submitted?.profileDetails ?? props.organizationProfile;
|
|
617
|
+
if (profileDetails === null) return null;
|
|
618
|
+
return props.slots.organization({
|
|
619
|
+
state,
|
|
620
|
+
profileDetails,
|
|
621
|
+
submitted,
|
|
622
|
+
pinnedHandle: submitted?.organization.handle ?? null,
|
|
623
|
+
submit: (organization, options) => {
|
|
624
|
+
if (submitted !== null) return auth.createOrganization(submitted, options);
|
|
625
|
+
const normalized = normalizeOrganization(organization);
|
|
626
|
+
if (normalized === null) return Promise.resolve({
|
|
627
|
+
ok: false,
|
|
628
|
+
reason: "INVALID_INPUT"
|
|
629
|
+
});
|
|
630
|
+
const next = {
|
|
631
|
+
profileDetails,
|
|
632
|
+
organization: normalized
|
|
633
|
+
};
|
|
634
|
+
props.onSubmittedOrganization(next);
|
|
635
|
+
return auth.createOrganization(next, options);
|
|
636
|
+
},
|
|
637
|
+
back: () => {
|
|
638
|
+
props.onOrganizationProfile(null);
|
|
639
|
+
props.navigation.organizationBack();
|
|
640
|
+
},
|
|
641
|
+
cancel: props.navigation.organizationCancel
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
//#endregion
|
|
645
|
+
export { useCapxulAuth as a, useCapxulIdentityOrNull as c, capxulKeys as d, useCapxulClientOrNull as f, entered as i, useCapxulSend as l, CapxulOnboardingController as n, useCapxulDestination as o, useCapxul as p, CapxulProvider as r, useCapxulIdentity as s, CapxulAuthenticationController as t, useCapxulTransitions as u };
|
|
646
|
+
|
|
647
|
+
//# sourceMappingURL=controllers-BU11km12.mjs.map
|