@uluops/ops-sdk 6.1.0 → 6.4.1
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/CHANGELOG.md +93 -1
- package/README.md +231 -2
- package/dist/client.d.ts +155 -81
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +141 -67
- package/dist/client.js.map +1 -1
- package/dist/config/constants.d.ts +3 -1
- package/dist/config/constants.d.ts.map +1 -1
- package/dist/config/constants.js +2 -0
- package/dist/config/constants.js.map +1 -1
- package/dist/config/generated-version.d.ts +1 -1
- package/dist/config/generated-version.js +1 -1
- package/dist/config/index.d.ts +1 -0
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +1 -0
- package/dist/config/index.js.map +1 -1
- package/dist/config/validators.d.ts +25 -1
- package/dist/config/validators.d.ts.map +1 -1
- package/dist/config/validators.js +31 -1
- package/dist/config/validators.js.map +1 -1
- package/dist/config/workspace-org.d.ts +77 -0
- package/dist/config/workspace-org.d.ts.map +1 -0
- package/dist/config/workspace-org.js +182 -0
- package/dist/config/workspace-org.js.map +1 -0
- package/dist/errors/errors.d.ts +118 -0
- package/dist/errors/errors.d.ts.map +1 -1
- package/dist/errors/errors.js +138 -0
- package/dist/errors/errors.js.map +1 -1
- package/dist/http/http-client.d.ts +46 -0
- package/dist/http/http-client.d.ts.map +1 -1
- package/dist/http/http-client.js +88 -5
- package/dist/http/http-client.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/operations/admin.d.ts +100 -0
- package/dist/operations/admin.d.ts.map +1 -0
- package/dist/operations/admin.js +130 -0
- package/dist/operations/admin.js.map +1 -0
- package/dist/operations/auth.d.ts +21 -1
- package/dist/operations/auth.d.ts.map +1 -1
- package/dist/operations/auth.js +49 -3
- package/dist/operations/auth.js.map +1 -1
- package/dist/operations/orgs.d.ts +27 -0
- package/dist/operations/orgs.d.ts.map +1 -0
- package/dist/operations/orgs.js +34 -0
- package/dist/operations/orgs.js.map +1 -0
- package/dist/operations/projects.d.ts +46 -0
- package/dist/operations/projects.d.ts.map +1 -1
- package/dist/operations/projects.js +54 -1
- package/dist/operations/projects.js.map +1 -1
- package/dist/types/auth.d.ts +10 -0
- package/dist/types/auth.d.ts.map +1 -1
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +2 -0
- package/dist/types/index.js.map +1 -1
- package/dist/types/org.d.ts +25 -0
- package/dist/types/org.d.ts.map +1 -0
- package/dist/types/org.js +2 -0
- package/dist/types/org.js.map +1 -0
- package/dist/types/rehome.d.ts +234 -0
- package/dist/types/rehome.d.ts.map +1 -0
- package/dist/types/rehome.js +171 -0
- package/dist/types/rehome.js.map +1 -0
- package/dist/types/response-schemas.d.ts +19 -0
- package/dist/types/response-schemas.d.ts.map +1 -1
- package/dist/types/response-schemas.js +19 -0
- package/dist/types/response-schemas.js.map +1 -1
- package/dist/types/schemas.d.ts +20 -0
- package/dist/types/schemas.d.ts.map +1 -1
- package/dist/types/schemas.js +22 -0
- package/dist/types/schemas.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,96 @@ All notable changes to `@uluops/ops-sdk` will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
6
6
|
|
|
7
|
+
## [6.4.1] - 2026-09-15
|
|
8
|
+
|
|
9
|
+
6.4.0 reached Verdaccio only; the review of that change set (anxiety-reader 84 / docs-validator 81 / dx-validator 83 / code-auditor 98, all on the `feat/rehome-surfaces` diff) folded into this release before npm. What changed against 6.4.0:
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **`login(email, password, { autoRefresh })`.** The default (`true`) is unchanged; `false` installs the session without the password so a 401 surfaces untouched. Why it exists: sdk-core's refresh is a *fresh login*, and under the API's default single-session policy a login **revokes the user's other sessions**; the mutation that hit the 401 is then *not* retried, and the budget is one. A script that must treat 401 as "stop" (spec §4.7) was having the SDK act first. Tested both ways: no `/auth/login` on a 401 with the option, exactly one without.
|
|
14
|
+
- **`isInsufficientRoleError`** — the fifth 403 code gains the guard every other one has.
|
|
15
|
+
- `admin.rehomeProject` / `admin.releaseProjectRehome` **refuse a non-UUID client-side** (`InputValidationError`) — `validateUuid` existed and was dead code; the admin route is id-only and answers 400 to a name, and the whole `projects.*` surface trains callers to pass names.
|
|
16
|
+
- TSDoc `@param`/`@returns`/`@example` on the new operations and validators; `@throws {MfaRequiredError}` on `login`.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- **`admin.releaseProjectRehome` shape failure is a `ZodError`**, like every other shape failure in this SDK, not a bare `Error` — and the doc says what it means: the server answers 200 after the row is gone, so a shape failure is most likely a *completed* release.
|
|
21
|
+
- The admin path's missing-`reason` message says why it is required ("the target org's only standing for the move") instead of Zod's "expected nonoptional".
|
|
22
|
+
- `OrgAuditFeedQuery.limit` is documented as **1–100, not clamped** (the API answers 400) — the JSDoc said "1–200 (API-clamped)", which was false on both counts.
|
|
23
|
+
|
|
24
|
+
### Fixed — documentation that overclaimed
|
|
25
|
+
|
|
26
|
+
- The 6.4.0 entry said "an MFA-enrolled account could not log in through the SDK … Fixed". True for `login()`/`auth.login()` only: a client constructed with `{ email, password }` (or autoloaded credentials) logs in inside sdk-core, which cannot see the challenge and surfaces a generic `UnauthorizedError`. Documented on `OpsClientConfig`, `MfaRequiredError` and in the README; not fixable from this package without intercepting sdk-core's strategy.
|
|
27
|
+
- The MFA challenge token is **single-use and consumed before the code is verified** (API `totp-service.verifyLogin`) — a mistyped code burns it. The README example read as retryable; it no longer does.
|
|
28
|
+
- **`same_org` is the idempotence signal of the ADMIN path only.** The member path's lookup is source-scoped, so a re-run after the move answers 404, not `same_org`. `projects.rehome`'s doc, the README table and `rehomeRefusalReason`'s doc now say so, and the table gained the two missing reasons (`project_soft_deleted`, `project_has_no_org`) plus a row for *no HTTP answer* (`null` from `rehomeRefusalReason` is "not a refusal I can name", not "safe to retry").
|
|
29
|
+
- README "automatic token refresh" is now qualified with the three properties above.
|
|
30
|
+
|
|
31
|
+
### Known, not fixed here
|
|
32
|
+
|
|
33
|
+
- sdk-core's 401 branch inspects the *client's* auth strategy, not the request's `skipAuth`, so a wrong TOTP code on a bare client says "Set ULUOPS_API_KEY" — the one remedy D20 forbids. Filed against sdk-core.
|
|
34
|
+
- A TOTP-installed session's expiry message ("credentials were cleared after login") is sdk-core's and wrong for this path; `JwtSessionAuth` has no way to be handed `expiresAt`.
|
|
35
|
+
|
|
36
|
+
## [6.4.0] - 2026-09-15 (Verdaccio only)
|
|
37
|
+
|
|
38
|
+
### Added
|
|
39
|
+
|
|
40
|
+
The client side of project re-home (project-org-routing-and-rehome spec §4; API deployed 2026-09-15). Three surfaces for three audiences — a member capability, a member feed, and a platform-admin namespace — plus the login branch the admin path made unavoidable.
|
|
41
|
+
|
|
42
|
+
- **`projects.rehome(idOrName, { targetOrg, reason? }, options?)`** — the member path (`POST /projects/:id/rehome`). The SOURCE is the call's org scope (`options.org` / client `orgSlug`); the project is looked up there, so a work-org project moved without scope is a 404, not a silent personal-org lookup. Returns `RehomeResponse`: the project after the move plus `rehome.{from_org, to_org, audit_ids}` (snake_case by contract; `audit_ids` is `[]` today because the platform ACL returns no ids — the ledger is the durable record). Body is built as the API's `.strict()` schema wants it: `reason` is omitted, never sent as null.
|
|
43
|
+
- **`rehomeRefusalReason(err)`** and `REHOME_REFUSAL_REASONS` — reads `details.reason` on the 400/409 refusals (`same_org`, `moved_during_request`, `deadlock_retry`, `concurrent_modification`, `name_collision`, `soft_deleted_conflict`, `rehomed_away_conflict`, `export_in_progress`, `project_soft_deleted`, `project_has_no_org`) and returns `null` for anything else, an unknown reason included. Enumerated because the Phase 4 runbook (spec §4.7) assigns each a disposition and a script that types the strings itself will misspell one.
|
|
44
|
+
- **`orgs.getVisibleAuditLog(slug, { cursor?, limit? })`** — the D19 feed (`GET /orgs/:slug/audit-log/global`): rows a writer marked `visibility: 'org'`, readable by any member; today, projects leaving the org for a personal org. New `orgs` namespace (reads only). **`readRehomeAuditDetails(entry)`** narrows an entry's `details` to the re-home writer's shape (`RehomeAuditDetails`) or `null`, so a renderer can print a move in one line and everything else raw.
|
|
45
|
+
- **`admin.*` namespace** — `rehomeProject(projectId, { targetOrg, reason })` (the D8 platform path: any project, any orgs, by UUID, `reason` REQUIRED), `listProjectRehomes(query?)` (the reservation/redirect table), `listProjectRehomeEvents(query?)` (the D21 append-only ledger, paged by `seq`; unknown event kinds parse verbatim), `releaseProjectRehome(rehomeId)`. All four need the PLATFORM admin role (`403 INSUFFICIENT_ROLE` otherwise); the two writes are session-only (D20). This is the Phase 4 migration script's substrate; no MCP tool wraps it, on purpose.
|
|
46
|
+
- **`SESSION_REQUIRED`** / **`isSessionRequiredError`** and **`INSUFFICIENT_ROLE`** — the two 403 codes the admin path adds to the org-routing set.
|
|
47
|
+
- **MFA login.** `login()` / `auth.login()` now throw **`MfaRequiredError`** (`mfaChallengeToken`, `expiresAt`, `mfaMethods`; guard `isMfaRequiredError`) when `POST /auth/login` answers the challenge shape, and **`OpsClient.loginWithTotp(challengeToken, code)`** / `auth.totpLogin()` complete it (`POST /auth/totp/login`) and install the session. Not a nice-to-have: D20 makes the admin re-home route session-only and the operator account may be MFA-enrolled, and the SDK could not log such an account in at all — see Fixed. A TOTP-installed session carries no password, so it is NOT auto-refreshed; on expiry requests fail 401 and the caller logs in again (a migration script treats that as "stop", never "retry with the key").
|
|
48
|
+
- `ProjectResponseSchema` gains **`orgId`** (nullable, optional). The API has emitted it on every project read since org scoping landed; `z.object()` was stripping it, so a re-homed project's new org was invisible through the SDK.
|
|
49
|
+
|
|
50
|
+
### Fixed
|
|
51
|
+
|
|
52
|
+
- **An MFA-enrolled account could not log in through the SDK.** `LoginResponseSchema` required `sessionToken`, and the API's `200 { mfa_required: true, mfa_challenge_token, ... }` challenge body therefore surfaced as a raw `ZodError` on `sessionToken` — a parse failure on a successful request, with the challenge token discarded. The challenge is now parsed first (narrower literal) and thrown as `MfaRequiredError`; a session body still parses (tested as the control).
|
|
53
|
+
|
|
54
|
+
### Notes
|
|
55
|
+
|
|
56
|
+
- Live-verified 2026-09-15 against the current API build on a prod-copy database, not only against nock: member round trip personal → team → personal, `410 PROJECT_REHOMED` at the vacated address on a write (reads there 404 — D14 is a write-path check), `same_org`, the source-scope 404, C6 (owner moving into their own personal org), annihilation on reversal, the D19 feed on both orgs with a non-member `ORG_ACCESS_DENIED` control, and the key→session login ladder. The admin ledger schemas were checked against the controller and nock only — the smoke user was not a platform admin (`INSUFFICIENT_ROLE` on all four, which also fixes the middleware order: platform role before the session gate).
|
|
57
|
+
- Spec §4.1 says the response carries `orgSlug`; the API emits `orgId` and the slug inside `rehome.to_org`. The schema follows the wire; the spec changelog records the drift.
|
|
58
|
+
|
|
59
|
+
## [6.3.1] - 2026-09-13
|
|
60
|
+
|
|
61
|
+
### Security
|
|
62
|
+
|
|
63
|
+
Security audit of the org-routing train (ops-uluops-api run #187, 2026-09-13). None of these was a bypass; each is a channel by which the wrong org could be chosen silently.
|
|
64
|
+
|
|
65
|
+
- **`resolveWorkspaceOrg` walk is bounded by the home directory** when `cwd` is under it: a `.uluops.json` at `/`, `/Users` or a container root can no longer become the default for every checkout beneath it (the single-default leak D13 exists to remove, one layer down). Outside home the walk still reaches the root. New `home` option (default `os.homedir()`), injectable for tests.
|
|
66
|
+
- **A workspace file owned by another user is refused**, not skipped — the planting vector is a shared parent directory. New `uid` option (default `process.getuid()`; `undefined` disables the check on platforms without uids).
|
|
67
|
+
- **The workspace file's key check is an allowlist** (`org`, `$schema`). It was a ten-name denylist while the error said "may carry only org"; `baseURL`, `apikey`, `token` passed. Inert (only `org` was read), but the stated invariant is now the enforced one.
|
|
68
|
+
- **`"personal"` is honoured as an explicit value** (`resolveWorkspaceOrg({ explicit: 'personal' })`, `withOrg('personal')`): no header, `source: 'explicit'`. It used to go to the wire as a slug (404 `ORG_NOT_FOUND`, or a real org if anyone registers the name). `withOrg('personal')` does not cancel a constructor `orgSlug`.
|
|
69
|
+
- **Per-call org header is set last, and an org header in `options.headers` is refused** on a scoped view. The spread order let a caller header win, and `X-Org-Id` outranks `X-Org-Slug` server-side — no live caller did this; the invariant held by luck.
|
|
70
|
+
|
|
71
|
+
## [6.3.0] - 2026-09-13
|
|
72
|
+
|
|
73
|
+
### Added
|
|
74
|
+
|
|
75
|
+
- **`resolveWorkspaceOrg()` — the D13 workspace default** (project-org-routing-and-rehome spec D13, §2.0). Walks upward from `cwd` to the nearest `.uluops.json` and honours only its `org`; the reserved value `"personal"` stops the walk and means "no org" (a personal repo cloned under a work tree carries it so the outer file does not win). Precedence: `explicit` (a `--org` flag, an MCP tool's `org` argument) > nearest workspace file > `ULUOPS_ORG_SLUG` (kept as the lowest fall-through for headless environments) > personal. Returns `{ org, source, path }` so a caller can print *why* a call landed where it did.
|
|
76
|
+
|
|
77
|
+
**The file may carry only `org`.** `apiKey`, `baseUrl`, `profile`, `credentials`, `sessionToken`, `email`, `password` are refused with `InputValidationError`, not ignored — a `.env` in cwd once retargeted the CLI's base URL, and the one guard that keeps that footgun from transferring is that this file cannot name a target or an identity. Malformed JSON and invalid slugs are refused loudly for the same reason: a silently ignored file is a silently wrong org. The slug is server-relative, so "where it landed" output should print the base URL beside the org.
|
|
78
|
+
|
|
79
|
+
Lives here, not in each consumer: the CLI and the tracker MCP both need it, and two copies of a walk-up function in two repos are the drift class the spec exists to remove. `findWorkspaceOrgFile` and `readWorkspaceOrgFile` are exported for tooling that wants to explain the answer. `ENV_VARS.ORG_SLUG` added. Tests build their own temp trees and pass `stopAt` so a real `.uluops.json` above the temp dir cannot leak in — this workspace will carry one.
|
|
80
|
+
|
|
81
|
+
## [6.2.0] - 2026-09-13
|
|
82
|
+
|
|
83
|
+
### Added
|
|
84
|
+
|
|
85
|
+
- **Per-call `org` on every project, run, issue and analytics operation** (project-org-routing-and-rehome spec §3.2, D12). Each method on `client.projects`, `client.runs`, `client.issues` and `client.analytics` takes a trailing `options?: { org?: string }` (run writes: `RunCallOptions`, which also carries the existing `_skipClientValidation`). `org` is the target org's slug and becomes `X-Org-Slug` on that one request, overriding the constructor-level `orgSlug`. **Precedence on the wire, lowest to highest: personal org (no header) < constructor `orgSlug` < per-call `org`; an API key BOUND to an org ignores both headers and returns `403 ORG_ACCESS_DENIED` if they name a different org** — the platform's rule, surfaced verbatim. The API never infers an org from a project name (spec D2): a call that names no org creates or targets the *personal* project of that name even when a work org has one by the same name.
|
|
86
|
+
|
|
87
|
+
**Mechanism, because it differs from the spec's plan and the difference is worth knowing.** The spec (§3.2) said every operation would migrate from sdk-core's verb helpers to `client.request(..., { headers })`. sdk-core's `get/post/patch/put/delete` all delegate to `this.request`, so instead `OpsHttpClient` overrides `request` and exposes `withOrg(slug)`, a prototype-chained VIEW of the client that merges the header on every request it makes. One seam, zero operation rewrites, and the view shares the root's auth strategy, so a session installed on the root is honoured. `OpsClient` mints the view per call. Same wire contract; the test file asserts on the outgoing request as nock sees it, including a `badheaders` control proving the root client sends no header and that minting a view does not mutate the root.
|
|
88
|
+
|
|
89
|
+
- **`OpsHttpClient.withOrg(slug)`** and **`scopedOrg`** (read-only, for diagnostics) on the low-level client; `ORG_SLUG_PATTERN` and `ORG_SLUG_HEADER` exported. `withOrg` validates the slug with the same pattern as `orgSlug` and throws `InputValidationError` — it is a header value, so CRLF and whitespace can never reach the wire.
|
|
90
|
+
|
|
91
|
+
- **Org-routing error codes and type guards**: `INSUFFICIENT_ORG_ROLE` / `isInsufficientOrgRoleError` (403 — below the org's write floor; on tracker writes the API's body carries `details.applied: false` and forbids retrying without `org`: **do not retry the same call org-less — that is not a fallback, it files the work in the caller's personal org**), `ORG_ACCESS_DENIED` / `isOrgAccessDeniedError` (403 — not a member, or a bound key naming another org), and `PROJECT_REHOMED` / `isProjectRehomedError` with `ProjectRehomedDetails` (410 — spec D14: the project this name once denoted in this org now lives in `details.target_org.slug`; pass it as `org` and the same call succeeds). The rehomed guard requires the details shape it promises, not only the code, so a narrowed error is safe to dereference. `InsufficientOrgRoleDetails` declared.
|
|
92
|
+
|
|
93
|
+
### Changed
|
|
94
|
+
|
|
95
|
+
- `client.runs.*` write and preview options are typed `RunCallOptions` (`OrgScopedOptions & { _skipClientValidation? }`) instead of the inline `{ _skipClientValidation?: boolean }`. Structurally compatible; nothing a caller wrote breaks.
|
|
96
|
+
|
|
7
97
|
## [6.1.0] - 2026-09-10
|
|
8
98
|
|
|
9
99
|
### Added
|
|
@@ -115,7 +205,9 @@ deploys; SDK 6.0.0 (after the flip) re-pins the new shapes strictly.
|
|
|
115
205
|
|
|
116
206
|
### Added
|
|
117
207
|
|
|
118
|
-
- **`runs.save()` surfaces the `analysisWrite` confirmation** (tool-sweep T21; API
|
|
208
|
+
- **`runs.save()` surfaces the `analysisWrite` confirmation** (tool-sweep T21; API 2.0.0+, or a
|
|
209
|
+
1.70.0 deploy after 2026-08-19 — this read "≥1.71.0" until 2026-09-13; no such version was
|
|
210
|
+
ever declared, see ops-uluops-api issue `aea76608`).
|
|
119
211
|
The save response envelope now carries `analysisWrite` as a sibling of `data` — the same
|
|
120
212
|
placement as the update envelope — read via `rawEnvelope` and returned on the result
|
|
121
213
|
(`analysisWrite: AnalysisWriteEcho | null`; `recordMode: 'initial'` on this path). Like the
|
package/README.md
CHANGED
|
@@ -95,6 +95,8 @@ for (const [domain, trend] of Object.entries(burndown.trends)) {
|
|
|
95
95
|
- [Issue Operations](#issue-operations)
|
|
96
96
|
- [Analytics Operations](#analytics-operations)
|
|
97
97
|
- [Taxonomy Operations](#taxonomy-operations)
|
|
98
|
+
- [Org Operations](#org-operations)
|
|
99
|
+
- [Admin Operations](#admin-operations)
|
|
98
100
|
- [Health Check](#health-check)
|
|
99
101
|
- [Environment Variables](#environment-variables)
|
|
100
102
|
- [Error Handling](#error-handling)
|
|
@@ -170,7 +172,7 @@ console.log(client.getAuthType()); // 'api_key'
|
|
|
170
172
|
|
|
171
173
|
### Session-Based Authentication
|
|
172
174
|
|
|
173
|
-
For interactive applications, use `client.login()` which
|
|
175
|
+
For interactive applications, use `client.login()` which installs the session and, by default, re-logs in on a 401:
|
|
174
176
|
|
|
175
177
|
```typescript
|
|
176
178
|
import { OpsClient } from '@uluops/ops-sdk';
|
|
@@ -192,6 +194,43 @@ await client.logout();
|
|
|
192
194
|
|
|
193
195
|
> **Note:** Prefer `client.login()` over `client.auth.login()`. The latter only returns the token without installing it, requiring manual client construction.
|
|
194
196
|
|
|
197
|
+
**Accounts with MFA (6.4.0).** For an account with TOTP or a passkey enrolled, `POST /auth/login`
|
|
198
|
+
answers a *challenge*, not a session, and `client.login()` throws `MfaRequiredError` carrying
|
|
199
|
+
`mfaChallengeToken`, `expiresAt` and `mfaMethods`. Complete it with the current code:
|
|
200
|
+
|
|
201
|
+
```typescript
|
|
202
|
+
import { isMfaRequiredError } from '@uluops/ops-sdk';
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
await client.login(email, password);
|
|
206
|
+
} catch (err) {
|
|
207
|
+
if (!isMfaRequiredError(err)) throw err;
|
|
208
|
+
await client.loginWithTotp(err.mfaChallengeToken, '123456'); // installs the session
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
**The challenge token is single-use and is consumed before the code is checked** — a mistyped
|
|
213
|
+
code burns it; do not loop on `loginWithTotp` with the same token, call `login()` again for a fresh
|
|
214
|
+
challenge. A TOTP-installed session has no password to re-login with, so it is **not**
|
|
215
|
+
auto-refreshed: when it expires, requests fail `401` and you log in again. (Before 6.4.0 an MFA
|
|
216
|
+
account could not log in through `login()` at all — the challenge body failed the session schema
|
|
217
|
+
with a `ZodError`.) `MfaRequiredError` is raised by `login()` / `auth.login()` **only**: a client
|
|
218
|
+
constructed with `{ email, password }` (or autoloaded credentials) logs in inside sdk-core, which
|
|
219
|
+
cannot see the challenge and surfaces a generic `UnauthorizedError` — MFA accounts must use the
|
|
220
|
+
two-step path. WebAuthn completion is not offered here.
|
|
221
|
+
|
|
222
|
+
**What "auto-refresh" does — read before scripting against a session.** With the default
|
|
223
|
+
`login()`, the password is kept so sdk-core can re-login when a request answers 401. That refresh
|
|
224
|
+
is a *fresh login*: under the API's default single-session policy it **revokes the user's other
|
|
225
|
+
sessions** (your dashboard tab); **mutations are not retried** afterwards (the POST that hit the 401
|
|
226
|
+
still throws it); and the budget is **one** (the password is cleared after the first re-login).
|
|
227
|
+
For a script — the Phase 4 migration, anything that shares the account with a dashboard — pass
|
|
228
|
+
`{ autoRefresh: false }` so a 401 means *stop*, untouched:
|
|
229
|
+
|
|
230
|
+
```typescript
|
|
231
|
+
await client.login(email, password, { autoRefresh: false }); // session without a password → no re-login
|
|
232
|
+
```
|
|
233
|
+
|
|
195
234
|
### Credential Priority Chain
|
|
196
235
|
|
|
197
236
|
The SDK loads credentials in the following order:
|
|
@@ -307,7 +346,8 @@ const client = new OpsClient({
|
|
|
307
346
|
debug: false, // Enable debug logging
|
|
308
347
|
|
|
309
348
|
// Multi-tenancy
|
|
310
|
-
orgSlug: 'my-org', // Org slug (sets X-Org-Slug header on all requests)
|
|
349
|
+
orgSlug: 'my-org', // Org slug (sets X-Org-Slug header on all requests);
|
|
350
|
+
// any method's trailing `{ org }` overrides it per call
|
|
311
351
|
|
|
312
352
|
// Callbacks
|
|
313
353
|
onTokenRefresh: (token) => { /* handle token refresh */ },
|
|
@@ -321,6 +361,47 @@ const client = new OpsClient({
|
|
|
321
361
|
});
|
|
322
362
|
```
|
|
323
363
|
|
|
364
|
+
#### Org routing — which org a call lands in
|
|
365
|
+
|
|
366
|
+
Every project, run, issue and analytics method takes a trailing `options` with `org?: string`
|
|
367
|
+
(run writes: `RunCallOptions`, which also carries `_skipClientValidation`). It becomes the
|
|
368
|
+
`X-Org-Slug` header on that one request.
|
|
369
|
+
|
|
370
|
+
```typescript
|
|
371
|
+
await client.runs.save(input, { org: 'ulu-labs' }); // this call → ulu-labs
|
|
372
|
+
await client.projects.list({ org: 'ulu-labs' }); // reads take it too
|
|
373
|
+
await client.projects.list(); // → constructor orgSlug, else your personal org
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
**Precedence on the wire, lowest to highest:** personal org (no header) < constructor `orgSlug`
|
|
377
|
+
< per-call `org`. An API key **bound** to an org ignores both headers and answers
|
|
378
|
+
`403 ORG_ACCESS_DENIED` if they name a different org. The API never infers an org from a project
|
|
379
|
+
name: a call that names no org creates or targets the *personal* project of that name, even when
|
|
380
|
+
a work org has one by the same name (spec D2). Name the org.
|
|
381
|
+
|
|
382
|
+
Three org-routing errors are worth branching on (all exported with type guards):
|
|
383
|
+
|
|
384
|
+
| Code | Status | Guard | What to do |
|
|
385
|
+
|---|---|---|---|
|
|
386
|
+
| `INSUFFICIENT_ORG_ROLE` | 403 | `isInsufficientOrgRoleError` | Your role in that org is below `publisher`. **Do not retry without `org`** — an org-less retry files the work in your personal org; the API says so in the body (`details.applied: false`). |
|
|
387
|
+
| `ORG_ACCESS_DENIED` | 403 | `isOrgAccessDeniedError` | Not a member of that org, or your key is bound to a different one. Terminal. |
|
|
388
|
+
| `PROJECT_REHOMED` | 410 | `isProjectRehomedError` | The project moved orgs. `err.details.target_org.slug` is where it lives — pass it as `org` and retry the same call. |
|
|
389
|
+
| `SESSION_REQUIRED` | 403 | `isSessionRequiredError` | A session-only admin route refused an API key (D20). Log in; never mint another key to get past it. |
|
|
390
|
+
| `INSUFFICIENT_ROLE` | 403 | `isInsufficientRoleError` | The admin path needs the *platform* role (`users.role = admin`); no `org` argument changes it. Terminal. |
|
|
391
|
+
|
|
392
|
+
Low-level: `new OpsHttpClient(cfg).withOrg('acme')` returns a view of the client scoped to that org.
|
|
393
|
+
|
|
394
|
+
**Where does the default come from?** For tools that run inside a checkout (the CLI, the tracker
|
|
395
|
+
MCP), `resolveWorkspaceOrg({ explicit, cwd })` implements the spec's D13 rule: an explicit value
|
|
396
|
+
wins; else the nearest `.uluops.json` above `cwd` (`{ "org": "ulu-labs" }`, or `{ "org": "personal" }`
|
|
397
|
+
to stop the walk in a personal repo nested under a work tree); else `ULUOPS_ORG_SLUG`; else your
|
|
398
|
+
personal org. The file may carry only `org` (and `$schema`) — any other key is refused, not ignored.
|
|
399
|
+
Two more refusals since 6.3.1 (security audit run #187): the walk never rises above your home
|
|
400
|
+
directory (a file at `/` or `/Users` cannot become everyone's default), and a file owned by another
|
|
401
|
+
user is refused (a shared parent directory is the planting vector). `"personal"` is also honoured as
|
|
402
|
+
an explicit value — `{ org: 'personal' }` sends no header. On a scoped call the per-call org header
|
|
403
|
+
is set last and an `X-Org-Slug`/`X-Org-Id` in `options.headers` is refused.
|
|
404
|
+
|
|
324
405
|
---
|
|
325
406
|
|
|
326
407
|
### Security Events
|
|
@@ -769,6 +850,52 @@ const result = await client.projects.mergeIssues('my-project', {
|
|
|
769
850
|
});
|
|
770
851
|
```
|
|
771
852
|
|
|
853
|
+
#### `client.projects.rehome(idOrName, input, options?)` — move a project to another org
|
|
854
|
+
|
|
855
|
+
Moves the project and its whole history (runs, issues, analytics) into another org
|
|
856
|
+
(project-org-routing-and-rehome spec §4.1, D14). **The source org is this call's org scope** —
|
|
857
|
+
pass `{ org: '<source>' }` (or set the client `orgSlug`) unless the project is in your personal
|
|
858
|
+
org; the project is looked up *there*, and an unscoped call for a work-org project is a 404. You
|
|
859
|
+
need `admin`/`owner` in both orgs; a personal org as *target* only when it is yours.
|
|
860
|
+
|
|
861
|
+
| Parameter | Type | Required | Description |
|
|
862
|
+
|-----------|------|----------|-------------|
|
|
863
|
+
| `targetOrg` | `string` | Yes | Slug of the destination org |
|
|
864
|
+
| `reason` | `string` | No | ≤ 500 chars; stored on the tombstone, never rendered into an error |
|
|
865
|
+
|
|
866
|
+
```typescript
|
|
867
|
+
const moved = await client.projects.rehome('billing', { targetOrg: 'ulu-labs', reason: 'team took it over' }, { org: 'acme' });
|
|
868
|
+
moved.orgId; // the target org's id
|
|
869
|
+
moved.rehome.from_org; // { id, slug: 'acme' }
|
|
870
|
+
moved.rehome.to_org; // { id, slug: 'ulu-labs' }
|
|
871
|
+
moved.rehome.audit_ids; // [] today — the ledger, not this array, is the durable record
|
|
872
|
+
```
|
|
873
|
+
|
|
874
|
+
After the move the old `(org, name)` address is a **tombstone**: an org-less *write* naming the
|
|
875
|
+
project there (a `save` or a `create`) gets `410 PROJECT_REHOMED` naming the target
|
|
876
|
+
(`isProjectRehomedError`) instead of silently forking a new project. Reads at the old address 404.
|
|
877
|
+
Moving back is an ordinary `rehome` the other way and annihilates the tombstone.
|
|
878
|
+
|
|
879
|
+
Refusals a caller branches on — `rehomeRefusalReason(err)` reads the enumerated
|
|
880
|
+
`details.reason` values and returns `null` for anything else:
|
|
881
|
+
|
|
882
|
+
| Answer | Meaning | Disposition |
|
|
883
|
+
|---|---|---|
|
|
884
|
+
| `400` `same_org` | already there | done (idempotent re-run) |
|
|
885
|
+
| `409` `moved_during_request` / `deadlock_retry` / `concurrent_modification` | lost a race | re-read, retry once |
|
|
886
|
+
| `409` `name_collision` / `soft_deleted_conflict` / `rehomed_away_conflict` | the name is taken in the target (live, soft-deleted, or reserved by another move) | skip and report |
|
|
887
|
+
| `409` `export_in_progress` | an export holds one of the orgs | wait, retry |
|
|
888
|
+
| `409` `project_soft_deleted` | the project is soft-deleted in the source | restore it there first, then move |
|
|
889
|
+
| `400` `project_has_no_org` | pre-org legacy row | stop; an operator repairs the row |
|
|
890
|
+
| `403` `INSUFFICIENT_ORG_ROLE` / `ORG_ACCESS_DENIED` / `ORG_SUSPENDED` | authority (source role, target membership or a personal target, C3) | stop |
|
|
891
|
+
| `402` `PROJECT_LIMIT` | target org at its project cap | stop |
|
|
892
|
+
| *no HTTP answer* (`isTimeoutError` / `isNetworkError`; `rehomeRefusalReason` → `null`) | the server may have committed the move before the response was lost | do **not** retry blind — read the project with `{ org: targetOrg }` (or the admin ledger); on the member path a blind retry is a 404, not `same_org` |
|
|
893
|
+
|
|
894
|
+
**Re-runs.** `same_org` is the idempotence signal of the **admin** path (lookup by id, unscoped).
|
|
895
|
+
On the member path the lookup is source-scoped, so the same call after the move answers **404**
|
|
896
|
+
(the source address is a tombstone) — not `same_org`. A member-path script that wants "already
|
|
897
|
+
done" checks the target org, not the refusal code.
|
|
898
|
+
|
|
772
899
|
---
|
|
773
900
|
|
|
774
901
|
### Run Operations
|
|
@@ -1783,6 +1910,108 @@ console.log('Priorities:', taxonomy.priorities);
|
|
|
1783
1910
|
|
|
1784
1911
|
---
|
|
1785
1912
|
|
|
1913
|
+
### Org Operations
|
|
1914
|
+
|
|
1915
|
+
Reads any **member** of an org can make. (Org CRUD, membership and invitations are dashboard
|
|
1916
|
+
surfaces and are not wrapped by this SDK.)
|
|
1917
|
+
|
|
1918
|
+
#### `client.orgs.getVisibleAuditLog(slug, query?)` — the org-visible audit feed (D19)
|
|
1919
|
+
|
|
1920
|
+
The subset of an org's audit log that any member may read: rows whose writer marked
|
|
1921
|
+
`details.visibility = 'org'`. Today that is one class — a project **leaving this org for someone's
|
|
1922
|
+
personal org** (an org admin may do that; before D19 the org's owner could not see it). The full
|
|
1923
|
+
audit log stays admin+ and is not wrapped here.
|
|
1924
|
+
|
|
1925
|
+
| Parameter | Type | Description |
|
|
1926
|
+
|-----------|------|-------------|
|
|
1927
|
+
| `cursor` | `string` | Opaque keyset cursor — pass a prior page's `nextCursor` back verbatim |
|
|
1928
|
+
| `limit` | `number` | Page size, 1–100 — the API answers 400 outside that range (it does not clamp) |
|
|
1929
|
+
|
|
1930
|
+
```typescript
|
|
1931
|
+
import { readRehomeAuditDetails } from '@uluops/ops-sdk';
|
|
1932
|
+
|
|
1933
|
+
const feed = await client.orgs.getVisibleAuditLog('acme', { limit: 50 });
|
|
1934
|
+
for (const entry of feed.data.entries) {
|
|
1935
|
+
const move = readRehomeAuditDetails(entry); // null for rows written by anything else
|
|
1936
|
+
if (move) console.log(`${move.project_name}: ${move.from_org.slug} → ${move.to_org.slug} (${move.action})`);
|
|
1937
|
+
}
|
|
1938
|
+
// feed.count is the page size, feed.hasMore / feed.nextCursor page it
|
|
1939
|
+
```
|
|
1940
|
+
|
|
1941
|
+
`action` on every row is the platform's closed enum (`org.updated`); the real event is
|
|
1942
|
+
`details.action` (`project.rehome_out` on the source org's feed, `project.rehome_in` on the
|
|
1943
|
+
target's). A non-member gets `403 ORG_ACCESS_DENIED`.
|
|
1944
|
+
|
|
1945
|
+
---
|
|
1946
|
+
|
|
1947
|
+
### Admin Operations
|
|
1948
|
+
|
|
1949
|
+
The **platform-admin** surface (`users.role = 'admin'` — a platform role, not an org role). The two
|
|
1950
|
+
writes additionally require a **login-issued session**: an API key, even an admin's, answers
|
|
1951
|
+
`403 SESSION_REQUIRED` (`isSessionRequiredError`) — that is D20, the compensation for a stolen
|
|
1952
|
+
admin key being able to move any project anywhere in one call. Log in with `client.login()` (and
|
|
1953
|
+
`loginWithTotp()` if challenged) and the session bearer is what these calls send. A non-admin
|
|
1954
|
+
principal gets `403 INSUFFICIENT_ROLE` on all four. There is deliberately no MCP tool for any of
|
|
1955
|
+
this.
|
|
1956
|
+
|
|
1957
|
+
#### `client.admin.rehomeProject(projectId, { targetOrg, reason })` — session only
|
|
1958
|
+
|
|
1959
|
+
Move **any** project between **any** two orgs by UUID; no source- or target-membership
|
|
1960
|
+
requirement, so `reason` is **required** (on this path the target org's only standing is the audit
|
|
1961
|
+
row plus that text). Resolves the project unscoped — the route takes a UUID and the SDK refuses a
|
|
1962
|
+
name client-side (`InputValidationError`). Answers the same refusals as the member path; here
|
|
1963
|
+
`same_org` does mean "already done" (the lookup is by id, so the moved project is found wherever
|
|
1964
|
+
it is), which is what makes a scripted pass over a list idempotent.
|
|
1965
|
+
|
|
1966
|
+
```typescript
|
|
1967
|
+
await client.login(email, password, { autoRefresh: false }); // or loginWithTotp(...) after an MfaRequiredError
|
|
1968
|
+
try {
|
|
1969
|
+
const moved = await client.admin.rehomeProject(projectId, { targetOrg: 'ulu-labs', reason: 'OQ-4 row 7' });
|
|
1970
|
+
console.log(moved.rehome.from_org.slug, '→', moved.rehome.to_org.slug);
|
|
1971
|
+
} catch (err) {
|
|
1972
|
+
if (rehomeRefusalReason(err) === 'same_org') { /* done on a previous run */ }
|
|
1973
|
+
else throw err;
|
|
1974
|
+
}
|
|
1975
|
+
```
|
|
1976
|
+
|
|
1977
|
+
#### `client.admin.listProjectRehomes(query?)` — key-readable
|
|
1978
|
+
|
|
1979
|
+
The **current redirect table**: one row per vacated `(sourceOrgId, name)` and the `targetOrgId` it
|
|
1980
|
+
points at. A reversal annihilates its row and a release deletes it, so this is state, not history.
|
|
1981
|
+
`org` takes a UUID or a slug; `total` is the matching count and `returned` the page.
|
|
1982
|
+
|
|
1983
|
+
```typescript
|
|
1984
|
+
const { data, total } = await client.admin.listProjectRehomes({ org: 'system', limit: 100 });
|
|
1985
|
+
// data[0] → { id, sourceOrgId, name, projectId, targetOrgId, actorId, reason, createdAt }
|
|
1986
|
+
```
|
|
1987
|
+
|
|
1988
|
+
#### `client.admin.listProjectRehomeEvents(query?)` — key-readable
|
|
1989
|
+
|
|
1990
|
+
The **append-only ledger** (D21): every `moved`, `repointed`, `annihilated`, `degenerate_dropped`,
|
|
1991
|
+
`released` and `hard_deleted` event, written in the same transaction as the change; nothing the API
|
|
1992
|
+
offers removes a row. Pages by `seq` (insertion order) — `nextCursor` is the `seq` to continue
|
|
1993
|
+
below; pass it back verbatim. **Reconcile a migration against this**, not against the reservation
|
|
1994
|
+
table and not against the script's own log. Unknown future event kinds parse verbatim
|
|
1995
|
+
(`PROJECT_REHOME_EVENT_KINDS` lists the known six).
|
|
1996
|
+
|
|
1997
|
+
```typescript
|
|
1998
|
+
const page = await client.admin.listProjectRehomeEvents({ org: 'ulu-labs', event: 'moved', limit: 100 });
|
|
1999
|
+
page.data[0]; // { seq, event, projectId, projectName, fromOrgId, toOrgId, viaAdminPath, reason, createdAt, ... }
|
|
2000
|
+
```
|
|
2001
|
+
|
|
2002
|
+
#### `client.admin.releaseProjectRehome(rehomeId)` — session only
|
|
2003
|
+
|
|
2004
|
+
Release a vacated address so the old `(org, name)` is creatable again — a by-name writer there then
|
|
2005
|
+
gets a **new** project instead of a 410. Audited (a `released` ledger row); never done by time.
|
|
2006
|
+
|
|
2007
|
+
```typescript
|
|
2008
|
+
await client.admin.releaseProjectRehome(reservationId); // → { released: true }
|
|
2009
|
+
```
|
|
2010
|
+
A `ZodError` here means the 200 body was not `{ released: true }` — and since the server answers
|
|
2011
|
+
after the row is gone, the release most likely happened; re-read the listing before retrying.
|
|
2012
|
+
|
|
2013
|
+
---
|
|
2014
|
+
|
|
1786
2015
|
### Health Check
|
|
1787
2016
|
|
|
1788
2017
|
Check API server status. This endpoint does not require authentication.
|