@loomcycle/client 1.124.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.
@@ -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>;
@@ -446,6 +465,51 @@ class LoomcycleClient {
446
465
  async getEffectiveConfig(runId, opts) {
447
466
  return (0, fetch_helpers_js_1.jsonFetch)(this.ctx, `/v1/runs/${encodeURIComponent(runId)}/effective-config`, opts);
448
467
  }
468
+ /** Run one File-tool op on a run's inbox from outside the run — the files the
469
+ * run's tools returned. Mirrors `POST /v1/runs/{run_id}/file`, the HTTP twin
470
+ * of the MCP `file` tool. Ops: stat, ls, extract, import, keep, rm, clear.
471
+ * The response is the op's own JSON (narrow as needed).
472
+ *
473
+ * Needs `runs:read`, and works on any run you can read; another tenant's run
474
+ * is an opaque {@link NotFoundError}. extract, keep, rm and clear write, so
475
+ * they need the run's owner or an admin — a tenant operator counts as one
476
+ * for its own tenant's runs; anyone else gets a
477
+ * {@link SubstrateToolRefusedError}, as does a refused op. */
478
+ async runFile(runId, input, opts) {
479
+ return (0, fetch_helpers_js_1.postJSON)(this.ctx, `/v1/runs/${encodeURIComponent(runId)}/file`, input, opts);
480
+ }
481
+ /** Download one file of a run's inbox. Mirrors
482
+ * `GET /v1/runs/{run_id}/inbox/{path}` (HEAD with `head: true`). `path` is
483
+ * relative to the inbox root (`documents/a.pdf`) or a reference
484
+ * (`volume:inbox:documents/a.pdf`).
485
+ *
486
+ * Any reader of the run may download (`runs:read`). `range` is sent as the
487
+ * Range header (`bytes=0-1023`). The inbox is deleted when its run ends, so
488
+ * a finished run's file — like a missing file, run, or another tenant's run —
489
+ * is a {@link NotFoundError}. */
490
+ async downloadRunInboxFile(runId, path, opts) {
491
+ const rel = path.replace(/^volume:inbox:/, "");
492
+ const encoded = rel.split("/").map(encodeURIComponent).join("/");
493
+ const headers = {};
494
+ if (this.ctx.authToken)
495
+ headers.Authorization = `Bearer ${this.ctx.authToken}`;
496
+ if (opts?.range)
497
+ headers.Range = opts.range;
498
+ const resp = await this.ctx.fetchImpl(`${this.ctx.baseUrl}/v1/runs/${encodeURIComponent(runId)}/inbox/${encoded}`, { method: opts?.head ? "HEAD" : "GET", headers, signal: opts?.signal });
499
+ if (!resp.ok) {
500
+ await (0, fetch_helpers_js_1.raiseFromResponse)(resp);
501
+ }
502
+ const bytes = opts?.head ? new Uint8Array() : new Uint8Array(await resp.arrayBuffer());
503
+ return {
504
+ bytes,
505
+ status: resp.status,
506
+ contentType: resp.headers.get("Content-Type") ?? "",
507
+ filename: dispositionFilename(resp.headers.get("Content-Disposition")) ?? rel.split("/").pop() ?? "",
508
+ sha256: resp.headers.get("X-Loomcycle-SHA256") ?? "",
509
+ origin: resp.headers.get("X-Loomcycle-Origin") ?? "",
510
+ headers: resp.headers,
511
+ };
512
+ }
449
513
  /** Which per-run overrides a run of `name` may set, for this caller — what a
450
514
  * run form needs before it sends anything. Mirrors
451
515
  * `GET /v1/_agentdef/{name}/overridable`.
@@ -799,8 +863,11 @@ class LoomcycleClient {
799
863
  return { agents: resp.agents ?? [], next_cursor: resp.next_cursor ?? "" };
800
864
  }
801
865
  /** Aggregated token-usage + cost report (RFC AV). Group by any of
802
- * tenant/user/provider/model/source over an optional time window; group by
803
- * `source` for the operator-vs-tenant split. Tenant-scoped server-side: a
866
+ * tenant/user/provider/model/source/purpose/tier/user_tier/agent/class over
867
+ * an optional time window, split into periods with `bucket`;
868
+ * group by `source` for the operator-vs-tenant split, by `purpose` to see
869
+ * what OCR and speech-to-text cost (a row's `audio_seconds` is the audio its
870
+ * speech-to-text calls transcribed). Tenant-scoped server-side: a
804
871
  * tenant principal sees only its own tenant (the `tenant` focus is
805
872
  * admin-only). Mirrors `GET /v1/_usage`. */
806
873
  async usageReport(opts) {
@@ -813,6 +880,16 @@ class LoomcycleClient {
813
880
  params.set("to", opts.to);
814
881
  if (opts?.tenant)
815
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
+ }
816
893
  const q = params.toString() ? `?${params.toString()}` : "";
817
894
  return (0, fetch_helpers_js_1.jsonFetch)(this.ctx, `/v1/_usage${q}`, opts);
818
895
  }
@@ -937,28 +1014,151 @@ class LoomcycleClient {
937
1014
  //
938
1015
  // A tenant operator (substrate:tenant) manages the first-class users in its
939
1016
  // OWN tenant and mints/lists/revokes bearer tokens for them. The tenant is
940
- // always server-derived from the authenticated principal — never sent — so
941
- // 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
942
1021
  // (503 otherwise).
943
1022
  /** Register a first-class user in the caller's own tenant. `access_mode`
944
1023
  * defaults to "tenant" (collaborates on tenant-shared primitives);
945
1024
  * "isolated" confines the member to its own user scope. 409 on a duplicate
946
1025
  * (tenant, subject). */
947
1026
  async createUser(body, opts) {
948
- 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
+ });
949
1030
  }
950
1031
  /** Patch mutable fields on a user in the caller's own tenant. An omitted key
951
1032
  * leaves the column unchanged. Disabling a user (status:"disabled") also
952
- * 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. */
953
1036
  async updateUser(subject, body, opts) {
954
- 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 });
955
1038
  }
956
1039
  /** Delete the user identity row in the caller's own tenant (204). Its
957
1040
  * delegated tokens are retired first so the bearer cannot outlive the row.
958
1041
  * Owned data (runs / sessions / memory) is left intact — this is not
959
1042
  * erasure ({@link erasureExecute}). Cross-tenant target → 404. */
960
1043
  async deleteUser(subject, opts) {
961
- 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);
962
1162
  }
963
1163
  /** Mint a bearer token for one of the caller's own ACTIVE users. The granted
964
1164
  * scopes are DERIVED server-side from the member's access_mode (never sent),
@@ -1152,7 +1352,11 @@ class LoomcycleClient {
1152
1352
  * `.close()` to stop. Uses the global WebSocket (browsers, Node 22+); pass
1153
1353
  * `WebSocketImpl` (e.g. the `ws` package) on older Node. */
1154
1354
  connectClientTools(opts) {
1155
- 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);
1156
1360
  host.start();
1157
1361
  return host;
1158
1362
  }
@@ -1275,6 +1479,12 @@ class LoomcycleClient {
1275
1479
  body.document_ids = input.documentIds;
1276
1480
  if (input.excludeDocumentIds !== undefined)
1277
1481
  body.exclude_document_ids = input.excludeDocumentIds;
1482
+ // The second scope's documents: sent only when set, so a one-scope search's
1483
+ // response stays byte-identical (no per-entry `scope`).
1484
+ if (input.documentsScope !== undefined)
1485
+ body.documents_scope = input.documentsScope;
1486
+ if (input.documentsScopeId !== undefined)
1487
+ body.documents_scope_id = input.documentsScopeId;
1278
1488
  return (0, fetch_helpers_js_1.postJSON)(this.ctx, "/v1/_memory/search", body, {
1279
1489
  signal: opts?.signal,
1280
1490
  });
@@ -1783,6 +1993,73 @@ class LoomcycleClient {
1783
1993
  async volumeDef(input, opts) {
1784
1994
  return (0, fetch_helpers_js_1.postJSON)(this.ctx, "/v1/_volumedef", input, opts);
1785
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
+ }
1786
2063
  // ---- Def name enumeration (GET /v1/_<family>def/names) ----
1787
2064
  //
1788
2065
  // Every name declared in one substrate family, each with its version
@@ -3219,6 +3496,23 @@ class LoomcycleClient {
3219
3496
  }
3220
3497
  }
3221
3498
  exports.LoomcycleClient = LoomcycleClient;
3499
+ /** The file name of an RFC 8187 `filename*=UTF-8''…` Content-Disposition, or
3500
+ * a plain quoted `filename="…"`; undefined when there is neither. */
3501
+ function dispositionFilename(header) {
3502
+ if (!header)
3503
+ return undefined;
3504
+ const star = /filename\*=UTF-8''([^;]+)/i.exec(header);
3505
+ if (star) {
3506
+ try {
3507
+ return decodeURIComponent(star[1].trim());
3508
+ }
3509
+ catch {
3510
+ return undefined;
3511
+ }
3512
+ }
3513
+ const plain = /filename="([^"]*)"/i.exec(header);
3514
+ return plain ? plain[1] : undefined;
3515
+ }
3222
3516
  /** Lightweight SSE parser tailored to the run-state stream. Each
3223
3517
  * frame's event name distinguishes the two kinds; data is JSON.
3224
3518
  * Comment lines (": keepalive") are ignored. */
@@ -3281,6 +3575,12 @@ function defWriteInput(op, name, overlay, opts) {
3281
3575
  input.promote = opts.promote;
3282
3576
  return input;
3283
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
+ }
3284
3584
  function signalOpts(opts) {
3285
3585
  return opts?.signal ? { signal: opts.signal } : undefined;
3286
3586
  }
@@ -0,0 +1,146 @@
1
+ "use strict";
2
+ /**
3
+ * @internal — reached through LoomcycleClient.deviceLogin().
4
+ *
5
+ * The client side of loomcycle's device login (RFC 8628, a `kind: device`
6
+ * sign-in gate): ask the server for a code pair, show the person the short
7
+ * code, poll until they approve it in a signed-in Web UI session, and return
8
+ * the token issued to them. Both requests are unauthenticated — the device
9
+ * code is the credential — so no bearer the client holds is sent with them.
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.deviceLogin = deviceLogin;
13
+ const errors_js_1 = require("./errors.js");
14
+ const GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
15
+ /** RFC 8628 §3.5: a slow_down lengthens this and every later poll by 5 s. */
16
+ const SLOW_DOWN_STEP_MS = 5_000;
17
+ const DEFAULT_INTERVAL_S = 5;
18
+ /** The server issues codes for ten minutes; a longer claim is not trusted. */
19
+ const DEFAULT_EXPIRES_S = 600;
20
+ const MAX_EXPIRES_S = 3_600;
21
+ async function postForm(ctx, path, form, signal) {
22
+ const res = await ctx.fetchImpl(`${ctx.baseUrl}${path}`, {
23
+ method: "POST",
24
+ headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" },
25
+ body: new URLSearchParams(form).toString(),
26
+ signal,
27
+ });
28
+ const text = await res.text();
29
+ let body = {};
30
+ try {
31
+ const parsed = JSON.parse(text);
32
+ if (parsed && typeof parsed === "object")
33
+ body = parsed;
34
+ }
35
+ catch {
36
+ // A non-JSON answer is reported by status below.
37
+ }
38
+ return { status: res.status, body, text };
39
+ }
40
+ function sleep(ms, signal) {
41
+ return new Promise((resolve, reject) => {
42
+ if (signal?.aborted) {
43
+ reject(signal.reason ?? new Error("aborted"));
44
+ return;
45
+ }
46
+ const t = setTimeout(() => {
47
+ signal?.removeEventListener("abort", onAbort);
48
+ resolve();
49
+ }, ms);
50
+ const onAbort = () => {
51
+ clearTimeout(t);
52
+ reject(signal?.reason ?? new Error("aborted"));
53
+ };
54
+ signal?.addEventListener("abort", onAbort, { once: true });
55
+ });
56
+ }
57
+ /** A string shown to a person must not carry control characters a hostile
58
+ * server could use to rewrite what they see. */
59
+ function printable(s, max) {
60
+ return typeof s === "string" && s.length > 0 && s.length <= max && /^[\x20-\x7e]+$/.test(s);
61
+ }
62
+ function httpURL(s) {
63
+ if (!printable(s, 2048))
64
+ return false;
65
+ try {
66
+ const u = new URL(s);
67
+ return u.protocol === "https:" || u.protocol === "http:";
68
+ }
69
+ catch {
70
+ return false;
71
+ }
72
+ }
73
+ function positiveInt(v) {
74
+ return typeof v === "number" && Number.isInteger(v) && v > 0 ? v : undefined;
75
+ }
76
+ async function deviceLogin(ctx, opts) {
77
+ const clientId = opts.clientId ?? "loomcycle-ts";
78
+ const start = await postForm(ctx, "/v1/_auth/device", { client_id: clientId }, opts.signal);
79
+ if (start.status !== 200) {
80
+ const code = typeof start.body.code === "string" ? start.body.code : undefined;
81
+ throw new errors_js_1.DeviceLoginError(`the server refused to start a device login (HTTP ${start.status})`, {
82
+ status: start.status,
83
+ bodyText: start.text.slice(0, 1024),
84
+ code,
85
+ });
86
+ }
87
+ const da = start.body;
88
+ if (typeof da.device_code !== "string" || da.device_code === "" ||
89
+ !printable(da.user_code, 32) || !httpURL(da.verification_uri)) {
90
+ throw new errors_js_1.DeviceLoginError("the server's device-login answer is malformed", { status: start.status });
91
+ }
92
+ const deviceCode = da.device_code;
93
+ let expiresIn = positiveInt(da.expires_in) ?? DEFAULT_EXPIRES_S;
94
+ if (expiresIn > MAX_EXPIRES_S)
95
+ expiresIn = DEFAULT_EXPIRES_S;
96
+ await opts.onPrompt({
97
+ userCode: da.user_code,
98
+ verificationUri: da.verification_uri,
99
+ verificationUriComplete: httpURL(da.verification_uri_complete) ? da.verification_uri_complete : undefined,
100
+ expiresIn,
101
+ });
102
+ let intervalMs = (positiveInt(da.interval) ?? DEFAULT_INTERVAL_S) * 1000;
103
+ // A little past the code's own expiry, so the server's expired_token is
104
+ // what ends a login nobody approved.
105
+ const deadline = Date.now() + expiresIn * 1000 + 30_000;
106
+ for (;;) {
107
+ await sleep(intervalMs, opts.signal);
108
+ if (Date.now() > deadline) {
109
+ throw new errors_js_1.DeviceLoginError("the code expired before it was approved", { code: "expired_token" });
110
+ }
111
+ const res = await postForm(ctx, "/v1/_auth/device/token", { grant_type: GRANT_TYPE, device_code: deviceCode, client_id: clientId }, opts.signal);
112
+ const tok = res.body;
113
+ if (res.status === 200 && typeof tok.access_token === "string" && tok.access_token !== "") {
114
+ return {
115
+ accessToken: tok.access_token,
116
+ tokenType: typeof tok.token_type === "string" ? tok.token_type : "Bearer",
117
+ expiresIn: positiveInt(tok.expires_in),
118
+ scope: typeof tok.scope === "string" && tok.scope !== "" ? tok.scope.split(" ") : undefined,
119
+ };
120
+ }
121
+ switch (tok.error) {
122
+ case "authorization_pending":
123
+ continue;
124
+ case "slow_down":
125
+ intervalMs += SLOW_DOWN_STEP_MS;
126
+ continue;
127
+ }
128
+ // RFC 8628 refusals carry the code in `error`; loomcycle's own refusals
129
+ // (device_login_disabled, …) carry it in `code` and a message in `error`.
130
+ const code = typeof res.body.code === "string" ? res.body.code : typeof tok.error === "string" ? tok.error : undefined;
131
+ const why = code === "access_denied"
132
+ ? "the login was denied, or its approval no longer holds"
133
+ : code === "expired_token"
134
+ ? "the code expired before it was approved"
135
+ : typeof tok.error_description === "string"
136
+ ? tok.error_description
137
+ : `the server refused the login (HTTP ${res.status})`;
138
+ // A refusal's body carries no token. A 200 without a usable one is
139
+ // malformed, and its body is not echoed, in case it holds one anyway.
140
+ throw new errors_js_1.DeviceLoginError(why, {
141
+ status: res.status,
142
+ bodyText: res.status === 200 ? undefined : res.text.slice(0, 1024),
143
+ code,
144
+ });
145
+ }
146
+ }
@@ -19,7 +19,7 @@
19
19
  * to raise typed errors via `raiseFromResponse`.
20
20
  */
21
21
  Object.defineProperty(exports, "__esModule", { value: true });
22
- exports.SubstrateToolRefusedError = exports.ChannelCursorRegressionError = exports.SnapshotVersionError = exports.PromptTooLargeError = exports.RequestTooLargeError = exports.SnapshotTooLargeError = exports.SnapshotNotFoundError = exports.NotPausedError = exports.AlreadyPausingError = exports.PauseNotConfiguredError = exports.InvalidArgumentError = exports.UnavailableError = exports.AclDeniedError = exports.InsufficientScopeError = exports.AuthError = exports.PerUserQuotaExhaustedError = exports.TokenLimitExceededError = exports.RateLimitedError = exports.BackpressureError = exports.AgentIDInUseError = exports.SessionBusyError = exports.SessionNotFoundError = exports.AgentNotFoundError = exports.NotFoundError = exports.LoomcycleError = void 0;
22
+ exports.DeviceLoginError = exports.OAuthError = exports.SubstrateToolRefusedError = exports.ChannelCursorRegressionError = exports.SnapshotVersionError = exports.PromptTooLargeError = exports.RequestTooLargeError = exports.SnapshotTooLargeError = exports.SnapshotNotFoundError = exports.NotPausedError = exports.AlreadyPausingError = exports.PauseNotConfiguredError = exports.InvalidArgumentError = exports.UnavailableError = exports.AclDeniedError = exports.InsufficientScopeError = exports.AuthError = exports.PerUserQuotaExhaustedError = exports.TokenLimitExceededError = exports.RateLimitedError = exports.BackpressureError = exports.AgentIDInUseError = exports.SessionBusyError = exports.SessionNotFoundError = exports.AgentNotFoundError = exports.NotFoundError = exports.LoomcycleError = void 0;
23
23
  class LoomcycleError extends Error {
24
24
  status;
25
25
  bodyText;
@@ -455,3 +455,42 @@ class SubstrateToolRefusedError extends LoomcycleError {
455
455
  }
456
456
  }
457
457
  exports.SubstrateToolRefusedError = SubstrateToolRefusedError;
458
+ /** An app sign-in (RFC FL) failed. `code` is the OAuth error code: one the
459
+ * server sent (`invalid_grant`, `invalid_client`, `access_denied`,
460
+ * `temporarily_unavailable`, …, RFC 6749 §4.1.2.1 / §5.2), or one the client
461
+ * decided on before trusting a response:
462
+ * - `state_mismatch`: the redirect's `state` is not the one this sign-in
463
+ * sent — a forged or crossed response; nothing was exchanged.
464
+ * - `issuer_mismatch`: the redirect's `iss` names another server (RFC 9207).
465
+ * - `invalid_response`: the server's answer is not a token response.
466
+ * - `signed_out`: the sign-in ended (its refresh token was refused); sign in
467
+ * again.
468
+ * `invalid_grant` on a refresh means the same: the sign-in is over. */
469
+ class OAuthError extends LoomcycleError {
470
+ /** The server's `error_description`, when it sent one. */
471
+ errorDescription;
472
+ constructor(code, opts) {
473
+ super(opts?.errorDescription ? `loomcycle sign-in: ${code}: ${opts.errorDescription}` : `loomcycle sign-in: ${code}`, { status: opts?.status, bodyText: opts?.bodyText, code });
474
+ this.name = "OAuthError";
475
+ this.errorDescription = opts?.errorDescription;
476
+ }
477
+ /** `temporarily_unavailable` (and a 429 throttle) clear on their own; every
478
+ * other sign-in failure needs the person or the app to act. */
479
+ get retryable() {
480
+ return this.code === "temporarily_unavailable";
481
+ }
482
+ }
483
+ exports.OAuthError = OAuthError;
484
+ /** A device login ({@link LoomcycleClient.deviceLogin}) that ended without a
485
+ * token. `code` is the server's RFC 8628 error: `access_denied` (the person
486
+ * denied it, or the approval no longer holds — the approving session ended
487
+ * or its credential was retired), `expired_token` (nobody approved it in
488
+ * time), or another refusal. Start a new login; the same one never
489
+ * succeeds. */
490
+ class DeviceLoginError extends LoomcycleError {
491
+ constructor(message, opts) {
492
+ super(message, opts);
493
+ this.name = "DeviceLoginError";
494
+ }
495
+ }
496
+ exports.DeviceLoginError = DeviceLoginError;
@@ -11,6 +11,7 @@
11
11
  * the boring fetch + error-translation machinery lives here.
12
12
  */
13
13
  Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.authorizedFetch = authorizedFetch;
14
15
  exports.authHeaders = authHeaders;
15
16
  exports.jsonFetch = jsonFetch;
16
17
  exports.postJSON = postJSON;
@@ -20,6 +21,36 @@ exports.deleteRequest = deleteRequest;
20
21
  exports.deleteJSON = deleteJSON;
21
22
  exports.raiseFromResponse = raiseFromResponse;
22
23
  const errors_js_1 = require("./errors.js");
24
+ /** authorizedFetch wraps a fetch so every request carries the provider's
25
+ * current bearer (replacing whatever Authorization the caller set), and a
26
+ * 401 is retried once with the token `onUnauthorized` hands back — never
27
+ * twice, so a token the server keeps refusing cannot loop. A request whose
28
+ * body is a stream is not retried: the stream was consumed by the first
29
+ * send. */
30
+ function authorizedFetch(inner, provider) {
31
+ return async (input, init) => {
32
+ const token = await provider.getToken();
33
+ const first = await inner(input, withBearer(init, token));
34
+ if (first.status !== 401 || !provider.onUnauthorized || !replayableBody(init?.body))
35
+ return first;
36
+ const fresh = await provider.onUnauthorized(token);
37
+ if (!fresh || fresh === token)
38
+ return first;
39
+ await first.body?.cancel().catch(() => undefined);
40
+ return inner(input, withBearer(init, fresh));
41
+ };
42
+ }
43
+ function withBearer(init, token) {
44
+ const headers = new Headers(init?.headers);
45
+ if (token)
46
+ headers.set("Authorization", `Bearer ${token}`);
47
+ else
48
+ headers.delete("Authorization");
49
+ return { ...init, headers };
50
+ }
51
+ function replayableBody(body) {
52
+ return !(typeof ReadableStream !== "undefined" && body instanceof ReadableStream);
53
+ }
23
54
  /** authHeaders builds the standard request header set: JSON Accept
24
55
  * + Bearer token when the client was constructed with one. The
25
56
  * caller adds Content-Type when posting a body. */