From 21ceb105cac7f7b90c12aec1347a3d7d651b043f Mon Sep 17 00:00:00 2001 From: Andy00L <89641810+Andy00L@users.noreply.github.com> Date: Tue, 21 Jul 2026 09:13:35 -0400 Subject: [PATCH] feat(http): validate API responses at runtime with valibot instead of blind casts --- src/lib/http.ts | 101 ++++++++++-- src/lib/response-schemas.test.ts | 104 ++++++++++++ src/lib/response-schemas.ts | 271 +++++++++++++++++++++++++++++++ 3 files changed, 461 insertions(+), 15 deletions(-) create mode 100644 src/lib/response-schemas.test.ts create mode 100644 src/lib/response-schemas.ts diff --git a/src/lib/http.ts b/src/lib/http.ts index 21369de..34a58ec 100644 --- a/src/lib/http.ts +++ b/src/lib/http.ts @@ -1,7 +1,16 @@ import { randomUUID } from 'node:crypto'; +import * as v from 'valibot'; import type { ErrorCode } from './errors.js'; import { ApiError, InterruptError, RequestTimeoutError, TransportError } from './errors.js'; import { VERSION } from '../version.js'; +import { + BATCH_RERUN_RESPONSE_SCHEMA, + BATCH_RUN_FRESH_RESPONSE_SCHEMA, + LIST_RUNS_RESPONSE_SCHEMA, + RERUN_RESPONSE_SCHEMA, + RUN_RESPONSE_SCHEMA, + TRIGGER_RUN_RESPONSE_SCHEMA, +} from './response-schemas.js'; import type { TriggerRunBody, TriggerRunResponse, @@ -108,10 +117,26 @@ export interface HttpClientOptions { shutdownSignal?: AbortSignal; } -export interface RequestOptions { +export interface RequestOptions { query?: Record; signal?: AbortSignal; requestId?: string; + /** + * Optional valibot schema for the parsed 2xx response body (issue #102). + * + * When present, `requestWithMeta` runs `v.safeParse` on the OK-path JSON: + * success returns the parsed output (unknown extra keys preserved via + * `looseObject`); failure throws an INTERNAL `ApiError` envelope naming the + * request path and the first {@link MAX_SCHEMA_ISSUES_IN_DETAILS} mismatched + * field paths (never the body itself). When absent, behavior is unchanged: + * the body is returned via the historical blind `as T` cast. + * + * Wired by the typed run helpers only (`triggerRun`, `triggerRunWithMeta`, + * `triggerRerun`, `triggerBatchRerun`, `triggerBatchRunFresh`, `getRun`, + * `listTestRuns`); generic `get`/`post`/... callers stay opt-in. + * sourceRef: response-schemas.ts. + */ + schema?: v.GenericSchema; /** * Optional JSON body for non-GET requests. Serialized with * `JSON.stringify`; `Content-Type: application/json` is auto-attached @@ -168,6 +193,11 @@ const MAX_RATE_LIMITED_DELAY_MS = 60_000; const CONFLICT_DELAY_MS = 1000; const INTERNAL_DELAY_MS = 500; +// Cap on how many valibot issues a shape-mismatch INTERNAL envelope carries in +// `details.issues` (path + message each). Keeps the envelope readable and +// guarantees the response body itself is never echoed back to the operator. +const MAX_SCHEMA_ISSUES_IN_DETAILS = 3; + /** * Result of a successful HTTP request, including the parsed body and the * `x-request-id` that was sent (useful for surfacing in happy-path output). @@ -222,23 +252,23 @@ export class HttpClient { } } - async get(path: string, options: RequestOptions = {}): Promise { + async get(path: string, options: RequestOptions = {}): Promise { return this.requestWithMeta('GET', path, options).then(r => r.body); } - async post(path: string, options: RequestOptions = {}): Promise { + async post(path: string, options: RequestOptions = {}): Promise { return this.requestWithMeta('POST', path, options).then(r => r.body); } - async put(path: string, options: RequestOptions = {}): Promise { + async put(path: string, options: RequestOptions = {}): Promise { return this.requestWithMeta('PUT', path, options).then(r => r.body); } - async patch(path: string, options: RequestOptions = {}): Promise { + async patch(path: string, options: RequestOptions = {}): Promise { return this.requestWithMeta('PATCH', path, options).then(r => r.body); } - async delete(path: string, options: RequestOptions = {}): Promise { + async delete(path: string, options: RequestOptions = {}): Promise { return this.requestWithMeta('DELETE', path, options).then(r => r.body); } @@ -247,23 +277,26 @@ export class HttpClient { * `requestId` and `status`, so callers can surface the requestId in * happy-path output (dogfood item 1). */ - async getWithMeta(path: string, options: RequestOptions = {}): Promise> { + async getWithMeta(path: string, options: RequestOptions = {}): Promise> { return this.requestWithMeta('GET', path, options); } - async postWithMeta(path: string, options: RequestOptions = {}): Promise> { + async postWithMeta(path: string, options: RequestOptions = {}): Promise> { return this.requestWithMeta('POST', path, options); } - async putWithMeta(path: string, options: RequestOptions = {}): Promise> { + async putWithMeta(path: string, options: RequestOptions = {}): Promise> { return this.requestWithMeta('PUT', path, options); } - async patchWithMeta(path: string, options: RequestOptions = {}): Promise> { + async patchWithMeta(path: string, options: RequestOptions = {}): Promise> { return this.requestWithMeta('PATCH', path, options); } - async deleteWithMeta(path: string, options: RequestOptions = {}): Promise> { + async deleteWithMeta( + path: string, + options: RequestOptions = {}, + ): Promise> { return this.requestWithMeta('DELETE', path, options); } @@ -282,6 +315,7 @@ export class HttpClient { body, headers: { 'idempotency-key': options.idempotencyKey }, signal: options.signal, + schema: TRIGGER_RUN_RESPONSE_SCHEMA, // 409 on POST /runs means "another run is already in flight" — a // persistent condition, not a transient snapshot conflict. Retrying // would enqueue a second run once the first finishes. @@ -310,6 +344,7 @@ export class HttpClient { body, headers: { 'idempotency-key': options.idempotencyKey }, signal: options.signal, + schema: TRIGGER_RUN_RESPONSE_SCHEMA, retryOnConflict: false, // Default true: single `test run` / `test create --run` retain 429 retry. // Batch call site passes false to keep outer-loop as sole rate-limit owner. @@ -334,6 +369,7 @@ export class HttpClient { body, headers: { 'idempotency-key': options.idempotencyKey }, signal: options.signal, + schema: RERUN_RESPONSE_SCHEMA, retryOnConflict: false, }).then(r => r.body); } @@ -353,6 +389,7 @@ export class HttpClient { body, headers: { 'idempotency-key': options.idempotencyKey }, signal: options.signal, + schema: BATCH_RERUN_RESPONSE_SCHEMA, retryOnConflict: false, }).then(r => r.body); } @@ -373,6 +410,7 @@ export class HttpClient { body, headers: { 'idempotency-key': options.idempotencyKey }, signal: options.signal, + schema: BATCH_RUN_FRESH_RESPONSE_SCHEMA, retryOnConflict: false, }).then(r => r.body); } @@ -391,7 +429,10 @@ export class HttpClient { if (query.pageSize !== undefined) q.pageSize = query.pageSize; if (query.source !== undefined) q.source = query.source; if (query.since !== undefined) q.since = query.since; - return this.get(`/tests/${encodeURIComponent(testId)}/runs`, { query: q }); + return this.get(`/tests/${encodeURIComponent(testId)}/runs`, { + query: q, + schema: LIST_RUNS_RESPONSE_SCHEMA, + }); } /** @@ -421,6 +462,7 @@ export class HttpClient { return this.get(`/runs/${encodeURIComponent(runId)}`, { query: Object.keys(query).length > 0 ? query : undefined, signal: options?.signal, + schema: RUN_RESPONSE_SCHEMA, }); } @@ -481,7 +523,7 @@ export class HttpClient { async requestWithMeta( method: string, path: string, - options: RequestOptions = {}, + options: RequestOptions = {}, ): Promise> { if (!this.apiKey) throw ApiError.authRequired(); @@ -594,8 +636,9 @@ export class HttpClient { requestId, durationMs, }); + let raw: unknown; try { - return { body: (await response.json()) as T, requestId, status: response.status }; + raw = await response.json(); } catch (err) { // Interrupt passthrough (see the fetch catch above). if (err instanceof InterruptError) throw err; @@ -609,6 +652,34 @@ export class HttpClient { // and break the --output json envelope contract. throw malformedResponseError(response, requestId, err); } + if (options.schema !== undefined) { + const parsed = v.safeParse(options.schema, raw); + if (!parsed.success) { + const issues = parsed.issues.slice(0, MAX_SCHEMA_ISSUES_IN_DETAILS).map(issue => ({ + path: v.getDotPath(issue) ?? '(root)', + message: issue.message, + })); + // Shape drift is a server-side contract break: surface a typed + // INTERNAL envelope (requestId + the first mismatched paths, + // never the body) instead of letting a blind cast poison + // downstream output with undefined fields or a raw TypeError. + throw ApiError.fromEnvelope( + { + error: { + code: 'INTERNAL', + message: `Response shape mismatch from ${shortPath(path)}.`, + nextAction: + 'Retry; if it persists, report this requestId (the server returned an unexpected shape).', + requestId, + details: { issues }, + }, + }, + response.status, + ); + } + return { body: parsed.output as T, requestId, status: response.status }; + } + return { body: raw as T, requestId, status: response.status }; } let rawBody: unknown; @@ -790,7 +861,7 @@ export class HttpClient { * `client.request(...)` directly. New callers should use * `requestWithMeta` or the typed helpers (`get`, `post`, etc.). */ - async request(method: string, path: string, options: RequestOptions = {}): Promise { + async request(method: string, path: string, options: RequestOptions = {}): Promise { return this.requestWithMeta(method, path, options).then(r => r.body); } } diff --git a/src/lib/response-schemas.test.ts b/src/lib/response-schemas.test.ts new file mode 100644 index 0000000..79c56d6 --- /dev/null +++ b/src/lib/response-schemas.test.ts @@ -0,0 +1,104 @@ +/** + * Dedicated tests for the response schemas (issue #102): the schemas must be + * loose (additive server fields pass), mirror nullability, and turn drift + * into a typed INTERNAL envelope at the HttpClient boundary. + */ + +import { describe, expect, it } from 'vitest'; +import * as v from 'valibot'; +import { HttpClient } from './http.js'; +import { RUN_RESPONSE_SCHEMA, TRIGGER_RUN_RESPONSE_SCHEMA } from './response-schemas.js'; + +const VALID_RUN = { + runId: 'run_1', + testId: 'test_1', + projectId: 'p_1', + userId: 'u_1', + status: 'passed', + source: 'cli', + createdAt: '2026-06-01T10:00:00.000Z', + startedAt: null, + finishedAt: null, + codeVersion: 'v1', + targetUrl: 'https://example.com', + createdFrom: null, + failedStepIndex: null, + failureKind: null, + error: null, + videoUrl: null, + stepSummary: { total: 0, completed: 0, passedCount: 0, failedCount: 0 }, +}; + +function makeClient(fetchImpl: typeof fetch): HttpClient { + return new HttpClient({ + baseUrl: 'https://api.example.com/api/cli/v1', + apiKey: 'sk-test', + fetchImpl, + sleep: () => Promise.resolve(), + random: () => 0, + }); +} + +describe('RUN_RESPONSE_SCHEMA', () => { + it('accepts a valid run and preserves unknown extra keys (additive drift is non-breaking)', () => { + const parsed = v.safeParse(RUN_RESPONSE_SCHEMA, { + ...VALID_RUN, + someFutureField: 'kept', + }); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect((parsed.output as { someFutureField?: string }).someFutureField).toBe('kept'); + } + }); + + it('rejects a run missing a required field, naming the path', () => { + const withoutStatus: Record = { ...VALID_RUN }; + delete withoutStatus.status; + const parsed = v.safeParse(RUN_RESPONSE_SCHEMA, withoutStatus); + expect(parsed.success).toBe(false); + if (!parsed.success) { + expect(parsed.issues.some(issue => v.getDotPath(issue) === 'status')).toBe(true); + } + }); +}); + +describe('HttpClient schema hook', () => { + it('getRun surfaces drift as a typed INTERNAL envelope with issue paths (never a blind cast)', async () => { + const drifted: Record = { ...VALID_RUN }; + delete drifted.status; + const fetchImpl = (async () => + new Response(JSON.stringify(drifted), { + status: 200, + headers: { 'content-type': 'application/json' }, + })) as typeof fetch; + const client = makeClient(fetchImpl); + const rejection = await client.getRun('run_1').catch((error: unknown) => error); + expect(rejection).toMatchObject({ code: 'INTERNAL' }); + const issues = (rejection as { getDetail: (key: string) => unknown }).getDetail('issues'); + expect(Array.isArray(issues)).toBe(true); + expect(JSON.stringify(issues)).toContain('status'); + }); + + it('a schemaless generic get still returns whatever JSON came back (unchanged behavior)', async () => { + const fetchImpl = (async () => + new Response(JSON.stringify({ anything: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })) as typeof fetch; + const client = makeClient(fetchImpl); + await expect(client.get('/me')).resolves.toEqual({ anything: true }); + }); +}); + +describe('TRIGGER_RUN_RESPONSE_SCHEMA', () => { + it('accepts the queued-run envelope', () => { + const parsed = v.safeParse(TRIGGER_RUN_RESPONSE_SCHEMA, { + runId: 'run_1', + status: 'queued', + enqueuedAt: '2026-06-01T10:00:00.000Z', + codeVersion: 'v1', + targetUrl: 'https://example.com', + }); + expect(parsed.success).toBe(true); + }); +}); diff --git a/src/lib/response-schemas.ts b/src/lib/response-schemas.ts new file mode 100644 index 0000000..de86431 --- /dev/null +++ b/src/lib/response-schemas.ts @@ -0,0 +1,271 @@ +/** + * Valibot schemas for the run-path wire shapes (issue #102). + * + * `requestWithMeta` used to return `(await response.json()) as T` with zero + * runtime validation, so a drifted or partial server response surfaced as + * `undefined` output or an opaque TypeError deep inside a command. These + * schemas are wired (opt-in, via `RequestOptions.schema`) into the typed + * HttpClient helpers only: `triggerRun`, `triggerRunWithMeta`, `triggerRerun`, + * `triggerBatchRerun`, `triggerBatchRunFresh`, `getRun`, `listTestRuns`. + * The generic `get`/`post`/`put`/`patch`/`delete` paths stay schema-free. + * + * Resilience rules (additive server changes must never hard-fail the CLI): + * + * 1. Every object is `v.looseObject`: unknown extra keys pass validation AND + * are preserved in the output, so a new server field still reaches + * `--output json` consumers untouched. + * 2. Enum-ish string fields (`status`, `source`, `role`, step `type`, ...) are + * validated as open strings via {@link openWireLiteral}: the CLI already + * treats unknown values as open (e.g. `isTerminalStatus` returns false and + * the poll continues; renderers print the raw value), so a new server enum + * member must degrade gracefully, never reject the whole response. + * 3. REQUIRED-nullable interface fields use `v.nullish(inner, null)`: the wire + * may omit a nullable field entirely (real fixture evidence: the chained + * `test create --run` poll bodies in `test.test.ts` omit `error`), and + * every consumer already null-checks these, so absence normalizes to + * `null` instead of failing. OPTIONAL interface fields (`?`) use + * `v.optional` with NO default so presence/absence semantics that commands + * branch on (e.g. `RerunResponse.closure`, `RunResponse.steps`) survive + * validation byte-identically. + * + * Each schema is annotated `v.GenericSchema` against the + * interface it mirrors, so schema/interface drift fails `tsc` in this file. + */ +import * as v from 'valibot'; +import type { + BatchRerunResponse, + BatchRunFreshResponse, + ListRunsResponse, + RerunClosure, + RerunResponse, + RunResponse, + RunSource, + RunStatus, + TriggerRunResponse, +} from './runs.types.js'; + +/** + * Compile-time literal union, runtime open string. + * + * Keeps `InferOutput` aligned with the union declared in `runs.types.ts` + * while accepting any string on the wire, per resilience rule 2 above. + * `v.custom` is valibot's documented escape hatch for exactly this + * "caller-asserted type, custom runtime check" pattern. + */ +function openWireLiteral(): v.GenericSchema { + return v.custom(value => typeof value === 'string'); +} + +// --------------------------------------------------------------------------- +// GET /runs/{runId} +// --------------------------------------------------------------------------- + +/** Mirrors `RunStepSummary` (runs.types.ts): per-run step counters. */ +const RUN_STEP_SUMMARY_SCHEMA = v.looseObject({ + total: v.number(), + completed: v.number(), + passedCount: v.number(), + failedCount: v.number(), +}); + +/** Mirrors `RunStepDto` (runs.types.ts): one `?includeSteps=true` step row. */ +const RUN_STEP_DTO_SCHEMA = v.looseObject({ + stepIndex: v.string(), + type: openWireLiteral<'action' | 'assertion'>(), + action: v.string(), + status: v.nullish(openWireLiteral<'passed' | 'failed'>(), null), + description: v.nullish(v.string(), null), + error: v.nullish(v.string(), null), + screenshotUrl: v.nullish(v.string(), null), + htmlSnapshotUrl: v.nullish(v.string(), null), + createdAt: v.string(), +}); + +/** Mirrors `RunResponse` (runs.types.ts): `GET /api/cli/v1/runs/{runId}`. */ +export const RUN_RESPONSE_SCHEMA: v.GenericSchema = v.looseObject({ + runId: v.string(), + testId: v.string(), + projectId: v.string(), + userId: v.string(), + status: openWireLiteral(), + source: v.string(), + createdAt: v.string(), + startedAt: v.nullish(v.string(), null), + finishedAt: v.nullish(v.string(), null), + codeVersion: v.string(), + targetUrl: v.string(), + createdFrom: v.nullish(v.string(), null), + failedStepIndex: v.nullish(v.number(), null), + failureKind: v.nullish(v.string(), null), + // Loosened per fixture evidence (rule 3): several real poll bodies omit + // `error` entirely; consumers render it only when non-null. + error: v.nullish(v.string(), null), + videoUrl: v.nullish(v.string(), null), + stepSummary: RUN_STEP_SUMMARY_SCHEMA, + retryAfterSeconds: v.optional(v.number()), + // Client-synthesized Portal link (never sent by the server); tolerated so a + // future server echo cannot fail validation. + dashboardUrl: v.optional(v.string()), + // Absence means "steps not requested" and drives command branching, so no + // default is applied (rule 3, optional branch). + steps: v.optional(v.nullable(v.array(RUN_STEP_DTO_SCHEMA))), +}); + +// --------------------------------------------------------------------------- +// POST /tests/{testId}/runs +// --------------------------------------------------------------------------- + +/** Mirrors `TriggerRunResponse` (runs.types.ts): `POST /tests/{testId}/runs`. */ +export const TRIGGER_RUN_RESPONSE_SCHEMA: v.GenericSchema = + v.looseObject({ + runId: v.string(), + status: openWireLiteral<'queued'>(), + enqueuedAt: v.string(), + codeVersion: v.string(), + targetUrl: v.string(), + }); + +// --------------------------------------------------------------------------- +// POST /tests/{testId}/runs/rerun +// --------------------------------------------------------------------------- + +/** Mirrors `RerunClosureMember` (runs.types.ts): one BE closure member. */ +const RERUN_CLOSURE_MEMBER_SCHEMA = v.looseObject({ + testId: v.string(), + runId: v.string(), + role: openWireLiteral<'selected' | 'producer' | 'teardown'>(), +}); + +/** Mirrors `RerunClosure` (runs.types.ts): BE closure breakdown. */ +const RERUN_CLOSURE_SCHEMA: v.GenericSchema = v.looseObject({ + members: v.array(RERUN_CLOSURE_MEMBER_SCHEMA), + addedProducers: v.array(v.string()), + addedTeardowns: v.array(v.string()), + clearedCaptured: v.number(), +}); + +/** Mirrors `RerunResponse` (runs.types.ts): `POST /tests/{testId}/runs/rerun`. */ +export const RERUN_RESPONSE_SCHEMA: v.GenericSchema = v.looseObject({ + runId: v.string(), + status: openWireLiteral<'queued'>(), + enqueuedAt: v.string(), + codeVersion: v.string(), + autoHeal: v.boolean(), + // FE reruns omit `closure`; the CLI's `!!closure` truthy check relies on + // absent staying absent, so optional with no default (rule 3). + closure: v.optional(v.nullable(RERUN_CLOSURE_SCHEMA)), +}); + +// --------------------------------------------------------------------------- +// POST /tests/batch/rerun +// --------------------------------------------------------------------------- + +/** Mirrors `BatchRerunResponse` (runs.types.ts): `POST /tests/batch/rerun`. */ +export const BATCH_RERUN_RESPONSE_SCHEMA: v.GenericSchema = + v.looseObject({ + // Mirrors BatchRerunAccepted (runs.types.ts). + accepted: v.array( + v.looseObject({ testId: v.string(), runId: v.string(), enqueuedAt: v.string() }), + ), + // Mirrors BatchRerunDeferred (runs.types.ts). + deferred: v.array(v.looseObject({ testId: v.string(), reason: v.string() })), + // Mirrors BatchRerunConflict (runs.types.ts). + conflicts: v.array(v.looseObject({ testId: v.string(), currentRunId: v.string() })), + // Mirrors BatchRerunClosure / BatchRerunClosureByProject (runs.types.ts). + closure: v.looseObject({ + byProject: v.array( + v.looseObject({ + projectId: v.string(), + testIds: v.array(v.string()), + addedProducers: v.array(v.string()), + addedTeardowns: v.array(v.string()), + clearedCaptured: v.number(), + }), + ), + }), + // Optional on the wire for back-compat with older backends (D2-CLI). + notFound: v.optional(v.array(v.string())), + }); + +// --------------------------------------------------------------------------- +// POST /tests/batch/run +// --------------------------------------------------------------------------- + +/** Mirrors `BatchRunFreshResponse` (runs.types.ts): `POST /tests/batch/run`. */ +export const BATCH_RUN_FRESH_RESPONSE_SCHEMA: v.GenericSchema = + v.looseObject({ + // Mirrors BatchRunFreshAccepted (runs.types.ts); dashboardUrl is + // client-synthesized, tolerated as optional. + accepted: v.array( + v.looseObject({ + testId: v.string(), + runId: v.string(), + enqueuedAt: v.string(), + dashboardUrl: v.optional(v.string()), + }), + ), + conflicts: v.array(v.looseObject({ testId: v.string() })), + deferred: v.array(v.looseObject({ testId: v.string() })), + skippedFrontend: v.array(v.string()), + skippedIntegration: v.array(v.looseObject({ testId: v.string() })), + }); + +// --------------------------------------------------------------------------- +// GET /tests/{testId}/runs +// --------------------------------------------------------------------------- + +/** Mirrors `RunHistoryItem` (runs.types.ts): one run-history row. */ +const RUN_HISTORY_ITEM_SCHEMA = v.looseObject({ + runId: v.string(), + status: openWireLiteral(), + source: openWireLiteral(), + isRerun: v.boolean(), + createdFrom: v.nullish(v.string(), null), + createdAt: v.string(), + startedAt: v.nullish(v.string(), null), + finishedAt: v.nullish(v.string(), null), + codeVersion: v.string(), + failureKind: v.nullish(v.string(), null), + // G1b fields: optional on the wire for back-compat with older backends. + targetUrl: v.optional(v.nullable(v.string())), + targetUrlSource: v.optional(v.nullable(openWireLiteral<'run' | 'unresolved'>())), +}); + +/** Mirrors `ListRunsResponse` (runs.types.ts): `GET /tests/{testId}/runs`. */ +export const LIST_RUNS_RESPONSE_SCHEMA: v.GenericSchema = v.looseObject({ + runs: v.array(RUN_HISTORY_ITEM_SCHEMA), + nextCursor: v.nullish(v.string(), null), + // Mirrors RunHistoryMeta (runs.types.ts): every field optional, and the + // history command reads `resp.meta.note` / `resp.meta.portalUrl` directly, + // so the container itself stays required like the interface declares. + meta: v.looseObject({ + testKind: v.optional(openWireLiteral<'frontend' | 'backend'>()), + historyStartsAt: v.optional(v.string()), + note: v.optional(v.string()), + portalUrl: v.optional(v.string()), + }), +}); + +// --------------------------------------------------------------------------- +// GET /me +// --------------------------------------------------------------------------- + +/** + * Minimal `/me` identity core shared by its consumers. `doctor` reads a + * two-field optional projection (`MeIdentity` in commands/doctor.ts) while + * `auth whoami` reads the full `MeResponse` (commands/auth.ts); this schema + * validates the common identity core so it can guard either caller, and + * `looseObject` lets the full projection (scopes, env, email, ...) pass + * through untouched. Not wired into any typed helper yet: `/me` callers use + * the generic `get`, which stays schema-free in this change. + */ +export interface MeIdentityWire { + userId?: string; + keyId?: string; +} + +/** Mirrors `MeIdentity` (commands/doctor.ts): `GET /api/cli/v1/me` core. */ +export const ME_IDENTITY_SCHEMA: v.GenericSchema = v.looseObject({ + userId: v.optional(v.string()), + keyId: v.optional(v.string()), +});