diff --git a/.changeset/twenty-lions-pull.md b/.changeset/twenty-lions-pull.md new file mode 100644 index 00000000..94c4b41e --- /dev/null +++ b/.changeset/twenty-lions-pull.md @@ -0,0 +1,5 @@ +--- +"@toapi/server": minor +--- + +Transparently hash tags before passing them to the client diff --git a/packages/toapi-server/src/create-request-handler.ts b/packages/toapi-server/src/create-request-handler.ts index 37681773..7960c303 100644 --- a/packages/toapi-server/src/create-request-handler.ts +++ b/packages/toapi-server/src/create-request-handler.ts @@ -15,6 +15,7 @@ import type { TRequest } from "@toapi/common"; import type { Cache } from "./cache.js"; import { generateOpenAPISchema } from "./openapi.js"; import { streamRevalidatedTags } from "./revalidation-stream.js"; +import { encodeTags } from "./hash-tags.js"; interface Options { /** the root path for all API routes */ @@ -145,6 +146,7 @@ export function createRequestHandler( } } } + await encodeTags(api.revalidationStreamConfig?.hashSecret, res); return res; } catch (error) { // catches errors while actually handling the request @@ -180,6 +182,7 @@ export function createRequestHandler( errorLog(error); } } + await encodeTags(api.revalidationStreamConfig?.hashSecret, res); return res; } catch (error) { await errorLog(error); @@ -219,6 +222,7 @@ export function createRequestHandler( errorLog(error); } } + await encodeTags(api.revalidationStreamConfig?.hashSecret, res); return res; } catch (error) { await errorLog(error); diff --git a/packages/toapi-server/src/hash-tags.ts b/packages/toapi-server/src/hash-tags.ts new file mode 100644 index 00000000..322a1aec --- /dev/null +++ b/packages/toapi-server/src/hash-tags.ts @@ -0,0 +1,47 @@ +import { TAGS_HEADER, TResponse, type MaybePromise } from "@toapi/common"; + +export async function hashTag(te: TextEncoder, secret: string, tag: string) { + const digest = await crypto.subtle.digest("SHA-256", te.encode(secret + tag)); + const dv = new DataView(digest); + const num = dv.getBigUint64(0); + return num.toString(36); +} + +type Encode = (tags: string[]) => MaybePromise; + +export function hashEncoder(secret: string): Encode { + const te = new TextEncoder(); + return async (tags) => { + if (!tags.length) return te.encode("\n"); + const hashedTags = await Promise.all( + tags.map((tag) => hashTag(te, secret, tag)), + ); + const tagsString = hashedTags.join(" "); + return te.encode(tagsString + "\n"); + }; +} + +export function textEncoder(): Encode { + const te = new TextEncoder(); + return (tags) => { + if (!tags.length) return te.encode("\n"); + const tagsString = tags.join(" "); + return te.encode(tagsString + "\n"); + }; +} + +export async function encodeTags( + secret: string | undefined, + res: TResponse, +) { + if (!secret) return; + const tags = + res.cache?.tags ?? res.headers.get(TAGS_HEADER)?.split(" ") ?? []; + + const te = new TextEncoder(); + const hashedTags = await Promise.all( + tags.map((tag) => hashTag(te, secret, tag)), + ); + + res.headers.set(TAGS_HEADER, hashedTags.join(" ")); +} diff --git a/packages/toapi-server/src/hashed-tags.test.ts b/packages/toapi-server/src/hashed-tags.test.ts new file mode 100644 index 00000000..a69e79b6 --- /dev/null +++ b/packages/toapi-server/src/hashed-tags.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test, vi } from "vitest"; +import { defineApi } from "./define-api.js"; +import { PubSub } from "./cache.js"; +import { createRequestHandler } from "./create-request-handler.js"; +import { defineHandler } from "./define-handler.js"; +import { INVALIDATIONS_ROUTE, TAGS_HEADER, TResponse } from "@toapi/common"; +import { hashTag } from "./hash-tags.js"; + +describe("Hashed Tags", async () => { + const secret = "superSecretTestSecret"; + const tags = ["foo", "bar"]; + const te = new TextEncoder(); + + const expectedTags = await Promise.all( + tags.map((tag) => hashTag(te, secret, tag)), + ); + + const cache = new PubSub(); + + const respond = vi.fn(async () => { + return TResponse.void({ cache: { tags } }); + }); + + const api = defineApi({ + revalidationStream: { + hashSecret: secret, + }, + cache, + }).route("/foo", { + GET: defineHandler( + { + authorize: () => true, + }, + respond, + ), + POST: defineHandler( + { + authorize: () => true, + }, + respond, + ), + DELETE: defineHandler( + { + authorize: () => true, + }, + respond, + ), + }); + + const handler = createRequestHandler(api); + + test.for([["GET"], ["POST"], ["DELETE"]])( + "hashes tags in %s response header", + async ([method]) => { + const res = await handler( + new Request("http://localhost/foo", { + method, + }), + ); + expect(res.headers.get(TAGS_HEADER)).toBe(expectedTags.join(" ")); + }, + ); + + test("hashes tags in invalidation stream", async () => { + const stream = await handler( + new Request(`http://localhost${INVALIDATIONS_ROUTE}`), + ); + cache.delete(tags); + const reader = stream.body?.getReader(); + const chunk0 = await reader?.read(); + expect(chunk0?.value).toEqual(te.encode("\n")); + const chunk1 = await reader?.read(); + expect(chunk1?.value).toEqual(te.encode(expectedTags.join(" ") + "\n")); + }); +}); diff --git a/packages/toapi-server/src/revalidation-stream.ts b/packages/toapi-server/src/revalidation-stream.ts index c5583fdc..22b130cf 100644 --- a/packages/toapi-server/src/revalidation-stream.ts +++ b/packages/toapi-server/src/revalidation-stream.ts @@ -4,6 +4,7 @@ import { type MaybePromise, } from "@toapi/common"; import type { Cache } from "./cache.js"; +import { hashEncoder, textEncoder } from "./hash-tags.js"; const DEFAULT_KEEPALIVE_INTERVAL = 10 * 1000; const DEFAULT_THROTTLE_TIMEOUT = 500; @@ -12,6 +13,7 @@ export interface RevalidationStreamConfig { throttleTimeout?: number; keepaliveInterval?: number; filter?: (req: Request) => MaybePromise<(tag: string) => boolean>; + hashSecret?: string; } interface Options { @@ -34,10 +36,12 @@ export function streamRevalidatedTags({ cache, config = {}, req }: Options) { async start(controller) { const filterTag = await filter(req); let queue = new Set(); - const textEncoder = new TextEncoder(); + const encode = config.hashSecret + ? hashEncoder(config.hashSecret) + : textEncoder(); // subscribe to tag invalidations - unsubscribe = cache.subscribe((tags, meta) => { + unsubscribe = cache.subscribe(async (tags, meta) => { // ignore our own invalidations if ( meta && @@ -55,15 +59,11 @@ export function streamRevalidatedTags({ cache, config = {}, req }: Options) { // send tags to client if (queue.size > 0 && !timeout) { - controller.enqueue( - textEncoder.encode(`${Array.from(queue).join(" ")}\n`), - ); + controller.enqueue(await encode(Array.from(queue))); queue = new Set(); - timeout = setTimeout(() => { - controller.enqueue( - textEncoder.encode(`${Array.from(queue).join(" ")}\n`), - ); + timeout = setTimeout(async () => { + controller.enqueue(await encode(Array.from(queue))); queue = new Set(); timeout = null; }, throttleTimeout); @@ -73,7 +73,7 @@ export function streamRevalidatedTags({ cache, config = {}, req }: Options) { // keepalive. The first one is sent right away so the response headers // are flushed immediately instead of only once the interval first // fires — consumers skip empty lines, so this is a no-op for them. - const keepalive = () => controller.enqueue(textEncoder.encode("\n")); + const keepalive = () => controller.enqueue(new Uint8Array([10])); keepalive(); interval = setInterval(keepalive, keepaliveInterval); },