@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.
Files changed (75) hide show
  1. package/CHANGELOG.md +93 -1
  2. package/README.md +231 -2
  3. package/dist/client.d.ts +155 -81
  4. package/dist/client.d.ts.map +1 -1
  5. package/dist/client.js +141 -67
  6. package/dist/client.js.map +1 -1
  7. package/dist/config/constants.d.ts +3 -1
  8. package/dist/config/constants.d.ts.map +1 -1
  9. package/dist/config/constants.js +2 -0
  10. package/dist/config/constants.js.map +1 -1
  11. package/dist/config/generated-version.d.ts +1 -1
  12. package/dist/config/generated-version.js +1 -1
  13. package/dist/config/index.d.ts +1 -0
  14. package/dist/config/index.d.ts.map +1 -1
  15. package/dist/config/index.js +1 -0
  16. package/dist/config/index.js.map +1 -1
  17. package/dist/config/validators.d.ts +25 -1
  18. package/dist/config/validators.d.ts.map +1 -1
  19. package/dist/config/validators.js +31 -1
  20. package/dist/config/validators.js.map +1 -1
  21. package/dist/config/workspace-org.d.ts +77 -0
  22. package/dist/config/workspace-org.d.ts.map +1 -0
  23. package/dist/config/workspace-org.js +182 -0
  24. package/dist/config/workspace-org.js.map +1 -0
  25. package/dist/errors/errors.d.ts +118 -0
  26. package/dist/errors/errors.d.ts.map +1 -1
  27. package/dist/errors/errors.js +138 -0
  28. package/dist/errors/errors.js.map +1 -1
  29. package/dist/http/http-client.d.ts +46 -0
  30. package/dist/http/http-client.d.ts.map +1 -1
  31. package/dist/http/http-client.js +88 -5
  32. package/dist/http/http-client.js.map +1 -1
  33. package/dist/index.d.ts +2 -1
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +1 -0
  36. package/dist/index.js.map +1 -1
  37. package/dist/operations/admin.d.ts +100 -0
  38. package/dist/operations/admin.d.ts.map +1 -0
  39. package/dist/operations/admin.js +130 -0
  40. package/dist/operations/admin.js.map +1 -0
  41. package/dist/operations/auth.d.ts +21 -1
  42. package/dist/operations/auth.d.ts.map +1 -1
  43. package/dist/operations/auth.js +49 -3
  44. package/dist/operations/auth.js.map +1 -1
  45. package/dist/operations/orgs.d.ts +27 -0
  46. package/dist/operations/orgs.d.ts.map +1 -0
  47. package/dist/operations/orgs.js +34 -0
  48. package/dist/operations/orgs.js.map +1 -0
  49. package/dist/operations/projects.d.ts +46 -0
  50. package/dist/operations/projects.d.ts.map +1 -1
  51. package/dist/operations/projects.js +54 -1
  52. package/dist/operations/projects.js.map +1 -1
  53. package/dist/types/auth.d.ts +10 -0
  54. package/dist/types/auth.d.ts.map +1 -1
  55. package/dist/types/index.d.ts +2 -0
  56. package/dist/types/index.d.ts.map +1 -1
  57. package/dist/types/index.js +2 -0
  58. package/dist/types/index.js.map +1 -1
  59. package/dist/types/org.d.ts +25 -0
  60. package/dist/types/org.d.ts.map +1 -0
  61. package/dist/types/org.js +2 -0
  62. package/dist/types/org.js.map +1 -0
  63. package/dist/types/rehome.d.ts +234 -0
  64. package/dist/types/rehome.d.ts.map +1 -0
  65. package/dist/types/rehome.js +171 -0
  66. package/dist/types/rehome.js.map +1 -0
  67. package/dist/types/response-schemas.d.ts +19 -0
  68. package/dist/types/response-schemas.d.ts.map +1 -1
  69. package/dist/types/response-schemas.js +19 -0
  70. package/dist/types/response-schemas.js.map +1 -1
  71. package/dist/types/schemas.d.ts +20 -0
  72. package/dist/types/schemas.d.ts.map +1 -1
  73. package/dist/types/schemas.js +22 -0
  74. package/dist/types/schemas.js.map +1 -1
  75. 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 ≥1.71.0).
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 automatically configures token auto-refresh:
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.