@loomcycle/client 1.120.0 → 1.122.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -501,12 +501,58 @@ One method per substrate family returns every declared name with its version rol
501
501
  | `listWebhookDefNames(opts?)` | `GET /v1/_webhookdef/names` |
502
502
  | `listMemoryBackendDefNames(opts?)` | `GET /v1/_memorybackenddef/names` |
503
503
  | `listDocumentSourceDefNames(opts?)` | `GET /v1/_documentsourcedef/names` |
504
- | `listOperatorTokenDefNames(opts?)` | `GET /v1/_operatortokendef/names` — **admin token only**; `{name, tenant_id, subject, token_count, has_current, last_updated}`, never a token. |
504
+ | `listOperatorTokenDefNames(opts?)` | `GET /v1/_operatortokendef/names` — **admin token only**; one row per (tenant, name): `{name, tenant_id, subject, token_count, has_current, current_def_id?, last_updated}`, never a token. Rotate or retire by `current_def_id`; a per-user (`u-`) name is refused as a target. |
505
505
 
506
506
  All but the last return `DefNamesResponse`; `live_version_count` / `active_retired` are reported by the AgentDef, SkillDef, HookDef and MCPServerDef families. Teams have `listTeams()`.
507
507
 
508
508
  Team options ride on existing methods: `createTeam(name, overlay, { promote, description })` sends the create's own fields (omitted, a create is promoted — the server default; `promote: false` authors it without putting it in force), `forkTeam(name, overlay, { promote, parentDefId, description })` sends the fork's (omitted, the fork is authored but not active — the server default for a fork), and `runTeam({ ..., interruptOnCap: true })` asks a human whether to continue, reroute or abort when a state hits its iteration cap, instead of ending with `iteration_cap`.
509
509
 
510
+ ### Access control (RFC EY / EZ)
511
+
512
+ Groups, access-control rule lists, the bindings that attach a list to a subject, and the AuthZEN-shaped decision questions. Groups, lists and bindings are the tenant operator's (`substrate:tenant`, an admin, or the legacy shared token; any other principal gets an `InsufficientScopeError`); the decision questions are open to any principal about itself. Every call acts in your own tenant; `opts.tenant` focuses another for an admin and is ignored for a tenant token. Rows the config declares (`acls:` / `groups:` / `acl_bindings:`) are listed beside the stored ones with `source: "config"`, without versions or timestamps, and cannot be written here. Every call needs a persistent store and is refused from inside a run.
513
+
514
+ | Method | Route | Returns |
515
+ |---|---|---|
516
+ | `listGroups(opts?)` | `GET /v1/_groups` | `GroupListResponse` — stored and config groups, by name |
517
+ | `getGroup(name, opts?)` | `GET /v1/_groups/{name}` | `GroupDetail` — a stored group, its direct members, `contained_in`; a config-only group is a `NotFoundError` |
518
+ | `getGroupEffective(name, opts?)` | `GET /v1/_groups/{name}/effective` | `GroupEffective` — every user through nested groups, config members included |
519
+ | `createGroup({name, description?, external_id?}, opts?)` | `POST /v1/_groups` | `GroupRow` — an upsert by name |
520
+ | `deleteGroup(name, opts?)` | `DELETE /v1/_groups/{name}` | `{deleted}` |
521
+ | `addGroupMember(name, {kind, id}, opts?)` | `POST /v1/_groups/{name}/members` | `GroupMemberChange`; `kind` is `user` or `group` (nesting) |
522
+ | `removeGroupMember(name, kind, id, opts?)` | `DELETE /v1/_groups/{name}/members/{kind}/{id}` | `GroupMemberChange` with `removed: true` |
523
+ | `aclDef(input, opts?)` | `POST /v1/_acldef` | the op's body; `op: "names"` reads `GET /v1/_acldef/names` |
524
+ | `listAclDefs({includeRetired?}?)` | `aclDef` op `list` | `AclDefListResponse` — `acls` plus the read-only `builtin` baselines |
525
+ | `getAclDef(name, opts?)` | op `get` | `AclDefRow` |
526
+ | `putAclDef({name, rules, description?}, opts?)` | op `put` | `AclDefRow` — creates or replaces, bumping `version` |
527
+ | `retireAclDef(name, opts?)` | op `retire` | `{retired}` |
528
+ | `validateAclRules(rules, opts?)` | op `validate` | `AclValidateResult`; bad rules are an `InvalidArgumentError` with code `acl_invalid` |
529
+ | `getAclCatalog(opts?)` | op `catalog` | `AclCatalog` — types, verbs, scopes, transports |
530
+ | `listAclDefNames(opts?)` | `GET /v1/_acldef/names` | `{names}` |
531
+ | `listAclBindings({includeRetired?}?)` | `GET /v1/_aclbinding` | `AclBindingListResponse` |
532
+ | `createAclBinding({acl, subject_kind, subject_id?, expires_at?}, opts?)` | `POST /v1/_aclbinding` | `AclBinding`; a missing list or a past expiry is an `InvalidArgumentError` with code `acl_binding_invalid` |
533
+ | `deleteAclBinding(bindingId, opts?)` | `DELETE /v1/_aclbinding/{id}` | `{retired}` |
534
+ | `authzEvaluation(req, opts?)` | `POST /v1/_authz/evaluation` | `AuthzDecision` — `decision` is true only when a rule allowed |
535
+ | `authzEvaluations(req, opts?)` | `POST /v1/_authz/evaluations` | `{evaluations}` — up to 256, in order |
536
+ | `authzExplain(req, opts?)` | `POST /v1/_authz/explain` | `AuthzExplainResponse` — every rule read and why |
537
+ | `authzSimulate(req, opts?)` | `POST /v1/_authz/simulate` | `AuthzSimulateResponse` — `now` and `with_draft` per request; nothing is written |
538
+
539
+ `whoami()` also reports `acl_mode` (`compat` / `shadow` / `enforce`), the caller's `groups` and `acl_version`, each absent when the server does not send it. A delegated writer changing its own authority is refused with code `acl_self_elevation`; a write to a group an identity provider owns (`external_id` set) with a 409 `group_external`. A rule that refuses any call — here or on any other route — throws `AclDeniedError` (see [Errors](#errors)).
540
+
541
+ ```ts
542
+ const { names } = await client.listAclDefNames();
543
+ await client.putAclDef({
544
+ name: "support-tier-1",
545
+ rules: [{ effect: "deny", actions: ["invoke"], resources: ["tool:Bash"] }],
546
+ });
547
+ await client.createAclBinding({ acl: "support-tier-1", subject_kind: "group", subject_id: "support" });
548
+ const why = await client.authzExplain({
549
+ subject: { type: "user", id: "alice" },
550
+ action: { name: "invoke" },
551
+ resource: { type: "tool", id: "Bash" },
552
+ });
553
+ console.log(why.decision.outcome, why.traces);
554
+ ```
555
+
510
556
  ### Decision models
511
557
 
512
558
  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.
@@ -805,11 +851,13 @@ When the error body is JSON with a string `code` (`token_limit_exceeded`, `per_u
805
851
  | 400 | `InvalidArgumentError` |
806
852
  | 401 | `AuthError` |
807
853
  | 403 + code `insufficient_scope` | `InsufficientScopeError` (not retryable; `e.requiredScope` names the scope the token lacks — from the body's `required_scope`, or from `WWW-Authenticate` on an older server that sent plain text) |
808
- | 403 (other) | `LoomcycleError` (base; e.g. `restricted_token`, `operator_key_restricted` on `e.code`) |
854
+ | 403 + code `acl_denied` | `AclDeniedError` (an access-control rule refused the call; `e.action`, `e.resource` and `e.reasons` — the rules that decided, denies first, empty when none matched; not retryable) |
855
+ | 403 (other) | `LoomcycleError` (base; e.g. `restricted_token`, `operator_key_restricted`, `acl_self_elevation`, `authz_subject` on `e.code`) |
809
856
  | 404 + "snapshot" | `SnapshotNotFoundError` ⎫ |
810
857
  | 404 + "session" | `SessionNotFoundError` ⎬ all extend `NotFoundError` |
811
858
  | 404 + "agent" | `AgentNotFoundError` ⎬ |
812
859
  | 404 (other) | `NotFoundError` (base) ⎭ catch any 404 with one `instanceof` |
860
+ | 409 + code `group_external` / `group_exists` / `group_member_exists` / `acl_exists` / `acl_binding_exists` | `LoomcycleError` (base; matched by code before the keywords below, which a group named e.g. `session-ops` would trip) |
813
861
  | 409 + "already_pausing" / "already paused" | `AlreadyPausingError` |
814
862
  | 409 + "not_paused" / "not paused" | `NotPausedError` |
815
863
  | 409 + "session" | `SessionBusyError` |
@@ -1499,6 +1499,8 @@ class LoomcycleClient {
1499
1499
  };
1500
1500
  if (opts.headers)
1501
1501
  overlay.headers = opts.headers;
1502
+ if (opts.binaryResults)
1503
+ overlay.binary_results = opts.binaryResults;
1502
1504
  const createInput = { op: "create", name: opts.name, overlay };
1503
1505
  if (opts.description)
1504
1506
  createInput.description = opts.description;
@@ -1824,13 +1826,183 @@ class LoomcycleClient {
1824
1826
  async listDocumentSourceDefNames(opts) {
1825
1827
  return this.listDefNames("documentsourcedef", opts);
1826
1828
  }
1827
- /** Every OperatorTokenDef name (`GET /v1/_operatortokendef/names`), with its
1828
- * subject and token counts — never a token. **Admin token only**: token
1829
+ /** Every OperatorTokenDef name (`GET /v1/_operatortokendef/names`), one row
1830
+ * per (tenant, name), with its subject, token counts and current def_id —
1831
+ * never a token. **Admin token only**: token
1829
1832
  * minting stays operator-only, so a `substrate:tenant` token is refused
1830
1833
  * with 403. */
1831
1834
  async listOperatorTokenDefNames(opts) {
1832
1835
  return this.listDefNames("operatortokendef", opts);
1833
1836
  }
1837
+ // ---- Access control: groups, rule lists, bindings, decisions (RFC EY / EZ) ----
1838
+ //
1839
+ // Groups, lists and bindings are the tenant operator's: a `substrate:tenant`
1840
+ // token, an admin, or the legacy shared token. Any other principal is
1841
+ // refused with 403 (InsufficientScopeError). Every call acts in the caller's
1842
+ // own tenant; `tenant` is an admin's focus on another one and is ignored for
1843
+ // a tenant operator. Rows the config declares are listed beside the stored
1844
+ // ones with `source: "config"`; they cannot be written here. Every write
1845
+ // takes effect on every replica at once. A rule that refuses a call throws
1846
+ // AclDeniedError (403 `acl_denied`) naming the rules that decided.
1847
+ //
1848
+ // The decision questions (authzEvaluation / authzEvaluations / authzExplain
1849
+ // / authzSimulate) are open to any principal about itself; naming another
1850
+ // subject of the tenant needs a tenant operator (403 `authz_subject`).
1851
+ // Every call here needs a persistent store (503 otherwise) and is refused
1852
+ // from inside a run.
1853
+ /** `path` with the admin tenant focus and any extra query parameters. */
1854
+ aclPath(path, tenant, extra) {
1855
+ const q = new URLSearchParams(extra);
1856
+ if (tenant)
1857
+ q.set("tenant", tenant);
1858
+ const qs = q.toString();
1859
+ return qs ? `${path}?${qs}` : path;
1860
+ }
1861
+ /** Every group of the tenant, stored and config-declared, sorted by name
1862
+ * (`GET /v1/_groups`). */
1863
+ async listGroups(opts) {
1864
+ return (0, fetch_helpers_js_1.jsonFetch)(this.ctx, this.aclPath("/v1/_groups", opts?.tenant), opts);
1865
+ }
1866
+ /** One stored group with its direct members and the groups it is inside of
1867
+ * (`GET /v1/_groups/{name}`). A config-only group is not stored and raises
1868
+ * NotFoundError; {@link LoomcycleClient.getGroupEffective} covers it. */
1869
+ async getGroup(name, opts) {
1870
+ return (0, fetch_helpers_js_1.jsonFetch)(this.ctx, this.aclPath(`/v1/_groups/${encodeURIComponent(name)}`, opts?.tenant), opts);
1871
+ }
1872
+ /** Every user the group holds, through nested groups at any depth and
1873
+ * config-declared members included (`GET /v1/_groups/{name}/effective`). */
1874
+ async getGroupEffective(name, opts) {
1875
+ return (0, fetch_helpers_js_1.jsonFetch)(this.ctx, this.aclPath(`/v1/_groups/${encodeURIComponent(name)}/effective`, opts?.tenant), opts);
1876
+ }
1877
+ /** Create a group, or update its description and `external_id` if the name
1878
+ * exists (`POST /v1/_groups`). A group with an `external_id` is owned by an
1879
+ * identity provider: a later write to it raises a 409 LoomcycleError with
1880
+ * code `group_external`. */
1881
+ async createGroup(body, opts) {
1882
+ return (0, fetch_helpers_js_1.postJSON)(this.ctx, this.aclPath("/v1/_groups", opts?.tenant), body, opts);
1883
+ }
1884
+ /** Delete a group and every membership edge naming it
1885
+ * (`DELETE /v1/_groups/{name}`). Bindings to it stay and apply to no one.
1886
+ * NotFoundError when there is no such group. */
1887
+ async deleteGroup(name, opts) {
1888
+ return (0, fetch_helpers_js_1.deleteJSON)(this.ctx, this.aclPath(`/v1/_groups/${encodeURIComponent(name)}`, opts?.tenant), opts);
1889
+ }
1890
+ /** Add a user or a group to a group (`POST /v1/_groups/{name}/members`). A
1891
+ * group member nests: its members are in this group too. The server
1892
+ * refuses an edge that would close a cycle or nest too deep (400), one
1893
+ * that exists (409 `group_member_exists`), and a delegated writer adding
1894
+ * itself (403 `acl_self_elevation`). */
1895
+ async addGroupMember(name, member, opts) {
1896
+ return (0, fetch_helpers_js_1.postJSON)(this.ctx, this.aclPath(`/v1/_groups/${encodeURIComponent(name)}/members`, opts?.tenant), { kind: member.kind, id: member.id }, opts);
1897
+ }
1898
+ /** Remove one membership edge
1899
+ * (`DELETE /v1/_groups/{name}/members/{kind}/{id}`). NotFoundError when
1900
+ * there is no such edge. */
1901
+ async removeGroupMember(name, kind, id, opts) {
1902
+ const path = `/v1/_groups/${encodeURIComponent(name)}/members/${encodeURIComponent(kind)}/${encodeURIComponent(id)}`;
1903
+ return (0, fetch_helpers_js_1.deleteJSON)(this.ctx, this.aclPath(path, opts?.tenant), opts);
1904
+ }
1905
+ /** The AclDef family, op-dispatched — the same ops the gRPC `AclDef` RPC
1906
+ * and the MCP `acldef` tool take. Every op but `names` posts to
1907
+ * `POST /v1/_acldef`; `names` reads `GET /v1/_acldef/names`, which is
1908
+ * where that op lives on HTTP. The response varies by op; the typed
1909
+ * helpers below return each op's shape. */
1910
+ async aclDef(input, opts) {
1911
+ if (input.op === "names")
1912
+ return this.listAclDefNames(opts);
1913
+ return (0, fetch_helpers_js_1.postJSON)(this.ctx, this.aclPath("/v1/_acldef", opts?.tenant), input, opts);
1914
+ }
1915
+ /** Every rule list of the tenant, stored and config-declared, plus the
1916
+ * built-in baselines (`aclDef` op `list`). Retired lists only with
1917
+ * `includeRetired`. */
1918
+ async listAclDefs(opts) {
1919
+ const input = { op: "list" };
1920
+ if (opts?.includeRetired)
1921
+ input.include_retired = true;
1922
+ return (await this.aclDef(input, opts));
1923
+ }
1924
+ /** One rule list, retired or not (`aclDef` op `get`). A config-declared
1925
+ * list is returned with `source: "config"`. NotFoundError when absent. */
1926
+ async getAclDef(name, opts) {
1927
+ return (await this.aclDef({ op: "get", name }, opts));
1928
+ }
1929
+ /** Create or replace a rule list by name (`aclDef` op `put`): bumps its
1930
+ * version and un-retires it. The rules are validated first; a list the
1931
+ * engine could not enforce is refused with 400. A delegated writer may
1932
+ * not rewrite a list bound to itself (403 `acl_self_elevation`). */
1933
+ async putAclDef(body, opts) {
1934
+ const input = { op: "put", name: body.name, rules: body.rules };
1935
+ if (body.description !== undefined)
1936
+ input.description = body.description;
1937
+ return (await this.aclDef(input, opts));
1938
+ }
1939
+ /** Retire a rule list (`aclDef` op `retire`). Bindings to it stay and match
1940
+ * nothing; a later `putAclDef` of the name brings it back. NotFoundError
1941
+ * when there is no live list of that name. */
1942
+ async retireAclDef(name, opts) {
1943
+ return (await this.aclDef({ op: "retire", name }, opts));
1944
+ }
1945
+ /** Check rules without storing them (`aclDef` op `validate`). Rules the
1946
+ * engine refuses raise InvalidArgumentError (code `acl_invalid`) naming
1947
+ * the fault. */
1948
+ async validateAclRules(rules, opts) {
1949
+ return (await this.aclDef({ op: "validate", rules }, opts));
1950
+ }
1951
+ /** The resource types, verbs and transports a rule may name (`aclDef` op
1952
+ * `catalog`). */
1953
+ async getAclCatalog(opts) {
1954
+ return (await this.aclDef({ op: "catalog" }, opts));
1955
+ }
1956
+ /** Every live rule-list name a binding in the tenant can reach, stored and
1957
+ * config-declared, sorted (`GET /v1/_acldef/names`). */
1958
+ async listAclDefNames(opts) {
1959
+ return (0, fetch_helpers_js_1.jsonFetch)(this.ctx, this.aclPath("/v1/_acldef/names", opts?.tenant), opts);
1960
+ }
1961
+ /** Every binding of the tenant, stored and config-declared
1962
+ * (`GET /v1/_aclbinding`). Retired ones only with `includeRetired`. */
1963
+ async listAclBindings(opts) {
1964
+ const extra = opts?.includeRetired ? { include_retired: "1" } : undefined;
1965
+ return (0, fetch_helpers_js_1.jsonFetch)(this.ctx, this.aclPath("/v1/_aclbinding", opts?.tenant, extra), opts);
1966
+ }
1967
+ /** Bind a rule list to a subject (`POST /v1/_aclbinding`). The list must
1968
+ * exist and `expires_at` must be in the future, or InvalidArgumentError
1969
+ * (code `acl_binding_invalid`). A delegated writer may not bind itself, a
1970
+ * group it is in, its token or its tenant (403 `acl_self_elevation`). An
1971
+ * empty `subject_id` / `expires_at` is left out of the request. */
1972
+ async createAclBinding(body, opts) {
1973
+ const out = { acl: body.acl, subject_kind: body.subject_kind };
1974
+ if (body.subject_id)
1975
+ out.subject_id = body.subject_id;
1976
+ if (body.expires_at)
1977
+ out.expires_at = body.expires_at;
1978
+ return (0, fetch_helpers_js_1.postJSON)(this.ctx, this.aclPath("/v1/_aclbinding", opts?.tenant), out, opts);
1979
+ }
1980
+ /** Retire a binding by its `binding_id` (`DELETE /v1/_aclbinding/{id}`).
1981
+ * NotFoundError when there is no live binding of that id. A config
1982
+ * binding has no id and cannot be retired here. */
1983
+ async deleteAclBinding(bindingId, opts) {
1984
+ return (0, fetch_helpers_js_1.deleteJSON)(this.ctx, this.aclPath(`/v1/_aclbinding/${encodeURIComponent(bindingId)}`, opts?.tenant), opts);
1985
+ }
1986
+ /** May the subject do this? (`POST /v1/_authz/evaluation`, AuthZEN 1.0).
1987
+ * Omit `subject` to ask about yourself. */
1988
+ async authzEvaluation(req, opts) {
1989
+ return (0, fetch_helpers_js_1.postJSON)(this.ctx, this.aclPath("/v1/_authz/evaluation", opts?.tenant), req, opts);
1990
+ }
1991
+ /** Up to 256 questions for one subject in one call
1992
+ * (`POST /v1/_authz/evaluations`); decisions come back in order. */
1993
+ async authzEvaluations(req, opts) {
1994
+ return (0, fetch_helpers_js_1.postJSON)(this.ctx, this.aclPath("/v1/_authz/evaluations", opts?.tenant), req, opts);
1995
+ }
1996
+ /** A decision with its working: every rule read for the resource's type
1997
+ * and why it did or did not match (`POST /v1/_authz/explain`). */
1998
+ async authzExplain(req, opts) {
1999
+ return (0, fetch_helpers_js_1.postJSON)(this.ctx, this.aclPath("/v1/_authz/explain", opts?.tenant), req, opts);
2000
+ }
2001
+ /** What a draft of lists and bindings would decide, beside what is decided
2002
+ * now (`POST /v1/_authz/simulate`). Nothing is written. */
2003
+ async authzSimulate(req, opts) {
2004
+ return (0, fetch_helpers_js_1.postJSON)(this.ctx, this.aclPath("/v1/_authz/simulate", opts?.tenant), req, opts);
2005
+ }
1834
2006
  // ---- Schedules admin (GET/POST /v1/_schedules/...) ----
1835
2007
  //
1836
2008
  // What the Web UI's schedules page drives: the merged list, one schedule's
@@ -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.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.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;
@@ -283,6 +283,34 @@ class InsufficientScopeError extends LoomcycleError {
283
283
  }
284
284
  }
285
285
  exports.InsufficientScopeError = InsufficientScopeError;
286
+ /** An HTTP 403 because an access-control list refused the call: body code
287
+ * `acl_denied`. The token's scopes would allow it, but a rule bound to the
288
+ * caller (to its subject, a group it is in, its token or its tenant) denies
289
+ * it, or, where the deployment enforces lists, no rule allows it.
290
+ * `reasons` names the rules that decided — empty when none did — so an
291
+ * operator can find the list to change with `authzExplain`. Not retryable:
292
+ * the same call is refused until a list or binding changes.
293
+ *
294
+ * Other access-control refusals (`acl_self_elevation`, `authz_subject`,
295
+ * `operator_plane`, …) stay a plain LoomcycleError with that `code`. */
296
+ class AclDeniedError extends LoomcycleError {
297
+ /** The verb asked, e.g. `read`, `author`, `manage`. Null when the body did
298
+ * not carry one. */
299
+ action;
300
+ /** The resource asked about, `type:name` (e.g. `def:acl/ops`). Null when
301
+ * the body did not carry one. */
302
+ resource;
303
+ /** The rules that decided, denies first; empty when no rule matched. */
304
+ reasons;
305
+ constructor(message, opts) {
306
+ super(message, opts);
307
+ this.name = "AclDeniedError";
308
+ this.action = opts?.action ?? null;
309
+ this.resource = opts?.resource ?? null;
310
+ this.reasons = opts?.reasons ?? [];
311
+ }
312
+ }
313
+ exports.AclDeniedError = AclDeniedError;
286
314
  /** Every HTTP 503. The status is shared between a condition that clears on
287
315
  * its own and features this deployment does not have
288
316
  * (`decision_not_configured`, `store_unavailable`, `vector_unsupported`, …),
@@ -169,6 +169,7 @@ async function deleteJSON(ctx, path, opts) {
169
169
  * 401 → AuthError
170
170
  * 403 + code "insufficient_scope" (or an older server's
171
171
  * WWW-Authenticate scope hint) → InsufficientScopeError
172
+ * 403 + code "acl_denied" → AclDeniedError (action, resource, reasons)
172
173
  * 403 (other) → LoomcycleError (base)
173
174
  * 404 + "snapshot" → SnapshotNotFoundError ────────┐
174
175
  * 404 + "session" → SessionNotFoundError │ All extend
@@ -176,6 +177,7 @@ async function deleteJSON(ctx, path, opts) {
176
177
  * 404 + (other) → NotFoundError (base) │ catch any 404
177
178
  * │ with one
178
179
  * │ instanceof.
180
+ * 409 + an access-control code (group_external, *_exists) → LoomcycleError
179
181
  * 409 + "already_pausing" / "already paused" → AlreadyPausingError
180
182
  * 409 + "not_paused" / "not paused" → NotPausedError
181
183
  * 409 + "session" → SessionBusyError
@@ -240,6 +242,15 @@ async function raiseFromResponse(resp) {
240
242
  requiredScope: stringField(bodyObject(bodyText)?.required_scope) ?? hinted,
241
243
  });
242
244
  }
245
+ if (opts.code === "acl_denied") {
246
+ const body = bodyObject(bodyText) ?? {};
247
+ throw new errors_js_1.AclDeniedError(msg, {
248
+ ...opts,
249
+ action: stringField(body.action),
250
+ resource: stringField(body.resource),
251
+ reasons: aclReasons(body.reasons),
252
+ });
253
+ }
243
254
  throw new errors_js_1.LoomcycleError(msg, opts);
244
255
  }
245
256
  case 404:
@@ -258,6 +269,11 @@ async function raiseFromResponse(resp) {
258
269
  throw new errors_js_1.AgentNotFoundError(msg, opts);
259
270
  throw new errors_js_1.NotFoundError(msg, opts);
260
271
  case 409:
272
+ // The access-control conflicts carry a code, and their messages quote
273
+ // caller-chosen names (a group called "session-ops"), which the
274
+ // keyword matches below would misread.
275
+ if (opts.code !== undefined && ACL_CONFLICT_CODES.has(opts.code))
276
+ throw new errors_js_1.LoomcycleError(msg, opts);
261
277
  if (bodyLower.includes("already_pausing") || bodyLower.includes("already paused"))
262
278
  throw new errors_js_1.AlreadyPausingError(msg, opts);
263
279
  if (bodyLower.includes("not_paused") || bodyLower.includes("not paused"))
@@ -349,6 +365,39 @@ async function raiseFromResponse(resp) {
349
365
  throw new errors_js_1.LoomcycleError(msg, opts);
350
366
  }
351
367
  }
368
+ /** The 409 codes of the access-control management routes: a group an
369
+ * identity provider owns, and a row or membership that already exists. */
370
+ const ACL_CONFLICT_CODES = new Set([
371
+ "group_external",
372
+ "group_exists",
373
+ "group_member_exists",
374
+ "acl_exists",
375
+ "acl_binding_exists",
376
+ ]);
377
+ /** aclReasons reads an `acl_denied` body's `reasons`, keeping only entries
378
+ * with the fields a reason always has: a proxy's or an older server's
379
+ * stray value must not surface as a rule. */
380
+ function aclReasons(v) {
381
+ if (!Array.isArray(v))
382
+ return [];
383
+ const out = [];
384
+ for (const r of v) {
385
+ if (r === null || typeof r !== "object")
386
+ continue;
387
+ const o = r;
388
+ const acl = stringField(o.acl);
389
+ const rule = numberField(o.rule);
390
+ const effect = o.effect;
391
+ if (acl === undefined || rule === undefined || (effect !== "allow" && effect !== "deny"))
392
+ continue;
393
+ const reason = { acl, rule, effect };
394
+ const version = numberField(o.version);
395
+ if (version !== undefined)
396
+ reason.version = version;
397
+ out.push(reason);
398
+ }
399
+ return out;
400
+ }
352
401
  /** retryAfterMs reads a `Retry-After` header given in seconds, as the server
353
402
  * always sends it, and returns milliseconds; undefined when the header is
354
403
  * absent or not a number (an HTTP-date form is not parsed). */
package/dist/cjs/index.js CHANGED
@@ -95,6 +95,15 @@
95
95
  * (opts?): Promise<DefNamesResponse>
96
96
  * listOperatorTokenDefNames(opts?): Promise<OperatorTokenDefNamesResponse> // admin token only
97
97
  *
98
+ * // Access control (RFC EY / EZ; tenant operator; opts.tenant = admin focus)
99
+ * listGroups / getGroup(name) / getGroupEffective(name) / createGroup(body) /
100
+ * deleteGroup(name) / addGroupMember(name, {kind, id}) / removeGroupMember(name, kind, id)
101
+ * aclDef(input): Promise<unknown> // op-dispatched; names → GET /v1/_acldef/names
102
+ * listAclDefs / getAclDef(name) / putAclDef(body) / retireAclDef(name) /
103
+ * validateAclRules(rules) / getAclCatalog() / listAclDefNames()
104
+ * listAclBindings / createAclBinding(body) / deleteAclBinding(bindingId)
105
+ * authzEvaluation(req) / authzEvaluations(req) / authzExplain(req) / authzSimulate(req) // any principal, about itself
106
+ *
98
107
  * // Schedules admin
99
108
  * listAllSchedules(opts?): Promise<ScheduleListResponse> // static yaml + ScheduleDefs, merged
100
109
  * getScheduleState(defId): Promise<ScheduleStateView>
@@ -159,7 +168,9 @@
159
168
  * SnapshotTooLargeError, RequestTooLargeError + PromptTooLargeError
160
169
  * (both subclasses of SnapshotTooLargeError), TokenLimitExceededError
161
170
  * (NOT a BackpressureError: a hard budget, not retryable),
162
- * SnapshotVersionError, SubstrateToolRefusedError (v0.8.22).
171
+ * SnapshotVersionError, SubstrateToolRefusedError (v0.8.22),
172
+ * AclDeniedError (a 403 an access-control rule decided; action,
173
+ * resource, reasons).
163
174
  * Every one carries `retryable` (true only for a refusal that clears
164
175
  * on its own).
165
176
  *
@@ -171,7 +182,7 @@
171
182
  * See `adapters/ts/README.md` for usage examples.
172
183
  */
173
184
  Object.defineProperty(exports, "__esModule", { value: true });
174
- 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.CLIENT_TOOL_SUBPROTOCOL = exports.clientToolsURL = exports.ClientToolHost = exports.verifyLoomcycleDelivery = exports.verifyLoomcycleSignature = exports.InteractiveSession = exports.LoomcycleClient = void 0;
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;
175
186
  var client_js_1 = require("./client.js");
176
187
  Object.defineProperty(exports, "LoomcycleClient", { enumerable: true, get: function () { return client_js_1.LoomcycleClient; } });
177
188
  var interactive_js_1 = require("./interactive.js");
@@ -184,6 +195,7 @@ Object.defineProperty(exports, "ClientToolHost", { enumerable: true, get: functi
184
195
  Object.defineProperty(exports, "clientToolsURL", { enumerable: true, get: function () { return client_tools_js_1.clientToolsURL; } });
185
196
  Object.defineProperty(exports, "CLIENT_TOOL_SUBPROTOCOL", { enumerable: true, get: function () { return client_tools_js_1.CLIENT_TOOL_SUBPROTOCOL; } });
186
197
  var errors_js_1 = require("./errors.js");
198
+ Object.defineProperty(exports, "AclDeniedError", { enumerable: true, get: function () { return errors_js_1.AclDeniedError; } });
187
199
  Object.defineProperty(exports, "AgentIDInUseError", { enumerable: true, get: function () { return errors_js_1.AgentIDInUseError; } });
188
200
  Object.defineProperty(exports, "AgentNotFoundError", { enumerable: true, get: function () { return errors_js_1.AgentNotFoundError; } });
189
201
  Object.defineProperty(exports, "AlreadyPausingError", { enumerable: true, get: function () { return errors_js_1.AlreadyPausingError; } });