From d18519171bfd00d410a7a0182ed2d4a848e0f46d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 12:09:10 +0000 Subject: [PATCH 1/6] A1 transport, A6 CI, and adoption briefs for gh-v1 and the slack bot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Transport: credentials mode, onUnauthorized (resends an idempotent request once; a plain write never), per-request signal and client.withSignal. Spec 000 §6 updated. - CI: verify on Node 20/22/24 and an npm pack artifact; a prepare script so consumers can pin a git commit before a registry release. - plans/002 (gh-v1, Track C) and plans/003 (slack bot, Track B). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016NSWj9gL1oCubj5e2anVkb --- .github/workflows/ci.yml | 33 ++++++ README.md | 6 +- package.json | 1 + plans/001-sdk-adoption-campaign.md | 8 +- plans/002-adopt-sdk-gh-v1.md | 73 ++++++++++++ plans/003-adopt-sdk-k6i-agility-slack-bot.md | 77 ++++++++++++ specs/000-layers.md | 7 ++ src/client.ts | 24 +++- src/core/clientConfig.ts | 4 +- src/core/fetchTransport.ts | 85 ++++++++++++-- src/core/transport.ts | 5 + src/index.ts | 1 + test/unit/transportAuth.test.ts | 117 +++++++++++++++++++ 13 files changed, 425 insertions(+), 16 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 plans/002-adopt-sdk-gh-v1.md create mode 100644 plans/003-adopt-sdk-k6i-agility-slack-bot.md create mode 100644 test/unit/transportAuth.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5696b6d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: CI + +on: + pull_request: + push: + branches: [master, proposed_type_script_api_version] + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + strategy: + matrix: + node: [20, 22, 24] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: npm + - run: npm ci + # Unit tier only: the integration tier needs a testing instance and writes fixtures. + - run: npm run verify + - name: Pack + if: matrix.node == 24 + run: npm pack + - uses: actions/upload-artifact@v4 + if: matrix.node == 24 + with: + name: versionone-sdk-tarball + path: versionone-sdk-*.tgz diff --git a/README.md b/README.md index 2b4bd68..c660fbd 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ npm test - `resolve.numbers` / `resolve.ref` - asset numbers and OID tokens to assets, in one request - `assets.sendGuarded` - a batch as a compare-and-set on each asset's ChangeDate; changed assets come back `skipped` - `batch.run` / `batch.reversible` - preview, snapshot, (confirm,) send guarded, and a receipt with a reverse batch -- `createFetchTransport` - configurable HTTP transport +- `createFetchTransport` - configurable HTTP transport: `credentials` for same-origin cookie auth, `onUnauthorized` to re-authenticate on a 401 (resends idempotent requests once), and a per-request `signal`; `client.withSignal(signal)` ties a whole client to one - `ApiError` - typed API error; `CommandFailureError` - an api/asset batch in which commands failed (`failures` by index, plus the `response`) API reference: https://docs.digital.ai/agility-api/ @@ -38,6 +38,10 @@ API reference: https://docs.digital.ai/agility-api/ 4. Export from `src/index.ts`. 5. Add tests under `test/`. +## Installing before a release + +No registry package yet. Pin a commit: `npm install github:versionone/VersionOne.SDK.JavaScript#` builds `dist` on install (the `prepare` script). CI also uploads an `npm pack` tarball on every run. + ## Before PR - Run `npm run verify` to validate typecheck, build, and tests before opening a pull request. \ No newline at end of file diff --git a/package.json b/package.json index c4d317b..fea3a73 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ }, "scripts": { "build": "tsup", + "prepare": "npm run build", "dev": "tsup --watch", "download:metamodel": "node scripts/download-metamodel.mjs", "clean": "rimraf dist", diff --git a/plans/001-sdk-adoption-campaign.md b/plans/001-sdk-adoption-campaign.md index b9dfead..7eea774 100644 --- a/plans/001-sdk-adoption-campaign.md +++ b/plans/001-sdk-adoption-campaign.md @@ -351,15 +351,15 @@ ESM" here means finishing the edges: ## Checklist -- [ ] A1 transport: credentials, onUnauthorized, signal +- [x] A1 transport: credentials, onUnauthorized (idempotent requests resent once; writes never), signal (`RequestOptions.signal`, `client.withSignal`) - [x] A2 command-failure error (`CommandFailureError`, `throwOnFailure`, reads always throw) - [x] A3 loc2 (`loc2.localize`, `client.localize`; GET, POST past 2000 encoded chars, culture via Accept-Language) - [ ] A4 slack-shaped reads (queryV1 or api/asset) - [x] A5 services port from gh-v1 — `resolve` (`client.resolve.numbers`/`.ref`), `guard` (`client.assets.sendGuarded`), `batch` (`client.batch.run`/`.reversible`) -- [ ] A6 CI + prerelease publish +- [ ] A6 CI + prerelease publish — CI (`.github/workflows/ci.yml`: verify on Node 20/22/24, `npm pack` artifact) and git-SHA installs (`prepare`) done; publish waits on the registry decision - [ ] G0–G3 gh-v1 migrations (gh-v1 session) - [ ] S1–S4 slack-bot migrations (slack-bot session, `bolt-migration`) -- [ ] B1–B4 slack bot SDK swap +- [ ] B1–B4 slack bot SDK swap — brief: `plans/003-adopt-sdk-k6i-agility-slack-bot.md` - [x] C0 gh-v1 surveyed (2026-11-04) -- [ ] C1–C4 gh-v1 +- [ ] C1–C4 gh-v1 — brief: `plans/002-adopt-sdk-gh-v1.md` - [ ] D1–D4 agility-rooms diff --git a/plans/002-adopt-sdk-gh-v1.md b/plans/002-adopt-sdk-gh-v1.md new file mode 100644 index 0000000..52f4202 --- /dev/null +++ b/plans/002-adopt-sdk-gh-v1.md @@ -0,0 +1,73 @@ +# Plan 002: gh-v1 adopts `@versionone/sdk` (Track C) + +Brief for a session on `versionone/gh-v1`. Start once the SDK PR that adds +this file has merged into `proposed_type_script_api_version`. Source plan: +`plans/001-sdk-adoption-campaign.md`, Track C. + +## Goal + +gh-v1 talks to Agility only through `@versionone/sdk`. CLI behaviour and +output stay the same; the code that duplicates the SDK goes. + +## Where things stand (checked 2026-11-06, gh-v1 `676bcd8`) + +- G0–G3 are merged: executable bin, `V1_URL`/`V1_ACCESS_TOKEN` config, ESM + (`"type": "module"`), `jsconfig.json` + `npm run typecheck`, one paging + module (`lib/paging.js`). +- One seam: `lib/api.js` exports `api(cfg)` returning `query`, `queries`, + `asset`, `attempt`, `rest`, `meta`, `localize`, `raw`, `blob`, + `permalink`, typed by the `Api` JSDoc typedef. 104 tests under `test/` + stub global `fetch`. +- String-boolean and OID helpers (`isTrue`, `oidOf`, `momentless`) are used + in `lib/api.js`, `meta.js`, `resolve.js`, `board.js`, `batch.js`, and + `commands/apply.js`, `bulk.js`, `board.js`. + +## Dependency + +No registry package yet. Add the first runtime dependency as a pinned commit: +`"@versionone/sdk": "github:versionone/VersionOne.SDK.JavaScript#"`. +The SDK's `prepare` script builds `dist` on install. gh-v1 is ESM now, so use +`import` (the ESM build), not `require`. + +## Steps, one PR each, on branch `sdk-adoption` + +1. **C1 Seam swap** (`lib/api.js`). Keep `api(cfg)` and the `Api` typedef. + Inside, build one `AgilityApiClient({ baseUrl: cfg.v1Url, accessToken })`. + Map: `query`/`queries` → `client.assets.query`/`queries`; + `asset` → `client.assets.send(cmds, { throwOnFailure: true })` + (a `CommandFailureError` carries `failures` by index — keep `err.failures`); + `attempt` → `client.assets.send` (failures returned, not thrown); + `rest` → `client.data.list`/`get`; `meta` → `client.meta.*`; + `localize` → `client.localize`. `raw`, `blob`, `permalink` stay local. + Rows now arrive translated (`true`, numbers, single relations as one + Ref), so update the fixtures in tests that assert raw strings, and add + the meta fixture the schema needs (`test/meta-fixture.js`, or seed + `ClientOptions.metamodel`). +2. **C2 Delete the decodes.** Remove `isTrue`/`oidOf` call sites listed + above; import `momentless` from the SDK. +3. **C3 Invert the services.** `resolveAssets` → `client.resolve.numbers` + (its `missing` drives the "not found — skipped" lines). `batch.run` → + `client.batch.run`, with `confirm` as the prompt and the receipt written + as returned. `--reverse` → `client.batch.reversible` (`--force` passes + `force`). Keep the CLI's UX for an unguardable batch by checking + `unguardable()` first, then sending with `guard: false` and the existing + note. Prompting, rendering, permalinks and receipt files stay in gh-v1. +4. **C4 Passthrough.** `raw`/`blob` stay as they are. `lib/guard.js` stays: + it is CLI policy, not SDK behaviour. + +## Rules + +- Requests out must match today's, apart from what translation changes. + Add a test that asserts the request body where it differs, and say why. +- Every query keeps an explicit page (the SDK refuses one without). +- No live Agility writes. Live reads only against the API-testing instance, + with an explicit page size, well under 20 s of server work. + +## Done when + +- `npm test` and `npm run typecheck` pass on each PR. +- `lib/api.js` has no `fetch` call except in `raw`/`blob`. +- No `isTrue`/`oidOf`/local `momentless` remain in `lib/`. +- `lib/resolve.js` and `lib/batch.js` keep only prompting, rendering and + file I/O. +- Plan 001's checklist line `C1–C4 gh-v1` is ticked, with the PR links. diff --git a/plans/003-adopt-sdk-k6i-agility-slack-bot.md b/plans/003-adopt-sdk-k6i-agility-slack-bot.md new file mode 100644 index 0000000..2699ab7 --- /dev/null +++ b/plans/003-adopt-sdk-k6i-agility-slack-bot.md @@ -0,0 +1,77 @@ +# Plan 003: k6i-agility-slack-bot adopts `@versionone/sdk` (Track B) + +Brief for a session on `digital-ai/k6i-agility-slack-bot`. Start once the +SDK PR that adds this file has merged into `proposed_type_script_api_version`. +Source plan: `plans/001-sdk-adoption-campaign.md`, Track B. Work on +`bolt-migration` (branch `sdk-adoption` from it), never `main`. + +## Goal + +`services/v1.js`'s `createV1Client` runs on `@versionone/sdk`, with the same +exported surface, so listeners and features do not change. The bot's +hand-decoding and rest-1 write path go. + +## Where things stand (checked 2026-11-06, `bolt-migration` `4d77693`) + +- `createV1Client({ url, accessToken })` uses native `fetch` and throws + `AuthFailureError` on 401. Its methods: `query` (query.v1), `bulk`, + `createWithNumber`, `exec`/`quickClose`/`reactivate` (api/asset), + `create`/`update` (rest-1 writes via `lib/rest1DataTransformer.js`), + `findAssets` (rest-1 `find`/`findin`, `page` up to 200), + `fetchLocalizations` (loc-2), and `queryFor*` helpers built on + `lib/queries.js`. `test/v1-service.test.js` stubs global `fetch`. +- **S1–S4 are not done yet**: `jsconfig.json` still says `commonJS`/`es6`; + reads still post to `query.v1`; `axios`, `botkit*`, `botbuilder-*` are + still dependencies and `lib/v1api.js` still exists. These come first. + +## Dependency + +No registry package yet. Pin a commit: +`"@versionone/sdk": "github:versionone/VersionOne.SDK.JavaScript#"`. +The SDK's `prepare` script builds `dist` on install. The bot is ESM, so +`import { AgilityApiClient } from "@versionone/sdk"`. + +## Steps, one PR each + +0. **S1–S4 first**, as written in `plans/handoffs/k6i-agility-slack-bot.md`. + B1 needs at least S2 (reads on api/asset) and S3 (axios and Botkit gone). + If the bot's own session already merged them, skip. +1. **B1 Client swap** (`services/v1.js`). Inside `createV1Client`, build one + `new AgilityApiClient({ baseUrl: url, accessToken })`. `query` → + `client.assets.queries` (positional; a failed query now throws instead + of shifting later results); `bulk`/`exec`/`createWithNumber` → + `client.assets.send`; `fetchLocalizations` → `client.localize`; + `findAssets` → `client.data.list("BaseAsset", { find, findin, where, + sort, page, deleted: false })`. Map `ApiError` with `status === 401` + to `AuthFailureError` in one place. The SDK's `onUnauthorized` is not + needed. `test/v1-service.test.js` stays the parity proof. +2. **B2 Delete the decodes.** Rows arrive translated: drop the + `"Team.HasProcess" === "True"` and `IsKanban.toLowerCase() === "true"` + comparisons, local `toMomentless` (use the SDK's `momentless`), and the + `results[1]`/`results[2]` guessing in `queryForPrimaryWorkitemByOid` and + `queryForTeamRoomContext`. +3. **B3 Writes on api/asset.** `create`/`update` → `client.assets.send`, + with the change comment as `ChangeComment` inside the `update` node (or + at the root of a create). `execute` cannot carry a comment. Delete + `lib/rest1DataTransformer.js` and its test. +4. **B4 Number resolution.** `queryForAssetByNumber` → + `client.resolve.numbers([n])`; a miss is `missing`, not an empty row. + +## Rules + +- Every api/asset query keeps an explicit `page` (the SDK refuses one + without). Values from user text go in `with` variables, not spliced into + `filter`/`where` (an em dash breaks a literal). +- Fixtures send what the server sends (S4): `"True"`, not `true`. +- No live Agility writes from the session. Live reads only with an explicit + page size and well under 20 s of server work. + +## Done when + +- `npm test` passes on each PR; S1's `npm run typecheck` passes. +- `services/v1.js` makes no `fetch` call of its own; nothing posts to + `query.v1` or writes through rest-1. +- No string-boolean comparisons or local `toMomentless` remain. +- Sandbox-workspace smoke passes: asset mention, `/agility find`, + backlog-item status change, webhook notification. +- Plan 001's checklist lines `S1–S4` and `B1–B4` are ticked, with PR links. diff --git a/specs/000-layers.md b/specs/000-layers.md index 1a0ce79..16aa7ed 100644 --- a/specs/000-layers.md +++ b/specs/000-layers.md @@ -138,6 +138,13 @@ retries only idempotent requests (GET by default), only on a network failure or a 5xx, never a 4xx; a batch write is never retried unless the caller has made it idempotent with a guard and says so. +Re-authentication follows the same rule. On a 401 (or a status the caller +lists) the transport calls `onUnauthorized` once per request; "retry" resends +an idempotent request one more time, outside the `retries` count. A plain +write is never resent: the hook still runs, so the session can be repaired, +and the caller decides. A caller's abort (`signal`) ends the request as +`ABORTED` and is never retried. + A failure never reads as an empty. api/asset answers 200 and commits per command, so a failed command is reported inside the envelope (`commandFailures`, each by `sourceCommandIndex`). `assetApi.send` returns diff --git a/src/client.ts b/src/client.ts index acb54b0..619a9fb 100644 --- a/src/client.ts +++ b/src/client.ts @@ -22,6 +22,8 @@ export interface ClientOptions { * queries first touch them. */ metamodel?: Metamodel; + /** Share an existing schema (and its cache) instead of starting a new one. */ + schema?: Schema; } /** @@ -44,9 +46,27 @@ export class AgilityApiClient { accessToken: config.accessToken, timeoutMs: config.timeoutMs, retries: config.retries, - defaultHeaders: config.defaultHeaders + defaultHeaders: config.defaultHeaders, + credentials: config.credentials, + onUnauthorized: config.onUnauthorized, + unauthorizedStatuses: config.unauthorizedStatuses }); - this.schema = new Schema(this.transport, opts.metamodel); + this.config = config; + this.schema = opts.schema ?? new Schema(this.transport, opts.metamodel); + } + + private readonly config: ClientConfig; + + /** + * The same client, with every request it sends tied to `signal`: abort it + * (on navigation, say) and in-flight requests end with an ApiError coded + * ABORTED. The schema and its cache are shared; a type load that other + * callers are waiting on is not cancelled. + */ + withSignal(signal: AbortSignal): AgilityApiClient { + const base = this.transport; + const transport: Transport = { request: (options) => base.request({ ...options, signal: options.signal ?? signal }) }; + return new AgilityApiClient(this.config, { transport, schema: this.schema }); } /** meta.v1: the instance's schema, raw. (The translated, cached view is `schema`.) */ diff --git a/src/core/clientConfig.ts b/src/core/clientConfig.ts index d09e0b8..2600020 100644 --- a/src/core/clientConfig.ts +++ b/src/core/clientConfig.ts @@ -1,4 +1,6 @@ -export interface ClientConfig { +import type { FetchTransportConfig } from "./fetchTransport"; + +export interface ClientConfig extends Pick { /** The instance base URL: "https://www7.v1host.com/V1Production". */ baseUrl: string; /** A ready Authorization header value ("Basic …" for a dev instance). Wins over accessToken. */ diff --git a/src/core/fetchTransport.ts b/src/core/fetchTransport.ts index ff12b30..fbab5f0 100644 --- a/src/core/fetchTransport.ts +++ b/src/core/fetchTransport.ts @@ -31,6 +31,41 @@ export interface FetchTransportConfig { /** Extra attempts after the first, for idempotent requests only. Default 0. */ retries?: number; fetchImpl?: typeof fetch; + /** + * fetch's credentials mode. A browser app on the instance's own origin + * that authenticates with the session cookie sets "same-origin" or + * "include" (pair it with defaultHeaders { "X-Requested-With": + * "XMLHttpRequest" } so the server answers 401 rather than a login page). + * Left unset, fetch's default applies. + */ + credentials?: RequestCredentials; + /** + * Called when the server answers 401 (or a status in unauthorizedStatuses): + * the place to re-authenticate — open a login popup, re-run setup. Resolve + * "retry" to send the request once more, "fail" to throw the error. + * + * A retry is offered only for an idempotent request (every GET; a write the + * caller marked idempotent). A plain api/asset write is never resent + * automatically, even after a 401: the hook still runs, so the session + * can be repaired, and the caller decides whether to send again. + */ + onUnauthorized?: (context: UnauthorizedContext) => UnauthorizedDecision | Promise; + /** Which statuses call onUnauthorized. Default [401]; a cookie-session app may add 403. */ + unauthorizedStatuses?: readonly number[]; +} + +export type UnauthorizedDecision = "retry" | "fail"; + +export interface UnauthorizedContext { + error: ApiError; + request: RequestOptions; + /** Whether a "retry" will be honoured: false for a non-idempotent write. */ + retryable: boolean; +} + +/** The ApiError a caller's abort turns into. */ +function abortedError(signal: AbortSignal): ApiError { + return new ApiError("Request aborted", 0, "ABORTED", { cause: signal.reason }); } function buildUrl(baseUrl: string, path: string, query?: RequestOptions["query"]): string { @@ -101,6 +136,7 @@ export function createFetchTransport(config: FetchTransportConfig): Transport { const timeoutMs = config.timeoutMs ?? 60_000; const retries = Math.max(0, config.retries ?? 0); const authorization = config.authorization ?? (config.accessToken ? `Bearer ${config.accessToken}` : undefined); + const unauthorizedStatuses = config.unauthorizedStatuses ?? [401]; return { async request(options: RequestOptions): Promise { @@ -114,15 +150,29 @@ export function createFetchTransport(config: FetchTransportConfig): Transport { const idempotent = options.idempotent ?? options.method === "GET"; const attempts = idempotent ? retries + 1 : 1; + const callerSignal = options.signal; + let lastError: unknown; + let reauthorized = false; for (let attempt = 0; attempt < attempts; attempt += 1) { + if (callerSignal?.aborted) throw abortedError(callerSignal); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + const onAbort = () => controller.abort(); + callerSignal?.addEventListener("abort", onAbort, { once: true }); + const settle = () => { + clearTimeout(timeoutId); + callerSignal?.removeEventListener("abort", onAbort); + }; let response: Response; try { - response = await fetchImpl(url, { method: options.method, headers, body, signal: controller.signal }); + const init: RequestInit = { method: options.method, headers, body, signal: controller.signal }; + if (config.credentials) init.credentials = config.credentials; + response = await fetchImpl(url, init); } catch (error) { - clearTimeout(timeoutId); + settle(); + // The caller gave up: final, whatever attempt this was. + if (callerSignal?.aborted) throw abortedError(callerSignal); // Network failure or timeout: the only case a retry can help. lastError = error instanceof Error && error.name === "AbortError" ? new ApiError(`Timed out after ${timeoutMs} ms`, 0, "TIMEOUT", { cause: error }) @@ -130,14 +180,33 @@ export function createFetchTransport(config: FetchTransportConfig): Transport { if (attempt + 1 < attempts) await delay(150 * (attempt + 1)); continue; } - clearTimeout(timeoutId); - if (response.ok) return (await readBody(response, responseType)) as T; + try { + if (response.ok) return (await readBody(response, responseType)) as T; + + // An answer, not a failure to get one. 5xx may be retried when + // idempotent; anything else is final. + const data = await readBody(response, responseType === "bytes" ? "text" : responseType === "json" ? "auto" : responseType); + lastError = new ApiError(messageOf(data, response.status), response.status, undefined, data); + } catch (error) { + if (callerSignal?.aborted) throw abortedError(callerSignal); + throw error; + } finally { + settle(); + } - // An answer, not a failure to get one. 5xx may be retried when - // idempotent; anything else is final. - const data = await readBody(response, responseType === "bytes" ? "text" : responseType === "json" ? "auto" : responseType); - lastError = new ApiError(messageOf(data, response.status), response.status, undefined, data); + if (config.onUnauthorized && !reauthorized && unauthorizedStatuses.includes(response.status)) { + // One re-authentication per request: a second refusal is final. + reauthorized = true; + const decision = await config.onUnauthorized({ error: lastError as ApiError, request: options, retryable: idempotent }); + if (callerSignal?.aborted) throw abortedError(callerSignal); + if (decision === "retry" && idempotent) { + // Not counted against `retries`: those are for failures to get an answer. + attempt -= 1; + continue; + } + break; + } if (response.status >= 500 && attempt + 1 < attempts) { await delay(150 * (attempt + 1)); continue; diff --git a/src/core/transport.ts b/src/core/transport.ts index 3e8870b..523a5a4 100644 --- a/src/core/transport.ts +++ b/src/core/transport.ts @@ -24,6 +24,11 @@ export interface RequestOptions { * never idempotent unless the caller has made it so with a guard. */ idempotent?: boolean; + /** + * The caller's cancellation: aborting it ends the request (and any retry + * still to come) with an ApiError coded ABORTED. Never retried. + */ + signal?: AbortSignal; } export interface Transport { diff --git a/src/index.ts b/src/index.ts index ba670a2..22a6e3d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ export { AgilityApiClient } from "./client"; export type { ClientOptions } from "./client"; export { ApiError } from "./core/errors"; export { createFetchTransport } from "./core/fetchTransport"; +export type { FetchTransportConfig, UnauthorizedContext, UnauthorizedDecision } from "./core/fetchTransport"; export type { ClientConfig } from "./core/clientConfig"; export type { RequestBody, RequestOptions, ResponseType, Transport } from "./core/transport"; diff --git a/test/unit/transportAuth.test.ts b/test/unit/transportAuth.test.ts new file mode 100644 index 0000000..4bd05e4 --- /dev/null +++ b/test/unit/transportAuth.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it, vi } from "vitest"; +import { AgilityApiClient } from "../../src/client"; +import { ApiError } from "../../src/core/errors"; +import { createFetchTransport } from "../../src/core/fetchTransport"; + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json; charset=utf-8" } }); +const unauthorized = () => json({ _type: "Error", error: "Unauthorized" }, 401); + +// A1: what a browser app (rooms) and a re-authenticating bot need from the transport. +describe("transport for real callers", () => { + it("passes fetch a credentials mode only when one is configured", async () => { + const fetchImpl = vi.fn().mockImplementation(() => Promise.resolve(json({}))); + await createFetchTransport({ baseUrl: "https://x", fetchImpl, credentials: "same-origin", defaultHeaders: { "X-Requested-With": "XMLHttpRequest" } }).request({ method: "GET", path: "/a" }); + await createFetchTransport({ baseUrl: "https://x", fetchImpl }).request({ method: "GET", path: "/a" }); + expect(fetchImpl.mock.calls[0]![1].credentials).toBe("same-origin"); + expect(fetchImpl.mock.calls[0]![1].headers["X-Requested-With"]).toBe("XMLHttpRequest"); + expect("credentials" in fetchImpl.mock.calls[1]![1]).toBe(false); + }); + + it("calls onUnauthorized on a 401 and resends a GET once when it says retry", async () => { + const fetchImpl = vi.fn().mockResolvedValueOnce(unauthorized()).mockResolvedValueOnce(json({ ok: true })); + const onUnauthorized = vi.fn().mockResolvedValue("retry"); + const transport = createFetchTransport({ baseUrl: "https://x", fetchImpl, onUnauthorized }); + expect(await transport.request({ method: "GET", path: "/a" })).toEqual({ ok: true }); + expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(onUnauthorized).toHaveBeenCalledTimes(1); + const [context] = onUnauthorized.mock.calls[0]!; + expect(context.error).toBeInstanceOf(ApiError); + expect(context.error.status).toBe(401); + expect(context.retryable).toBe(true); + expect(context.request.path).toBe("/a"); + }); + + it("re-authenticates once per request: a second 401 is final, and 'fail' throws the first", async () => { + const twice = vi.fn().mockImplementation(() => Promise.resolve(unauthorized())); + const retry = vi.fn().mockReturnValue("retry"); + await expect(createFetchTransport({ baseUrl: "https://x", fetchImpl: twice, onUnauthorized: retry }).request({ method: "GET", path: "/a" })).rejects.toMatchObject({ status: 401 }); + expect(twice).toHaveBeenCalledTimes(2); + expect(retry).toHaveBeenCalledTimes(1); + + const once = vi.fn().mockImplementation(() => Promise.resolve(unauthorized())); + await expect(createFetchTransport({ baseUrl: "https://x", fetchImpl: once, onUnauthorized: () => "fail" }).request({ method: "GET", path: "/a" })).rejects.toMatchObject({ status: 401 }); + expect(once).toHaveBeenCalledTimes(1); + }); + + it("never resends a plain api/asset write after a 401, but still lets the hook repair the session", async () => { + const fetchImpl = vi.fn().mockImplementation(() => Promise.resolve(unauthorized())); + const onUnauthorized = vi.fn().mockResolvedValue("retry"); + const transport = createFetchTransport({ baseUrl: "https://x", fetchImpl, onUnauthorized }); + await expect(transport.request({ method: "POST", path: "/api/asset", body: [] })).rejects.toMatchObject({ status: 401 }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(onUnauthorized.mock.calls[0]![0].retryable).toBe(false); + // A guarded (idempotent) write may be resent. + const guarded = vi.fn().mockResolvedValueOnce(unauthorized()).mockResolvedValueOnce(json({})); + await createFetchTransport({ baseUrl: "https://x", fetchImpl: guarded, onUnauthorized }).request({ method: "POST", path: "/api/asset", body: [], idempotent: true }); + expect(guarded).toHaveBeenCalledTimes(2); + }); + + it("does not count a re-authenticated retry against `retries`, and honours unauthorizedStatuses", async () => { + const fetchImpl = vi.fn() + .mockResolvedValueOnce(json({}, 403)) + .mockResolvedValueOnce(json({ error: "boom" }, 503)) + .mockResolvedValueOnce(json({ ok: 1 })); + const transport = createFetchTransport({ baseUrl: "https://x", fetchImpl, retries: 1, onUnauthorized: () => "retry", unauthorizedStatuses: [401, 403] }); + expect(await transport.request({ method: "GET", path: "/a" })).toEqual({ ok: 1 }); + expect(fetchImpl).toHaveBeenCalledTimes(3); + // 403 is an answer, not a login prompt, unless the caller says so. + const hook = vi.fn(); + await expect(createFetchTransport({ baseUrl: "https://x", fetchImpl: vi.fn().mockResolvedValue(json({}, 403)), onUnauthorized: hook }).request({ method: "GET", path: "/a" })).rejects.toMatchObject({ status: 403 }); + expect(hook).not.toHaveBeenCalled(); + }); + + it("ends a request the caller aborts with ABORTED and never retries it", async () => { + const hang = vi.fn().mockImplementation((_url: string, init: RequestInit) => new Promise((_resolve, reject) => { + init.signal?.addEventListener("abort", () => reject(Object.assign(new Error("aborted"), { name: "AbortError" }))); + })); + const transport = createFetchTransport({ baseUrl: "https://x", fetchImpl: hang, retries: 3 }); + const controller = new AbortController(); + const pending = transport.request({ method: "GET", path: "/slow", signal: controller.signal }); + controller.abort(); + await expect(pending).rejects.toMatchObject({ code: "ABORTED", status: 0 }); + expect(hang).toHaveBeenCalledTimes(1); + // Already aborted: nothing is sent. + const idle = vi.fn(); + await expect(createFetchTransport({ baseUrl: "https://x", fetchImpl: idle }).request({ method: "GET", path: "/a", signal: controller.signal })).rejects.toMatchObject({ code: "ABORTED" }); + expect(idle).not.toHaveBeenCalled(); + }); + + it("client.withSignal ties every request to the signal and shares the schema", async () => { + const transport = { request: vi.fn().mockResolvedValue({ Story: "Story" }) }; + const base = new AgilityApiClient({ baseUrl: "https://x" }, { transport }); + const controller = new AbortController(); + const scoped = base.withSignal(controller.signal); + expect(scoped.schema).toBe(base.schema); + await scoped.localize(["Story"]); + expect(transport.request.mock.calls[0]![0].signal).toBe(controller.signal); + // The original client is untouched. + await base.localize(["Story"]); + expect(transport.request.mock.calls[1]![0].signal).toBeUndefined(); + }); + + it("the client passes credentials and onUnauthorized through to its fetch transport", async () => { + const original = globalThis.fetch; + const fetchMock = vi.fn().mockResolvedValueOnce(unauthorized()).mockResolvedValueOnce(json({ Story: "Story" })); + globalThis.fetch = fetchMock as unknown as typeof fetch; + try { + const onUnauthorized = vi.fn().mockResolvedValue("retry"); + const client = new AgilityApiClient({ baseUrl: "https://x", credentials: "include", onUnauthorized }); + await client.localize(["Story"]); + expect(onUnauthorized).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[1]![1].credentials).toBe("include"); + } finally { + globalThis.fetch = original; + } + }); +}); From 51b2af80dbda3ca86977fdbd8aa9c2eb2bb85f3f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 12:25:40 +0000 Subject: [PATCH 2/6] Briefs: allow writes to the API/SDK testing instance only Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016NSWj9gL1oCubj5e2anVkb --- plans/002-adopt-sdk-gh-v1.md | 7 +++++-- plans/003-adopt-sdk-k6i-agility-slack-bot.md | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/plans/002-adopt-sdk-gh-v1.md b/plans/002-adopt-sdk-gh-v1.md index 52f4202..8c53306 100644 --- a/plans/002-adopt-sdk-gh-v1.md +++ b/plans/002-adopt-sdk-gh-v1.md @@ -60,8 +60,11 @@ The SDK's `prepare` script builds `dist` on install. gh-v1 is ESM now, so use - Requests out must match today's, apart from what translation changes. Add a test that asserts the request body where it differs, and say why. - Every query keeps an explicit page (the SDK refuses one without). -- No live Agility writes. Live reads only against the API-testing instance, - with an explicit page size, well under 20 s of server work. +- Live Agility calls go only to the API/SDK testing instance, + `https://www14.v1host.com/v1sdktesting` (`V1_URL`). Writes are allowed + there and nowhere else: name fixtures `sdk-integration …` and delete them + by exact OID when done. Every query sets an explicit page size and stays + well under 20 s of server work. ## Done when diff --git a/plans/003-adopt-sdk-k6i-agility-slack-bot.md b/plans/003-adopt-sdk-k6i-agility-slack-bot.md index 2699ab7..b9f8131 100644 --- a/plans/003-adopt-sdk-k6i-agility-slack-bot.md +++ b/plans/003-adopt-sdk-k6i-agility-slack-bot.md @@ -63,8 +63,11 @@ The SDK's `prepare` script builds `dist` on install. The bot is ESM, so without). Values from user text go in `with` variables, not spliced into `filter`/`where` (an em dash breaks a literal). - Fixtures send what the server sends (S4): `"True"`, not `true`. -- No live Agility writes from the session. Live reads only with an explicit - page size and well under 20 s of server work. +- Live Agility calls go only to the API/SDK testing instance, + `https://www14.v1host.com/v1sdktesting` (`V1_URL`). Writes are allowed + there and nowhere else: name fixtures `sdk-integration …` and delete them + by exact OID when done. Every query sets an explicit page size and stays + well under 20 s of server work. ## Done when From 06e03b8e6fee64659f3b64dc1c620f71a4c858e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 12:29:40 +0000 Subject: [PATCH 3/6] Allow the integration tier without a prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runner itself refuses V1Production and any hosted instance not set aside for API testing (spec 000 §5). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016NSWj9gL1oCubj5e2anVkb --- .claude/settings.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .claude/settings.json diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..f43b635 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(npm run test:integration*)" + ] + } +} From 3acd1260b64f5fd297f025e60144f4cb5cd89dc3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 13:05:09 +0000 Subject: [PATCH 4/6] Brief 002: gh-v1 at 2c58c7e, sh launcher in front of gh-v1.js Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016NSWj9gL1oCubj5e2anVkb --- plans/002-adopt-sdk-gh-v1.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/plans/002-adopt-sdk-gh-v1.md b/plans/002-adopt-sdk-gh-v1.md index 8c53306..c1697ab 100644 --- a/plans/002-adopt-sdk-gh-v1.md +++ b/plans/002-adopt-sdk-gh-v1.md @@ -9,14 +9,17 @@ this file has merged into `proposed_type_script_api_version`. Source plan: gh-v1 talks to Agility only through `@versionone/sdk`. CLI behaviour and output stay the same; the code that duplicates the SDK goes. -## Where things stand (checked 2026-11-06, gh-v1 `676bcd8`) +## Where things stand (checked 2026-11-06, gh-v1 `2c58c7e`) - G0–G3 are merged: executable bin, `V1_URL`/`V1_ACCESS_TOKEN` config, ESM (`"type": "module"`), `jsconfig.json` + `npm run typecheck`, one paging module (`lib/paging.js`). +- The bin `gh-v1` is now a `sh` launcher that runs `gh-v1.js` with the real + node (bypassing Volta's shim, which mangles arguments on Windows). The + swap does not touch either; keep `test/launcher.test.js` green. - One seam: `lib/api.js` exports `api(cfg)` returning `query`, `queries`, `asset`, `attempt`, `rest`, `meta`, `localize`, `raw`, `blob`, - `permalink`, typed by the `Api` JSDoc typedef. 104 tests under `test/` + `permalink`, typed by the `Api` JSDoc typedef. Its tests under `test/` stub global `fetch`. - String-boolean and OID helpers (`isTrue`, `oidOf`, `momentless`) are used in `lib/api.js`, `meta.js`, `resolve.js`, `board.js`, `batch.js`, and From 5452f52714c82b234130b0376b116d87b2e94e7a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 13:05:41 +0000 Subject: [PATCH 5/6] Revert "Brief 002: gh-v1 at 2c58c7e, sh launcher in front of gh-v1.js" This reverts commit 3acd126. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016NSWj9gL1oCubj5e2anVkb --- plans/002-adopt-sdk-gh-v1.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/plans/002-adopt-sdk-gh-v1.md b/plans/002-adopt-sdk-gh-v1.md index c1697ab..8c53306 100644 --- a/plans/002-adopt-sdk-gh-v1.md +++ b/plans/002-adopt-sdk-gh-v1.md @@ -9,17 +9,14 @@ this file has merged into `proposed_type_script_api_version`. Source plan: gh-v1 talks to Agility only through `@versionone/sdk`. CLI behaviour and output stay the same; the code that duplicates the SDK goes. -## Where things stand (checked 2026-11-06, gh-v1 `2c58c7e`) +## Where things stand (checked 2026-11-06, gh-v1 `676bcd8`) - G0–G3 are merged: executable bin, `V1_URL`/`V1_ACCESS_TOKEN` config, ESM (`"type": "module"`), `jsconfig.json` + `npm run typecheck`, one paging module (`lib/paging.js`). -- The bin `gh-v1` is now a `sh` launcher that runs `gh-v1.js` with the real - node (bypassing Volta's shim, which mangles arguments on Windows). The - swap does not touch either; keep `test/launcher.test.js` green. - One seam: `lib/api.js` exports `api(cfg)` returning `query`, `queries`, `asset`, `attempt`, `rest`, `meta`, `localize`, `raw`, `blob`, - `permalink`, typed by the `Api` JSDoc typedef. Its tests under `test/` + `permalink`, typed by the `Api` JSDoc typedef. 104 tests under `test/` stub global `fetch`. - String-boolean and OID helpers (`isTrue`, `oidOf`, `momentless`) are used in `lib/api.js`, `meta.js`, `resolve.js`, `board.js`, `batch.js`, and From 227c5e40b755ac79c4eb6aae93ce7377d151ad8b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 13:06:09 +0000 Subject: [PATCH 6/6] Reapply "Brief 002: gh-v1 at 2c58c7e, sh launcher in front of gh-v1.js" This reverts commit 5452f52. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016NSWj9gL1oCubj5e2anVkb --- plans/002-adopt-sdk-gh-v1.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/plans/002-adopt-sdk-gh-v1.md b/plans/002-adopt-sdk-gh-v1.md index 8c53306..c1697ab 100644 --- a/plans/002-adopt-sdk-gh-v1.md +++ b/plans/002-adopt-sdk-gh-v1.md @@ -9,14 +9,17 @@ this file has merged into `proposed_type_script_api_version`. Source plan: gh-v1 talks to Agility only through `@versionone/sdk`. CLI behaviour and output stay the same; the code that duplicates the SDK goes. -## Where things stand (checked 2026-11-06, gh-v1 `676bcd8`) +## Where things stand (checked 2026-11-06, gh-v1 `2c58c7e`) - G0–G3 are merged: executable bin, `V1_URL`/`V1_ACCESS_TOKEN` config, ESM (`"type": "module"`), `jsconfig.json` + `npm run typecheck`, one paging module (`lib/paging.js`). +- The bin `gh-v1` is now a `sh` launcher that runs `gh-v1.js` with the real + node (bypassing Volta's shim, which mangles arguments on Windows). The + swap does not touch either; keep `test/launcher.test.js` green. - One seam: `lib/api.js` exports `api(cfg)` returning `query`, `queries`, `asset`, `attempt`, `rest`, `meta`, `localize`, `raw`, `blob`, - `permalink`, typed by the `Api` JSDoc typedef. 104 tests under `test/` + `permalink`, typed by the `Api` JSDoc typedef. Its tests under `test/` stub global `fetch`. - String-boolean and OID helpers (`isTrue`, `oidOf`, `momentless`) are used in `lib/api.js`, `meta.js`, `resolve.js`, `board.js`, `batch.js`, and