diff --git a/docs/specs/guarded-approval-http.md b/docs/specs/guarded-approval-http.md new file mode 100644 index 0000000..f675abe --- /dev/null +++ b/docs/specs/guarded-approval-http.md @@ -0,0 +1,27 @@ +# Guarded approval HTTP client + +This opt-in, unpublished core client wraps the [guarded data contract](guarded-approval-contract.md). Existing delivery, authoring and localization APIs stay unchanged. A compatible server must verify actual identity, current admission and receipt ownership. JWT shape validation and response parsing establish none of those facts. + +## Interface and configuration + +`createLingvaGuardedApprovalHttpClient({endpoint,getBearerToken,fetcher?,timeoutMs?})` returns `getContext(scope,{signal?}?)` and `approve(command,{signal?}?)`. They resolve to the existing context/approval response types. `getBearerToken(signal)` supplies a raw string synchronously or asynchronously. Configuration is captured when the factory runs. Each invocation validates, snapshots and serializes its input before awaiting credentials, without freezing or mutating caller data. + +Endpoints are explicit absolute HTTPS URLs, or HTTP with literal `127.0.0.1` or `[::1]` hosts for owned local tests. Reject userinfo, relative URLs, queries and fragments, including empty delimiters. Endpoint and injected Fetch are trusted configuration, not a general SSRF sandbox. There is no hosted default, ambient environment/cookie credential, arbitrary RequestInit, OAuth flow, retry, polling, UUID generation, persistence or legacy fallback. + +Send one POST with the exact existing builder payload and explicit Authorization Bearer and JSON content-type headers. Set `redirect:error`, `credentials:omit`, `cache:no-store`, `referrerPolicy:no-referrer`. Reject empty, prefixed, whitespace/control-bearing, non-three-part-base64url or over16KiB UTF-8 token strings locally. Tokens and claims never enter business data, errors or retained client state beyond the active invocation. + +## Bounds and observations + +Default timeout is5000ms; optional timeout is an integer1..5000ms. One application deadline covers credential acquisition, Fetch and complete body consumption, even if a dependency ignores abort. Caller cancellation composes with it. Dispose timers/listeners and safely cancel/release readers on exit. Cancellation never proves remote rollback. + +Read successful bodies as byte streams, with an actual128KiB ceiling independent of Content-Length. Require strict UTF-8 JSON and use the existing parser against captured scope or UUID. Missing bodies, malformed data and mismatches fail. Return a fresh known-field projection, retaining recognized optional fields. Approval success acknowledges only the UUID; captured command supplies its target association. Non-2xx bodies are discarded/canceled and never exposed. + +`LingvaGuardedApprovalHttpError` carries static message, `kind`, optional numeric `status`, `phase:local|request`, and `recovery:none|keep-command`. It contains no URL, token, raw exception/cause, server text, scope, subject, translation or command. HTTP400/413 map to invalid-request;401 unauthenticated;403 forbidden;404 not-found;409 conflict;5xx unavailable. Unexpected statuses or malformed successes are invalid-response. Local validation is invalid-request; a throwing credential callback is unavailable; network/cancellation/deadline is transport. Phase becomes request only when Fetch is invoked. All failed approve attempts, including local validation and401/403/409, carry keep-command. Context failures carry none. Factory errors are local/none. + +## Intent recovery + +The caller owns captured intent and authenticated project/account scope. After any failed approve, retain the same command, UUID and context token. A later explicit invocation may obtain a rotated credential but sends the identical body. Never refresh context, remint a UUID or change target automatically. A locally rejected retry says nothing about an earlier uncertain outcome. Only a valid matching acknowledgment resolves the captured command. No persistence across navigation or session changes is provided. + +## Validation and exclusions + +Tests use synthetic credentials and injected Fetch, plus owned loopback HTTP servers for default Fetch redirect and receipt-recovery contracts. A loopback receipt simulation is not proof of a real server transaction or database atomicity. Packed ESM/CJS/TypeScript consumers prove exports. No server source, package version/license/dependency change, package publication, existing caller adoption, provider call or deployment belongs to this batch. Removing the additive helper/exports reverses this unpublished candidate. diff --git a/docs/superpowers/plans/2026-10-29-guarded-approval-http.md b/docs/superpowers/plans/2026-10-29-guarded-approval-http.md new file mode 100644 index 0000000..861c6f8 --- /dev/null +++ b/docs/superpowers/plans/2026-10-29-guarded-approval-http.md @@ -0,0 +1,92 @@ +# Guarded Approval HTTP Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking. + +**Goal:** Add an opt-in bounded core HTTP client for guarded context and exact-command approval recovery. + +**Architecture:** A focused transport module reuses existing builders and parsers. One attempt owns its deadline, abort composition, Fetch and bounded byte reader. Existing delivery and authoring owners are unchanged. + +**Tech Stack:** TypeScript, standard Fetch/AbortController/ReadableStream, Vitest, Node owned-loopback HTTP, protected package build scripts. + +**Spec:** `docs/specs/guarded-approval-http.md` + +## Global Constraints + +- Native inline execution in the existing isolated SDK worktree; one fresh whole-branch review at the end. +- Default5000ms; optional integer1..5000ms;16KiB credentials;128KiB streamed response. Existing16KiB request codec bound applies. +- No automatic retry, legacy adoption, persistence, server changes, dependency/version/license changes, package publication or hosted calls. +- Public documentation and fixtures are self-contained. Credentials are synthetic; network tests use owned loopback only. + +## Review Focus + +- Caller/config mutation during deferred credentials must not change the captured wire or validation target (Task1). +- A dependency that ignores abort and settles late must not retain a response reader or create an unhandled rejection (Task1). +- Many empty stream chunks must not starve the application deadline or retain unbounded chunk objects (Task1). +- Errors thrown by dependencies, even public error instances with sensitive text, must not escape sanitized classification (Task1). +- Uncertain acknowledgment followed by denied retries must preserve the original intent until explicit matching success (Task2). + +### Task 1: Portable bounded HTTP client + +**Files:** + +- Create: `packages/core/src/guarded-approval-http.ts` +- Create: `packages/core/src/guarded-approval-http.test.ts` +- Modify: `packages/core/src/index.ts` + +**Interfaces:** + +- Consumes existing guarded scope/command/builders/parsers and response types. +- Produces `createLingvaGuardedApprovalHttpClient(options: LingvaGuardedApprovalHttpOptions): LingvaGuardedApprovalHttpClient`, methods `getContext(scope,options?): Promise` and `approve(command,options?): Promise`. +- Exports error class and named error-kind constants, timeout/token constants, options/client/call-options/kind/phase/recovery types. Error fields are readonly `kind`, `status?`, `phase`, `recovery`. + +- [x] Write tests importing the public index namespace; assert factory/error exports exist, literal POST wire, headers/options, recognized context projection and matching acknowledgment. Test deferred credential/config/caller mutation, input/endpoint/token limits, zero network calls on invalid input and safe errors for every status/classification. +- [x] Add byte-stream tests: exact128KiB success/one byte over, split multibyte UTF-8, invalid UTF-8/JSON/missing body, wrong full locator/UUID, ignored Content-Length, cancellation and released locks. Add stalled callback/Fetch/body, pre/mid abort, late responses/rejections, empty-chunk deadline, timer/listener cleanup and at most one network call. +- [x] Run `pnpm --filter @lingva/core test -- src/guarded-approval-http.test.ts`. + Expected: assertion failure because factory export is missing, not a missing-module error. +- [x] Implement the interface in the new module. Capture config at factory creation and payload/body before the first await. Use a single application deadline and internal-only failure classification; never trust dependency error text. Check elapsed monotonic time between stream chunks as well as timer abort. Cancel/discard late responses without awaiting unbounded cleanup. +- [x] Run `pnpm --filter @lingva/core test` and core lint/typecheck. + Expected: existing250 plus all new tests pass; static gates pass. +- [x] Commit client, exports, tests, self-contained spec and this plan. +- [x] Completion command: `pnpm --filter @lingva/core test`. + +### Task 2: Real Fetch and packaged-consumer contracts + +**Files:** + +- Create: `packages/core/src/guarded-approval-http.loopback.test.ts` +- Create: `packages/core/test/guarded-approval-http-consumer.cjs` +- Modify: `packages/core/test/guarded-approval-consumer.types.ts` +- Modify: `scripts/verify-public-packages.mjs` + +**Interfaces:** + +- Consumes Task1 factory/error/types and unchanged codec scope/command types. +- Produces owned-loopback contract tests and async ESM/CJS/TypeScript package smoke, without an actual server implementation. + +- [x] Write owned-loopback tests for default Fetch POST/header/body, redirect target hit count0, no cookie/credential fallback, stalled body deadline and simulated receipt/lost-ack followed by401/403/409 and explicit identical-command success. Assert one simulated commit, body/UUID equality and no automatic requests. Write packed consumer smoke asserting new exports, both operations and static error fields; add compile-time invalid options/identity override assertions. +- [x] Run core loopback tests; run new smoke against baseline main built core. + Expected: loopback tests pass on Task1 behavior; baseline packed smoke fails with missing-export assertion, proving consumer wiring before modification. +- [x] Wire async HTTP smoke into both packed ESM and CJS entrypoints; preserve existing codec smoke and all11 package checks. +- [x] Run `pnpm --filter @lingva/core test`, `pnpm build:packages`, `pnpm verify:packages`. + Expected: all core cases and11 packed consumers including ESM/CJS/TypeScript pass; no registry publication occurs. +- [x] Commit owned-loopback and consumer contracts. +- [x] Completion command: `pnpm --filter @lingva/core test`. + +### Task 3: Whole-workspace verification and delivery record + +**Files:** + +- Create: `docs/verification/guarded-approval-http.md` +- Modify: plan checkboxes after observed completion. + +**Interfaces:** + +- Consumes Task1 client and Task2 test/package contracts. +- Produces dated local verification record with exact counts, limitations and one public candidate PR. + +- [x] Run `pnpm test`, `pnpm lint`, `pnpm typecheck`, `pnpm test:dependency-security`, `pnpm audit:prod` and inspect results. Run build/packages again only if changes invalidate prior artifacts. + Expected: workspace gates pass; audit records existing documented patched-artifact exceptions accurately. +- [x] Verify unchanged legacy codec/authoring/delivery sources, manifests/licenses/dependency pins and original SDK checkout. Record command results and loopback simulation limits. +- [x] Commit verification record. Completion command: `pnpm --filter @lingva/core test`. +- [x] Dispatch one fresh whole-branch review after all task gates. Regrade findings by user effect; fix Important/Critical once with genuine regression RED/GREEN and green suite. Ledger minors and every ruling. +- [x] Publish one public SDK PR and attach it. Delivery: [candidate PR12](https://github.com/usausoft/lingva/pull/12). Project reconciliation and exact-head read-back are recorded in delivery evidence. No successor roadmap PR or SDK merge in this batch. diff --git a/docs/verification/guarded-approval-http.md b/docs/verification/guarded-approval-http.md new file mode 100644 index 0000000..f911d1d --- /dev/null +++ b/docs/verification/guarded-approval-http.md @@ -0,0 +1,27 @@ +# Guarded approval HTTP local verification + +29 October2026. Candidate behavior is specified in [the HTTP contract](../specs/guarded-approval-http.md). All credentials and data below are synthetic. Existing callers remain on their existing APIs. + +## Observed gates + +- Core: 330 tests in 12 files, including 65 injected-Fetch cases, 8 owned-loopback cases and 7 lifecycle regression cases. The250 existing core cases remain green. The missing public factory produced genuine assertion RED before implementation; the old built package produced a missing-export assertion RED before adding packed HTTP consumer coverage. +- `pnpm test`:31 workspace tasks passed. `pnpm lint`:28 tasks plus repository lint passed. `pnpm typecheck`:29 tasks passed. +- `pnpm build:packages`:11 protected public package builds passed. `pnpm verify:packages`:all11 packed consumers passed, including the new guarded HTTP ESM/CJS behavior and TypeScript positive/negative contracts. No registry publication command ran. +- `pnpm test:dependency-security`:23 passed. `pnpm audit:prod` exited0 with the existing two reviewed patched-artifact exceptions (one low, one high). This is not an exception-free audit; see [dependency repair evidence](../specs/workspace-dependency-security.md). +- Legacy codec, authoring and delivery sources, package manifests, lock/workspace files and license files match the branch base (51 preserved files). Original SDK checkout remains clean at its prior revision. + +## Transport evidence + +Injected tests cover literal guarded wire, captured configuration/intent before deferred credentials, token and endpoint validation, byte ceilings, multibyte boundaries, malformed observations, static failure projection, stalled dependencies, caller abort, late resolution/rejection and reader/timer/listener cleanup. An endless empty-chunk stream terminates through elapsed-time checks without accumulating chunks. + +Default Fetch against owned loopback servers sends explicit bearer credentials without cookies. Five redirect statuses301/302/303/307/308 each hit their source once and never reach the destination. A real body stalls after headers and fails within the configured budget. A receipt fixture simulates one commit and lost acknowledgment, then a local invalid-credential attempt and HTTP401/403/409; final explicit credential rotation sends the identical command/UUID and receives the original acknowledgment. There are five network attempts and one simulated commit. No hidden retry occurs. + +The receipt fixture is a transport contract simulation, not evidence of any server's authorization or database atomicity. Parsing JWT shape is not identity verification. Cancellation does not establish rollback. Callers retain their captured command until a matching success. + +## Delivery boundary + +This remains an unpublished opt-in candidate. Package versions, licenses and existing API behavior are unchanged. Server implementation, existing caller adoption, automatic recovery persistence, provider operations and deployment are excluded. One fresh whole-branch review identified four Important lifecycle issues and one interoperability issue, promoted to Important because valid cross-realm byte chunks must work. All five were corrected in one pass with seven assertions observed failing against the original implementation, then passing with the complete 330-case suite. No findings remain deferred. + +The corrections replace shared interruption-promise reactions with removable subscriptions, attach rejection handlers before cancellation checks, capture response ownership at fulfillment, make cleanup nonthrowing, and accept cross-realm Uint8Array chunks. A forced-GC subprocess retained all 10,000 empty chunks before the correction; after correction its assertion permits at most two while the next read remains stalled. Other regressions cover synchronous abort during credential/Fetch entry, cancellation immediately after response fulfillment, throwing body/listener cleanup and foreign-realm bytes. Subprocesses load current source with the declared Vite transformer in middleware mode without listening; they do not rebuild shared dist during workspace compilation. Protected package builds and packed consumers run separately. + +The final workspace, static, protected-build, packed-consumer, dependency-security and audit gates above were rerun after corrections. Server identity/admission, receipt retention and database atomicity remain outside this client and unproven by these tests. Existing caller adoption and cross-session persistence remain caller-owned; this additive client does not supply them. diff --git a/packages/core/src/guarded-approval-http.lifecycle.test.ts b/packages/core/src/guarded-approval-http.lifecycle.test.ts new file mode 100644 index 0000000..a32a4da --- /dev/null +++ b/packages/core/src/guarded-approval-http.lifecycle.test.ts @@ -0,0 +1,114 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { runInNewContext } from 'node:vm'; +import { describe, expect, it, vi } from 'vitest'; +import { + createLingvaGuardedApprovalHttpClient as create, + LingvaGuardedApprovalHttpError, +} from './guarded-approval-http'; +const scope = { + projectId: 'synthetic_project', + environment: 'dev', + locator: { entityKey: 'authoring#dev#fr#hello#draft_one', draftId: 'draft_one', key: 'hello', locale: 'fr' }, +}; +const command = { ...scope, contextToken: 'a'.repeat(64), operationId: '11111111-1111-4111-8111-111111111111' }; +const ack = { + ok: true, + result: { + operationId: command.operationId, + reviewRevision: 1, + outcome: 'approved', + approvedAt: '2026-10-29T12:00:00Z', + }, +}; +const config = { endpoint: 'https://example.invalid/review', getBearerToken: () => 'synthetic.payload.signature' }; +const execute = promisify(execFile); +describe('guarded HTTP lifecycle regressions', () => { + it('does not retain settled empty chunks while the current read stalls', async () => { + const result = await execute( + process.execPath, + ['--expose-gc', 'test/guarded-approval-http-subprocess.cjs', 'retention'], + { cwd: process.cwd(), timeout: 10000 } + ); + expect(result.stderr).toBe(''); + }); + it.each(['token', 'fetch'])( + 'handles a rejecting %s promise even when dependency entry aborts synchronously', + async (stage) => { + const result = await execute(process.execPath, ['test/guarded-approval-http-subprocess.cjs', stage], { + cwd: process.cwd(), + timeout: 10000, + }); + expect(result.stderr).toBe(''); + } + ); + it('disposes a response fulfilled immediately before caller cancellation', async () => { + const controller = new AbortController(); + const cancel = vi.fn(); + const body = new ReadableStream({ cancel }); + const api = create({ + ...config, + fetcher: () => { + const response = Promise.resolve(new Response(body)); + void response.then(() => { + queueMicrotask(() => controller.abort()); + }); + return response; + }, + }); + await expect(api.approve(command, { signal: controller.signal })).rejects.toMatchObject({ + kind: 'transport', + recovery: 'keep-command', + }); + expect(cancel).toHaveBeenCalledTimes(1); + expect(body.locked).toBe(false); + }); + it('never lets a throwing Response body getter replace classified errors', async () => { + const sensitive = new LingvaGuardedApprovalHttpError('forbidden', 'local', 'none', 403); + sensitive.message = 'sensitive dependency text'; + class ThrowingResponse extends Response { + override get body(): ReadableStream | null { + throw sensitive; + } + } + const api = create({ ...config, fetcher: async () => new ThrowingResponse('{}') }); + const error = await api.approve(command).catch((error: unknown) => error); + expect(error).not.toBe(sensitive); + expect(error).toMatchObject({ + kind: 'transport', + phase: 'request', + recovery: 'keep-command', + message: 'Guarded approval transport failed.', + }); + }); + it('does not let throwing signal cleanup replace success or static failure', async () => { + const controller = new AbortController(); + const remove = vi.spyOn(controller.signal, 'removeEventListener').mockImplementation(() => { + throw new Error('sensitive cleanup'); + }); + try { + await expect( + create({ ...config, fetcher: async () => Response.json(ack) }).approve(command, { signal: controller.signal }) + ).resolves.toEqual(ack); + await expect( + create({ ...config, fetcher: async () => new Response(null, { status: 401 }) }).approve(command, { + signal: controller.signal, + }) + ).rejects.toMatchObject({ kind: 'unauthenticated', recovery: 'keep-command' }); + } finally { + remove.mockRestore(); + } + }); + it('accepts a valid Uint8Array chunk from another JavaScript realm', async () => { + const data = new TextEncoder().encode(JSON.stringify(ack)); + const foreign = runInNewContext('new Uint8Array(bytes)', { bytes: [...data] }) as Uint8Array; + expect(foreign instanceof Uint8Array).toBe(false); + const body = new ReadableStream({ + start(controller) { + controller.enqueue(foreign); + controller.close(); + }, + }); + await expect(create({ ...config, fetcher: async () => new Response(body) }).approve(command)).resolves.toEqual(ack); + }); +}); diff --git a/packages/core/src/guarded-approval-http.loopback.test.ts b/packages/core/src/guarded-approval-http.loopback.test.ts new file mode 100644 index 0000000..0e879f6 --- /dev/null +++ b/packages/core/src/guarded-approval-http.loopback.test.ts @@ -0,0 +1,209 @@ +/** Owned transport simulation only; receipt behavior here does not prove server/database atomicity. */ +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import { afterEach, describe, expect, it } from 'vitest'; +import { createLingvaGuardedApprovalHttpClient, LingvaGuardedApprovalHttpError } from './guarded-approval-http'; + +const scope = { + projectId: 'synthetic_project', + environment: 'dev', + locator: { + entityKey: 'authoring#dev#fr#hello#draft_one', + draftId: 'draft_one', + key: 'hello', + locale: 'fr', + }, +}; +const command = { ...scope, contextToken: 'a'.repeat(64), operationId: '11111111-1111-4111-8111-111111111111' }; +const acknowledgment = { + ok: true, + result: { + operationId: command.operationId, + reviewRevision: 1, + outcome: 'approved', + approvedAt: '2026-10-29T12:00:00Z', + }, +}; +const cleanup: (() => Promise)[] = []; +async function server(handler: (request: IncomingMessage, response: ServerResponse) => void) { + const owned = createServer(handler); + await new Promise((resolve, reject) => { + owned.once('error', reject); + owned.listen(0, '127.0.0.1', resolve); + }); + cleanup.push( + () => + new Promise((resolve, reject) => { + owned.closeAllConnections(); + owned.close((error) => (error ? reject(error) : resolve())); + }) + ); + const address = owned.address(); + if (!address || typeof address === 'string') throw new Error('Owned server has no port.'); + return `http://127.0.0.1:${address.port}`; +} +async function body(request: IncomingMessage) { + let result = ''; + for await (const chunk of request) result += String(chunk); + return result; +} +function json(response: ServerResponse, value: unknown) { + response.setHeader('Content-Type', 'application/json'); + response.end(JSON.stringify(value)); +} +afterEach(async () => { + for (const close of cleanup.splice(0).reverse()) await close(); +}); + +describe('default Fetch owned-loopback contracts', () => { + it('sends exact context and approval wire with explicit credentials only', async () => { + const observed: { + body: string; + authorization: string | undefined; + cookie: string | undefined; + method: string | undefined; + }[] = []; + const endpoint = await server((request, response) => { + void (async () => { + const text = await body(request); + observed.push({ + body: text, + authorization: request.headers.authorization, + cookie: request.headers.cookie, + method: request.method, + }); + if (JSON.parse(text).action === 'review-context') + json(response, { + ok: true, + context: { + ...scope, + actorId: 'synthetic_actor', + contextToken: command.contextToken, + isCurrent: true, + draft: { value: 'Bonjour', status: 'draft' }, + review: { revision: 0 }, + baseline: null, + permissions: { comment: true, assign: false, decide: true }, + }, + }); + else json(response, acknowledgment); + })(); + }); + const api = createLingvaGuardedApprovalHttpClient({ + endpoint, + getBearerToken: () => 'synthetic.payload.signature', + }); + expect((await api.getContext(scope)).context.locator).toEqual(scope.locator); + expect(await api.approve(command)).toEqual(acknowledgment); + expect(observed.map((row) => JSON.parse(row.body))).toEqual([ + { action: 'review-context', ...scope }, + { action: 'review-mutate', command: 'approve', ...command }, + ]); + expect( + observed.every( + (row) => + row.authorization === 'Bearer synthetic.payload.signature' && + row.cookie === undefined && + row.method === 'POST' + ) + ).toBe(true); + }); + it.each([301, 302, 303, 307, 308])('never reaches redirect destination for HTTP%s', async (status) => { + let destinationHits = 0; + let sourceHits = 0; + const target = await server((_, response) => { + destinationHits++; + json(response, acknowledgment); + }); + const source = await server((_, response) => { + sourceHits++; + response.writeHead(status, { Location: target }); + response.end('server-text'); + }); + const api = createLingvaGuardedApprovalHttpClient({ + endpoint: source, + getBearerToken: () => 'synthetic.payload.signature', + }); + await expect(api.approve(command)).rejects.toMatchObject({ + kind: 'transport', + phase: 'request', + recovery: 'keep-command', + }); + expect(sourceHits).toBe(1); + expect(destinationHits).toBe(0); + }); + it('bounds a real Fetch body after headers have arrived', async () => { + let hits = 0; + const endpoint = await server((_, response) => { + hits++; + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.write('{'); + }); + const api = createLingvaGuardedApprovalHttpClient({ + endpoint, + timeoutMs: 100, + getBearerToken: () => 'synthetic.payload.signature', + }); + await expect(api.approve(command)).rejects.toMatchObject({ + kind: 'transport', + phase: 'request', + recovery: 'keep-command', + }); + expect(hits).toBe(1); + }); + it('retains uncertain intent through401/403/409 and explicit rotated-credential recovery', async () => { + // Deliberate fixture: simulate one stored receipt and a lost acknowledgment, not a database owner. + const receipts = new Map(); + const bodies: string[] = []; + const tokens: string[] = []; + const denials = [401, 403, 409]; + let commits = 0; + const endpoint = await server((request, response) => { + void (async () => { + const text = await body(request); + bodies.push(text); + tokens.push(request.headers.authorization ?? ''); + if (bodies.length === 1) { + receipts.set(command.operationId, acknowledgment); + commits++; + response.destroy(); + return; + } + const status = denials.shift(); + if (status) { + response.writeHead(status); + response.end('secret server-text'); + return; + } + json(response, receipts.get(command.operationId)); + })(); + }); + let token = 'initial.payload.signature'; + const api = createLingvaGuardedApprovalHttpClient({ endpoint, getBearerToken: () => token }); + const captured = structuredClone(command); + await expect(api.approve(captured)).rejects.toMatchObject({ kind: 'transport', recovery: 'keep-command' }); + expect(bodies).toHaveLength(1); + token = 'invalid'; + await expect(api.approve(captured)).rejects.toMatchObject({ + phase: 'local', + recovery: 'keep-command', + kind: 'invalid-request', + }); + expect(bodies).toHaveLength(1); + for (const [index, kind] of ['unauthenticated', 'forbidden', 'conflict'].entries()) { + token = `rotated${index}.payload.signature`; + const error = await api.approve(captured).catch((error: unknown) => error); + expect(error).toBeInstanceOf(LingvaGuardedApprovalHttpError); + expect(error).toMatchObject({ kind, phase: 'request', recovery: 'keep-command' }); + expect(captured).toEqual(command); + expect(bodies).toHaveLength(index + 2); + } + token = 'final.payload.signature'; + expect(await api.approve(captured)).toEqual(acknowledgment); + expect(commits).toBe(1); + expect(receipts.size).toBe(1); + expect(bodies).toHaveLength(5); + expect(new Set(bodies).size).toBe(1); + expect(new Set(tokens).size).toBe(5); + expect(JSON.parse(bodies[0]!)).toEqual({ action: 'review-mutate', command: 'approve', ...command }); + }); +}); diff --git a/packages/core/src/guarded-approval-http.test.ts b/packages/core/src/guarded-approval-http.test.ts new file mode 100644 index 0000000..8a16669 --- /dev/null +++ b/packages/core/src/guarded-approval-http.test.ts @@ -0,0 +1,481 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as core from './index'; +import type { LingvaGuardedApprovalHttpOptions } from './index'; + +const scope = () => ({ + projectId: 'synthetic_project', + environment: 'dev', + locator: { + entityKey: 'authoring#dev#fr#hello#draft_one', + draftId: 'draft_one', + key: 'hello', + locale: 'fr', + variantId: 'formal', + }, +}); +const operationId = '11111111-1111-4111-8111-111111111111'; +const command = () => ({ ...scope(), contextToken: 'a'.repeat(64), operationId }); +const jwt = 'synthetic.payload.signature'; +const ack = () => ({ + ok: true, + result: { operationId, reviewRevision: 1, outcome: 'approved', approvedAt: '2026-10-29T12:00:00Z' }, +}); +const context = () => ({ + ok: true, + context: { + ...scope(), + actorId: 'synthetic_actor', + contextToken: 'a'.repeat(64), + isCurrent: true, + draft: { + value: 'Bonjour 🌍', + status: 'approved', + authorId: 'author', + approvedBy: 'reviewer', + approvedAt: '2026-10-29T12:00:00Z', + }, + review: { revision: 1, assigneeId: 'reviewer', outcome: 'approved' }, + baseline: { revision: 'r1', publishId: 'p1' }, + permissions: { comment: true, assign: false, decide: true }, + }, +}); +function client(options: Partial = {}) { + expect(core.createLingvaGuardedApprovalHttpClient).toBeTypeOf('function'); + return core.createLingvaGuardedApprovalHttpClient({ + endpoint: 'https://example.invalid/review', + getBearerToken: () => jwt, + fetcher: vi.fn(async () => Response.json(ack())), + ...options, + }); +} +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} +const bytes = (text: string) => new TextEncoder().encode(text); +function streamed(chunks: Uint8Array[], headers?: HeadersInit) { + const cancel = vi.fn(); + const body = new ReadableStream({ + start(c) { + for (const part of chunks) c.enqueue(part); + c.close(); + }, + cancel, + }); + return { response: new Response(body, { headers }), body, cancel }; +} +async function failure( + promise: Promise, + kind: string, + phase = 'request', + recovery = 'keep-command', + status?: number +) { + const error = await promise.catch((e: unknown) => e); + expect(error).toBeInstanceOf(core.LingvaGuardedApprovalHttpError); + expect(error).toMatchObject({ kind, phase, recovery, ...(status === undefined ? {} : { status }) }); + expect(Object.keys(error as object).sort()).toEqual( + ['kind', 'name', 'phase', 'recovery', ...(status === undefined ? [] : ['status'])].sort() + ); + expect(JSON.stringify(error)).not.toMatch(/example.invalid|synthetic|payload|Bonjour|draft_one|secret|server-text/); + expect((error as Error).cause).toBeUndefined(); + return error; +} +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe('guarded HTTP wire and captured intent', () => { + it('exports the named client contract and sends only literal guarded wire and isolated headers', async () => { + const fetcher = vi.fn(async () => Response.json(ack())); + expect(await client({ fetcher }).approve(command())).toEqual(ack()); + expect(fetcher).toHaveBeenCalledTimes(1); + const [endpoint, init] = fetcher.mock.calls[0]! as unknown as [string, RequestInit]; + expect(endpoint).toBe('https://example.invalid/review'); + expect(init).toMatchObject({ + method: 'POST', + redirect: 'error', + credentials: 'omit', + cache: 'no-store', + referrerPolicy: 'no-referrer', + signal: expect.any(AbortSignal), + }); + expect(init.headers).toEqual({ Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' }); + expect(JSON.parse(init.body as string)).toEqual({ action: 'review-mutate', command: 'approve', ...command() }); + expect(core.LINGVA_GUARDED_APPROVAL_HTTP_TIMEOUT_MS).toBe(5000); + expect(core.LINGVA_GUARDED_APPROVAL_HTTP_TOKEN_MAX_BYTES).toBe(16384); + expect(Object.values(core.LINGVA_GUARDED_APPROVAL_HTTP_ERROR_KINDS)).toEqual([ + 'invalid-request', + 'unauthenticated', + 'forbidden', + 'not-found', + 'conflict', + 'unavailable', + 'transport', + 'invalid-response', + ]); + }); + it('projects context optional facts and snapshots scope/config before credentials resolve', async () => { + const token = deferred(); + const input = scope(); + const original = scope(); + const observed = context(); + (observed.context as unknown as Record).secret = 'discard'; + const fetcher = vi.fn(async () => Response.json(observed)); + const options = { endpoint: 'https://example.invalid/review', getBearerToken: () => token.promise, fetcher }; + const api = client(options); + const pending = api.getContext(input); + input.locator.variantId = 'changed'; + input.projectId = 'changed'; + options.endpoint = 'https://changed.invalid'; + options.getBearerToken = () => Promise.reject('secret'); + options.fetcher = vi.fn(async () => { + throw new Error('secret'); + }); + token.resolve(jwt); + expect(await pending).toEqual(context()); + expect(fetcher.mock.calls[0]![0]).toBe('https://example.invalid/review'); + expect(JSON.parse((fetcher.mock.calls[0]![1] as RequestInit).body as string)).toEqual({ + action: 'review-context', + ...original, + }); + expect(Object.isFrozen(input)).toBe(false); + }); + it('captures complete command/UUID before an await without mutating caller input', async () => { + const token = deferred(); + const input = command(); + const original = command(); + const fetcher = vi.fn(async () => Response.json(ack())); + const pending = client({ getBearerToken: () => token.promise, fetcher }).approve(input); + input.operationId = '22222222-2222-4222-8222-222222222222'; + input.contextToken = 'b'.repeat(64); + input.locator.key = 'changed'; + token.resolve(jwt); + expect(await pending).toEqual(ack()); + expect(JSON.parse((fetcher.mock.calls[0]![1] as RequestInit).body as string)).toEqual({ + action: 'review-mutate', + command: 'approve', + ...original, + }); + expect(Object.isFrozen(input.locator)).toBe(false); + }); + it.each([ + 'http://example.invalid', + '/relative', + 'https://user:pass@example.invalid', + 'https://@example.invalid', + 'https://example.invalid?', + 'https://example.invalid#', + 'https://example.invalid?q=x', + 'ftp://example.invalid', + 'http://localhost:1234', + 'http://127.1:1234', + 'http://2130706433:1234', + ])('rejects endpoint %s', (endpoint) => { + expect(() => client({ endpoint })).toThrow('Invalid guarded approval request.'); + }); + it.each(['http://127.0.0.1:1234/review', 'http://[::1]:1234/review', 'https://example.invalid/review'])( + 'allows explicit endpoint %s', + (endpoint) => { + expect(client({ endpoint })).toBeDefined(); + } + ); + it.each([0, 5001, 1.5, NaN, Infinity])('rejects invalid timeout %s', (timeoutMs) => { + expect(() => client({ timeoutMs })).toThrow('Invalid guarded approval request.'); + }); + it('rejects invalid input before credentials or Fetch, including credential/actor overrides', async () => { + const fetcher = vi.fn(); + const getBearerToken = vi.fn(() => jwt); + const api = client({ fetcher, getBearerToken }); + for (const input of [ + { ...command(), operationId: 'bad' }, + { ...command(), actorId: 'secret' }, + { ...command(), locator: { ...scope().locator, variantId: '' } }, + { ...command(), projectId: 'x'.repeat(201) }, + { ...command(), token: jwt }, + ]) { + await failure(api.approve(input), 'invalid-request', 'local'); + } + await failure(api.getContext({ ...scope(), environment: 'not valid' }), 'invalid-request', 'local', 'none'); + expect(getBearerToken).not.toHaveBeenCalled(); + expect(fetcher).not.toHaveBeenCalled(); + }); + it.each([ + '', + `Bearer ${jwt}`, + 'a.b', + 'a..c', + 'a.b.c.d', + 'a.b.c\n', + 'a. b.c', + 'a.b.é', + 'a.b.\0', + `${'a'.repeat(16381)}.b.c`, + ])('rejects malformed credentials locally (%#)', async (token) => { + const fetcher = vi.fn(); + await failure(client({ getBearerToken: () => token, fetcher }).approve(command()), 'invalid-request', 'local'); + expect(fetcher).not.toHaveBeenCalled(); + }); + it('allows exactly16KiB shape without treating it as identity proof', async () => { + const token = `${'a'.repeat(16380)}.b.c`; + const fetcher = vi.fn(async () => Response.json(ack())); + await client({ getBearerToken: () => token, fetcher }).approve(command()); + expect(fetcher).toHaveBeenCalledTimes(1); + }); +}); + +describe('sanitized static failures and recovery', () => { + it.each([ + [400, 'invalid-request'], + [413, 'invalid-request'], + [401, 'unauthenticated'], + [403, 'forbidden'], + [404, 'not-found'], + [409, 'conflict'], + [500, 'unavailable'], + [503, 'unavailable'], + [302, 'invalid-response'], + [429, 'invalid-response'], + ])('maps HTTP%s without reading its body', async (status, kind) => { + const cancel = vi.fn(); + const body = new ReadableStream({ cancel }); + const fetcher = vi.fn( + async () => new Response(body, { status: status as number, headers: { 'X-Secret': 'server-text' } }) + ); + await failure(client({ fetcher }).approve(command()), kind as string, 'request', 'keep-command', status as number); + expect(cancel).toHaveBeenCalledTimes(1); + expect(body.locked).toBe(false); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + it('sanitizes callback errors and fake public errors instead of trusting them', async () => { + await failure( + client({ + getBearerToken: () => { + throw new Error('secret server-text'); + }, + }).approve(command()), + 'unavailable', + 'local' + ); + const error = new core.LingvaGuardedApprovalHttpError('forbidden', 'local', 'none', 403); + error.message = 'secret server-text'; + await failure( + client({ + fetcher: async () => { + throw error; + }, + }).approve(command()), + 'transport' + ); + await failure( + client({ + getBearerToken: () => { + throw error; + }, + }).getContext(scope()), + 'unavailable', + 'local', + 'none' + ); + }); + it('uses static kind-specific messages with no attached external exception', async () => { + const error = await failure( + client({ + fetcher: async () => { + throw 'secret'; + }, + }).approve(command()), + 'transport' + ); + expect((error as Error).message).toBe('Guarded approval transport failed.'); + expect((error as Error).stack).not.toContain('secret'); + }); +}); + +describe('bounded strict streamed observations', () => { + it('accepts exact128KiB and rejects the next actual byte despite lying content length', async () => { + const base = JSON.stringify({ ...ack(), padding: '' }); + const text = JSON.stringify({ ...ack(), padding: 'x'.repeat(131072 - bytes(base).length) }); + expect(bytes(text).length).toBe(131072); + const exact = streamed([bytes(text)], { 'Content-Length': '1' }); + expect(await client({ fetcher: async () => exact.response }).approve(command())).toEqual(ack()); + expect(exact.body.locked).toBe(false); + const tooLarge = streamed([bytes(text), bytes(' ')], { 'Content-Length': '1' }); + await failure(client({ fetcher: async () => tooLarge.response }).approve(command()), 'invalid-response'); + expect(tooLarge.body.locked).toBe(false); + }); + it('decodes split multibyte chunks and projects known fields without content length', async () => { + const data = bytes(JSON.stringify({ ...context(), secret: 'discard' })); + const split = Array.from(data, (byte) => new Uint8Array([byte])); + const result = await client({ fetcher: async () => streamed(split).response }).getContext(scope()); + expect(result).toEqual(context()); + }); + it.each([ + 'invalid-utf8', + 'invalid-json', + 'missing-body', + 'wrong-uuid', + 'legacy-success', + 'wrong-scope', + 'wrong-variant', + 'inconsistent', + ])('rejects %s success', async (kind) => { + let response: Response; + if (kind === 'invalid-utf8') response = streamed([new Uint8Array([0xff])]).response; + else if (kind === 'invalid-json') response = new Response('{'); + else if (kind === 'missing-body') response = new Response(null); + else if (kind === 'wrong-uuid') + response = Response.json({ + ...ack(), + result: { ...ack().result, operationId: '22222222-2222-4222-8222-222222222222' }, + }); + else if (kind === 'legacy-success') response = Response.json({ ok: true, draftId: 'draft_one' }); + else { + const value = context(); + if (kind === 'wrong-scope') value.context.projectId = 'other'; + else if (kind === 'wrong-variant') value.context.locator.variantId = 'other'; + else value.context.isCurrent = false; + response = Response.json(value); + } + const api = client({ fetcher: async () => response }); + await failure( + kind.startsWith('wrong-scope') || kind === 'wrong-variant' || kind === 'inconsistent' + ? api.getContext(scope()) + : api.approve(command()), + 'invalid-response', + 'request', + ['wrong-scope', 'wrong-variant', 'inconsistent'].includes(kind) ? 'none' : 'keep-command' + ); + }); + it('cancels and releases an overflowing reader even when cancellation never settles', async () => { + const cancel = vi.fn(() => new Promise(() => {})); + const body = new ReadableStream({ + start(c) { + c.enqueue(new Uint8Array(131073)); + }, + cancel, + }); + await failure(client({ fetcher: async () => new Response(body) }).approve(command()), 'invalid-response'); + expect(cancel).toHaveBeenCalledTimes(1); + expect(body.locked).toBe(false); + }); +}); + +describe('one application deadline and abort lifecycle', () => { + it.each(['token', 'fetch', 'body'])('bounds stalled %s even when dependencies ignore abort', async (stage) => { + vi.useFakeTimers(); + const token = deferred(); + const fetch = deferred(); + const cancel = vi.fn(); + const body = new ReadableStream({ cancel }); + const signal = new AbortController(); + const remove = vi.spyOn(signal.signal, 'removeEventListener'); + const fetcher = vi.fn(() => (stage === 'body' ? Promise.resolve(new Response(body)) : fetch.promise)); + const api = client({ timeoutMs: 50, getBearerToken: () => (stage === 'token' ? token.promise : jwt), fetcher }); + const result = failure( + api.approve(command(), { signal: signal.signal }), + 'transport', + stage === 'token' ? 'local' : 'request' + ); + await vi.advanceTimersByTimeAsync(50); + await result; + expect(vi.getTimerCount()).toBe(0); + expect(remove).toHaveBeenCalledTimes(1); + expect(fetcher.mock.calls.length).toBe(stage === 'token' ? 0 : 1); + if (stage === 'body') { + expect(cancel).toHaveBeenCalledTimes(1); + expect(body.locked).toBe(false); + } + if (stage === 'token') token.reject('late secret'); + if (stage === 'fetch') fetch.reject('late secret'); + await Promise.resolve(); + }); + it('uses one total budget across credential acquisition and response body', async () => { + vi.useFakeTimers(); + const token = deferred(); + const cancel = vi.fn(); + const body = new ReadableStream({ cancel }); + const api = client({ timeoutMs: 50, getBearerToken: () => token.promise, fetcher: async () => new Response(body) }); + const result = failure(api.approve(command()), 'transport'); + await vi.advanceTimersByTimeAsync(40); + token.resolve(jwt); + await vi.advanceTimersByTimeAsync(10); + await result; + expect(cancel).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + }); + it('pre-abort prevents credentials and Fetch; mid-fetch abort uses request phase', async () => { + const controller = new AbortController(); + controller.abort('secret'); + const fetcher = vi.fn(); + const token = vi.fn(() => jwt); + await failure( + client({ fetcher, getBearerToken: token }).approve(command(), { signal: controller.signal }), + 'transport', + 'local' + ); + expect(token).not.toHaveBeenCalled(); + expect(fetcher).not.toHaveBeenCalled(); + const live = new AbortController(); + const gate = deferred(); + const active = vi.fn(() => gate.promise); + const result = failure(client({ fetcher: active }).approve(command(), { signal: live.signal }), 'transport'); + await vi.waitFor(() => expect(active).toHaveBeenCalledTimes(1)); + live.abort('secret'); + await result; + gate.reject('late secret'); + }); + it('cancels a response that arrives after deadline and does not retain it', async () => { + vi.useFakeTimers(); + const fetch = deferred(); + const cancel = vi.fn(); + const body = new ReadableStream({ cancel }); + const result = failure(client({ timeoutMs: 10, fetcher: () => fetch.promise }).approve(command()), 'transport'); + await vi.advanceTimersByTimeAsync(10); + await result; + fetch.resolve(new Response(body)); + await vi.advanceTimersByTimeAsync(0); + expect(cancel).toHaveBeenCalledTimes(1); + expect(body.locked).toBe(false); + }); + it('cleans listener/timer on success and aborts a stalled reader on caller cancellation', async () => { + vi.useFakeTimers(); + const control = new AbortController(); + const remove = vi.spyOn(control.signal, 'removeEventListener'); + await client().approve(command(), { signal: control.signal }); + expect(vi.getTimerCount()).toBe(0); + expect(remove).toHaveBeenCalledTimes(1); + const cancel = vi.fn(); + const body = new ReadableStream({ cancel }); + const next = new AbortController(); + const result = failure( + client({ fetcher: async () => new Response(body) }).approve(command(), { signal: next.signal }), + 'transport' + ); + await vi.advanceTimersByTimeAsync(0); + next.abort(); + await result; + expect(body.locked).toBe(false); + expect(cancel).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + }); + it('enforces elapsed deadline across an unending series of empty chunks', async () => { + const cancel = vi.fn(); + const body = new ReadableStream({ + pull(c) { + c.enqueue(new Uint8Array()); + }, + cancel, + }); + await failure(client({ timeoutMs: 5, fetcher: async () => new Response(body) }).approve(command()), 'transport'); + expect(cancel).toHaveBeenCalledTimes(1); + expect(body.locked).toBe(false); + }); +}); diff --git a/packages/core/src/guarded-approval-http.ts b/packages/core/src/guarded-approval-http.ts new file mode 100644 index 0000000..0150766 --- /dev/null +++ b/packages/core/src/guarded-approval-http.ts @@ -0,0 +1,389 @@ +/** Opt-in guarded HTTP transport. The server owns identity, admission and receipt authority. */ +import { LINGVA_HTTP_METHODS } from './constants'; +import { + createLingvaGuardedApprovalContextPayload, + createLingvaGuardedApprovalPayload, + parseLingvaGuardedApprovalContextResponse, + parseLingvaGuardedApprovalResponse, + LINGVA_GUARDED_APPROVAL_RESPONSE_MAX_BYTES, + type LingvaGuardedApprovalScope, + type LingvaGuardedApprovalCommand, + type LingvaGuardedApprovalContextResponse, + type LingvaGuardedApprovalResponse, +} from './guarded-approval'; + +export const LINGVA_GUARDED_APPROVAL_HTTP_TIMEOUT_MS = 5000; +export const LINGVA_GUARDED_APPROVAL_HTTP_TOKEN_MAX_BYTES = 16 * 1024; +export const LINGVA_GUARDED_APPROVAL_HTTP_ERROR_KINDS = Object.freeze({ + invalidRequest: 'invalid-request', + unauthenticated: 'unauthenticated', + forbidden: 'forbidden', + notFound: 'not-found', + conflict: 'conflict', + unavailable: 'unavailable', + transport: 'transport', + invalidResponse: 'invalid-response', +} as const); +export type LingvaGuardedApprovalHttpErrorKind = + (typeof LINGVA_GUARDED_APPROVAL_HTTP_ERROR_KINDS)[keyof typeof LINGVA_GUARDED_APPROVAL_HTTP_ERROR_KINDS]; +export type LingvaGuardedApprovalHttpPhase = 'local' | 'request'; +export type LingvaGuardedApprovalHttpRecovery = 'none' | 'keep-command'; +export interface LingvaGuardedApprovalHttpCallOptions { + signal?: AbortSignal; +} +export interface LingvaGuardedApprovalHttpOptions { + endpoint: string; + getBearerToken: (signal: AbortSignal) => string | Promise; + fetcher?: typeof fetch; + timeoutMs?: number; +} +export interface LingvaGuardedApprovalHttpClient { + getContext( + scope: LingvaGuardedApprovalScope, + options?: LingvaGuardedApprovalHttpCallOptions + ): Promise; + approve( + command: LingvaGuardedApprovalCommand, + options?: LingvaGuardedApprovalHttpCallOptions + ): Promise; +} +const KINDS = LINGVA_GUARDED_APPROVAL_HTTP_ERROR_KINDS; +const MESSAGES: Record = { + 'invalid-request': 'Invalid guarded approval request.', + unauthenticated: 'Guarded approval authentication required.', + forbidden: 'Guarded approval access denied.', + 'not-found': 'Guarded approval target unavailable.', + conflict: 'Guarded approval conflict.', + unavailable: 'Guarded approval service unavailable.', + transport: 'Guarded approval transport failed.', + 'invalid-response': 'Invalid guarded approval response.', +}; + +export class LingvaGuardedApprovalHttpError extends Error { + declare readonly status?: number; + constructor( + readonly kind: LingvaGuardedApprovalHttpErrorKind, + readonly phase: LingvaGuardedApprovalHttpPhase, + readonly recovery: LingvaGuardedApprovalHttpRecovery, + status?: number + ) { + super(MESSAGES[kind]); + this.name = 'LingvaGuardedApprovalHttpError'; + if (status !== undefined) this.status = status; + } +} + +// Only internally produced failures are trusted. Dependency exceptions, including public errors, +// are classified anew and never copied into a public message or cause. +class Failure extends Error { + constructor( + readonly kind: LingvaGuardedApprovalHttpErrorKind, + readonly status?: number + ) { + super('Guarded approval internal failure.'); + } +} +function endpointUrl(value: string): string { + if (typeof value !== 'string' || value.trim() !== value || /[?#\\\s]/.test(value)) + throw new Failure(KINDS.invalidRequest); + const authority = /^https?:\/\/([^/]+)/i.exec(value)?.[1]; + const url = new URL(value); + if ( + !authority || + authority.includes('@') || + url.username || + url.password || + (url.protocol !== 'https:' && !(url.protocol === 'http:' && /^(127\.0\.0\.1|\[::1\])(?::\d+)?$/.test(authority))) + ) { + throw new Failure(KINDS.invalidRequest); + } + return url.href; +} +function validToken(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length <= LINGVA_GUARDED_APPROVAL_HTTP_TOKEN_MAX_BYTES && + /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(value) + ); +} +function discard(body: ReadableStream | null): void { + try { + void body?.cancel().catch(() => {}); + } catch { + /* Untrusted cleanup cannot escape or block. */ + } +} +function discardResponse(response: Response | undefined): void { + try { + const body = response?.body; + if (body && !body.locked) discard(body); + } catch { + /* Property access is untrusted cleanup too. */ + } +} +function statusKind(status: number): LingvaGuardedApprovalHttpErrorKind { + if (status === 400 || status === 413) return KINDS.invalidRequest; + if (status === 401) return KINDS.unauthenticated; + if (status === 403) return KINDS.forbidden; + if (status === 404) return KINDS.notFound; + if (status === 409) return KINDS.conflict; + if (status >= 500 && status <= 599) return KINDS.unavailable; + return KINDS.invalidResponse; +} +function attempt(timeoutMs: number, caller?: AbortSignal) { + const controller = new AbortController(); + const deadline = performance.now() + timeoutMs; + let open = true; + let interrupted = false; + // Settled waits must unsubscribe; a shared pending promise would retain every read/chunk. + const waiters = new Set<(error: Failure) => void>(); + const abort = () => { + if (!open || interrupted) return; + interrupted = true; + controller.abort(); + for (const waiter of waiters) waiter(new Failure(KINDS.transport)); + }; + const timer = setTimeout(abort, timeoutMs); + try { + caller?.addEventListener('abort', abort, { once: true }); + } catch { + clearTimeout(timer); + throw new Failure(KINDS.invalidRequest); + } + function check() { + if (caller?.aborted || performance.now() >= deadline) abort(); + if (interrupted) throw new Failure(KINDS.transport); + } + return { + signal: controller.signal, + isOpen: () => open && !interrupted, + check, + wait(promise: Promise): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const fail = (error: unknown) => { + if (settled) return; + settled = true; + waiters.delete(fail); + reject(error instanceof Error ? error : new Error('Guarded approval dependency failed.')); + }; + // Install both handlers before check can throw: dependency entry may already have aborted. + void promise.then( + (value) => { + if (settled) return; + try { + check(); + } catch (error) { + fail(error); + return; + } + if (settled) return; + settled = true; + waiters.delete(fail); + resolve(value); + }, + (error: unknown) => { + try { + check(); + } catch (interruption) { + fail(interruption); + return; + } + fail(error); + } + ); + waiters.add(fail); + try { + check(); + } catch (error) { + fail(error); + } + }); + }, + close() { + open = false; + clearTimeout(timer); + try { + caller?.removeEventListener('abort', abort); + } catch { + // Native fallback handles AbortSignal subclasses/adapters with broken cleanup methods. + try { + EventTarget.prototype.removeEventListener.call(caller, 'abort', abort); + } catch { + /* Best effort only. */ + } + } + }, + }; +} +type Attempt = ReturnType; +async function readResponse(response: Response, active: Attempt): Promise { + if (!response.body) throw new Failure(KINDS.invalidResponse); + const reader = response.body.getReader(); + const buffer = new Uint8Array(LINGVA_GUARDED_APPROVAL_RESPONSE_MAX_BYTES); + let length = 0; + let complete = false; + try { + while (true) { + // Also checks elapsed time when immediate/empty chunks starve the timer queue. + active.check(); + const chunk = await active.wait(reader.read()); + if (chunk.done) { + complete = true; + break; + } + if ( + !(ArrayBuffer.isView(chunk.value) && Object.prototype.toString.call(chunk.value) === '[object Uint8Array]') || + chunk.value.byteLength > buffer.byteLength - length + ) { + throw new Failure(KINDS.invalidResponse); + } + buffer.set(chunk.value, length); + length += chunk.value.byteLength; + } + try { + const result: unknown = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(buffer.subarray(0, length))); + active.check(); + return result; + } catch (error) { + if (error instanceof Failure) throw error; + throw new Failure(KINDS.invalidResponse); + } + } finally { + if (!complete) { + try { + void reader.cancel().catch(() => {}); + } catch { + /* Do not await cleanup. */ + } + } + try { + reader.releaseLock(); + } catch { + /* A broken injected stream cannot leak a raw exception. */ + } + } +} + +export function createLingvaGuardedApprovalHttpClient( + options: LingvaGuardedApprovalHttpOptions +): LingvaGuardedApprovalHttpClient { + let endpoint: string, + getBearerToken: LingvaGuardedApprovalHttpOptions['getBearerToken'], + fetcher: typeof fetch, + timeoutMs: number; + try { + endpoint = endpointUrl(options.endpoint); + getBearerToken = options.getBearerToken; + fetcher = options.fetcher ?? globalThis.fetch; + timeoutMs = options.timeoutMs ?? LINGVA_GUARDED_APPROVAL_HTTP_TIMEOUT_MS; + if ( + typeof getBearerToken !== 'function' || + typeof fetcher !== 'function' || + !Number.isInteger(timeoutMs) || + timeoutMs < 1 || + timeoutMs > LINGVA_GUARDED_APPROVAL_HTTP_TIMEOUT_MS + ) { + throw new Failure(KINDS.invalidRequest); + } + } catch { + throw new LingvaGuardedApprovalHttpError(KINDS.invalidRequest, 'local', 'none'); + } + + async function invoke( + build: () => { body: string; parse: (value: unknown) => T | null }, + recovery: LingvaGuardedApprovalHttpRecovery, + callOptions?: LingvaGuardedApprovalHttpCallOptions + ): Promise { + let phase: LingvaGuardedApprovalHttpPhase = 'local'; + let active: Attempt | undefined; + let response: Response | undefined; + try { + let captured: ReturnType; + try { + captured = build(); + } catch { + throw new Failure(KINDS.invalidRequest); + } + active = attempt(timeoutMs, callOptions?.signal); + active.check(); + let token: unknown; + try { + token = await active.wait(Promise.resolve(getBearerToken(active.signal))); + } catch (error) { + if (error instanceof Failure) throw error; + throw new Failure(KINDS.unavailable); + } + if (!validToken(token)) throw new Failure(KINDS.invalidRequest); + active.check(); + phase = 'request'; + const current = active; + // Race the dependency, then dispose a response arriving after the application deadline. + const pending = Promise.resolve( + fetcher(endpoint, { + method: LINGVA_HTTP_METHODS.post, + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: captured.body, + signal: active.signal, + redirect: 'error', + credentials: 'omit', + cache: 'no-store', + referrerPolicy: 'no-referrer', + }) + ).then((value) => { + response = value; + if (!current.isOpen()) discardResponse(value); + return value; + }); + token = undefined; + response = await active.wait(pending); + if (response.status < 200 || response.status >= 300) + throw new Failure(statusKind(response.status), response.status); + const raw = await readResponse(response, active); + const result = captured.parse(raw); + active.check(); + if (!result) throw new Failure(KINDS.invalidResponse); + return result; + } catch (error) { + const safe = + error instanceof Failure ? error : new Failure(phase === 'local' ? KINDS.invalidRequest : KINDS.transport); + throw new LingvaGuardedApprovalHttpError(safe.kind, phase, recovery, safe.status); + } finally { + try { + active?.close(); + } catch { + /* Cleanup never replaces the classified result. */ + } + // readResponse owns acquired readers; this covers errors before reader acquisition. + discardResponse(response); + } + } + return { + getContext(input, callOptions) { + return invoke( + () => { + const payload = createLingvaGuardedApprovalContextPayload(input); + return { + body: JSON.stringify(payload), + parse: (value) => parseLingvaGuardedApprovalContextResponse(value, payload), + }; + }, + 'none', + callOptions + ); + }, + approve(input, callOptions) { + return invoke( + () => { + const payload = createLingvaGuardedApprovalPayload(input); + return { + body: JSON.stringify(payload), + parse: (value) => parseLingvaGuardedApprovalResponse(value, payload.operationId), + }; + }, + 'keep-command', + callOptions + ); + }, + }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c0e7bb1..0ff379e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1431,3 +1431,19 @@ export function shouldFailLingvaValidation( return result.errorCount > 0 || result.warningCount > 0 || result.infoCount > 0; } + +export { + LINGVA_GUARDED_APPROVAL_HTTP_TIMEOUT_MS, + LINGVA_GUARDED_APPROVAL_HTTP_TOKEN_MAX_BYTES, + LINGVA_GUARDED_APPROVAL_HTTP_ERROR_KINDS, + LingvaGuardedApprovalHttpError, + createLingvaGuardedApprovalHttpClient, +} from './guarded-approval-http'; +export type { + LingvaGuardedApprovalHttpOptions, + LingvaGuardedApprovalHttpClient, + LingvaGuardedApprovalHttpCallOptions, + LingvaGuardedApprovalHttpErrorKind, + LingvaGuardedApprovalHttpPhase, + LingvaGuardedApprovalHttpRecovery, +} from './guarded-approval-http'; diff --git a/packages/core/test/guarded-approval-consumer.types.ts b/packages/core/test/guarded-approval-consumer.types.ts index a7dd617..d992979 100644 --- a/packages/core/test/guarded-approval-consumer.types.ts +++ b/packages/core/test/guarded-approval-consumer.types.ts @@ -39,3 +39,47 @@ createLingvaGuardedApprovalContextPayload({ ...guardedScope, actorId: 'not-a-req // @ts-expect-error Guarded approval cannot carry a caller-approved identity. createLingvaGuardedApprovalPayload({ ...guardedApprovalPayload, approvedBy: 'not-a-request-field' }); export const guardedOutcome = (response: LingvaGuardedApprovalResponse): 'approved' => response.result.outcome; + +import { + createLingvaGuardedApprovalHttpClient, + LingvaGuardedApprovalHttpError, + type LingvaGuardedApprovalHttpOptions, + type LingvaGuardedApprovalHttpClient, + type LingvaGuardedApprovalHttpCallOptions, + type LingvaGuardedApprovalHttpErrorKind, + type LingvaGuardedApprovalHttpPhase, + type LingvaGuardedApprovalHttpRecovery, +} from '@lingva/core'; +const guardedHttpOptions: LingvaGuardedApprovalHttpOptions = { + endpoint: 'https://example.invalid/review', + getBearerToken: (signal: AbortSignal) => { + void signal; + return 'synthetic.payload.signature'; + }, +}; +export const guardedHttpClient: LingvaGuardedApprovalHttpClient = + createLingvaGuardedApprovalHttpClient(guardedHttpOptions); +const guardedCall: LingvaGuardedApprovalHttpCallOptions = { signal: new AbortController().signal }; +export const guardedHttpContext: Promise = guardedHttpClient.getContext( + guardedScope, + guardedCall +); +export const guardedHttpApproval: Promise = guardedHttpClient.approve( + { ...guardedScope, contextToken: 'a'.repeat(64), operationId: '11111111-1111-4111-8111-111111111111' }, + guardedCall +); +export function guardedHttpErrorFields(error: LingvaGuardedApprovalHttpError) { + const kind: LingvaGuardedApprovalHttpErrorKind = error.kind; + const phase: LingvaGuardedApprovalHttpPhase = error.phase; + const recovery: LingvaGuardedApprovalHttpRecovery = error.recovery; + return { kind, phase, recovery }; +} +// @ts-expect-error Arbitrary request headers are not a supported escape hatch. +createLingvaGuardedApprovalHttpClient({ ...guardedHttpOptions, headers: { 'X-Actor': 'caller' } }); +guardedHttpClient.approve({ + ...guardedScope, + contextToken: 'a'.repeat(64), + operationId: '11111111-1111-4111-8111-111111111111', + // @ts-expect-error Actor attribution is never a command field. + actorId: 'caller', +}); diff --git a/packages/core/test/guarded-approval-http-abort-entry.cjs b/packages/core/test/guarded-approval-http-abort-entry.cjs new file mode 100644 index 0000000..576e9a8 --- /dev/null +++ b/packages/core/test/guarded-approval-http-abort-entry.cjs @@ -0,0 +1,34 @@ +'use strict'; +const assert = require('node:assert/strict'); +module.exports = async function verifyAbortEntry(core, stage) { + const errors = []; + const onUnhandled = (error) => errors.push(error); + process.on('unhandledRejection', onUnhandled); + try { + const controller = new AbortController(); + const api = core.createLingvaGuardedApprovalHttpClient({ + endpoint: 'https://example.invalid/review', + getBearerToken: () => { + if (stage === 'token') { + controller.abort(); + return Promise.reject(new Error('sensitive dependency rejection')); + } + return 'synthetic.payload.signature'; + }, + fetcher: () => { + controller.abort(); + return Promise.reject(new Error('sensitive dependency rejection')); + }, + }); + const scope = { + projectId: 'synthetic_project', + environment: 'dev', + locator: { entityKey: 'authoring#dev#fr#hello#draft_one', draftId: 'draft_one', key: 'hello', locale: 'fr' }, + }; + await assert.rejects(api.getContext(scope, { signal: controller.signal }), (error) => error.kind === 'transport'); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(errors.length, 0, 'Dependency cancellation leaked an unhandled rejection.'); + } finally { + process.removeListener('unhandledRejection', onUnhandled); + } +}; diff --git a/packages/core/test/guarded-approval-http-consumer.cjs b/packages/core/test/guarded-approval-http-consumer.cjs new file mode 100644 index 0000000..cefb924 --- /dev/null +++ b/packages/core/test/guarded-approval-http-consumer.cjs @@ -0,0 +1,64 @@ +'use strict'; +const assert = require('node:assert/strict'); + +module.exports = async function verifyGuardedApprovalHttp(core) { + assert.equal(typeof core.createLingvaGuardedApprovalHttpClient, 'function', 'Missing packed guarded HTTP export.'); + assert.equal(typeof core.LingvaGuardedApprovalHttpError, 'function'); + assert.equal(core.LINGVA_GUARDED_APPROVAL_HTTP_TIMEOUT_MS, 5000); + assert.equal(core.LINGVA_GUARDED_APPROVAL_HTTP_TOKEN_MAX_BYTES, 16384); + const scope = { + projectId: 'synthetic_project', + environment: 'dev', + locator: { entityKey: 'authoring#dev#en#hello#draft_one', draftId: 'draft_one', key: 'hello', locale: 'en' }, + }; + const command = { ...scope, contextToken: 'a'.repeat(64), operationId: '11111111-1111-4111-8111-111111111111' }; + const ack = { + ok: true, + result: { + operationId: command.operationId, + reviewRevision: 1, + outcome: 'approved', + approvedAt: '2026-10-29T12:00:00Z', + }, + }; + const context = { + ok: true, + context: { + ...scope, + actorId: 'synthetic_actor', + contextToken: command.contextToken, + isCurrent: true, + draft: { value: 'Hello', status: 'draft' }, + review: { revision: 0 }, + baseline: null, + permissions: { comment: true, assign: false, decide: true }, + }, + }; + const calls = []; + const api = core.createLingvaGuardedApprovalHttpClient({ + endpoint: 'https://example.invalid/review', + getBearerToken: () => 'synthetic.payload.signature', + fetcher: async (_, init) => { + calls.push(JSON.parse(init.body)); + assert.equal(init.redirect, 'error'); + assert.equal(init.credentials, 'omit'); + return Response.json(calls.at(-1).action === 'review-context' ? context : ack); + }, + }); + assert.deepEqual(await api.getContext(scope), context); + assert.deepEqual(await api.approve(command), ack); + assert.deepEqual(calls, [ + { action: 'review-context', ...scope }, + { action: 'review-mutate', command: 'approve', ...command }, + ]); + await assert.rejects(api.approve({ ...command, operationId: 'invalid' }), (error) => { + assert.ok(error instanceof core.LingvaGuardedApprovalHttpError); + assert.equal(error.kind, 'invalid-request'); + assert.equal(error.phase, 'local'); + assert.equal(error.recovery, 'keep-command'); + assert.equal(error.message, 'Invalid guarded approval request.'); + assert.equal(error.cause, undefined); + return true; + }); + assert.equal(calls.length, 2); +}; diff --git a/packages/core/test/guarded-approval-http-retention.cjs b/packages/core/test/guarded-approval-http-retention.cjs new file mode 100644 index 0000000..219d290 --- /dev/null +++ b/packages/core/test/guarded-approval-http-retention.cjs @@ -0,0 +1,41 @@ +'use strict'; +// Separate process enables deterministic forced-GC assertions without changing the test runner. +const assert = require('node:assert/strict'); +module.exports = async function verifyRetention(core) { + const refs = []; + const controller = new AbortController(); + let ready; + const stalled = new Promise((resolve) => { + ready = resolve; + }); + let count = 0; + const body = new ReadableStream({ + pull(stream) { + if (count++ >= 10000) { + ready(); + return new Promise(() => {}); + } + const chunk = new Uint8Array(); + refs.push(new WeakRef(chunk)); + stream.enqueue(chunk); + }, + }); + const api = core.createLingvaGuardedApprovalHttpClient({ + endpoint: 'https://example.invalid/review', + getBearerToken: () => 'synthetic.payload.signature', + fetcher: async () => new Response(body), + }); + const scope = { + projectId: 'synthetic_project', + environment: 'dev', + locator: { entityKey: 'authoring#dev#fr#hello#draft_one', draftId: 'draft_one', key: 'hello', locale: 'fr' }, + }; + const pending = api.getContext(scope, { signal: controller.signal }).catch((error) => error); + await stalled; + await new Promise((resolve) => setImmediate(resolve)); + global.gc(); + const retained = refs.filter((ref) => ref.deref() !== undefined).length; + controller.abort(); + await pending; + assert.ok(retained <= 2, `Settled empty chunks retained: ${retained}`); +}; diff --git a/packages/core/test/guarded-approval-http-subprocess.cjs b/packages/core/test/guarded-approval-http-subprocess.cjs new file mode 100644 index 0000000..be3b31e --- /dev/null +++ b/packages/core/test/guarded-approval-http-subprocess.cjs @@ -0,0 +1,26 @@ +'use strict'; +const assert = require('node:assert/strict'); + +async function main(stage) { + // Load current source with the same declared transformer as Vitest. Never rebuild shared dist + // while workspace consumers are compiling against it; protected package checks run separately. + const { createServer } = await import('vite'); + const server = await createServer({ + configFile: false, + logLevel: 'silent', + server: { middlewareMode: true, hmr: false, watch: null }, + optimizeDeps: { noDiscovery: true }, + }); + try { + assert.equal(server.httpServer, null); + const core = await server.ssrLoadModule('/src/guarded-approval-http.ts'); + if (stage === 'retention') await require('./guarded-approval-http-retention.cjs')(core); + else await require('./guarded-approval-http-abort-entry.cjs')(core, stage); + } finally { + await server.close(); + } +} +void main(process.argv[2]).catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/scripts/verify-public-packages.mjs b/scripts/verify-public-packages.mjs index a27afdf..5f2635b 100644 --- a/scripts/verify-public-packages.mjs +++ b/scripts/verify-public-packages.mjs @@ -149,6 +149,10 @@ async function main() { path.join(root, 'packages/core/test/guarded-approval-consumer.cjs'), path.join(consumerDirectory, 'guarded-approval-smoke.cjs') ); + await fs.copyFile( + path.join(root, 'packages/core/test/guarded-approval-http-consumer.cjs'), + path.join(consumerDirectory, 'guarded-approval-http-smoke.cjs') + ); const guardedTypes = await fs.readFile( path.join(root, 'packages/core/test/guarded-approval-consumer.types.ts'), 'utf8' @@ -159,6 +163,7 @@ async function main() { `${PUBLIC_PACKAGE_NAMES.map((name, index) => `import * as package${index} from '${name}';`).join('\n')}\n\n` + `const packages = [${PUBLIC_PACKAGE_NAMES.map((_, index) => `package${index}`).join(', ')}];\n` + `import verifyGuardedApproval from './guarded-approval-smoke.cjs';\nverifyGuardedApproval(packages[${coreIndex}]);\n` + + `import verifyGuardedApprovalHttp from './guarded-approval-http-smoke.cjs';\nawait verifyGuardedApprovalHttp(packages[${coreIndex}]);\n` + `if (packages.some((entry) => Object.keys(entry).length === 0)) throw new Error('A Lingva package exported no symbols.');\n`, 'utf8' ); @@ -166,6 +171,7 @@ async function main() { path.join(consumerDirectory, 'smoke.cjs'), `'use strict';\nconst packages = ${JSON.stringify(PUBLIC_PACKAGE_NAMES)}.map((name) => require(name));\n` + `require('./guarded-approval-smoke.cjs')(packages[${coreIndex}]);\n` + + `require('./guarded-approval-http-smoke.cjs')(packages[${coreIndex}]).catch((error) => { console.error(error); process.exitCode = 1; });\n` + `if (packages.some((entry) => Object.keys(entry).length === 0)) throw new Error('A Lingva package exported no symbols.');\n`, 'utf8' );