Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/twenty-lions-pull.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@toapi/server": minor
---

Transparently hash tags before passing them to the client
4 changes: 4 additions & 0 deletions packages/toapi-server/src/create-request-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -145,6 +146,7 @@ export function createRequestHandler(
}
}
}
await encodeTags(api.revalidationStreamConfig?.hashSecret, res);
return res;
} catch (error) {
// catches errors while actually handling the request
Expand Down Expand Up @@ -180,6 +182,7 @@ export function createRequestHandler(
errorLog(error);
}
}
await encodeTags(api.revalidationStreamConfig?.hashSecret, res);
return res;
} catch (error) {
await errorLog(error);
Expand Down Expand Up @@ -219,6 +222,7 @@ export function createRequestHandler(
errorLog(error);
}
}
await encodeTags(api.revalidationStreamConfig?.hashSecret, res);
return res;
} catch (error) {
await errorLog(error);
Expand Down
47 changes: 47 additions & 0 deletions packages/toapi-server/src/hash-tags.ts
Original file line number Diff line number Diff line change
@@ -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<Uint8Array>;

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<unknown>,
) {
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(" "));
}
75 changes: 75 additions & 0 deletions packages/toapi-server/src/hashed-tags.test.ts
Original file line number Diff line number Diff line change
@@ -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"));
});
});
20 changes: 10 additions & 10 deletions packages/toapi-server/src/revalidation-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -12,6 +13,7 @@ export interface RevalidationStreamConfig {
throttleTimeout?: number;
keepaliveInterval?: number;
filter?: (req: Request) => MaybePromise<(tag: string) => boolean>;
hashSecret?: string;
}

interface Options {
Expand All @@ -34,10 +36,12 @@ export function streamRevalidatedTags({ cache, config = {}, req }: Options) {
async start(controller) {
const filterTag = await filter(req);
let queue = new Set<string>();
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 &&
Expand All @@ -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);
Expand All @@ -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);
},
Expand Down
Loading