From 028ff55ee51d9d4bc3824f0c4eb199fea1ee2c0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 11 Sep 2026 01:19:01 +0200 Subject: [PATCH 1/2] feat(kilo-mcp): add server-side PostHog analytics to the MCP worker (part 1/1) https://github.com/Kilo-Org/cloud/pull/6066 --- services/kilo-mcp/src/analytics.test.ts | 583 ++++++++++ services/kilo-mcp/src/analytics.ts | 324 ++++++ services/kilo-mcp/src/auth/authorize.test.ts | 92 +- services/kilo-mcp/src/auth/authorize.ts | 31 + services/kilo-mcp/src/auth/token.test.ts | 145 ++- services/kilo-mcp/src/auth/token.ts | 59 +- services/kilo-mcp/src/index.test.ts | 509 +++++++- services/kilo-mcp/src/index.ts | 170 ++- .../src/oauth-pages/authorize-page.test.ts | 131 +++ .../src/oauth-pages/authorize-page.ts | 18 + services/kilo-mcp/vitest.config.ts | 5 + services/kilo-mcp/worker-configuration.d.ts | 1027 ++++------------- services/kilo-mcp/wrangler.jsonc | 5 + 13 files changed, 2249 insertions(+), 850 deletions(-) create mode 100644 services/kilo-mcp/src/analytics.test.ts create mode 100644 services/kilo-mcp/src/analytics.ts diff --git a/services/kilo-mcp/src/analytics.test.ts b/services/kilo-mcp/src/analytics.test.ts new file mode 100644 index 0000000000..49a65f510d --- /dev/null +++ b/services/kilo-mcp/src/analytics.test.ts @@ -0,0 +1,583 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + ANONYMOUS_DISTINCT_ID, + buildCapturePayload, + callRejectedEvent, + classifyToolError, + createMcpAnalytics, + oauthSignInEvent, + queryShape, + searchPerformedEvent, + sessionStartedEvent, + toolCalledEvent, +} from './analytics'; +import type { + AnalyticsIdentity, + CapturePayload, + McpAnalytics, + McpAnalyticsEvent, + SearchPerformedInput, +} from './analytics'; +import { JsonRpcFailure } from './types'; + +const identity: AnalyticsIdentity = { kiloUserId: 'user-123', organizationId: 'org-123' }; +const personalIdentity: AnalyticsIdentity = { kiloUserId: 'user-456', organizationId: null }; + +const API_KEY = 'phc_test_key'; +const POSTHOG_URL = 'https://us.i.posthog.com/i/v0/e/'; + +/** The full capture property set is compared, never a subset. */ +function expectExactKeys(payload: CapturePayload, expected: string[]): void { + expect(Object.keys(payload.properties).sort()).toEqual([...expected].sort()); +} + +const BASE_AUTH = ['feature', '$lib', 'userId', 'organizationId']; +const BASE_ANON = ['feature', '$lib', '$process_person_profile']; + +describe('event builders: property allowlists', () => { + it('sessionStartedEvent emits only protocolVersion, clientName, and the shared context', () => { + const payload = buildCapturePayload( + sessionStartedEvent({ identity, protocolVersion: '2025-06-18', clientName: 'kilo-cli' }), + API_KEY + ); + expect(payload.event).toBe('kilo_mcp_session_started'); + expectExactKeys(payload, [...BASE_AUTH, 'protocolVersion', 'clientName']); + }); + + it('sessionStartedEvent omits absent optional properties', () => { + const payload = buildCapturePayload(sessionStartedEvent({ identity }), API_KEY); + expectExactKeys(payload, BASE_AUTH); + }); + + it('toolCalledEvent emits tool, success, errorClass, latencyMs, and path when present', () => { + const payload = buildCapturePayload( + toolCalledEvent({ + identity, + tool: 'call', + path: 'organizations.list', + success: true, + errorClass: 'none', + latencyMs: 12, + }), + API_KEY + ); + expect(payload.event).toBe('kilo_mcp_tool_called'); + expectExactKeys(payload, [...BASE_AUTH, 'tool', 'success', 'errorClass', 'latencyMs', 'path']); + }); + + it('toolCalledEvent omits path when absent', () => { + const payload = buildCapturePayload( + toolCalledEvent({ + identity, + tool: 'search', + success: false, + errorClass: 'invalid_params', + latencyMs: 3, + }), + API_KEY + ); + expectExactKeys(payload, [...BASE_AUTH, 'tool', 'success', 'errorClass', 'latencyMs']); + }); + + it('searchPerformedEvent emits only the query shape and hit metadata', () => { + const payload = buildCapturePayload( + searchPerformedEvent({ + identity, + hitCount: 3, + empty: false, + queryTokenCount: 2, + queryCharBucket: '17-64', + limit: 10, + }), + API_KEY + ); + expect(payload.event).toBe('kilo_mcp_search_performed'); + expectExactKeys(payload, [ + ...BASE_AUTH, + 'hitCount', + 'empty', + 'queryTokenCount', + 'queryCharBucket', + 'limit', + ]); + }); + + it('callRejectedEvent emits reason and path when present', () => { + const payload = buildCapturePayload( + callRejectedEvent({ identity, reason: 'unknown_path', path: 'nope.missing' }), + API_KEY + ); + expect(payload.event).toBe('kilo_mcp_call_rejected'); + expectExactKeys(payload, [...BASE_AUTH, 'reason', 'path']); + }); + + it('callRejectedEvent omits path when absent', () => { + const payload = buildCapturePayload( + callRejectedEvent({ identity, reason: 'auth_failure' }), + API_KEY + ); + expectExactKeys(payload, [...BASE_AUTH, 'reason']); + }); + + it('oauthSignInEvent selects the event by phase and emits clientId/reason when present', () => { + const started = buildCapturePayload( + oauthSignInEvent({ identity, phase: 'started', clientId: 'client-1' }), + API_KEY + ); + expect(started.event).toBe('kilo_mcp_oauth_sign_in_started'); + expectExactKeys(started, [...BASE_AUTH, 'phase', 'clientId']); + + const succeeded = buildCapturePayload( + oauthSignInEvent({ identity, phase: 'succeeded' }), + API_KEY + ); + expect(succeeded.event).toBe('kilo_mcp_oauth_sign_in_succeeded'); + expectExactKeys(succeeded, [...BASE_AUTH, 'phase']); + + const failed = buildCapturePayload( + oauthSignInEvent({ identity, phase: 'failed', reason: 'access_denied' }), + API_KEY + ); + expect(failed.event).toBe('kilo_mcp_oauth_sign_in_failed'); + expectExactKeys(failed, [...BASE_AUTH, 'phase', 'reason']); + }); +}); + +describe('identity binding', () => { + it('binds authenticated events to the user and the organization', () => { + const payload = buildCapturePayload( + callRejectedEvent({ identity, reason: 'schema_invalid' }), + API_KEY + ); + expect(payload.distinct_id).toBe(identity.kiloUserId); + expect(payload.properties['userId']).toBe(identity.kiloUserId); + expect(payload.properties['organizationId']).toBe(identity.organizationId); + }); + + it('omits organizationId when the identity has no organization', () => { + const payload = buildCapturePayload( + sessionStartedEvent({ identity: personalIdentity }), + API_KEY + ); + expect(payload.distinct_id).toBe('user-456'); + expect(payload.properties).toHaveProperty('userId', 'user-456'); + expect(payload.properties).not.toHaveProperty('organizationId'); + }); + + it('null identity uses the anonymous id, creates no person, and has no userId', () => { + const payload = buildCapturePayload( + searchPerformedEvent({ + identity: null, + hitCount: 0, + empty: true, + queryTokenCount: 1, + queryCharBucket: '1-16', + limit: 10, + }), + API_KEY + ); + expect(payload.distinct_id).toBe(ANONYMOUS_DISTINCT_ID); + expect(payload.distinct_id).toBe('kilo-mcp-anonymous'); + expect(payload.properties['$process_person_profile']).toBe(false); + expect(payload.properties).not.toHaveProperty('userId'); + expect(payload.properties).not.toHaveProperty('organizationId'); + expectExactKeys(payload, [ + ...BASE_ANON, + 'hitCount', + 'empty', + 'queryTokenCount', + 'queryCharBucket', + 'limit', + ]); + }); +}); + +describe('secret absence', () => { + const SENTINEL_QUERY = 'SENTINEL-QUERY-9f'; + const SENTINEL_AUTH = 'Bearer SENTINEL-AUTH-9f'; + const SENTINEL_TOKEN = 'kilo_SENTINEL-TOKEN-9f'; + // The pattern the task requires for credential-looking keys. + const SENSITIVE_KEY_PATTERN = + /token|authorization|cookie|secret|password|prompt|content|^query$/i; + // `queryTokenCount` is a reviewed, required event property. The substring + // "token" in its name is not a credential, so the documented query-shape key + // is the one exemption from the pattern. + const REVIEWED_SHAPE_KEYS = new Set(['queryTokenCount']); + + it('never leaks raw query text, authorization strings, or tokens', () => { + const shape = queryShape(`${SENTINEL_QUERY} ${SENTINEL_AUTH} ${SENTINEL_TOKEN}`); + // Extra fields a caller might wrongly forward: the builders construct an + // explicit allowlist, so unknown keys are dropped. + const leak = { query: SENTINEL_QUERY, authorization: SENTINEL_AUTH, token: SENTINEL_TOKEN }; + + const events: McpAnalyticsEvent[] = [ + sessionStartedEvent({ identity, protocolVersion: '2025-06-18', clientName: 'kilo-cli' }), + toolCalledEvent({ + identity, + tool: 'search', + success: true, + errorClass: 'none', + latencyMs: 4, + }), + searchPerformedEvent({ + identity, + ...shape, + hitCount: 1, + empty: false, + limit: 10, + ...leak, + } as SearchPerformedInput), + callRejectedEvent({ identity, reason: 'auth_failure' }), + oauthSignInEvent({ identity, phase: 'succeeded', clientId: 'client-1' }), + ]; + + for (const event of events) { + const payload = buildCapturePayload(event, API_KEY); + const json = JSON.stringify(payload); + expect(json).not.toContain(SENTINEL_QUERY); + expect(json).not.toContain(SENTINEL_AUTH); + expect(json).not.toContain(SENTINEL_TOKEN); + expect(payload.distinct_id).toBe(identity.kiloUserId); + for (const [key, value] of Object.entries(payload.properties)) { + if (!REVIEWED_SHAPE_KEYS.has(key)) { + expect(key).not.toMatch(SENSITIVE_KEY_PATTERN); + } + if (typeof value === 'string') { + expect(value).not.toContain('@'); + } + } + } + }); + + it('queryShape never returns the raw text', () => { + const shape = queryShape(`Hello world ${SENTINEL_QUERY}`); + const json = JSON.stringify(shape); + expect(json).not.toContain('Hello'); + expect(json).not.toContain('world'); + expect(json).not.toContain(SENTINEL_QUERY); + }); +}); + +describe('queryShape', () => { + it('returns a token count and a bucket for prose', () => { + const shape = queryShape('Hello world secrets'); + expect(shape.queryTokenCount).toBe(3); + expect(shape.queryCharBucket).toBe('17-64'); + }); + + it('buckets by character length at the documented boundaries', () => { + expect(queryShape('').queryCharBucket).toBe('0'); + expect(queryShape('a'.repeat(16)).queryCharBucket).toBe('1-16'); + expect(queryShape('a'.repeat(17)).queryCharBucket).toBe('17-64'); + expect(queryShape('a'.repeat(64)).queryCharBucket).toBe('17-64'); + expect(queryShape('a'.repeat(65)).queryCharBucket).toBe('65-256'); + expect(queryShape('a'.repeat(256)).queryCharBucket).toBe('65-256'); + expect(queryShape('a'.repeat(257)).queryCharBucket).toBe('257+'); + }); +}); + +describe('classifyToolError', () => { + it('classifies an unknown catalog path', () => { + expect( + classifyToolError( + new JsonRpcFailure(-32602, 'Unknown path "nope.missing". The call tool only accepts ...', { + path: 'nope.missing', + }) + ) + ).toBe('unknown_path'); + }); + + it('classifies a published-schema violation', () => { + expect( + classifyToolError( + new JsonRpcFailure( + -32602, + 'Input does not match the published schema for "x": (root): input is required', + { path: 'x', violations: ['(root): input is required'] } + ) + ) + ).toBe('schema_invalid'); + }); + + it('classifies an unknown tool', () => { + expect( + classifyToolError( + new JsonRpcFailure(-32602, 'Unknown tool "nope". Available tools: search, call.') + ) + ).toBe('unknown_tool'); + }); + + it('classifies a retryable upstream failure', () => { + expect( + classifyToolError( + new JsonRpcFailure(-32000, 'Could not reach the Kilo API for "x". Retry the call.', { + path: 'x', + retryable: true, + }) + ) + ).toBe('upstream_unreachable'); + }); + + it('classifies a tRPC upstream failure', () => { + expect( + classifyToolError( + new JsonRpcFailure(-32000, 'Upstream Kilo request failed', { + path: 'x', + trpcCode: 'NOT_FOUND', + }) + ) + ).toBe('upstream_error'); + }); + + it('classifies invalid params', () => { + expect( + classifyToolError(new JsonRpcFailure(-32602, 'search requires a non-empty string "query".')) + ).toBe('invalid_params'); + }); + + it('classifies an internal error', () => { + expect( + classifyToolError( + new JsonRpcFailure(-32000, 'The Kilo API replied to "x" without a tRPC result body.', { + path: 'x', + }) + ) + ).toBe('internal_error'); + }); + + it('classifies a plain Error by its name', () => { + expect(classifyToolError(new Error('boom'))).toBe('Error'); + }); + + it('classifies a non-error value as unknown', () => { + expect(classifyToolError('boom')).toBe('unknown'); + }); +}); + +type Ctx = { waitUntil(promise: Promise): void }; + +function makeCtx(): { ctx: Ctx; promises: Promise[] } { + const promises: Promise[] = []; + return { + ctx: { + waitUntil: promise => { + promises.push(promise); + }, + }, + promises, + }; +} + +const EMITS: Array<[string, (analytics: McpAnalytics) => void]> = [ + [ + 'sessionStarted', + analytics => + analytics.sessionStarted({ identity, protocolVersion: '2025-06-18', clientName: 'kilo-cli' }), + ], + [ + 'toolCalled', + analytics => + analytics.toolCalled({ + identity, + tool: 'call', + path: 'organizations.list', + success: false, + errorClass: 'upstream_error', + latencyMs: 12, + }), + ], + [ + 'searchPerformed', + analytics => + analytics.searchPerformed({ + identity, + ...queryShape('hello world'), + hitCount: 0, + empty: true, + limit: 10, + }), + ], + [ + 'callRejected', + analytics => + analytics.callRejected({ identity, reason: 'schema_invalid', path: 'organizations.list' }), + ], + [ + 'oauthSignIn', + analytics => + analytics.oauthSignIn({ identity, phase: 'failed', clientId: 'client-1', reason: 'denied' }), + ], +]; + +describe('createMcpAnalytics failure paths never throw', () => { + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown): void => { + unhandled.push(reason); + }; + + beforeEach(() => { + unhandled.length = 0; + process.on('unhandledRejection', onUnhandled); + }); + + afterEach(() => { + process.off('unhandledRejection', onUnhandled); + }); + + async function flush(): Promise { + await new Promise(resolve => setTimeout(resolve, 0)); + } + + it('(a) survives a synchronous fetch throw, waitUntil gets a resolving promise', async () => { + const { ctx, promises } = makeCtx(); + const throwingFetch = (() => { + throw new Error('sync boom'); + }) as unknown as typeof fetch; + const analytics = createMcpAnalytics({ + env: { NEXT_PUBLIC_POSTHOG_KEY: API_KEY }, + ctx, + fetchImpl: throwingFetch, + log: () => {}, + }); + + for (const [, emit] of EMITS) { + expect(() => emit(analytics)).not.toThrow(); + } + expect(promises).toHaveLength(EMITS.length); + await Promise.all(promises); + await flush(); + expect(unhandled).toEqual([]); + }); + + it('(b) survives a rejected fetch, waitUntil gets a resolving promise', async () => { + const { ctx, promises } = makeCtx(); + const rejectingFetch = vi.fn().mockRejectedValue(new Error('rejected')); + const analytics = createMcpAnalytics({ + env: { NEXT_PUBLIC_POSTHOG_KEY: API_KEY }, + ctx, + fetchImpl: rejectingFetch as unknown as typeof fetch, + log: () => {}, + }); + + for (const [, emit] of EMITS) { + expect(() => emit(analytics)).not.toThrow(); + } + expect(promises).toHaveLength(EMITS.length); + await Promise.all(promises); + await flush(); + expect(unhandled).toEqual([]); + }); + + it('(c) survives a never-resolving fetch, waitUntil is still called', async () => { + const { ctx, promises } = makeCtx(); + const pendingFetch = (() => new Promise(() => {})) as unknown as typeof fetch; + const analytics = createMcpAnalytics({ + env: { NEXT_PUBLIC_POSTHOG_KEY: API_KEY }, + ctx, + fetchImpl: pendingFetch, + log: () => {}, + }); + + for (const [, emit] of EMITS) { + expect(() => emit(analytics)).not.toThrow(); + } + expect(promises).toHaveLength(EMITS.length); + await flush(); + expect(unhandled).toEqual([]); + }); + + it('(d) survives failure when there is no ExecutionContext', () => { + const throwingFetch = (() => { + throw new Error('sync boom'); + }) as unknown as typeof fetch; + const analytics = createMcpAnalytics({ + env: { NEXT_PUBLIC_POSTHOG_KEY: API_KEY }, + fetchImpl: throwingFetch, + log: () => {}, + }); + + for (const [, emit] of EMITS) { + expect(() => emit(analytics)).not.toThrow(); + } + }); +}); + +describe('createMcpAnalytics transport and gating', () => { + it('logs the decisive line but does not fetch when the key is unset or empty', () => { + const fetchImpl = vi.fn().mockResolvedValue(new Response('{}', { status: 200 })); + const log = vi.fn(); + for (const key of [undefined, '']) { + const analytics = createMcpAnalytics({ + env: { NEXT_PUBLIC_POSTHOG_KEY: key }, + fetchImpl: fetchImpl as unknown as typeof fetch, + log, + }); + analytics.toolCalled({ + identity, + tool: 'search', + success: true, + errorClass: 'none', + latencyMs: 1, + }); + } + + expect(fetchImpl).not.toHaveBeenCalled(); + expect(log).toHaveBeenCalledTimes(2); + expect(log.mock.calls[0]?.[0]).toMatch(/^\[kilo-mcp\] analytics kilo_mcp_tool_called /); + }); + + it('logs event and properties without the key', () => { + const log = vi.fn(); + const { ctx } = makeCtx(); + const fetchImpl = vi.fn().mockResolvedValue(new Response('{}', { status: 200 })); + const analytics = createMcpAnalytics({ + env: { NEXT_PUBLIC_POSTHOG_KEY: API_KEY }, + ctx, + fetchImpl: fetchImpl as unknown as typeof fetch, + log, + }); + + analytics.callRejected({ identity, reason: 'unknown_path', path: 'nope.missing' }); + + const line = String(log.mock.calls[0]?.[0] ?? ''); + expect(line).toContain('kilo_mcp_call_rejected'); + expect(line).toContain('unknown_path'); + expect(line).toContain('userId'); + expect(line).not.toContain(API_KEY); + }); + + it('posts the capture payload with a timeout and consumes the body', async () => { + const fetchImpl = vi.fn().mockResolvedValue(new Response('{}', { status: 200 })); + const { ctx, promises } = makeCtx(); + const analytics = createMcpAnalytics({ + env: { NEXT_PUBLIC_POSTHOG_KEY: API_KEY }, + ctx, + fetchImpl: fetchImpl as unknown as typeof fetch, + log: () => {}, + }); + + analytics.searchPerformed({ + identity, + ...queryShape('hello world'), + hitCount: 2, + empty: false, + limit: 10, + }); + await Promise.all(promises); + + expect(fetchImpl).toHaveBeenCalledTimes(1); + const [url, init] = fetchImpl.mock.calls[0] ?? []; + expect(url).toBe(POSTHOG_URL); + expect(init).toMatchObject({ method: 'POST' }); + expect(init?.signal).toBeInstanceOf(AbortSignal); + const body = JSON.parse(String(init?.body)) as Record; + expect(body['api_key']).toBe(API_KEY); + expect(body['event']).toBe('kilo_mcp_search_performed'); + expect(body['distinct_id']).toBe(identity.kiloUserId); + const properties = body['properties'] as Record; + expect(properties['feature']).toBe('kilo-mcp'); + expect(properties['$lib']).toBe('kilo-mcp-worker'); + expect(properties['userId']).toBe(identity.kiloUserId); + expect(properties['organizationId']).toBe(identity.organizationId); + }); +}); diff --git a/services/kilo-mcp/src/analytics.ts b/services/kilo-mcp/src/analytics.ts new file mode 100644 index 0000000000..04145b635a --- /dev/null +++ b/services/kilo-mcp/src/analytics.ts @@ -0,0 +1,324 @@ +/** + * Server-side PostHog analytics for the kilo-MCP worker. + * + * Transport mirrors the repository's Cloudflare-Worker PostHog convention: a + * direct `fetch` to the capture API with a 5s `AbortSignal.timeout`, no SDK and + * no dependency (see services/kiloclaw/src/lib/posthog.ts and + * services/security-auto-analysis/src/posthog.ts). Captures are scheduled + * through `ExecutionContext.waitUntil` so they never block an MCP response, + * and every public call is wrapped so analytics can never throw. + * + * Privacy: the builders accept identifiers, counts, durations, and error + * classes only. Raw query text is reduced to a token count and a length bucket + * by `queryShape`. Authenticated events carry `userId` and (when present) + * `organizationId`; anonymous events set `$process_person_profile: false` so + * PostHog creates no person row. No message content, prompt, token, cookie, or + * credential is ever emitted. + */ +import { tokenize } from './search'; +import { JsonRpcFailure } from './types'; + +const POSTHOG_CAPTURE_URL = 'https://us.i.posthog.com/i/v0/e/'; +const POSTHOG_TIMEOUT_MS = 5_000; + +/** distinct_id for events emitted without an authenticated identity. */ +export const ANONYMOUS_DISTINCT_ID = 'kilo-mcp-anonymous'; + +/** The authenticated caller an event is bound to. */ +export type AnalyticsIdentity = { + kiloUserId: string; + organizationId: string | null; +}; + +/** A pure, pre-transport analytics event. */ +export type McpAnalyticsEvent = { + event: string; + identity: AnalyticsIdentity | null; + properties: Record; +}; + +/** Error classes `classifyToolError` can return. */ +export type ToolErrorClass = + | 'auth_failure' + | 'unknown_path' + | 'schema_invalid' + | 'invalid_params' + | 'unknown_tool' + | 'upstream_unreachable' + | 'upstream_error' + | 'internal_error' + | 'unknown'; + +/** Reasons a call is rejected before a tool result is produced. */ +export type CallRejectedReason = + | 'auth_failure' + | 'unknown_path' + | 'schema_invalid' + | 'invalid_params'; + +/** Query length bucket; carries no user text. */ +export type QueryCharBucket = '0' | '1-16' | '17-64' | '65-256' | '257+'; + +/** The query's shape only: token count and length bucket. */ +export type QueryShape = { + queryTokenCount: number; + queryCharBucket: QueryCharBucket; +}; + +export type SessionStartedInput = { + identity: AnalyticsIdentity | null; + protocolVersion?: string; + clientName?: string; +}; + +export type ToolCalledInput = { + identity: AnalyticsIdentity | null; + tool: string; + path?: string; + success: boolean; + errorClass: string; + latencyMs: number; +}; + +export type SearchPerformedInput = { + identity: AnalyticsIdentity | null; + hitCount: number; + empty: boolean; + queryTokenCount: number; + queryCharBucket: QueryCharBucket; + limit: number; +}; + +export type CallRejectedInput = { + identity: AnalyticsIdentity | null; + reason: CallRejectedReason; + path?: string; +}; + +export type OAuthSignInPhase = 'started' | 'succeeded' | 'failed'; + +export type OAuthSignInInput = { + identity: AnalyticsIdentity | null; + phase: OAuthSignInPhase; + clientId?: string; + reason?: string; +}; + +/** `kilo_mcp_session_started`. */ +export function sessionStartedEvent(input: SessionStartedInput): McpAnalyticsEvent { + const properties: Record = {}; + if (input.protocolVersion !== undefined) properties['protocolVersion'] = input.protocolVersion; + if (input.clientName !== undefined) properties['clientName'] = input.clientName; + return { event: 'kilo_mcp_session_started', identity: input.identity, properties }; +} + +/** `kilo_mcp_tool_called`. */ +export function toolCalledEvent(input: ToolCalledInput): McpAnalyticsEvent { + const properties: Record = { + tool: input.tool, + success: input.success, + errorClass: input.errorClass, + latencyMs: input.latencyMs, + }; + if (input.path !== undefined) properties['path'] = input.path; + return { event: 'kilo_mcp_tool_called', identity: input.identity, properties }; +} + +/** `kilo_mcp_search_performed`. */ +export function searchPerformedEvent(input: SearchPerformedInput): McpAnalyticsEvent { + return { + event: 'kilo_mcp_search_performed', + identity: input.identity, + properties: { + hitCount: input.hitCount, + empty: input.empty, + queryTokenCount: input.queryTokenCount, + queryCharBucket: input.queryCharBucket, + limit: input.limit, + }, + }; +} + +/** `kilo_mcp_call_rejected`. */ +export function callRejectedEvent(input: CallRejectedInput): McpAnalyticsEvent { + const properties: Record = { reason: input.reason }; + if (input.path !== undefined) properties['path'] = input.path; + return { event: 'kilo_mcp_call_rejected', identity: input.identity, properties }; +} + +const OAUTH_EVENT_BY_PHASE: Record = { + started: 'kilo_mcp_oauth_sign_in_started', + succeeded: 'kilo_mcp_oauth_sign_in_succeeded', + failed: 'kilo_mcp_oauth_sign_in_failed', +}; + +/** `kilo_mcp_oauth_sign_in_{started,succeeded,failed}`. */ +export function oauthSignInEvent(input: OAuthSignInInput): McpAnalyticsEvent { + const properties: Record = { phase: input.phase }; + if (input.clientId !== undefined) properties['clientId'] = input.clientId; + if (input.reason !== undefined) properties['reason'] = input.reason; + return { event: OAUTH_EVENT_BY_PHASE[input.phase], identity: input.identity, properties }; +} + +/** + * Event properties plus the identity binding and the shared PostHog context. + * Authenticated events get `userId` and, when non-null, `organizationId`; + * anonymous events set `$process_person_profile: false` so no PostHog person + * row is created. + */ +function captureProperties(event: McpAnalyticsEvent): Record { + const properties: Record = { + ...event.properties, + feature: 'kilo-mcp', + $lib: 'kilo-mcp-worker', + }; + if (event.identity) { + properties['userId'] = event.identity.kiloUserId; + if (event.identity.organizationId !== null) { + properties['organizationId'] = event.identity.organizationId; + } + } else { + properties['$process_person_profile'] = false; + } + return properties; +} + +/** The exact PostHog capture request body. */ +export type CapturePayload = { + api_key: string; + distinct_id: string; + event: string; + properties: Record; +}; + +/** Build the exact capture body for an event. */ +export function buildCapturePayload(event: McpAnalyticsEvent, apiKey: string): CapturePayload { + return { + api_key: apiKey, + distinct_id: event.identity?.kiloUserId ?? ANONYMOUS_DISTINCT_ID, + event: event.event, + properties: captureProperties(event), + }; +} + +/** Reduce a raw query to a token count and a length bucket; never the text. */ +export function queryShape(query: string): QueryShape { + const queryTokenCount = tokenize(query).length; + const length = query.length; + const queryCharBucket: QueryCharBucket = + length === 0 + ? '0' + : length <= 16 + ? '1-16' + : length <= 64 + ? '17-64' + : length <= 256 + ? '65-256' + : '257+'; + return { queryTokenCount, queryCharBucket }; +} + +const INVALID_PARAMS = -32602; +const INTERNAL_ERROR = -32000; + +/** + * Classify a tool failure into an allowlisted error class. Message content is + * inspected, never emitted; the returned class is what reaches analytics (a + * plain Error contributes only its `name`). + */ +export function classifyToolError(error: unknown): string { + if (error instanceof JsonRpcFailure) { + if (error.code === INVALID_PARAMS && error.message.startsWith('Unknown path')) { + return 'unknown_path'; + } + if (error.message.includes('published schema')) { + return 'schema_invalid'; + } + if (error.message.includes('Unknown tool')) { + return 'unknown_tool'; + } + if (error.code === INTERNAL_ERROR && error.data?.['retryable'] === true) { + return 'upstream_unreachable'; + } + if (error.code === INTERNAL_ERROR && typeof error.data?.['trpcCode'] === 'string') { + return 'upstream_error'; + } + if (error.code === INTERNAL_ERROR) { + return 'internal_error'; + } + return 'invalid_params'; + } + if (error instanceof Error) { + return error.name; + } + return 'unknown'; +} + +/** Injectable dependencies for `createMcpAnalytics`. */ +export type McpAnalyticsDeps = { + env: { NEXT_PUBLIC_POSTHOG_KEY?: string }; + ctx?: { waitUntil(promise: Promise): void }; + fetchImpl?: typeof fetch; + log?: (line: string) => void; +}; + +/** The five emit methods; each takes its builder's input shape. */ +export type McpAnalytics = { + sessionStarted(input: SessionStartedInput): void; + toolCalled(input: ToolCalledInput): void; + searchPerformed(input: SearchPerformedInput): void; + callRejected(input: CallRejectedInput): void; + oauthSignIn(input: OAuthSignInInput): void; +}; + +/** + * POST one capture with a 5s timeout and consume the body. The fetch runs + * inside an async function with a local try/catch so a `fetchImpl` that throws + * synchronously, rejects, or times out can never surface. + */ +async function sendCapture(payload: CapturePayload, fetchImpl: typeof fetch): Promise { + try { + const response = await fetchImpl(POSTHOG_CAPTURE_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + signal: AbortSignal.timeout(POSTHOG_TIMEOUT_MS), + body: JSON.stringify(payload), + }); + // Consume the body so the Worker does not warn about a leaked response. + await response.text().catch(() => ''); + } catch { + // Best-effort: a failed capture must never surface to the caller. + } +} + +/** + * Create the analytics emitter for one request. Every method always logs the + * decisive local-proof line, then either stops (no key: consent / non-prod + * gate) or schedules a capture through `waitUntil`. It can never throw. + */ +export function createMcpAnalytics(deps: McpAnalyticsDeps): McpAnalytics { + const log = deps.log ?? console.log; + const fetchImpl = deps.fetchImpl ?? fetch; + + function emit(event: McpAnalyticsEvent): void { + try { + const serialized = JSON.stringify(captureProperties(event)) ?? '{}'; + log(`[kilo-mcp] analytics ${event.event} ${serialized}`); + const apiKey = deps.env.NEXT_PUBLIC_POSTHOG_KEY; + if (!apiKey) return; + const promise = sendCapture(buildCapturePayload(event, apiKey), fetchImpl).catch(() => {}); + if (deps.ctx) deps.ctx.waitUntil(promise); + else void promise; + } catch { + // Analytics is best-effort and must never break an MCP response. + } + } + + return { + sessionStarted: input => emit(sessionStartedEvent(input)), + toolCalled: input => emit(toolCalledEvent(input)), + searchPerformed: input => emit(searchPerformedEvent(input)), + callRejected: input => emit(callRejectedEvent(input)), + oauthSignIn: input => emit(oauthSignInEvent(input)), + }; +} diff --git a/services/kilo-mcp/src/auth/authorize.test.ts b/services/kilo-mcp/src/auth/authorize.test.ts index 8b7d398cbb..4beb45b76d 100644 --- a/services/kilo-mcp/src/auth/authorize.test.ts +++ b/services/kilo-mcp/src/auth/authorize.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { handleAuthorize } from './authorize'; import { codeChallengeFromVerifier, generateCodeVerifier } from './pkce'; +import type { McpAnalytics, OAuthSignInInput } from '../analytics'; import type { NewOAuthCode, OAuthCodeRecord, @@ -117,9 +118,43 @@ function deviceAuthFetch(code = 'PAIR-1234') { ); } +/** + * Fake emitter cast to the production `McpAnalytics` interface: the tests + * assert exactly what the worker hands it, so a new field cannot slip past. + */ +function fakeAnalytics(): { analytics: McpAnalytics; calls: OAuthSignInInput[] } { + const calls: OAuthSignInInput[] = []; + const analytics = { + oauthSignIn: vi.fn((input: OAuthSignInInput) => { + calls.push(input); + }), + } as unknown as McpAnalytics; + return { analytics, calls }; +} + +const OAUTH_EVENT_FIELDS = ['clientId', 'identity', 'phase', 'reason']; +const IDENTITY_FIELDS = ['kiloUserId', 'organizationId']; + +/** + * The sign-in events must carry no token, authorization code, PKCE verifier, + * or state: every recorded event exposes only the documented fields. + */ +function expectNoCredentialLeak(calls: OAuthSignInInput[]): void { + for (const call of calls) { + for (const key of Object.keys(call)) { + expect(OAUTH_EVENT_FIELDS).toContain(key); + } + if (call.identity) { + for (const key of Object.keys(call.identity)) { + expect(IDENTITY_FIELDS).toContain(key); + } + } + } +} + async function authorize( overrides: Record = {}, - deps: { store?: OAuthStoreApi; fetchImpl?: typeof fetch } = {} + deps: { store?: OAuthStoreApi; fetchImpl?: typeof fetch; analytics?: McpAnalytics } = {} ): Promise<{ response: Response; store: ReturnType }> { const store = deps.store ?? storeWithClient(); const response = await handleAuthorize( @@ -140,6 +175,7 @@ async function authorize( store, webBaseUrl: WEB, fetchImpl: (deps.fetchImpl ?? deviceAuthFetch()) as typeof fetch, + analytics: deps.analytics, } ); return { response, store: store as ReturnType }; @@ -310,3 +346,57 @@ describe('GET /authorize (retryable unhappy: Kilo pairing unavailable)', () => { expect(store.codes.size).toBe(0); }); }); + +describe('GET /authorize analytics (s3)', () => { + it('emits exactly one anonymous started event with the client_id on success', async () => { + const { analytics, calls } = fakeAnalytics(); + const { response } = await authorize({}, { analytics }); + expect(response.status).toBe(200); + expect(calls).toHaveLength(1); + expect(calls[0]).toEqual({ phase: 'started', identity: null, clientId: CLIENT_ID }); + expectNoCredentialLeak(calls); + }); + + it('reports a rate-limited pairing as failed with that reason', async () => { + const { analytics, calls } = fakeAnalytics(); + const fetchImpl = vi.fn(async () => Response.json({ error: 'too many' }, { status: 429 })); + const { response } = await authorize( + {}, + { fetchImpl: fetchImpl as unknown as typeof fetch, analytics } + ); + expect(response.status).toBe(503); + expect(calls).toEqual([ + { phase: 'failed', identity: null, clientId: CLIENT_ID, reason: 'rate_limited' }, + ]); + expectNoCredentialLeak(calls); + }); + + it('reports an unreachable pairing as failed with that reason', async () => { + const { analytics, calls } = fakeAnalytics(); + const fetchImpl = vi.fn(async () => { + throw new Error('network down'); + }); + await authorize({}, { fetchImpl: fetchImpl as unknown as typeof fetch, analytics }); + expect(calls).toEqual([ + { phase: 'failed', identity: null, clientId: CLIENT_ID, reason: 'unreachable' }, + ]); + expectNoCredentialLeak(calls); + }); + + it('reports a redirectable validation error as failed with the OAuth error code', async () => { + const { analytics, calls } = fakeAnalytics(); + await authorize({ resource: 'https://other.test/mcp' }, { analytics }); + expect(calls).toEqual([ + { phase: 'failed', identity: null, clientId: CLIENT_ID, reason: 'invalid_target' }, + ]); + expectNoCredentialLeak(calls); + }); + + it('reports a rendered validation error and omits clientId when it was not parsed', async () => { + const { analytics, calls } = fakeAnalytics(); + // `authorizeUrl` drops undefined params, so this request has no client_id. + await authorize({ client_id: undefined }, { analytics }); + expect(calls).toStrictEqual([{ phase: 'failed', identity: null, reason: 'invalid_request' }]); + expectNoCredentialLeak(calls); + }); +}); diff --git a/services/kilo-mcp/src/auth/authorize.ts b/services/kilo-mcp/src/auth/authorize.ts index 3fad541132..77b2a01983 100644 --- a/services/kilo-mcp/src/auth/authorize.ts +++ b/services/kilo-mcp/src/auth/authorize.ts @@ -15,6 +15,7 @@ import { base64UrlEncode, isValidCodeChallenge } from './pkce'; import { mcpResourceUrl } from './metadata'; import { MCP_SCOPE, errorPage, redirectToClientError } from './http'; import { consentPage } from '../oauth-pages/authorize-page'; +import type { McpAnalytics } from '../analytics'; import type { OAuthStoreApi, StoredClient } from '../store/oauth-store'; export type AuthorizeDeps = { @@ -23,6 +24,8 @@ export type AuthorizeDeps = { webBaseUrl: string; fetchImpl?: typeof fetch; now?: () => Date; + /** Best-effort sign-in analytics; never awaited and never allowed to throw. */ + analytics?: McpAnalytics; }; /** Pairing records are short-lived: 10 minutes to complete sign-in. */ @@ -215,10 +218,25 @@ export async function handleAuthorize(request: Request, deps: AuthorizeDeps): Pr try { validated = await validateAuthorizeRequest(url, deps.store, issuer); } catch (error) { + // A rejected request never produced an identity; report the OAuth error + // code and the client_id only when the request actually carried one. + const clientId = url.searchParams.get('client_id'); if (error instanceof AuthorizePageError) { + deps.analytics?.oauthSignIn({ + phase: 'failed', + identity: null, + ...(clientId !== null ? { clientId } : {}), + reason: 'invalid_request', + }); return errorPage('invalid_request', error.message); } if (error instanceof AuthorizeRedirectError) { + deps.analytics?.oauthSignIn({ + phase: 'failed', + identity: null, + ...(clientId !== null ? { clientId } : {}), + reason: error.error, + }); return redirectToClientError(error.redirectUri, error.error, error.description, error.state); } throw error; @@ -228,6 +246,12 @@ export async function handleAuthorize(request: Request, deps: AuthorizeDeps): Pr if (!pairing.ok) { // Retryable unhappy path: nothing was created; tell the user exactly what // to do (wait vs check connection) and send them back to the client. + deps.analytics?.oauthSignIn({ + phase: 'failed', + identity: null, + clientId: validated.client.clientId, + reason: pairing.kind, + }); const description = pairing.kind === 'rate_limited' ? 'Too many pending Kilo sign-in requests from your network right now. Wait a few minutes, then retry from your MCP client.' @@ -250,6 +274,13 @@ export async function handleAuthorize(request: Request, deps: AuthorizeDeps): Pr expiresAt: new Date(now.getTime() + CODE_TTL_SECONDS * 1000).toISOString(), }); + // The user has not signed in yet: this pre-auth start event is anonymous. + deps.analytics?.oauthSignIn({ + phase: 'started', + identity: null, + clientId: validated.client.clientId, + }); + return consentPage({ clientName: validated.client.clientName, scope: validated.scope, diff --git a/services/kilo-mcp/src/auth/token.test.ts b/services/kilo-mcp/src/auth/token.test.ts index e716742715..160cc43052 100644 --- a/services/kilo-mcp/src/auth/token.test.ts +++ b/services/kilo-mcp/src/auth/token.test.ts @@ -1,7 +1,8 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { handleToken, ACCESS_TOKEN_TTL_SECONDS } from './token'; import { decodeJwt } from './jwt'; import { codeChallengeFromVerifier, generateCodeVerifier } from './pkce'; +import type { McpAnalytics, OAuthSignInInput } from '../analytics'; import type { NewRefreshToken, NewOAuthCode, @@ -209,12 +210,47 @@ function tokenRequest(form: Record): Request { }); } -function handle(form: Record, store: OAuthStoreApi) { +/** + * Fake emitter cast to the production `McpAnalytics` interface: the tests + * assert exactly what the worker hands it, so a new field cannot slip past. + */ +function fakeAnalytics(): { analytics: McpAnalytics; calls: OAuthSignInInput[] } { + const calls: OAuthSignInInput[] = []; + const analytics = { + oauthSignIn: vi.fn((input: OAuthSignInInput) => { + calls.push(input); + }), + } as unknown as McpAnalytics; + return { analytics, calls }; +} + +const OAUTH_EVENT_FIELDS = ['clientId', 'identity', 'phase', 'reason']; +const IDENTITY_FIELDS = ['kiloUserId', 'organizationId']; + +/** + * The sign-in events must carry no token, authorization code, PKCE verifier, + * or state: every recorded event exposes only the documented fields. + */ +function expectNoCredentialLeak(calls: OAuthSignInInput[]): void { + for (const call of calls) { + for (const key of Object.keys(call)) { + expect(OAUTH_EVENT_FIELDS).toContain(key); + } + if (call.identity) { + for (const key of Object.keys(call.identity)) { + expect(IDENTITY_FIELDS).toContain(key); + } + } + } +} + +function handle(form: Record, store: OAuthStoreApi, analytics?: McpAnalytics) { return handleToken(tokenRequest(form), { store, tokenSecret: SECRET, issuer: ISSUER, now: () => NOW, + analytics, }); } @@ -691,6 +727,111 @@ describe('POST /token refresh_token (rotation)', () => { }); }); +describe('POST /token analytics (s3)', () => { + it('emits succeeded bound to the kilo user and organization for a completed exchange', async () => { + const store = storeWithClient(); + const code = await seedApprovedCode(store); + const { analytics, calls } = fakeAnalytics(); + const response = await handle( + { + grant_type: 'authorization_code', + code, + client_id: CLIENT_ID, + redirect_uri: REDIRECT, + code_verifier: verifier, + }, + store, + analytics + ); + expect(response.status).toBe(200); + expect(calls).toEqual([ + { + phase: 'succeeded', + identity: { kiloUserId: 'kilo-user-1', organizationId: 'org-1' }, + clientId: CLIENT_ID, + }, + ]); + expectNoCredentialLeak(calls); + }); + + it('emits failed/invalid_grant and leaves the error body readable for the client', async () => { + const store = storeWithClient(); + const code = await seedApprovedCode(store, { status: 'used' }); + const { analytics, calls } = fakeAnalytics(); + const response = await handle( + { + grant_type: 'authorization_code', + code, + client_id: CLIENT_ID, + redirect_uri: REDIRECT, + code_verifier: verifier, + }, + store, + analytics + ); + expect(response.status).toBe(400); + // The analytics read used a clone, so the client still gets the body. + await expect(response.json()).resolves.toMatchObject({ error: 'invalid_grant' }); + expect(calls).toEqual([ + { phase: 'failed', identity: null, clientId: CLIENT_ID, reason: 'invalid_grant' }, + ]); + expectNoCredentialLeak(calls); + }); + + it('emits failed/invalid_client for an unknown client', async () => { + const store = storeWithClient(); + const code = await seedApprovedCode(store); + const { analytics, calls } = fakeAnalytics(); + const response = await handle( + { + grant_type: 'authorization_code', + code, + client_id: 'ghost', + redirect_uri: REDIRECT, + code_verifier: verifier, + }, + store, + analytics + ); + expect(response.status).toBe(400); + expect(calls).toEqual([ + { phase: 'failed', identity: null, clientId: 'ghost', reason: 'invalid_client' }, + ]); + }); + + it('emits nothing for refresh_token outcomes (success or failure)', async () => { + const store = storeWithClient(); + const { analytics, calls } = fakeAnalytics(); + const refreshToken = 'opaque-refresh-token-value-0000000000000000000000000000'; + await store.saveRefreshToken({ + id: 'rt-analytics', + tokenHash: await sha256HexTest(refreshToken), + clientId: CLIENT_ID, + kiloUserId: 'kilo-user-1', + organizationId: 'org-1', + kiloToken: 'kilo-token-1', + resource: RESOURCE, + scope: 'mcp', + createdAt: NOW.toISOString(), + expiresAt: new Date(NOW.getTime() + 30 * 24 * 3600_000).toISOString(), + }); + const rotated = await handle( + { grant_type: 'refresh_token', refresh_token: refreshToken, client_id: CLIENT_ID }, + store, + analytics + ); + expect(rotated.status).toBe(200); + // Replaying the rotated-away token is an invalid_grant, not a sign-in. + const replay = await handle( + { grant_type: 'refresh_token', refresh_token: refreshToken, client_id: CLIENT_ID }, + store, + analytics + ); + expect(replay.status).toBe(400); + expect(calls).toEqual([]); + }); +}); + async function sha256HexTest(value: string): Promise { const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value)); return [...new Uint8Array(digest)].map(byte => byte.toString(16).padStart(2, '0')).join(''); diff --git a/services/kilo-mcp/src/auth/token.ts b/services/kilo-mcp/src/auth/token.ts index 2210be6b2f..f0436785e3 100644 --- a/services/kilo-mcp/src/auth/token.ts +++ b/services/kilo-mcp/src/auth/token.ts @@ -19,6 +19,7 @@ import { signJwt } from './jwt'; import { base64UrlEncode, isValidCodeVerifier, verifyPkceS256 } from './pkce'; import { MCP_SCOPE, oauthErrorResponse, authJsonResponse } from './http'; +import type { McpAnalytics } from '../analytics'; import type { OAuthStoreApi } from '../store/oauth-store'; export type TokenDeps = { @@ -28,6 +29,8 @@ export type TokenDeps = { /** This worker's origin for the current request — the JWT `iss`. */ issuer: string; now?: () => Date; + /** Best-effort sign-in analytics; never awaited and never allowed to throw. */ + analytics?: McpAnalytics; }; /** Access tokens are short-lived; revocation is via the jti registry. */ @@ -264,6 +267,15 @@ async function exchangeAuthorizationCode( opaqueToken(48), now ); + // First point where both the user and the organization are known. + deps.analytics?.oauthSignIn({ + phase: 'succeeded', + identity: { + kiloUserId: consumed.kiloUserId ?? record.kiloUserId, + organizationId: consumed.organizationId, + }, + clientId, + }); return authJsonResponse(pair, 200, { 'Cache-Control': 'no-store' }); } @@ -394,16 +406,41 @@ export async function handleToken(request: Request, deps: TokenDeps): Promise => { + switch (grantType) { + case 'authorization_code': + return exchangeAuthorizationCode(deps, params, now); + case 'refresh_token': + return redeemRefreshToken(deps, params, now); + default: + return oauthErrorResponse( + 400, + 'unsupported_grant_type', + 'Only authorization_code and refresh_token grants are supported.' + ); + } + })(); + + // A failed authorization_code exchange is a sign-in failure. A failed + // refresh is not (the user already signed in), so it stays silent. + if (grantType === 'authorization_code' && !response.ok) { + const body: unknown = await response + .clone() + .json() + .catch(() => null); + const reason = + typeof body === 'object' && + body !== null && + typeof (body as { error?: unknown }).error === 'string' + ? (body as { error: string }).error + : 'invalid_request'; + deps.analytics?.oauthSignIn({ + phase: 'failed', + identity: null, + clientId: params['client_id'], + reason, + }); } + return response; } diff --git a/services/kilo-mcp/src/index.test.ts b/services/kilo-mcp/src/index.test.ts index d343245a08..2891940888 100644 --- a/services/kilo-mcp/src/index.test.ts +++ b/services/kilo-mcp/src/index.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import worker, { createMcpHandler } from './index'; +import { ANONYMOUS_DISTINCT_ID, createMcpAnalytics } from './analytics'; import { ORGANIZATION_ID_HEADER } from './auth'; import { decodeJwt, signJwt } from './auth/jwt'; import type { @@ -24,6 +25,18 @@ vi.mock('cloudflare:workers', () => ({ }, })); +/** The default fetch handler takes the Worker ExecutionContext; these tests are + * not exercising analytics scheduling, so a no-op sink is enough. */ +const TEST_CTX = { + waitUntil: () => {}, + passThroughOnException: () => {}, +} as unknown as ExecutionContext; + +/** Call the default fetch handler with the required ExecutionContext filled in. */ +function workerFetch(request: Request, env: Env): Promise { + return worker.fetch(request, env, TEST_CTX); +} + /** Inline test catalog (no committed fixture; tests never depend on catalog drift). */ const testCatalog: Catalog = { 'organizations.list': { @@ -87,12 +100,12 @@ describe('routing and transport', () => { const env = { WEB_BASE_URL: 'https://app.kilo.ai' } as Env; it('serves MCP only at /mcp: unknown routes are 404', async () => { - const response = await worker.fetch(new Request('https://kilo-mcp.test/other'), env); + const response = await workerFetch(new Request('https://kilo-mcp.test/other'), env); expect(response.status).toBe(404); }); it('answers CORS preflight with the shared header set', async () => { - const response = await worker.fetch( + const response = await workerFetch( new Request('https://kilo-mcp.test/mcp', { method: 'OPTIONS' }), env ); @@ -102,7 +115,7 @@ describe('routing and transport', () => { }); it('is stateless POST-only: GET /mcp is 405', async () => { - const response = await worker.fetch( + const response = await workerFetch( new Request('https://kilo-mcp.test/mcp', { method: 'GET' }), env ); @@ -580,7 +593,7 @@ describe('auth endpoint routing (s5)', () => { } async function fetchJson(path: string, init?: RequestInit) { - const response = await worker.fetch( + const response = await workerFetch( new Request(`${ISSUER}${path}`, init), oauthEnv(createRoutingStore()) ); @@ -626,7 +639,7 @@ describe('auth endpoint routing (s5)', () => { it('POST /register persists a public client and answers 201 without a secret', async () => { const store = createRoutingStore(); - const response = await worker.fetch( + const response = await workerFetch( new Request(`${ISSUER}/register`, { method: 'POST', headers: { 'content-type': 'application/json' }, @@ -661,7 +674,7 @@ describe('auth endpoint routing (s5)', () => { url.searchParams.set('code_challenge_method', 'S256'); url.searchParams.set('resource', `${ISSUER}/mcp`); url.searchParams.set('state', 'st-1'); - const response = await worker.fetch(new Request(url.toString()), oauthEnv(store)); + const response = await workerFetch(new Request(url.toString()), oauthEnv(store)); expect(response.status).toBe(200); expect(response.headers.get('content-type')).toContain('text/html'); @@ -685,7 +698,7 @@ describe('auth endpoint routing (s5)', () => { }); async function fetchJsonOn(store: OAuthStoreApi, path: string) { - const response = await worker.fetch(new Request(`${ISSUER}${path}`), oauthEnv(store)); + const response = await workerFetch(new Request(`${ISSUER}${path}`), oauthEnv(store)); return { response, json: (await response.json()) as Record }; } @@ -748,20 +761,20 @@ describe('auth endpoint routing (s5)', () => { authorizeUrl.searchParams.set('code_challenge_method', 'S256'); authorizeUrl.searchParams.set('resource', `${ISSUER}/mcp`); authorizeUrl.searchParams.set('state', 'st-1'); - const consent = await worker.fetch(new Request(authorizeUrl.toString()), env); + const consent = await workerFetch(new Request(authorizeUrl.toString()), env); expect(consent.status).toBe(200); const record = [...store.codes.values()][0]!; // 2. Polling before the user approves: still pending. const pollUrl = `${ISSUER}/authorize/status?code=${record.code}`; - expect(await (await worker.fetch(new Request(pollUrl), env)).json()).toEqual({ + expect(await (await workerFetch(new Request(pollUrl), env)).json()).toEqual({ status: 'pending', }); // 3. User approves the Kilo sign-in -> the worker holds the pairing and // sends the page to the org picker. pairingApproved = true; - expect(await (await worker.fetch(new Request(pollUrl), env)).json()).toEqual({ + expect(await (await workerFetch(new Request(pollUrl), env)).json()).toEqual({ status: 'needs_org', picker_url: `/authorize/org?code=${record.code}`, }); @@ -770,7 +783,7 @@ describe('auth endpoint routing (s5)', () => { expect(pollCalls).toBe(2); // 4. GET the picker: personal + the user's organization. - const picker = await worker.fetch( + const picker = await workerFetch( new Request(`${ISSUER}/authorize/org?code=${record.code}`), env ); @@ -779,7 +792,7 @@ describe('auth endpoint routing (s5)', () => { expect(pickerHtml).toContain('Personal account'); // 5. POST the selection -> authorize completes with a client redirect. - const done = await worker.fetch( + const done = await workerFetch( new Request(`${ISSUER}/authorize/org?code=${record.code}`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, @@ -794,7 +807,7 @@ describe('auth endpoint routing (s5)', () => { expect(location.searchParams.get('state')).toBe('st-1'); // 6. Token exchange (PKCE verifier = the RFC 7636 Appendix B vector). - const tokenResponse = await worker.fetch( + const tokenResponse = await workerFetch( new Request(`${ISSUER}/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, @@ -817,7 +830,7 @@ describe('auth endpoint routing (s5)', () => { // 7. tools/call with the MCP token: apps/web sees the Kilo bearer and the // org from the token claims — the picked identity, end to end. calls.length = 0; - const call = await worker.fetch( + const call = await workerFetch( new Request(`${ISSUER}/mcp`, { method: 'POST', headers: { @@ -840,7 +853,7 @@ describe('auth endpoint routing (s5)', () => { seenOrganization: 'org-e2e', }); // A spoofed caller org header changes nothing: the claim wins. - const spoofed = await worker.fetch( + const spoofed = await workerFetch( new Request(`${ISSUER}/mcp`, { method: 'POST', headers: { @@ -863,7 +876,7 @@ describe('auth endpoint routing (s5)', () => { }); // 8. search runs under the same enforced token. - const search = await worker.fetch( + const search = await workerFetch( new Request(`${ISSUER}/mcp`, { method: 'POST', headers: { @@ -916,7 +929,7 @@ describe('auth endpoint routing (s5)', () => { const expired = await signJwt({ ...claims, exp: Math.floor(Date.now() / 1000) - 60 }, SECRET); const rpc = { jsonrpc: '2.0', id: 1, method: 'tools/list' }; - const ok = await worker.fetch( + const ok = await workerFetch( new Request(`${ISSUER}/mcp`, { method: 'POST', headers: { Authorization: `Bearer ${live}`, 'content-type': 'application/json' }, @@ -926,7 +939,7 @@ describe('auth endpoint routing (s5)', () => { ); expect(ok.status).toBe(200); - const rejected = await worker.fetch( + const rejected = await workerFetch( new Request(`${ISSUER}/mcp`, { method: 'POST', headers: { Authorization: `Bearer ${expired}`, 'content-type': 'application/json' }, @@ -938,7 +951,7 @@ describe('auth endpoint routing (s5)', () => { // s6 enforcement: a foreign bearer is rejected outright — /mcp accepts // ONLY MCP tokens signed by this worker. - const foreign = await worker.fetch( + const foreign = await workerFetch( new Request(`${ISSUER}/mcp`, { method: 'POST', headers: { Authorization: 'Bearer tok_123', 'content-type': 'application/json' }, @@ -968,7 +981,7 @@ describe('auth endpoint routing (s5)', () => { }, SECRET ); - const response = await worker.fetch( + const response = await workerFetch( new Request(`${ISSUER}/mcp`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'content-type': 'application/json' }, @@ -980,7 +993,7 @@ describe('auth endpoint routing (s5)', () => { }); it('/mcp refuses every bearer on a worker without the OAuth bindings (s6: no unverified passthrough)', async () => { - const response = await worker.fetch( + const response = await workerFetch( new Request(`${ISSUER}/mcp`, { method: 'POST', headers: { Authorization: 'Bearer tok_123', 'content-type': 'application/json' }, @@ -991,3 +1004,457 @@ describe('auth endpoint routing (s5)', () => { expect(response.status).toBe(503); }); }); + +/** + * s2 analytics wiring: the /mcp transport emits PostHog events through the + * injected emitter. Captures are recorded through a fake fetch; the test awaits + * the `waitUntil` promises before asserting. + */ +describe('analytics wiring (s2)', () => { + const ANALYTICS_HEADERS = { + Authorization: 'Bearer tok_123', + 'Content-Type': 'application/json', + }; + + type FakeCtx = { waitUntil(promise: Promise): void }; + + function createHarness(options?: { + captureFetchImpl?: typeof fetch; + upstreamFetchImpl?: typeof fetch; + }) { + const captured: Array> = []; + const captureFetch = vi.fn((_url: string | URL, init?: RequestInit) => { + const rawBody = init?.body; + captured.push( + JSON.parse(typeof rawBody === 'string' ? rawBody : '{}') as Record + ); + return Promise.resolve(new Response('{}', { status: 200 })); + }); + const promises: Promise[] = []; + const ctx: FakeCtx = { + waitUntil: promise => { + promises.push(promise); + }, + }; + const analytics = createMcpAnalytics({ + env: { NEXT_PUBLIC_POSTHOG_KEY: 'phc_test' }, + ctx, + fetchImpl: options?.captureFetchImpl ?? (captureFetch as unknown as typeof fetch), + log: console.log, + }); + const handler = createMcpHandler({ + catalog: testCatalog, + webBaseUrl: 'https://app.kilo.ai', + analytics, + ...(options?.upstreamFetchImpl ? { fetchImpl: options.upstreamFetchImpl } : {}), + }); + return { captured, promises, handler }; + } + + async function post( + handler: ReturnType, + body: unknown, + headers: Record = ANALYTICS_HEADERS + ): Promise { + return handler( + new Request('https://kilo-mcp.test/mcp', { + method: 'POST', + headers, + body: JSON.stringify(body), + }) + ); + } + + async function settle(promises: Promise[]): Promise { + await Promise.all(promises); + } + + function eventsNamed( + captured: Array>, + event: string + ): Array> { + return captured.filter(payload => payload['event'] === event); + } + + function propertiesOf(payload: Record): Record { + return payload['properties'] as Record; + } + + it('initialize emits kilo_mcp_session_started with the negotiated version and client name', async () => { + const { captured, promises, handler } = createHarness(); + await post(handler, { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: '2025-03-26', clientInfo: { name: 'claude-desktop' } }, + }); + await settle(promises); + + const events = eventsNamed(captured, 'kilo_mcp_session_started'); + expect(events).toHaveLength(1); + expect(propertiesOf(events[0]!)).toMatchObject({ + feature: 'kilo-mcp', + $lib: 'kilo-mcp-worker', + protocolVersion: '2025-03-26', + clientName: 'claude-desktop', + }); + }); + + it('search emits kilo_mcp_search_performed with hits, emptiness, and the query shape only', async () => { + const { captured, promises, handler } = createHarness(); + await post(handler, { + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'search', arguments: { query: 'organizations list' } }, + }); + await settle(promises); + + const events = eventsNamed(captured, 'kilo_mcp_search_performed'); + expect(events).toHaveLength(1); + const properties = propertiesOf(events[0]!); + expect(properties).toMatchObject({ + feature: 'kilo-mcp', + $lib: 'kilo-mcp-worker', + hitCount: 1, + empty: false, + queryTokenCount: 2, + queryCharBucket: '17-64', + limit: 10, + }); + expect(JSON.stringify(properties)).not.toContain('organizations list'); + expect(eventsNamed(captured, 'kilo_mcp_tool_called')).toHaveLength(1); + }); + + it('an empty search emits empty: true and hitCount 0', async () => { + const { captured, promises, handler } = createHarness(); + await post(handler, { + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'search', arguments: { query: 'zzqqx nothing' } }, + }); + await settle(promises); + + const [event] = eventsNamed(captured, 'kilo_mcp_search_performed'); + const properties = propertiesOf(event!); + expect(properties['empty']).toBe(true); + expect(properties['hitCount']).toBe(0); + }); + + it('a successful call emits exactly one kilo_mcp_tool_called with the resolved path and latency', async () => { + const upstream = vi.fn(() => + Promise.resolve(Response.json({ result: { data: { balance: 42 } } })) + ); + const { captured, promises, handler } = createHarness({ + upstreamFetchImpl: upstream as unknown as typeof fetch, + }); + const response = await post(handler, { + jsonrpc: '2.0', + id: 4, + method: 'tools/call', + params: { name: 'call', arguments: { path: 'organizations.list' } }, + }); + expect(response.status).toBe(200); + await settle(promises); + + const events = eventsNamed(captured, 'kilo_mcp_tool_called'); + expect(events).toHaveLength(1); + const properties = propertiesOf(events[0]!); + expect(properties).toMatchObject({ + feature: 'kilo-mcp', + $lib: 'kilo-mcp-worker', + tool: 'call', + path: 'organizations.list', + success: true, + }); + expect(typeof properties['latencyMs']).toBe('number'); + expect(properties['latencyMs'] as number).toBeGreaterThanOrEqual(0); + expect(eventsNamed(captured, 'kilo_mcp_call_rejected')).toHaveLength(0); + }); + + it('an unknown path emits unknown_path on tool_called and call_rejected, and never forwards', async () => { + const upstream = vi.fn(); + const { captured, promises, handler } = createHarness({ + upstreamFetchImpl: upstream as unknown as typeof fetch, + }); + await post(handler, { + jsonrpc: '2.0', + id: 5, + method: 'tools/call', + params: { name: 'call', arguments: { path: 'nope.goes.here' } }, + }); + await settle(promises); + + const toolEvents = eventsNamed(captured, 'kilo_mcp_tool_called'); + expect(toolEvents).toHaveLength(1); + expect(propertiesOf(toolEvents[0]!)).toMatchObject({ + tool: 'call', + success: false, + errorClass: 'unknown_path', + }); + // The attempted path is not a catalog key, so it is never recorded. + expect(propertiesOf(toolEvents[0]!)).not.toHaveProperty('path'); + + const rejected = eventsNamed(captured, 'kilo_mcp_call_rejected'); + expect(rejected).toHaveLength(1); + expect(propertiesOf(rejected[0]!)).toMatchObject({ reason: 'unknown_path' }); + expect(upstream).not.toHaveBeenCalled(); + }); + + it('schema-invalid input emits schema_invalid with the catalog path on tool_called and call_rejected', async () => { + const upstream = vi.fn(); + const { captured, promises, handler } = createHarness({ + upstreamFetchImpl: upstream as unknown as typeof fetch, + }); + await post(handler, { + jsonrpc: '2.0', + id: 6, + method: 'tools/call', + params: { name: 'call', arguments: { path: 'cliSessions.search', input: { query: 12 } } }, + }); + await settle(promises); + + const toolEvents = eventsNamed(captured, 'kilo_mcp_tool_called'); + expect(toolEvents).toHaveLength(1); + expect(propertiesOf(toolEvents[0]!)).toMatchObject({ + tool: 'call', + path: 'cliSessions.search', + success: false, + errorClass: 'schema_invalid', + }); + + const rejected = eventsNamed(captured, 'kilo_mcp_call_rejected'); + expect(rejected).toHaveLength(1); + expect(propertiesOf(rejected[0]!)).toMatchObject({ + reason: 'schema_invalid', + path: 'cliSessions.search', + }); + expect(upstream).not.toHaveBeenCalled(); + }); + + it('an unknown tool emits unknown_tool and never records the caller-supplied name', async () => { + // `tools/call` accepts any string as `name`; a caller must not be able to + // put arbitrary text into PostHog through it. + const callerName = 'delete-me@example.com '; + const { captured, promises, handler } = createHarness(); + await post(handler, { + jsonrpc: '2.0', + id: 7, + method: 'tools/call', + params: { name: callerName, arguments: {} }, + }); + await settle(promises); + + const toolEvents = eventsNamed(captured, 'kilo_mcp_tool_called'); + expect(toolEvents).toHaveLength(1); + expect(propertiesOf(toolEvents[0]!)).toMatchObject({ + tool: 'unknown', + success: false, + errorClass: 'unknown_tool', + }); + expect(JSON.stringify(toolEvents[0])).not.toContain(callerName); + expect(propertiesOf(toolEvents[0]!)).not.toHaveProperty('path'); + expect(eventsNamed(captured, 'kilo_mcp_call_rejected')).toHaveLength(0); + }); + + it('a missing bearer emits an anonymous auth_failure rejection', async () => { + const { captured, promises, handler } = createHarness(); + const response = await handler( + new Request('https://kilo-mcp.test/mcp', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 8, method: 'initialize' }), + }) + ); + expect(response.status).toBe(401); + await settle(promises); + + const rejected = eventsNamed(captured, 'kilo_mcp_call_rejected'); + expect(rejected).toHaveLength(1); + expect(rejected[0]!['distinct_id']).toBe(ANONYMOUS_DISTINCT_ID); + const properties = propertiesOf(rejected[0]!); + expect(properties).toMatchObject({ reason: 'auth_failure', $process_person_profile: false }); + expect(properties).not.toHaveProperty('userId'); + expect(properties).not.toHaveProperty('organizationId'); + }); + + it('an upstream network failure emits upstream_unreachable and is not a rejected call', async () => { + const upstream = vi.fn(() => Promise.reject(new Error('network down'))); + const { captured, promises, handler } = createHarness({ + upstreamFetchImpl: upstream as unknown as typeof fetch, + }); + await post(handler, { + jsonrpc: '2.0', + id: 9, + method: 'tools/call', + params: { name: 'call', arguments: { path: 'organizations.list' } }, + }); + await settle(promises); + + const toolEvents = eventsNamed(captured, 'kilo_mcp_tool_called'); + expect(toolEvents).toHaveLength(1); + expect(propertiesOf(toolEvents[0]!)).toMatchObject({ + tool: 'call', + path: 'organizations.list', + success: false, + errorClass: 'upstream_unreachable', + }); + expect(eventsNamed(captured, 'kilo_mcp_call_rejected')).toHaveLength(0); + }); + + it('every captured payload carries no bearer or PostHog key inside its properties', async () => { + const upstream = vi.fn(() => + Promise.resolve(Response.json({ result: { data: { ok: true } } })) + ); + const { captured, promises, handler } = createHarness({ + upstreamFetchImpl: upstream as unknown as typeof fetch, + }); + for (const body of [ + { jsonrpc: '2.0', id: 10, method: 'initialize', params: { protocolVersion: '2025-06-18' } }, + { + jsonrpc: '2.0', + id: 11, + method: 'tools/call', + params: { name: 'search', arguments: { query: 'organizations list' } }, + }, + { + jsonrpc: '2.0', + id: 12, + method: 'tools/call', + params: { name: 'call', arguments: { path: 'organizations.list' } }, + }, + { + jsonrpc: '2.0', + id: 13, + method: 'tools/call', + params: { name: 'call', arguments: { path: 'nope.goes.here' } }, + }, + { + jsonrpc: '2.0', + id: 14, + method: 'tools/call', + params: { name: 'call', arguments: { path: 'cliSessions.search', input: { query: 12 } } }, + }, + ]) { + await post(handler, body); + } + await settle(promises); + + expect(captured.length).toBeGreaterThan(0); + for (const payload of captured) { + expect(payload['api_key']).toBe('phc_test'); + expect(JSON.stringify(payload)).not.toContain('tok_123'); + expect(JSON.stringify(propertiesOf(payload))).not.toContain('phc_'); + for (const value of Object.values(propertiesOf(payload))) { + if (typeof value === 'string') { + expect(value).not.toContain('organizations list'); + } + } + } + }); + + it('a rejecting capture transport leaves the JSON-RPC response unchanged and raises no unhandled rejection', async () => { + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown): void => { + unhandled.push(reason); + }; + process.on('unhandledRejection', onUnhandled); + try { + const rejecting = vi.fn(() => Promise.reject(new Error('capture down'))); + const { promises, handler } = createHarness({ + captureFetchImpl: rejecting as unknown as typeof fetch, + }); + const response = await post(handler, { + jsonrpc: '2.0', + id: 15, + method: 'tools/call', + params: { name: 'search', arguments: { query: 'organizations list' } }, + }); + expect(response.status).toBe(200); + const json = (await response.json()) as { result: { content: Array<{ text: string }> } }; + expect(json.result.content[0]!.text).toContain('organizations.list'); + + await settle(promises); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(unhandled).toEqual([]); + } finally { + process.off('unhandledRejection', onUnhandled); + } + }); + + it('a verified MCP token binds the event to the user and the organization', async () => { + const issuer = 'https://kilo-mcp.test'; + const secret = 'analytics-test-secret'; + const store = { + isJtiRevoked: async () => false, + getKiloToken: async () => 'kilo-forward-me', + } as unknown as OAuthStoreApi; + const token = await signJwt( + { + iss: issuer, + sub: 'user-1', + org: 'org-1', + aud: `${issuer}/mcp`, + client_id: 'c-1', + jti: 'j-analytics', + exp: Math.floor(Date.now() / 1000) + 60, + }, + secret + ); + + const captured: Array> = []; + const promises: Promise[] = []; + const ctx: FakeCtx = { + waitUntil: promise => { + promises.push(promise); + }, + }; + const analytics = createMcpAnalytics({ + env: { NEXT_PUBLIC_POSTHOG_KEY: 'phc_test' }, + ctx, + fetchImpl: ((_url: string | URL, init?: RequestInit) => { + const rawBody = init?.body; + captured.push( + JSON.parse(typeof rawBody === 'string' ? rawBody : '{}') as Record + ); + return Promise.resolve(new Response('{}', { status: 200 })); + }) as unknown as typeof fetch, + log: console.log, + }); + const upstream = vi.fn(() => + Promise.resolve(Response.json({ result: { data: { balance: 42 } } })) + ); + const handler = createMcpHandler({ + catalog: testCatalog, + webBaseUrl: 'https://app.kilo.ai', + fetchImpl: upstream as unknown as typeof fetch, + mcpAuth: { tokenSecret: secret, store }, + analytics, + }); + + const response = await handler( + new Request(`${issuer}/mcp`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 16, + method: 'tools/call', + params: { name: 'call', arguments: { path: 'organizations.list' } }, + }), + }) + ); + expect(response.status).toBe(200); + await settle(promises); + + const toolEvents = eventsNamed(captured, 'kilo_mcp_tool_called'); + expect(toolEvents).toHaveLength(1); + expect(toolEvents[0]!['distinct_id']).toBe('user-1'); + expect(propertiesOf(toolEvents[0]!)).toMatchObject({ + userId: 'user-1', + organizationId: 'org-1', + }); + expect(propertiesOf(toolEvents[0]!)).not.toHaveProperty('$process_person_profile'); + }); +}); diff --git a/services/kilo-mcp/src/index.ts b/services/kilo-mcp/src/index.ts index e3f99127ea..9f745842f4 100644 --- a/services/kilo-mcp/src/index.ts +++ b/services/kilo-mcp/src/index.ts @@ -1,4 +1,12 @@ import catalogJson from '../catalog.json'; +import { + classifyToolError, + createMcpAnalytics, + queryShape, + type AnalyticsIdentity, + type CallRejectedReason, + type McpAnalytics, +} from './analytics'; import { authenticate } from './auth'; import { handleAuthorize } from './auth/authorize'; import { handleRegistration } from './auth/dcr'; @@ -42,6 +50,14 @@ const UNAUTHORIZED = -32001; const PROTOCOL_VERSION = '2025-06-18'; const SERVER_INFO = { name: 'kilo-mcp', version: '1.0.0' } as const; +/** + * The published tool names. `tools/call` accepts any string as `name`, so the + * value is caller-controlled; analytics records a name only when it is one of + * these and reports everything else as `unknown`, so arbitrary caller text can + * never reach PostHog. + */ +const PUBLISHED_TOOL_NAMES = new Set(['search', 'call']); + const CORS_HEADERS: Record = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'POST, OPTIONS', @@ -145,8 +161,38 @@ type McpHandlerDeps = { * foreign bearers are rejected. Omitted in tests without the OAuth flow. */ mcpAuth?: { tokenSecret: string; store: OAuthStoreApi }; + /** + * PostHog emitter for this request (s2). Omitted in tests that do not care + * about analytics; the handler then uses a no-op emitter so behaviour is + * unchanged. The emitter is best-effort and can never throw. + */ + analytics?: McpAnalytics; +}; + +/** + * Used when a handler is built without an analytics emitter (existing tests, + * and any caller that does not opt in). Every method is a no-op. + */ +const noopAnalytics: McpAnalytics = { + sessionStarted: () => {}, + toolCalled: () => {}, + searchPerformed: () => {}, + callRejected: () => {}, + oauthSignIn: () => {}, }; +/** + * The identity an event is bound to: present only when the bearer was verified + * as this worker's MCP access token (s6). A caller-supplied header never + * contributes — identity comes from the verified claims, never from the + * request. + */ +function identity(auth: ForwardedAuth): AnalyticsIdentity | null { + return auth.mcpIdentity + ? { kiloUserId: auth.mcpIdentity.kiloUserId, organizationId: auth.mcpIdentity.organizationId } + : null; +} + /** An MCP tools/call success payload. */ type ToolResult = { content: Array<{ type: 'text'; text: string }>; @@ -161,7 +207,9 @@ async function runTool( name: string, args: Record, auth: ForwardedAuth, - deps: McpHandlerDeps + deps: McpHandlerDeps, + analytics: McpAnalytics, + callerIdentity: AnalyticsIdentity | null ): Promise { if (name === 'search') { const query = args['query']; @@ -177,6 +225,14 @@ async function runTool( limit, semanticCandidates: deps.semanticCandidates ?? noSemanticCandidates, }); + // The query's shape only — never the raw text (it can carry personal data). + analytics.searchPerformed({ + identity: callerIdentity, + hitCount: results.length, + empty: results.length === 0, + ...queryShape(query), + limit: Math.max(1, Math.floor(limit ?? DEFAULT_SEARCH_LIMIT)), + }); if (results.length === 0) { // Empty state, not an error: tell the agent how to recover. return textResult( @@ -226,6 +282,8 @@ async function handleRpcMessage( ): Promise { const id = typeof message.id === 'string' || typeof message.id === 'number' ? message.id : null; const method = typeof message.method === 'string' ? message.method : ''; + const analytics = deps.analytics ?? noopAnalytics; + const callerIdentity = identity(auth); // Notifications carry no id and get no JSON-RPC response body. if (id === null && method.startsWith('notifications/')) { @@ -235,10 +293,20 @@ async function handleRpcMessage( try { switch (method) { case 'initialize': { - const params = (message.params ?? {}) as { protocolVersion?: unknown }; + const params = (message.params ?? {}) as { + protocolVersion?: unknown; + clientInfo?: unknown; + }; + const protocolVersion = + typeof params.protocolVersion === 'string' ? params.protocolVersion : PROTOCOL_VERSION; + const clientInfo = (params.clientInfo ?? {}) as { name?: unknown }; + analytics.sessionStarted({ + identity: callerIdentity, + protocolVersion, + ...(typeof clientInfo.name === 'string' ? { clientName: clientInfo.name } : {}), + }); return jsonRpcResult(id ?? 0, { - protocolVersion: - typeof params.protocolVersion === 'string' ? params.protocolVersion : PROTOCOL_VERSION, + protocolVersion, capabilities: { tools: {} }, serverInfo: SERVER_INFO, instructions: @@ -254,9 +322,57 @@ async function handleRpcMessage( if (typeof params.name !== 'string') { throw new JsonRpcFailure(INVALID_PARAMS, 'tools/call requires a string "name".'); } + const toolName = params.name; const args = (params.arguments ?? {}) as Record; - const result = await runTool(params.name, args, auth, deps); - return jsonRpcResult(id ?? 0, result); + // Analytics records only a published tool name; anything else is + // caller-supplied text and must never reach PostHog verbatim. + const analyticsTool = PUBLISHED_TOOL_NAMES.has(toolName) ? toolName : 'unknown'; + // The path is only recorded when it is a real catalog key, so a caller + // cannot put arbitrary text into the event through `path`. + const path = + typeof args['path'] === 'string' && + Object.prototype.hasOwnProperty.call(deps.catalog, args['path']) + ? args['path'] + : undefined; + const startedAt = performance.now(); + try { + const result = await runTool(toolName, args, auth, deps, analytics, callerIdentity); + analytics.toolCalled({ + identity: callerIdentity, + tool: analyticsTool, + ...(path !== undefined ? { path } : {}), + success: true, + errorClass: 'none', + latencyMs: performance.now() - startedAt, + }); + return jsonRpcResult(id ?? 0, result); + } catch (error) { + const errorClass = classifyToolError(error); + analytics.toolCalled({ + identity: callerIdentity, + tool: analyticsTool, + ...(path !== undefined ? { path } : {}), + success: false, + errorClass, + latencyMs: performance.now() - startedAt, + }); + // A `call` rejected locally — before any upstream request — is a + // distinct event: auth failure is handled at the transport above. + const rejectedReason: CallRejectedReason | null = + errorClass === 'unknown_path' || + errorClass === 'schema_invalid' || + errorClass === 'invalid_params' + ? errorClass + : null; + if (toolName === 'call' && rejectedReason) { + analytics.callRejected({ + identity: callerIdentity, + reason: rejectedReason, + ...(path !== undefined ? { path } : {}), + }); + } + throw error; + } } default: return jsonRpcError(id, METHOD_NOT_FOUND, `Unknown method "${method}".`); @@ -284,6 +400,8 @@ export function createMcpHandler(deps: McpHandlerDeps) { return withCorsHeaders(new Response('Method not allowed', { status: 405 })); } + const analytics = deps.analytics ?? noopAnalytics; + // The issuer is always the URL this worker is reached at (dev vs prod // advertise themselves); the access token's `aud` is the `/mcp` resource. const issuer = new URL(request.url).origin; @@ -313,6 +431,9 @@ export function createMcpHandler(deps: McpHandlerDeps) { : undefined ); if (!auth) { + // Anonymous by construction: no user has been verified, so the event + // must not be bound to a person ($process_person_profile: false). + analytics.callRejected({ identity: null, reason: 'auth_failure' }); // HTTP 401 alongside a JSON-RPC error body; rejected before any // catalog lookup or upstream request. The challenge names this // server's protected-resource metadata (RFC 9728) so the MCP client @@ -365,9 +486,12 @@ export function createMcpHandler(deps: McpHandlerDeps) { } export default { - async fetch(request: Request, env: Env): Promise { + async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { const url = new URL(request.url); const issuer = url.origin; + // Best-effort PostHog emitter for this request; every emit is scheduled + // through `ctx.waitUntil` so it never blocks or fails the response. + const analytics = createMcpAnalytics({ env, ctx }); switch (url.pathname) { case AUTH_PATHS.mcp: { @@ -396,6 +520,7 @@ export default { catalog, webBaseUrl: env.WEB_BASE_URL, semanticCandidates: createSemanticCandidates(env), + analytics, }); return transportOnly(request); } @@ -404,6 +529,7 @@ export default { webBaseUrl: env.WEB_BASE_URL, semanticCandidates: createSemanticCandidates(env), mcpAuth: { tokenSecret: env.MCP_TOKEN_SECRET, store: getKiloMcpOAuthStoreStub(env) }, + analytics, }); return handler(request); } @@ -415,27 +541,39 @@ export default { return handleProtectedResourceMetadata(request, { issuer }); case AUTH_PATHS.register: return handleRegistration(request, { store: getKiloMcpOAuthStoreStub(env) }); - case AUTH_PATHS.authorize: - return handleAuthorize(request, { + case AUTH_PATHS.authorize: { + // Built as a variable (not an inline literal) so the extra `analytics` + // property is allowed until s3 adds the optional field to the handler + // deps type. s3 emits OAuth sign-in events from this emitter. + const authorizeDeps = { store: getKiloMcpOAuthStoreStub(env), webBaseUrl: env.WEB_BASE_URL, - }); - case AUTH_PATHS.pairingStatus: - return handlePairingStatus(request, { + analytics, + }; + return handleAuthorize(request, authorizeDeps); + } + case AUTH_PATHS.pairingStatus: { + const pairingStatusDeps = { store: getKiloMcpOAuthStoreStub(env), webBaseUrl: env.WEB_BASE_URL, - }); + analytics, + }; + return handlePairingStatus(request, pairingStatusDeps); + } case AUTH_PATHS.orgPicker: return handleOrgPicker(request, { store: getKiloMcpOAuthStoreStub(env), webBaseUrl: env.WEB_BASE_URL, }); - case AUTH_PATHS.token: - return handleToken(request, { + case AUTH_PATHS.token: { + const tokenDeps = { store: getKiloMcpOAuthStoreStub(env), tokenSecret: requireMcpTokenSecret(env), issuer, - }); + analytics, + }; + return handleToken(request, tokenDeps); + } default: return withCorsHeaders(new Response('Not found', { status: 404 })); } diff --git a/services/kilo-mcp/src/oauth-pages/authorize-page.test.ts b/services/kilo-mcp/src/oauth-pages/authorize-page.test.ts index 6dbce4c2b7..10fdf275cf 100644 --- a/services/kilo-mcp/src/oauth-pages/authorize-page.test.ts +++ b/services/kilo-mcp/src/oauth-pages/authorize-page.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import { consentPage, handlePairingStatus, pollKiloPairing } from './authorize-page'; +import type { McpAnalytics, OAuthSignInInput } from '../analytics'; import type { NewOAuthCode, OAuthCodeRecord, @@ -134,6 +135,40 @@ function upstreamFetch(handler: (url: string) => Response | Promise): return vi.fn(async (input: string | URL) => handler(String(input))) as unknown as typeof fetch; } +/** + * Fake emitter cast to the production `McpAnalytics` interface: the tests + * assert exactly what the worker hands it, so a new field cannot slip past. + */ +function fakeAnalytics(): { analytics: McpAnalytics; calls: OAuthSignInInput[] } { + const calls: OAuthSignInInput[] = []; + const analytics = { + oauthSignIn: vi.fn((input: OAuthSignInInput) => { + calls.push(input); + }), + } as unknown as McpAnalytics; + return { analytics, calls }; +} + +const OAUTH_EVENT_FIELDS = ['clientId', 'identity', 'phase', 'reason']; +const IDENTITY_FIELDS = ['kiloUserId', 'organizationId']; + +/** + * The sign-in events must carry no token, authorization code, PKCE verifier, + * or state: every recorded event exposes only the documented fields. + */ +function expectNoCredentialLeak(calls: OAuthSignInInput[]): void { + for (const call of calls) { + for (const key of Object.keys(call)) { + expect(OAUTH_EVENT_FIELDS).toContain(key); + } + if (call.identity) { + for (const key of Object.keys(call.identity)) { + expect(IDENTITY_FIELDS).toContain(key); + } + } + } +} + describe('pollKiloPairing (apps/web relay)', () => { const deps = { webBaseUrl: WEB }; @@ -369,3 +404,99 @@ describe('consentPage (s6 contract)', () => { expect(html).toContain('--primary:#f7f586'); }); }); + +describe('GET /authorize/status analytics (s3)', () => { + it('records a denial as failed/denied with the client id when upstream reports it', async () => { + const store = createFakeOAuthStore(); + const record = seedPendingCode(store); + const fetchImpl = upstreamFetch(() => Response.json({ status: 'denied' }, { status: 403 })); + const { analytics, calls } = fakeAnalytics(); + await handlePairingStatus(statusRequest(record.code), { + store, + webBaseUrl: WEB, + fetchImpl, + analytics, + }); + expect(calls).toEqual([ + { phase: 'failed', identity: null, clientId: 'client-abc', reason: 'denied' }, + ]); + expectNoCredentialLeak(calls); + }); + + it('emits a denial exactly once across repeated polls of the terminal record', async () => { + const store = createFakeOAuthStore(); + const record = seedPendingCode(store); + const fetchImpl = upstreamFetch(() => Response.json({ status: 'denied' }, { status: 403 })); + const { analytics, calls } = fakeAnalytics(); + const deps = { store, webBaseUrl: WEB, fetchImpl, analytics }; + await expect( + handlePairingStatus(statusRequest(record.code), deps).then(r => r.json()) + ).resolves.toEqual({ status: 'denied' }); + // The record is now terminal `denied`; every later poll must answer the + // same way WITHOUT re-emitting (the failure was already recorded at the + // transition) and without asking apps/web again. + for (let poll = 0; poll < 3; poll += 1) { + await expect( + handlePairingStatus(statusRequest(record.code), deps).then(r => r.json()) + ).resolves.toEqual({ status: 'denied' }); + } + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(calls).toEqual([ + { phase: 'failed', identity: null, clientId: 'client-abc', reason: 'denied' }, + ]); + expectNoCredentialLeak(calls); + }); + + it('an already-denied record answers denied without emitting or polling upstream', async () => { + const store = createFakeOAuthStore(); + const record = seedPendingCode(store); + store.codes.set(record.code, { ...store.codes.get(record.code)!, status: 'denied' }); + const { analytics, calls } = fakeAnalytics(); + const fetchImpl = upstreamFetch(() => Promise.reject(new Error('must not poll'))); + const response = await handlePairingStatus(statusRequest(record.code), { + store, + webBaseUrl: WEB, + fetchImpl, + analytics, + }); + await expect(response.json()).resolves.toEqual({ status: 'denied' }); + expect(fetchImpl).not.toHaveBeenCalled(); + expect(calls).toEqual([]); + }); + + it('records an expired pairing as failed/expired', async () => { + const store = createFakeOAuthStore(); + const record = seedPendingCode(store); + const fetchImpl = upstreamFetch(() => Response.json({ status: 'expired' }, { status: 410 })); + const { analytics, calls } = fakeAnalytics(); + await handlePairingStatus(statusRequest(record.code), { + store, + webBaseUrl: WEB, + fetchImpl, + analytics, + }); + expect(calls).toEqual([ + { phase: 'failed', identity: null, clientId: 'client-abc', reason: 'expired' }, + ]); + expectNoCredentialLeak(calls); + }); + + it('records nothing while the pairing is pending or unreachable', async () => { + const store = createFakeOAuthStore(); + const record = seedPendingCode(store); + const { analytics, calls } = fakeAnalytics(); + await handlePairingStatus(statusRequest(record.code), { + store, + webBaseUrl: WEB, + fetchImpl: upstreamFetch(() => Response.json({ status: 'pending' }, { status: 202 })), + analytics, + }); + await handlePairingStatus(statusRequest(record.code), { + store, + webBaseUrl: WEB, + fetchImpl: upstreamFetch(() => Promise.reject(new Error('network'))), + analytics, + }); + expect(calls).toEqual([]); + }); +}); diff --git a/services/kilo-mcp/src/oauth-pages/authorize-page.ts b/services/kilo-mcp/src/oauth-pages/authorize-page.ts index 77196dfd2c..ed22fb8508 100644 --- a/services/kilo-mcp/src/oauth-pages/authorize-page.ts +++ b/services/kilo-mcp/src/oauth-pages/authorize-page.ts @@ -27,6 +27,7 @@ import { htmlResponse, PAIRING_POLL_INTERVAL_MS, } from '../auth/http'; +import type { McpAnalytics } from '../analytics'; import type { OAuthStoreApi } from '../store/oauth-store'; export type PairingStatusDeps = { @@ -35,6 +36,8 @@ export type PairingStatusDeps = { webBaseUrl: string; fetchImpl?: typeof fetch; now?: () => Date; + /** Best-effort sign-in analytics; never awaited and never allowed to throw. */ + analytics?: McpAnalytics; }; /** @@ -173,6 +176,9 @@ export async function handlePairingStatus( return authJsonResponse({ status: 'unknown' } satisfies PairingStatus); } if (record.status === 'denied') { + // Terminal state, and the transition already recorded the failure once + // (`denyCode` is only ever called just before that emit). A later poll of + // the same record must answer denied without re-emitting. return authJsonResponse({ status: 'denied' } satisfies PairingStatus); } if (record.status === 'approved') { @@ -191,9 +197,21 @@ export async function handlePairingStatus( return authJsonResponse({ status: 'pending' } satisfies PairingStatus); case 'denied': { await deps.store.denyCode(record.deviceAuthCode, nowIso); + deps.analytics?.oauthSignIn({ + phase: 'failed', + identity: null, + clientId: record.clientId, + reason: 'denied', + }); return authJsonResponse({ status: 'denied' } satisfies PairingStatus); } case 'expired': + deps.analytics?.oauthSignIn({ + phase: 'failed', + identity: null, + clientId: record.clientId, + reason: 'expired', + }); return authJsonResponse({ status: 'expired' } satisfies PairingStatus); case 'approved': { // Persist BEFORE any further poll: the upstream answer is single-use. diff --git a/services/kilo-mcp/vitest.config.ts b/services/kilo-mcp/vitest.config.ts index 5d431d278b..35bb16dfcf 100644 --- a/services/kilo-mcp/vitest.config.ts +++ b/services/kilo-mcp/vitest.config.ts @@ -22,6 +22,11 @@ export default defineConfig({ globals: true, environment: 'node', include: ['src/**/*.test.ts'], + // Verbose lists every passing test by name and prints the analytics + // module's decisive `[kilo-mcp] analytics ...` log lines. The default + // reporter hides both, so the local-proof lines the worker emits could + // not be captured as evidence (gr1 backend gate). + reporters: ['verbose'], coverage: { provider: 'v8', reporter: ['text', 'json', 'html'], diff --git a/services/kilo-mcp/worker-configuration.d.ts b/services/kilo-mcp/worker-configuration.d.ts index 793c6397b1..562b57345f 100644 --- a/services/kilo-mcp/worker-configuration.d.ts +++ b/services/kilo-mcp/worker-configuration.d.ts @@ -1,12 +1,13 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: 92b3a7808ca3d786fe11eb9e1c9e4893) -// Runtime types generated with workerd@1.20260828.1 2025-09-01 nodejs_compat +// Generated by Wrangler by running `wrangler types` (hash: 5d03304de955ddd38944115f9c664ed2) +// Runtime types generated with workerd@1.20260714.1 2025-09-01 nodejs_compat interface __BaseEnv_Env { VECTORIZE: VectorizeIndex; AI: Ai; WEB_BASE_URL: "http://localhost:3000" | "https://app.kilo.ai"; + MCP_TOKEN_SECRET: string; KILO_MCP_OAUTH_STORE: DurableObjectNamespace; - MCP_TOKEN_SECRET?: string; + NEXT_PUBLIC_POSTHOG_KEY?: "phc_GK2Pxl0HPj5ZPfwhLRjXrtdz8eD7e9MKnXiFrOqnB6z"; } declare namespace Cloudflare { interface GlobalProps { @@ -17,6 +18,7 @@ declare namespace Cloudflare { VECTORIZE: VectorizeIndex; AI: Ai; WEB_BASE_URL: "http://localhost:3000"; + MCP_TOKEN_SECRET: string; KILO_MCP_OAUTH_STORE: DurableObjectNamespace; } interface Env extends __BaseEnv_Env {} @@ -26,7 +28,7 @@ type StringifyValues> = { [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; }; declare namespace NodeJS { - interface ProcessEnv extends StringifyValues> {} + interface ProcessEnv extends StringifyValues> {} } // Begin runtime types @@ -48,26 +50,26 @@ and limitations under the License. // noinspection JSUnusedGlobalSymbols declare var onmessage: never; /** - * The **`DOMException`** interface represents an abnormal event (called an exception) that occurs as a result of calling a method or accessing a property of a web API. This is how error conditions are described in web APIs. + * The **`DOMException`** interface represents an abnormal event (called an **exception**) that occurs as a result of calling a method or accessing a property of a web API. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException) */ declare class DOMException extends Error { constructor(message?: string, name?: string); /** - * The **`message`** read-only property of the DOMException interface returns a string representing a message or description associated with the given error name. + * The **`message`** read-only property of the a message or description associated with the given error name. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException/message) */ readonly message: string; /** - * The **`name`** read-only property of the DOMException interface returns a string that contains one of the strings associated with an error name. + * The **`name`** read-only property of the one of the strings associated with an error name. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException/name) */ readonly name: string; /** - * The **`code`** read-only property of the DOMException interface returns one of the legacy error code constants, or 0 if none match. + * The **`code`** read-only property of the DOMException interface returns one of the legacy error code constants, or `0` if none match. * @deprecated * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException/code) @@ -125,61 +127,61 @@ interface Console { */ clear(): void; /** - * The **`console.count()`** static method logs the number of times that this particular call to count() has been called. + * The **`console.count()`** static method logs the number of times that this particular call to `count()` has been called. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/count_static) */ count(label?: string): void; /** - * The **`console.countReset()`** static method resets counter used with console.count(). + * The **`console.countReset()`** static method resets counter used with console/count_static. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/countReset_static) */ countReset(label?: string): void; /** - * The **`console.debug()`** static method outputs a message to the console at the "debug" log level. The message is only displayed to the user if the console is configured to display debug output. In most cases, the log level is configured within the console UI. This log level might correspond to the Debug or Verbose log level. + * The **`console.debug()`** static method outputs a message to the console at the 'debug' log level. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/debug_static) */ debug(...data: any[]): void; /** - * The **`console.dir()`** static method displays a list of the properties of the specified JavaScript object. In browser consoles, the output is presented as a hierarchical listing with disclosure triangles that let you see the contents of child objects. + * The **`console.dir()`** static method displays a list of the properties of the specified JavaScript object. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/dir_static) */ dir(item?: any, options?: any): void; /** - * The **`console.dirxml()`** static method displays an interactive tree of the descendant elements of the specified XML/HTML element. If it is not possible to display as an element the JavaScript Object view is shown instead. The output is presented as a hierarchical listing of expandable nodes that let you see the contents of child nodes. + * The **`console.dirxml()`** static method displays an interactive tree of the descendant elements of the specified XML/HTML element. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/dirxml_static) */ dirxml(...data: any[]): void; /** - * The **`console.error()`** static method outputs a message to the console at the "error" log level. The message is only displayed to the user if the console is configured to display error output. In most cases, the log level is configured within the console UI. The message may be formatted as an error, with red colors and call stack information. + * The **`console.error()`** static method outputs a message to the console at the 'error' log level. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/error_static) */ error(...data: any[]): void; /** - * The **`console.group()`** static method creates a new inline group in the Web console log, causing any subsequent console messages to be indented by an additional level, until console.groupEnd() is called. + * The **`console.group()`** static method creates a new inline group in the Web console log, causing any subsequent console messages to be indented by an additional level, until console/groupEnd_static is called. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/group_static) */ group(...data: any[]): void; /** - * The **`console.groupCollapsed()`** static method creates a new inline group in the console. Unlike console.group(), however, the new group is created collapsed. The user will need to use the disclosure button next to it to expand it, revealing the entries created in the group. + * The **`console.groupCollapsed()`** static method creates a new inline group in the console. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/groupCollapsed_static) */ groupCollapsed(...data: any[]): void; /** - * The **`console.groupEnd()`** static method exits the current inline group in the console. See Using groups in the console in the console documentation for details and examples. + * The **`console.groupEnd()`** static method exits the current inline group in the console. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/groupEnd_static) */ groupEnd(): void; /** - * The **`console.info()`** static method outputs a message to the console at the "info" log level. The message is only displayed to the user if the console is configured to display info output. In most cases, the log level is configured within the console UI. The message may receive special formatting, such as a small "i" icon next to it. + * The **`console.info()`** static method outputs a message to the console at the 'info' log level. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/info_static) */ @@ -197,24 +199,23 @@ interface Console { */ table(tabularData?: any, properties?: string[]): void; /** - * The **`console.time()`** static method starts a timer you can use to track how long an operation takes. You give each timer a unique name, and may have up to 10,000 timers running on a given page. When you call console.timeEnd() with the same name, the browser will output the time, in milliseconds, that elapsed since the timer was started. + * The **`console.time()`** static method starts a timer you can use to track how long an operation takes. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/time_static) */ time(label?: string): void; /** - * The **`console.timeEnd()`** static method stops a timer that was previously started by calling console.time(). + * The **`console.timeEnd()`** static method stops a timer that was previously started by calling console/time_static. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/timeEnd_static) */ timeEnd(label?: string): void; /** - * The **`console.timeLog()`** static method logs the current value of a timer that was previously started by calling console.time(). + * The **`console.timeLog()`** static method logs the current value of a timer that was previously started by calling console/time_static. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/timeLog_static) */ timeLog(label?: string, ...data: any[]): void; - /* The **`console.timeStamp()`** static method adds a single marker to the browser's Performance tool (Firefox bug 1387528, Chrome). This lets you correlate a point in your code with the other events recorded in the timeline, such as layout and paint events. */ timeStamp(label?: string): void; /** * The **`console.trace()`** static method outputs a stack trace to the console. @@ -223,7 +224,7 @@ interface Console { */ trace(...data: any[]): void; /** - * The **`console.warn()`** static method outputs a warning message to the console at the "warning" log level. The message is only displayed to the user if the console is configured to display warning output. In most cases, the log level is configured within the console UI. The message may receive special formatting, such as yellow colors and a warning icon. + * The **`console.warn()`** static method outputs a warning message to the console at the 'warning' log level. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/warn_static) */ @@ -389,7 +390,7 @@ interface ServiceWorkerGlobalScope extends WorkerGlobalScope { declare function addEventListener(type: Type, handler: EventListenerOrEventListenerObject, options?: EventTargetAddEventListenerOptions | boolean): void; declare function removeEventListener(type: Type, handler: EventListenerOrEventListenerObject, options?: EventTargetEventListenerOptions | boolean): void; /** - * The **`dispatchEvent()`** method of the EventTarget sends an Event to the object, (synchronously) invoking the affected event listeners in the appropriate order. The normal event processing rules (including the capturing and optional bubbling phase) also apply to events dispatched manually with dispatchEvent(). + * The **`dispatchEvent()`** method of the EventTarget sends an Event to the object, (synchronously) invoking the affected event listeners in the appropriate order. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget/dispatchEvent) */ @@ -434,7 +435,6 @@ declare const crypto: Crypto; * [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/cache/) */ declare const caches: CacheStorage; -/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/scheduler) */ declare const scheduler: Scheduler; /** * The Workers runtime supports a subset of the Performance API, used to measure timing and performance, @@ -455,7 +455,6 @@ interface ExecutionContext { cache?: CacheContext; readonly access?: CloudflareAccessContext; tracing: Tracing; - abort(reason?: any): void; } type ExportedHandlerFetchHandler = (request: Request>, env: Env, ctx: ExecutionContext) => Response | Promise; type ExportedHandlerConnectHandler = (socket: Socket, env: Env, ctx: ExecutionContext) => void | Promise; @@ -572,7 +571,7 @@ interface DurableObjectState { setHibernatableWebSocketEventTimeout(timeoutMs?: number): void; getHibernatableWebSocketEventTimeout(): number | null; getTags(ws: WebSocket): string[]; - abort(reason?: string, options?: DurableObjectAbortOptions): void; + abort(reason?: string): void; } interface DurableObjectTransaction { get(key: string, options?: DurableObjectGetOptions): Promise; @@ -608,9 +607,6 @@ interface DurableObjectStorage { getBookmarkForTime(timestamp: number | Date): Promise; onNextSessionRestoreBookmark(bookmark: string): Promise; } -interface DurableObjectAbortOptions { - retryAlarm?: boolean; -} interface DurableObjectListOptions { start?: string; startAfter?: string; @@ -661,26 +657,26 @@ interface AnalyticsEngineDataPoint { blobs?: ((ArrayBuffer | string) | null)[]; } /** - * The **`Event`** interface represents an event which takes place on an EventTarget. + * The **`Event`** interface represents an event which takes place on an `EventTarget`. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event) */ declare class Event { constructor(type: string, init?: EventInit); /** - * The **`type`** read-only property of the Event interface returns a string containing the event's type. It is set when the event is constructed and is the name commonly used to refer to the specific event, such as click, load, or error. + * The **`type`** read-only property of the Event interface returns a string containing the event's type. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/type) */ get type(): string; /** - * The **`eventPhase`** read-only property of the Event interface indicates which phase of the event flow is currently being evaluated. + * The **`eventPhase`** read-only property of the being evaluated. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/eventPhase) */ get eventPhase(): number; /** - * The read-only **`composed`** property of the Event interface returns a boolean value which indicates whether or not the event will propagate across the shadow DOM boundary into the standard DOM. + * The read-only **`composed`** property of the or not the event will propagate across the shadow DOM boundary into the standard DOM. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/composed) */ @@ -717,13 +713,13 @@ declare class Event { */ get currentTarget(): EventTarget | undefined; /** - * The read-only **`target`** property of the Event interface is a reference to the object onto which the event was dispatched. It is different from Event.currentTarget when the event handler is called during the bubbling or capturing phase of the event. + * The read-only **`target`** property of the dispatched. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/target) */ get target(): EventTarget | undefined; /** - * The deprecated **`Event.srcElement`** is an alias for the Event.target property. Use Event.target instead. + * The deprecated **`Event.srcElement`** is an alias for the Event.target property. * @deprecated * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/srcElement) @@ -736,45 +732,45 @@ declare class Event { */ get timeStamp(): number; /** - * The **`isTrusted`** read-only property of the Event interface is a boolean value that is true when the event was generated by the user agent (including via user actions and programmatic methods such as HTMLElement.focus()), and false when the event was dispatched via EventTarget.dispatchEvent(). The only exception is the click event, which initializes the isTrusted property to false in user agents. + * The **`isTrusted`** read-only property of the when the event was generated by the user agent (including via user actions and programmatic methods such as HTMLElement.focus()), and `false` when the event was dispatched via The only exception is the `click` event, which initializes the `isTrusted` property to `false` in user agents. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/isTrusted) */ get isTrusted(): boolean; /** - * The **`cancelBubble`** property of the Event interface is deprecated. Use Event.stopPropagation() instead. Setting its value to true before returning from an event handler prevents propagation of the event. In later implementations, setting this to false does nothing. See Browser compatibility for details. + * The **`cancelBubble`** property of the Event interface is deprecated. * @deprecated * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/cancelBubble) */ get cancelBubble(): boolean; /** - * The **`cancelBubble`** property of the Event interface is deprecated. Use Event.stopPropagation() instead. Setting its value to true before returning from an event handler prevents propagation of the event. In later implementations, setting this to false does nothing. See Browser compatibility for details. + * The **`cancelBubble`** property of the Event interface is deprecated. * @deprecated * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/cancelBubble) */ set cancelBubble(value: boolean); /** - * The **`stopImmediatePropagation()`** method of the Event interface prevents other listeners of the same event from being called. + * The **`stopImmediatePropagation()`** method of the If several listeners are attached to the same element for the same event type, they are called in the order in which they were added. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/stopImmediatePropagation) */ stopImmediatePropagation(): void; /** - * The **`preventDefault()`** method of the Event interface tells the user agent that the event is being explicitly handled, so its default action, such as page scrolling, link navigation, or pasting text, should not be taken. + * The **`preventDefault()`** method of the Event interface tells the user agent that if the event does not get explicitly handled, its default action should not be taken as it normally would be. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/preventDefault) */ preventDefault(): void; /** - * The **`stopPropagation()`** method of the Event interface prevents further propagation of the current event in the capturing and bubbling phases. It does not, however, prevent any default behaviors from occurring; for instance, clicks on links are still processed. If you want to stop those behaviors, see the preventDefault() method. It also does not prevent propagation to other event-handlers of the current element. If you want to stop those, see stopImmediatePropagation(). + * The **`stopPropagation()`** method of the Event interface prevents further propagation of the current event in the capturing and bubbling phases. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/stopPropagation) */ stopPropagation(): void; /** - * The **`composedPath()`** method of the Event interface returns the event's path which is an array of the objects on which listeners will be invoked. This does not include nodes in shadow trees if the shadow root was created with its ShadowRoot.mode closed. + * The **`composedPath()`** method of the Event interface returns the event's path which is an array of the objects on which listeners will be invoked. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event/composedPath) */ @@ -795,7 +791,7 @@ interface EventListenerObject { } type EventListenerOrEventListenerObject = EventListener | EventListenerObject; /** - * The **`EventTarget`** interface is implemented by objects that can receive events and may have listeners for them. In other words, any target of events implements the three methods associated with this interface. + * The **`EventTarget`** interface is implemented by objects that can receive events and may have listeners for them. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget) */ @@ -808,13 +804,13 @@ declare class EventTarget = Record(type: Type, handler: EventListenerOrEventListenerObject, options?: EventTargetAddEventListenerOptions | boolean): void; /** - * The **`removeEventListener()`** method of the EventTarget interface removes an event listener previously registered with EventTarget.addEventListener() from the target. The event listener to be removed is identified using a combination of the event type, the event listener function itself, and various optional options that may affect the matching process; see Matching event listeners for removal. + * The **`removeEventListener()`** method of the EventTarget interface removes an event listener previously registered with EventTarget.addEventListener() from the target. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget/removeEventListener) */ removeEventListener(type: Type, handler: EventListenerOrEventListenerObject, options?: EventTargetEventListenerOptions | boolean): void; /** - * The **`dispatchEvent()`** method of the EventTarget sends an Event to the object, (synchronously) invoking the affected event listeners in the appropriate order. The normal event processing rules (including the capturing and optional bubbling phase) also apply to events dispatched manually with dispatchEvent(). + * The **`dispatchEvent()`** method of the EventTarget sends an Event to the object, (synchronously) invoking the affected event listeners in the appropriate order. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget/dispatchEvent) */ @@ -846,7 +842,7 @@ declare class AbortController { */ get signal(): AbortSignal; /** - * The **`abort()`** method of the AbortController interface aborts an asynchronous operation before it has completed. This is able to abort fetch requests, the consumption of any response bodies, or streams. + * The **`abort()`** method of the AbortController interface aborts an asynchronous operation before it has completed. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortController/abort) */ @@ -859,7 +855,7 @@ declare class AbortController { */ declare abstract class AbortSignal extends EventTarget { /** - * The **`AbortSignal.abort()`** static method returns an AbortSignal that is already set as aborted (and which does not trigger an abort event). + * The **`AbortSignal.abort()`** static method returns an AbortSignal that is already set as aborted (and which does not trigger an AbortSignal/abort_event event). * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/abort_static) */ @@ -871,13 +867,13 @@ declare abstract class AbortSignal extends EventTarget { */ static timeout(delay: number): AbortSignal; /** - * The **`AbortSignal.any()`** static method takes an iterable of abort signals and returns an AbortSignal. The returned abort signal is aborted when any of the input iterable abort signals are aborted. The abort reason will be set to the reason of the first signal that is aborted. If any of the given abort signals are already aborted then so will be the returned AbortSignal. + * The **`AbortSignal.any()`** static method takes an iterable of abort signals and returns an AbortSignal. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/any_static) */ static any(signals: AbortSignal[]): AbortSignal; /** - * The **`aborted`** read-only property returns a value that indicates whether the asynchronous operations the signal is communicating with are aborted (true) or not (false). + * The **`aborted`** read-only property returns a value that indicates whether the asynchronous operations the signal is communicating with are aborted (`true`) or not (`false`). * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/aborted) */ @@ -893,17 +889,12 @@ declare abstract class AbortSignal extends EventTarget { /* [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/abort_event) */ set onabort(value: any | null); /** - * The **`throwIfAborted()`** method throws the signal's abort reason if the signal has been aborted; otherwise it does nothing. + * The **`throwIfAborted()`** method throws the signal's abort AbortSignal.reason if the signal has been aborted; otherwise it does nothing. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal/throwIfAborted) */ throwIfAborted(): void; } -/** - * The **`Scheduler`** interface of the Prioritized Task Scheduling API provides methods for scheduling prioritized tasks. - * - * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Scheduler) - */ interface Scheduler { wait(delay: number, maybeOptions?: SchedulerWaitOptions): Promise; } @@ -911,20 +902,20 @@ interface SchedulerWaitOptions { signal?: AbortSignal; } /** - * The **`ExtendableEvent`** interface extends the lifetime of the install and activate events dispatched on the global scope as part of the service worker lifecycle. This ensures that any functional events (like FetchEvent) are not dispatched until it upgrades database schemas and deletes the outdated cache entries. + * The **`ExtendableEvent`** interface extends the lifetime of the `install` and `activate` events dispatched on the global scope as part of the service worker lifecycle. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ExtendableEvent) */ declare abstract class ExtendableEvent extends Event { /** - * The **`ExtendableEvent.waitUntil()`** method tells the event dispatcher that work is ongoing. It can also be used to detect whether that work was successful. In service workers, waitUntil() tells the browser that work is ongoing until the promise settles, and it shouldn't terminate the service worker if it wants that work to complete. + * The **`ExtendableEvent.waitUntil()`** method tells the event dispatcher that work is ongoing. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ExtendableEvent/waitUntil) */ waitUntil(promise: Promise): void; } /** - * The **`CustomEvent`** interface can be used to attach custom data to an event generated by an application. + * The **`CustomEvent`** interface represents events initialized by an application for any purpose. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CustomEvent) */ @@ -963,7 +954,7 @@ declare class Blob { */ get type(): string; /** - * The **`slice()`** method of the Blob interface creates and returns a new Blob object which contains data from a subset of the blob on which it's called. + * The **`slice()`** method of the Blob interface creates and returns a new `Blob` object which contains data from a subset of the blob on which it's called. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/slice) */ @@ -981,13 +972,13 @@ declare class Blob { */ bytes(): Promise; /** - * The **`text()`** method of the Blob interface returns a Promise that resolves with a string containing the contents of the blob, interpreted as UTF-8. + * The **`text()`** method of the string containing the contents of the blob, interpreted as UTF-8. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/text) */ text(): Promise; /** - * The **`stream()`** method of the Blob interface returns a ReadableStream which upon reading returns the data contained within the Blob. + * The **`stream()`** method of the Blob interface returns a ReadableStream which upon reading returns the data contained within the `Blob`. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/stream) */ @@ -1004,13 +995,13 @@ interface BlobOptions { declare class File extends Blob { constructor(bits: ((ArrayBuffer | ArrayBufferView) | string | Blob)[] | undefined, name: string, options?: FileOptions); /** - * The **`name`** read-only property of the File interface returns the name of the file represented by a File object. For security reasons, the path is excluded from this property. + * The **`name`** read-only property of the File interface returns the name of the file represented by a File object. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/File/name) */ get name(): string; /** - * The **`lastModified`** read-only property of the File interface provides the last modified date of the file as the number of milliseconds since the Unix epoch (January 1, 1970 at midnight). Files without a known last modified date return the current date. + * The **`lastModified`** read-only property of the File interface provides the last modified date of the file as the number of milliseconds since the Unix epoch (January 1, 1970 at midnight). * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/File/lastModified) */ @@ -1027,7 +1018,7 @@ interface FileOptions { */ declare abstract class CacheStorage { /** - * The **`open()`** method of the CacheStorage interface returns a Promise that resolves to the Cache object matching the cacheName. + * The **`open()`** method of the the Cache object matching the `cacheName`. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CacheStorage/open) */ @@ -1060,14 +1051,14 @@ interface CacheQueryOptions { */ declare abstract class Crypto { /** - * The **`Crypto.subtle`** read-only property returns a SubtleCrypto which can then be used to perform low-level cryptographic operations. + * The **`Crypto.subtle`** read-only property returns a cryptographic operations. * Available only in secure contexts. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Crypto/subtle) */ get subtle(): SubtleCrypto; /** - * The **`Crypto.getRandomValues()`** method lets you get cryptographically strong random values. The array given as the parameter is filled with random numbers (random in its cryptographic meaning). + * The **`Crypto.getRandomValues()`** method lets you get cryptographically strong random values. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Crypto/getRandomValues) */ @@ -1095,7 +1086,7 @@ declare abstract class SubtleCrypto { */ encrypt(algorithm: string | SubtleCryptoEncryptAlgorithm, key: CryptoKey, plainText: ArrayBuffer | ArrayBufferView): Promise; /** - * The **`decrypt()`** method of the SubtleCrypto interface decrypts some encrypted data. It takes as arguments a key to decrypt with, some optional extra parameters, and the data to decrypt (also known as "ciphertext"). It returns a Promise which will be fulfilled with the decrypted data (also known as "plaintext"). + * The **`decrypt()`** method of the SubtleCrypto interface decrypts some encrypted data. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/decrypt) */ @@ -1113,7 +1104,7 @@ declare abstract class SubtleCrypto { */ verify(algorithm: string | SubtleCryptoSignAlgorithm, key: CryptoKey, signature: ArrayBuffer | ArrayBufferView, data: ArrayBuffer | ArrayBufferView): Promise; /** - * The **`digest()`** method of the SubtleCrypto interface generates a digest of the given data, using the specified hash function. A digest is a short fixed-length value derived from some variable-length input. Cryptographic digests should exhibit collision-resistance, meaning that it's hard to come up with two different inputs that have the same digest value. + * The **`digest()`** method of the SubtleCrypto interface generates a _digest_ of the given data, using the specified hash function. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/digest) */ @@ -1131,7 +1122,7 @@ declare abstract class SubtleCrypto { */ deriveKey(algorithm: string | SubtleCryptoDeriveKeyAlgorithm, baseKey: CryptoKey, derivedKeyAlgorithm: string | SubtleCryptoImportKeyAlgorithm, extractable: boolean, keyUsages: string[]): Promise; /** - * The **`deriveBits()`** method of the SubtleCrypto interface can be used to derive an array of bits from a base key. + * The **`deriveBits()`** method of the key. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/deriveBits) */ @@ -1149,13 +1140,13 @@ declare abstract class SubtleCrypto { */ exportKey(format: string, key: CryptoKey): Promise; /** - * The **`wrapKey()`** method of the SubtleCrypto interface "wraps" a key. This means that it exports the key in an external, portable format, then encrypts the exported key. Wrapping a key helps protect it in untrusted environments, such as inside an otherwise unprotected data store or in transmission over an unprotected network. + * The **`wrapKey()`** method of the SubtleCrypto interface 'wraps' a key. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/wrapKey) */ wrapKey(format: string, key: CryptoKey, wrappingKey: CryptoKey, wrapAlgorithm: string | SubtleCryptoEncryptAlgorithm): Promise; /** - * The **`unwrapKey()`** method of the SubtleCrypto interface "unwraps" a key. This means that it takes as its input a key that has been exported and then encrypted (also called "wrapped"). It decrypts the key and then imports it, returning a CryptoKey object that can be used in the Web Crypto API. + * The **`unwrapKey()`** method of the SubtleCrypto interface 'unwraps' a key. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/SubtleCrypto/unwrapKey) */ @@ -1163,20 +1154,20 @@ declare abstract class SubtleCrypto { timingSafeEqual(a: ArrayBuffer | ArrayBufferView, b: ArrayBuffer | ArrayBufferView): boolean; } /** - * The **`CryptoKey`** interface of the Web Crypto API represents a cryptographic key obtained from one of the SubtleCrypto methods generateKey(), deriveKey(), importKey(), or unwrapKey(). + * The **`CryptoKey`** interface of the Web Crypto API represents a cryptographic key obtained from one of the SubtleCrypto methods SubtleCrypto.generateKey, SubtleCrypto.deriveKey, SubtleCrypto.importKey, or SubtleCrypto.unwrapKey. * Available only in secure contexts. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CryptoKey) */ declare abstract class CryptoKey { /** - * The read-only **`type`** property of the CryptoKey interface indicates which kind of key is represented by the object. It can have the following values: + * The read-only **`type`** property of the CryptoKey interface indicates which kind of key is represented by the object. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CryptoKey/type) */ readonly type: string; /** - * The read-only **`extractable`** property of the CryptoKey interface indicates whether or not the key may be extracted using SubtleCrypto.exportKey() or SubtleCrypto.wrapKey(). + * The read-only **`extractable`** property of the CryptoKey interface indicates whether or not the key may be extracted using `SubtleCrypto.exportKey()` or `SubtleCrypto.wrapKey()`. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CryptoKey/extractable) */ @@ -1293,15 +1284,12 @@ interface CryptoKeyArbitraryKeyAlgorithm { length?: number; } declare class DigestStream extends WritableStream { - constructor(algorithm: string | SubtleCryptoHashAlgorithm, options?: DigestStreamOptions); + constructor(algorithm: string | SubtleCryptoHashAlgorithm); readonly digest: Promise; get bytesWritten(): number | bigint; } -interface DigestStreamOptions { - toWellFormed?: boolean; -} /** - * The **`TextDecoder`** interface represents a decoder for a specific text encoding, such as UTF-8, ISO-8859-2, or GBK. A decoder takes an array of bytes as input and returns a JavaScript string. + * The **`TextDecoder`** interface represents a decoder for a specific text encoding, such as `UTF-8`, `ISO-8859-2`, `KOI8-R`, `GBK`, etc. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextDecoder) */ @@ -1318,20 +1306,20 @@ declare class TextDecoder { get ignoreBOM(): boolean; } /** - * The **`TextEncoder`** interface enables you to encode a JavaScript string using UTF-8. + * The **`TextEncoder`** interface takes a stream of code points as input and emits a stream of UTF-8 bytes. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextEncoder) */ declare class TextEncoder { constructor(); /** - * The **`TextEncoder.encode()`** method takes a string as input, and returns a Uint8Array containing the string encoded using UTF-8. + * The **`TextEncoder.encode()`** method takes a string as input, and returns a Global_Objects/Uint8Array containing the text given in parameters encoded with the specific method for that TextEncoder object. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextEncoder/encode) */ encode(input?: string): Uint8Array; /** - * The **`TextEncoder.encodeInto()`** method takes a string to encode and a destination Uint8Array to put resulting UTF-8 encoded text into, and returns an object indicating the progress of the encoding. This is potentially more performant than the encode() method — especially when the target buffer is a view into a Wasm heap. + * The **`TextEncoder.encodeInto()`** method takes a string to encode and a destination Uint8Array to put resulting UTF-8 encoded text into, and returns a dictionary object indicating the progress of the encoding. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextEncoder/encodeInto) */ @@ -1388,9 +1376,6 @@ declare class ErrorEvent extends Event { get error(): any; } interface ErrorEventErrorEventInit { - bubbles?: boolean; - cancelable?: boolean; - composed?: boolean; message?: string; filename?: string; lineno?: number; @@ -1403,139 +1388,135 @@ interface ErrorEventErrorEventInit { * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent) */ declare class MessageEvent extends Event { - constructor(type: string, initializer?: MessageEventInit); + constructor(type: string, initializer: MessageEventInit); /** - * The **`data`** read-only property of the MessageEvent interface represents the data sent by the message emitter. + * The **`data`** read-only property of the The data sent by the message emitter; this can be any data type, depending on what originated this event. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent/data) */ readonly data: any; /** - * The **`origin`** read-only property of the MessageEvent interface is a string representing the origin of the message emitter. + * The **`origin`** read-only property of the origin of the message emitter. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent/origin) */ readonly origin: string | null; /** - * The **`lastEventId`** read-only property of the MessageEvent interface is a string representing a unique ID for the event. + * The **`lastEventId`** read-only property of the unique ID for the event. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent/lastEventId) */ readonly lastEventId: string; /** - * The **`source`** read-only property of the MessageEvent interface is a MessageEventSource (which can be a WindowProxy, MessagePort, or ServiceWorker object) representing the message emitter. + * The **`source`** read-only property of the a WindowProxy, MessagePort, or a `MessageEventSource` (which can be a WindowProxy, message emitter. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent/source) */ readonly source: MessagePort | null; /** - * The **`ports`** read-only property of the MessageEvent interface is an array of MessagePort objects containing all MessagePort objects sent with the message, in order. + * The **`ports`** read-only property of the containing all MessagePort objects sent with the message, in order. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageEvent/ports) */ readonly ports: MessagePort[]; } interface MessageEventInit { - bubbles?: boolean; - cancelable?: boolean; - composed?: boolean; - data?: any; - origin?: string; - lastEventId?: string; - source?: MessagePort; - ports?: MessagePort[]; + data: ArrayBuffer | string; } /** - * The **`PromiseRejectionEvent`** interface represents events which are sent to the global script context when JavaScript Promises are rejected. These events are particularly useful for telemetry and debugging purposes. + * The **`PromiseRejectionEvent`** interface represents events which are sent to the global script context when JavaScript Promises are rejected. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/PromiseRejectionEvent) */ declare abstract class PromiseRejectionEvent extends Event { /** - * The PromiseRejectionEvent interface's **`promise`** read-only property indicates the JavaScript Promise which was rejected. You can examine the event's PromiseRejectionEvent.reason property to learn why the promise was rejected. + * The PromiseRejectionEvent interface's **`promise`** read-only property indicates the JavaScript rejected. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/PromiseRejectionEvent/promise) */ readonly promise: Promise; /** - * The PromiseRejectionEvent **`reason`** read-only property is any JavaScript value or Object which provides the reason passed into Promise.reject(). This in theory provides information about why the promise was rejected. + * The PromiseRejectionEvent **`reason`** read-only property is any JavaScript value or Object which provides the reason passed into Promise.reject(). * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/PromiseRejectionEvent/reason) */ readonly reason: any; } /** - * The **`FormData`** interface provides a way to construct a set of key/value pairs representing form fields and their values, which can be sent using the fetch(), XMLHttpRequest.send() or navigator.sendBeacon() methods. It uses the same format a form would use if the encoding type were set to "multipart/form-data". + * The **`FormData`** interface provides a way to construct a set of key/value pairs representing form fields and their values, which can be sent using the Window/fetch, XMLHttpRequest.send() or navigator.sendBeacon() methods. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData) */ declare class FormData { constructor(); /** - * The **`append()`** method of the FormData interface appends a new value onto an existing key inside a FormData object, or adds the key if it does not already exist. + * The **`append()`** method of the FormData interface appends a new value onto an existing key inside a `FormData` object, or adds the key if it does not already exist. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/append) */ append(name: string, value: string | Blob): void; /** - * The **`append()`** method of the FormData interface appends a new value onto an existing key inside a FormData object, or adds the key if it does not already exist. + * The **`append()`** method of the FormData interface appends a new value onto an existing key inside a `FormData` object, or adds the key if it does not already exist. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/append) */ append(name: string, value: string): void; /** - * The **`append()`** method of the FormData interface appends a new value onto an existing key inside a FormData object, or adds the key if it does not already exist. + * The **`append()`** method of the FormData interface appends a new value onto an existing key inside a `FormData` object, or adds the key if it does not already exist. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/append) */ append(name: string, value: Blob, filename?: string): void; /** - * The **`delete()`** method of the FormData interface deletes a key and its value(s) from a FormData object. + * The **`delete()`** method of the FormData interface deletes a key and its value(s) from a `FormData` object. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/delete) */ delete(name: string): void; /** - * The **`get()`** method of the FormData interface returns the first value associated with a given key from within a FormData object. If you expect multiple values and want all of them, use the getAll() method instead. + * The **`get()`** method of the FormData interface returns the first value associated with a given key from within a `FormData` object. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/get) */ get(name: string): (File | string) | null; /** - * The **`getAll()`** method of the FormData interface returns all the values associated with a given key from within a FormData object. + * The **`getAll()`** method of the FormData interface returns all the values associated with a given key from within a `FormData` object. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/getAll) */ getAll(name: string): (File | string)[]; /** - * The **`has()`** method of the FormData interface returns whether a FormData object contains a certain key. + * The **`has()`** method of the FormData interface returns whether a `FormData` object contains a certain key. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/has) */ has(name: string): boolean; /** - * The **`set()`** method of the FormData interface sets a new value for an existing key inside a FormData object, or adds the key/value if it does not already exist. + * The **`set()`** method of the FormData interface sets a new value for an existing key inside a `FormData` object, or adds the key/value if it does not already exist. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/set) */ set(name: string, value: string | Blob): void; /** - * The **`set()`** method of the FormData interface sets a new value for an existing key inside a FormData object, or adds the key/value if it does not already exist. + * The **`set()`** method of the FormData interface sets a new value for an existing key inside a `FormData` object, or adds the key/value if it does not already exist. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/set) */ set(name: string, value: string): void; /** - * The **`set()`** method of the FormData interface sets a new value for an existing key inside a FormData object, or adds the key/value if it does not already exist. + * The **`set()`** method of the FormData interface sets a new value for an existing key inside a `FormData` object, or adds the key/value if it does not already exist. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData/set) */ set(name: string, value: Blob, filename?: string): void; + /* Returns an array of key, value pairs for every entry in the list. */ entries(): IterableIterator<[ key: string, value: File | string ]>; + /* Returns a list of keys in the list. */ keys(): IterableIterator; + /* Returns a list of values in the list. */ values(): IterableIterator<(File | string)>; forEach(callback: (this: This, value: File | string, key: string, parent: FormData) => void, thisArg?: This): void; [Symbol.iterator](): IterableIterator<[ @@ -1614,19 +1595,19 @@ interface DocumentEnd { append(content: string, options?: ContentOptions): DocumentEnd; } /** - * This is the event type for fetch events dispatched on the service worker global scope. It contains information about the fetch, including the request and how the receiver will treat the response. It provides the event.respondWith() method, which allows us to provide a response to this fetch. + * This is the event type for `fetch` events dispatched on the ServiceWorkerGlobalScope. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FetchEvent) */ declare abstract class FetchEvent extends ExtendableEvent { /** - * The **`request`** read-only property of the FetchEvent interface returns the Request that triggered the event handler. + * The **`request`** read-only property of the the event handler. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FetchEvent/request) */ readonly request: Request; /** - * The **`respondWith()`** method of FetchEvent prevents the browser's default fetch handling, and allows you to provide a promise for a Response yourself. + * The **`respondWith()`** method of allows you to provide a promise for a Response yourself. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FetchEvent/respondWith) */ @@ -1635,55 +1616,58 @@ declare abstract class FetchEvent extends ExtendableEvent { } type HeadersInit = Headers | Iterable> | Record; /** - * The **`Headers`** interface of the Fetch API allows you to perform various actions on HTTP request and response headers. These actions include retrieving, setting, adding to, and removing headers from the list of the request's headers. + * The **`Headers`** interface of the Fetch API allows you to perform various actions on HTTP request and response headers. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers) */ declare class Headers { constructor(init?: HeadersInit); /** - * The **`get()`** method of the Headers interface returns a byte string of all the values of a header within a Headers object with a given name. If the requested header doesn't exist in the Headers object, it returns null. + * The **`get()`** method of the Headers interface returns a byte string of all the values of a header within a `Headers` object with a given name. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/get) */ get(name: string): string | null; getAll(name: string): string[]; /** - * The **`getSetCookie()`** method of the Headers interface returns an array containing the values of all Set-Cookie headers associated with a response. This allows Headers objects to handle having multiple Set-Cookie headers, which wasn't possible prior to its implementation. + * The **`getSetCookie()`** method of the Headers interface returns an array containing the values of all Set-Cookie headers associated with a response. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/getSetCookie) */ getSetCookie(): string[]; /** - * The **`has()`** method of the Headers interface returns a boolean stating whether a Headers object contains a certain header. + * The **`has()`** method of the Headers interface returns a boolean stating whether a `Headers` object contains a certain header. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/has) */ has(name: string): boolean; /** - * The **`set()`** method of the Headers interface sets a new value for an existing header inside a Headers object, or adds the header if it does not already exist. + * The **`set()`** method of the Headers interface sets a new value for an existing header inside a `Headers` object, or adds the header if it does not already exist. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/set) */ set(name: string, value: string): void; /** - * The **`append()`** method of the Headers interface appends a new value onto an existing header inside a Headers object, or adds the header if it does not already exist. + * The **`append()`** method of the Headers interface appends a new value onto an existing header inside a `Headers` object, or adds the header if it does not already exist. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/append) */ append(name: string, value: string): void; /** - * The **`delete()`** method of the Headers interface deletes a header from the current Headers object. + * The **`delete()`** method of the Headers interface deletes a header from the current `Headers` object. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Headers/delete) */ delete(name: string): void; forEach(callback: (this: This, value: string, key: string, parent: Headers) => void, thisArg?: This): void; + /* Returns an iterator allowing to go through all key/value pairs contained in this object. */ entries(): IterableIterator<[ key: string, value: string ]>; + /* Returns an iterator allowing to go through all keys of the key/value pairs contained in this object. */ keys(): IterableIterator; + /* Returns an iterator allowing to go through all values of the key/value pairs contained in this object. */ values(): IterableIterator; [Symbol.iterator](): IterableIterator<[ key: string, @@ -1746,7 +1730,7 @@ interface Response extends Body { */ statusText: string; /** - * The **`headers`** read-only property of the Response interface contains the Headers object associated with the response. + * The **`headers`** read-only property of the with the response. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/headers) */ @@ -1764,7 +1748,7 @@ interface Response extends Body { */ redirected: boolean; /** - * The **`url`** read-only property of the Response interface contains the URL of the response. The value of the url property will be the final URL obtained after any redirects. + * The **`url`** read-only property of the Response interface contains the URL of the response. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/url) */ @@ -1772,7 +1756,7 @@ interface Response extends Body { webSocket: WebSocket | null; cf: any | undefined; /** - * The **`type`** read-only property of the Response interface contains the type of the response. The type determines whether scripts are able to access the response body and headers. + * The **`type`** read-only property of the Response interface contains the type of the response. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Response/type) */ @@ -1803,13 +1787,13 @@ declare var Request: { */ interface Request> extends Body { /** - * The **`clone()`** method of the Request interface creates a copy of the current Request object. + * The **`clone()`** method of the Request interface creates a copy of the current `Request` object. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/clone) */ clone(): Request; /** - * The **`method`** read-only property of the Request interface contains the request's method (GET, POST, etc.) + * The **`method`** read-only property of the `POST`, etc.) A String indicating the method of the request. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/method) */ @@ -1821,7 +1805,7 @@ interface Request> e */ url: string; /** - * The **`headers`** read-only property of the Request interface contains the Headers object associated with the request. + * The **`headers`** read-only property of the with the request. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/headers) */ @@ -1847,13 +1831,13 @@ interface Request> e */ integrity: string; /** - * The **`keepalive`** read-only property of the Request interface contains the request's keepalive setting (true or false), which indicates whether the browser will keep the associated request alive if the page that initiated it is unloaded before the request is complete. + * The **`keepalive`** read-only property of the Request interface contains the request's `keepalive` setting (`true` or `false`), which indicates whether the browser will keep the associated request alive if the page that initiated it is unloaded before the request is complete. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/keepalive) */ keepalive: boolean; /** - * The **`cache`** read-only property of the Request interface contains the cache mode of the request. It controls how the request will interact with the browser's HTTP cache. + * The **`cache`** read-only property of the Request interface contains the cache mode of the request. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/cache) */ @@ -2242,7 +2226,7 @@ type ReadableStreamReadResult = { value?: undefined; }; /** - * The **`ReadableStream`** interface of the Streams API represents a readable stream of byte data. The Fetch API offers a concrete instance of a ReadableStream through the body property of a Response object. + * The `ReadableStream` interface of the Streams API represents a readable stream of byte data. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream) */ @@ -2260,13 +2244,13 @@ interface ReadableStream { */ cancel(reason?: any): Promise; /** - * The **`getReader()`** method of the ReadableStream interface creates a reader and locks the stream to it. While the stream is locked, no other reader can be acquired until this one is released. + * The **`getReader()`** method of the ReadableStream interface creates a reader and locks the stream to it. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/getReader) */ getReader(): ReadableStreamDefaultReader; /** - * The **`getReader()`** method of the ReadableStream interface creates a reader and locks the stream to it. While the stream is locked, no other reader can be acquired until this one is released. + * The **`getReader()`** method of the ReadableStream interface creates a reader and locks the stream to it. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/getReader) */ @@ -2278,13 +2262,13 @@ interface ReadableStream { */ pipeThrough(transform: ReadableWritablePair, options?: StreamPipeOptions): ReadableStream; /** - * The **`pipeTo()`** method of the ReadableStream interface pipes the current ReadableStream to a given WritableStream and returns a Promise that fulfills when the piping process completes successfully, or rejects if any errors were encountered. + * The **`pipeTo()`** method of the ReadableStream interface pipes the current `ReadableStream` to a given WritableStream and returns a Promise that fulfills when the piping process completes successfully, or rejects if any errors were encountered. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/pipeTo) */ pipeTo(destination: WritableStream, options?: StreamPipeOptions): Promise; /** - * The **`tee()`** method of the ReadableStream interface tees the current readable stream, returning a two-element array containing the two resulting branches as new ReadableStream instances. + * The **`tee()`** method of the two-element array containing the two resulting branches as new ReadableStream instances. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream/tee) */ @@ -2296,7 +2280,7 @@ interface ReadableStream { [Symbol.asyncIterator](options?: ReadableStreamValuesOptions): AsyncIterableIterator; } /** - * The **`ReadableStream`** interface of the Streams API represents a readable stream of byte data. The Fetch API offers a concrete instance of a ReadableStream through the body property of a Response object. + * The `ReadableStream` interface of the Streams API represents a readable stream of byte data. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStream) */ @@ -2328,7 +2312,7 @@ declare class ReadableStreamDefaultReader { releaseLock(): void; } /** - * The **`ReadableStreamBYOBReader`** interface of the Streams API defines a reader for a ReadableStream that supports zero-copy reading from an underlying byte source. It is used for efficient copying from underlying sources where the data is delivered as an "anonymous" sequence of bytes, such as files. + * The `ReadableStreamBYOBReader` interface of the Streams API defines a reader for a ReadableStream that supports zero-copy reading from an underlying byte source. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBReader) */ @@ -2337,13 +2321,13 @@ declare class ReadableStreamBYOBReader { get closed(): Promise; cancel(reason?: any): Promise; /** - * The **`read()`** method of the ReadableStreamBYOBReader interface is used to read data into a view on a user-supplied buffer from an associated readable byte stream. A request for data will be satisfied from the stream's internal queues if there is any data present. If the stream queues are empty, the request may be supplied as a zero-copy transfer from the underlying byte source. + * The **`read()`** method of the ReadableStreamBYOBReader interface is used to read data into a view on a user-supplied buffer from an associated readable byte stream. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBReader/read) */ read(view: T): Promise>; /** - * The **`releaseLock()`** method of the ReadableStreamBYOBReader interface releases the reader's lock on the stream. After the lock is released, the reader is no longer active. + * The **`releaseLock()`** method of the ReadableStreamBYOBReader interface releases the reader's lock on the stream. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBReader/releaseLock) */ @@ -2362,7 +2346,7 @@ interface ReadableStreamGetReaderOptions { mode: "byob"; } /** - * The **`ReadableStreamBYOBRequest`** interface of the Streams API represents a "pull request" for data from an underlying source that will made as a zero-copy transfer to a consumer (bypassing the stream's internal queues). + * The **`ReadableStreamBYOBRequest`** interface of the Streams API represents a 'pull request' for data from an underlying source that will made as a zero-copy transfer to a consumer (bypassing the stream's internal queues). * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBRequest) */ @@ -2388,13 +2372,13 @@ declare abstract class ReadableStreamBYOBRequest { get atLeast(): number | null; } /** - * The **`ReadableStreamDefaultController`** interface of the Streams API represents a controller allowing control of a ReadableStream's state and internal queue. Default controllers are for streams that are not byte streams. + * The **`ReadableStreamDefaultController`** interface of the Streams API represents a controller allowing control of a ReadableStream's state and internal queue. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultController) */ declare abstract class ReadableStreamDefaultController { /** - * The **`desiredSize`** read-only property of the ReadableStreamDefaultController interface returns the desired size required to fill the stream's internal queue. + * The **`desiredSize`** read-only property of the required to fill the stream's internal queue. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultController/desiredSize) */ @@ -2406,32 +2390,32 @@ declare abstract class ReadableStreamDefaultController { */ close(): void; /** - * The **`enqueue()`** method of the ReadableStreamDefaultController interface enqueues a given chunk in the associated stream. + * The **`enqueue()`** method of the ```js-nolint enqueue(chunk) ``` - `chunk` - : The chunk to enqueue. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultController/enqueue) */ enqueue(chunk?: R): void; /** - * The **`error()`** method of the ReadableStreamDefaultController interface causes any future interactions with the associated stream to error. + * The **`error()`** method of the with the associated stream to error. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamDefaultController/error) */ error(reason: any): void; } /** - * The **`ReadableByteStreamController`** interface of the Streams API represents a controller for a readable byte stream. It allows control of the state and internal queue of a ReadableStream with an underlying byte source, and enables efficient zero-copy transfer of data from the underlying source to a consumer when the stream's internal queue is empty. + * The **`ReadableByteStreamController`** interface of the Streams API represents a controller for a readable byte stream. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableByteStreamController) */ declare abstract class ReadableByteStreamController { /** - * The **`byobRequest`** read-only property of the ReadableByteStreamController interface returns the current BYOB request, or null if there are no pending requests. + * The **`byobRequest`** read-only property of the ReadableByteStreamController interface returns the current BYOB request, or `null` if there are no pending requests. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableByteStreamController/byobRequest) */ get byobRequest(): ReadableStreamBYOBRequest | null; /** - * The **`desiredSize`** read-only property of the ReadableByteStreamController interface returns the number of bytes required to fill the stream's internal queue to its "desired size". + * The **`desiredSize`** read-only property of the ReadableByteStreamController interface returns the number of bytes required to fill the stream's internal queue to its 'desired size'. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableByteStreamController/desiredSize) */ @@ -2443,7 +2427,7 @@ declare abstract class ReadableByteStreamController { */ close(): void; /** - * The **`enqueue()`** method of the ReadableByteStreamController interface enqueues a given chunk on the associated readable byte stream (the chunk is transferred into the stream's internal queues). + * The **`enqueue()`** method of the ReadableByteStreamController interface enqueues a given chunk on the associated readable byte stream (the chunk is copied into the stream's internal queues). * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableByteStreamController/enqueue) */ @@ -2456,7 +2440,7 @@ declare abstract class ReadableByteStreamController { error(reason: any): void; } /** - * The **`WritableStreamDefaultController`** interface of the Streams API represents a controller allowing control of a WritableStream's state. When constructing a WritableStream, the underlying sink is given a corresponding WritableStreamDefaultController instance to manipulate. + * The **`WritableStreamDefaultController`** interface of the Streams API represents a controller allowing control of a WritableStream's state. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultController) */ @@ -2468,7 +2452,7 @@ declare abstract class WritableStreamDefaultController { */ get signal(): AbortSignal; /** - * The **`error()`** method of the WritableStreamDefaultController interface causes any future interactions with the associated stream to error. + * The **`error()`** method of the with the associated stream to error. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultController/error) */ @@ -2493,7 +2477,7 @@ declare abstract class TransformStreamDefaultController { */ enqueue(chunk?: O): void; /** - * The **`error()`** method of the TransformStreamDefaultController interface errors both sides of the stream. Any further interactions with it will fail with the given error message, and any chunks in the queue will be discarded. + * The **`error()`** method of the TransformStreamDefaultController interface errors both sides of the stream. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStreamDefaultController/error) */ @@ -2515,14 +2499,14 @@ interface ReadableWritablePair { writable: WritableStream; } /** - * The **`WritableStream`** interface of the Streams API provides a standard abstraction for writing streaming data to a destination, known as a sink. This object comes with built-in backpressure and queuing. + * The **`WritableStream`** interface of the Streams API provides a standard abstraction for writing streaming data to a destination, known as a sink. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStream) */ declare class WritableStream { constructor(underlyingSink?: UnderlyingSink, queuingStrategy?: QueuingStrategy); /** - * The **`locked`** read-only property of the WritableStream interface returns a boolean indicating whether the WritableStream is locked to a writer. + * The **`locked`** read-only property of the WritableStream interface returns a boolean indicating whether the `WritableStream` is locked to a writer. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStream/locked) */ @@ -2534,83 +2518,83 @@ declare class WritableStream { */ abort(reason?: any): Promise; /** - * The **`close()`** method of the WritableStream interface closes the associated stream. All chunks written before this method is called are sent before the returned promise is fulfilled. + * The **`close()`** method of the WritableStream interface closes the associated stream. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStream/close) */ close(): Promise; /** - * The **`getWriter()`** method of the WritableStream interface returns a new instance of WritableStreamDefaultWriter and locks the stream to that instance. While the stream is locked, no other writer can be acquired until this one is released. + * The **`getWriter()`** method of the WritableStream interface returns a new instance of WritableStreamDefaultWriter and locks the stream to that instance. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStream/getWriter) */ getWriter(): WritableStreamDefaultWriter; } /** - * The **`WritableStreamDefaultWriter`** interface of the Streams API is the object returned by WritableStream.getWriter() and once created locks the writer to the WritableStream ensuring that no other streams can write to the underlying sink. + * The **`WritableStreamDefaultWriter`** interface of the Streams API is the object returned by WritableStream.getWriter() and once created locks the writer to the `WritableStream` ensuring that no other streams can write to the underlying sink. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter) */ declare class WritableStreamDefaultWriter { constructor(stream: WritableStream); /** - * The **`closed`** read-only property of the WritableStreamDefaultWriter interface returns a Promise that fulfills if the stream becomes closed, or rejects if the stream errors or the writer's lock is released. + * The **`closed`** read-only property of the the stream errors or the writer's lock is released. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/closed) */ get closed(): Promise; /** - * The **`ready`** read-only property of the WritableStreamDefaultWriter interface returns a Promise that resolves when the desired size of the stream's internal queue transitions from non-positive to positive, signaling that it is no longer applying backpressure. + * The **`ready`** read-only property of the that resolves when the desired size of the stream's internal queue transitions from non-positive to positive, signaling that it is no longer applying backpressure. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/ready) */ get ready(): Promise; /** - * The **`desiredSize`** read-only property of the WritableStreamDefaultWriter interface returns the desired size required to fill the stream's internal queue. + * The **`desiredSize`** read-only property of the to fill the stream's internal queue. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/desiredSize) */ get desiredSize(): number | null; /** - * The **`abort()`** method of the WritableStreamDefaultWriter interface aborts the stream, signaling that the producer can no longer successfully write to the stream and it is to be immediately moved to an error state, with any queued writes discarded. + * The **`abort()`** method of the the producer can no longer successfully write to the stream and it is to be immediately moved to an error state, with any queued writes discarded. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/abort) */ abort(reason?: any): Promise; /** - * The **`close()`** method of the WritableStreamDefaultWriter interface closes the associated writable stream. + * The **`close()`** method of the stream. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/close) */ close(): Promise; /** - * The **`write()`** method of the WritableStreamDefaultWriter interface writes a passed chunk of data to a WritableStream and its underlying sink, then returns a Promise that resolves to indicate the success or failure of the write operation. + * The **`write()`** method of the operation. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/write) */ write(chunk?: W): Promise; /** - * The **`releaseLock()`** method of the WritableStreamDefaultWriter interface releases the writer's lock on the corresponding stream. After the lock is released, the writer is no longer active. If the associated stream is errored when the lock is released, the writer will appear errored in the same way from now on; otherwise, the writer will appear closed. + * The **`releaseLock()`** method of the corresponding stream. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WritableStreamDefaultWriter/releaseLock) */ releaseLock(): void; } /** - * The **`TransformStream`** interface of the Streams API represents a concrete implementation of the pipe chain transform stream concept. + * The **`TransformStream`** interface of the Streams API represents a concrete implementation of the pipe chain _transform stream_ concept. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStream) */ declare class TransformStream { constructor(transformer?: Transformer, writableStrategy?: QueuingStrategy, readableStrategy?: QueuingStrategy); /** - * The **`readable`** read-only property of the TransformStream interface returns the ReadableStream instance controlled by this TransformStream. This stream emits the transformed output data. + * The **`readable`** read-only property of the TransformStream interface returns the ReadableStream instance controlled by this `TransformStream`. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStream/readable) */ get readable(): ReadableStream; /** - * The **`writable`** read-only property of the TransformStream interface returns the WritableStream instance controlled by this TransformStream. This stream accepts input data that will be transformed and emitted to the readable stream. + * The **`writable`** read-only property of the TransformStream interface returns the WritableStream instance controlled by this `TransformStream`. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TransformStream/writable) */ @@ -2629,7 +2613,7 @@ interface ReadableStreamValuesOptions { preventCancel?: boolean; } /** - * The **`CompressionStream`** interface of the Compression Streams API compresses a stream of data. It implements the same shape as a TransformStream, allowing it to be used in ReadableStream.pipeThrough() and similar methods. + * The **`CompressionStream`** interface of the Compression Streams API is an API for compressing a stream of data. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CompressionStream) */ @@ -2637,7 +2621,7 @@ declare class CompressionStream extends TransformStream { get encoding(): string; } /** - * The **`TextDecoderStream`** interface of the Encoding API converts a stream of text in a binary encoding, such as UTF-8 etc., to a stream of strings. It is the streaming equivalent of TextDecoder. It implements the same shape as a TransformStream, allowing it to be used in ReadableStream.pipeThrough() and similar methods. + * The **`TextDecoderStream`** interface of the Encoding API converts a stream of text in a binary encoding, such as UTF-8 etc., to a stream of strings. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextDecoderStream) */ @@ -2803,12 +2787,6 @@ interface TraceLog { readonly timestamp: number; readonly level: string; readonly message: any; - readonly errorInfo?: (TraceLogErrorInfo | null)[]; -} -interface TraceLogErrorInfo { - name: string; - message: string; - stack?: string; } interface TraceException { readonly timestamp: number; @@ -2829,7 +2807,7 @@ interface UnsafeTraceMetrics { fromTrace(item: TraceItem): TraceMetrics; } /** - * The **`URL`** interface is used to parse, construct, normalize, and encode URLs. It works by providing properties which allow you to easily read and modify the components of a URL. + * The **`URL`** interface is used to parse, construct, normalize, and encode URL. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL) */ @@ -2854,121 +2832,121 @@ declare class URL { */ set href(value: string); /** - * The **`protocol`** property of the URL interface is a string containing the protocol or scheme of the URL, including the final ":". + * The **`protocol`** property of the URL interface is a string containing the protocol or scheme of the URL, including the final `':'`. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/protocol) */ get protocol(): string; /** - * The **`protocol`** property of the URL interface is a string containing the protocol or scheme of the URL, including the final ":". + * The **`protocol`** property of the URL interface is a string containing the protocol or scheme of the URL, including the final `':'`. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/protocol) */ set protocol(value: string); /** - * The **`username`** property of the URL interface is a string containing the username component of the URL. If the URL does not have a username, this property contains an empty string, "". + * The **`username`** property of the URL interface is a string containing the username component of the URL. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/username) */ get username(): string; /** - * The **`username`** property of the URL interface is a string containing the username component of the URL. If the URL does not have a username, this property contains an empty string, "". + * The **`username`** property of the URL interface is a string containing the username component of the URL. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/username) */ set username(value: string); /** - * The **`password`** property of the URL interface is a string containing the password component of the URL. If the URL does not have a password, this property contains an empty string, "". + * The **`password`** property of the URL interface is a string containing the password component of the URL. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/password) */ get password(): string; /** - * The **`password`** property of the URL interface is a string containing the password component of the URL. If the URL does not have a password, this property contains an empty string, "". + * The **`password`** property of the URL interface is a string containing the password component of the URL. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/password) */ set password(value: string); /** - * The **`host`** property of the URL interface is a string containing the host, which is the hostname, and then, if the port of the URL is nonempty, a ":", followed by the port of the URL. If the URL does not have a hostname, this property contains an empty string, "". + * The **`host`** property of the URL interface is a string containing the host, which is the URL.hostname, and then, if the port of the URL is nonempty, a `':'`, followed by the URL.port of the URL. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/host) */ get host(): string; /** - * The **`host`** property of the URL interface is a string containing the host, which is the hostname, and then, if the port of the URL is nonempty, a ":", followed by the port of the URL. If the URL does not have a hostname, this property contains an empty string, "". + * The **`host`** property of the URL interface is a string containing the host, which is the URL.hostname, and then, if the port of the URL is nonempty, a `':'`, followed by the URL.port of the URL. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/host) */ set host(value: string); /** - * The **`hostname`** property of the URL interface is a string containing either the domain name or IP address of the URL. If the URL does not have a hostname, this property contains an empty string, "". IPv4 and IPv6 addresses are normalized, such as stripping leading zeros, and domain names are converted to IDN. + * The **`hostname`** property of the URL interface is a string containing either the domain name or IP address of the URL. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/hostname) */ get hostname(): string; /** - * The **`hostname`** property of the URL interface is a string containing either the domain name or IP address of the URL. If the URL does not have a hostname, this property contains an empty string, "". IPv4 and IPv6 addresses are normalized, such as stripping leading zeros, and domain names are converted to IDN. + * The **`hostname`** property of the URL interface is a string containing either the domain name or IP address of the URL. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/hostname) */ set hostname(value: string); /** - * The **`port`** property of the URL interface is a string containing the port number of the URL. If the port is the default for the protocol (80 for ws: and http:, 443 for wss: and https:, and 21 for ftp:), this property contains an empty string, "". + * The **`port`** property of the URL interface is a string containing the port number of the URL. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/port) */ get port(): string; /** - * The **`port`** property of the URL interface is a string containing the port number of the URL. If the port is the default for the protocol (80 for ws: and http:, 443 for wss: and https:, and 21 for ftp:), this property contains an empty string, "". + * The **`port`** property of the URL interface is a string containing the port number of the URL. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/port) */ set port(value: string); /** - * The **`pathname`** property of the URL interface represents a location in a hierarchical structure. It is a string constructed from a list of path segments, each of which is prefixed by a / character. + * The **`pathname`** property of the URL interface represents a location in a hierarchical structure. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/pathname) */ get pathname(): string; /** - * The **`pathname`** property of the URL interface represents a location in a hierarchical structure. It is a string constructed from a list of path segments, each of which is prefixed by a / character. + * The **`pathname`** property of the URL interface represents a location in a hierarchical structure. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/pathname) */ set pathname(value: string); /** - * The **`search`** property of the URL interface is a search string, also called a query string, that is a string containing a "?" followed by the parameters of the URL. If the URL does not have a search query, this property contains an empty string, "". + * The **`search`** property of the URL interface is a search string, also called a _query string_, that is a string containing a `'?'` followed by the parameters of the URL. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/search) */ get search(): string; /** - * The **`search`** property of the URL interface is a search string, also called a query string, that is a string containing a "?" followed by the parameters of the URL. If the URL does not have a search query, this property contains an empty string, "". + * The **`search`** property of the URL interface is a search string, also called a _query string_, that is a string containing a `'?'` followed by the parameters of the URL. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/search) */ set search(value: string); /** - * The **`hash`** property of the URL interface is a string containing a "#" followed by the fragment identifier of the URL. If the URL does not have a fragment identifier, this property contains an empty string, "". + * The **`hash`** property of the URL interface is a string containing a `'#'` followed by the fragment identifier of the URL. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/hash) */ get hash(): string; /** - * The **`hash`** property of the URL interface is a string containing a "#" followed by the fragment identifier of the URL. If the URL does not have a fragment identifier, this property contains an empty string, "". + * The **`hash`** property of the URL interface is a string containing a `'#'` followed by the fragment identifier of the URL. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/hash) */ set hash(value: string); /** - * The **`searchParams`** read-only property of the URL interface returns a URLSearchParams object allowing access to the GET decoded query arguments contained in the URL. + * The **`searchParams`** read-only property of the access to the [MISSING: httpmethod('GET')] decoded query arguments contained in the URL. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/searchParams) */ get searchParams(): URLSearchParams; /** - * The **`toJSON()`** method of the URL interface returns a string containing a serialized version of the URL, although in practice it seems to have the same effect as URL.toString(). + * The **`toJSON()`** method of the URL interface returns a string containing a serialized version of the URL, although in practice it seems to have the same effect as ```js-nolint toJSON() ``` None. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/toJSON) */ @@ -2988,13 +2966,13 @@ declare class URL { */ static parse(url: string, base?: string): URL | null; /** - * The **`createObjectURL()`** static method of the URL interface creates a string containing a blob URL pointing to the object given in the parameter. + * The **`createObjectURL()`** static method of the URL interface creates a string containing a URL representing the object given in the parameter. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/createObjectURL_static) */ static createObjectURL(object: File | Blob): string; /** - * The **`revokeObjectURL()`** static method of the URL interface releases an existing object URL which was previously created by calling URL.createObjectURL(). + * The **`revokeObjectURL()`** static method of the URL interface releases an existing object URL which was previously created by calling Call this method when you've finished using an object URL to let the browser know not to keep the reference to the file any longer. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URL/revokeObjectURL_static) */ @@ -3044,22 +3022,25 @@ declare class URLSearchParams { */ has(name: string, value?: string): boolean; /** - * The **`set()`** method of the URLSearchParams interface sets the value associated with a given search parameter to the given value. If there were several matching values, this method deletes the others. If the search parameter doesn't exist, this method creates it. + * The **`set()`** method of the URLSearchParams interface sets the value associated with a given search parameter to the given value. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/set) */ set(name: string, value: string): void; /** - * The **`URLSearchParams.sort()`** method sorts all key/value pairs contained in this object in place and returns undefined. Key/value pairs are sorted by the values of the UTF-16 code units of the keys. This method uses a stable sorting algorithm (i.e., the relative order between key/value pairs with equal keys will be preserved). + * The **`URLSearchParams.sort()`** method sorts all key/value pairs contained in this object in place and returns `undefined`. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLSearchParams/sort) */ sort(): void; + /* Returns an array of key, value pairs for every entry in the search params. */ entries(): IterableIterator<[ key: string, value: string ]>; + /* Returns a list of keys in the search params. */ keys(): IterableIterator; + /* Returns a list of values in the search params. */ values(): IterableIterator; forEach(callback: (this: This, value: string, key: string, parent: URLSearchParams) => void, thisArg?: This): void; /*function toString() { [native code] }*/ @@ -3069,78 +3050,18 @@ declare class URLSearchParams { value: string ]>; } -/** - * The **`URLPattern`** interface of the URL Pattern API matches URLs or parts of URLs against a pattern. The pattern can contain capturing groups that extract parts of the matched URL. - * - * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLPattern) - */ declare class URLPattern { constructor(input?: (string | URLPatternInit), baseURL?: (string | URLPatternOptions), patternOptions?: URLPatternOptions); - /** - * The **`protocol`** read-only property of the URLPattern interface is a string containing the pattern used to match the protocol part of a URL. - * - * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLPattern/protocol) - */ get protocol(): string; - /** - * The **`username`** read-only property of the URLPattern interface is a string containing the pattern used to match the username part of a URL. - * - * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLPattern/username) - */ get username(): string; - /** - * The **`password`** read-only property of the URLPattern interface is a string containing the pattern used to match the password part of a URL. - * - * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLPattern/password) - */ get password(): string; - /** - * The **`hostname`** read-only property of the URLPattern interface is a string containing the pattern used to match the hostname part of a URL. - * - * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLPattern/hostname) - */ get hostname(): string; - /** - * The **`port`** read-only property of the URLPattern interface is a string containing the pattern used to match the port part of a URL. - * - * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLPattern/port) - */ get port(): string; - /** - * The **`pathname`** read-only property of the URLPattern interface is a string containing the pattern used to match the pathname part of a URL. - * - * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLPattern/pathname) - */ get pathname(): string; - /** - * The **`search`** read-only property of the URLPattern interface is a string containing the pattern used to match the search part of a URL. - * - * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLPattern/search) - */ get search(): string; - /** - * The **`hash`** read-only property of the URLPattern interface is a string containing the pattern used to match the fragment part of a URL. - * - * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLPattern/hash) - */ get hash(): string; - /** - * The **`hasRegExpGroups`** read-only property of the URLPattern interface is a boolean indicating whether or not any of the URLPattern components contain regular expression capturing groups. - * - * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLPattern/hasRegExpGroups) - */ get hasRegExpGroups(): boolean; - /** - * The **`test()`** method of the URLPattern interface takes a URL string or object of URL parts, and returns a boolean indicating if the given input matches the current pattern. - * - * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLPattern/test) - */ test(input?: (string | URLPatternInit), baseURL?: string): boolean; - /** - * The **`exec()`** method of the URLPattern interface takes a URL or object of URL parts, and returns either an object containing the results of matching the URL to the pattern, or null if the URL does not match the pattern. - * - * [MDN Reference](https://developer.mozilla.org/docs/Web/API/URLPattern/exec) - */ exec(input?: (string | URLPatternInit), baseURL?: string): URLPatternResult | null; } interface URLPatternInit { @@ -3173,7 +3094,7 @@ interface URLPatternOptions { ignoreCase?: boolean; } /** - * A **`CloseEvent`** is sent to clients using WebSockets when the connection is closed. This is delivered to the listener indicated by the WebSocket object's onclose attribute. + * A `CloseEvent` is sent to clients using WebSockets when the connection is closed. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CloseEvent) */ @@ -3192,16 +3113,13 @@ declare class CloseEvent extends Event { */ readonly reason: string; /** - * The **`wasClean`** read-only property of the CloseEvent interface returns true if the connection closed cleanly. + * The **`wasClean`** read-only property of the CloseEvent interface returns `true` if the connection closed cleanly. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CloseEvent/wasClean) */ readonly wasClean: boolean; } interface CloseEventInit { - bubbles?: boolean; - cancelable?: boolean; - composed?: boolean; code?: number; reason?: string; wasClean?: boolean; @@ -3213,7 +3131,7 @@ type WebSocketEventMap = { error: ErrorEvent; }; /** - * The **`WebSocket`** object provides the API for creating and managing a WebSocket connection to a server, as well as for sending and receiving data on the connection. + * The `WebSocket` object provides the API for creating and managing a WebSocket connection to a server, as well as for sending and receiving data on the connection. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket) */ @@ -3230,20 +3148,20 @@ declare var WebSocket: { readonly CLOSED: number; }; /** - * The **`WebSocket`** object provides the API for creating and managing a WebSocket connection to a server, as well as for sending and receiving data on the connection. + * The `WebSocket` object provides the API for creating and managing a WebSocket connection to a server, as well as for sending and receiving data on the connection. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket) */ interface WebSocket extends EventTarget { accept(options?: WebSocketAcceptOptions): void; /** - * The **`WebSocket.send()`** method enqueues the specified data to be transmitted to the server over the WebSocket connection, increasing the value of bufferedAmount by the number of bytes needed to contain the data. If the data can't be sent (for example, because it needs to be buffered but the buffer is full), the socket is closed automatically. The browser will throw an exception if you call send() when the connection is in the CONNECTING state. If you call send() when the connection is in the CLOSING or CLOSED states, the browser will silently discard the data. + * The **`WebSocket.send()`** method enqueues the specified data to be transmitted to the server over the WebSocket connection, increasing the value of `bufferedAmount` by the number of bytes needed to contain the data. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/send) */ send(message: (ArrayBuffer | ArrayBufferView) | string): void; /** - * The **`WebSocket.close()`** method closes the WebSocket connection or connection attempt, if any. If the connection is already CLOSED, this method does nothing. + * The **`WebSocket.close()`** method closes the already `CLOSED`, this method does nothing. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/close) */ @@ -3263,13 +3181,13 @@ interface WebSocket extends EventTarget { */ url: string | null; /** - * The **`WebSocket.protocol`** read-only property returns the name of the sub-protocol the server selected; this will be one of the strings specified in the protocols parameter when creating the WebSocket object, or the empty string if no connection is established. + * The **`WebSocket.protocol`** read-only property returns the name of the sub-protocol the server selected; this will be one of the strings specified in the `protocols` parameter when creating the WebSocket object, or the empty string if no connection is established. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/protocol) */ protocol: string | null; /** - * The **`WebSocket.extensions`** read-only property returns the extensions selected by the server. This is currently only the empty string or a list of extensions as negotiated by the connection. + * The **`WebSocket.extensions`** read-only property returns the extensions selected by the server. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/WebSocket/extensions) */ @@ -3356,25 +3274,25 @@ interface SocketInfo { declare class EventSource extends EventTarget { constructor(url: string, init?: EventSourceEventSourceInit); /** - * The **`close()`** method of the EventSource interface closes the connection, if one is made, and sets the EventSource.readyState attribute to 2 (closed). + * The **`close()`** method of the EventSource interface closes the connection, if one is made, and sets the ```js-nolint close() ``` None. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/close) */ close(): void; /** - * The **`url`** read-only property of the EventSource interface returns a string representing the URL of the source. + * The **`url`** read-only property of the URL of the source. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/url) */ get url(): string; /** - * The **`withCredentials`** read-only property of the EventSource interface returns a boolean value indicating whether the EventSource object was instantiated with CORS credentials set. + * The **`withCredentials`** read-only property of the the `EventSource` object was instantiated with CORS credentials set. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/withCredentials) */ get withCredentials(): boolean; /** - * The **`readyState`** read-only property of the EventSource interface returns a number representing the state of the connection. + * The **`readyState`** read-only property of the connection. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventSource/readyState) */ @@ -3409,26 +3327,18 @@ interface ContainerExecOptions { cwd?: string; env?: Record; user?: string; - signal?: AbortSignal; - pty?: boolean | ContainerExecPtyOptions; stdin?: ReadableStream | "pipe"; stdout?: "pipe" | "ignore"; stderr?: "pipe" | "ignore" | "combined"; } -interface ContainerExecPtyOptions { - cols?: number; - rows?: number; -} interface ExecProcess { readonly stdin: WritableStream | null; readonly stdout: ReadableStream | null; readonly stderr: ReadableStream | null; readonly pid: number; - readonly isPty: boolean; readonly exitCode: Promise; output(): Promise; kill(signal?: number): void; - resize(cols: number, rows: number): void; } interface Container { get running(): boolean; @@ -3455,42 +3365,25 @@ interface ContainerDirectorySnapshotOptions { dir: string; name?: string; } -type ContainerDirectorySnapshotRestoreParams = { +interface ContainerDirectorySnapshotRestoreParams { snapshot: ContainerDirectorySnapshot; mountPoint?: string; -} | { - snapshot?: undefined; - mountPoint: string; -}; +} interface ContainerSnapshot { id: string; size: number; name?: string; } -interface ContainerSnapshotRestoreParams { - id: string; -} interface ContainerSnapshotOptions { name?: string; } -type ContainerStartupOptions = { +interface ContainerStartupOptions { entrypoint?: string[]; enableInternet: boolean; env?: Record; - instance?: "lite" | "standard-1" | "standard-2" | "standard-3" | "standard-4" | ContainerStartResources; labels?: Record; directorySnapshots?: ContainerDirectorySnapshotRestoreParams[]; -} & ({ - image: string; - containerSnapshot?: never; -} | { - image?: never; - containerSnapshot?: ContainerSnapshotRestoreParams; -}); -interface ContainerStartResources { - vcpu: number; - memoryMib: number; - diskMb: number; + containerSnapshot?: ContainerSnapshot; } /** * The **`MessagePort`** interface of the Channel Messaging API represents one of the two ports of a MessageChannel, allowing messages to be sent from one port and listening out for them arriving at the other. @@ -3499,19 +3392,19 @@ interface ContainerStartResources { */ declare abstract class MessagePort extends EventTarget { /** - * The **`postMessage()`** method of the MessagePort interface sends a message from the port, and optionally, transfers ownership of objects to other browsing contexts. + * The **`postMessage()`** method of the transfers ownership of objects to other browsing contexts. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessagePort/postMessage) */ postMessage(data?: any, options?: (any[] | MessagePortPostMessageOptions)): void; /** - * The **`close()`** method of the MessagePort interface disconnects the port, so it is no longer active. This stops the flow of messages to that port. + * The **`close()`** method of the MessagePort interface disconnects the port, so it is no longer active. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessagePort/close) */ close(): void; /** - * The **`start()`** method of the MessagePort interface starts the sending of messages queued on the port. This method is only needed when using EventTarget.addEventListener; it is implied when using onmessage. + * The **`start()`** method of the MessagePort interface starts the sending of messages queued on the port. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessagePort/start) */ @@ -3527,13 +3420,13 @@ declare abstract class MessagePort extends EventTarget { declare class MessageChannel { constructor(); /** - * The **`port1`** read-only property of the MessageChannel interface returns the first port of the message channel — the port attached to the context that originated the channel. + * The **`port1`** read-only property of the the port attached to the context that originated the channel. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageChannel/port1) */ readonly port1: MessagePort; /** - * The **`port2`** read-only property of the MessageChannel interface returns the second port of the message channel — the port attached to the context at the other end of the channel, which the message is initially sent to. + * The **`port2`** read-only property of the the port attached to the context at the other end of the channel, which the message is initially sent to. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/MessageChannel/port2) */ @@ -3593,7 +3486,7 @@ interface WorkerLoaderModule { data?: ArrayBuffer; json?: any; py?: string; - wasm?: ArrayBuffer | ArrayBufferView | WebAssembly.Module; + wasm?: ArrayBuffer; } interface WorkerLoaderWorkerCode { compatibilityDate: string; @@ -3601,7 +3494,7 @@ interface WorkerLoaderWorkerCode { allowExperimental?: boolean; limits?: workerdResourceLimits; mainModule: string; - modules: Record; + modules: Record; env?: any; globalOutbound?: (Fetcher | null); tails?: Fetcher[]; @@ -3623,7 +3516,7 @@ declare abstract class Performance { /* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/performance/#performancenow) */ now(): number; /** - * The **`toJSON()`** method of the Performance interface is a serializer; it returns a JSON representation of the Performance object. + * The **`toJSON()`** method of the Performance interface is a Serialization; it returns a JSON representation of the Performance object. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Performance/toJSON) */ @@ -3632,13 +3525,11 @@ declare abstract class Performance { interface Tracing { enterSpan(name: string, callback: (span: Span, ...args: A) => T, ...args: A): T; startActiveSpan(name: string, callback: (span: Span, ...args: A) => T, ...args: A): T; - startSpan(name: string): Span; Span: typeof Span; } declare abstract class Span { get isTraced(): boolean; - setAttribute(key: string, value: boolean | number | string): this; - setAttributes(attributes: Record): this; + setAttribute(key: string, value?: (boolean | number | string)): void; end(): void; } /** @@ -4278,13 +4169,6 @@ type AiSearchListItemsParams = { source?: string; /** JSON-encoded Vectorize filter for metadata filtering. */ metadata_filter?: string; - /** Filter items by their unique ID. Returns at most one item. */ - item_id?: string; - /** - * Filter items by their exact key (object key / filename). Keys are unique - * per source, so combine with `source` to disambiguate across data sources. - */ - key?: string; }; type AiSearchListItemsResponse = { result: AiSearchItemInfo[]; @@ -10182,163 +10066,6 @@ declare abstract class Base_Ai_Cf_Google_Gemma_4_26B_A4B_IT { inputs: ChatCompletionsInput; postProcessedOutputs: ChatCompletionsOutput; } -declare abstract class Base_Ai_Cf_Moonshotai_Kimi_K2_7_Code { - inputs: ChatCompletionsInput; - postProcessedOutputs: ChatCompletionsOutput; -} -declare abstract class Base_Ai_Cf_Zai_Org_Glm_5_2 { - inputs: ChatCompletionsInput; - postProcessedOutputs: ChatCompletionsOutput; -} -interface Ai_Cf_Moondream_Moondream3_1_9B_A2B_Input { - /** - * Which Moondream skill to run. - */ - task?: "query" | "caption" | "point" | "detect"; - /** - * Input image as a public HTTPS URL or base64 data URI. Optional for `query`; required for `caption`, `point`, and `detect`. - */ - image?: string; - /** - * Question for the `query` task. - */ - question?: string; - /** - * Caption length for the `caption` task. - */ - caption_length?: "short" | "normal" | "long"; - /** - * Object phrase to locate for `point` and `detect` tasks (e.g. 'person wearing a red shirt'). - */ - target?: string; - /** - * Enable reasoning trace for the `query` task. - */ - reasoning?: boolean; - /** - * Sampling temperature. - */ - temperature?: number; - /** - * Top-p (nucleus) sampling. - */ - top_p?: number; - /** - * Max tokens to generate for `query` and `caption`. - */ - max_tokens?: number; - /** - * Max objects to return for `point` and `detect`. - */ - max_objects?: number; - /** - * Return incremental tokens for `query` and `caption`. `point` and `detect` do not support streaming. - */ - stream?: boolean; -} -interface Ai_Cf_Moondream_Moondream3_1_9B_A2B_Output { - /** - * Reason the generation finished. - */ - finish_reason: string; - metrics: { - /** - * Number of input tokens consumed. - */ - input_tokens: number; - /** - * Number of output tokens generated. - */ - output_tokens: number; - /** - * Prefill time in milliseconds. - */ - prefill_time_ms: number; - /** - * Decode time in milliseconds. - */ - decode_time_ms: number; - /** - * Time to first token in milliseconds. - */ - ttft_ms: number; - }; - /** - * Answer text for the `query` task. Null for other tasks. - */ - answer?: string; - /** - * Caption text for the `caption` task. Null for other tasks. - */ - caption?: string; - /** - * Located points for the `point` task. Null for other tasks. - */ - points?: { - /** - * X coordinate. - */ - x: number; - /** - * Y coordinate. - */ - y: number; - }[]; - /** - * Detected bounding boxes for the `detect` task. Null for other tasks. - */ - objects?: { - /** - * Minimum X coordinate. - */ - x_min: number; - /** - * Minimum Y coordinate. - */ - y_min: number; - /** - * Maximum X coordinate. - */ - x_max: number; - /** - * Maximum Y coordinate. - */ - y_max: number; - }[]; - /** - * Reasoning trace for the `query` task when reasoning=true. Null otherwise. - */ - reasoning?: { - /** - * Reasoning text. - */ - text: string; - /** - * Grounding information. - */ - grounding?: {}[]; - }; -} -declare abstract class Base_Ai_Cf_Moondream_Moondream3_1_9B_A2B { - inputs: Ai_Cf_Moondream_Moondream3_1_9B_A2B_Input; - postProcessedOutputs: Ai_Cf_Moondream_Moondream3_1_9B_A2B_Output; -} -declare abstract class Base_Ai_Cf_Deepseek_Ai_Deepseek_V4_Flash_0731 { - inputs: ChatCompletionsInput; - postProcessedOutputs: ChatCompletionsOutput; -} -declare abstract class Base_Ai_Cf_Deepseek_Ai_Deepseek_V4_Pro_0813 { - inputs: ChatCompletionsInput; - postProcessedOutputs: ChatCompletionsOutput; -} -declare abstract class Base_Ai_Cf_Qwen_Qwen3_8_27B { - inputs: ChatCompletionsInput; - postProcessedOutputs: ChatCompletionsOutput; -} -declare abstract class Base_Ai_Cf_Zai_Org_Glm_5_3_Flash { - inputs: ChatCompletionsInput; - postProcessedOutputs: ChatCompletionsOutput; -} interface AiModels { "@cf/huggingface/distilbert-sst-2-int8": BaseAiTextClassification; "@cf/stabilityai/stable-diffusion-xl-base-1.0": BaseAiTextToImage; @@ -10431,13 +10158,6 @@ interface AiModels { "@cf/moonshotai/kimi-k2.6": Base_Ai_Cf_Moonshotai_Kimi_K2_6; "@cf/nvidia/nemotron-3-120b-a12b": Base_Ai_Cf_Nvidia_Nemotron_3_120B_A12B; "@cf/google/gemma-4-26b-a4b-it": Base_Ai_Cf_Google_Gemma_4_26B_A4B_IT; - "@cf/moonshotai/kimi-k2.7-code": Base_Ai_Cf_Moonshotai_Kimi_K2_7_Code; - "@cf/zai-org/glm-5.2": Base_Ai_Cf_Zai_Org_Glm_5_2; - "@cf/moondream/moondream3.1-9B-A2B": Base_Ai_Cf_Moondream_Moondream3_1_9B_A2B; - "@cf/deepseek-ai/deepseek-v4-flash-0731": Base_Ai_Cf_Deepseek_Ai_Deepseek_V4_Flash_0731; - "@cf/deepseek-ai/deepseek-v4-pro-0813": Base_Ai_Cf_Deepseek_Ai_Deepseek_V4_Pro_0813; - "@cf/qwen/qwen3.8-27b": Base_Ai_Cf_Qwen_Qwen3_8_27B; - "@cf/zai-org/glm-5.3-flash": Base_Ai_Cf_Zai_Org_Glm_5_3_Flash; } type AiOptions = { /** @@ -11187,29 +10907,10 @@ type BrowserRunLinksOptions = BrowserRunCommonOptions & { /** When true, exclude links pointing to external domains. @default false */ excludeExternalLinks?: boolean; }; -type BrowserRunSnapshotFormat = 'content' | 'screenshot' | 'markdown' | 'accessibilityTree'; type BrowserRunSnapshotOptions = BrowserRunCommonOptions & { - /** Which representations of the page to return. At least two distinct formats - * are required; request a single format from its dedicated action instead. - * @default ["content","screenshot"] - */ - formats?: BrowserRunSnapshotFormat[]; /** @see https://pptr.dev/api/puppeteer.screenshotoptions */ screenshotOptions?: Omit; }; -/** Options for the `accessibilityTree` quick action. */ -type BrowserRunAccessibilityTreeOptions = BrowserRunCommonOptions & { - /** When true, prune nodes that carry no semantic meaning, such as generic - * containers. Defaults to true, or to false when `root` is set so that the - * requested subtree is returned as-is. - */ - interestingOnly?: boolean; - /** CSS selector limiting the tree to the matching element's subtree. - * A selector that matches nothing yields `accessibilityTree: null` with - * HTTP 200; a malformed selector is an error. - */ - root?: string; -}; interface BrowserRunJsonBaseOptions { /** Custom AI models to try in order. Max 3. Falls back to next on error. */ custom_ai?: Array<{ @@ -11236,78 +10937,12 @@ type BrowserRunJsonOptions = BrowserRunCommonOptions & BrowserRunJsonBaseOptions }); type BrowserRunContentOptions = BrowserRunCommonOptions; type BrowserRunMarkdownOptions = BrowserRunCommonOptions; -type BrowserRunRedirectHop = { - /** URL that returned the redirect. */ - url: string; - /** HTTP status of the redirect. */ - status: number; - /** Redirect response headers, including `location`. */ - headers: Record; -}; type BrowserRunResponseMeta = { /** HTTP status code of the rendered page */ status: number; /** Page title */ title: string; - /** Origin response headers, lowercased. Repeated headers are joined with a newline. Credential and transport-only headers that do not survive rendering are omitted. */ - headers?: Record; - /** URL that served the response, after any redirects the browser followed. */ - finalUrl?: string; - /** HTTP redirects followed to reach `finalUrl`, oldest first. Omitted for direct navigation and for client-side redirects such as meta refresh. An empty array means redirects occurred but their intermediate responses could not be read. */ - redirectChain?: BrowserRunRedirectHop[]; -}; -/** - * A node in the page's accessibility tree, as exposed to assistive technology. - * `role` is the only field always present; the rest are populated when the - * underlying element defines them. - * @see https://pptr.dev/api/puppeteer.serializedaxnode - */ -interface BrowserRunSerializedAXNode { - /** The ARIA role, e.g. `"button"`, `"heading"`, `"RootWebArea"`. */ - role: string; - /** The `aria-autocomplete` value. */ - autocomplete?: string; - /** Checked state of a checkbox, radio, or menu item. */ - checked?: boolean | 'mixed'; - /** Accessible description, typically from `aria-describedby` or `title`. */ - description?: string; - disabled?: boolean; - expanded?: boolean; - /** Whether the element currently holds keyboard focus. */ - focused?: boolean; - /** The kind of popup the element triggers, e.g. `"menu"`, `"dialog"`. */ - haspopup?: string; - /** The `aria-invalid` value. */ - invalid?: string; - /** Keyboard shortcuts bound to the element, from `aria-keyshortcuts`. */ - keyshortcuts?: string; - /** Hierarchical level, e.g. the heading level of an `

`. */ - level?: number; - /** Whether the element is a modal dialog. */ - modal?: boolean; - /** Whether a text input accepts multiple lines. */ - multiline?: boolean; - /** Whether more than one option can be selected. */ - multiselectable?: boolean; - /** Accessible name, e.g. a button's label or an image's alt text. */ - name?: string; - orientation?: string; - /** Pressed state of a toggle button. */ - pressed?: boolean | 'mixed'; - readonly?: boolean; - required?: boolean; - /** Author-supplied role description, from `aria-roledescription`. */ - roledescription?: string; - selected?: boolean; - /** Current value of an input or range element. */ - value?: string | number; - valuemax?: number; - valuemin?: number; - /** Human-readable form of `value`, from `aria-valuetext`. */ - valuetext?: string; - /** Child nodes. Absent for leaf nodes. */ - children?: BrowserRunSerializedAXNode[]; -} +}; /** Success response for `content` action. */ type BrowserRunContentSuccessResponse = { success: true; @@ -11320,7 +10955,6 @@ type BrowserRunLinksSuccessResponse = { success: true; /** Extracted links */ result: string[]; - meta: BrowserRunResponseMeta; }; /** Success response for `scrape` action. */ type BrowserRunScrapeSuccessResponse = { @@ -11351,33 +10985,15 @@ type BrowserRunScrapeSuccessResponse = { }>; }>; }>; - meta: BrowserRunResponseMeta; }; -/** Success response for `snapshot` action. Each field is present only when the - * corresponding entry was requested in `formats`. - */ +/** Success response for `snapshot` action. */ type BrowserRunSnapshotSuccessResponse = { success: true; result: { /** HTML content of the page. */ - content?: string; + content: string; /** Base64-encoded screenshot image. */ - screenshot?: string; - /** Markdown content. Prefixed with YAML frontmatter (e.g. `title`) when the - * page provides that metadata. - */ - markdown?: string; - /** Root of the page's accessibility tree. */ - accessibilityTree?: BrowserRunSerializedAXNode; - }; - meta: BrowserRunResponseMeta; -}; -/** Success response for `accessibilityTree` action. */ -type BrowserRunAccessibilityTreeSuccessResponse = { - success: true; - result: { - /** Root of the accessibility tree, or `null` when `root` matched no element. */ - accessibilityTree: BrowserRunSerializedAXNode | null; + screenshot: string; }; meta: BrowserRunResponseMeta; }; @@ -11386,14 +11002,12 @@ type BrowserRunJsonSuccessResponse = { success: true; /** JSON data extracted from the page using an AI model */ result: Record; - meta: BrowserRunResponseMeta; }; /** Success response for `markdown` action. */ type BrowserRunMarkdownSuccessResponse = { success: true; /** Extracted markdown content */ result: string; - meta: BrowserRunResponseMeta; }; /** Error response for BrowserRun actions. */ type BrowserRunErrorResponse = { @@ -11503,10 +11117,9 @@ declare abstract class BrowserRun { */ quickAction(action: 'links', options: BrowserRunLinksOptions): Promise; /** - * Get several representations of a web page in one request. + * Get both the HTML content and a base64-encoded screenshot of a web page. * @param action - Must be `'snapshot'`. - * @param options - Snapshot options including the `formats` to return and - * screenshot settings (encoding is always base64). + * @param options - Snapshot options including screenshot settings (encoding is always base64). * @returns A `Response` containing one of: * * **Success (HTTP 200):** @@ -11554,26 +11167,6 @@ declare abstract class BrowserRun { * - `X-Browser-Ms-Used`: Browser time consumed in milliseconds (set when status < 500) */ quickAction(action: 'markdown', options: BrowserRunMarkdownOptions): Promise; - /** - * Get the accessibility tree of a web page. - * @param action - Must be `'accessibilityTree'`. - * @param options - Options to scope the tree to a subtree and to control - * whether semantically uninteresting nodes are pruned. - * @returns A `Response` containing one of: - * - * **Success (HTTP 200):** - * - `BrowserRunAccessibilityTreeSuccessResponse` JSON with `Content-Type: application/json` - * - `result.accessibilityTree` is `null` when `root` matched no element - * - * **Error:** - * - `BrowserRunErrorResponse` JSON with appropriate HTTP status code (400, 422, 429, 500, 503) - * - HTTP 422 for a malformed `root` selector - * - HTTP 500 with code `2017` or `2018` when the tree could not be built - * - * **Headers:** - * - `X-Browser-Ms-Used`: Browser time consumed in milliseconds (set when status < 500) - */ - quickAction(action: 'accessibilityTree', options: BrowserRunAccessibilityTreeOptions): Promise; } /** * In addition to the properties you can set in the RequestInit dict @@ -12864,15 +12457,6 @@ interface Hyperdrive { * for your database. */ readonly host: string; - /* - * A synthetic IPv4 address (in the reserved 240.0.0.0/4 range) that, like the - * host field, is only valid within the context of the currently running - * Worker and, when passed into the `connect()` function from the - * "cloudflare:sockets" module, will connect to the Hyperdrive instance for - * your database. This is provided for database drivers that require the host - * to be an IP literal rather than a hostname. - */ - readonly ip: string; /* * The port that must be paired the the host field when connecting. */ @@ -12905,27 +12489,6 @@ type ImageInfoResponse = { width: number; height: number; }; -/** - * Parameters for rasterizing text into an image. - */ -type TextRasterize = { - /** The text content to render */ - content: string; - /** rasterization options for the text **/ - options: TextOptions; -}; -type TextOptions = { - /** Font configuration */ - font: { - /** URL to a font file in TrueType (.ttf), OpenType (.otf), WOFF (.woff), or WOFF2 (.woff2) format */ - url: string; - }; - /** Font size in points (pt) */ - size?: number; - /** Text color in CSS format: hex (#RRGGBB or #RRGGBBAA), rgb(r,g,b), rgba(r,g,b,a), or named colors */ - color?: string; -}; -type ImageSource = ReadableStream | TextRasterize; type ImageTransform = { width?: number; height?: number; @@ -13015,9 +12578,6 @@ interface ImageUploadOptions { requireSignedURLs?: boolean; metadata?: Record; creator?: string; - /** - * If 'base64', the input data will be decoded from base64 before processing - */ encoding?: 'base64'; } interface ImageUpdateOptions { @@ -13025,40 +12585,11 @@ interface ImageUpdateOptions { metadata?: Record; creator?: string; } -type ImageMetadataFilterOperators = { - eq?: string | number | boolean; - in?: string[] | number[]; - gt?: number; - gte?: number; - lt?: number; - lte?: number; -}; -type ImageMetadataFilterValue = string | number | boolean | ImageMetadataFilterOperators; -interface ImageListFilter { - metadata?: Record; -} interface ImageListOptions { limit?: number; cursor?: string; sortOrder?: 'asc' | 'desc'; creator?: string; - filter?: ImageListFilter; -} -interface ImageSignedUrlOptions { - variant: string; - expiresIn?: number; - keyName?: string; -} -interface ImageDirectUploadOptions { - id?: string; - requireSignedURLs?: boolean; - metadata?: Record; - creator?: string; - expiresIn?: number; -} -interface ImageDirectUploadResult { - id: string; - uploadURL: string; } interface ImageList { images: ImageMetadata[]; @@ -13076,13 +12607,6 @@ interface ImageHandle { * @returns ReadableStream of image bytes, or null if not found */ bytes(): Promise | null>; - /** - * Generate a signed delivery URL for this hosted image. - * @param options Signing configuration - * @returns A signed image delivery URL - * @throws {@link ImagesError} if signing fails - */ - signedUrl(options: ImageSignedUrlOptions): Promise; /** * Update hosted image metadata * @param options Properties to update @@ -13118,14 +12642,6 @@ interface HostedImagesBinding { * @throws {@link ImagesError} if list fails */ list(options?: ImageListOptions): Promise; - /** - * Create a Direct Creator Upload link, letting an end user upload an - * image straight to Cloudflare without exposing an API token - * @param options Upload link configuration - * @returns The new image ID and the upload URL to hand to the end user - * @throws {@link ImagesError} if creation fails - */ - createDirectUpload(options?: ImageDirectUploadOptions): Promise; } interface ImagesBinding { /** @@ -13140,13 +12656,6 @@ interface ImagesBinding { * @returns A transform handle */ input(stream: ReadableStream, options?: ImageInputOptions): ImageTransformer; - /** - * Begin applying a series of transformations to text - * @param content string to be rendered - * @param options font, optional color and size to use in rendering text - * @returns A transform handle - */ - text(content: string, options: TextOptions): ImageTransformer; /** * Access hosted images CRUD operations */ @@ -13176,15 +12685,11 @@ interface ImageTransformer { type ImageTransformationOutputOptions = { encoding?: 'base64'; }; -type ImageTransformationResponseOptions = { - headers?: HeadersInit; -}; interface ImageTransformationResult { /** * The image as a response, ready to store in cache or return to users - * @param options Options that apply to the returned response, e.g. additional headers */ - response(options?: ImageTransformationResponseOptions): Response; + response(): Response; /** * The content type of the returned image */ @@ -13638,26 +13143,6 @@ declare namespace CloudflareWorkersModule { timeout?: WorkflowTimeoutDuration | number; sensitive?: WorkflowStepSensitivity; }; - // Internal discriminators used only for `WorkflowStep.do` overload - // resolution. They mirror `WorkflowStepConfig` but pin `retries.delay` to a - // single kind so the callback context can be narrowed based on the shape of - // the config argument (rather than on an inferred type parameter, which is - // lost when the caller supplies an explicit return-type argument). Not - // exported: they must not widen the public type surface. - type WorkflowStepConfigWithStaticDelay = Omit & { - retries?: { - limit: number; - delay: WorkflowDelayDuration | number; - backoff?: WorkflowBackoff; - }; - }; - type WorkflowStepConfigWithDelayFunction = Omit & { - retries: { - limit: number; - delay: WorkflowDelayFunction; - backoff?: WorkflowBackoff; - }; - }; export type WorkflowStepRollbackConfig = Pick; export type WorkflowCronSchedule = { /** Cron expression that triggered this event. */ @@ -13695,35 +13180,23 @@ declare namespace CloudflareWorkersModule { sensitive?: WorkflowStepSensitivity; }; }; - // The rollback handler receives the step context, so it mirrors the same - // delay discriminant as the step callback: when the step was configured with - // a dynamic delay function the resolved `config.retries.delay` is omitted, - // otherwise it is present. `Delay` is threaded from the `WorkflowStep.do` - // overload that matched the step config. - export type WorkflowRollbackContext = { - ctx: WorkflowStepContext; + export type WorkflowRollbackContext = { + ctx: WorkflowStepContext; error: Error; output: T | undefined; /** @deprecated Use `ctx.step.name` and `ctx.step.count` instead. */ stepName: string; }; - export type WorkflowRollbackHandler = (ctx: WorkflowRollbackContext) => Promise; - export type WorkflowStepRollbackOptions = { - rollback: WorkflowRollbackHandler; + export type WorkflowRollbackHandler = (ctx: WorkflowRollbackContext) => Promise; + export type WorkflowStepRollbackOptions = { + rollback: WorkflowRollbackHandler; rollbackConfig?: WorkflowStepRollbackConfig; }; export abstract class WorkflowStep { do>(name: string, callback: (ctx: WorkflowStepContext) => Promise, rollbackOptions?: WorkflowStepRollbackOptions): Promise; - // The config overloads discriminate on the shape of `config.retries.delay` - // so the callback context reflects whether the resolved delay is present - // (static delay) or omitted (dynamic delay function). Each has a single - // type parameter, so an explicit return-type argument (`do(...)`) still - // resolves here. ORDERING IS LOAD-BEARING: the broad `WorkflowStepConfig` - // fallback MUST remain last, otherwise it shadows the discriminating - // overloads and narrowing is silently lost. - do>(name: string, config: WorkflowStepConfigWithDelayFunction, callback: (ctx: WorkflowStepContext) => Promise, rollbackOptions?: WorkflowStepRollbackOptions): Promise; - do>(name: string, config: WorkflowStepConfigWithStaticDelay, callback: (ctx: WorkflowStepContext) => Promise, rollbackOptions?: WorkflowStepRollbackOptions): Promise; - do>(name: string, config: WorkflowStepConfig, callback: (ctx: WorkflowStepContext) => Promise, rollbackOptions?: WorkflowStepRollbackOptions): Promise; + do, const C extends WorkflowStepConfig>(name: string, config: C, callback: (ctx: WorkflowStepContext) => Promise, rollbackOptions?: WorkflowStepRollbackOptions): Promise; sleep: (name: string, duration: WorkflowSleepDuration) => Promise; sleepUntil: (name: string, timestamp: Date | number) => Promise; waitForEvent>(name: string, options: { @@ -14498,12 +13971,11 @@ type MarkdownDocument = { name: string; blob: Blob; }; -type OutputFormat = 'markdown' | 'text'; type ConversionResponse = { id: string; name: string; mimeType: string; - format: OutputFormat; + format: 'markdown'; tokens: number; data: string; } | { @@ -14520,11 +13992,7 @@ type EmbeddedImageConversionOptions = ImageConversionOptions & { convert?: boolean; maxConvertedImages?: number; }; -type ConversionOutputOptions = { - format?: OutputFormat; -}; type ConversionOptions = { - output?: ConversionOutputOptions; html?: { images?: EmbeddedImageConversionOptions & { convertOGImage?: boolean; @@ -14619,7 +14087,7 @@ declare namespace TailStream { interface ConnectEventInfo { readonly type: "connect"; } - type EventOutcome = "ok" | "canceled" | "exception" | "unknown" | "killSwitch" | "daemonDown" | "exceededCpu" | "exceededMemory" | "loadShed" | "responseStreamDisconnected" | "scriptNotFound" | "internalError" | "exceededWallTime" | "aborted"; + type EventOutcome = "ok" | "canceled" | "exception" | "unknown" | "killSwitch" | "daemonDown" | "exceededCpu" | "exceededMemory" | "loadShed" | "responseStreamDisconnected" | "scriptNotFound" | "internalError" | "exceededWallTime"; interface ScriptVersion { readonly id: string; readonly tag?: string; @@ -14673,22 +14141,11 @@ declare namespace TailStream { readonly message: string; readonly stack?: string; } - interface TailStreamErrorInfo { - readonly name: string; - readonly message: string; - readonly stack?: string; - } - type Log = { + interface Log { readonly type: "log"; readonly level: "debug" | "error" | "info" | "log" | "warn"; - readonly errorInfo?: readonly (TailStreamErrorInfo | null)[]; - } & ({ readonly message: object; - readonly truncated?: false; - } | { - readonly message: string; - readonly truncated: true; - }); + } interface DroppedEventsDiagnostic { readonly diagnosticsType: "droppedEvents"; readonly count: number; @@ -15166,31 +14623,10 @@ declare abstract class Workflow { * @returns A promise that resolves with a list of handles for the created instances. */ public createBatch(batch: WorkflowInstanceCreateOptions[]): Promise; - /** - * Delete a batch of Workflow instances and their stored state. - * `deleteBatch` is limited to 100 instances at a time. Duplicate IDs are deleted once. - * The result contains one entry for each input position; IDs that do not exist are returned as per-instance errors. - * @param instanceIds IDs of the Workflow instances to delete - * @returns A promise that resolves with the successfully deleted instances and any per-instance errors. - */ - public deleteBatch(instanceIds: string[]): Promise; } -type WorkflowBatchDeleteResult = { - deleted: { - id: string; - }[]; - errors: { - id: string; - code: number; - message: string; - }[]; -}; type WorkflowDurationLabel = 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year'; type WorkflowSleepDuration = `${number} ${WorkflowDurationLabel}${'s' | ''}` | number; type WorkflowRetentionDuration = WorkflowSleepDuration; -/** Geographic regions supported when creating a Workflow instance. - * Location hints are best-effort placement preferences. */ -type WorkflowInstanceLocationHint = 'wnam' | 'enam' | 'sam' | 'weur' | 'eeur' | 'apac' | 'apac-ne' | 'apac-se' | 'oc' | 'afr' | 'me'; interface WorkflowInstanceCreateOptions { /** * An id for your Workflow instance. Must be unique within the Workflow. @@ -15208,9 +14644,6 @@ interface WorkflowInstanceCreateOptions { successRetention?: WorkflowRetentionDuration; errorRetention?: WorkflowRetentionDuration; }; - /** A best-effort geographic placement preference for the Workflow instance. - * See `WorkflowInstanceLocationHint` for supported regions. */ - locationHint?: WorkflowInstanceLocationHint; } type InstanceStatus = { status: 'queued' // means that instance is waiting to be started (see concurrency limits) @@ -15277,10 +14710,6 @@ declare abstract class WorkflowInstance { * @param options Options for the restart, including an optional step to restart from. */ public restart(options?: WorkflowInstanceRestartOptions): Promise; - /** - * Delete the instance and its stored state. - */ - public delete(): Promise; /** * Returns the current status of the instance. */ diff --git a/services/kilo-mcp/wrangler.jsonc b/services/kilo-mcp/wrangler.jsonc index 4bfee02f1c..5ce8c09e9c 100644 --- a/services/kilo-mcp/wrangler.jsonc +++ b/services/kilo-mcp/wrangler.jsonc @@ -59,6 +59,11 @@ "vars": { "WEB_BASE_URL": "https://app.kilo.ai", + // Server-side PostHog capture key (src/analytics.ts). It is a worker var, + // never a client bundle, and it is deliberately absent from the `env.dev` + // vars below so local/dev runs do not send (the web non-production + // opt-out convention, apps/web/src/components/PostHogProvider.tsx:52-57). + "NEXT_PUBLIC_POSTHOG_KEY": "phc_GK2Pxl0HPj5ZPfwhLRjXrtdz8eD7e9MKnXiFrOqnB6z", }, // ============================================ From 6c42d90b2cfd5c2ad5dd4709782f09a3345c1554 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 11 Sep 2026 13:09:31 +0200 Subject: [PATCH 2/2] fix(kilo-mcp): emit OAuth failure analytics once per pairing - the denied poll emits kilo_mcp_oauth_sign_in_failed only when this request wins the pending -> denied transition, so concurrent polls do not double-count. - an upstream expired answer is persisted as a terminal code status (pending -> expired) and emitted once; later polls answer expired from the record. The org picker rejects the expired state too. --- services/kilo-mcp/src/auth/authorize.test.ts | 1 + services/kilo-mcp/src/auth/dcr.test.ts | 1 + services/kilo-mcp/src/auth/token.test.ts | 1 + services/kilo-mcp/src/db/sqlite-schema.ts | 4 +- services/kilo-mcp/src/index.test.ts | 13 +++++ .../src/oauth-pages/authorize-page.test.ts | 50 ++++++++++++++++--- .../src/oauth-pages/authorize-page.ts | 43 ++++++++++------ .../src/oauth-pages/org-picker.test.ts | 1 + .../kilo-mcp/src/oauth-pages/org-picker.ts | 3 +- .../kilo-mcp/src/store/oauth-store.test.ts | 8 +++ services/kilo-mcp/src/store/oauth-store.ts | 20 +++++++- 11 files changed, 121 insertions(+), 24 deletions(-) diff --git a/services/kilo-mcp/src/auth/authorize.test.ts b/services/kilo-mcp/src/auth/authorize.test.ts index 4beb45b76d..d3c3f6638f 100644 --- a/services/kilo-mcp/src/auth/authorize.test.ts +++ b/services/kilo-mcp/src/auth/authorize.test.ts @@ -47,6 +47,7 @@ function createFakeOAuthStore(): OAuthStoreApi & { }, recordPairingApproval: unused, denyCode: unused, + markCodeExpired: unused, async approveCode(deviceAuthCode, identity, nowIso) { for (const [code, record] of codes) { if ( diff --git a/services/kilo-mcp/src/auth/dcr.test.ts b/services/kilo-mcp/src/auth/dcr.test.ts index fe53c45300..7dc0f61a7a 100644 --- a/services/kilo-mcp/src/auth/dcr.test.ts +++ b/services/kilo-mcp/src/auth/dcr.test.ts @@ -24,6 +24,7 @@ function createFakeOAuthStore(): OAuthStoreApi & { clients: Map { } return false; }, + async markCodeExpired(deviceAuthCode, nowIso) { + for (const [code, record] of codes) { + if ( + record.deviceAuthCode === deviceAuthCode && + record.status === 'pending' && + record.expiresAt > nowIso + ) { + codes.set(code, { ...record, status: 'expired' }); + return true; + } + } + return false; + }, async approveCode(deviceAuthCode, identity, nowIso) { for (const [code, record] of codes) { if ( diff --git a/services/kilo-mcp/src/oauth-pages/authorize-page.test.ts b/services/kilo-mcp/src/oauth-pages/authorize-page.test.ts index 10fdf275cf..17c2a033d9 100644 --- a/services/kilo-mcp/src/oauth-pages/authorize-page.test.ts +++ b/services/kilo-mcp/src/oauth-pages/authorize-page.test.ts @@ -73,6 +73,19 @@ function createFakeOAuthStore(): OAuthStoreApi & { } return false; }, + async markCodeExpired(deviceAuthCode, nowIso) { + for (const [code, record] of codes) { + if ( + record.deviceAuthCode === deviceAuthCode && + record.status === 'pending' && + record.expiresAt > nowIso + ) { + codes.set(code, { ...record, status: 'expired' }); + return true; + } + } + return false; + }, async approveCode(deviceAuthCode, identity, nowIso) { for (const [code, record] of codes) { if ( @@ -299,18 +312,41 @@ describe('GET /authorize/status (consent-page pairing poll)', () => { expect(fetchImpl).toHaveBeenCalledTimes(1); }); - it('an expired upstream pairing reports expired (retryable via restart)', async () => { + it('concurrent denied polls emit the failure only once', async () => { + const store = createFakeOAuthStore(); + const record = seedPendingCode(store); + const fetchImpl = upstreamFetch(() => Response.json({ status: 'denied' }, { status: 403 })); + const { analytics, calls } = fakeAnalytics(); + const deps = { store, webBaseUrl: WEB, fetchImpl, analytics }; + // Both requests read the same pending record before either persists the + // denial; only the transition winner may emit. + const responses = await Promise.all([ + handlePairingStatus(statusRequest(record.code), deps).then(r => r.json()), + handlePairingStatus(statusRequest(record.code), deps).then(r => r.json()), + ]); + expect(responses).toEqual([{ status: 'denied' }, { status: 'denied' }]); + expect(store.codes.get(record.code)?.status).toBe('denied'); + expect(calls).toHaveLength(1); + }); + + it('an expired upstream pairing reports expired once and persists the terminal state', async () => { const store = createFakeOAuthStore(); const record = seedPendingCode(store); const fetchImpl = upstreamFetch(() => Response.json({ status: 'expired' }, { status: 410 })); + const { analytics, calls } = fakeAnalytics(); + const deps = { store, webBaseUrl: WEB, fetchImpl, analytics }; await expect( - handlePairingStatus(statusRequest(record.code), { store, webBaseUrl: WEB, fetchImpl }).then( - r => r.json() - ) + handlePairingStatus(statusRequest(record.code), deps).then(r => r.json()) ).resolves.toEqual({ status: 'expired' }); - // The local record stays pending (no terminal store state for upstream - // expiry) so the page can still restart before its own TTL. - expect(store.codes.get(record.code)?.status).toBe('pending'); + // The terminal expiry is persisted so repeated polls answer from the + // record instead of re-emitting the failure. + expect(store.codes.get(record.code)?.status).toBe('expired'); + expect(calls).toHaveLength(1); + await expect( + handlePairingStatus(statusRequest(record.code), deps).then(r => r.json()) + ).resolves.toEqual({ status: 'expired' }); + expect(calls).toHaveLength(1); + expect(fetchImpl).toHaveBeenCalledTimes(1); }); it('an unreachable upstream keeps the page waiting instead of failing the flow', async () => { diff --git a/services/kilo-mcp/src/oauth-pages/authorize-page.ts b/services/kilo-mcp/src/oauth-pages/authorize-page.ts index ed22fb8508..bb8c210fa0 100644 --- a/services/kilo-mcp/src/oauth-pages/authorize-page.ts +++ b/services/kilo-mcp/src/oauth-pages/authorize-page.ts @@ -181,6 +181,11 @@ export async function handlePairingStatus( // the same record must answer denied without re-emitting. return authJsonResponse({ status: 'denied' } satisfies PairingStatus); } + if (record.status === 'expired') { + // Terminal state, recorded when the first poll learned of the upstream + // expiry; later polls answer expired without re-emitting. + return authJsonResponse({ status: 'expired' } satisfies PairingStatus); + } if (record.status === 'approved') { return clientRedirect(record); } @@ -196,23 +201,33 @@ export async function handlePairingStatus( // Transient upstream failure keeps the page waiting; its next poll retries. return authJsonResponse({ status: 'pending' } satisfies PairingStatus); case 'denied': { - await deps.store.denyCode(record.deviceAuthCode, nowIso); - deps.analytics?.oauthSignIn({ - phase: 'failed', - identity: null, - clientId: record.clientId, - reason: 'denied', - }); + // Emit only when THIS request won the pending -> denied transition; + // a concurrent poll that lost the race must not double-count. + const transitioned = await deps.store.denyCode(record.deviceAuthCode, nowIso); + if (transitioned) { + deps.analytics?.oauthSignIn({ + phase: 'failed', + identity: null, + clientId: record.clientId, + reason: 'denied', + }); + } return authJsonResponse({ status: 'denied' } satisfies PairingStatus); } - case 'expired': - deps.analytics?.oauthSignIn({ - phase: 'failed', - identity: null, - clientId: record.clientId, - reason: 'expired', - }); + case 'expired': { + // Persist the terminal expiry so every later poll answers from the + // record instead of re-emitting the failure. + const transitioned = await deps.store.markCodeExpired(record.deviceAuthCode, nowIso); + if (transitioned) { + deps.analytics?.oauthSignIn({ + phase: 'failed', + identity: null, + clientId: record.clientId, + reason: 'expired', + }); + } return authJsonResponse({ status: 'expired' } satisfies PairingStatus); + } case 'approved': { // Persist BEFORE any further poll: the upstream answer is single-use. await deps.store.recordPairingApproval( diff --git a/services/kilo-mcp/src/oauth-pages/org-picker.test.ts b/services/kilo-mcp/src/oauth-pages/org-picker.test.ts index 175db1139a..c84ba9de33 100644 --- a/services/kilo-mcp/src/oauth-pages/org-picker.test.ts +++ b/services/kilo-mcp/src/oauth-pages/org-picker.test.ts @@ -59,6 +59,7 @@ function createFakeOAuthStore(): OAuthStoreApi & { return false; }, denyCode: unused, + markCodeExpired: unused, async approveCode(deviceAuthCode, identity, nowIso) { for (const [code, record] of codes) { if ( diff --git a/services/kilo-mcp/src/oauth-pages/org-picker.ts b/services/kilo-mcp/src/oauth-pages/org-picker.ts index c65634b2da..b627ec312f 100644 --- a/services/kilo-mcp/src/oauth-pages/org-picker.ts +++ b/services/kilo-mcp/src/oauth-pages/org-picker.ts @@ -190,7 +190,8 @@ export async function handleOrgPicker(request: Request, deps: OrgPickerDeps): Pr !record || record.expiresAt <= nowIso || record.status === 'used' || - record.status === 'denied' + record.status === 'denied' || + record.status === 'expired' ) { return errorPage( 'invalid_request', diff --git a/services/kilo-mcp/src/store/oauth-store.test.ts b/services/kilo-mcp/src/store/oauth-store.test.ts index b8904dc5ad..964e494a02 100644 --- a/services/kilo-mcp/src/store/oauth-store.test.ts +++ b/services/kilo-mcp/src/store/oauth-store.test.ts @@ -279,6 +279,14 @@ describe('KiloMcpOAuthStore (real drizzle durable-sqlite over node:sqlite)', () expect(await store.denyCode('PAIR-DENY', NOW)).toBe(false); expect(await store.denyCode('PAIR-GHOST', NOW)).toBe(false); }); + + it('markCodeExpired moves a pending pairing to expired exactly once', async () => { + await store.createCode({ ...pairInput, code: 's6-expire', deviceAuthCode: 'PAIR-EXP' }); + expect(await store.markCodeExpired('PAIR-EXP', NOW)).toBe(true); + expect((await store.getCode('s6-expire'))?.status).toBe('expired'); + expect(await store.markCodeExpired('PAIR-EXP', NOW)).toBe(false); + expect(await store.markCodeExpired('PAIR-GHOST', NOW)).toBe(false); + }); }); describe('getKiloToken (forwarding credential, s6)', () => { diff --git a/services/kilo-mcp/src/store/oauth-store.ts b/services/kilo-mcp/src/store/oauth-store.ts index 89d026e52d..a811d418fe 100644 --- a/services/kilo-mcp/src/store/oauth-store.ts +++ b/services/kilo-mcp/src/store/oauth-store.ts @@ -47,7 +47,7 @@ export type NewOAuthClient = { createdAt: string; }; -export type OAuthCodeStatus = 'pending' | 'approved' | 'used' | 'denied'; +export type OAuthCodeStatus = 'pending' | 'approved' | 'used' | 'denied' | 'expired'; export type OAuthCodeRecord = { code: string; @@ -138,6 +138,8 @@ export interface OAuthStoreApi { ): Promise; /** pending -> denied after the user denied the Kilo pairing upstream (s6). */ denyCode(deviceAuthCode: string, nowIso: string): Promise; + /** pending -> expired after apps/web reported the pairing expired upstream. */ + markCodeExpired(deviceAuthCode: string, nowIso: string): Promise; /** pending -> approved with the Kilo identity; false when not exchangeable-pending. */ approveCode( deviceAuthCode: string, @@ -319,6 +321,22 @@ export class KiloMcpOAuthStore extends DurableObject implements OAuthStoreA return row !== undefined; } + async markCodeExpired(deviceAuthCode: string, nowIso: string): Promise { + const row = this.db + .update(oauthCodes) + .set({ status: 'expired' }) + .where( + and( + eq(oauthCodes.device_auth_code, deviceAuthCode), + eq(oauthCodes.status, 'pending'), + gt(oauthCodes.expires_at, nowIso) + ) + ) + .returning({ code: oauthCodes.code }) + .get(); + return row !== undefined; + } + async approveCode( deviceAuthCode: string, identity: { kiloUserId: string; organizationId: string | null },