@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/README.md +158 -0
- package/dist/cjs/client-tools.js +28 -5
- package/dist/cjs/client.js +310 -10
- package/dist/cjs/device-login.js +146 -0
- package/dist/cjs/errors.js +40 -1
- package/dist/cjs/fetch-helpers.js +31 -0
- package/dist/cjs/index.js +35 -1
- package/dist/cjs/oauth.js +400 -0
- package/dist/client-tools.d.ts +6 -2
- package/dist/client-tools.js +28 -5
- package/dist/client.d.ts +226 -4
- package/dist/client.js +311 -11
- package/dist/device-login.d.ts +12 -0
- package/dist/device-login.js +143 -0
- package/dist/errors.d.ts +36 -0
- package/dist/errors.js +37 -0
- package/dist/fetch-helpers.d.ts +8 -0
- package/dist/fetch-helpers.js +30 -0
- package/dist/index.d.ts +31 -2
- package/dist/index.js +29 -1
- package/dist/oauth.d.ts +131 -0
- package/dist/oauth.js +393 -0
- package/dist/types.d.ts +593 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,6 +12,7 @@ TypeScript client for the [loomcycle](https://github.com/denn-gubsky/loomcycle)
|
|
|
12
12
|
|
|
13
13
|
### What's new since v0.8.18
|
|
14
14
|
|
|
15
|
+
- **Sign in with loomcycle (apps)** (RFC FL) — `LoomcycleOAuth` (also `client.oauth`) drives loomcycle's own OAuth sign-in for an app: `beginAuthorize` / `completeAuthorize` (PKCE S256, state and RFC 9207 `iss` checked), `refresh`, `revoke`, `discover`. A new `tokenProvider` client option (instead of `authToken`) takes short-lived bearers; `OAuthTokenProvider` refreshes before expiry and once after a 401. ClientDef methods register the apps: `createClientDef` / `updateClientDef` / `getClientDef` / `listClientDefs` / `retireClientDef` / `verifyClientDef` / `listClientDefNames`. See [Sign in with loomcycle (apps)](#sign-in-with-loomcycle-apps). Additive — an `authToken` client is unchanged.
|
|
15
16
|
- **Breaking — `tools` replaces `allowedTools`** — the per-run tool allowlist is now a single canonical key. The `runStreaming` / `continueSession` option is renamed from `allowedTools` to **`tools`**, and the `AgentDefOverlay` + library wire field is likewise **`tools`**, matching the loomcycle YAML rename and aligning with Claude Code agent frontmatter. No dual-key fallback — update call sites to `runStreaming({ …, tools: [...] })`. Requires a loomcycle server carrying the same rename.
|
|
16
17
|
- **Path/Document browse-by-subject + the full Document op set** (v1.12.1, RFC AS/AK) — `path(input, opts)` and `document(input, opts)` accept optional `scopeId` / `tenant` browse overrides, sent as `?scope_id=` / `?tenant=` query params (the server reads them from the URL and re-checks authorization — a tenant principal's `tenant` is ignored, `scopeId` picks any subject it may see); omit both to browse your own subject (byte-identical to the pre-RFC-AS request). `DocumentToolInput.op` now covers all 16 backend ops — adds **`set_path`** (attach/re-home a Path-tree name for an existing document), **`export_md`** (render to Markdown; `include_metadata: false` for clean human-facing output), and **`import_md`** (build a document from export_md-shaped `markdown`). Additive — existing `path()` / `document()` callers are unchanged.
|
|
17
18
|
- **`interactiveSession` / `sendRunInput` / `streamRunByID` + the `interactive` flag** (v1.1.1, RFC AI) — the interactive agentic session, the adapter port of the Web UI's run terminal. Pass `interactive: true` to `runStreaming` / `continueSession` to start a **persistent** run that parks at end_turn (an `awaiting_input` frame) instead of ending; **`sendRunInput(runId, text)`** steers it (the response arrives on the same stream); **`streamRunByID(runId, {fromSeq})`** re-attaches by run_id (the operator's prior turns replay as `steer` events, `user_input.source === "replay"`, so a cold client — e.g. another device — reconstructs the whole conversation). The high-level **`client.interactiveSession({agent, segments})`** returns an `InteractiveSession` with `events()` / `send()` / `cancel()`; **`attachInteractiveSession(runId)`** resumes one. The `AgentEvent` union gains `awaiting_input` / `steer` / `context_compaction`.
|
|
@@ -553,6 +554,163 @@ const why = await client.authzExplain({
|
|
|
553
554
|
console.log(why.decision.outcome, why.traces);
|
|
554
555
|
```
|
|
555
556
|
|
|
557
|
+
### Users and sign-in gates (RFC FA)
|
|
558
|
+
|
|
559
|
+
First-class users and how people sign in. A user's `status` (`UserStatus`) is `active`, `disabled` or `pending`: a sign-in gate configured with `new_user.status: pending` provisions people who can sign in but are refused everything until an operator approves them. `createUser` / `updateUser` accept only `active` and `disabled`; a pending user is activated by `approveUser` (a PATCH to `active` is a 409 `use_approve`). `whoami().auth.status` is `"pending"` for such a session.
|
|
560
|
+
|
|
561
|
+
The tenant is your own. `opts.tenant` names another for an admin token, and for an `auth:manage` credential (a website's), which must name one (400 `tenant_required`) and is held to the gate's new-user floor; a tenant operator always acts in its own tenant. Every call needs a persistent store.
|
|
562
|
+
|
|
563
|
+
| Method | Route | Returns |
|
|
564
|
+
|---|---|---|
|
|
565
|
+
| `createUser(body, opts?)` / `updateUser(subject, body, opts?)` / `deleteUser(subject, opts?)` | `POST /v1/_users` / `PATCH` / `DELETE /v1/_users/{subject}` | `UserRecord` / `UserRecord` / void |
|
|
566
|
+
| `approveUser(subject, opts?)` | `POST /v1/_users/{subject}/approve` | `UserRecord`, now `active`; 409 `not_pending` otherwise |
|
|
567
|
+
| `listUserIdentities(subject, opts?)` | `GET /v1/_users/{subject}/identities` | `UserIdentitiesResponse` — the identity-provider accounts a user signs in with; a user reads its own |
|
|
568
|
+
| `unlinkUserIdentity(subject, gate, opts?)` | `DELETE /v1/_users/{subject}/identities/{gate}` | void; for an operator — a user unlinks its own only from its browser session |
|
|
569
|
+
| `listAuthGates({probe?}?)` | `GET /v1/_authgate` | `AuthGateListResponse` — every configured gate, never key material. **Admin token only** |
|
|
570
|
+
| `createInvite(gate, body, opts?)` | `POST /v1/_authgate/{gate}/invites` | `CreatedInvite` — `{invite_id, url, expires_at}`; the URL is shown once |
|
|
571
|
+
| `listInvites(gate, opts?)` | `GET /v1/_authgate/{gate}/invites` | `AuthInviteListResponse` — `state` is `live` / `redeemed` / `revoked` / `expired`; never a code |
|
|
572
|
+
| `revokeInvite(gate, inviteId, opts?)` | `DELETE /v1/_authgate/{gate}/invites/{id}` | void; 409 `invite_not_live` |
|
|
573
|
+
| `createPasswordReset(gate, subject, opts?)` | `POST /v1/_authgate/{gate}/resets` | `CreatedPasswordReset` — a one-time link that sets a new password |
|
|
574
|
+
| `removeGateCredentials(gate, subject, opts?)` | `DELETE /v1/_authgate/{gate}/credentials/{subject}` | void — password, authenticator and recovery codes go; the login stays |
|
|
575
|
+
| `renameGateLogin(gate, subject, login, opts?)` | `PATCH /v1/_authgate/{gate}/logins/{subject}` | `{subject, login}`; 409 `login_taken` |
|
|
576
|
+
| `listGateIdentities(gate, subject, opts?)` | `GET /v1/_authgate/{gate}/identities?subject=` | `GateIdentitiesResponse` — logins, `has_password`, `has_totp` |
|
|
577
|
+
| `getGateTenantStatus(gate, opts?)` | `GET /v1/_authgate/{gate}/tenant-status` | `GateTenantStatus` — whether the tenant has, or ever had, a user, and whether an owner invite is live |
|
|
578
|
+
| `deviceLogin({onPrompt, clientId?, signal?})` | `POST /v1/_auth/device`, then `POST /v1/_auth/device/token` | `DeviceLoginResult` — signs a person in through the server's device gate (RFC 8628): `onPrompt` gets the code to show, the person approves it at `/ui/device`, and the token is theirs, never outliving the session they approved from. Sends no bearer. `DeviceLoginError` (`code` `access_denied` / `expired_token`) otherwise |
|
|
579
|
+
|
|
580
|
+
None of these takes a password: the person sets their own through the invite or reset link. Hand the link's `url` to the person and do not log it — its fragment is the one-time code.
|
|
581
|
+
|
|
582
|
+
Signing in, signing out and a person's own password or authenticator are not here. Those routes (`POST /ui/session`, `POST /v1/_auth/logout`, the password login, redeeming an invite or a reset, `/v1/_auth/account`, `/v1/_auth/password`, `/v1/_auth/totp`) establish or act on a browser's cookie session and accept only same-origin requests from loomcycle's own Web UI. A bearer has no session to end: retire a delegated user token with `revokeUserToken`, an operator token with `operatorTokenDef`.
|
|
583
|
+
|
|
584
|
+
```ts
|
|
585
|
+
const pending = (await client.listUsers({ tenant: "acme" })).users.filter((u) => u.status === "pending");
|
|
586
|
+
for (const u of pending) await client.approveUser(u.user_id, { tenant: "acme" });
|
|
587
|
+
|
|
588
|
+
const { url } = await client.createInvite("password", { email: "carol@example.com", ttl: "72h" }, { tenant: "acme" });
|
|
589
|
+
```
|
|
590
|
+
|
|
591
|
+
### Sign in with loomcycle (apps)
|
|
592
|
+
|
|
593
|
+
An app built on this client does not need a pasted token: it can send the person to loomcycle's own login page, where they sign in through whatever gates the operator configured, and get back a short-lived access token for that same user (RFC FL). It is the OAuth 2.0 authorization code flow with PKCE S256: the app holds no secret, never sees a password, and is authorized exactly as the user is anywhere else. The server side needs `LOOMCYCLE_PUBLIC_URL` and the app registered as a client — in the config's `auth.clients` or at runtime with `createClientDef` (below); see `docs/CONFIGURATION.md`, "App sign-in".
|
|
594
|
+
|
|
595
|
+
`LoomcycleOAuth` (also `client.oauth`) runs on Web Crypto and `fetch`, so the same code serves a browser SPA, a Chrome extension and a Tauri or Node app. On Node 18, which has no global `crypto`, pass `crypto: require("node:crypto").webcrypto`.
|
|
596
|
+
|
|
597
|
+
| Method | Route | Returns |
|
|
598
|
+
|---|---|---|
|
|
599
|
+
| `beginAuthorize({clientId, redirectUri, state?})` | — (builds `GET /v1/_oauth/authorize?…`) | `{url, verifier, state}` — persist `verifier` and `state`, then send the person to `url` |
|
|
600
|
+
| `completeAuthorize({...parseAuthorizeResponse(href), expectedState, verifier, clientId, redirectUri})` | `POST /v1/_oauth/token` | `OAuthTokenSet` — `{access_token, refresh_token, token_type, expires_in, expires_at}` (`expires_at` in ms since the epoch) |
|
|
601
|
+
| `refresh({refreshToken, clientId})` | `POST /v1/_oauth/token` | a new `OAuthTokenSet`; the old refresh token is spent |
|
|
602
|
+
| `revoke({token, clientId})` | `POST /v1/_oauth/revoke` | void — a refresh token ends the whole sign-in |
|
|
603
|
+
| `discover()` | `GET /.well-known/oauth-authorization-server` | `OAuthServerMetadata` (RFC 8414), cached |
|
|
604
|
+
|
|
605
|
+
`completeAuthorize` refuses, before the code is used, a redirect whose `state` is not the one this sign-in sent (`state_mismatch`) or whose `iss` names another server (`issuer_mismatch`, RFC 9207; the issuer comes from the `issuer` option or from `discover()`), and throws a redirect's `error` (`access_denied` with `pending` for a user awaiting approval). Every failure is an `OAuthError` whose `code` is the OAuth error code.
|
|
606
|
+
|
|
607
|
+
A browser SPA:
|
|
608
|
+
|
|
609
|
+
```ts
|
|
610
|
+
import { LoomcycleClient, LoomcycleOAuth, OAuthTokenProvider, parseAuthorizeResponse } from "@loomcycle/client";
|
|
611
|
+
|
|
612
|
+
const baseUrl = "https://loom.example.com";
|
|
613
|
+
const clientId = "loomboard";
|
|
614
|
+
const redirectUri = `${location.origin}/auth/callback`; // registered exactly
|
|
615
|
+
const oauth = new LoomcycleOAuth({ baseUrl });
|
|
616
|
+
|
|
617
|
+
// "Sign in" button:
|
|
618
|
+
const req = await oauth.beginAuthorize({ clientId, redirectUri });
|
|
619
|
+
sessionStorage.setItem("lc_sign_in", JSON.stringify({ verifier: req.verifier, state: req.state }));
|
|
620
|
+
location.assign(req.url);
|
|
621
|
+
|
|
622
|
+
// On /auth/callback:
|
|
623
|
+
const saved = JSON.parse(sessionStorage.getItem("lc_sign_in") ?? "{}");
|
|
624
|
+
sessionStorage.removeItem("lc_sign_in");
|
|
625
|
+
const tokens = await oauth.completeAuthorize({
|
|
626
|
+
...parseAuthorizeResponse(location.href),
|
|
627
|
+
expectedState: saved.state,
|
|
628
|
+
verifier: saved.verifier,
|
|
629
|
+
clientId,
|
|
630
|
+
redirectUri,
|
|
631
|
+
});
|
|
632
|
+
history.replaceState(null, "", "/"); // the code is spent; drop it from the address bar
|
|
633
|
+
|
|
634
|
+
const provider = new OAuthTokenProvider({
|
|
635
|
+
oauth,
|
|
636
|
+
clientId,
|
|
637
|
+
tokens,
|
|
638
|
+
onTokens: (t) => saveTokens(t), // persist every rotated set
|
|
639
|
+
onSignedOut: () => showSignIn(),
|
|
640
|
+
});
|
|
641
|
+
const client = new LoomcycleClient({ baseUrl, tokenProvider: provider });
|
|
642
|
+
console.log((await client.whoami()).subject);
|
|
643
|
+
|
|
644
|
+
// "Sign out":
|
|
645
|
+
await provider.signOut();
|
|
646
|
+
```
|
|
647
|
+
|
|
648
|
+
A Chrome extension does the same with `chrome.identity.launchWebAuthFlow({url: req.url, interactive: true})`, which resolves with the redirect URL to hand to `parseAuthorizeResponse`; register `https://<extension-id>.chromiumapp.org/` as the redirect URI and `chrome-extension://<extension-id>` as an allowed origin.
|
|
649
|
+
|
|
650
|
+
A desktop app (Tauri, Electron, a CLI) uses a loopback redirect: register `http://127.0.0.1/callback` and the server accepts it on any port (RFC 8252). In Node:
|
|
651
|
+
|
|
652
|
+
```ts
|
|
653
|
+
import { createServer } from "node:http";
|
|
654
|
+
|
|
655
|
+
const server = createServer();
|
|
656
|
+
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
|
|
657
|
+
const port = (server.address() as { port: number }).port;
|
|
658
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
659
|
+
|
|
660
|
+
const req = await oauth.beginAuthorize({ clientId: "loomboard-desktop", redirectUri });
|
|
661
|
+
openInSystemBrowser(req.url); // e.g. Tauri's shell.open, Electron's shell.openExternal
|
|
662
|
+
|
|
663
|
+
const href = await new Promise<string>((resolve) =>
|
|
664
|
+
server.on("request", (r, res) => {
|
|
665
|
+
res.end("Signed in. You can close this window.");
|
|
666
|
+
resolve(`http://127.0.0.1:${port}${r.url}`);
|
|
667
|
+
}),
|
|
668
|
+
);
|
|
669
|
+
server.close();
|
|
670
|
+
const tokens = await oauth.completeAuthorize({
|
|
671
|
+
...parseAuthorizeResponse(href),
|
|
672
|
+
expectedState: req.state,
|
|
673
|
+
verifier: req.verifier,
|
|
674
|
+
clientId: "loomboard-desktop",
|
|
675
|
+
redirectUri,
|
|
676
|
+
});
|
|
677
|
+
```
|
|
678
|
+
|
|
679
|
+
A Tauri app may register a private-use scheme instead (`loomboard://auth/callback`) and pass the deep link it receives to `parseAuthorizeResponse`. Token calls made from its webview send the origin `tauri://localhost`, which the client must list in `allowed_origins`; calls from the Rust or Node side send none.
|
|
680
|
+
|
|
681
|
+
**The token provider.** `new LoomcycleClient({ tokenProvider })` (instead of `authToken`; passing both throws) asks the provider for the bearer on every request, `connectClientTools` included. `OAuthTokenProvider` refreshes 60 s before the access token expires (`refreshSkewMs`), and once after a 401: the request is retried a single time with the new token, never more. Concurrent requests share one refresh. Any other source of short-lived tokens can implement `TokenProvider` (`getToken`, optional `onUnauthorized`).
|
|
682
|
+
|
|
683
|
+
Things to know:
|
|
684
|
+
|
|
685
|
+
- **Persist every rotated set.** A refresh spends the old refresh token, and presenting a spent one again ends the whole sign-in (reuse detection). `onTokens` is called before the new token is used.
|
|
686
|
+
- **One provider per sign-in.** Two providers, or two browser tabs, refreshing the same refresh token end it the same way. Share one, or sign each tab in separately.
|
|
687
|
+
- **Never log a token or put one in a URL.** The helper sends codes, verifiers and tokens only in POST bodies, with no cookie and no `Authorization` header; keep the `verifier` in session storage, not the URL, and clear it after the exchange.
|
|
688
|
+
- **An ended sign-in** (revoked, expired, its client retired) reaches `onSignedOut` once, when its refresh token is refused; requests then fail with an `OAuthError` (or the server's 401 as `AuthError`). Send the person to sign in again.
|
|
689
|
+
|
|
690
|
+
**Registering apps (ClientDef).** A tenant operator (`substrate:tenant`) registers its own tenant's apps; an admin any tenant's, or one for every tenant (`tenant: ""`, the default). Config clients are listed read only, and another tenant's client reads as not found. A refusal raises `SubstrateToolRefusedError`.
|
|
691
|
+
|
|
692
|
+
| Method | Route | Returns |
|
|
693
|
+
|---|---|---|
|
|
694
|
+
| `createClientDef(name, client, {description?, tenant?}?)` | `POST /v1/_clientdef` `op: create` | `ClientDefRecord` |
|
|
695
|
+
| `updateClientDef(name, client, {description?}?)` | `op: update` | `ClientDefRecord`, a new version; unexchanged codes stop working |
|
|
696
|
+
| `getClientDef({name} \| {def_id})` | `op: get` | `AppClientEntry` (`source: "def"` or `"config"`) |
|
|
697
|
+
| `listClientDefs({tenant?}?)` | `op: list` | `{clients: AppClientEntry[]}` |
|
|
698
|
+
| `retireClientDef(name)` | `op: retire` | `{name, retired}` — its sign-ins are revoked and their tokens retired |
|
|
699
|
+
| `verifyClientDef(name, {contentSha256?}?)` | `op: verify` | `ClientDefVerifyResult` |
|
|
700
|
+
| `listClientDefNames({tenant?}?)` | `GET /v1/_clientdef/names` | `{names: ClientDefNameSummary[]}` |
|
|
701
|
+
| `clientDef(input)` | `POST /v1/_clientdef` | the op dispatch, as sent |
|
|
702
|
+
|
|
703
|
+
```ts
|
|
704
|
+
await admin.createClientDef("loomboard", {
|
|
705
|
+
kind: "public",
|
|
706
|
+
label: "Loomboard",
|
|
707
|
+
redirect_uris: ["https://board.example.com/auth/callback", "http://127.0.0.1/callback"],
|
|
708
|
+
allowed_origins: ["https://board.example.com"],
|
|
709
|
+
access_token_ttl: "1h",
|
|
710
|
+
refresh: { idle: "7d", absolute: "30d" },
|
|
711
|
+
}, { tenant: "acme" });
|
|
712
|
+
```
|
|
713
|
+
|
|
556
714
|
### Decision models
|
|
557
715
|
|
|
558
716
|
Ask a **decision model** typed questions about a piece of text and get each answer with its probabilities. A decision model does not write: it reads the `state` you give it and answers a choice, a yes/no or a score, so there is no reply to parse and a call costs a few output tokens. Use it to route, gate, rank or grade text you already have. No run is involved; the tokens are charged to the caller and count against its token budget.
|
package/dist/cjs/client-tools.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
15
|
exports.ClientToolHost = exports.CLIENT_TOOL_SUBPROTOCOL = void 0;
|
|
16
16
|
exports.clientToolsURL = clientToolsURL;
|
|
17
|
+
const errors_js_1 = require("./errors.js");
|
|
17
18
|
/** The app-level subprotocol the server negotiates for /v1/client-tools. */
|
|
18
19
|
exports.CLIENT_TOOL_SUBPROTOCOL = "loomcycle.client-tools.v1";
|
|
19
20
|
/**
|
|
@@ -24,13 +25,16 @@ exports.CLIENT_TOOL_SUBPROTOCOL = "loomcycle.client-tools.v1";
|
|
|
24
25
|
*/
|
|
25
26
|
class ClientToolHost {
|
|
26
27
|
url;
|
|
27
|
-
|
|
28
|
+
auth;
|
|
28
29
|
opts;
|
|
29
30
|
ws;
|
|
30
31
|
closed = false;
|
|
31
|
-
|
|
32
|
+
/** `auth` is the bearer, or a function that supplies one on each
|
|
33
|
+
* (re)connect — a client built with a token provider passes one, so a
|
|
34
|
+
* reconnect after the access token expired carries the refreshed one. */
|
|
35
|
+
constructor(url, auth, opts) {
|
|
32
36
|
this.url = url;
|
|
33
|
-
this.
|
|
37
|
+
this.auth = auth;
|
|
34
38
|
this.opts = opts;
|
|
35
39
|
}
|
|
36
40
|
/** Open the connection (called for you by connectClientTools). */
|
|
@@ -49,9 +53,28 @@ class ClientToolHost {
|
|
|
49
53
|
if (!WS) {
|
|
50
54
|
throw new Error("connectClientTools: no WebSocket implementation available — pass WebSocketImpl (e.g. the `ws` package) on Node < 22");
|
|
51
55
|
}
|
|
56
|
+
if (typeof this.auth === "function") {
|
|
57
|
+
this.auth().then((token) => this.open(WS, token), (err) => {
|
|
58
|
+
this.opts.onError?.(err);
|
|
59
|
+
// A sign-in that ended does not come back by retrying: stop
|
|
60
|
+
// rather than ask again every few seconds.
|
|
61
|
+
if (err instanceof errors_js_1.OAuthError && !err.retryable) {
|
|
62
|
+
this.closed = true;
|
|
63
|
+
this.opts.onStatus?.("closed");
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
this.scheduleReconnect();
|
|
67
|
+
});
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
this.open(WS, this.auth);
|
|
71
|
+
}
|
|
72
|
+
open(WS, token) {
|
|
73
|
+
if (this.closed)
|
|
74
|
+
return;
|
|
52
75
|
const protocols = [exports.CLIENT_TOOL_SUBPROTOCOL];
|
|
53
|
-
if (
|
|
54
|
-
protocols.push("bearer." +
|
|
76
|
+
if (token)
|
|
77
|
+
protocols.push("bearer." + token);
|
|
55
78
|
this.opts.onStatus?.("connecting");
|
|
56
79
|
const ws = new WS(this.url, protocols);
|
|
57
80
|
this.ws = ws;
|