diff --git a/apps/labeler/src/aggregator-client.ts b/apps/labeler/src/aggregator-client.ts new file mode 100644 index 0000000000..0d0b1a0c45 --- /dev/null +++ b/apps/labeler/src/aggregator-client.ts @@ -0,0 +1,139 @@ +/** + * Read-only client over the aggregator Worker's XRPC surface, reached via + * the `AGGREGATOR` service binding (fetch-over-binding; the aggregator is + * HTTP/XRPC-only with no RPC entrypoint). + * + * Each method performs exactly one `fetch` per call with no memoization, + * caching, or retry: internal consumers require a fresh read at call time + * (W10.4). Callers layer their own retry/backoff if they want it. + * + * Every request sends a BLANK `atproto-accept-labelers` header to obtain the + * genuinely unfiltered view. Counter-intuitively, OMITTING the header does the + * opposite: the aggregator resolves an absent header to its default policy + * (every trusted labeler, `redact: true`), so a subject redacted by any trusted + * labeler — plausibly including this labeler itself — presents as `NotFound` + * and is indistinguishable from an unindexed subject. A blank value parses to + * an empty accepted-labelers set, which the aggregator enforces as a no-op. + * The labeler is the moderation authority performing analysis; it must not be + * blinded to redacted subjects when resolving a takedown contact, reading + * history context, or re-assessing a flagged subject. + * + * Boundary: this unfiltered view is for INTERNAL ANALYSIS ONLY. Its results + * must never be surfaced to a public serving path without re-applying takedown + * enforcement at that layer (see the ratified sync.getRecord / artifact-mirror + * serving decisions) — an unfiltered read that reached a public surface would + * re-expose taken-down content. + * + * Views are returned as their `@emdash-cms/registry-lexicons` types without + * runtime validation: the aggregator is a first-party in-process binding, and + * the acquire stage independently re-verifies artifact checksums downstream. + * + * Error contract: a missing subject (XRPC `NotFound`, HTTP 404) resolves to + * `null`. Any other non-2xx status, or a transport failure, throws — callers + * may treat a throw as transient and retry the whole read. + */ + +import type { AggregatorDefs, AggregatorListReleases } from "@emdash-cms/registry-lexicons"; + +/** + * XRPC endpoint host. Irrelevant to routing — a service binding dispatches by + * binding, not DNS — but `fetch` needs a syntactically valid absolute URL. + */ +const XRPC_BASE = "https://aggregator/xrpc"; + +/** + * ATProto label-negotiation header. Sent with a blank value on every read to + * opt out of the aggregator's default trusted-labeler redaction (see the + * module doc). Mirrors the aggregator's own local constant; this is a stable + * protocol string, not currently exported from a shared package. + */ +const ACCEPT_LABELERS_HEADER = "atproto-accept-labelers"; + +/** NSIDs are fixed per method and never derived from caller data. Only query + * param *values* carry caller data, and every value is `encodeURIComponent`- + * encoded in {@link buildUrl}; the param *keys* are hard-coded literals. There + * is therefore no path for caller data to alter the target host or path. */ +const NSID = { + getPackage: "com.emdashcms.experimental.aggregator.getPackage", + getLatestRelease: "com.emdashcms.experimental.aggregator.getLatestRelease", + listReleases: "com.emdashcms.experimental.aggregator.listReleases", +} as const; + +/** Build an XRPC GET URL from a constant NSID and caller-supplied param + * values. Keys are the caller's fixed literals; each value is URL-encoded. */ +function buildUrl(nsid: string, params: Record): string { + const query = Object.entries(params) + .map(([key, value]) => `${key}=${encodeURIComponent(value)}`) + .join("&"); + return `${XRPC_BASE}/${nsid}?${query}`; +} + +/** The `error` field of an XRPC error envelope, or undefined if the body + * isn't a recognisable `{ error }` object. */ +function parseXrpcErrorName(body: string): string | undefined { + try { + const parsed: unknown = JSON.parse(body); + if (parsed && typeof parsed === "object" && "error" in parsed) { + const { error } = parsed; + if (typeof error === "string") return error; + } + } catch { + // Non-JSON error body (e.g. an infra-level 502); fall through to throw. + } + return undefined; +} + +export class AggregatorClient { + readonly #fetcher: Fetcher; + + constructor(fetcher: Fetcher) { + this.#fetcher = fetcher; + } + + /** Fetch the package view for `(did, slug)`, or `null` if not indexed. */ + async getPackage(did: string, slug: string): Promise { + const url = buildUrl(NSID.getPackage, { did, slug }); + return this.#query(NSID.getPackage, url); + } + + /** Fetch the highest-precedence live release for `(did, pkg)`, or `null` + * if the package has no eligible release. */ + async getLatestRelease(did: string, pkg: string): Promise { + const url = buildUrl(NSID.getLatestRelease, { did, package: pkg }); + return this.#query(NSID.getLatestRelease, url); + } + + /** List releases for `(did, pkg)`, newest first, one page per call. + * Returns `null` if the parent package isn't indexed. */ + async listReleases( + did: string, + pkg: string, + cursor?: string, + ): Promise { + const params: Record = { did, package: pkg }; + // Only a non-empty cursor is a page token; an empty string would decode as + // a malformed cursor (the aggregator 400s), so treat it as "first page". + if (cursor) params["cursor"] = cursor; + const url = buildUrl(NSID.listReleases, params); + return this.#query(NSID.listReleases, url); + } + + /** One fetch, no retry. `NotFound` → `null`; any other non-2xx or a + * transport failure throws. */ + async #query(nsid: string, url: string): Promise { + const response = await this.#fetcher.fetch(url, { + headers: { [ACCEPT_LABELERS_HEADER]: "" }, + }); + if (response.ok) { + return response.json(); + } + const body = await response.text(); + const errorName = parseXrpcErrorName(body); + if (response.status === 404 && errorName === "NotFound") { + return null; + } + throw new Error( + `Aggregator ${nsid} failed: ${response.status}${errorName ? ` ${errorName}` : ""}`, + ); + } +} diff --git a/apps/labeler/test/aggregator-client.test.ts b/apps/labeler/test/aggregator-client.test.ts new file mode 100644 index 0000000000..c237e1ff6b --- /dev/null +++ b/apps/labeler/test/aggregator-client.test.ts @@ -0,0 +1,198 @@ +import { env } from "cloudflare:test"; +import { describe, expect, it } from "vitest"; + +import { AggregatorClient } from "../src/aggregator-client.js"; + +/** Records every URL and request init the client fetches and returns a + * caller-supplied Response (or throws, to simulate a transport failure). */ +function mockFetcher(handler: (url: string) => Response) { + const urls: string[] = []; + const inits: (RequestInit | undefined)[] = []; + const fetcher = { + fetch: (input: string, init?: RequestInit) => { + urls.push(input); + inits.push(init); + return Promise.resolve(handler(input)); + }, + } as unknown as Fetcher; + return { fetcher, urls, inits }; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function notFoundResponse(): Response { + return jsonResponse({ error: "NotFound", message: "No package indexed." }, 404); +} + +const BASE = "https://aggregator/xrpc"; + +const PACKAGE_VIEW = { + uri: "at://did:plc:abc123/com.emdashcms.experimental.package.profile/my-plugin", + cid: "bafyreib2rxk3rybk3examplecidvalue", + did: "did:plc:abc123", + slug: "my-plugin", + profile: { + $type: "com.emdashcms.experimental.package.profile", + id: "at://did:plc:abc123/com.emdashcms.experimental.package.profile/my-plugin", + type: "plugin", + license: "MIT", + authors: [{ name: "Ada", handle: "ada.example" }], + security: [{ contact: "security@example.test" }], + slug: "my-plugin", + }, + indexedAt: "2026-07-01T00:00:00.000Z", + labels: [], + latestVersion: "1.2.0", +}; + +const RELEASE_VIEW = { + uri: "at://did:plc:abc123/com.emdashcms.experimental.package.release/1.2.0", + cid: "bafyreirelease123examplecidvalue", + did: "did:plc:abc123", + package: "my-plugin", + version: "1.2.0", + release: { + $type: "com.emdashcms.experimental.package.release", + package: "my-plugin", + version: "1.2.0", + artifacts: { + tarball: { + url: "https://cdn.example/my-plugin-1.2.0.tgz", + checksum: "sha256-0123456789abcdef", + }, + }, + }, + mirrors: [], + indexedAt: "2026-07-01T00:00:00.000Z", + labels: [], +}; + +describe("AggregatorClient.getPackage", () => { + it("builds the getPackage URL with URL-encoded params and parses the view", async () => { + const { fetcher, urls } = mockFetcher(() => jsonResponse(PACKAGE_VIEW)); + const view = await new AggregatorClient(fetcher).getPackage("did:plc:abc123", "my-plugin"); + + expect(urls).toEqual([ + `${BASE}/com.emdashcms.experimental.aggregator.getPackage?did=did%3Aplc%3Aabc123&slug=my-plugin`, + ]); + expect(view).toEqual(PACKAGE_VIEW); + }); + + it("returns null on a NotFound (404) error", async () => { + const { fetcher } = mockFetcher(() => notFoundResponse()); + const view = await new AggregatorClient(fetcher).getPackage("did:plc:abc123", "missing"); + expect(view).toBeNull(); + }); + + it("sends a blank atproto-accept-labelers header to opt out of default redaction", async () => { + const { fetcher, inits } = mockFetcher(() => jsonResponse(PACKAGE_VIEW)); + await new AggregatorClient(fetcher).getPackage("did:plc:abc123", "my-plugin"); + + const headers = new Headers(inits[0]?.headers); + expect(headers.get("atproto-accept-labelers")).toBe(""); + }); + + it("throws on a 5xx response", async () => { + const { fetcher } = mockFetcher(() => new Response("upstream boom", { status: 500 })); + await expect( + new AggregatorClient(fetcher).getPackage("did:plc:abc123", "my-plugin"), + ).rejects.toThrow(/getPackage failed: 500/); + }); + + it("throws when the binding fetch rejects (transport failure)", async () => { + const fetcher = { + fetch: () => Promise.reject(new Error("binding unreachable")), + } as unknown as Fetcher; + await expect( + new AggregatorClient(fetcher).getPackage("did:plc:abc123", "my-plugin"), + ).rejects.toThrow("binding unreachable"); + }); +}); + +describe("AggregatorClient.getLatestRelease", () => { + it("builds the getLatestRelease URL with a `package` param and parses the view", async () => { + const { fetcher, urls } = mockFetcher(() => jsonResponse(RELEASE_VIEW)); + const view = await new AggregatorClient(fetcher).getLatestRelease( + "did:plc:abc123", + "my-plugin", + ); + + expect(urls).toEqual([ + `${BASE}/com.emdashcms.experimental.aggregator.getLatestRelease?did=did%3Aplc%3Aabc123&package=my-plugin`, + ]); + expect(view).toEqual(RELEASE_VIEW); + }); + + it("returns null on a NotFound (404) error", async () => { + const { fetcher } = mockFetcher(() => notFoundResponse()); + const view = await new AggregatorClient(fetcher).getLatestRelease("did:plc:abc123", "missing"); + expect(view).toBeNull(); + }); +}); + +describe("AggregatorClient.listReleases", () => { + it("omits the cursor param when none is given and parses the page", async () => { + const page = { releases: [RELEASE_VIEW], cursor: "next-page-cursor" }; + const { fetcher, urls } = mockFetcher(() => jsonResponse(page)); + const result = await new AggregatorClient(fetcher).listReleases("did:plc:abc123", "my-plugin"); + + expect(urls).toEqual([ + `${BASE}/com.emdashcms.experimental.aggregator.listReleases?did=did%3Aplc%3Aabc123&package=my-plugin`, + ]); + expect(result).toEqual(page); + }); + + it("appends a URL-encoded cursor param when given", async () => { + const { fetcher, urls } = mockFetcher(() => jsonResponse({ releases: [] })); + await new AggregatorClient(fetcher).listReleases("did:plc:abc123", "my-plugin", "abc=="); + + expect(urls).toEqual([ + `${BASE}/com.emdashcms.experimental.aggregator.listReleases?did=did%3Aplc%3Aabc123&package=my-plugin&cursor=abc%3D%3D`, + ]); + }); + + it("omits the cursor param for an empty-string cursor (first page, not a 400)", async () => { + const { fetcher, urls } = mockFetcher(() => jsonResponse({ releases: [] })); + await new AggregatorClient(fetcher).listReleases("did:plc:abc123", "my-plugin", ""); + + expect(urls).toEqual([ + `${BASE}/com.emdashcms.experimental.aggregator.listReleases?did=did%3Aplc%3Aabc123&package=my-plugin`, + ]); + }); + + it("returns null when the parent package is NotFound", async () => { + const { fetcher } = mockFetcher(() => notFoundResponse()); + const result = await new AggregatorClient(fetcher).listReleases("did:plc:abc123", "missing"); + expect(result).toBeNull(); + }); +}); + +describe("AggregatorClient param encoding", () => { + it("percent-encodes reserved characters so they cannot alter the query", async () => { + const { fetcher, urls } = mockFetcher(() => jsonResponse(PACKAGE_VIEW)); + await new AggregatorClient(fetcher).getPackage("did:plc:a&b c", "s/l&ug"); + + expect(urls).toEqual([ + `${BASE}/com.emdashcms.experimental.aggregator.getPackage?did=did%3Aplc%3Aa%26b%20c&slug=s%2Fl%26ug`, + ]); + }); +}); + +describe("AGGREGATOR binding transport", () => { + // The unfiltered-read contract depends on the blank atproto-accept-labelers + // header surviving the service-binding hop: if workerd dropped an + // empty-valued header, the aggregator would fall back to default redaction + // and return NotFound for redacted subjects, silently blinding analysis. + // The vitest AGGREGATOR stub echoes the inbound header as a marker. + it("delivers a blank accept-labelers header across the service binding", async () => { + const response = await env.AGGREGATOR.fetch("https://aggregator/xrpc/probe", { + headers: { "atproto-accept-labelers": "" }, + }); + expect(response.headers.get("x-test-accept-labelers")).toBe("empty"); + }); +}); diff --git a/apps/labeler/vitest.config.ts b/apps/labeler/vitest.config.ts index 9cf0d5dc6b..cb4adca95d 100644 --- a/apps/labeler/vitest.config.ts +++ b/apps/labeler/vitest.config.ts @@ -22,6 +22,24 @@ export default defineConfig({ LABEL_SIGNING_PRIVATE_KEY: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAE", NOTIFICATION_HASH_PEPPER: "test-notification-hash-pepper", }, + // wrangler.jsonc declares an AGGREGATOR service binding to the + // aggregator Worker, which doesn't exist in the test runtime. + // Stub it so miniflare can start; most tests inject their own mock + // Fetcher rather than using this binding. The stub still fails loud + // (501) for an accidental call, and echoes the inbound + // atproto-accept-labelers header as a marker so a binding-transport + // test can prove the client's blank value survives the hop (a + // dropped empty header would read `absent`, not `empty`). + serviceBindings: { + AGGREGATOR: (request) => { + const raw = request.headers.get("atproto-accept-labelers"); + const marker = raw === null ? "absent" : raw === "" ? "empty" : `value:${raw}`; + return new Response("AGGREGATOR is stubbed in tests", { + status: 501, + headers: { "x-test-accept-labelers": marker }, + }); + }, + }, }, }), ], diff --git a/apps/labeler/worker-configuration.d.ts b/apps/labeler/worker-configuration.d.ts index cba90b0aca..5d654f933f 100644 --- a/apps/labeler/worker-configuration.d.ts +++ b/apps/labeler/worker-configuration.d.ts @@ -1,6 +1,6 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: ccfd2a4d903b4c76107d4323cc123869) -// Runtime types generated with workerd@1.20260611.1 2026-02-24 nodejs_compat +// Generated by Wrangler by running `wrangler types` (hash: e21c438219f0e3a8c154bb09b450524b) +// Runtime types generated with workerd@1.20260708.1 2026-02-24 nodejs_compat interface __BaseEnv_Env { EVIDENCE: R2Bucket; DB: D1Database; @@ -17,6 +17,7 @@ interface __BaseEnv_Env { NOTIFICATION_HASH_PEPPER: string; LABEL_SUBSCRIPTION: DurableObjectNamespace; LABELER_DISCOVERY_DO: DurableObjectNamespace; + AGGREGATOR: Fetcher /* emdash-aggregator */; } declare namespace Cloudflare { interface GlobalProps { @@ -456,7 +457,8 @@ interface ExecutionContext { readonly exports: Cloudflare.Exports; readonly props: Props; cache?: CacheContext; - tracing?: Tracing; + readonly access?: CloudflareAccessContext; + tracing: Tracing; } type ExportedHandlerFetchHandler = (request: Request>, env: Env, ctx: ExecutionContext) => Response | Promise; type ExportedHandlerConnectHandler = (socket: Socket, env: Env, ctx: ExecutionContext) => void | Promise; @@ -512,6 +514,10 @@ interface CachePurgeOptions { interface CacheContext { purge(options: CachePurgeOptions): Promise; } +interface CloudflareAccessContext { + readonly aud: string; + getIdentity(): Promise; +} declare abstract class ColoLocalActorNamespace { get(actorId: string): Fetcher; } @@ -541,11 +547,11 @@ declare abstract class DurableObjectNamespace; jurisdiction(jurisdiction: DurableObjectJurisdiction): DurableObjectNamespace; } -type DurableObjectJurisdiction = "eu" | "fedramp" | "fedramp-high"; +type DurableObjectJurisdiction = "eu" | "fedramp" | "fedramp-high" | "us"; interface DurableObjectNamespaceNewUniqueIdOptions { jurisdiction?: DurableObjectJurisdiction; } -type DurableObjectLocationHint = "wnam" | "enam" | "sam" | "weur" | "eeur" | "apac" | "oc" | "afr" | "me"; +type DurableObjectLocationHint = "wnam" | "enam" | "sam" | "weur" | "eeur" | "apac" | "apac-ne" | "apac-se" | "oc" | "afr" | "me"; type DurableObjectRoutingMode = "primary-only"; interface DurableObjectNamespaceGetDurableObjectOptions { locationHint?: DurableObjectLocationHint; @@ -641,6 +647,7 @@ interface DurableObjectFacets { get(name: string, getStartupOptions: () => FacetStartupOptions | Promise>): Fetcher; abort(name: string, reason: any): void; delete(name: string): void; + clone(src: string, dst: string): void; } interface FacetStartupOptions { id?: DurableObjectId | string; @@ -3316,6 +3323,28 @@ interface EventSourceEventSourceInit { withCredentials?: boolean; fetcher?: Fetcher; } +interface ExecOutput { + readonly stdout: ArrayBuffer; + readonly stderr: ArrayBuffer; + readonly exitCode: number; +} +interface ContainerExecOptions { + cwd?: string; + env?: Record; + user?: string; + stdin?: ReadableStream | "pipe"; + stdout?: "pipe" | "ignore"; + stderr?: "pipe" | "ignore" | "combined"; +} +interface ExecProcess { + readonly stdin: WritableStream | null; + readonly stdout: ReadableStream | null; + readonly stderr: ReadableStream | null; + readonly pid: number; + readonly exitCode: Promise; + output(): Promise; + kill(signal?: number): void; +} interface Container { get running(): boolean; start(options?: ContainerStartupOptions): void; @@ -3329,6 +3358,7 @@ interface Container { snapshotDirectory(options: ContainerDirectorySnapshotOptions): Promise; snapshotContainer(options: ContainerSnapshotOptions): Promise; interceptOutboundHttps(addr: string, binding: Fetcher): Promise; + exec(cmd: string[], options?: ContainerExecOptions): Promise; } interface ContainerDirectorySnapshot { id: string; @@ -3499,11 +3529,58 @@ 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; Span: typeof Span; } declare abstract class Span { get isTraced(): boolean; setAttribute(key: string, value?: (boolean | number | string)): void; + end(): void; +} +/** + * Represents the identity of a user authenticated via Cloudflare Access. + * This matches the result of calling /cdn-cgi/access/get-identity. + * + * The exact structure of the returned object depends on the identity provider + * configuration for the Access application. The fields below represent commonly + * available properties, but additional provider-specific fields may be present. + */ +interface CloudflareAccessIdentity extends Record { + /** The user's email address, if available from the identity provider. */ + email?: string; + /** The user's display name. */ + name?: string; + /** The user's unique identifier. */ + user_uuid?: string; + /** The Cloudflare account ID. */ + account_id?: string; + /** Login timestamp (Unix epoch seconds). */ + iat?: number; + /** The user's IP address at authentication time. */ + ip?: string; + /** Authentication methods used (e.g., "pwd"). */ + amr?: string[]; + /** Identity provider information. */ + idp?: { + id: string; + type: string; + }; + /** Geographic information about where the user authenticated. */ + geo?: { + country: string; + }; + /** Group memberships from the identity provider. */ + groups?: Array<{ + id: string; + name: string; + email?: string; + }>; + /** Device posture check results, keyed by check ID. */ + devicePosture?: Record; + /** True if the user connected via Cloudflare WARP. */ + is_warp?: boolean; + /** True if the user is authenticated via Cloudflare Gateway. */ + is_gateway?: boolean; } // ============================================================================ // Agent Memory @@ -11096,78 +11173,6 @@ declare abstract class BrowserRun { */ quickAction(action: 'markdown', options: BrowserRunMarkdownOptions): Promise; } -interface BasicImageTransformations { - /** - * Maximum width in image pixels. The value must be an integer. - */ - width?: number; - /** - * Maximum height in image pixels. The value must be an integer. - */ - height?: number; - /** - * Resizing mode as a string. It affects interpretation of width and height - * options: - * - scale-down: Similar to contain, but the image is never enlarged. If - * the image is larger than given width or height, it will be resized. - * Otherwise its original size will be kept. - * - contain: Resizes to maximum size that fits within the given width and - * height. If only a single dimension is given (e.g. only width), the - * image will be shrunk or enlarged to exactly match that dimension. - * Aspect ratio is always preserved. - * - cover: Resizes (shrinks or enlarges) to fill the entire area of width - * and height. If the image has an aspect ratio different from the ratio - * of width and height, it will be cropped to fit. - * - crop: The image will be shrunk and cropped to fit within the area - * specified by width and height. The image will not be enlarged. For images - * smaller than the given dimensions it's the same as scale-down. For - * images larger than the given dimensions, it's the same as cover. - * See also trim. - * - pad: Resizes to the maximum size that fits within the given width and - * height, and then fills the remaining area with a background color - * (white by default). Use of this mode is not recommended, as the same - * effect can be more efficiently achieved with the contain mode and the - * CSS object-fit: contain property. - * - squeeze: Stretches and deforms to the width and height given, even if it - * breaks aspect ratio - */ - fit?: "scale-down" | "contain" | "cover" | "crop" | "pad" | "squeeze"; - /** - * Image segmentation using artificial intelligence models. Sets pixels not - * within selected segment area to transparent e.g "foreground" sets every - * background pixel as transparent. - */ - segment?: "foreground"; - /** - * When cropping with fit: "cover", this defines the side or point that should - * be left uncropped. The value is either a string - * "left", "right", "top", "bottom", "auto", or "center" (the default), - * or an object {x, y} containing focal point coordinates in the original - * image expressed as fractions ranging from 0.0 (top or left) to 1.0 - * (bottom or right), 0.5 being the center. {fit: "cover", gravity: "top"} will - * crop bottom or left and right sides as necessary, but won’t crop anything - * from the top. {fit: "cover", gravity: {x:0.5, y:0.2}} will crop each side to - * preserve as much as possible around a point at 20% of the height of the - * source image. - */ - gravity?: 'face' | 'left' | 'right' | 'top' | 'bottom' | 'center' | 'auto' | 'entropy' | BasicImageTransformationsGravityCoordinates; - /** - * Background color to add underneath the image. Applies only to images with - * transparency (such as PNG). Accepts any CSS color (#RRGGBB, rgba(…), - * hsl(…), etc.) - */ - background?: string; - /** - * Number of degrees (90, 180, 270) to rotate the image by. width and height - * options refer to axes after rotation. - */ - rotate?: 0 | 90 | 180 | 270 | 360; -} -interface BasicImageTransformationsGravityCoordinates { - x?: number; - y?: number; - mode?: 'remainder' | 'box-center'; -} /** * In addition to the properties you can set in the RequestInit dict * that you pass as an argument to the Request constructor, you can @@ -11206,6 +11211,8 @@ interface RequestInitCfProperties extends Record { * (e.g. { '200-299': 86400, '404': 1, '500-599': 0 }) */ cacheTtlByStatus?: Record; + /** Controls how responses with a `Vary` header are cached for this request. */ + vary?: RequestInitCfPropertiesVary; /** * Explicit Cache-Control header value to set on the response stored in cache. * This gives full control over cache directives (e.g. 'public, max-age=3600, s-maxage=86400'). @@ -11243,6 +11250,17 @@ interface RequestInitCfProperties extends Record { cacheReserveMinimumFileSize?: number; scrapeShield?: boolean; apps?: boolean; + /** + * Controls whether an outbound gRPC-web subrequest from this Worker is + * converted to gRPC at the Cloudflare edge. + * + * - `"passthrough"`: forward the subrequest unchanged as gRPC-web (default). + * - `"convert"`: convert the gRPC-web subrequest to gRPC at the edge. + * + * Provides per-request control over the same edge conversion behavior + * gated by the `auto_grpc_convert` compatibility flag. + */ + grpcWeb?: "passthrough" | "convert"; image?: RequestInitCfPropertiesImage; minify?: RequestInitCfPropertiesImageMinify; mirage?: boolean; @@ -11263,6 +11281,244 @@ interface RequestInitCfProperties extends Record { */ resolveOverride?: string; } +/** + * Controls how Workers Standard Vary handles a request header listed by an + * origin `Vary` response header: + * + * - `"normalize"`: normalize the request header value before it is used in the + * cache variance key. + * - `"passthrough"`: use the raw request header value in the cache variance + * key. + * - `"bypass"`: bypass cache when the header appears in the origin `Vary` + * response header. + */ +type RequestInitCfPropertiesVaryAction = "normalize" | "passthrough" | "bypass"; +/** Configuration for Workers Standard Vary support. */ +interface RequestInitCfPropertiesVary { + /** The fallback action for varied request headers not listed in `headers`. */ + default: RequestInitCfPropertiesVaryHeader; + /** + * Lowercase request header names and their Vary configuration. + * + * The `accept` header can include `media_types`, the `accept-language` + * header can include `languages`, and other headers support only `action`. + */ + headers?: RequestInitCfPropertiesVaryHeaders; +} +/** Common Vary behavior for a single request header. */ +interface RequestInitCfPropertiesVaryHeader { + /** How this request header contributes to cache variance. */ + action: RequestInitCfPropertiesVaryAction; +} +/** Vary behavior for the `accept` request header. */ +interface RequestInitCfPropertiesVaryAcceptHeader extends RequestInitCfPropertiesVaryHeader { + /** + * Media types to keep when normalizing the `Accept` request header. + * + * Named `media_types` to match the serialized `cf.vary` configuration. + */ + media_types?: string[]; +} +/** Vary behavior for the `accept-language` request header. */ +interface RequestInitCfPropertiesVaryAcceptLanguageHeader extends RequestInitCfPropertiesVaryHeader { + /** + * Language tags to keep when normalizing the `Accept-Language` request + * header. + */ + languages?: string[]; +} +/** + * Lowercase request header names and their Vary behavior. + * + * The index signature allows arbitrary custom request headers beyond the + * well-known `accept` and `accept-language` specializations. + */ +interface RequestInitCfPropertiesVaryHeaders { + accept?: RequestInitCfPropertiesVaryAcceptHeader; + "accept-language"?: RequestInitCfPropertiesVaryAcceptLanguageHeader; + [header: string]: RequestInitCfPropertiesVaryHeader | RequestInitCfPropertiesVaryAcceptHeader | RequestInitCfPropertiesVaryAcceptLanguageHeader | undefined; +} +interface BasicImageTransformations { + /** + * Maximum width in image pixels. The value must be an integer. + */ + width?: number; + /** + * Maximum height in image pixels. The value must be an integer. + */ + height?: number; + /** + * When cropping with fit: "cover", this defines the side or point that should + * be left uncropped. The value is either a string + * "left", "right", "top", "bottom", "auto", or "center" (the default), + * or an object {x, y} containing focal point coordinates in the original + * image expressed as fractions ranging from 0.0 (top or left) to 1.0 + * (bottom or right), 0.5 being the center. {fit: "cover", gravity: "top"} will + * crop bottom or left and right sides as necessary, but won’t crop anything + * from the top. {fit: "cover", gravity: {x:0.5, y:0.2}} will crop each side to + * preserve as much as possible around a point at 20% of the height of the + * source image. + */ + gravity?: 'face' | 'left' | 'right' | 'top' | 'bottom' | 'center' | 'auto' | 'entropy' | BasicImageTransformationsGravityCoordinates; + /** + * Specifies how closely the image is cropped toward detected faces when combined + * with the gravity=face option. Accepts a valid range between 0.0 (includes as much + * of the background as possible) and 1.0 (crops the image as closely to the face as + * possible). The default is 0. + */ + zoom?: number; + /** + * Resizing mode as a string. It affects interpretation of width and height + * options: + * - scale-down: Similar to contain, but the image is never enlarged. If + * the image is larger than given width or height, it will be resized. + * Otherwise its original size will be kept. + * - scale-up: Similar to contain, but the image is never shrunk. If the + * image is smaller than the given width or height, it will be resized. + * Otherwise its original size will be kept. + * - contain: Resizes to maximum size that fits within the given width and + * height. If only a single dimension is given (e.g. only width), the + * image will be shrunk or enlarged to exactly match that dimension. + * Aspect ratio is always preserved. + * - cover: Resizes (shrinks or enlarges) to fill the entire area of width + * and height. If the image has an aspect ratio different from the ratio + * of width and height, it will be cropped to fit. + * - crop: The image will be shrunk and cropped to fit within the area + * specified by width and height. The image will not be enlarged. For images + * smaller than the given dimensions it's the same as scale-down. For + * images larger than the given dimensions, it's the same as cover. + * See also trim. + * - pad: Resizes to the maximum size that fits within the given width and + * height, and then fills the remaining area with a background color + * (white by default). Use of this mode is not recommended, as the same + * effect can be more efficiently achieved with the contain mode and the + * CSS object-fit: contain property. + * - squeeze: Stretches and deforms to the width and height given, even if it + * breaks aspect ratio + */ + fit?: "scale-down" | "scale-up" | "contain" | "cover" | "crop" | "pad" | "squeeze"; + /** + * Allows you to trim your image. Takes dpr into account and is performed before + * resizing or rotation. + * + * It can be used as: + * - left, top, right, bottom - it will specify the number of pixels to cut + * off each side + * - width, height - the width/height you'd like to end up with - can be used + * in combination with the properties above + * - border - this will automatically trim the surroundings of an image based on + * it's color. It consists of three properties: + * - color: rgb or hex representation of the color you wish to trim (todo: verify the rgba bit) + * - tolerance: difference from color to treat as color + * - keep: the number of pixels of border to keep + */ + trim?: "border" | { + top?: number; + bottom?: number; + left?: number; + right?: number; + width?: number; + height?: number; + border?: boolean | { + color?: string; + tolerance?: number; + keep?: number; + }; + }; + /** + * Background color to add underneath the image. Applies only to images with + * transparency (such as PNG). Accepts any CSS color (#RRGGBB, rgba(…), + * hsl(…), etc.) + */ + background?: string; + /** + * Flips the images horizontally, vertically, or both. Flipping is applied before + * rotation, so if you apply flip=h,rotate=90 then the image will be flipped + * horizontally, then rotated by 90 degrees. + */ + flip?: 'h' | 'v' | 'hv'; + /** + * Number of degrees (90, 180, 270) to rotate the image by. width and height + * options refer to axes after rotation. + */ + rotate?: 0 | 90 | 180 | 270 | 360; + /** + * Strength of sharpening filter to apply to the image. Floating-point + * number between 0 (no sharpening, default) and 10 (maximum). 1.0 is a + * recommended value for downscaled images. + */ + sharpen?: number; + /** + * Radius of a blur filter (approximate gaussian). Maximum supported radius + * is 250. + */ + blur?: number; + /** + * Increase contrast by a factor. A value of 1.0 equals no change, a value of + * 0.5 equals low contrast, and a value of 2.0 equals high contrast. 0 is + * ignored. + */ + contrast?: number; + /** + * Increase brightness by a factor. A value of 1.0 equals no change, a value + * of 0.5 equals half brightness, and a value of 2.0 equals twice as bright. + * 0 is ignored. + */ + brightness?: number; + /** + * Increase exposure by a factor. A value of 1.0 equals no change, a value of + * 0.5 darkens the image, and a value of 2.0 lightens the image. 0 is ignored. + */ + gamma?: number; + /** + * Increase contrast by a factor. A value of 1.0 equals no change, a value of + * 0.5 equals low contrast, and a value of 2.0 equals high contrast. 0 is + * ignored. + */ + saturation?: number; + /** + * Device Pixel Ratio. Default 1. Multiplier for width/height that makes it + * easier to specify higher-DPI sizes in . + */ + dpr?: number; + /** + * Adds a border around the image. The border is added after resizing. Border + * width takes dpr into account, and can be specified either using a single + * width property, or individually for each side. + */ + border?: { + color: string; + width: number; + } | { + color: string; + top: number; + right: number; + bottom: number; + left: number; + }; + /** + * Image segmentation using artificial intelligence models. Sets pixels not + * within selected segment area to transparent e.g "foreground" sets every + * background pixel as transparent. + */ + segment?: "foreground"; + /** + * Controls the algorithm used when an image needs to be enlarged. This + * parameter works with any fit mode that upscales, such as `contain`, + * `cover`, and `scale-up`. It has no effect when `fit=scale-down` or when + * the target dimensions are smaller than the source. + * - interpolate: Uses bicubic interpolation, which may reduce image quality. + * This is the default behavior when `upscale` is not specified. + * - generate: Uses AI upscaling to produce sharper, more detailed results + * when enlarging images. + */ + upscale?: "interpolate" | "generate"; +} +interface BasicImageTransformationsGravityCoordinates { + x?: number; + y?: number; + mode?: 'remainder' | 'box-center'; +} interface RequestInitCfPropertiesImageDraw extends BasicImageTransformations { /** * Absolute URL of the image file to use for the drawing. It can be any of @@ -11317,39 +11573,6 @@ interface RequestInitCfPropertiesImageDraw extends BasicImageTransformations { right?: number; } interface RequestInitCfPropertiesImage extends BasicImageTransformations { - /** - * Device Pixel Ratio. Default 1. Multiplier for width/height that makes it - * easier to specify higher-DPI sizes in . - */ - dpr?: number; - /** - * Allows you to trim your image. Takes dpr into account and is performed before - * resizing or rotation. - * - * It can be used as: - * - left, top, right, bottom - it will specify the number of pixels to cut - * off each side - * - width, height - the width/height you'd like to end up with - can be used - * in combination with the properties above - * - border - this will automatically trim the surroundings of an image based on - * it's color. It consists of three properties: - * - color: rgb or hex representation of the color you wish to trim (todo: verify the rgba bit) - * - tolerance: difference from color to treat as color - * - keep: the number of pixels of border to keep - */ - trim?: "border" | { - top?: number; - bottom?: number; - left?: number; - right?: number; - width?: number; - height?: number; - border?: boolean | { - color?: string; - tolerance?: number; - keep?: number; - }; - }; /** * Quality setting from 1-100 (useful values are in 60-90 range). Lower values * make images look worse, but load faster. The default is 85. It applies only @@ -11391,17 +11614,6 @@ interface RequestInitCfPropertiesImage extends BasicImageTransformations { * output formats always discard metadata. */ metadata?: "keep" | "copyright" | "none"; - /** - * Strength of sharpening filter to apply to the image. Floating-point - * number between 0 (no sharpening, default) and 10 (maximum). 1.0 is a - * recommended value for downscaled images. - */ - sharpen?: number; - /** - * Radius of a blur filter (approximate gaussian). Maximum supported radius - * is 250. - */ - blur?: number; /** * Overlays are drawn in the order they appear in the array (last array * entry is the topmost layer). @@ -11413,50 +11625,6 @@ interface RequestInitCfPropertiesImage extends BasicImageTransformations { * the origin. */ "origin-auth"?: "share-publicly"; - /** - * Adds a border around the image. The border is added after resizing. Border - * width takes dpr into account, and can be specified either using a single - * width property, or individually for each side. - */ - border?: { - color: string; - width: number; - } | { - color: string; - top: number; - right: number; - bottom: number; - left: number; - }; - /** - * Increase brightness by a factor. A value of 1.0 equals no change, a value - * of 0.5 equals half brightness, and a value of 2.0 equals twice as bright. - * 0 is ignored. - */ - brightness?: number; - /** - * Increase contrast by a factor. A value of 1.0 equals no change, a value of - * 0.5 equals low contrast, and a value of 2.0 equals high contrast. 0 is - * ignored. - */ - contrast?: number; - /** - * Increase exposure by a factor. A value of 1.0 equals no change, a value of - * 0.5 darkens the image, and a value of 2.0 lightens the image. 0 is ignored. - */ - gamma?: number; - /** - * Increase contrast by a factor. A value of 1.0 equals no change, a value of - * 0.5 equals low contrast, and a value of 2.0 equals high contrast. 0 is - * ignored. - */ - saturation?: number; - /** - * Flips the images horizontally, vertically, or both. Flipping is applied before - * rotation, so if you apply flip=h,rotate=90 then the image will be flipped - * horizontally, then rotated by 90 degrees. - */ - flip?: 'h' | 'v' | 'hv'; /** * Slightly reduces latency on a cache miss by selecting a * quickest-to-compress file format, at a cost of increased file size and @@ -12074,6 +12242,13 @@ interface ForwardableEmailMessage extends EmailMessage { * @returns A promise that resolves when the email message is replied. */ reply(message: EmailMessage): Promise; + /** + * Reply to the sender of this email message with a message built from the given + * fields. Threading headers (In-Reply-To/References) are set automatically. + * @param builder The reply message contents. + * @returns A promise that resolves when the email message is replied. + */ + reply(builder: EmailReplyMessageBuilder): Promise; } /** A file attachment for an email message */ type EmailAttachment = { @@ -12094,23 +12269,46 @@ interface EmailAddress { name: string; email: string; } +/** + * Recipient fields for `SendEmail.send()`. At least one of `to`, `cc`, or + * `bcc` must be provided. + */ +type EmailDestinations = { + to?: string | EmailAddress | (string | EmailAddress)[]; + cc?: string | EmailAddress | (string | EmailAddress)[]; + bcc?: string | EmailAddress | (string | EmailAddress)[]; +} & ({ + to: string | EmailAddress | (string | EmailAddress)[]; +} | { + cc: string | EmailAddress | (string | EmailAddress)[]; +} | { + bcc: string | EmailAddress | (string | EmailAddress)[]; +}); +/** + * Fields shared by all composed emails (no recipients). Used directly by + * `ForwardableEmailMessage.reply()`, which always replies to the original + * sender, and extended by `EmailMessageBuilder` for `SendEmail.send()`. + */ +interface EmailReplyMessageBuilder { + from: string | EmailAddress; + subject: string; + replyTo?: string | EmailAddress; + headers?: Record; + text?: string; + html?: string; + attachments?: EmailAttachment[]; +} +/** + * Fields for composing an email without constructing raw MIME, for + * `SendEmail.send()`. Requires at least one of `to`, `cc`, or `bcc`. + */ +type EmailMessageBuilder = EmailReplyMessageBuilder & EmailDestinations; /** * A binding that allows a Worker to send email messages. */ interface SendEmail { send(message: EmailMessage): Promise; - send(builder: { - from: string | EmailAddress; - to: string | EmailAddress | (string | EmailAddress)[]; - subject: string; - replyTo?: string | EmailAddress; - cc?: string | EmailAddress | (string | EmailAddress)[]; - bcc?: string | EmailAddress | (string | EmailAddress)[]; - headers?: Record; - text?: string; - html?: string; - attachments?: EmailAttachment[]; - }): Promise; + send(builder: EmailMessageBuilder): Promise; } declare abstract class EmailEvent extends ExtendableEvent { readonly message: ForwardableEmailMessage; @@ -12932,6 +13130,11 @@ declare namespace CloudflareWorkersModule { export type WorkflowDurationLabel = 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year'; export type WorkflowSleepDuration = `${number} ${WorkflowDurationLabel}${'s' | ''}` | number; export type WorkflowDelayDuration = WorkflowSleepDuration; + export type WorkflowDynamicDelayContext = { + ctx: WorkflowStepContext; + error: Error; + }; + export type WorkflowDelayFunction = (input: WorkflowDynamicDelayContext) => WorkflowDelayDuration | Promise; export type WorkflowTimeoutDuration = WorkflowSleepDuration; export type WorkflowRetentionDuration = WorkflowSleepDuration; export type WorkflowBackoff = 'constant' | 'linear' | 'exponential'; @@ -12939,12 +13142,13 @@ declare namespace CloudflareWorkersModule { export type WorkflowStepConfig = { retries?: { limit: number; - delay: WorkflowDelayDuration | number; + delay: WorkflowDelayDuration | number | WorkflowDelayFunction; backoff?: WorkflowBackoff; }; timeout?: WorkflowTimeoutDuration | number; sensitive?: WorkflowStepSensitivity; }; + export type WorkflowStepRollbackConfig = Pick; export type WorkflowCronSchedule = { /** Cron expression that triggered this event. */ cron: string; @@ -12964,27 +13168,40 @@ declare namespace CloudflareWorkersModule { type: string; sensitive?: WorkflowStepSensitivity; }; - export type WorkflowStepContext = { + export type WorkflowStepContext = { step: { name: string; count: number; }; attempt: number; - config: WorkflowStepConfig; + config: { + retries?: { + limit: number; + backoff?: WorkflowBackoff; + } & (Delay extends WorkflowDelayFunction ? {} : { + delay: WorkflowDelayDuration | number; + }); + timeout?: WorkflowTimeoutDuration | number; + sensitive?: WorkflowStepSensitivity; + }; }; 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; - rollbackConfig?: WorkflowStepConfig; + rollback: WorkflowRollbackHandler; + rollbackConfig?: WorkflowStepRollbackConfig; }; export abstract class WorkflowStep { do>(name: string, 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: { @@ -13875,7 +14092,7 @@ declare namespace TailStream { interface ConnectEventInfo { readonly type: "connect"; } - type EventOutcome = "ok" | "canceled" | "exception" | "unknown" | "killSwitch" | "daemonDown" | "exceededCpu" | "exceededMemory" | "loadShed" | "responseStreamDisconnected" | "scriptNotFound" | "internalError"; + type EventOutcome = "ok" | "canceled" | "exception" | "unknown" | "killSwitch" | "daemonDown" | "exceededCpu" | "exceededMemory" | "loadShed" | "responseStreamDisconnected" | "scriptNotFound" | "internalError" | "exceededWallTime"; interface ScriptVersion { readonly id: string; readonly tag?: string; @@ -13894,6 +14111,7 @@ declare namespace TailStream { readonly dispatchNamespace?: string; readonly entrypoint?: string; readonly executionModel: string; + readonly durableObjectId?: string; readonly scriptName?: string; readonly scriptTags?: string[]; readonly scriptVersion?: ScriptVersion; @@ -14448,6 +14666,13 @@ interface WorkflowError { code?: number; message: string; } +interface WorkflowInstanceTerminateOptions { + /** + * If true, run registered rollback handlers before terminating the instance. + * Only steps that registered rollback handlers are rolled back. + */ + rollback?: boolean; +} interface WorkflowInstanceRestartOptions { /** * Restart from a specific step. If omitted, the instance restarts from the beginning. @@ -14481,8 +14706,9 @@ declare abstract class WorkflowInstance { public resume(): Promise; /** * Terminate the instance. If it is errored, terminated or complete, an error will be thrown. + * @param options Options for termination, including whether registered rollback handlers should run. */ - public terminate(): Promise; + public terminate(options?: WorkflowInstanceTerminateOptions): Promise; /** * Restart the instance. Optionally restart from a specific step, preserving * cached results for all steps before it. diff --git a/apps/labeler/wrangler.jsonc b/apps/labeler/wrangler.jsonc index 8daeae21cf..924b5aeede 100644 --- a/apps/labeler/wrangler.jsonc +++ b/apps/labeler/wrangler.jsonc @@ -43,6 +43,15 @@ "ai": { "binding": "AI", }, + // Read-only service binding to the aggregator Worker's XRPC surface + // (fetch-over-binding; the aggregator is HTTP/XRPC-only, no RPC entrypoint). + // Consumed via src/aggregator-client.ts. + "services": [ + { + "binding": "AGGREGATOR", + "service": "emdash-aggregator", + }, + ], // Operator console SPA (apps/labeler/console), built to ./dist/console by // `console:build`. `run_worker_first: true` keeps the Worker the sole // router: the SPA fallback only fires where index.ts explicitly calls