From 9da9b41ec4a2b4fa64d3029c75d2abbe89ac1236 Mon Sep 17 00:00:00 2001 From: Michel Smola Date: Sun, 11 Oct 2026 16:08:09 +0200 Subject: [PATCH 1/2] feat: hash tags --- .changeset/twenty-lions-pull.md | 5 ++ .../src/create-request-handler.ts | 4 ++ packages/toapi-server/src/hash-tags.ts | 47 ++++++++++++++++ packages/toapi-server/src/hashed-tags.test.ts | 54 +++++++++++++++++++ .../toapi-server/src/revalidation-stream.ts | 20 +++---- 5 files changed, 120 insertions(+), 10 deletions(-) create mode 100644 .changeset/twenty-lions-pull.md create mode 100644 packages/toapi-server/src/hash-tags.ts create mode 100644 packages/toapi-server/src/hashed-tags.test.ts 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..2d27ae79 --- /dev/null +++ b/packages/toapi-server/src/hashed-tags.test.ts @@ -0,0 +1,54 @@ +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", () => { + test("hashes tags the same way for responses and invalidation stream", 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 get = vi.fn(async () => { + return TResponse.void({ cache: { tags } }); + }); + + const api = defineApi({ + revalidationStream: { + hashSecret: secret, + }, + cache, + }).route("/foo", { + GET: defineHandler( + { + authorize: () => true, + }, + get, + ), + }); + + const handler = createRequestHandler(api); + + const res = await handler(new Request("http://localhost/foo")); + expect(res.headers.get(TAGS_HEADER)).toBe(expectedTags.join(" ")); + + 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); }, From fb4a7f0d15afadead0c417478c2f5d8509b9ca82 Mon Sep 17 00:00:00 2001 From: Michel Smola Date: Sun, 11 Oct 2026 16:22:38 +0200 Subject: [PATCH 2/2] feat: better tests for hashed tags --- packages/toapi-server/src/hashed-tags.test.ts | 77 ++++++++++++------- 1 file changed, 49 insertions(+), 28 deletions(-) diff --git a/packages/toapi-server/src/hashed-tags.test.ts b/packages/toapi-server/src/hashed-tags.test.ts index 2d27ae79..a69e79b6 100644 --- a/packages/toapi-server/src/hashed-tags.test.ts +++ b/packages/toapi-server/src/hashed-tags.test.ts @@ -6,41 +6,62 @@ import { defineHandler } from "./define-handler.js"; import { INVALIDATIONS_ROUTE, TAGS_HEADER, TResponse } from "@toapi/common"; import { hashTag } from "./hash-tags.js"; -describe("Hashed Tags", () => { - test("hashes tags the same way for responses and invalidation stream", async () => { - const secret = "superSecretTestSecret"; - const tags = ["foo", "bar"]; - const te = new TextEncoder(); - - const expectedTags = await Promise.all( - tags.map((tag) => hashTag(te, secret, tag)), - ); +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 cache = new PubSub(); - const get = vi.fn(async () => { - return TResponse.void({ cache: { tags } }); - }); + const respond = vi.fn(async () => { + return TResponse.void({ cache: { tags } }); + }); - const api = defineApi({ - revalidationStream: { - hashSecret: secret, + const api = defineApi({ + revalidationStream: { + hashSecret: secret, + }, + cache, + }).route("/foo", { + GET: defineHandler( + { + authorize: () => true, }, - cache, - }).route("/foo", { - GET: defineHandler( - { - authorize: () => true, - }, - get, - ), - }); + respond, + ), + POST: defineHandler( + { + authorize: () => true, + }, + respond, + ), + DELETE: defineHandler( + { + authorize: () => true, + }, + respond, + ), + }); - const handler = createRequestHandler(api); + const handler = createRequestHandler(api); - const res = await handler(new Request("http://localhost/foo")); - expect(res.headers.get(TAGS_HEADER)).toBe(expectedTags.join(" ")); + 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}`), );