@loomcycle/client 1.125.0 → 1.127.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.
@@ -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. */
package/dist/cjs/index.js CHANGED
@@ -28,6 +28,20 @@
28
28
  * mintUserToken(subject, opts?): Promise<MintedUserToken> // delegated token
29
29
  * listUserTokens(subject, opts?): Promise<ListUserTokensResponse>
30
30
  * revokeUserToken(subject, defId, opts?): Promise<{def_id, retired_at}>
31
+ * approveUser(subject, opts?): Promise<UserRecord> // pending → active (RFC FA)
32
+ * listUserIdentities(subject, opts?): Promise<UserIdentitiesResponse>
33
+ * unlinkUserIdentity(subject, gate, opts?): Promise<void>
34
+ *
35
+ * // Sign-in gates + the password gate's management API (RFC FA)
36
+ * listAuthGates(opts?): Promise<AuthGateListResponse> // admin token only
37
+ * createInvite(gate, body, opts?): Promise<CreatedInvite> // one-time link, shown once
38
+ * listInvites(gate, opts?): Promise<AuthInviteListResponse>
39
+ * revokeInvite(gate, inviteId, opts?): Promise<void>
40
+ * createPasswordReset(gate, subject, opts?): Promise<CreatedPasswordReset>
41
+ * removeGateCredentials(gate, subject, opts?): Promise<void>
42
+ * renameGateLogin(gate, subject, login, opts?): Promise<{subject, login}>
43
+ * listGateIdentities(gate, subject, opts?): Promise<GateIdentitiesResponse>
44
+ * getGateTenantStatus(gate, opts?): Promise<GateTenantStatus>
31
45
  * runnableAgents(opts?): Promise<RunnableAgentsResponse> // RFC BY (v1.51.0) — agents I can run
32
46
  * whoami(): Promise<WhoamiResponse> // RFC L principal (v0.17.0)
33
47
  *
@@ -116,6 +130,19 @@
116
130
  * listVolumes(): Promise<PersistentVolumesResponse>
117
131
  * listEphemeralVolumes(): Promise<EphemeralVolumesResponse>
118
132
  *
133
+ * // App clients (RFC FL ClientDef; tenant operator or admin)
134
+ * clientDef(input): Promise<unknown> // POST /v1/_clientdef
135
+ * createClientDef / updateClientDef(name, client, opts?): Promise<ClientDefRecord>
136
+ * getClientDef({name | def_id}): Promise<AppClientEntry>
137
+ * listClientDefs(opts?): Promise<ClientDefListResponse>
138
+ * retireClientDef(name): Promise<ClientDefRetireResult>
139
+ * verifyClientDef(name, {contentSha256}?): Promise<ClientDefVerifyResult>
140
+ * listClientDefNames(opts?): Promise<ClientDefNamesResponse> // GET /v1/_clientdef/names
141
+ *
142
+ * // App sign-in (RFC FL) — also standalone as `new LoomcycleOAuth(...)`
143
+ * oauth: LoomcycleOAuth // beginAuthorize / completeAuthorize / refresh / revoke / discover
144
+ * new LoomcycleClient({ tokenProvider: new OAuthTokenProvider(...) }) // refreshes on its own
145
+ *
119
146
  * // Agent Teams (RFC AP — TeamDef substrate; tenant-confined)
120
147
  * listTeams(): Promise<ListTeamsResponse>
121
148
  * renderTeamDiagram(name, opts?): Promise<TeamDiagram>
@@ -182,7 +209,7 @@
182
209
  * See `adapters/ts/README.md` for usage examples.
183
210
  */
184
211
  Object.defineProperty(exports, "__esModule", { value: true });
185
- exports.UnavailableError = exports.TokenLimitExceededError = exports.SubstrateToolRefusedError = exports.SnapshotVersionError = exports.SnapshotTooLargeError = exports.SnapshotNotFoundError = exports.SessionNotFoundError = exports.SessionBusyError = exports.RequestTooLargeError = exports.RateLimitedError = exports.PromptTooLargeError = exports.PerUserQuotaExhaustedError = exports.PauseNotConfiguredError = exports.NotPausedError = exports.LoomcycleError = exports.ChannelCursorRegressionError = exports.InvalidArgumentError = exports.InsufficientScopeError = exports.NotFoundError = exports.BackpressureError = exports.AuthError = exports.AlreadyPausingError = exports.AgentNotFoundError = exports.AgentIDInUseError = exports.AclDeniedError = exports.CLIENT_TOOL_SUBPROTOCOL = exports.clientToolsURL = exports.ClientToolHost = exports.verifyLoomcycleDelivery = exports.verifyLoomcycleSignature = exports.InteractiveSession = exports.LoomcycleClient = void 0;
212
+ exports.UnavailableError = exports.TokenLimitExceededError = exports.SubstrateToolRefusedError = exports.SnapshotVersionError = exports.SnapshotTooLargeError = exports.SnapshotNotFoundError = exports.SessionNotFoundError = exports.SessionBusyError = exports.RequestTooLargeError = exports.RateLimitedError = exports.PromptTooLargeError = exports.PerUserQuotaExhaustedError = exports.PauseNotConfiguredError = exports.OAuthError = exports.NotPausedError = exports.LoomcycleError = exports.ChannelCursorRegressionError = exports.InvalidArgumentError = exports.InsufficientScopeError = exports.NotFoundError = exports.DeviceLoginError = exports.BackpressureError = exports.AuthError = exports.AlreadyPausingError = exports.AgentNotFoundError = exports.AgentIDInUseError = exports.AclDeniedError = exports.pkceChallenge = exports.parseAuthorizeResponse = exports.OAuthTokenProvider = exports.LoomcycleOAuth = exports.CLIENT_TOOL_SUBPROTOCOL = exports.clientToolsURL = exports.ClientToolHost = exports.verifyLoomcycleDelivery = exports.verifyLoomcycleSignature = exports.InteractiveSession = exports.LoomcycleClient = void 0;
186
213
  var client_js_1 = require("./client.js");
187
214
  Object.defineProperty(exports, "LoomcycleClient", { enumerable: true, get: function () { return client_js_1.LoomcycleClient; } });
188
215
  var interactive_js_1 = require("./interactive.js");
@@ -194,6 +221,11 @@ var client_tools_js_1 = require("./client-tools.js");
194
221
  Object.defineProperty(exports, "ClientToolHost", { enumerable: true, get: function () { return client_tools_js_1.ClientToolHost; } });
195
222
  Object.defineProperty(exports, "clientToolsURL", { enumerable: true, get: function () { return client_tools_js_1.clientToolsURL; } });
196
223
  Object.defineProperty(exports, "CLIENT_TOOL_SUBPROTOCOL", { enumerable: true, get: function () { return client_tools_js_1.CLIENT_TOOL_SUBPROTOCOL; } });
224
+ var oauth_js_1 = require("./oauth.js");
225
+ Object.defineProperty(exports, "LoomcycleOAuth", { enumerable: true, get: function () { return oauth_js_1.LoomcycleOAuth; } });
226
+ Object.defineProperty(exports, "OAuthTokenProvider", { enumerable: true, get: function () { return oauth_js_1.OAuthTokenProvider; } });
227
+ Object.defineProperty(exports, "parseAuthorizeResponse", { enumerable: true, get: function () { return oauth_js_1.parseAuthorizeResponse; } });
228
+ Object.defineProperty(exports, "pkceChallenge", { enumerable: true, get: function () { return oauth_js_1.pkceChallenge; } });
197
229
  var errors_js_1 = require("./errors.js");
198
230
  Object.defineProperty(exports, "AclDeniedError", { enumerable: true, get: function () { return errors_js_1.AclDeniedError; } });
199
231
  Object.defineProperty(exports, "AgentIDInUseError", { enumerable: true, get: function () { return errors_js_1.AgentIDInUseError; } });
@@ -201,12 +233,14 @@ Object.defineProperty(exports, "AgentNotFoundError", { enumerable: true, get: fu
201
233
  Object.defineProperty(exports, "AlreadyPausingError", { enumerable: true, get: function () { return errors_js_1.AlreadyPausingError; } });
202
234
  Object.defineProperty(exports, "AuthError", { enumerable: true, get: function () { return errors_js_1.AuthError; } });
203
235
  Object.defineProperty(exports, "BackpressureError", { enumerable: true, get: function () { return errors_js_1.BackpressureError; } });
236
+ Object.defineProperty(exports, "DeviceLoginError", { enumerable: true, get: function () { return errors_js_1.DeviceLoginError; } });
204
237
  Object.defineProperty(exports, "NotFoundError", { enumerable: true, get: function () { return errors_js_1.NotFoundError; } });
205
238
  Object.defineProperty(exports, "InsufficientScopeError", { enumerable: true, get: function () { return errors_js_1.InsufficientScopeError; } });
206
239
  Object.defineProperty(exports, "InvalidArgumentError", { enumerable: true, get: function () { return errors_js_1.InvalidArgumentError; } });
207
240
  Object.defineProperty(exports, "ChannelCursorRegressionError", { enumerable: true, get: function () { return errors_js_1.ChannelCursorRegressionError; } });
208
241
  Object.defineProperty(exports, "LoomcycleError", { enumerable: true, get: function () { return errors_js_1.LoomcycleError; } });
209
242
  Object.defineProperty(exports, "NotPausedError", { enumerable: true, get: function () { return errors_js_1.NotPausedError; } });
243
+ Object.defineProperty(exports, "OAuthError", { enumerable: true, get: function () { return errors_js_1.OAuthError; } });
210
244
  Object.defineProperty(exports, "PauseNotConfiguredError", { enumerable: true, get: function () { return errors_js_1.PauseNotConfiguredError; } });
211
245
  Object.defineProperty(exports, "PerUserQuotaExhaustedError", { enumerable: true, get: function () { return errors_js_1.PerUserQuotaExhaustedError; } });
212
246
  Object.defineProperty(exports, "PromptTooLargeError", { enumerable: true, get: function () { return errors_js_1.PromptTooLargeError; } });