@loomcycle/client 1.121.0 → 1.123.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` |
@@ -1265,6 +1265,16 @@ class LoomcycleClient {
1265
1265
  // the server rejects a malformed one rather than silently ignoring it.
1266
1266
  if (input.when !== undefined)
1267
1267
  body.when = input.when;
1268
+ // The document filters: sent only when set, so a filter-free search's
1269
+ // response stays byte-identical (no `filter` echo).
1270
+ if (input.underPaths !== undefined)
1271
+ body.under_paths = input.underPaths;
1272
+ if (input.excludeUnderPaths !== undefined)
1273
+ body.exclude_under_paths = input.excludeUnderPaths;
1274
+ if (input.documentIds !== undefined)
1275
+ body.document_ids = input.documentIds;
1276
+ if (input.excludeDocumentIds !== undefined)
1277
+ body.exclude_document_ids = input.excludeDocumentIds;
1268
1278
  return (0, fetch_helpers_js_1.postJSON)(this.ctx, "/v1/_memory/search", body, {
1269
1279
  signal: opts?.signal,
1270
1280
  });
@@ -1499,6 +1509,8 @@ class LoomcycleClient {
1499
1509
  };
1500
1510
  if (opts.headers)
1501
1511
  overlay.headers = opts.headers;
1512
+ if (opts.binaryResults)
1513
+ overlay.binary_results = opts.binaryResults;
1502
1514
  const createInput = { op: "create", name: opts.name, overlay };
1503
1515
  if (opts.description)
1504
1516
  createInput.description = opts.description;
@@ -1824,13 +1836,183 @@ class LoomcycleClient {
1824
1836
  async listDocumentSourceDefNames(opts) {
1825
1837
  return this.listDefNames("documentsourcedef", opts);
1826
1838
  }
1827
- /** Every OperatorTokenDef name (`GET /v1/_operatortokendef/names`), with its
1828
- * subject and token counts — never a token. **Admin token only**: token
1839
+ /** Every OperatorTokenDef name (`GET /v1/_operatortokendef/names`), one row
1840
+ * per (tenant, name), with its subject, token counts and current def_id —
1841
+ * never a token. **Admin token only**: token
1829
1842
  * minting stays operator-only, so a `substrate:tenant` token is refused
1830
1843
  * with 403. */
1831
1844
  async listOperatorTokenDefNames(opts) {
1832
1845
  return this.listDefNames("operatortokendef", opts);
1833
1846
  }
1847
+ // ---- Access control: groups, rule lists, bindings, decisions (RFC EY / EZ) ----
1848
+ //
1849
+ // Groups, lists and bindings are the tenant operator's: a `substrate:tenant`
1850
+ // token, an admin, or the legacy shared token. Any other principal is
1851
+ // refused with 403 (InsufficientScopeError). Every call acts in the caller's
1852
+ // own tenant; `tenant` is an admin's focus on another one and is ignored for
1853
+ // a tenant operator. Rows the config declares are listed beside the stored
1854
+ // ones with `source: "config"`; they cannot be written here. Every write
1855
+ // takes effect on every replica at once. A rule that refuses a call throws
1856
+ // AclDeniedError (403 `acl_denied`) naming the rules that decided.
1857
+ //
1858
+ // The decision questions (authzEvaluation / authzEvaluations / authzExplain
1859
+ // / authzSimulate) are open to any principal about itself; naming another
1860
+ // subject of the tenant needs a tenant operator (403 `authz_subject`).
1861
+ // Every call here needs a persistent store (503 otherwise) and is refused
1862
+ // from inside a run.
1863
+ /** `path` with the admin tenant focus and any extra query parameters. */
1864
+ aclPath(path, tenant, extra) {
1865
+ const q = new URLSearchParams(extra);
1866
+ if (tenant)
1867
+ q.set("tenant", tenant);
1868
+ const qs = q.toString();
1869
+ return qs ? `${path}?${qs}` : path;
1870
+ }
1871
+ /** Every group of the tenant, stored and config-declared, sorted by name
1872
+ * (`GET /v1/_groups`). */
1873
+ async listGroups(opts) {
1874
+ return (0, fetch_helpers_js_1.jsonFetch)(this.ctx, this.aclPath("/v1/_groups", opts?.tenant), opts);
1875
+ }
1876
+ /** One stored group with its direct members and the groups it is inside of
1877
+ * (`GET /v1/_groups/{name}`). A config-only group is not stored and raises
1878
+ * NotFoundError; {@link LoomcycleClient.getGroupEffective} covers it. */
1879
+ async getGroup(name, opts) {
1880
+ return (0, fetch_helpers_js_1.jsonFetch)(this.ctx, this.aclPath(`/v1/_groups/${encodeURIComponent(name)}`, opts?.tenant), opts);
1881
+ }
1882
+ /** Every user the group holds, through nested groups at any depth and
1883
+ * config-declared members included (`GET /v1/_groups/{name}/effective`). */
1884
+ async getGroupEffective(name, opts) {
1885
+ return (0, fetch_helpers_js_1.jsonFetch)(this.ctx, this.aclPath(`/v1/_groups/${encodeURIComponent(name)}/effective`, opts?.tenant), opts);
1886
+ }
1887
+ /** Create a group, or update its description and `external_id` if the name
1888
+ * exists (`POST /v1/_groups`). A group with an `external_id` is owned by an
1889
+ * identity provider: a later write to it raises a 409 LoomcycleError with
1890
+ * code `group_external`. */
1891
+ async createGroup(body, opts) {
1892
+ return (0, fetch_helpers_js_1.postJSON)(this.ctx, this.aclPath("/v1/_groups", opts?.tenant), body, opts);
1893
+ }
1894
+ /** Delete a group and every membership edge naming it
1895
+ * (`DELETE /v1/_groups/{name}`). Bindings to it stay and apply to no one.
1896
+ * NotFoundError when there is no such group. */
1897
+ async deleteGroup(name, opts) {
1898
+ return (0, fetch_helpers_js_1.deleteJSON)(this.ctx, this.aclPath(`/v1/_groups/${encodeURIComponent(name)}`, opts?.tenant), opts);
1899
+ }
1900
+ /** Add a user or a group to a group (`POST /v1/_groups/{name}/members`). A
1901
+ * group member nests: its members are in this group too. The server
1902
+ * refuses an edge that would close a cycle or nest too deep (400), one
1903
+ * that exists (409 `group_member_exists`), and a delegated writer adding
1904
+ * itself (403 `acl_self_elevation`). */
1905
+ async addGroupMember(name, member, opts) {
1906
+ 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);
1907
+ }
1908
+ /** Remove one membership edge
1909
+ * (`DELETE /v1/_groups/{name}/members/{kind}/{id}`). NotFoundError when
1910
+ * there is no such edge. */
1911
+ async removeGroupMember(name, kind, id, opts) {
1912
+ const path = `/v1/_groups/${encodeURIComponent(name)}/members/${encodeURIComponent(kind)}/${encodeURIComponent(id)}`;
1913
+ return (0, fetch_helpers_js_1.deleteJSON)(this.ctx, this.aclPath(path, opts?.tenant), opts);
1914
+ }
1915
+ /** The AclDef family, op-dispatched — the same ops the gRPC `AclDef` RPC
1916
+ * and the MCP `acldef` tool take. Every op but `names` posts to
1917
+ * `POST /v1/_acldef`; `names` reads `GET /v1/_acldef/names`, which is
1918
+ * where that op lives on HTTP. The response varies by op; the typed
1919
+ * helpers below return each op's shape. */
1920
+ async aclDef(input, opts) {
1921
+ if (input.op === "names")
1922
+ return this.listAclDefNames(opts);
1923
+ return (0, fetch_helpers_js_1.postJSON)(this.ctx, this.aclPath("/v1/_acldef", opts?.tenant), input, opts);
1924
+ }
1925
+ /** Every rule list of the tenant, stored and config-declared, plus the
1926
+ * built-in baselines (`aclDef` op `list`). Retired lists only with
1927
+ * `includeRetired`. */
1928
+ async listAclDefs(opts) {
1929
+ const input = { op: "list" };
1930
+ if (opts?.includeRetired)
1931
+ input.include_retired = true;
1932
+ return (await this.aclDef(input, opts));
1933
+ }
1934
+ /** One rule list, retired or not (`aclDef` op `get`). A config-declared
1935
+ * list is returned with `source: "config"`. NotFoundError when absent. */
1936
+ async getAclDef(name, opts) {
1937
+ return (await this.aclDef({ op: "get", name }, opts));
1938
+ }
1939
+ /** Create or replace a rule list by name (`aclDef` op `put`): bumps its
1940
+ * version and un-retires it. The rules are validated first; a list the
1941
+ * engine could not enforce is refused with 400. A delegated writer may
1942
+ * not rewrite a list bound to itself (403 `acl_self_elevation`). */
1943
+ async putAclDef(body, opts) {
1944
+ const input = { op: "put", name: body.name, rules: body.rules };
1945
+ if (body.description !== undefined)
1946
+ input.description = body.description;
1947
+ return (await this.aclDef(input, opts));
1948
+ }
1949
+ /** Retire a rule list (`aclDef` op `retire`). Bindings to it stay and match
1950
+ * nothing; a later `putAclDef` of the name brings it back. NotFoundError
1951
+ * when there is no live list of that name. */
1952
+ async retireAclDef(name, opts) {
1953
+ return (await this.aclDef({ op: "retire", name }, opts));
1954
+ }
1955
+ /** Check rules without storing them (`aclDef` op `validate`). Rules the
1956
+ * engine refuses raise InvalidArgumentError (code `acl_invalid`) naming
1957
+ * the fault. */
1958
+ async validateAclRules(rules, opts) {
1959
+ return (await this.aclDef({ op: "validate", rules }, opts));
1960
+ }
1961
+ /** The resource types, verbs and transports a rule may name (`aclDef` op
1962
+ * `catalog`). */
1963
+ async getAclCatalog(opts) {
1964
+ return (await this.aclDef({ op: "catalog" }, opts));
1965
+ }
1966
+ /** Every live rule-list name a binding in the tenant can reach, stored and
1967
+ * config-declared, sorted (`GET /v1/_acldef/names`). */
1968
+ async listAclDefNames(opts) {
1969
+ return (0, fetch_helpers_js_1.jsonFetch)(this.ctx, this.aclPath("/v1/_acldef/names", opts?.tenant), opts);
1970
+ }
1971
+ /** Every binding of the tenant, stored and config-declared
1972
+ * (`GET /v1/_aclbinding`). Retired ones only with `includeRetired`. */
1973
+ async listAclBindings(opts) {
1974
+ const extra = opts?.includeRetired ? { include_retired: "1" } : undefined;
1975
+ return (0, fetch_helpers_js_1.jsonFetch)(this.ctx, this.aclPath("/v1/_aclbinding", opts?.tenant, extra), opts);
1976
+ }
1977
+ /** Bind a rule list to a subject (`POST /v1/_aclbinding`). The list must
1978
+ * exist and `expires_at` must be in the future, or InvalidArgumentError
1979
+ * (code `acl_binding_invalid`). A delegated writer may not bind itself, a
1980
+ * group it is in, its token or its tenant (403 `acl_self_elevation`). An
1981
+ * empty `subject_id` / `expires_at` is left out of the request. */
1982
+ async createAclBinding(body, opts) {
1983
+ const out = { acl: body.acl, subject_kind: body.subject_kind };
1984
+ if (body.subject_id)
1985
+ out.subject_id = body.subject_id;
1986
+ if (body.expires_at)
1987
+ out.expires_at = body.expires_at;
1988
+ return (0, fetch_helpers_js_1.postJSON)(this.ctx, this.aclPath("/v1/_aclbinding", opts?.tenant), out, opts);
1989
+ }
1990
+ /** Retire a binding by its `binding_id` (`DELETE /v1/_aclbinding/{id}`).
1991
+ * NotFoundError when there is no live binding of that id. A config
1992
+ * binding has no id and cannot be retired here. */
1993
+ async deleteAclBinding(bindingId, opts) {
1994
+ return (0, fetch_helpers_js_1.deleteJSON)(this.ctx, this.aclPath(`/v1/_aclbinding/${encodeURIComponent(bindingId)}`, opts?.tenant), opts);
1995
+ }
1996
+ /** May the subject do this? (`POST /v1/_authz/evaluation`, AuthZEN 1.0).
1997
+ * Omit `subject` to ask about yourself. */
1998
+ async authzEvaluation(req, opts) {
1999
+ return (0, fetch_helpers_js_1.postJSON)(this.ctx, this.aclPath("/v1/_authz/evaluation", opts?.tenant), req, opts);
2000
+ }
2001
+ /** Up to 256 questions for one subject in one call
2002
+ * (`POST /v1/_authz/evaluations`); decisions come back in order. */
2003
+ async authzEvaluations(req, opts) {
2004
+ return (0, fetch_helpers_js_1.postJSON)(this.ctx, this.aclPath("/v1/_authz/evaluations", opts?.tenant), req, opts);
2005
+ }
2006
+ /** A decision with its working: every rule read for the resource's type
2007
+ * and why it did or did not match (`POST /v1/_authz/explain`). */
2008
+ async authzExplain(req, opts) {
2009
+ return (0, fetch_helpers_js_1.postJSON)(this.ctx, this.aclPath("/v1/_authz/explain", opts?.tenant), req, opts);
2010
+ }
2011
+ /** What a draft of lists and bindings would decide, beside what is decided
2012
+ * now (`POST /v1/_authz/simulate`). Nothing is written. */
2013
+ async authzSimulate(req, opts) {
2014
+ return (0, fetch_helpers_js_1.postJSON)(this.ctx, this.aclPath("/v1/_authz/simulate", opts?.tenant), req, opts);
2015
+ }
1834
2016
  // ---- Schedules admin (GET/POST /v1/_schedules/...) ----
1835
2017
  //
1836
2018
  // 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; } });