@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.
package/dist/client.js CHANGED
@@ -23,9 +23,11 @@
23
23
  * via fetch-helpers.ts:raiseFromResponse — see README.md for the
24
24
  * full mapping table.
25
25
  */
26
- import { authHeaders, deleteRequest, deleteJSON, jsonFetch, patchJSON, postJSON, putJSON, raiseFromResponse, } from "./fetch-helpers.js";
26
+ import { authHeaders, authorizedFetch, deleteRequest, deleteJSON, jsonFetch, patchJSON, postJSON, putJSON, raiseFromResponse, } from "./fetch-helpers.js";
27
27
  import { parseSSE } from "./stream.js";
28
28
  import { InteractiveSession } from "./interactive.js";
29
+ import { LoomcycleOAuth } from "./oauth.js";
30
+ import { deviceLogin } from "./device-login.js";
29
31
  import { ClientToolHost, clientToolsURL, } from "./client-tools.js";
30
32
  /** samplingToWire maps the camelCase SamplingOptions to the snake_case wire
31
33
  * object, omitting unset fields so they inherit the agent's value. */
@@ -217,13 +219,30 @@ function applyOverridesToWire(body, opts) {
217
219
  }
218
220
  export class LoomcycleClient {
219
221
  ctx;
222
+ tokenProvider;
223
+ oauthHelper;
224
+ rawFetch;
220
225
  constructor(opts = {}) {
226
+ if (opts.authToken && opts.tokenProvider) {
227
+ throw new TypeError("LoomcycleClient: pass authToken or tokenProvider, not both");
228
+ }
229
+ const fetchImpl = opts.fetch ?? fetch;
230
+ this.rawFetch = opts.fetch;
231
+ this.tokenProvider = opts.tokenProvider;
221
232
  this.ctx = {
222
233
  baseUrl: (opts.baseUrl ?? "http://127.0.0.1:8787").replace(/\/$/, ""),
223
234
  authToken: opts.authToken,
224
- fetchImpl: opts.fetch ?? fetch,
235
+ // With a provider, ctx.authToken stays unset and the wrapper sets the
236
+ // header on every request, so no method needs to know.
237
+ fetchImpl: opts.tokenProvider ? authorizedFetch(fetchImpl, opts.tokenProvider) : fetchImpl,
225
238
  };
226
239
  }
240
+ /** The app sign-in helper (RFC FL) for this server: same base URL and
241
+ * fetch, never this client's bearer. See {@link LoomcycleOAuth}. */
242
+ get oauth() {
243
+ this.oauthHelper ??= new LoomcycleOAuth({ baseUrl: this.ctx.baseUrl, fetch: this.rawFetch });
244
+ return this.oauthHelper;
245
+ }
227
246
  // ---- Run lifecycle ----
228
247
  /**
229
248
  * Run an agent and stream events. Returns AsyncIterable<AgentEvent>;
@@ -443,6 +462,51 @@ export class LoomcycleClient {
443
462
  async getEffectiveConfig(runId, opts) {
444
463
  return jsonFetch(this.ctx, `/v1/runs/${encodeURIComponent(runId)}/effective-config`, opts);
445
464
  }
465
+ /** Run one File-tool op on a run's inbox from outside the run — the files the
466
+ * run's tools returned. Mirrors `POST /v1/runs/{run_id}/file`, the HTTP twin
467
+ * of the MCP `file` tool. Ops: stat, ls, extract, import, keep, rm, clear.
468
+ * The response is the op's own JSON (narrow as needed).
469
+ *
470
+ * Needs `runs:read`, and works on any run you can read; another tenant's run
471
+ * is an opaque {@link NotFoundError}. extract, keep, rm and clear write, so
472
+ * they need the run's owner or an admin — a tenant operator counts as one
473
+ * for its own tenant's runs; anyone else gets a
474
+ * {@link SubstrateToolRefusedError}, as does a refused op. */
475
+ async runFile(runId, input, opts) {
476
+ return postJSON(this.ctx, `/v1/runs/${encodeURIComponent(runId)}/file`, input, opts);
477
+ }
478
+ /** Download one file of a run's inbox. Mirrors
479
+ * `GET /v1/runs/{run_id}/inbox/{path}` (HEAD with `head: true`). `path` is
480
+ * relative to the inbox root (`documents/a.pdf`) or a reference
481
+ * (`volume:inbox:documents/a.pdf`).
482
+ *
483
+ * Any reader of the run may download (`runs:read`). `range` is sent as the
484
+ * Range header (`bytes=0-1023`). The inbox is deleted when its run ends, so
485
+ * a finished run's file — like a missing file, run, or another tenant's run —
486
+ * is a {@link NotFoundError}. */
487
+ async downloadRunInboxFile(runId, path, opts) {
488
+ const rel = path.replace(/^volume:inbox:/, "");
489
+ const encoded = rel.split("/").map(encodeURIComponent).join("/");
490
+ const headers = {};
491
+ if (this.ctx.authToken)
492
+ headers.Authorization = `Bearer ${this.ctx.authToken}`;
493
+ if (opts?.range)
494
+ headers.Range = opts.range;
495
+ const resp = await this.ctx.fetchImpl(`${this.ctx.baseUrl}/v1/runs/${encodeURIComponent(runId)}/inbox/${encoded}`, { method: opts?.head ? "HEAD" : "GET", headers, signal: opts?.signal });
496
+ if (!resp.ok) {
497
+ await raiseFromResponse(resp);
498
+ }
499
+ const bytes = opts?.head ? new Uint8Array() : new Uint8Array(await resp.arrayBuffer());
500
+ return {
501
+ bytes,
502
+ status: resp.status,
503
+ contentType: resp.headers.get("Content-Type") ?? "",
504
+ filename: dispositionFilename(resp.headers.get("Content-Disposition")) ?? rel.split("/").pop() ?? "",
505
+ sha256: resp.headers.get("X-Loomcycle-SHA256") ?? "",
506
+ origin: resp.headers.get("X-Loomcycle-Origin") ?? "",
507
+ headers: resp.headers,
508
+ };
509
+ }
446
510
  /** Which per-run overrides a run of `name` may set, for this caller — what a
447
511
  * run form needs before it sends anything. Mirrors
448
512
  * `GET /v1/_agentdef/{name}/overridable`.
@@ -796,8 +860,11 @@ export class LoomcycleClient {
796
860
  return { agents: resp.agents ?? [], next_cursor: resp.next_cursor ?? "" };
797
861
  }
798
862
  /** Aggregated token-usage + cost report (RFC AV). Group by any of
799
- * tenant/user/provider/model/source over an optional time window; group by
800
- * `source` for the operator-vs-tenant split. Tenant-scoped server-side: a
863
+ * tenant/user/provider/model/source/purpose/tier/user_tier/agent/class over
864
+ * an optional time window, split into periods with `bucket`;
865
+ * group by `source` for the operator-vs-tenant split, by `purpose` to see
866
+ * what OCR and speech-to-text cost (a row's `audio_seconds` is the audio its
867
+ * speech-to-text calls transcribed). Tenant-scoped server-side: a
801
868
  * tenant principal sees only its own tenant (the `tenant` focus is
802
869
  * admin-only). Mirrors `GET /v1/_usage`. */
803
870
  async usageReport(opts) {
@@ -810,6 +877,16 @@ export class LoomcycleClient {
810
877
  params.set("to", opts.to);
811
878
  if (opts?.tenant)
812
879
  params.set("tenant", opts.tenant);
880
+ if (opts?.bucket)
881
+ params.set("bucket", opts.bucket);
882
+ if (opts?.top)
883
+ params.set("top", String(opts.top));
884
+ if (opts?.topBy)
885
+ params.set("top_by", opts.topBy);
886
+ for (const [dim, values] of Object.entries(opts?.filters ?? {})) {
887
+ if (values && values.length > 0)
888
+ params.set(dim, values.join(","));
889
+ }
813
890
  const q = params.toString() ? `?${params.toString()}` : "";
814
891
  return jsonFetch(this.ctx, `/v1/_usage${q}`, opts);
815
892
  }
@@ -934,28 +1011,151 @@ export class LoomcycleClient {
934
1011
  //
935
1012
  // A tenant operator (substrate:tenant) manages the first-class users in its
936
1013
  // OWN tenant and mints/lists/revokes bearer tokens for them. The tenant is
937
- // always server-derived from the authenticated principal — never sent — so
938
- // there is no tenant field on any of these calls. Requires a persistent store
1014
+ // server-derived from the authenticated principal. `opts.tenant` (sent as
1015
+ // ?tenant=) names another only for an admin token, and for an `auth:manage`
1016
+ // credential, which must name one (400 `tenant_required` otherwise); a
1017
+ // tenant operator always acts in its own tenant. Requires a persistent store
939
1018
  // (503 otherwise).
940
1019
  /** Register a first-class user in the caller's own tenant. `access_mode`
941
1020
  * defaults to "tenant" (collaborates on tenant-shared primitives);
942
1021
  * "isolated" confines the member to its own user scope. 409 on a duplicate
943
1022
  * (tenant, subject). */
944
1023
  async createUser(body, opts) {
945
- return postJSON(this.ctx, "/v1/_users", body, opts);
1024
+ return postJSON(this.ctx, this.aclPath("/v1/_users", opts?.tenant), body, {
1025
+ signal: opts?.signal,
1026
+ });
946
1027
  }
947
1028
  /** Patch mutable fields on a user in the caller's own tenant. An omitted key
948
1029
  * leaves the column unchanged. Disabling a user (status:"disabled") also
949
- * revokes its delegated tokens server-side. Cross-tenant target → 404. */
1030
+ * revokes its delegated tokens server-side. A pending user is activated by
1031
+ * {@link approveUser}, not here (409 `use_approve`). Cross-tenant target →
1032
+ * 404. */
950
1033
  async updateUser(subject, body, opts) {
951
- return patchJSON(this.ctx, `/v1/_users/${encodeURIComponent(subject)}`, body, opts);
1034
+ return patchJSON(this.ctx, this.aclPath(`/v1/_users/${encodeURIComponent(subject)}`, opts?.tenant), body, { signal: opts?.signal });
952
1035
  }
953
1036
  /** Delete the user identity row in the caller's own tenant (204). Its
954
1037
  * delegated tokens are retired first so the bearer cannot outlive the row.
955
1038
  * Owned data (runs / sessions / memory) is left intact — this is not
956
1039
  * erasure ({@link erasureExecute}). Cross-tenant target → 404. */
957
1040
  async deleteUser(subject, opts) {
958
- return deleteRequest(this.ctx, `/v1/_users/${encodeURIComponent(subject)}`, opts);
1041
+ return deleteRequest(this.ctx, this.aclPath(`/v1/_users/${encodeURIComponent(subject)}`, opts?.tenant), { signal: opts?.signal });
1042
+ }
1043
+ /** Approve a user awaiting approval: `pending` → `active`
1044
+ * (`POST /v1/_users/{subject}/approve`). A sign-in gate configured with
1045
+ * `new_user.status: pending` provisions people who can sign in but are
1046
+ * refused everything until this call. Returns the updated record. 409
1047
+ * `not_pending` for a user that is not pending; 404 for an unknown or
1048
+ * cross-tenant subject. An `auth:manage` credential may approve only a user
1049
+ * at its floor (403 `target_beyond_floor`). */
1050
+ async approveUser(subject, opts) {
1051
+ return postJSON(this.ctx, this.aclPath(`/v1/_users/${encodeURIComponent(subject)}/approve`, opts?.tenant), undefined, { signal: opts?.signal });
1052
+ }
1053
+ /** The identity-provider accounts a user signs in with
1054
+ * (`GET /v1/_users/{subject}/identities`): one row per linked gate, never a
1055
+ * secret. A user reads its own; a tenant operator or an admin reads any in
1056
+ * the tenant. Password-gate logins, with whether a password and an
1057
+ * authenticator are set, are {@link listGateIdentities}. */
1058
+ async listUserIdentities(subject, opts) {
1059
+ return jsonFetch(this.ctx, this.aclPath(`/v1/_users/${encodeURIComponent(subject)}/identities`, opts?.tenant), opts);
1060
+ }
1061
+ /** Unlink one identity-provider account from a user
1062
+ * (`DELETE /v1/_users/{subject}/identities/{gate}`, 204). Sessions made
1063
+ * through it end on their next request. For a tenant operator or an admin;
1064
+ * a user unlinking its own account must do it from a recently
1065
+ * signed-in Web UI session, so a bearer is refused (403
1066
+ * `link_needs_own_session`). A password gate's login is removed with
1067
+ * {@link removeGateCredentials} instead (409 `password_gate`). 404
1068
+ * `identity_not_found` when no account of that gate is linked. */
1069
+ async unlinkUserIdentity(subject, gate, opts) {
1070
+ return deleteRequest(this.ctx, this.aclPath(`/v1/_users/${encodeURIComponent(subject)}/identities/${encodeURIComponent(gate)}`, opts?.tenant), { signal: opts?.signal });
1071
+ }
1072
+ /** Sign in through the server's device gate (RFC 8628): shows the person a
1073
+ * code through `onPrompt`, polls until they approve it in a signed-in Web
1074
+ * UI session (`/ui/device`), and returns the token issued to them. The
1075
+ * token is the approver's own member token, at most the device gate's
1076
+ * `token_ttl` and never past the approving session or the credential it
1077
+ * was made from. Sends no bearer: a client constructed without one can
1078
+ * call it. Throws {@link DeviceLoginError} when the login is denied,
1079
+ * expires, or the server refuses it; 404 `device_login_disabled` when no
1080
+ * device gate is configured. */
1081
+ async deviceLogin(opts) {
1082
+ return deviceLogin(this.ctx, opts);
1083
+ }
1084
+ // ---- RFC FA: sign-in gates + the password gate's management API ----
1085
+ //
1086
+ // Invitations, reset links, credential removal, login renames and a user's
1087
+ // logins at a password gate, addressed by the gate's configured name. None
1088
+ // takes a password: the person sets their own through the link. A tenant
1089
+ // operator manages its own tenant, an admin any (`opts.tenant`, defaulting
1090
+ // to its own), and an `auth:manage` credential any tenant it names — it
1091
+ // must name one (400 `tenant_required`) and is held to the gate's new-user
1092
+ // floor. A gate that is not a password gate is a 404 `gate_not_found`.
1093
+ // Requires a persistent store (503 otherwise).
1094
+ /** Every configured sign-in gate (`GET /v1/_authgate`): kind, label,
1095
+ * whether a browser can sign in through it, its identity map and an
1096
+ * external gate's key fingerprint and removal timing — never key
1097
+ * material. **Admin token only.** `probe: true` also fetches each
1098
+ * external gate's discovery document (bounded) and compares it with the
1099
+ * registration. */
1100
+ async listAuthGates(opts) {
1101
+ const q = opts?.probe ? "?probe=1" : "";
1102
+ return jsonFetch(this.ctx, `/v1/_authgate${q}`, opts);
1103
+ }
1104
+ /** Invite a person to sign in through a password gate
1105
+ * (`POST /v1/_authgate/{gate}/invites`). Without `subject` the invite
1106
+ * makes a new user, at the gate's new-user floor unless the gate is
1107
+ * trusted; with one it gives that existing user a login (409
1108
+ * `credential_exists` if it has one — issue a reset instead). Returns the
1109
+ * one-time link, shown once. */
1110
+ async createInvite(gate, body, opts) {
1111
+ return postJSON(this.ctx, `/v1/_authgate/${encodeURIComponent(gate)}/invites`, withTenant(body, opts?.tenant), { signal: opts?.signal });
1112
+ }
1113
+ /** A password gate's invites in the tenant, live and past
1114
+ * (`GET /v1/_authgate/{gate}/invites`), never their codes. An admin naming
1115
+ * no tenant lists every tenant's. */
1116
+ async listInvites(gate, opts) {
1117
+ return jsonFetch(this.ctx, this.aclPath(`/v1/_authgate/${encodeURIComponent(gate)}/invites`, opts?.tenant), opts);
1118
+ }
1119
+ /** Revoke a live invite (`DELETE /v1/_authgate/{gate}/invites/{id}`, 204).
1120
+ * 409 `invite_not_live` when it was already redeemed, revoked or expired;
1121
+ * another tenant's invite is a 404. */
1122
+ async revokeInvite(gate, inviteId, opts) {
1123
+ return deleteRequest(this.ctx, this.aclPath(`/v1/_authgate/${encodeURIComponent(gate)}/invites/${encodeURIComponent(inviteId)}`, opts?.tenant), { signal: opts?.signal });
1124
+ }
1125
+ /** Issue a one-time link that sets a new password for a user with a login
1126
+ * at the gate (`POST /v1/_authgate/{gate}/resets`). A user with an
1127
+ * authenticator still needs a code from it. Completing the reset ends the
1128
+ * user's sessions from the old password and retires its delegated tokens.
1129
+ * 404 `login_not_found` when the user has no login at the gate. */
1130
+ async createPasswordReset(gate, subject, opts) {
1131
+ return postJSON(this.ctx, `/v1/_authgate/${encodeURIComponent(gate)}/resets`, withTenant({ subject }, opts?.tenant), { signal: opts?.signal });
1132
+ }
1133
+ /** Remove a user's password, authenticator and recovery codes at a gate
1134
+ * (`DELETE /v1/_authgate/{gate}/credentials/{subject}`, 204). The login
1135
+ * stays reserved, so a later {@link createPasswordReset} restores access. */
1136
+ async removeGateCredentials(gate, subject, opts) {
1137
+ return deleteRequest(this.ctx, this.aclPath(`/v1/_authgate/${encodeURIComponent(gate)}/credentials/${encodeURIComponent(subject)}`, opts?.tenant), { signal: opts?.signal });
1138
+ }
1139
+ /** Change the login a user signs in with at a gate
1140
+ * (`PATCH /v1/_authgate/{gate}/logins/{subject}`). The subject does not
1141
+ * change, so nothing the user owns moves. Returns the normalised login;
1142
+ * 409 `login_taken` when another user has it. */
1143
+ async renameGateLogin(gate, subject, login, opts) {
1144
+ return patchJSON(this.ctx, `/v1/_authgate/${encodeURIComponent(gate)}/logins/${encodeURIComponent(subject)}`, withTenant({ login }, opts?.tenant), { signal: opts?.signal });
1145
+ }
1146
+ /** A user's logins at every gate, and whether it has a password and a
1147
+ * confirmed authenticator (`GET /v1/_authgate/{gate}/identities`) — never
1148
+ * a secret. `gate` must name a password gate. */
1149
+ async listGateIdentities(gate, subject, opts) {
1150
+ return jsonFetch(this.ctx, this.aclPath(`/v1/_authgate/${encodeURIComponent(gate)}/identities`, opts?.tenant, {
1151
+ subject,
1152
+ }), opts);
1153
+ }
1154
+ /** Whether the tenant has, or ever had, a user and whether an owner invite
1155
+ * is live (`GET /v1/_authgate/{gate}/tenant-status`) — what deciding on an
1156
+ * owner invite needs, and nothing about who the users are. */
1157
+ async getGateTenantStatus(gate, opts) {
1158
+ return jsonFetch(this.ctx, this.aclPath(`/v1/_authgate/${encodeURIComponent(gate)}/tenant-status`, opts?.tenant), opts);
959
1159
  }
960
1160
  /** Mint a bearer token for one of the caller's own ACTIVE users. The granted
961
1161
  * scopes are DERIVED server-side from the member's access_mode (never sent),
@@ -1149,7 +1349,11 @@ export class LoomcycleClient {
1149
1349
  * `.close()` to stop. Uses the global WebSocket (browsers, Node 22+); pass
1150
1350
  * `WebSocketImpl` (e.g. the `ws` package) on older Node. */
1151
1351
  connectClientTools(opts) {
1152
- const host = new ClientToolHost(clientToolsURL(this.ctx.baseUrl), this.ctx.authToken, opts);
1352
+ // A provider is asked on every (re)connect, so a host outliving one
1353
+ // access token reconnects with the next.
1354
+ const provider = this.tokenProvider;
1355
+ const auth = provider ? () => provider.getToken() : this.ctx.authToken;
1356
+ const host = new ClientToolHost(clientToolsURL(this.ctx.baseUrl), auth, opts);
1153
1357
  host.start();
1154
1358
  return host;
1155
1359
  }
@@ -1272,6 +1476,12 @@ export class LoomcycleClient {
1272
1476
  body.document_ids = input.documentIds;
1273
1477
  if (input.excludeDocumentIds !== undefined)
1274
1478
  body.exclude_document_ids = input.excludeDocumentIds;
1479
+ // The second scope's documents: sent only when set, so a one-scope search's
1480
+ // response stays byte-identical (no per-entry `scope`).
1481
+ if (input.documentsScope !== undefined)
1482
+ body.documents_scope = input.documentsScope;
1483
+ if (input.documentsScopeId !== undefined)
1484
+ body.documents_scope_id = input.documentsScopeId;
1275
1485
  return postJSON(this.ctx, "/v1/_memory/search", body, {
1276
1486
  signal: opts?.signal,
1277
1487
  });
@@ -1780,6 +1990,73 @@ export class LoomcycleClient {
1780
1990
  async volumeDef(input, opts) {
1781
1991
  return postJSON(this.ctx, "/v1/_volumedef", input, opts);
1782
1992
  }
1993
+ // ---- App clients (RFC FL ClientDef) ----
1994
+ //
1995
+ // The apps that sign people in through this server's gates, registered at
1996
+ // runtime beside the config's `auth.clients`. A tenant operator
1997
+ // (substrate:tenant) registers its own tenant's; an admin any tenant's, or
1998
+ // one for every tenant. Config clients are read only here, and another
1999
+ // tenant's client answers as not found. A refusal raises
2000
+ // SubstrateToolRefusedError. Each method posts to `POST /v1/_clientdef`.
2001
+ /** The ClientDef op dispatch, as sent: `{op, name?, def_id?, client?,
2002
+ * description?, tenant?, content_sha256?}`. The typed methods below are
2003
+ * the usual way in. */
2004
+ async clientDef(input, opts) {
2005
+ return postJSON(this.ctx, "/v1/_clientdef", input, opts);
2006
+ }
2007
+ /** Register an app (`op: "create"`). Its redirect URIs and origins are
2008
+ * checked as strictly as a config client's. `tenant` is for an admin: the
2009
+ * tenant whose people it signs in, "" (the default) for every tenant. */
2010
+ async createClientDef(name, client, opts) {
2011
+ const input = { op: "create", name, client };
2012
+ if (opts?.description !== undefined)
2013
+ input.description = opts.description;
2014
+ if (opts?.tenant !== undefined)
2015
+ input.tenant = opts.tenant;
2016
+ return (await this.clientDef(input, signalOpts(opts)));
2017
+ }
2018
+ /** Change an app (`op: "update"`): a new version, whole. Codes issued
2019
+ * under the old version that were not exchanged yet stop working. */
2020
+ async updateClientDef(name, client, opts) {
2021
+ const input = { op: "update", name, client };
2022
+ if (opts?.description !== undefined)
2023
+ input.description = opts.description;
2024
+ return (await this.clientDef(input, signalOpts(opts)));
2025
+ }
2026
+ /** One app by name (a config client too) or one version by `def_id`
2027
+ * (`op: "get"`). */
2028
+ async getClientDef(ref, opts) {
2029
+ return (await this.clientDef({ op: "get", ...ref }, opts));
2030
+ }
2031
+ /** The apps the caller may see, config ones included (`op: "list"`). An
2032
+ * admin may focus one tenant with `tenant` ("" = the every-tenant ones). */
2033
+ async listClientDefs(opts) {
2034
+ const input = { op: "list" };
2035
+ if (opts?.tenant !== undefined)
2036
+ input.tenant = opts.tenant;
2037
+ return (await this.clientDef(input, signalOpts(opts)));
2038
+ }
2039
+ /** Take an app out of service (`op: "retire"`): its unused codes are
2040
+ * voided, its sign-ins revoked and their access tokens retired. The name
2041
+ * is not reusable. */
2042
+ async retireClientDef(name, opts) {
2043
+ return (await this.clientDef({ op: "retire", name }, opts));
2044
+ }
2045
+ /** Whether an app is deployed and, with `contentSha256`, whether it matches
2046
+ * (`op: "verify"`). An absent app answers `deployed: false`. */
2047
+ async verifyClientDef(name, opts) {
2048
+ const input = { op: "verify", name };
2049
+ if (opts?.contentSha256)
2050
+ input.content_sha256 = opts.contentSha256;
2051
+ return (await this.clientDef(input, signalOpts(opts)));
2052
+ }
2053
+ /** The apps the caller may see, by name (`GET /v1/_clientdef/names`). An
2054
+ * admin may focus one tenant with `tenant`; unlike the other families'
2055
+ * name lists, `""` is a focus too (the every-tenant apps). */
2056
+ async listClientDefNames(opts) {
2057
+ const q = opts?.tenant !== undefined ? `?tenant=${encodeURIComponent(opts.tenant)}` : "";
2058
+ return jsonFetch(this.ctx, `/v1/_clientdef/names${q}`, opts);
2059
+ }
1783
2060
  // ---- Def name enumeration (GET /v1/_<family>def/names) ----
1784
2061
  //
1785
2062
  // Every name declared in one substrate family, each with its version
@@ -3215,6 +3492,23 @@ export class LoomcycleClient {
3215
3492
  return postJSON(this.ctx, "/v1/_crypto", input, opts);
3216
3493
  }
3217
3494
  }
3495
+ /** The file name of an RFC 8187 `filename*=UTF-8''…` Content-Disposition, or
3496
+ * a plain quoted `filename="…"`; undefined when there is neither. */
3497
+ function dispositionFilename(header) {
3498
+ if (!header)
3499
+ return undefined;
3500
+ const star = /filename\*=UTF-8''([^;]+)/i.exec(header);
3501
+ if (star) {
3502
+ try {
3503
+ return decodeURIComponent(star[1].trim());
3504
+ }
3505
+ catch {
3506
+ return undefined;
3507
+ }
3508
+ }
3509
+ const plain = /filename="([^"]*)"/i.exec(header);
3510
+ return plain ? plain[1] : undefined;
3511
+ }
3218
3512
  /** Lightweight SSE parser tailored to the run-state stream. Each
3219
3513
  * frame's event name distinguishes the two kinds; data is JSON.
3220
3514
  * Comment lines (": keepalive") are ignored. */
@@ -3277,6 +3571,12 @@ function defWriteInput(op, name, overlay, opts) {
3277
3571
  input.promote = opts.promote;
3278
3572
  return input;
3279
3573
  }
3574
+ /** A password-gate management body with the tenant the caller named. The
3575
+ * POST/PATCH routes read the tenant from the body where the others read
3576
+ * ?tenant=; like aclPath, an empty tenant is left out. */
3577
+ function withTenant(body, tenant) {
3578
+ return tenant ? { ...body, tenant } : body;
3579
+ }
3280
3580
  function signalOpts(opts) {
3281
3581
  return opts?.signal ? { signal: opts.signal } : undefined;
3282
3582
  }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * @internal — reached through LoomcycleClient.deviceLogin().
3
+ *
4
+ * The client side of loomcycle's device login (RFC 8628, a `kind: device`
5
+ * sign-in gate): ask the server for a code pair, show the person the short
6
+ * code, poll until they approve it in a signed-in Web UI session, and return
7
+ * the token issued to them. Both requests are unauthenticated — the device
8
+ * code is the credential — so no bearer the client holds is sent with them.
9
+ */
10
+ import type { _FetchContext } from "./fetch-helpers.js";
11
+ import type { DeviceLoginOptions, DeviceLoginResult } from "./types.js";
12
+ export declare function deviceLogin(ctx: _FetchContext, opts: DeviceLoginOptions): Promise<DeviceLoginResult>;
@@ -0,0 +1,143 @@
1
+ /**
2
+ * @internal — reached through LoomcycleClient.deviceLogin().
3
+ *
4
+ * The client side of loomcycle's device login (RFC 8628, a `kind: device`
5
+ * sign-in gate): ask the server for a code pair, show the person the short
6
+ * code, poll until they approve it in a signed-in Web UI session, and return
7
+ * the token issued to them. Both requests are unauthenticated — the device
8
+ * code is the credential — so no bearer the client holds is sent with them.
9
+ */
10
+ import { DeviceLoginError } from "./errors.js";
11
+ const GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
12
+ /** RFC 8628 §3.5: a slow_down lengthens this and every later poll by 5 s. */
13
+ const SLOW_DOWN_STEP_MS = 5_000;
14
+ const DEFAULT_INTERVAL_S = 5;
15
+ /** The server issues codes for ten minutes; a longer claim is not trusted. */
16
+ const DEFAULT_EXPIRES_S = 600;
17
+ const MAX_EXPIRES_S = 3_600;
18
+ async function postForm(ctx, path, form, signal) {
19
+ const res = await ctx.fetchImpl(`${ctx.baseUrl}${path}`, {
20
+ method: "POST",
21
+ headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" },
22
+ body: new URLSearchParams(form).toString(),
23
+ signal,
24
+ });
25
+ const text = await res.text();
26
+ let body = {};
27
+ try {
28
+ const parsed = JSON.parse(text);
29
+ if (parsed && typeof parsed === "object")
30
+ body = parsed;
31
+ }
32
+ catch {
33
+ // A non-JSON answer is reported by status below.
34
+ }
35
+ return { status: res.status, body, text };
36
+ }
37
+ function sleep(ms, signal) {
38
+ return new Promise((resolve, reject) => {
39
+ if (signal?.aborted) {
40
+ reject(signal.reason ?? new Error("aborted"));
41
+ return;
42
+ }
43
+ const t = setTimeout(() => {
44
+ signal?.removeEventListener("abort", onAbort);
45
+ resolve();
46
+ }, ms);
47
+ const onAbort = () => {
48
+ clearTimeout(t);
49
+ reject(signal?.reason ?? new Error("aborted"));
50
+ };
51
+ signal?.addEventListener("abort", onAbort, { once: true });
52
+ });
53
+ }
54
+ /** A string shown to a person must not carry control characters a hostile
55
+ * server could use to rewrite what they see. */
56
+ function printable(s, max) {
57
+ return typeof s === "string" && s.length > 0 && s.length <= max && /^[\x20-\x7e]+$/.test(s);
58
+ }
59
+ function httpURL(s) {
60
+ if (!printable(s, 2048))
61
+ return false;
62
+ try {
63
+ const u = new URL(s);
64
+ return u.protocol === "https:" || u.protocol === "http:";
65
+ }
66
+ catch {
67
+ return false;
68
+ }
69
+ }
70
+ function positiveInt(v) {
71
+ return typeof v === "number" && Number.isInteger(v) && v > 0 ? v : undefined;
72
+ }
73
+ export async function deviceLogin(ctx, opts) {
74
+ const clientId = opts.clientId ?? "loomcycle-ts";
75
+ const start = await postForm(ctx, "/v1/_auth/device", { client_id: clientId }, opts.signal);
76
+ if (start.status !== 200) {
77
+ const code = typeof start.body.code === "string" ? start.body.code : undefined;
78
+ throw new DeviceLoginError(`the server refused to start a device login (HTTP ${start.status})`, {
79
+ status: start.status,
80
+ bodyText: start.text.slice(0, 1024),
81
+ code,
82
+ });
83
+ }
84
+ const da = start.body;
85
+ if (typeof da.device_code !== "string" || da.device_code === "" ||
86
+ !printable(da.user_code, 32) || !httpURL(da.verification_uri)) {
87
+ throw new DeviceLoginError("the server's device-login answer is malformed", { status: start.status });
88
+ }
89
+ const deviceCode = da.device_code;
90
+ let expiresIn = positiveInt(da.expires_in) ?? DEFAULT_EXPIRES_S;
91
+ if (expiresIn > MAX_EXPIRES_S)
92
+ expiresIn = DEFAULT_EXPIRES_S;
93
+ await opts.onPrompt({
94
+ userCode: da.user_code,
95
+ verificationUri: da.verification_uri,
96
+ verificationUriComplete: httpURL(da.verification_uri_complete) ? da.verification_uri_complete : undefined,
97
+ expiresIn,
98
+ });
99
+ let intervalMs = (positiveInt(da.interval) ?? DEFAULT_INTERVAL_S) * 1000;
100
+ // A little past the code's own expiry, so the server's expired_token is
101
+ // what ends a login nobody approved.
102
+ const deadline = Date.now() + expiresIn * 1000 + 30_000;
103
+ for (;;) {
104
+ await sleep(intervalMs, opts.signal);
105
+ if (Date.now() > deadline) {
106
+ throw new DeviceLoginError("the code expired before it was approved", { code: "expired_token" });
107
+ }
108
+ const res = await postForm(ctx, "/v1/_auth/device/token", { grant_type: GRANT_TYPE, device_code: deviceCode, client_id: clientId }, opts.signal);
109
+ const tok = res.body;
110
+ if (res.status === 200 && typeof tok.access_token === "string" && tok.access_token !== "") {
111
+ return {
112
+ accessToken: tok.access_token,
113
+ tokenType: typeof tok.token_type === "string" ? tok.token_type : "Bearer",
114
+ expiresIn: positiveInt(tok.expires_in),
115
+ scope: typeof tok.scope === "string" && tok.scope !== "" ? tok.scope.split(" ") : undefined,
116
+ };
117
+ }
118
+ switch (tok.error) {
119
+ case "authorization_pending":
120
+ continue;
121
+ case "slow_down":
122
+ intervalMs += SLOW_DOWN_STEP_MS;
123
+ continue;
124
+ }
125
+ // RFC 8628 refusals carry the code in `error`; loomcycle's own refusals
126
+ // (device_login_disabled, …) carry it in `code` and a message in `error`.
127
+ const code = typeof res.body.code === "string" ? res.body.code : typeof tok.error === "string" ? tok.error : undefined;
128
+ const why = code === "access_denied"
129
+ ? "the login was denied, or its approval no longer holds"
130
+ : code === "expired_token"
131
+ ? "the code expired before it was approved"
132
+ : typeof tok.error_description === "string"
133
+ ? tok.error_description
134
+ : `the server refused the login (HTTP ${res.status})`;
135
+ // A refusal's body carries no token. A 200 without a usable one is
136
+ // malformed, and its body is not echoed, in case it holds one anyway.
137
+ throw new DeviceLoginError(why, {
138
+ status: res.status,
139
+ bodyText: res.status === 200 ? undefined : res.text.slice(0, 1024),
140
+ code,
141
+ });
142
+ }
143
+ }
package/dist/errors.d.ts CHANGED
@@ -423,3 +423,39 @@ export declare class SubstrateToolRefusedError extends LoomcycleError {
423
423
  tool?: string;
424
424
  });
425
425
  }
426
+ /** An app sign-in (RFC FL) failed. `code` is the OAuth error code: one the
427
+ * server sent (`invalid_grant`, `invalid_client`, `access_denied`,
428
+ * `temporarily_unavailable`, …, RFC 6749 §4.1.2.1 / §5.2), or one the client
429
+ * decided on before trusting a response:
430
+ * - `state_mismatch`: the redirect's `state` is not the one this sign-in
431
+ * sent — a forged or crossed response; nothing was exchanged.
432
+ * - `issuer_mismatch`: the redirect's `iss` names another server (RFC 9207).
433
+ * - `invalid_response`: the server's answer is not a token response.
434
+ * - `signed_out`: the sign-in ended (its refresh token was refused); sign in
435
+ * again.
436
+ * `invalid_grant` on a refresh means the same: the sign-in is over. */
437
+ export declare class OAuthError extends LoomcycleError {
438
+ /** The server's `error_description`, when it sent one. */
439
+ readonly errorDescription?: string;
440
+ constructor(code: string, opts?: {
441
+ status?: number;
442
+ bodyText?: string;
443
+ errorDescription?: string;
444
+ });
445
+ /** `temporarily_unavailable` (and a 429 throttle) clear on their own; every
446
+ * other sign-in failure needs the person or the app to act. */
447
+ get retryable(): boolean;
448
+ }
449
+ /** A device login ({@link LoomcycleClient.deviceLogin}) that ended without a
450
+ * token. `code` is the server's RFC 8628 error: `access_denied` (the person
451
+ * denied it, or the approval no longer holds — the approving session ended
452
+ * or its credential was retired), `expired_token` (nobody approved it in
453
+ * time), or another refusal. Start a new login; the same one never
454
+ * succeeds. */
455
+ export declare class DeviceLoginError extends LoomcycleError {
456
+ constructor(message: string, opts?: {
457
+ status?: number;
458
+ bodyText?: string;
459
+ code?: string;
460
+ });
461
+ }
package/dist/errors.js CHANGED
@@ -427,3 +427,40 @@ export class SubstrateToolRefusedError extends LoomcycleError {
427
427
  this.tool = opts?.tool ?? "";
428
428
  }
429
429
  }
430
+ /** An app sign-in (RFC FL) failed. `code` is the OAuth error code: one the
431
+ * server sent (`invalid_grant`, `invalid_client`, `access_denied`,
432
+ * `temporarily_unavailable`, …, RFC 6749 §4.1.2.1 / §5.2), or one the client
433
+ * decided on before trusting a response:
434
+ * - `state_mismatch`: the redirect's `state` is not the one this sign-in
435
+ * sent — a forged or crossed response; nothing was exchanged.
436
+ * - `issuer_mismatch`: the redirect's `iss` names another server (RFC 9207).
437
+ * - `invalid_response`: the server's answer is not a token response.
438
+ * - `signed_out`: the sign-in ended (its refresh token was refused); sign in
439
+ * again.
440
+ * `invalid_grant` on a refresh means the same: the sign-in is over. */
441
+ export class OAuthError extends LoomcycleError {
442
+ /** The server's `error_description`, when it sent one. */
443
+ errorDescription;
444
+ constructor(code, opts) {
445
+ super(opts?.errorDescription ? `loomcycle sign-in: ${code}: ${opts.errorDescription}` : `loomcycle sign-in: ${code}`, { status: opts?.status, bodyText: opts?.bodyText, code });
446
+ this.name = "OAuthError";
447
+ this.errorDescription = opts?.errorDescription;
448
+ }
449
+ /** `temporarily_unavailable` (and a 429 throttle) clear on their own; every
450
+ * other sign-in failure needs the person or the app to act. */
451
+ get retryable() {
452
+ return this.code === "temporarily_unavailable";
453
+ }
454
+ }
455
+ /** A device login ({@link LoomcycleClient.deviceLogin}) that ended without a
456
+ * token. `code` is the server's RFC 8628 error: `access_denied` (the person
457
+ * denied it, or the approval no longer holds — the approving session ended
458
+ * or its credential was retired), `expired_token` (nobody approved it in
459
+ * time), or another refusal. Start a new login; the same one never
460
+ * succeeds. */
461
+ export class DeviceLoginError extends LoomcycleError {
462
+ constructor(message, opts) {
463
+ super(message, opts);
464
+ this.name = "DeviceLoginError";
465
+ }
466
+ }