diff --git a/package-lock.json b/package-lock.json index ace5459a4..debfe8b47 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "./packages/vdaf", "./packages/prio3", "./packages/dap", + "./packages/divviup", "./packages/interop-test-client" ], "devDependencies": { @@ -2509,6 +2510,10 @@ "node": ">=8" } }, + "node_modules/divviup": { + "resolved": "packages/divviup", + "link": true + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -7523,6 +7528,16 @@ "hpke": "^0.5.0" } }, + "packages/divviup": { + "version": "0.1.0", + "license": "MPL-2.0", + "dependencies": { + "@divviup/dap": "^0.1.0" + }, + "devDependencies": { + "hpke": "^0.5.0" + } + }, "packages/field": { "name": "@divviup/field", "version": "0.1.0", diff --git a/package.json b/package.json index 386db48fc..ed52135a5 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "./packages/vdaf", "./packages/prio3", "./packages/dap", + "./packages/divviup", "./packages/interop-test-client" ], "scripts": { diff --git a/packages/dap/src/index.ts b/packages/dap/src/index.ts index c316ec886..9834c02c5 100644 --- a/packages/dap/src/index.ts +++ b/packages/dap/src/index.ts @@ -1,3 +1,10 @@ -export { DAPClient, DAPClient as default } from "./client"; +export { + DAPClient, + DAPClient as default, + KnownVdafSpec, + VdafMeasurement, +} from "./client"; export { DAPError } from "./errors"; export type { ReportOptions } from "./client"; +export { TaskId } from "./taskId"; +export { HpkeConfig, HpkeConfigList } from "./hpkeConfig"; diff --git a/packages/divviup/package.json b/packages/divviup/package.json new file mode 100644 index 000000000..0e24802e0 --- /dev/null +++ b/packages/divviup/package.json @@ -0,0 +1,31 @@ +{ + "name": "divviup", + "version": "0.1.0", + "description": "", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "source": "src/index.ts", + "module": "dist/module.js", + "browser": "dist/browser.js", + "type": "module", + "license": "MPL-2.0", + "scripts": { + "clean": "rm -rf dist/*", + "build:clean": "npm run clean && npm run build", + "build": "npm run build:web && npm run build:node", + "build:web": "esbuild browser=src/index.ts --bundle --loader:.wasm=binary --format=esm --outdir=dist --sourcemap --minify", + "build:node": "tsc -p ./tsconfig.json", + "docs": "typedoc src", + "test": "mocha \"src/**/*.spec.ts\"", + "lint": "eslint src --ext .ts && prettier -c src", + "format": "prettier -w src", + "check": "tsc --noEmit -p ./tsconfig.json", + "test:coverage": "c8 npm test" + }, + "dependencies": { + "@divviup/dap": "^0.1.0" + }, + "devDependencies": { + "hpke": "^0.5.0" + } +} diff --git a/packages/divviup/src/index.spec.ts b/packages/divviup/src/index.spec.ts new file mode 100644 index 000000000..69fcf3090 --- /dev/null +++ b/packages/divviup/src/index.spec.ts @@ -0,0 +1,189 @@ +import assert from "assert"; +import { inspect } from "node:util"; +import { DivviupClient, sendMeasurement } from "."; +import { HpkeConfigList, HpkeConfig, TaskId } from "@divviup/dap"; +import * as hpke from "hpke"; + +describe("DivviupClient", () => { + it("fetches task from an id", async () => { + const taskId = TaskId.random().toString(); + const client = new DivviupClient(taskId); + const fetch = mockFetch({ + ...dapMocks(taskId), + [`https://api.staging.divviup.org/tasks/${taskId}`]: [ + { + status: 200, + body: JSON.stringify(task(taskId)), + contentType: "application/json", + }, + ], + }); + client.fetch = fetch; + await client.sendMeasurement(10); + assert.equal(fetch.calls.length, 4); + assert.deepEqual(fetch.callStrings(), [ + `GET https://api.staging.divviup.org/tasks/${taskId}`, + `GET https://a.example.com/v1/hpke_config?task_id=${taskId}`, + `GET https://b.example.com/dap/hpke_config?task_id=${taskId}`, + `PUT https://a.example.com/v1/tasks/${taskId}/reports`, + ]); + }); + + it("fetches task from a task url", async () => { + const taskId = TaskId.random().toString(); + const client = new DivviupClient( + `https://production.divvi.up/v3/different-url/${taskId}.json`, + ); + const fetch = mockFetch({ + ...dapMocks(taskId), + [`https://production.divvi.up/v3/different-url/${taskId}.json`]: [ + { + status: 200, + body: JSON.stringify(task(taskId)), + contentType: "application/json", + }, + ], + }); + client.fetch = fetch; + await client.sendMeasurement(10); + assert.equal(fetch.calls.length, 4); + assert.deepEqual(fetch.callStrings(), [ + `GET https://production.divvi.up/v3/different-url/${taskId}.json`, + `GET https://a.example.com/v1/hpke_config?task_id=${taskId}`, + `GET https://b.example.com/dap/hpke_config?task_id=${taskId}`, + `PUT https://a.example.com/v1/tasks/${taskId}/reports`, + ]); + }); +}); + +describe("sendMeasurement", () => { + it("fetches task from an id", async () => { + const taskId = TaskId.random().toString(); + const fetch = mockFetch({ + ...dapMocks(taskId), + [`https://api.staging.divviup.org/tasks/${taskId}`]: [ + { + status: 200, + body: JSON.stringify(task(taskId)), + contentType: "application/json", + }, + ], + }); + + await sendMeasurement(taskId, 10, fetch); + + assert.equal(fetch.calls.length, 4); + assert.deepEqual(fetch.callStrings(), [ + `GET https://api.staging.divviup.org/tasks/${taskId}`, + `GET https://a.example.com/v1/hpke_config?task_id=${taskId}`, + `GET https://b.example.com/dap/hpke_config?task_id=${taskId}`, + `PUT https://a.example.com/v1/tasks/${taskId}/reports`, + ]); + }); +}); + +function dapMocks(taskId: string) { + return { + [`https://a.example.com/v1/hpke_config?task_id=${taskId}`]: [ + hpkeConfigResponse(), + ], + + [`https://b.example.com/dap/hpke_config?task_id=${taskId}`]: [ + hpkeConfigResponse(), + ], + + [`https://api.staging.divviup.org/tasks/${taskId}`]: [ + { + status: 200, + body: JSON.stringify(task(taskId)), + contentType: "application/json", + }, + ], + [`https://a.example.com/v1/tasks/${taskId}/reports`]: [{ status: 201 }], + }; +} + +interface Fetch { + (input: RequestInfo, init?: RequestInit | undefined): Promise; + calls: [RequestInfo, RequestInit | undefined][]; + callStrings(): string[]; +} + +interface ResponseSpec { + body?: Buffer | Uint8Array | number[] | string; + contentType?: string; + status?: number; +} + +function mockFetch(mocks: { [url: string]: ResponseSpec[] }): Fetch { + function fakeFetch( + input: RequestInfo, + init?: RequestInit | undefined, + ): Promise { + fakeFetch.calls.push([input, init]); + const responseSpec = mocks[input.toString()]; + const response = responseSpec?.shift(); + + if (!response) { + throw new Error( + `received unhandled request.\n\nurl: ${input.toString()}.\n\nmocks: ${inspect( + mocks, + ).slice(1, -1)}`, + ); + } + + return Promise.resolve( + new Response(Buffer.from(response.body || ""), { + status: response.status || 200, + headers: { "Content-Type": response.contentType || "text/plain" }, + }), + ); + } + + fakeFetch.calls = [] as [RequestInfo, RequestInit | undefined][]; + fakeFetch.callStrings = function () { + return this.calls.map((x) => `${x[1]?.method || "GET"} ${x[0].toString()}`); + }; + return fakeFetch; +} + +function task(taskId: string): { + vdaf: { + type: "sum"; + bits: number; + }; + helper: string; + leader: string; + id: string; + time_precision_seconds: number; +} { + return { + vdaf: { + type: "sum", + bits: 16, + }, + leader: "https://a.example.com/v1", + helper: "https://b.example.com/dap/", + id: taskId, + time_precision_seconds: 1, + }; +} + +function hpkeConfigResponse(config = buildHpkeConfigList()): ResponseSpec { + return { + body: config.encode(), + contentType: "application/dap-hpke-config-list", + }; +} + +function buildHpkeConfigList(): HpkeConfigList { + return new HpkeConfigList([ + new HpkeConfig( + Math.floor(Math.random() * 255), + hpke.Kem.DhP256HkdfSha256, + hpke.Kdf.Sha256, + hpke.Aead.AesGcm128, + Buffer.from(new hpke.Keypair(hpke.Kem.DhP256HkdfSha256).public_key), + ), + ]); +} diff --git a/packages/divviup/src/index.ts b/packages/divviup/src/index.ts new file mode 100644 index 000000000..9af453d85 --- /dev/null +++ b/packages/divviup/src/index.ts @@ -0,0 +1,78 @@ +import { DAPClient } from "@divviup/dap"; +import { KnownVdafSpec } from "@divviup/dap/dist/client"; + +type Fetch = ( + input: RequestInfo, + init?: RequestInit | undefined, +) => Promise; + +interface PublicTask { + id: string; + vdaf: KnownVdafSpec; + leader: string; + helper: string; + time_precision_seconds: number; +} + +type AnyMeasurement = number | bigint | boolean; +type GenericDAPClient = DAPClient; + +export class DivviupClient { + #baseUrl = new URL("https://api.staging.divviup.org/tasks"); + #fetch: Fetch = globalThis.fetch.bind(globalThis); + #dapClient: null | GenericDAPClient = null; + #taskUrl: URL; + + /** @internal */ + set fetch(fetch: Fetch) { + this.#fetch = fetch; + if (this.#dapClient) this.#dapClient.fetch = fetch; + } + + constructor(urlOrTaskId: string | URL) { + if (typeof urlOrTaskId === "string") { + try { + this.#taskUrl = new URL(urlOrTaskId); + } catch (e) { + this.#taskUrl = new URL(`${this.#baseUrl.toString()}/${urlOrTaskId}`); + } + } else { + this.#taskUrl = urlOrTaskId; + } + } + + private async taskClient(): Promise { + if (this.#dapClient) return this.#dapClient; + const response = await this.#fetch(this.#taskUrl.toString()); + const task = (await response.json()) as PublicTask; + const { leader, helper, vdaf, id, time_precision_seconds } = task; + const client = new DAPClient({ + taskId: id, + leader, + helper, + id, + timePrecisionSeconds: time_precision_seconds, + ...vdaf, + }); + client.fetch = this.#fetch; + this.#dapClient = client; + return client; + } + + async sendMeasurement(measurement: AnyMeasurement) { + const client = await this.taskClient(); + return client.sendMeasurement(measurement); + } +} + +export default DivviupClient; + +export async function sendMeasurement( + urlOrTaskId: string | URL, + measurement: AnyMeasurement, + fetch?: Fetch, +) { + const client = new DivviupClient(urlOrTaskId); + if (fetch) client.fetch = fetch; + return client.sendMeasurement(measurement); +} diff --git a/packages/divviup/tsconfig.json b/packages/divviup/tsconfig.json new file mode 100644 index 000000000..bad77ca65 --- /dev/null +++ b/packages/divviup/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["./src/*.ts"] +} diff --git a/packages/divviup/typedoc.json b/packages/divviup/typedoc.json new file mode 100644 index 000000000..0ba819b1c --- /dev/null +++ b/packages/divviup/typedoc.json @@ -0,0 +1,5 @@ +{ + "extends": ["../../typedoc.base.json"], + "entryPointStrategy": "expand", + "entryPoints": ["src/index.ts"] +}