diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 5ca5b34..c124948 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -121,22 +121,45 @@ jobs:
test "$code" = "200"
working-directory: infra
- # Green means the steps ran. It says nothing about whether the page works, so the page is
- # opened and read before this run is called a success.
- - name: read the page that is now serving
+ # The deploy serving at all is a different question from the product working, so they are
+ # asked separately. This one only proves the distribution reaches the function.
+ - name: read the front page
+ run: |
+ set -euo pipefail
+ address="$(terraform output -raw address)"
+ code=$(curl -s -o /tmp/front.html -w '%{http_code}' --max-time 90 "$address/" || true)
+ echo "the front page answered $code with $(wc -c < /tmp/front.html) bytes"
+ test "$code" = "200"
+ grep -q "Read a video as text" /tmp/front.html
+ working-directory: infra
+
+ # Green means the steps ran. It says nothing about whether a reader gets a transcript, so one
+ # is asked for here. A failure names what the platform did rather than the status alone,
+ # because a bare 404 told nobody anything for a day. The page states the cause in its title.
+ - name: read a transcript from the page that is now serving
run: |
set -euo pipefail
address="$(terraform output -raw address)"
- cd "$GITHUB_WORKSPACE"
for attempt in 1 2 3 4 5 6 7 8; do
code=$(curl -s -o /tmp/page.html -w '%{http_code}' --max-time 90 "$address/videos?id=gyN9lV9QgyA" || true)
if [ "$code" = "200" ]; then break; fi
echo "attempt $attempt answered $code, the distribution may still be spreading"
sleep 30
done
- test "$code" = "200"
bytes=$(wc -c < /tmp/page.html)
- echo "the page answered $code with $bytes bytes"
+ said=$(sed -n "s/.*
\\(.*\\)<\\/title>.*/\\1/p" /tmp/page.html | head -1)
+ echo "the page answered $code with $bytes bytes, and said: $said"
+ if [ "$code" != "200" ]; then
+ {
+ echo "## The page is serving, and it cannot read a transcript"
+ echo ""
+ echo "It answered $code and said: $said"
+ echo ""
+ echo "The cause is in the function log, on the watch_page_unreadable line."
+ } >> "$GITHUB_STEP_SUMMARY"
+ echo "::error::the page answered $code and said: $said"
+ exit 1
+ fi
test "$bytes" -gt 5000
grep -q "Transcript" /tmp/page.html
echo "the page carries text"
diff --git a/README.md b/README.md
index e597a2f..dbcfc69 100644
--- a/README.md
+++ b/README.md
@@ -41,6 +41,104 @@ it is correct for the player, and for captions it returns zero bytes.
The integrity token behind the mint lasts twelve hours, so a warm function attests once and mints per
video after that.
+## What the deployed function actually gets
+
+Captured from the deployed function on 31 August 2026, in eu-west-1, on the `watch_page_unreadable`
+line of its own log:
+
+ {"event":"watch_page_unreadable","video_id":"gyN9lV9QgyA","cause":"bot_check",
+ "status":200,"bytes":1412860,"has_video_details":true,"has_caption_tracks":false,
+ "has_player_response":true,"playability":"LOGIN_REQUIRED",
+ "playability_reason":"Sign in to confirm you are not a bot"}
+
+The platform serves the function a full watch page, HTTP 200, over a megabyte, and that page carries
+no caption track and a player response that asks the caller to prove it is a person. The same
+request from a laptop returns the video and 560 caption segments. The block is on the address the
+function calls from, and it lands on the watch page, before the proof of origin work above is
+reached.
+
+Reading it needed the boundary to say so. Before this, the title came back null and every cause threw
+the same error, so a refusal, a rate limit and a deleted video all reached the reader as one 404
+saying "No video with that id".
+
+## What gets the text when the watch page is refused
+
+The refusal above lands on the watch page, so no caption address is ever read and the proof of origin
+work is never reached. Asking the same question a different way answers it, from an address the
+platform does not refuse. This request carries no token at all:
+
+ POST https://www.youtube.com/youtubei/v1/player?prettyPrint=false
+ content-type: application/json
+ user-agent: com.google.ios.youtube/20.10.4 (iPhone16,2; U; CPU iOS 18_0 like Mac OS X)
+ x-youtube-client-name: 5
+ x-youtube-client-version: 20.10.4
+
+ {"videoId":"gyN9lV9QgyA","context":{"client":{"clientName":"IOS","clientVersion":"20.10.4",
+ "deviceMake":"Apple","deviceModel":"iPhone16,2","hl":"en","gl":"US"}},
+ "contentCheckOk":true,"racyCheckOk":true}
+
+It answers with the title and a caption track. That track's address with `&fmt=json3` added returns
+406,491 bytes of json3, which is 560 segments and 19,420 characters on `gyN9lV9QgyA`. That is the
+same count the watch page path produces.
+
+Measured on 1 September 2026, from an address the platform does not refuse. Three readings:
+
+- The caption address from the watch page returns HTTP 200 and zero bytes without a minted token.
+ The token is what that path needs.
+- The caption address from the player endpoint needs no token. Plain, it returns 406,491 bytes.
+- The `ANDROID` client answers too, but ignores `fmt=json3` and returns XML, so `IOS` is the one used.
+
+The watch page is still read first, because it is the path that tells a video with no captions apart
+from a video that is missing. The player endpoint is asked only after a refusal, and a proved missing
+video never reaches it. When the player endpoint has nothing to add, the refusal the watch page named
+is what the reader gets, unchanged.
+
+## The player endpoint is refused from the function too
+
+The paragraph above is true from an address the platform accepts. It is not true from the deployed
+function. Captured on 1 September 2026 from the function log in eu-west-1, on the run that deployed
+this code:
+
+ {"event":"watch_page_unreadable","video_id":"gyN9lV9QgyA","cause":"bot_check","status":200,
+ "bytes":1339782,"has_video_details":true,"has_caption_tracks":false,
+ "playability":"LOGIN_REQUIRED","playability_reason":"Sign in to confirm you're not a bot"}
+ {"event":"player_unusable","video_id":"gyN9lV9QgyA",
+ "reason":"the player endpoint did not return a playable video","status":200,
+ "playability":"LOGIN_REQUIRED","playability_reason":"Sign in to confirm you're not a bot"}
+
+Both entry points are refused, with the same reason, on the same request. So the block is on the
+address the function calls from, and it is not a property of the client the request claims to be.
+Changing the client does not move it. That is the measurement, and it rules out the whole family of
+fixes that consist of asking as something else.
+
+What is left is the address. Reaching the platform from somewhere it does not refuse needs an egress
+this project refuses on cost: a network address translation gateway bills while nothing happens, and
+a proxy service is a paid dependency. Neither is a choice to make here, so the deploy gate that asks
+the page for a transcript stays red until a person decides which egress to buy.
+
+The gate is correct and it is not weakened. It asks for a transcript and it does not get one.
+
+## What a read can fail with
+
+Each cause gets its own page, its own status and its own line in the log, and only one of them is a
+404:
+
+ video_missing 404 the platform says the video is unplayable
+ bot_check 502 the platform wants proof the caller is a person
+ consent_wall 502 a consent page came back instead of the video
+ rate_limited 429 too many requests, and the answer carries retry-after
+ platform_error 502 a status that is not a page
+ unrecognised_page 502 a page with no video and no reason given
+ captions_refused 502 the video was found, the caption text was refused
+ captions_not_json 502 the caption endpoint answered with something else
+ captions_empty 502 the track holds no readable line
+
+A missing video is proved, never assumed. Anything the code cannot recognise is a refusal, because
+calling an unrecognised page a missing video is what told readers a working video was deleted.
+
+Nothing written to the log is a credential. The caption address is signed and the request carries
+cookies and a minted token, so an address is cut to its host and path before it is written.
+
## The gates
npm ci
diff --git a/features/a-refusal-is-not-a-missing-video.feature.ts b/features/a-refusal-is-not-a-missing-video.feature.ts
new file mode 100644
index 0000000..50d7e3f
--- /dev/null
+++ b/features/a-refusal-is-not-a-missing-video.feature.ts
@@ -0,0 +1,116 @@
+import { describe, expect, vi } from "vitest";
+import { route, type FunctionUrlEvent } from "../src/handler.js";
+import { makeLookup } from "../src/store/lookup.js";
+import { MemoryStore } from "../src/store/store.js";
+import { fetchTranscript } from "../src/transcript/youtube.js";
+import { scenario, theAnswer, type World } from "./scenario.js";
+
+// The deployed page reported a working video as deleted. The platform was refusing the address the
+// function reads from, and a refusal, a rate limit and a genuinely missing video all reached the
+// reader as one 404 that said "No video with that id".
+//
+// The page the platform really sent is copied here from the function's own log: HTTP 200, over a
+// megabyte, no caption track, and a player response asking the caller to prove it is a person.
+
+const REFUSED_BY_THE_PLATFORM =
+ ` - YouTube`;
+
+const DELETED_BY_ITS_OWNER =
+ ` - YouTube`;
+
+const answering = (body: string, status = 200) => {
+ const headers = new Headers({ "content-type": "text/html; charset=utf-8" });
+ return { ok: status >= 200 && status < 300, status, text: async () => body, headers } as unknown as Response;
+};
+
+function readerAsksFor(videoId: string, platformAnswers: Response): (world: World) => Promise {
+ return async (world) => {
+ const spy = vi.spyOn(console, "log").mockImplementation(() => undefined);
+ try {
+ const lookup = makeLookup(new MemoryStore(), (id) =>
+ fetchTranscript(id, {
+ fetch: (async () => platformAnswers) as unknown as typeof fetch,
+ minter: async () => ({ mint: async () => "a-token", expiresAt: Date.now() + 60_000 }),
+ }),
+ );
+ const request: FunctionUrlEvent = {
+ rawPath: "/videos",
+ queryStringParameters: { id: videoId },
+ requestContext: { http: { method: "GET", path: "/videos" } },
+ };
+ world.answer = await route(request, lookup);
+ world.log = spy.mock.calls.map((call) => JSON.parse(String(call[0])) as Record);
+ } finally {
+ spy.mockRestore();
+ }
+ };
+}
+
+describe("a refusal is not a missing video", () => {
+ scenario("the platform refuses the caller, and the reader is told the truth", ({ given, when, then, and }) => {
+ given("the platform asks this page to prove it is a person", () => {
+ expect(REFUSED_BY_THE_PLATFORM).toContain("LOGIN_REQUIRED");
+ });
+
+ when("a reader opens the page for a video that really exists", readerAsksFor("gyN9lV9QgyA", answering(REFUSED_BY_THE_PLATFORM)));
+
+ then("the page does not say the video is missing", (world) => {
+ expect(theAnswer(world).body).not.toContain("No video with that id");
+ expect(theAnswer(world).statusCode).not.toBe(404);
+ });
+
+ and("the status says the fault is upstream, not with the request", (world) => {
+ expect(theAnswer(world).statusCode).toBe(502);
+ });
+
+ and("the page says the platform refused this page rather than the video", (world) => {
+ expect(theAnswer(world).body).toContain("The platform refused this page, not your video");
+ expect(theAnswer(world).body).toContain("Your id is right and the video is fine");
+ });
+
+ and("the page offers the video where it can still be watched", (world) => {
+ expect(theAnswer(world).body).toContain("https://www.youtube.com/watch?v=gyN9lV9QgyA");
+ });
+
+ and("the page does not promise that trying again will work", (world) => {
+ expect(theAnswer(world).body).toContain("Trying again will not help");
+ });
+
+ and("nothing is held at the edge, so a later visit reaches the platform again", (world) => {
+ expect(theAnswer(world).headers["cache-control"]).toBe("no-store");
+ });
+
+ and("the log names the cause, so an operator can tell this from a deleted video", (world) => {
+ const boundary = world.log.find((entry) => entry.event === "watch_page_unreadable");
+ expect(boundary?.cause).toBe("bot_check");
+ expect(boundary?.playability).toBe("LOGIN_REQUIRED");
+ });
+
+ and("the log carries no credential", (world) => {
+ expect(JSON.stringify(world.log)).not.toContain("a-token");
+ });
+ });
+
+ scenario("a video that really is gone is still reported as gone", ({ given, when, then, and }) => {
+ given("the platform says the video is unavailable", () => {
+ expect(DELETED_BY_ITS_OWNER).toContain("ERROR");
+ });
+
+ when("a reader opens the page for it", readerAsksFor("aaaaaaaaaaa", answering(DELETED_BY_ITS_OWNER)));
+
+ then("the page answers 404", (world) => {
+ expect(theAnswer(world).statusCode).toBe(404);
+ });
+
+ and("the page says there is no video with that id", (world) => {
+ expect(theAnswer(world).body).toContain("No video with that id");
+ });
+
+ and("the log calls it a missing video, not a refusal", (world) => {
+ expect(world.log.find((entry) => entry.event === "watch_page_unreadable")?.cause).toBe("video_missing");
+ });
+ });
+});
diff --git a/features/scenario.ts b/features/scenario.ts
new file mode 100644
index 0000000..bfe217e
--- /dev/null
+++ b/features/scenario.ts
@@ -0,0 +1,46 @@
+import { expect, it } from "vitest";
+
+// A scenario reads as one sentence per step. When a step fails, the report names the step and the
+// whole scenario, so a failure says what the reader was supposed to get rather than a line number.
+
+export interface World {
+ answer: { statusCode: number; headers: Record; body: string } | null;
+ log: Record[];
+}
+
+type Step = (world: World) => Promise | void;
+
+export interface Steps {
+ given(text: string, step: Step): void;
+ when(text: string, step: Step): void;
+ then(text: string, step: Step): void;
+ and(text: string, step: Step): void;
+}
+
+export function scenario(title: string, build: (steps: Steps) => void): void {
+ const steps: { word: string; text: string; step: Step }[] = [];
+ const add = (word: string) => (text: string, step: Step) => {
+ steps.push({ word, text, step });
+ };
+
+ build({ given: add("Given"), when: add("When"), then: add("Then"), and: add("And") });
+
+ if (steps.length === 0) throw new Error(`the scenario "${title}" declares no step`);
+
+ it(title, async () => {
+ const world: World = { answer: null, log: [] };
+ for (const { word, text, step } of steps) {
+ try {
+ await step(world);
+ } catch (failure) {
+ const why = failure instanceof Error ? failure.message : String(failure);
+ expect.fail(`${word} ${text}\n\n${why}`);
+ }
+ }
+ });
+}
+
+export function theAnswer(world: World): { statusCode: number; headers: Record; body: string } {
+ if (world.answer === null) throw new Error("no page was asked for yet");
+ return world.answer;
+}
diff --git a/src/boundary.test.ts b/src/boundary.test.ts
new file mode 100644
index 0000000..6cb2e93
--- /dev/null
+++ b/src/boundary.test.ts
@@ -0,0 +1,191 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import type { Cause } from "./contract.js";
+import { route, type FunctionUrlEvent } from "./handler.js";
+import { makeLookup } from "./store/lookup.js";
+import { MemoryStore } from "./store/store.js";
+import { fetchTranscript } from "./transcript/youtube.js";
+
+// From the boundary to the page, with only the platform faked. Every one of these used to reach the
+// reader as the same 404 saying the video does not exist.
+
+const get = (id: string): FunctionUrlEvent => ({
+ rawPath: "/videos",
+ queryStringParameters: { id },
+ requestContext: { http: { method: "GET", path: "/videos" } },
+});
+
+const responseOf = (body: string, status = 200, cookies: string[] = []) => {
+ const headers = new Headers({ "content-type": "text/html; charset=utf-8" });
+ for (const cookie of cookies) headers.append("set-cookie", cookie);
+ return { ok: status >= 200 && status < 300, status, text: async () => body, headers } as unknown as Response;
+};
+
+// The shape the deployed function really received, copied from its own log: a full watch page,
+// HTTP 200, carrying videoDetails but no title, no caption track, and a player response that asks
+// the caller to prove it is a person.
+const BOT_CHECK_PAGE =
+ ` - YouTube`;
+
+const MISSING_VIDEO_PAGE =
+ ` - YouTube`;
+
+const GOOD_WATCH_PAGE =
+ `A title - YouTube`;
+
+const minter = async () => ({ mint: async () => "a-token", expiresAt: Date.now() + 60_000 });
+
+function pageFor(responses: Response[]) {
+ const f = vi.fn();
+ for (const response of responses) f.mockResolvedValueOnce(response);
+ const lookup = makeLookup(new MemoryStore(), (id) =>
+ fetchTranscript(id, { fetch: f as unknown as typeof fetch, minter }),
+ );
+ return { lookup, f };
+}
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe("what the reader is told, one answer per cause", () => {
+ it("a bot check is a refusal, and it never reads as a missing video", async () => {
+ const { lookup } = pageFor([responseOf(BOT_CHECK_PAGE)]);
+ const answer = await route(get("gyN9lV9QgyA"), lookup);
+
+ expect(answer.statusCode).toBe(502);
+ expect(answer.body).toContain("The platform refused this page, not your video");
+ expect(answer.body).toContain("Your id is right and the video is fine");
+ expect(answer.body).not.toContain("No video with that id");
+ expect(answer.body).not.toContain("may be deleted");
+ });
+
+ it("a bot check offers the video on the platform, and does not promise that trying again works", async () => {
+ const { lookup } = pageFor([responseOf(BOT_CHECK_PAGE)]);
+ const answer = await route(get("gyN9lV9QgyA"), lookup);
+
+ expect(answer.body).toContain("https://www.youtube.com/watch?v=gyN9lV9QgyA");
+ expect(answer.body).toContain("Trying again will not help");
+ });
+
+ it("a video that really is missing still answers 404", async () => {
+ const { lookup } = pageFor([responseOf(MISSING_VIDEO_PAGE)]);
+ const answer = await route(get("aaaaaaaaaaa"), lookup);
+
+ expect(answer.statusCode).toBe(404);
+ expect(answer.body).toContain("No video with that id");
+ });
+
+ it("a rate limit answers 429 and says when to come back", async () => {
+ const { lookup } = pageFor([responseOf("", 429)]);
+ const answer = await route(get("gyN9lV9QgyA"), lookup);
+
+ expect(answer.statusCode).toBe(429);
+ expect(answer.headers["retry-after"]).toBe("60");
+ expect(answer.body).toContain("asking too often");
+ });
+
+ it("a platform error answers 502, not 404", async () => {
+ const { lookup } = pageFor([responseOf("", 503)]);
+ const answer = await route(get("gyN9lV9QgyA"), lookup);
+
+ expect(answer.statusCode).toBe(502);
+ expect(answer.body).toContain("The platform did not answer");
+ });
+
+ it("captions refused after the video was found answers 502 and says the video is fine", async () => {
+ const { lookup } = pageFor([responseOf(GOOD_WATCH_PAGE, 200, ["VISITOR=1; Path=/"]), responseOf("")]);
+ const answer = await route(get("gyN9lV9QgyA"), lookup);
+
+ expect(answer.statusCode).toBe(502);
+ expect(answer.body).toContain("The platform refused the caption text");
+ expect(answer.body).toContain("Your id is right and the video is fine");
+ });
+
+ it("a page carrying no video and no reason is a refusal, not a deletion", async () => {
+ const { lookup } = pageFor([responseOf("something else entirely")]);
+ const answer = await route(get("gyN9lV9QgyA"), lookup);
+
+ expect(answer.statusCode).toBe(502);
+ expect(answer.body).toContain("The platform sent something this page cannot read");
+ });
+
+ it("a working video still serves its text, so none of this broke the good path", async () => {
+ const captions = JSON.stringify({
+ events: [{ tStartMs: 0, dDurationMs: 1500, segs: [{ utf8: "the words" }] }],
+ });
+ const { lookup } = pageFor([
+ responseOf(GOOD_WATCH_PAGE, 200, ["VISITOR=1; Path=/"]),
+ responseOf(captions),
+ ]);
+ const answer = await route(get("gyN9lV9QgyA"), lookup);
+
+ expect(answer.statusCode).toBe(200);
+ expect(answer.body).toContain("the words");
+ });
+
+ it("never holds a refusal at the edge, so the next visit reaches the platform", async () => {
+ const { lookup } = pageFor([responseOf(BOT_CHECK_PAGE)]);
+ const answer = await route(get("gyN9lV9QgyA"), lookup);
+ expect(answer.headers["cache-control"]).toBe("no-store");
+ });
+});
+
+describe("what the log says while the reader is being told", () => {
+ it("names the cause, the status and the shape of the body", async () => {
+ const spy = vi.spyOn(console, "log").mockImplementation(() => undefined);
+ const { lookup } = pageFor([responseOf(BOT_CHECK_PAGE)]);
+ await route(get("gyN9lV9QgyA"), lookup);
+
+ const lines = spy.mock.calls.map((call) => JSON.parse(String(call[0])) as Record);
+ const boundary = lines.find((l) => l.event === "watch_page_unreadable");
+
+ expect(boundary).toBeDefined();
+ expect(boundary?.cause satisfies unknown as Cause).toBe("bot_check");
+ expect(boundary?.status).toBe(200);
+ expect(boundary?.playability).toBe("LOGIN_REQUIRED");
+ expect(boundary?.has_caption_tracks).toBe(false);
+ expect(String(boundary?.playability_reason)).toContain("not a bot");
+ });
+
+ it("writes the cause the page shows, so a log line and a page line up", async () => {
+ const spy = vi.spyOn(console, "log").mockImplementation(() => undefined);
+ const { lookup } = pageFor([responseOf(MISSING_VIDEO_PAGE)]);
+ await route(get("aaaaaaaaaaa"), lookup);
+
+ const lines = spy.mock.calls.map((call) => JSON.parse(String(call[0])) as Record);
+ expect(lines.find((l) => l.event === "watch_page_unreadable")?.cause).toBe("video_missing");
+ });
+
+ // The caption address carries a signature and the request carries cookies and a minted token.
+ it("never writes a credential, whatever the platform answered", async () => {
+ const spy = vi.spyOn(console, "log").mockImplementation(() => undefined);
+ const { lookup } = pageFor([responseOf(GOOD_WATCH_PAGE, 200, ["VISITOR=s3ss10n; Path=/"]), responseOf("")]);
+ await route(get("gyN9lV9QgyA"), lookup);
+
+ const everything = spy.mock.calls.map((call) => String(call[0])).join("\n");
+ expect(everything).toContain("captions_unreadable");
+ expect(everything).not.toContain("s3cr3t");
+ expect(everything).not.toContain("s3ss10n");
+ expect(everything).not.toContain("a-token");
+ expect(everything).not.toContain("signature");
+ });
+
+ it("is silent about nothing: a read that worked is written too", async () => {
+ const spy = vi.spyOn(console, "log").mockImplementation(() => undefined);
+ const captions = JSON.stringify({
+ events: [{ tStartMs: 0, dDurationMs: 1500, segs: [{ utf8: "the words" }] }],
+ });
+ const { lookup } = pageFor([responseOf(GOOD_WATCH_PAGE, 200, []), responseOf(captions)]);
+ await route(get("gyN9lV9QgyA"), lookup);
+
+ const events = spy.mock.calls.map((call) => (JSON.parse(String(call[0])) as { event: string }).event);
+ expect(events).toContain("watch_page_read");
+ expect(events).toContain("captions_read");
+ });
+});
diff --git a/src/contract.ts b/src/contract.ts
index a4db155..c1c3735 100644
--- a/src/contract.ts
+++ b/src/contract.ts
@@ -20,11 +20,27 @@ export interface TranscriptItem {
source: string;
}
+// Why a read did not produce a transcript. The reader is told a different thing for each one, and
+// the log names the same value, so a page and a log line can be lined up.
+//
+// The default is never "the video is missing". A page this code does not recognise is a refusal,
+// because reporting a refusal as a missing video is how a working video came to read as deleted.
+export type Cause =
+ | "video_missing"
+ | "bot_check"
+ | "consent_wall"
+ | "rate_limited"
+ | "platform_error"
+ | "unrecognised_page"
+ | "captions_refused"
+ | "captions_not_json"
+ | "captions_empty";
+
export type LookupResult =
| { kind: "ok"; item: TranscriptItem; cached: boolean }
| { kind: "bad_id"; given: string }
| { kind: "not_found"; video_id: string }
- | { kind: "upstream_failed"; video_id: string; reason: string };
+ | { kind: "upstream_failed"; video_id: string; cause: Cause; reason: string };
export const VIDEO_ID_PATTERN = /^[A-Za-z0-9_-]{11}$/;
diff --git a/src/handler.test.ts b/src/handler.test.ts
index 548a1fe..0575f1d 100644
--- a/src/handler.test.ts
+++ b/src/handler.test.ts
@@ -83,7 +83,7 @@ describe("what the edge is told to hold", () => {
});
it("never holds a failure, so the next visit reaches the platform", () => {
- expect(cacheControlFor({ kind: "upstream_failed", video_id: "x", reason: "r" })).toBe("no-store");
+ expect(cacheControlFor({ kind: "upstream_failed", video_id: "x", cause: "bot_check", reason: "r" })).toBe("no-store");
});
});
diff --git a/src/handler.ts b/src/handler.ts
index ca12185..996c91a 100644
--- a/src/handler.ts
+++ b/src/handler.ts
@@ -32,10 +32,19 @@ export function cacheControlFor(result: LookupResult | null): string {
}
}
-function answer(page: Rendered, cacheControl: string): HttpAnswer {
+// A rate limit is the one refusal that says when to come back, so the answer says it too.
+export function retryAfterFor(result: LookupResult): Record {
+ if (result.kind === "upstream_failed" && result.cause === "rate_limited") {
+ return { "retry-after": "60" };
+ }
+ return {};
+}
+
+function answer(page: Rendered, cacheControl: string, extra: Record = {}): HttpAnswer {
return {
statusCode: page.status,
headers: {
+ ...extra,
"content-type": "text/html; charset=utf-8",
"cache-control": cacheControl,
"x-content-type-options": "nosniff",
@@ -61,7 +70,7 @@ export async function route(event: FunctionUrlEvent, lookup: Lookup): Promise {
expect(first?.segments.length).toBeGreaterThan(100);
});
});
+
+// What the deployed function is served at a datacentre address: a watch page carrying the bot
+// check. Nothing else is faked, so the player endpoint and the caption address are the real ones.
+// This is the check that the deploy gate was failing on.
+describe("against the real platform, with the watch page refused", () => {
+ const BOT_CHECK =
+ `YouTube`;
+
+ it("still reaches the text through the player endpoint", { timeout: 120_000 }, async () => {
+ const real = globalThis.fetch;
+ const refused = (async (url: string | URL | Request, options?: RequestInit) => {
+ if (String(url).includes("/watch?v=")) {
+ return new Response(BOT_CHECK, { status: 200, headers: { "content-type": "text/html; charset=utf-8" } });
+ }
+ return real(url, options);
+ }) as unknown as typeof fetch;
+
+ const item = await fetchTranscript("gyN9lV9QgyA", { fetch: refused });
+
+ console.log(
+ `refused watch page captions=${item.has_captions} segments=${item.segments.length}` +
+ ` chars=${item.text.length} source=${item.source}`,
+ );
+ expect(item.has_captions).toBe(true);
+ expect(item.segments.length).toBeGreaterThan(100);
+ expect(item.title.length).toBeGreaterThan(0);
+ });
+});
diff --git a/src/observe.test.ts b/src/observe.test.ts
new file mode 100644
index 0000000..5b299ad
--- /dev/null
+++ b/src/observe.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, it } from "vitest";
+import { line, withoutSecrets } from "./observe.js";
+
+describe("writing a line", () => {
+ it("names the event and carries the fields", () => {
+ expect(JSON.parse(line("watch_page_read", { video_id: "gyN9lV9QgyA", status: 200 }))).toEqual({
+ event: "watch_page_read",
+ video_id: "gyN9lV9QgyA",
+ status: 200,
+ });
+ });
+
+ it("writes one line, so one read is one record", () => {
+ expect(line("a", { b: "c\nd" })).not.toContain("\n");
+ });
+});
+
+describe("keeping credentials out of the log", () => {
+ // The caption address carries the signature the platform issued, the token minted for this video
+ // and whatever else travels in the query. None of it belongs in a log that is kept for a fortnight.
+ const signed =
+ "https://www.youtube.com/api/timedtext?v=gyN9lV9QgyA&signature=s3cr3t&expire=1756000000&pot=t0ken&fmt=json3";
+
+ it("keeps the host and the path and drops the query", () => {
+ expect(withoutSecrets(signed)).toBe("https://www.youtube.com/api/timedtext");
+ });
+
+ it("drops the signature, the token and the expiry", () => {
+ const written = withoutSecrets(signed);
+ expect(written).not.toContain("s3cr3t");
+ expect(written).not.toContain("t0ken");
+ expect(written).not.toContain("expire");
+ });
+
+ it("says so rather than throwing when the address does not parse", () => {
+ expect(withoutSecrets("not an address")).toBe("an address that does not parse");
+ });
+});
diff --git a/src/observe.ts b/src/observe.ts
new file mode 100644
index 0000000..1fa1157
--- /dev/null
+++ b/src/observe.ts
@@ -0,0 +1,25 @@
+// One line of JSON per thing worth knowing. CloudWatch reads a JSON line as fields, so a query can
+// count causes without a regular expression over prose.
+//
+// Nothing here ever carries a credential. The cookies the watch page sets, the proof of origin
+// token and the signature on a caption address are all secrets, so an address is reduced to its
+// host and path before it is written, and no header is written at all.
+
+export type Fields = Record;
+
+export function withoutSecrets(url: string): string {
+ try {
+ const parsed = new URL(url);
+ return `${parsed.origin}${parsed.pathname}`;
+ } catch {
+ return "an address that does not parse";
+ }
+}
+
+export function line(event: string, fields: Fields): string {
+ return JSON.stringify({ event, ...fields });
+}
+
+export function observe(event: string, fields: Fields): void {
+ console.log(line(event, fields));
+}
diff --git a/src/page/render.test.ts b/src/page/render.test.ts
index b4ce41d..16d88e5 100644
--- a/src/page/render.test.ts
+++ b/src/page/render.test.ts
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
-import { SCHEMA_VERSION, type LookupResult, type TranscriptItem } from "../contract.js";
+import { SCHEMA_VERSION, type Cause, type LookupResult, type TranscriptItem } from "../contract.js";
import { escapeHtml } from "./escape.js";
-import { paragraphsOf, renderIndex, renderPage, renderUnknownRoute } from "./render.js";
+import { REFUSALS, paragraphsOf, renderIndex, renderPage, renderUnknownRoute } from "./render.js";
const item = (over: Partial = {}): TranscriptItem => ({
video_id: "gyN9lV9QgyA",
@@ -141,22 +141,37 @@ describe("the pages for what went wrong", () => {
expect(page.html).toContain("aaaaaaaaaaa");
});
- it("answers 502 when the platform refused, and carries the reason", () => {
+ it("answers 502 when the platform refused, and says which refusal it was", () => {
const page = renderPage({
kind: "upstream_failed",
video_id: "gyN9lV9QgyA",
- reason: "the caption endpoint returned an empty body",
+ cause: "captions_refused",
+ reason: "an empty body",
});
expect(page.status).toBe(502);
- expect(page.html).toContain("The platform did not answer");
- expect(page.html).toContain("the caption endpoint returned an empty body");
+ expect(page.html).toContain("The platform refused the caption text");
+ });
+
+ // A refusal that answered 404 is the defect this page was built on. Nothing in this map may.
+ it("answers 404 for a missing video and for nothing else", () => {
+ for (const [cause, refusal] of Object.entries(REFUSALS)) {
+ expect(refusal.status === 404).toBe(cause === "video_missing");
+ }
+ });
+
+ it("gives every cause its own page, and never leaves one unrendered", () => {
+ for (const cause of Object.keys(REFUSALS) as Cause[]) {
+ const page = renderPage({ kind: "upstream_failed", video_id: "gyN9lV9QgyA", cause, reason: "r" });
+ expect(page.html).toContain(REFUSALS[cause].title);
+ expect(page.status).toBeGreaterThanOrEqual(400);
+ }
});
it("offers a way onward from every problem page", () => {
for (const result of [
{ kind: "bad_id", given: "x" },
{ kind: "not_found", video_id: "aaaaaaaaaaa" },
- { kind: "upstream_failed", video_id: "aaaaaaaaaaa", reason: "r" },
+ { kind: "upstream_failed", video_id: "aaaaaaaaaaa", cause: "bot_check", reason: "r" },
] satisfies LookupResult[]) {
expect(renderPage(result).html).toContain('