@capxul/sdk-react 4.2.0 → 4.20.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.
package/README.md CHANGED
@@ -28,10 +28,12 @@ export function Providers({ children }: { children: React.ReactNode }) {
28
28
  session. Account and Organization readiness transitions refresh active
29
29
  authenticated queries.
30
30
  - `CapxulAuthenticationController` and `CapxulOnboardingController` select
31
- app-owned slots and render no SDK-owned DOM. Supplying the onboarding
32
- controller's `recoveryOptions` factory lets a fresh actor resume account
33
- prerequisites before the stored Organization replay; a failed prerequisite
34
- returns the same manual replay instead of looping.
31
+ app-owned slots and render no SDK-owned DOM. The onboarding controller keeps
32
+ no Organization submission: after a reload the Core SDK reads the pending
33
+ Organization from the backend and attaches it, so the app re-submits nothing.
34
+ A claimed identity with no Organization and no app-held form step reports
35
+ `organizationProgress` with `{ at: "loading", orgId: null }` until that
36
+ attach lands.
35
37
  - Rich-data hooks such as `useCapxulProfile`, `useCapxulOrgs`, members, roles,
36
38
  treasury, and money hooks remain TanStack Query projections. Personal
37
39
  holdings and activity reads start only after the Account is claimed.
@@ -54,6 +56,8 @@ export function Providers({ children }: { children: React.ReactNode }) {
54
56
  engine hook stays module-scoped. Both actor paths ship: a personal actor omits
55
57
  `.Source` and spends the personal Account; an organization actor holds the
56
58
  Budget the payment is drawn from and spends the treasury Account.
59
+ The submitted amount uses the selected registry AssetId and precision. This
60
+ includes 18-decimal WETH.
57
61
  - `CapxulContacts` is the compound address-book component. One root owns the
58
62
  book behaviour and draws nothing. The `.Summary`, `.List`, and `.Add` region
59
63
  parts each hand the screen one finished slice, and a `.List` row carries its
@@ -73,6 +77,9 @@ export function Providers({ children }: { children: React.ReactNode }) {
73
77
  and an amount the caller passed in `prefill` wins over it. Both engine hooks
74
78
  stay module-scoped.
75
79
 
80
+ Activity groups use AssetId. The SDK resolves symbols and precision only for
81
+ display and CSV output. Assets with the same peg remain separate.
82
+
76
83
  Payroll confirmation restores a saved run before enabling submission. An
77
84
  uncertain run keeps its period, amounts, recipients, and SDK request key across
78
85
  close/reopen and reload. Its fields remain fixed until Core confirms success or terminal closure.
@@ -96,6 +103,16 @@ The testing harness uses the real public provider and controllers and imports
96
103
  its client factory only through `@capxul/sdk/testing`; it owns no renderer or
97
104
  parallel identity state.
98
105
 
106
+ ## Activity documents
107
+
108
+ `CapxulActivity.Detail` exposes `documents`, `selectedDocument`, and
109
+ `selectDocument({ documentHash, contentHash })`. Selection defaults to the
110
+ instruction, then the Movement Memo. `renderDocument()` returns safe semantic
111
+ HTML. `downloadDocument()` returns exact bytes for a deliberate download.
112
+ The host owns the panel and download action. Do not log these results.
113
+ Account or Organization changes clear selection and cancel pending reads.
114
+ These methods replace the receipt-only selector and action.
115
+
99
116
  ## Documentation
100
117
 
101
118
  - [Maintainer context](./CONTEXT.md)
@@ -37,6 +37,24 @@ function useCapxulClientOrNull() {
37
37
  }
38
38
  //#endregion
39
39
  //#region src/internal/reactivity-keys.ts
40
+ /**
41
+ * The account a read is answered FOR, as a key segment (#2007 R19).
42
+ *
43
+ * A read whose answer depends on who is asking belongs to the (scope, account)
44
+ * pair, not to the scope. Replacing one claimed account with another is not a
45
+ * state-label change. Keying is what
46
+ * makes the new person miss rather than read the previous person's answer.
47
+ * `undefined` while no account is claimed — no such read may run then anyway.
48
+ */
49
+ function actingAccount(identity) {
50
+ return identity !== null && identity.phase === "authenticated" && identity.account.at === "claimed" ? identity.account.address : void 0;
51
+ }
52
+ /** Keep invalidation prefixes shared, but never share a personal result across accounts.
53
+ * Session reads also run before an Account is claimed (for example `orgs()`).
54
+ */
55
+ function accountKey(key, identity) {
56
+ return [...key, actingAccount(identity) ?? (identity?.phase === "authenticated" ? identity.session.authUserId : null)];
57
+ }
40
58
  const capxulKeys = {
41
59
  root: ["capxul"],
42
60
  profile: ["capxul", "profile"],
@@ -80,11 +98,12 @@ const capxulKeys = {
80
98
  orgId ?? "pending",
81
99
  "permissions"
82
100
  ],
83
- orgMe: (orgId) => [
101
+ orgMe: (orgId, account) => [
84
102
  "capxul",
85
103
  "org",
86
104
  orgId ?? "pending",
87
- "me"
105
+ "me",
106
+ account ?? "pending"
88
107
  ],
89
108
  orgLifecycle: (orgId) => [
90
109
  "capxul",
@@ -151,10 +170,35 @@ const capxulKeys = {
151
170
  ]
152
171
  };
153
172
  //#endregion
154
- //#region src/identity.tsx
155
- const MISSING_IDENTITY_PROVIDER = Symbol("MISSING_IDENTITY_PROVIDER");
156
- const IdentityContext = createContext(MISSING_IDENTITY_PROVIDER);
157
- function controls(options) {
173
+ //#region src/internal/invocation-controls.ts
174
+ /**
175
+ * Keep only the five public option fields, and only when they hold the right
176
+ * kind of value. A named action verb takes its attempt as an optional FIRST
177
+ * argument, so a screen that wires one straight to a DOM handler
178
+ * (`onClick={row.hide}`) hands us a React SyntheticEvent. That is not an
179
+ * attempt: it must reach neither the SDK call nor the callback echo.
180
+ */
181
+ function toInvocationOptions(options) {
182
+ if (typeof options !== "object" || options === null) return void 0;
183
+ const attempt = {};
184
+ if (typeof options.correlationId === "string") attempt.correlationId = options.correlationId;
185
+ if (typeof options.journeyId === "string") attempt.journeyId = options.journeyId;
186
+ if (typeof options.timeoutMs === "number") attempt.timeoutMs = options.timeoutMs;
187
+ if (typeof options.deadlineMs === "number") attempt.deadlineMs = options.deadlineMs;
188
+ if (options.signal instanceof AbortSignal) attempt.signal = options.signal;
189
+ return Object.keys(attempt).length === 0 ? void 0 : attempt;
190
+ }
191
+ /**
192
+ * Map the public camel-case invocation options onto the Core SDK's control
193
+ * spelling. ONE definition, because the identity facade and every headless
194
+ * engine must hand the SDK the same shape: a caller that captured one user
195
+ * attempt before it started gets that attempt's `correlation_id`/`journey_id`
196
+ * on whichever operation it starts (#1960 R02).
197
+ *
198
+ * It creates no identifiers. An absent option stays absent, so an automatic
199
+ * read keeps its own invocation context instead of borrowing an attempt's.
200
+ */
201
+ function toInvocationControls(options) {
158
202
  if (options === void 0) return void 0;
159
203
  return {
160
204
  ...options.signal === void 0 ? {} : { signal: options.signal },
@@ -164,12 +208,16 @@ function controls(options) {
164
208
  ...options.journeyId === void 0 ? {} : { journey_id: options.journeyId }
165
209
  };
166
210
  }
211
+ //#endregion
212
+ //#region src/identity.tsx
213
+ const MISSING_IDENTITY_PROVIDER = Symbol("MISSING_IDENTITY_PROVIDER");
214
+ const IdentityContext = createContext(MISSING_IDENTITY_PROVIDER);
167
215
  const failure = (result) => result.ok ? null : {
168
216
  ok: false,
169
217
  reason: result.refused
170
218
  };
171
219
  async function guarded(runtime, verb, options, run) {
172
- const invocation = controls(options);
220
+ const invocation = toInvocationControls(options);
173
221
  try {
174
222
  return await (runtime.runFacade?.(verb, invocation, run) ?? run(invocation));
175
223
  } catch {
@@ -215,18 +263,102 @@ function normalizeOrganization(organization) {
215
263
  return null;
216
264
  }
217
265
  }
266
+ /** The Organization id when its lane is already running or finished, else null. */
267
+ const runningOrganization = (state) => {
268
+ if (state.phase !== "authenticated" || state.account.at !== "claimed") return null;
269
+ const org = state.account.org;
270
+ return org !== null && (org.at === "loading" || org.at === "settingUp" || org.at === "ready") ? org.orgId : null;
271
+ };
272
+ /** A superseded session read waits at most this long for the winning read. */
273
+ const SESSION_SETTLE_LIMIT_MS = 3e4;
274
+ const accountInFlight = (state) => state.phase === "authenticated" && (state.account.at === "deriving" || state.account.at === "claiming");
218
275
  function createAuth(client, clearAuthenticatedQueries) {
219
276
  const runtime = client._internal.identity;
277
+ const settledSession = (invocation) => new Promise((resolve) => {
278
+ let done = false;
279
+ let stop = null;
280
+ let timer = null;
281
+ const onAbort = () => finish({
282
+ ok: false,
283
+ reason: "CANCELLED"
284
+ });
285
+ const finish = (result) => {
286
+ if (done) return;
287
+ done = true;
288
+ stop?.();
289
+ if (timer !== null) clearTimeout(timer);
290
+ invocation?.signal?.removeEventListener("abort", onAbort);
291
+ resolve(result);
292
+ };
293
+ if (invocation?.signal?.aborted) {
294
+ onAbort();
295
+ return;
296
+ }
297
+ invocation?.signal?.addEventListener("abort", onAbort, { once: true });
298
+ const unsubscribe = runtime.subscribeTransitions((record) => {
299
+ if (record.slot !== "identity:session") return;
300
+ if (record.outcome === "applied" && record.event === "SessionRead") finish({ ok: true });
301
+ else if (record.outcome === "failed" || record.outcome === "cancelled") finish({
302
+ ok: false,
303
+ reason: record.error_code
304
+ });
305
+ });
306
+ if (done) {
307
+ unsubscribe();
308
+ return;
309
+ }
310
+ stop = unsubscribe;
311
+ timer = setTimeout(() => finish({
312
+ ok: false,
313
+ reason: "UNKNOWN"
314
+ }), SESSION_SETTLE_LIMIT_MS);
315
+ });
220
316
  const read = async (invocation) => {
221
317
  const result = await runtime.send({ _tag: "ReadSession" }, invocation);
222
- return failure(result) ?? { ok: true };
318
+ const refused = failure(result);
319
+ if (refused === null) return { ok: true };
320
+ return refused.reason === "SUPERSEDED" ? settledSession(invocation) : refused;
223
321
  };
224
322
  const ensureAccount = async (invocation) => {
225
323
  const result = await runtime.send({ _tag: "EnsureAccount" }, invocation);
226
324
  return failure(result) ?? { ok: true };
227
325
  };
326
+ const settledAccount = (invocation) => new Promise((resolve) => {
327
+ let done = false;
328
+ let stop = null;
329
+ const onAbort = () => finish({
330
+ ok: false,
331
+ reason: "CANCELLED"
332
+ });
333
+ const finish = (result) => {
334
+ if (done) return;
335
+ done = true;
336
+ stop?.();
337
+ invocation?.signal?.removeEventListener("abort", onAbort);
338
+ resolve(result);
339
+ };
340
+ if (invocation?.signal?.aborted) {
341
+ onAbort();
342
+ return;
343
+ }
344
+ invocation?.signal?.addEventListener("abort", onAbort, { once: true });
345
+ const unsubscribe = runtime.subscribe((state) => {
346
+ if (!accountInFlight(state)) finish({ ok: true });
347
+ });
348
+ if (done) {
349
+ unsubscribe();
350
+ return;
351
+ }
352
+ stop = unsubscribe;
353
+ if (!accountInFlight(runtime.snapshot())) finish({ ok: true });
354
+ });
228
355
  const reachClaimed = async (invocation) => {
229
356
  let state = runtime.snapshot();
357
+ if (accountInFlight(state)) {
358
+ const settled = await settledAccount(invocation);
359
+ if (!settled.ok) return settled;
360
+ state = runtime.snapshot();
361
+ }
230
362
  if (state.phase !== "authenticated" || state.account.at === "unknown") {
231
363
  const result = await runtime.send({ _tag: "EnsureAccount" }, invocation);
232
364
  const refused = failure(result);
@@ -238,6 +370,16 @@ function createAuth(client, clearAuthenticatedQueries) {
238
370
  reason: "WRONG_STATE"
239
371
  };
240
372
  if (state.account.at === "claimed") return { ok: true };
373
+ if (accountInFlight(state)) {
374
+ const settled = await settledAccount(invocation);
375
+ if (!settled.ok) return settled;
376
+ state = runtime.snapshot();
377
+ if (state.phase !== "authenticated") return {
378
+ ok: false,
379
+ reason: "WRONG_STATE"
380
+ };
381
+ if (state.account.at === "claimed") return { ok: true };
382
+ }
241
383
  const event = state.account.at === "failed" ? { _tag: "RetryAccount" } : state.account.at === "counterfactual" ? { _tag: "ClaimAccount" } : { _tag: "EnsureAccount" };
242
384
  const result = await runtime.send(event, invocation);
243
385
  const refused = failure(result);
@@ -258,13 +400,13 @@ function createAuth(client, clearAuthenticatedQueries) {
258
400
  ok: false,
259
401
  reason: "INVALID_INPUT"
260
402
  };
403
+ const refreshed = await read(invocation);
404
+ if (!refreshed.ok) return refreshed;
261
405
  const current = runtime.snapshot();
262
406
  if (current.phase !== "authenticated" || !current.profileComplete) {
263
407
  const completed = await runtime.completeProfile(submission.profileDetails, invocation);
264
408
  if (!completed.ok) return completed;
265
409
  }
266
- const refreshed = await read(invocation);
267
- if (!refreshed.ok) return refreshed;
268
410
  const claimed = await reachClaimed(invocation);
269
411
  return claimed.ok ? {
270
412
  ok: true,
@@ -341,12 +483,13 @@ function createAuth(client, clearAuthenticatedQueries) {
341
483
  }),
342
484
  createOrganization: (submission, options) => guarded(runtime, "createOrganization", options, async (invocation) => {
343
485
  const current = runtime.snapshot();
486
+ const running = runningOrganization(current);
487
+ if (running !== null) return {
488
+ ok: true,
489
+ orgId: running
490
+ };
344
491
  if (current.phase === "authenticated" && current.account.at === "claimed") {
345
492
  const org = current.account.org;
346
- if (org !== null && (org.at === "loading" || org.at === "settingUp" || org.at === "ready")) return {
347
- ok: true,
348
- orgId: org.orgId
349
- };
350
493
  if (org?.at === "failed" && org.orgId !== null) {
351
494
  if (!org.retryable) return {
352
495
  ok: false,
@@ -361,6 +504,11 @@ function createAuth(client, clearAuthenticatedQueries) {
361
504
  }
362
505
  const prepared = await prepareOrganization(submission, invocation);
363
506
  if (!prepared.ok) return prepared;
507
+ const opened = runningOrganization(runtime.snapshot());
508
+ if (opened !== null) return {
509
+ ok: true,
510
+ orgId: opened
511
+ };
364
512
  const created = await runtime.send({
365
513
  _tag: "CreateOrganization",
366
514
  draft: prepared.organization
@@ -417,7 +565,7 @@ function CapxulIdentityProvider({ client, children }) {
417
565
  if (!active) return;
418
566
  state = runtime.snapshot();
419
567
  }
420
- if (state.phase === "authenticated" && state.profileComplete && state.account.at === "unknown") await runtime.send({ _tag: "EnsureAccount" }, { signal: controller.signal });
568
+ if (client._internal.accountSetup !== "explicit" && state.phase === "authenticated" && state.profileComplete && state.account.at === "unknown") await runtime.send({ _tag: "EnsureAccount" }, { signal: controller.signal });
421
569
  }).catch(() => void 0);
422
570
  return () => {
423
571
  active = false;
@@ -426,9 +574,16 @@ function CapxulIdentityProvider({ client, children }) {
426
574
  }, [client, runtime]);
427
575
  useEffect(() => {
428
576
  if (runtime === null) return;
577
+ let previousAccount = actingAccount(runtime.snapshot());
429
578
  return runtime.subscribeTransitions((record) => {
430
579
  const state = runtime.snapshot();
431
- if (record.outcome === "applied" && record.from !== record.to && (record.to === "authenticated:claimed" || record.to === "authenticated:claimed:ready")) queryClient.invalidateQueries({ queryKey: capxulKeys.root });
580
+ const account = actingAccount(state);
581
+ const accountChanged = previousAccount !== void 0 && account !== void 0 && account !== previousAccount;
582
+ previousAccount = account;
583
+ if (record.outcome === "applied" && (record.from !== record.to || accountChanged) && (record.to === "authenticated:claimed" || record.to === "authenticated:claimed:ready")) queryClient.invalidateQueries({
584
+ queryKey: capxulKeys.root,
585
+ ...accountChanged ? { refetchType: "none" } : {}
586
+ });
432
587
  for (const listener of listeners.current) try {
433
588
  listener(record, state);
434
589
  } catch {
@@ -442,7 +597,7 @@ function CapxulIdentityProvider({ client, children }) {
442
597
  }, []);
443
598
  const value = useMemo(() => {
444
599
  if (client === null || runtime === null) return null;
445
- const send = (event, options) => runtime.send(event, controls(options));
600
+ const send = (event, options) => runtime.send(event, toInvocationControls(options));
446
601
  const clearAuthenticatedQueries = async () => {
447
602
  await queryClient.cancelQueries({ queryKey: capxulKeys.root });
448
603
  await queryClient.resetQueries({ queryKey: capxulKeys.root });
@@ -565,6 +720,7 @@ function clearClientScopedQueries(queryClient, ownsQueryClient) {
565
720
  }
566
721
  function CapxulProvider(props) {
567
722
  const { publishableKey, client: injectedClient, requirement, signer, observability, bootstrapBaseUrl, queryClient, children } = props;
723
+ const accountSetup = "accountSetup" in props ? props.accountSetup : void 0;
568
724
  const [resolvedQueryClient] = useState(() => queryClient ?? makeDefaultQueryClient());
569
725
  const [ownsQueryClient] = useState(() => queryClient === void 0);
570
726
  const [attempt, setAttempt] = useState(0);
@@ -576,13 +732,15 @@ function CapxulProvider(props) {
576
732
  ...requirement === void 0 ? {} : { requirement },
577
733
  ...signer === void 0 ? {} : { signer },
578
734
  ...observability === void 0 ? {} : { observability },
579
- ...bootstrapBaseUrl === void 0 ? {} : { bootstrapBaseUrl }
735
+ ...bootstrapBaseUrl === void 0 ? {} : { bootstrapBaseUrl },
736
+ ...accountSetup === void 0 ? {} : { accountSetup }
580
737
  }, [
581
738
  publishableKey,
582
739
  requirement,
583
740
  signer,
584
741
  observability,
585
742
  bootstrapBaseUrl,
743
+ accountSetup,
586
744
  attempt
587
745
  ]);
588
746
  const [ownedBootstrap, setOwnedBootstrap] = useState(null);
@@ -821,4 +979,4 @@ function organizationForm(props, state, auth, submitted) {
821
979
  });
822
980
  }
823
981
  //#endregion
824
- 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 };
982
+ export { useCapxul as _, useCapxulAuth as a, useCapxulIdentityOrNull as c, toInvocationControls as d, toInvocationOptions as f, useCapxulClientOrNull as g, capxulKeys as h, entered as i, useCapxulSend as l, actingAccount as m, CapxulOnboardingController as n, useCapxulDestination as o, accountKey as p, CapxulProvider as r, useCapxulIdentity as s, CapxulAuthenticationController as t, useCapxulTransitions as u };