@loomcycle/client 1.125.0 → 1.126.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 CHANGED
@@ -12,6 +12,7 @@ TypeScript client for the [loomcycle](https://github.com/denn-gubsky/loomcycle)
12
12
 
13
13
  ### What's new since v0.8.18
14
14
 
15
+ - **Sign in with loomcycle (apps)** (RFC FL) — `LoomcycleOAuth` (also `client.oauth`) drives loomcycle's own OAuth sign-in for an app: `beginAuthorize` / `completeAuthorize` (PKCE S256, state and RFC 9207 `iss` checked), `refresh`, `revoke`, `discover`. A new `tokenProvider` client option (instead of `authToken`) takes short-lived bearers; `OAuthTokenProvider` refreshes before expiry and once after a 401. ClientDef methods register the apps: `createClientDef` / `updateClientDef` / `getClientDef` / `listClientDefs` / `retireClientDef` / `verifyClientDef` / `listClientDefNames`. See [Sign in with loomcycle (apps)](#sign-in-with-loomcycle-apps). Additive — an `authToken` client is unchanged.
15
16
  - **Breaking — `tools` replaces `allowedTools`** — the per-run tool allowlist is now a single canonical key. The `runStreaming` / `continueSession` option is renamed from `allowedTools` to **`tools`**, and the `AgentDefOverlay` + library wire field is likewise **`tools`**, matching the loomcycle YAML rename and aligning with Claude Code agent frontmatter. No dual-key fallback — update call sites to `runStreaming({ …, tools: [...] })`. Requires a loomcycle server carrying the same rename.
16
17
  - **Path/Document browse-by-subject + the full Document op set** (v1.12.1, RFC AS/AK) — `path(input, opts)` and `document(input, opts)` accept optional `scopeId` / `tenant` browse overrides, sent as `?scope_id=` / `?tenant=` query params (the server reads them from the URL and re-checks authorization — a tenant principal's `tenant` is ignored, `scopeId` picks any subject it may see); omit both to browse your own subject (byte-identical to the pre-RFC-AS request). `DocumentToolInput.op` now covers all 16 backend ops — adds **`set_path`** (attach/re-home a Path-tree name for an existing document), **`export_md`** (render to Markdown; `include_metadata: false` for clean human-facing output), and **`import_md`** (build a document from export_md-shaped `markdown`). Additive — existing `path()` / `document()` callers are unchanged.
17
18
  - **`interactiveSession` / `sendRunInput` / `streamRunByID` + the `interactive` flag** (v1.1.1, RFC AI) — the interactive agentic session, the adapter port of the Web UI's run terminal. Pass `interactive: true` to `runStreaming` / `continueSession` to start a **persistent** run that parks at end_turn (an `awaiting_input` frame) instead of ending; **`sendRunInput(runId, text)`** steers it (the response arrives on the same stream); **`streamRunByID(runId, {fromSeq})`** re-attaches by run_id (the operator's prior turns replay as `steer` events, `user_input.source === "replay"`, so a cold client — e.g. another device — reconstructs the whole conversation). The high-level **`client.interactiveSession({agent, segments})`** returns an `InteractiveSession` with `events()` / `send()` / `cancel()`; **`attachInteractiveSession(runId)`** resumes one. The `AgentEvent` union gains `awaiting_input` / `steer` / `context_compaction`.
@@ -553,6 +554,163 @@ const why = await client.authzExplain({
553
554
  console.log(why.decision.outcome, why.traces);
554
555
  ```
555
556
 
557
+ ### Users and sign-in gates (RFC FA)
558
+
559
+ First-class users and how people sign in. A user's `status` (`UserStatus`) is `active`, `disabled` or `pending`: a sign-in gate configured with `new_user.status: pending` provisions people who can sign in but are refused everything until an operator approves them. `createUser` / `updateUser` accept only `active` and `disabled`; a pending user is activated by `approveUser` (a PATCH to `active` is a 409 `use_approve`). `whoami().auth.status` is `"pending"` for such a session.
560
+
561
+ The tenant is your own. `opts.tenant` names another for an admin token, and for an `auth:manage` credential (a website's), which must name one (400 `tenant_required`) and is held to the gate's new-user floor; a tenant operator always acts in its own tenant. Every call needs a persistent store.
562
+
563
+ | Method | Route | Returns |
564
+ |---|---|---|
565
+ | `createUser(body, opts?)` / `updateUser(subject, body, opts?)` / `deleteUser(subject, opts?)` | `POST /v1/_users` / `PATCH` / `DELETE /v1/_users/{subject}` | `UserRecord` / `UserRecord` / void |
566
+ | `approveUser(subject, opts?)` | `POST /v1/_users/{subject}/approve` | `UserRecord`, now `active`; 409 `not_pending` otherwise |
567
+ | `listUserIdentities(subject, opts?)` | `GET /v1/_users/{subject}/identities` | `UserIdentitiesResponse` — the identity-provider accounts a user signs in with; a user reads its own |
568
+ | `unlinkUserIdentity(subject, gate, opts?)` | `DELETE /v1/_users/{subject}/identities/{gate}` | void; for an operator — a user unlinks its own only from its browser session |
569
+ | `listAuthGates({probe?}?)` | `GET /v1/_authgate` | `AuthGateListResponse` — every configured gate, never key material. **Admin token only** |
570
+ | `createInvite(gate, body, opts?)` | `POST /v1/_authgate/{gate}/invites` | `CreatedInvite` — `{invite_id, url, expires_at}`; the URL is shown once |
571
+ | `listInvites(gate, opts?)` | `GET /v1/_authgate/{gate}/invites` | `AuthInviteListResponse` — `state` is `live` / `redeemed` / `revoked` / `expired`; never a code |
572
+ | `revokeInvite(gate, inviteId, opts?)` | `DELETE /v1/_authgate/{gate}/invites/{id}` | void; 409 `invite_not_live` |
573
+ | `createPasswordReset(gate, subject, opts?)` | `POST /v1/_authgate/{gate}/resets` | `CreatedPasswordReset` — a one-time link that sets a new password |
574
+ | `removeGateCredentials(gate, subject, opts?)` | `DELETE /v1/_authgate/{gate}/credentials/{subject}` | void — password, authenticator and recovery codes go; the login stays |
575
+ | `renameGateLogin(gate, subject, login, opts?)` | `PATCH /v1/_authgate/{gate}/logins/{subject}` | `{subject, login}`; 409 `login_taken` |
576
+ | `listGateIdentities(gate, subject, opts?)` | `GET /v1/_authgate/{gate}/identities?subject=` | `GateIdentitiesResponse` — logins, `has_password`, `has_totp` |
577
+ | `getGateTenantStatus(gate, opts?)` | `GET /v1/_authgate/{gate}/tenant-status` | `GateTenantStatus` — whether the tenant has, or ever had, a user, and whether an owner invite is live |
578
+ | `deviceLogin({onPrompt, clientId?, signal?})` | `POST /v1/_auth/device`, then `POST /v1/_auth/device/token` | `DeviceLoginResult` — signs a person in through the server's device gate (RFC 8628): `onPrompt` gets the code to show, the person approves it at `/ui/device`, and the token is theirs, never outliving the session they approved from. Sends no bearer. `DeviceLoginError` (`code` `access_denied` / `expired_token`) otherwise |
579
+
580
+ None of these takes a password: the person sets their own through the invite or reset link. Hand the link's `url` to the person and do not log it — its fragment is the one-time code.
581
+
582
+ Signing in, signing out and a person's own password or authenticator are not here. Those routes (`POST /ui/session`, `POST /v1/_auth/logout`, the password login, redeeming an invite or a reset, `/v1/_auth/account`, `/v1/_auth/password`, `/v1/_auth/totp`) establish or act on a browser's cookie session and accept only same-origin requests from loomcycle's own Web UI. A bearer has no session to end: retire a delegated user token with `revokeUserToken`, an operator token with `operatorTokenDef`.
583
+
584
+ ```ts
585
+ const pending = (await client.listUsers({ tenant: "acme" })).users.filter((u) => u.status === "pending");
586
+ for (const u of pending) await client.approveUser(u.user_id, { tenant: "acme" });
587
+
588
+ const { url } = await client.createInvite("password", { email: "carol@example.com", ttl: "72h" }, { tenant: "acme" });
589
+ ```
590
+
591
+ ### Sign in with loomcycle (apps)
592
+
593
+ An app built on this client does not need a pasted token: it can send the person to loomcycle's own login page, where they sign in through whatever gates the operator configured, and get back a short-lived access token for that same user (RFC FL). It is the OAuth 2.0 authorization code flow with PKCE S256: the app holds no secret, never sees a password, and is authorized exactly as the user is anywhere else. The server side needs `LOOMCYCLE_PUBLIC_URL` and the app registered as a client — in the config's `auth.clients` or at runtime with `createClientDef` (below); see `docs/CONFIGURATION.md`, "App sign-in".
594
+
595
+ `LoomcycleOAuth` (also `client.oauth`) runs on Web Crypto and `fetch`, so the same code serves a browser SPA, a Chrome extension and a Tauri or Node app. On Node 18, which has no global `crypto`, pass `crypto: require("node:crypto").webcrypto`.
596
+
597
+ | Method | Route | Returns |
598
+ |---|---|---|
599
+ | `beginAuthorize({clientId, redirectUri, state?})` | — (builds `GET /v1/_oauth/authorize?…`) | `{url, verifier, state}` — persist `verifier` and `state`, then send the person to `url` |
600
+ | `completeAuthorize({...parseAuthorizeResponse(href), expectedState, verifier, clientId, redirectUri})` | `POST /v1/_oauth/token` | `OAuthTokenSet` — `{access_token, refresh_token, token_type, expires_in, expires_at}` (`expires_at` in ms since the epoch) |
601
+ | `refresh({refreshToken, clientId})` | `POST /v1/_oauth/token` | a new `OAuthTokenSet`; the old refresh token is spent |
602
+ | `revoke({token, clientId})` | `POST /v1/_oauth/revoke` | void — a refresh token ends the whole sign-in |
603
+ | `discover()` | `GET /.well-known/oauth-authorization-server` | `OAuthServerMetadata` (RFC 8414), cached |
604
+
605
+ `completeAuthorize` refuses, before the code is used, a redirect whose `state` is not the one this sign-in sent (`state_mismatch`) or whose `iss` names another server (`issuer_mismatch`, RFC 9207; the issuer comes from the `issuer` option or from `discover()`), and throws a redirect's `error` (`access_denied` with `pending` for a user awaiting approval). Every failure is an `OAuthError` whose `code` is the OAuth error code.
606
+
607
+ A browser SPA:
608
+
609
+ ```ts
610
+ import { LoomcycleClient, LoomcycleOAuth, OAuthTokenProvider, parseAuthorizeResponse } from "@loomcycle/client";
611
+
612
+ const baseUrl = "https://loom.example.com";
613
+ const clientId = "loomboard";
614
+ const redirectUri = `${location.origin}/auth/callback`; // registered exactly
615
+ const oauth = new LoomcycleOAuth({ baseUrl });
616
+
617
+ // "Sign in" button:
618
+ const req = await oauth.beginAuthorize({ clientId, redirectUri });
619
+ sessionStorage.setItem("lc_sign_in", JSON.stringify({ verifier: req.verifier, state: req.state }));
620
+ location.assign(req.url);
621
+
622
+ // On /auth/callback:
623
+ const saved = JSON.parse(sessionStorage.getItem("lc_sign_in") ?? "{}");
624
+ sessionStorage.removeItem("lc_sign_in");
625
+ const tokens = await oauth.completeAuthorize({
626
+ ...parseAuthorizeResponse(location.href),
627
+ expectedState: saved.state,
628
+ verifier: saved.verifier,
629
+ clientId,
630
+ redirectUri,
631
+ });
632
+ history.replaceState(null, "", "/"); // the code is spent; drop it from the address bar
633
+
634
+ const provider = new OAuthTokenProvider({
635
+ oauth,
636
+ clientId,
637
+ tokens,
638
+ onTokens: (t) => saveTokens(t), // persist every rotated set
639
+ onSignedOut: () => showSignIn(),
640
+ });
641
+ const client = new LoomcycleClient({ baseUrl, tokenProvider: provider });
642
+ console.log((await client.whoami()).subject);
643
+
644
+ // "Sign out":
645
+ await provider.signOut();
646
+ ```
647
+
648
+ A Chrome extension does the same with `chrome.identity.launchWebAuthFlow({url: req.url, interactive: true})`, which resolves with the redirect URL to hand to `parseAuthorizeResponse`; register `https://<extension-id>.chromiumapp.org/` as the redirect URI and `chrome-extension://<extension-id>` as an allowed origin.
649
+
650
+ A desktop app (Tauri, Electron, a CLI) uses a loopback redirect: register `http://127.0.0.1/callback` and the server accepts it on any port (RFC 8252). In Node:
651
+
652
+ ```ts
653
+ import { createServer } from "node:http";
654
+
655
+ const server = createServer();
656
+ await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
657
+ const port = (server.address() as { port: number }).port;
658
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
659
+
660
+ const req = await oauth.beginAuthorize({ clientId: "loomboard-desktop", redirectUri });
661
+ openInSystemBrowser(req.url); // e.g. Tauri's shell.open, Electron's shell.openExternal
662
+
663
+ const href = await new Promise<string>((resolve) =>
664
+ server.on("request", (r, res) => {
665
+ res.end("Signed in. You can close this window.");
666
+ resolve(`http://127.0.0.1:${port}${r.url}`);
667
+ }),
668
+ );
669
+ server.close();
670
+ const tokens = await oauth.completeAuthorize({
671
+ ...parseAuthorizeResponse(href),
672
+ expectedState: req.state,
673
+ verifier: req.verifier,
674
+ clientId: "loomboard-desktop",
675
+ redirectUri,
676
+ });
677
+ ```
678
+
679
+ A Tauri app may register a private-use scheme instead (`loomboard://auth/callback`) and pass the deep link it receives to `parseAuthorizeResponse`. Token calls made from its webview send the origin `tauri://localhost`, which the client must list in `allowed_origins`; calls from the Rust or Node side send none.
680
+
681
+ **The token provider.** `new LoomcycleClient({ tokenProvider })` (instead of `authToken`; passing both throws) asks the provider for the bearer on every request, `connectClientTools` included. `OAuthTokenProvider` refreshes 60 s before the access token expires (`refreshSkewMs`), and once after a 401: the request is retried a single time with the new token, never more. Concurrent requests share one refresh. Any other source of short-lived tokens can implement `TokenProvider` (`getToken`, optional `onUnauthorized`).
682
+
683
+ Things to know:
684
+
685
+ - **Persist every rotated set.** A refresh spends the old refresh token, and presenting a spent one again ends the whole sign-in (reuse detection). `onTokens` is called before the new token is used.
686
+ - **One provider per sign-in.** Two providers, or two browser tabs, refreshing the same refresh token end it the same way. Share one, or sign each tab in separately.
687
+ - **Never log a token or put one in a URL.** The helper sends codes, verifiers and tokens only in POST bodies, with no cookie and no `Authorization` header; keep the `verifier` in session storage, not the URL, and clear it after the exchange.
688
+ - **An ended sign-in** (revoked, expired, its client retired) reaches `onSignedOut` once, when its refresh token is refused; requests then fail with an `OAuthError` (or the server's 401 as `AuthError`). Send the person to sign in again.
689
+
690
+ **Registering apps (ClientDef).** A tenant operator (`substrate:tenant`) registers its own tenant's apps; an admin any tenant's, or one for every tenant (`tenant: ""`, the default). Config clients are listed read only, and another tenant's client reads as not found. A refusal raises `SubstrateToolRefusedError`.
691
+
692
+ | Method | Route | Returns |
693
+ |---|---|---|
694
+ | `createClientDef(name, client, {description?, tenant?}?)` | `POST /v1/_clientdef` `op: create` | `ClientDefRecord` |
695
+ | `updateClientDef(name, client, {description?}?)` | `op: update` | `ClientDefRecord`, a new version; unexchanged codes stop working |
696
+ | `getClientDef({name} \| {def_id})` | `op: get` | `AppClientEntry` (`source: "def"` or `"config"`) |
697
+ | `listClientDefs({tenant?}?)` | `op: list` | `{clients: AppClientEntry[]}` |
698
+ | `retireClientDef(name)` | `op: retire` | `{name, retired}` — its sign-ins are revoked and their tokens retired |
699
+ | `verifyClientDef(name, {contentSha256?}?)` | `op: verify` | `ClientDefVerifyResult` |
700
+ | `listClientDefNames({tenant?}?)` | `GET /v1/_clientdef/names` | `{names: ClientDefNameSummary[]}` |
701
+ | `clientDef(input)` | `POST /v1/_clientdef` | the op dispatch, as sent |
702
+
703
+ ```ts
704
+ await admin.createClientDef("loomboard", {
705
+ kind: "public",
706
+ label: "Loomboard",
707
+ redirect_uris: ["https://board.example.com/auth/callback", "http://127.0.0.1/callback"],
708
+ allowed_origins: ["https://board.example.com"],
709
+ access_token_ttl: "1h",
710
+ refresh: { idle: "7d", absolute: "30d" },
711
+ }, { tenant: "acme" });
712
+ ```
713
+
556
714
  ### Decision models
557
715
 
558
716
  Ask a **decision model** typed questions about a piece of text and get each answer with its probabilities. A decision model does not write: it reads the `state` you give it and answers a choice, a yes/no or a score, so there is no reply to parse and a call costs a few output tokens. Use it to route, gate, rank or grade text you already have. No run is involved; the tokens are charged to the caller and count against its token budget.
@@ -14,6 +14,7 @@
14
14
  Object.defineProperty(exports, "__esModule", { value: true });
15
15
  exports.ClientToolHost = exports.CLIENT_TOOL_SUBPROTOCOL = void 0;
16
16
  exports.clientToolsURL = clientToolsURL;
17
+ const errors_js_1 = require("./errors.js");
17
18
  /** The app-level subprotocol the server negotiates for /v1/client-tools. */
18
19
  exports.CLIENT_TOOL_SUBPROTOCOL = "loomcycle.client-tools.v1";
19
20
  /**
@@ -24,13 +25,16 @@ exports.CLIENT_TOOL_SUBPROTOCOL = "loomcycle.client-tools.v1";
24
25
  */
25
26
  class ClientToolHost {
26
27
  url;
27
- authToken;
28
+ auth;
28
29
  opts;
29
30
  ws;
30
31
  closed = false;
31
- constructor(url, authToken, opts) {
32
+ /** `auth` is the bearer, or a function that supplies one on each
33
+ * (re)connect — a client built with a token provider passes one, so a
34
+ * reconnect after the access token expired carries the refreshed one. */
35
+ constructor(url, auth, opts) {
32
36
  this.url = url;
33
- this.authToken = authToken;
37
+ this.auth = auth;
34
38
  this.opts = opts;
35
39
  }
36
40
  /** Open the connection (called for you by connectClientTools). */
@@ -49,9 +53,28 @@ class ClientToolHost {
49
53
  if (!WS) {
50
54
  throw new Error("connectClientTools: no WebSocket implementation available — pass WebSocketImpl (e.g. the `ws` package) on Node < 22");
51
55
  }
56
+ if (typeof this.auth === "function") {
57
+ this.auth().then((token) => this.open(WS, token), (err) => {
58
+ this.opts.onError?.(err);
59
+ // A sign-in that ended does not come back by retrying: stop
60
+ // rather than ask again every few seconds.
61
+ if (err instanceof errors_js_1.OAuthError && !err.retryable) {
62
+ this.closed = true;
63
+ this.opts.onStatus?.("closed");
64
+ return;
65
+ }
66
+ this.scheduleReconnect();
67
+ });
68
+ return;
69
+ }
70
+ this.open(WS, this.auth);
71
+ }
72
+ open(WS, token) {
73
+ if (this.closed)
74
+ return;
52
75
  const protocols = [exports.CLIENT_TOOL_SUBPROTOCOL];
53
- if (this.authToken)
54
- protocols.push("bearer." + this.authToken);
76
+ if (token)
77
+ protocols.push("bearer." + token);
55
78
  this.opts.onStatus?.("connecting");
56
79
  const ws = new WS(this.url, protocols);
57
80
  this.ws = ws;
@@ -29,6 +29,8 @@ exports.LoomcycleClient = void 0;
29
29
  const fetch_helpers_js_1 = require("./fetch-helpers.js");
30
30
  const stream_js_1 = require("./stream.js");
31
31
  const interactive_js_1 = require("./interactive.js");
32
+ const oauth_js_1 = require("./oauth.js");
33
+ const device_login_js_1 = require("./device-login.js");
32
34
  const client_tools_js_1 = require("./client-tools.js");
33
35
  /** samplingToWire maps the camelCase SamplingOptions to the snake_case wire
34
36
  * object, omitting unset fields so they inherit the agent's value. */
@@ -220,13 +222,30 @@ function applyOverridesToWire(body, opts) {
220
222
  }
221
223
  class LoomcycleClient {
222
224
  ctx;
225
+ tokenProvider;
226
+ oauthHelper;
227
+ rawFetch;
223
228
  constructor(opts = {}) {
229
+ if (opts.authToken && opts.tokenProvider) {
230
+ throw new TypeError("LoomcycleClient: pass authToken or tokenProvider, not both");
231
+ }
232
+ const fetchImpl = opts.fetch ?? fetch;
233
+ this.rawFetch = opts.fetch;
234
+ this.tokenProvider = opts.tokenProvider;
224
235
  this.ctx = {
225
236
  baseUrl: (opts.baseUrl ?? "http://127.0.0.1:8787").replace(/\/$/, ""),
226
237
  authToken: opts.authToken,
227
- fetchImpl: opts.fetch ?? fetch,
238
+ // With a provider, ctx.authToken stays unset and the wrapper sets the
239
+ // header on every request, so no method needs to know.
240
+ fetchImpl: opts.tokenProvider ? (0, fetch_helpers_js_1.authorizedFetch)(fetchImpl, opts.tokenProvider) : fetchImpl,
228
241
  };
229
242
  }
243
+ /** The app sign-in helper (RFC FL) for this server: same base URL and
244
+ * fetch, never this client's bearer. See {@link LoomcycleOAuth}. */
245
+ get oauth() {
246
+ this.oauthHelper ??= new oauth_js_1.LoomcycleOAuth({ baseUrl: this.ctx.baseUrl, fetch: this.rawFetch });
247
+ return this.oauthHelper;
248
+ }
230
249
  // ---- Run lifecycle ----
231
250
  /**
232
251
  * Run an agent and stream events. Returns AsyncIterable<AgentEvent>;
@@ -844,7 +863,8 @@ class LoomcycleClient {
844
863
  return { agents: resp.agents ?? [], next_cursor: resp.next_cursor ?? "" };
845
864
  }
846
865
  /** Aggregated token-usage + cost report (RFC AV). Group by any of
847
- * tenant/user/provider/model/source/purpose over an optional time window;
866
+ * tenant/user/provider/model/source/purpose/tier/user_tier/agent/class over
867
+ * an optional time window, split into periods with `bucket`;
848
868
  * group by `source` for the operator-vs-tenant split, by `purpose` to see
849
869
  * what OCR and speech-to-text cost (a row's `audio_seconds` is the audio its
850
870
  * speech-to-text calls transcribed). Tenant-scoped server-side: a
@@ -860,6 +880,16 @@ class LoomcycleClient {
860
880
  params.set("to", opts.to);
861
881
  if (opts?.tenant)
862
882
  params.set("tenant", opts.tenant);
883
+ if (opts?.bucket)
884
+ params.set("bucket", opts.bucket);
885
+ if (opts?.top)
886
+ params.set("top", String(opts.top));
887
+ if (opts?.topBy)
888
+ params.set("top_by", opts.topBy);
889
+ for (const [dim, values] of Object.entries(opts?.filters ?? {})) {
890
+ if (values && values.length > 0)
891
+ params.set(dim, values.join(","));
892
+ }
863
893
  const q = params.toString() ? `?${params.toString()}` : "";
864
894
  return (0, fetch_helpers_js_1.jsonFetch)(this.ctx, `/v1/_usage${q}`, opts);
865
895
  }
@@ -984,28 +1014,151 @@ class LoomcycleClient {
984
1014
  //
985
1015
  // A tenant operator (substrate:tenant) manages the first-class users in its
986
1016
  // OWN tenant and mints/lists/revokes bearer tokens for them. The tenant is
987
- // always server-derived from the authenticated principal — never sent — so
988
- // there is no tenant field on any of these calls. Requires a persistent store
1017
+ // server-derived from the authenticated principal. `opts.tenant` (sent as
1018
+ // ?tenant=) names another only for an admin token, and for an `auth:manage`
1019
+ // credential, which must name one (400 `tenant_required` otherwise); a
1020
+ // tenant operator always acts in its own tenant. Requires a persistent store
989
1021
  // (503 otherwise).
990
1022
  /** Register a first-class user in the caller's own tenant. `access_mode`
991
1023
  * defaults to "tenant" (collaborates on tenant-shared primitives);
992
1024
  * "isolated" confines the member to its own user scope. 409 on a duplicate
993
1025
  * (tenant, subject). */
994
1026
  async createUser(body, opts) {
995
- return (0, fetch_helpers_js_1.postJSON)(this.ctx, "/v1/_users", body, opts);
1027
+ return (0, fetch_helpers_js_1.postJSON)(this.ctx, this.aclPath("/v1/_users", opts?.tenant), body, {
1028
+ signal: opts?.signal,
1029
+ });
996
1030
  }
997
1031
  /** Patch mutable fields on a user in the caller's own tenant. An omitted key
998
1032
  * leaves the column unchanged. Disabling a user (status:"disabled") also
999
- * revokes its delegated tokens server-side. Cross-tenant target → 404. */
1033
+ * revokes its delegated tokens server-side. A pending user is activated by
1034
+ * {@link approveUser}, not here (409 `use_approve`). Cross-tenant target →
1035
+ * 404. */
1000
1036
  async updateUser(subject, body, opts) {
1001
- return (0, fetch_helpers_js_1.patchJSON)(this.ctx, `/v1/_users/${encodeURIComponent(subject)}`, body, opts);
1037
+ return (0, fetch_helpers_js_1.patchJSON)(this.ctx, this.aclPath(`/v1/_users/${encodeURIComponent(subject)}`, opts?.tenant), body, { signal: opts?.signal });
1002
1038
  }
1003
1039
  /** Delete the user identity row in the caller's own tenant (204). Its
1004
1040
  * delegated tokens are retired first so the bearer cannot outlive the row.
1005
1041
  * Owned data (runs / sessions / memory) is left intact — this is not
1006
1042
  * erasure ({@link erasureExecute}). Cross-tenant target → 404. */
1007
1043
  async deleteUser(subject, opts) {
1008
- return (0, fetch_helpers_js_1.deleteRequest)(this.ctx, `/v1/_users/${encodeURIComponent(subject)}`, opts);
1044
+ return (0, fetch_helpers_js_1.deleteRequest)(this.ctx, this.aclPath(`/v1/_users/${encodeURIComponent(subject)}`, opts?.tenant), { signal: opts?.signal });
1045
+ }
1046
+ /** Approve a user awaiting approval: `pending` → `active`
1047
+ * (`POST /v1/_users/{subject}/approve`). A sign-in gate configured with
1048
+ * `new_user.status: pending` provisions people who can sign in but are
1049
+ * refused everything until this call. Returns the updated record. 409
1050
+ * `not_pending` for a user that is not pending; 404 for an unknown or
1051
+ * cross-tenant subject. An `auth:manage` credential may approve only a user
1052
+ * at its floor (403 `target_beyond_floor`). */
1053
+ async approveUser(subject, opts) {
1054
+ return (0, fetch_helpers_js_1.postJSON)(this.ctx, this.aclPath(`/v1/_users/${encodeURIComponent(subject)}/approve`, opts?.tenant), undefined, { signal: opts?.signal });
1055
+ }
1056
+ /** The identity-provider accounts a user signs in with
1057
+ * (`GET /v1/_users/{subject}/identities`): one row per linked gate, never a
1058
+ * secret. A user reads its own; a tenant operator or an admin reads any in
1059
+ * the tenant. Password-gate logins, with whether a password and an
1060
+ * authenticator are set, are {@link listGateIdentities}. */
1061
+ async listUserIdentities(subject, opts) {
1062
+ return (0, fetch_helpers_js_1.jsonFetch)(this.ctx, this.aclPath(`/v1/_users/${encodeURIComponent(subject)}/identities`, opts?.tenant), opts);
1063
+ }
1064
+ /** Unlink one identity-provider account from a user
1065
+ * (`DELETE /v1/_users/{subject}/identities/{gate}`, 204). Sessions made
1066
+ * through it end on their next request. For a tenant operator or an admin;
1067
+ * a user unlinking its own account must do it from a recently
1068
+ * signed-in Web UI session, so a bearer is refused (403
1069
+ * `link_needs_own_session`). A password gate's login is removed with
1070
+ * {@link removeGateCredentials} instead (409 `password_gate`). 404
1071
+ * `identity_not_found` when no account of that gate is linked. */
1072
+ async unlinkUserIdentity(subject, gate, opts) {
1073
+ return (0, fetch_helpers_js_1.deleteRequest)(this.ctx, this.aclPath(`/v1/_users/${encodeURIComponent(subject)}/identities/${encodeURIComponent(gate)}`, opts?.tenant), { signal: opts?.signal });
1074
+ }
1075
+ /** Sign in through the server's device gate (RFC 8628): shows the person a
1076
+ * code through `onPrompt`, polls until they approve it in a signed-in Web
1077
+ * UI session (`/ui/device`), and returns the token issued to them. The
1078
+ * token is the approver's own member token, at most the device gate's
1079
+ * `token_ttl` and never past the approving session or the credential it
1080
+ * was made from. Sends no bearer: a client constructed without one can
1081
+ * call it. Throws {@link DeviceLoginError} when the login is denied,
1082
+ * expires, or the server refuses it; 404 `device_login_disabled` when no
1083
+ * device gate is configured. */
1084
+ async deviceLogin(opts) {
1085
+ return (0, device_login_js_1.deviceLogin)(this.ctx, opts);
1086
+ }
1087
+ // ---- RFC FA: sign-in gates + the password gate's management API ----
1088
+ //
1089
+ // Invitations, reset links, credential removal, login renames and a user's
1090
+ // logins at a password gate, addressed by the gate's configured name. None
1091
+ // takes a password: the person sets their own through the link. A tenant
1092
+ // operator manages its own tenant, an admin any (`opts.tenant`, defaulting
1093
+ // to its own), and an `auth:manage` credential any tenant it names — it
1094
+ // must name one (400 `tenant_required`) and is held to the gate's new-user
1095
+ // floor. A gate that is not a password gate is a 404 `gate_not_found`.
1096
+ // Requires a persistent store (503 otherwise).
1097
+ /** Every configured sign-in gate (`GET /v1/_authgate`): kind, label,
1098
+ * whether a browser can sign in through it, its identity map and an
1099
+ * external gate's key fingerprint and removal timing — never key
1100
+ * material. **Admin token only.** `probe: true` also fetches each
1101
+ * external gate's discovery document (bounded) and compares it with the
1102
+ * registration. */
1103
+ async listAuthGates(opts) {
1104
+ const q = opts?.probe ? "?probe=1" : "";
1105
+ return (0, fetch_helpers_js_1.jsonFetch)(this.ctx, `/v1/_authgate${q}`, opts);
1106
+ }
1107
+ /** Invite a person to sign in through a password gate
1108
+ * (`POST /v1/_authgate/{gate}/invites`). Without `subject` the invite
1109
+ * makes a new user, at the gate's new-user floor unless the gate is
1110
+ * trusted; with one it gives that existing user a login (409
1111
+ * `credential_exists` if it has one — issue a reset instead). Returns the
1112
+ * one-time link, shown once. */
1113
+ async createInvite(gate, body, opts) {
1114
+ return (0, fetch_helpers_js_1.postJSON)(this.ctx, `/v1/_authgate/${encodeURIComponent(gate)}/invites`, withTenant(body, opts?.tenant), { signal: opts?.signal });
1115
+ }
1116
+ /** A password gate's invites in the tenant, live and past
1117
+ * (`GET /v1/_authgate/{gate}/invites`), never their codes. An admin naming
1118
+ * no tenant lists every tenant's. */
1119
+ async listInvites(gate, opts) {
1120
+ return (0, fetch_helpers_js_1.jsonFetch)(this.ctx, this.aclPath(`/v1/_authgate/${encodeURIComponent(gate)}/invites`, opts?.tenant), opts);
1121
+ }
1122
+ /** Revoke a live invite (`DELETE /v1/_authgate/{gate}/invites/{id}`, 204).
1123
+ * 409 `invite_not_live` when it was already redeemed, revoked or expired;
1124
+ * another tenant's invite is a 404. */
1125
+ async revokeInvite(gate, inviteId, opts) {
1126
+ return (0, fetch_helpers_js_1.deleteRequest)(this.ctx, this.aclPath(`/v1/_authgate/${encodeURIComponent(gate)}/invites/${encodeURIComponent(inviteId)}`, opts?.tenant), { signal: opts?.signal });
1127
+ }
1128
+ /** Issue a one-time link that sets a new password for a user with a login
1129
+ * at the gate (`POST /v1/_authgate/{gate}/resets`). A user with an
1130
+ * authenticator still needs a code from it. Completing the reset ends the
1131
+ * user's sessions from the old password and retires its delegated tokens.
1132
+ * 404 `login_not_found` when the user has no login at the gate. */
1133
+ async createPasswordReset(gate, subject, opts) {
1134
+ return (0, fetch_helpers_js_1.postJSON)(this.ctx, `/v1/_authgate/${encodeURIComponent(gate)}/resets`, withTenant({ subject }, opts?.tenant), { signal: opts?.signal });
1135
+ }
1136
+ /** Remove a user's password, authenticator and recovery codes at a gate
1137
+ * (`DELETE /v1/_authgate/{gate}/credentials/{subject}`, 204). The login
1138
+ * stays reserved, so a later {@link createPasswordReset} restores access. */
1139
+ async removeGateCredentials(gate, subject, opts) {
1140
+ return (0, fetch_helpers_js_1.deleteRequest)(this.ctx, this.aclPath(`/v1/_authgate/${encodeURIComponent(gate)}/credentials/${encodeURIComponent(subject)}`, opts?.tenant), { signal: opts?.signal });
1141
+ }
1142
+ /** Change the login a user signs in with at a gate
1143
+ * (`PATCH /v1/_authgate/{gate}/logins/{subject}`). The subject does not
1144
+ * change, so nothing the user owns moves. Returns the normalised login;
1145
+ * 409 `login_taken` when another user has it. */
1146
+ async renameGateLogin(gate, subject, login, opts) {
1147
+ return (0, fetch_helpers_js_1.patchJSON)(this.ctx, `/v1/_authgate/${encodeURIComponent(gate)}/logins/${encodeURIComponent(subject)}`, withTenant({ login }, opts?.tenant), { signal: opts?.signal });
1148
+ }
1149
+ /** A user's logins at every gate, and whether it has a password and a
1150
+ * confirmed authenticator (`GET /v1/_authgate/{gate}/identities`) — never
1151
+ * a secret. `gate` must name a password gate. */
1152
+ async listGateIdentities(gate, subject, opts) {
1153
+ return (0, fetch_helpers_js_1.jsonFetch)(this.ctx, this.aclPath(`/v1/_authgate/${encodeURIComponent(gate)}/identities`, opts?.tenant, {
1154
+ subject,
1155
+ }), opts);
1156
+ }
1157
+ /** Whether the tenant has, or ever had, a user and whether an owner invite
1158
+ * is live (`GET /v1/_authgate/{gate}/tenant-status`) — what deciding on an
1159
+ * owner invite needs, and nothing about who the users are. */
1160
+ async getGateTenantStatus(gate, opts) {
1161
+ return (0, fetch_helpers_js_1.jsonFetch)(this.ctx, this.aclPath(`/v1/_authgate/${encodeURIComponent(gate)}/tenant-status`, opts?.tenant), opts);
1009
1162
  }
1010
1163
  /** Mint a bearer token for one of the caller's own ACTIVE users. The granted
1011
1164
  * scopes are DERIVED server-side from the member's access_mode (never sent),
@@ -1199,7 +1352,11 @@ class LoomcycleClient {
1199
1352
  * `.close()` to stop. Uses the global WebSocket (browsers, Node 22+); pass
1200
1353
  * `WebSocketImpl` (e.g. the `ws` package) on older Node. */
1201
1354
  connectClientTools(opts) {
1202
- const host = new client_tools_js_1.ClientToolHost((0, client_tools_js_1.clientToolsURL)(this.ctx.baseUrl), this.ctx.authToken, opts);
1355
+ // A provider is asked on every (re)connect, so a host outliving one
1356
+ // access token reconnects with the next.
1357
+ const provider = this.tokenProvider;
1358
+ const auth = provider ? () => provider.getToken() : this.ctx.authToken;
1359
+ const host = new client_tools_js_1.ClientToolHost((0, client_tools_js_1.clientToolsURL)(this.ctx.baseUrl), auth, opts);
1203
1360
  host.start();
1204
1361
  return host;
1205
1362
  }
@@ -1836,6 +1993,73 @@ class LoomcycleClient {
1836
1993
  async volumeDef(input, opts) {
1837
1994
  return (0, fetch_helpers_js_1.postJSON)(this.ctx, "/v1/_volumedef", input, opts);
1838
1995
  }
1996
+ // ---- App clients (RFC FL ClientDef) ----
1997
+ //
1998
+ // The apps that sign people in through this server's gates, registered at
1999
+ // runtime beside the config's `auth.clients`. A tenant operator
2000
+ // (substrate:tenant) registers its own tenant's; an admin any tenant's, or
2001
+ // one for every tenant. Config clients are read only here, and another
2002
+ // tenant's client answers as not found. A refusal raises
2003
+ // SubstrateToolRefusedError. Each method posts to `POST /v1/_clientdef`.
2004
+ /** The ClientDef op dispatch, as sent: `{op, name?, def_id?, client?,
2005
+ * description?, tenant?, content_sha256?}`. The typed methods below are
2006
+ * the usual way in. */
2007
+ async clientDef(input, opts) {
2008
+ return (0, fetch_helpers_js_1.postJSON)(this.ctx, "/v1/_clientdef", input, opts);
2009
+ }
2010
+ /** Register an app (`op: "create"`). Its redirect URIs and origins are
2011
+ * checked as strictly as a config client's. `tenant` is for an admin: the
2012
+ * tenant whose people it signs in, "" (the default) for every tenant. */
2013
+ async createClientDef(name, client, opts) {
2014
+ const input = { op: "create", name, client };
2015
+ if (opts?.description !== undefined)
2016
+ input.description = opts.description;
2017
+ if (opts?.tenant !== undefined)
2018
+ input.tenant = opts.tenant;
2019
+ return (await this.clientDef(input, signalOpts(opts)));
2020
+ }
2021
+ /** Change an app (`op: "update"`): a new version, whole. Codes issued
2022
+ * under the old version that were not exchanged yet stop working. */
2023
+ async updateClientDef(name, client, opts) {
2024
+ const input = { op: "update", name, client };
2025
+ if (opts?.description !== undefined)
2026
+ input.description = opts.description;
2027
+ return (await this.clientDef(input, signalOpts(opts)));
2028
+ }
2029
+ /** One app by name (a config client too) or one version by `def_id`
2030
+ * (`op: "get"`). */
2031
+ async getClientDef(ref, opts) {
2032
+ return (await this.clientDef({ op: "get", ...ref }, opts));
2033
+ }
2034
+ /** The apps the caller may see, config ones included (`op: "list"`). An
2035
+ * admin may focus one tenant with `tenant` ("" = the every-tenant ones). */
2036
+ async listClientDefs(opts) {
2037
+ const input = { op: "list" };
2038
+ if (opts?.tenant !== undefined)
2039
+ input.tenant = opts.tenant;
2040
+ return (await this.clientDef(input, signalOpts(opts)));
2041
+ }
2042
+ /** Take an app out of service (`op: "retire"`): its unused codes are
2043
+ * voided, its sign-ins revoked and their access tokens retired. The name
2044
+ * is not reusable. */
2045
+ async retireClientDef(name, opts) {
2046
+ return (await this.clientDef({ op: "retire", name }, opts));
2047
+ }
2048
+ /** Whether an app is deployed and, with `contentSha256`, whether it matches
2049
+ * (`op: "verify"`). An absent app answers `deployed: false`. */
2050
+ async verifyClientDef(name, opts) {
2051
+ const input = { op: "verify", name };
2052
+ if (opts?.contentSha256)
2053
+ input.content_sha256 = opts.contentSha256;
2054
+ return (await this.clientDef(input, signalOpts(opts)));
2055
+ }
2056
+ /** The apps the caller may see, by name (`GET /v1/_clientdef/names`). An
2057
+ * admin may focus one tenant with `tenant`; unlike the other families'
2058
+ * name lists, `""` is a focus too (the every-tenant apps). */
2059
+ async listClientDefNames(opts) {
2060
+ const q = opts?.tenant !== undefined ? `?tenant=${encodeURIComponent(opts.tenant)}` : "";
2061
+ return (0, fetch_helpers_js_1.jsonFetch)(this.ctx, `/v1/_clientdef/names${q}`, opts);
2062
+ }
1839
2063
  // ---- Def name enumeration (GET /v1/_<family>def/names) ----
1840
2064
  //
1841
2065
  // Every name declared in one substrate family, each with its version
@@ -3351,6 +3575,12 @@ function defWriteInput(op, name, overlay, opts) {
3351
3575
  input.promote = opts.promote;
3352
3576
  return input;
3353
3577
  }
3578
+ /** A password-gate management body with the tenant the caller named. The
3579
+ * POST/PATCH routes read the tenant from the body where the others read
3580
+ * ?tenant=; like aclPath, an empty tenant is left out. */
3581
+ function withTenant(body, tenant) {
3582
+ return tenant ? { ...body, tenant } : body;
3583
+ }
3354
3584
  function signalOpts(opts) {
3355
3585
  return opts?.signal ? { signal: opts.signal } : undefined;
3356
3586
  }