Skip to content

Commit 6044f36

Browse files
authored
perf(web,api): drive ISR revalidation from ingest instead of the clock (#2199)
* perf(web,api): drive ISR revalidation from ingest instead of the clock ISR writes were ~49% of the Vercel bill ($9.10 of $18.77, Jul 15-29; ~151K writes/day). Two separate causes, both invisible at runtime. 1. A fetch revalidate in the ROOT LAYOUT capped every route in the app. A route's regeneration period is the MIN of its `export const revalidate` and every fetch revalidate in its render tree, layouts included. The site-notice fetch carried `next: { revalidate: 60 }` and renders in the root layout, so it silently overrode #2004's 900s bump the day after it landed. A second instance -- `revalidate: 3600` on the header's GitHub star count -- capped the site at an hour even after that. Nothing breaks when this happens; pages just regenerate ~1400x/day and the only symptom is the ISR-writes line item. `web/src/lib/isr.test.ts` now walks the import graph from `app/layout.tsx` and fails if anything reachable from it caches below the floor. A flat scan over src/ would be wrong both ways: it flags route handlers legitimately caching their own upstream, and misses that layout-reachability is the point. 2. Time-based ISR was the wrong model for the long tail. `/[orgSlug]` was 5.1K writes across 150 paths -- 34/path/day, one per ~42min. Those pages are traffic-bound, not TTL-bound, so raising the window barely moves them; requests arrive further apart than any reasonable TTL. Only invalidating on the write that actually changed the page helps. `runBatchIngestEffects` now calls `notifyWebRevalidate` (sibling of the IndexNow ping, same trigger and fire-and-forget semantics), which POSTs the affected slugs to web's new `POST /api/revalidate`. Page-level windows move 900 -> 86400 and become a backstop for a dropped ping rather than the freshness mechanism. Pages also get fresher: releases appear on ingest instead of up to 15 minutes later. Notes: - Auth is a CHANNEL credential (`RELEASES_SERVICE_KEY` / `WEB_SERVICE_KEY`, `verifyServiceKey`), mirroring RELEASES_PROXY_KEY inbound -- this is the first authenticated API-worker -> web call, and the next internal endpoint reuses it rather than minting a per-feature secret. Deliberately not the root API key. - `notifyWebRevalidate` does NOT copy IndexNow's `discovery === "on_demand"` skip; that gate is about search indexing, not staleness. - Slugs are pattern-validated before interpolation. `revalidatePath("/")` would evict the whole cache on every ingest. - Unbound on staging so prod ingest can't reach staging web's cache. * fix(web,api): close two holes in the ISR guard and revalidate ping Both from CodeRabbit review on #2199, plus one found while verifying them. 1. The guard was blind to symbolic values (isr.test.ts). `FETCH_REVALIDATE` only captured digits, so `next: { revalidate: DEFAULT_REVALIDATE_SECONDS }` in github-star.tsx -- the exact file the guard was added to watch -- was skipped entirely. Converting that literal to a constant is what made it invisible. A future `revalidate: SOME_LOW_CONST` would have passed silently; a green guard that cannot see the pattern the codebase actually uses is worse than no guard. Now captures the whole expression and resolves known constants to their real values. Unrecognized symbols are reported as offenders rather than skipped -- fail closed, per repo convention. Scanner extracted as `revalidateOffenders` so it is unit-testable against synthetic input instead of only the real graph. 2. Pre-flight awaits escaped the try (web-revalidate.ts). The secret read and both slug lookups sat outside it, so a rejected Secrets Store binding or D1 blip threw out of a function whose own doc comment promises it never throws into the ingest path. `Promise.allSettled` in runBatchIngestEffects then swallowed it: no log line, no result, page silently stale until the backstop. Given its own try so `resolve-failed` and `ping-failed` stay distinguishable in Axiom -- one means we never reached web, the other means web did not answer. 3. The floor aliased the default (found while verifying #1). `ISR_REVALIDATE_FLOOR_SECONDS = DEFAULT_REVALIDATE_SECONDS` meant lowering the default lowered the floor with it, so the likeliest future regression -- someone tuning that default down -- kept the guard green. Now an independent literal, plus a direct assertion that the default sits at or above it. That assertion is also what covers `applyCacheInit`, which propagates the default through a variable the source scan cannot see. Verified by temporarily lowering the default and confirming the guard trips on both the github-star constant and the invariant.
1 parent 80f55ec commit 6044f36

30 files changed

Lines changed: 1041 additions & 45 deletions

File tree

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,8 @@ The `release_coverage` schema lives with the rest of the DB-coupled internals in
9191
- Org overviews: AI-generated `knowledge_pages` (scope `org`) summarize recent activity; display staleness warning `OVERVIEW_STALE_DAYS = 30` from `@buildinternet/releases-core/overview`. Automated regen runs on a daily cron with a per-org cadence (7d default, 2d velocity fast tier, `overview_cadence_days` manual override; #1895). See [web.md → Org overviews](docs/architecture/web.md).
9292
- Collection daily summaries: one `collection_daily_summaries` row per (collection, ET day) — title + one-line summary + bullet takeaways generated nightly over closed ET days via the **shared summarization lane** (reuses `SUMMARIZE_MODEL` + its Haiku fail-open, distinct only by `generationName`; no per-feature model var), gated per-collection by `collections.daily_summary_enabled`; read via `GET /v1/collections/:slug/daily-summaries`, rendered as timeline date headers. See [web.md → Daily summaries](docs/architecture/web.md).
9393
- GitHub CHANGELOG files are fetched alongside tagged releases, stored in `source_changelog_files` (refresh piggybacks on every GitHub fetch); web surfaces them via `GET /v1/sources/:slug/changelog`. See [web.md](docs/architecture/web.md).
94+
- **ISR revalidation is ingest-driven, not clock-driven.** Ingest pings web's `POST /api/revalidate` (`notifyWebRevalidate`, sibling of the IndexNow ping); the pages' `revalidate = 86400` is only a backstop. A fetch revalidate anywhere in the ROOT LAYOUT's import graph caps EVERY route in the app — guarded by `web/src/lib/isr.test.ts`. See [web.md → ISR revalidation](docs/architecture/web.md).
95+
- **API-worker → web internal calls share ONE channel credential** (`RELEASES_SERVICE_KEY` / `WEB_SERVICE_KEY`, verified by `verifyServiceKey`), mirroring `RELEASES_PROXY_KEY` inbound. Reuse it for the next internal endpoint — do NOT mint a per-feature secret; it is deliberately not the root API key. See [web.md → ISR revalidation](docs/architecture/web.md).
9496
- Media handling: at ingest, `normalizeMediaUrl()` (`packages/rendering/src/media-url.ts`) strips Next.js/Vercel image-optimizer wrappers so the underlying CDN URL is stored. Ingest-time R2 mirroring runs whenever the `MEDIA` bucket binding is bound (always in prod); an unbound bucket stores third-party URLs verbatim. See [web.md → Media handling](docs/architecture/web.md).
9597
- **Inline hosted-video cards (#1549):** the cron poll-fetch media pre-pass detects Wistia/Loom/Vimeo/YouTube links in a new release's body (`detectInlineVideos`, `packages/rendering/src/video-embed.ts`), resolves a poster via oEmbed, and appends a `{ type:"video", url:<poster>, alt, linkUrl:<watchUrl> }` `media[]` item that rides the existing `processMediaForR2` mirror; web renders a read-only play-thumbnail card (`InlineVideoCard`) linking out. Special-cased to video (first inline-body asset promoted to mirrored media); fail-open, no flag; iframe embed deferred. See [web.md → Inline hosted-video cards](docs/architecture/web.md).
9698
- **Manual release media edit:** `PATCH /v1/releases/:id { media: [...] }` REPLACES a release's stored `media[]` wholesale (curator fix without re-sync); not-yet-mirrored items (no `r2Key`, not on `MEDIA_ORIGIN`) run through the ingest `processMediaForR2` mirror, gated on `env.MEDIA != null`. Cron re-fetch never clobbers this (`onConflictDoNothing` / `RELEASE_URL_UPSERT` only backfills stored-empty media). See [web.md → Media handling].

docs/architecture/deploy-coupling.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ Unbound optional bindings fail open: no `MEDIA` R2 → third-party media URLs st
7575

7676
Values live in the dashboard, never in git. Forks provision their own store and rebind every `secrets_store_secrets` entry:
7777

78-
`RELEASED_API_KEY`, `RELEASES_API_KEY`, `RELEASES_PROXY_KEY`, `GITHUB_TOKEN`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_API_KEY`, `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `VOYAGER_API_KEY`, `ANTHROPIC_API_KEY`, `AI_GATEWAY_TOKEN`, `OPENROUTER_API_KEY`, `CLOUDFLARE_ACCOUNT_ID`, `CLOUDFLARE_API_TOKEN`, `WEBHOOK_HMAC_MASTER`, `INDEXNOW_KEY`, `WEB_BOT_AUTH_PRIVATE_KEY`, `FIRECRAWL_API_KEY`, `FIRECRAWL_WEBHOOK_SECRET`, `RELEASES_GITHUB_WEBHOOK_SECRET`, `STAGING_ACCESS_KEY` (staging only).
78+
`RELEASED_API_KEY`, `RELEASES_API_KEY`, `RELEASES_PROXY_KEY`, `GITHUB_TOKEN`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_API_KEY`, `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `VOYAGER_API_KEY`, `ANTHROPIC_API_KEY`, `AI_GATEWAY_TOKEN`, `OPENROUTER_API_KEY`, `CLOUDFLARE_ACCOUNT_ID`, `CLOUDFLARE_API_TOKEN`, `WEBHOOK_HMAC_MASTER`, `INDEXNOW_KEY`, `WEB_SERVICE_KEY` (prod only — see below), `WEB_BOT_AUTH_PRIVATE_KEY`, `FIRECRAWL_API_KEY`, `FIRECRAWL_WEBHOOK_SECRET`, `RELEASES_GITHUB_WEBHOOK_SECRET`, `STAGING_ACCESS_KEY` (staging only).
7979

8080
Classic worker secret (not in Secrets Store): `ANTHROPIC_BASE_URL` — account-scoped AI Gateway URL on api + discovery; unset → direct Anthropic. Local dev: `workers/*/.dev.vars.example`.
8181

@@ -102,6 +102,6 @@ Staging uses a separate agent/env/vault/memstore set in `[env.staging]`. API wor
102102

103103
### Outside wrangler
104104

105-
- **Web (Vercel):** `web/.env.example``NEXT_PUBLIC_BETTER_AUTH_URL`, `RELEASES_API_URL`, `INDEXNOW_KEY` (must match api secret).
105+
- **Web (Vercel):** `web/.env.example``NEXT_PUBLIC_BETTER_AUTH_URL`, `RELEASES_API_URL`, `INDEXNOW_KEY` (must match api secret), `RELEASES_SERVICE_KEY` (channel credential for API-worker → web internal endpoints, mirroring `RELEASES_PROXY_KEY` inbound; must match the api worker's `WEB_SERVICE_KEY`; deliberately unbound on staging so prod ingest can't reach staging web's ISR cache).
106106
- **MCP Registry:** `sh.releases/mcp` — domain auth via `/.well-known/mcp-registry-auth`; CI secret `MCP_REGISTRY_PRIVATE_KEY_PEM`.
107107
- **Security disclosure:** `security@releases.sh`, [releases.sh/.well-known/security.txt](https://releases.sh/.well-known/security.txt) (no root `SECURITY.md`).

docs/architecture/web.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,20 @@ Next.js's `opengraph-image.tsx` file convention (one per route segment, cascadin
5353

5454
Dynamic routes carry `revalidate = 86400` so first-render cost amortizes across 24h of CDN hits; static routes (`/`, `/docs/*`) render at build. Tests in `tests/unit/og-helpers.test.ts`.
5555

56+
## ISR revalidation
57+
58+
Org, source, product and release pages are statically rendered. **Freshness comes from an ingest-time ping, not from the clock.** When `runBatchIngestEffects` inserts releases it calls `notifyWebRevalidate` (`workers/api/src/lib/web-revalidate.ts`) — a sibling of the IndexNow ping, same trigger and same fire-and-forget semantics — which POSTs the affected `{ orgSlug, sourceSlug?, productSlug? }` to web's `POST /api/revalidate`. The route's whole contract (bearer auth, body validation, path derivation) lives in `web/src/lib/revalidate-request.ts` so it is testable without `next/cache`; the route module only injects `revalidatePath` and the secret.
59+
60+
`export const revalidate = 86400` on those pages is the **backstop for a dropped ping**, not the mechanism. Time-based ISR fits this content badly: the long tail of org pages is low-traffic enough that a short window regenerates pages nothing changed on (writes ≈ requests), while a long one would leave fresh releases invisible. Windows and the shared default live in `web/src/lib/isr.ts`.
61+
62+
**Auth is a channel credential, not a per-feature one.** `RELEASES_SERVICE_KEY` (Vercel) / `WEB_SERVICE_KEY` (Secrets Store) authenticates first-party backend callers of web's internal endpoints — the API-worker → web direction, mirroring `RELEASES_PROXY_KEY` on the way in. `/api/revalidate` opened that trust boundary but does not own it: the next internal endpoint reuses the same key via `verifyServiceKey` (`web/src/lib/service-auth.ts`) rather than minting its own secret. It is deliberately NOT the root `RELEASES_API_KEY` — a leak should mean "someone can bust caches", not "someone is API root". The corollary is that everything behind this key shares a blast radius, so an endpoint that can do materially more damage than cache invalidation is a reason to revisit the boundary rather than quietly widen it.
63+
64+
Why it needs auth at all: `revalidatePath` marks entries stale and regeneration happens on the next request, so an open endpoint is a lever that converts a cheap POST into unbounded ISR writes — either by evict-then-request, or just by evicting and letting existing crawler traffic pay for the re-render. That is the exact line item this design exists to shrink.
65+
66+
Gating differs from IndexNow's on purpose. `notifyWebRevalidate` skips on no-secret / zero-rows / hidden source / no org, but **not** on `discovery === "on_demand"` — that gate keeps low-signal pages out of search indexes, which says nothing about whether cached HTML is stale.
67+
68+
> **The trap this replaced.** A route's regeneration period is the MIN of its `export const revalidate` and every fetch revalidate in its render tree, **layouts included**. A `next: { revalidate: 60 }` on the site-notice fetch — reached from the root layout — capped every route in the app at 60s for three weeks, silently overriding #2004's 900s bump; a second instance (`revalidate: 3600` on the header's GitHub star count) capped it at an hour. Nothing breaks at runtime; the only symptom is the Vercel ISR-write line item, which reached ~49% of the bill. `web/src/lib/isr.test.ts` walks the import graph from `app/layout.tsx` and fails if anything reachable from it caches below the floor.
69+
5670
## On-demand lookup field in search responses
5771

5872
`GET /v1/search` (lexical + hybrid) and the MCP `search` tool include a `lookup` field when the query parses as a `{org}/{repo}` GitHub coordinate **and** the in-DB search returned zero hits (no orgs, no catalog entries, no release/changelog-chunk hits). When either condition fails, the route skips the lookup call and `lookup` is `null`. Shape:

tests/unit/web-revalidate.test.ts

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
import { describe, it, expect } from "bun:test";
2+
import {
3+
notifyWebRevalidate,
4+
type WebRevalidateEnv,
5+
type RevalidateableSource,
6+
} from "../../workers/api/src/lib/web-revalidate.js";
7+
8+
const SECRET_VALUE = "shared-revalidate-secret";
9+
const SECRET = {
10+
async get() {
11+
return SECRET_VALUE;
12+
},
13+
};
14+
15+
const SOURCE: RevalidateableSource = {
16+
slug: "nextjs",
17+
orgId: "org_1",
18+
productId: null,
19+
isHidden: false,
20+
};
21+
22+
const DB = {
23+
async resolveOrgSlug(id: string) {
24+
return id === "org_1" ? "vercel" : null;
25+
},
26+
async resolveProductSlug(id: string) {
27+
return id === "prod_1" ? "next" : null;
28+
},
29+
};
30+
31+
function envOn(overrides: Partial<WebRevalidateEnv> = {}): WebRevalidateEnv {
32+
return {
33+
WEB_SERVICE_KEY: SECRET,
34+
WEB_BASE_URL: "https://releases.sh",
35+
...overrides,
36+
};
37+
}
38+
39+
interface Recorded {
40+
url: string;
41+
method: string;
42+
authorization: string | null;
43+
body: unknown;
44+
}
45+
46+
/** Records the outbound ping and replies with `status`. */
47+
function recorder(status = 200): { calls: Recorded[]; fetchImpl: typeof fetch } {
48+
const calls: Recorded[] = [];
49+
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
50+
const headers = new Headers(init?.headers);
51+
calls.push({
52+
url: String(input),
53+
method: init?.method ?? "GET",
54+
authorization: headers.get("authorization"),
55+
body: init?.body ? JSON.parse(String(init.body)) : null,
56+
});
57+
return new Response(JSON.stringify({ revalidated: [] }), { status });
58+
}) as unknown as typeof fetch;
59+
return { calls, fetchImpl };
60+
}
61+
62+
describe("notifyWebRevalidate", () => {
63+
it("posts the affected slugs to the web revalidate endpoint", async () => {
64+
const { calls, fetchImpl } = recorder();
65+
const res = await notifyWebRevalidate(envOn(), DB, SOURCE, 3, { fetchImpl });
66+
67+
expect(res.status).toBe("revalidated");
68+
expect(calls).toHaveLength(1);
69+
expect(calls[0]!.url).toBe("https://releases.sh/api/revalidate");
70+
expect(calls[0]!.method).toBe("POST");
71+
expect(calls[0]!.authorization).toBe(`Bearer ${SECRET_VALUE}`);
72+
expect(calls[0]!.body).toEqual({ orgSlug: "vercel", sourceSlug: "nextjs" });
73+
});
74+
75+
it("includes the product slug when the source has a product", async () => {
76+
const { calls, fetchImpl } = recorder();
77+
await notifyWebRevalidate(envOn(), DB, { ...SOURCE, productId: "prod_1" }, 1, { fetchImpl });
78+
79+
expect(calls[0]!.body).toEqual({
80+
orgSlug: "vercel",
81+
sourceSlug: "nextjs",
82+
productSlug: "next",
83+
});
84+
});
85+
86+
it("skips when no secret binding is configured", async () => {
87+
const { calls, fetchImpl } = recorder();
88+
const res = await notifyWebRevalidate(envOn({ WEB_SERVICE_KEY: undefined }), DB, SOURCE, 1, {
89+
fetchImpl,
90+
});
91+
92+
expect(res).toEqual({ status: "skipped", reason: "no_secret_binding" });
93+
expect(calls).toEqual([]);
94+
});
95+
96+
it("skips when the secret binding resolves empty", async () => {
97+
const { calls, fetchImpl } = recorder();
98+
const res = await notifyWebRevalidate(
99+
envOn({
100+
WEB_SERVICE_KEY: {
101+
async get() {
102+
return undefined;
103+
},
104+
},
105+
}),
106+
DB,
107+
SOURCE,
108+
1,
109+
{ fetchImpl },
110+
);
111+
112+
expect(res).toEqual({ status: "skipped", reason: "secret_unset" });
113+
expect(calls).toEqual([]);
114+
});
115+
116+
it("skips when nothing was inserted", async () => {
117+
const { calls, fetchImpl } = recorder();
118+
const res = await notifyWebRevalidate(envOn(), DB, SOURCE, 0, { fetchImpl });
119+
120+
expect(res).toEqual({ status: "skipped", reason: "no_releases" });
121+
expect(calls).toEqual([]);
122+
});
123+
124+
it("skips a hidden source, whose pages are filtered from public reads", async () => {
125+
const { calls, fetchImpl } = recorder();
126+
const res = await notifyWebRevalidate(envOn(), DB, { ...SOURCE, isHidden: true }, 1, {
127+
fetchImpl,
128+
});
129+
130+
expect(res).toEqual({ status: "skipped", reason: "source_hidden" });
131+
expect(calls).toEqual([]);
132+
});
133+
134+
it("skips an org-less source, which has no org page to revalidate", async () => {
135+
const { calls, fetchImpl } = recorder();
136+
const res = await notifyWebRevalidate(envOn(), DB, { ...SOURCE, orgId: null }, 1, {
137+
fetchImpl,
138+
});
139+
140+
expect(res).toEqual({ status: "skipped", reason: "no_org" });
141+
expect(calls).toEqual([]);
142+
});
143+
144+
it("reports a non-2xx response as an error without throwing", async () => {
145+
const { fetchImpl } = recorder(401);
146+
const res = await notifyWebRevalidate(envOn(), DB, SOURCE, 1, { fetchImpl });
147+
148+
expect(res.status).toBe("error");
149+
expect(res.httpStatus).toBe(401);
150+
});
151+
152+
// The pre-flight awaits (secret read, slug lookups) are as failure-prone as the
153+
// ping itself. Left outside the try they escape as a rejected promise, which
154+
// `Promise.allSettled` in runBatchIngestEffects swallows — no log line, no
155+
// result, and the page silently stays stale until the backstop.
156+
it("swallows a rejected secret binding and reports it as an error", async () => {
157+
const { calls, fetchImpl } = recorder();
158+
const res = await notifyWebRevalidate(
159+
envOn({
160+
WEB_SERVICE_KEY: {
161+
async get() {
162+
throw new Error("secrets store unavailable");
163+
},
164+
},
165+
}),
166+
DB,
167+
SOURCE,
168+
1,
169+
{ fetchImpl },
170+
);
171+
172+
expect(res.status).toBe("error");
173+
expect(res.reason).toContain("secrets store unavailable");
174+
expect(calls).toEqual([]);
175+
});
176+
177+
it("swallows a rejected org-slug lookup and reports it as an error", async () => {
178+
const { calls, fetchImpl } = recorder();
179+
const failingDb = {
180+
async resolveOrgSlug() {
181+
throw new Error("D1_ERROR: network");
182+
},
183+
resolveProductSlug: DB.resolveProductSlug,
184+
};
185+
const res = await notifyWebRevalidate(envOn(), failingDb, SOURCE, 1, { fetchImpl });
186+
187+
expect(res.status).toBe("error");
188+
expect(res.reason).toContain("D1_ERROR");
189+
expect(calls).toEqual([]);
190+
});
191+
192+
it("swallows a rejected product-slug lookup and reports it as an error", async () => {
193+
const { calls, fetchImpl } = recorder();
194+
const failingDb = {
195+
resolveOrgSlug: DB.resolveOrgSlug,
196+
async resolveProductSlug() {
197+
throw new Error("D1_ERROR: network");
198+
},
199+
};
200+
const res = await notifyWebRevalidate(
201+
envOn(),
202+
failingDb,
203+
{ ...SOURCE, productId: "prod_1" },
204+
1,
205+
{ fetchImpl },
206+
);
207+
208+
expect(res.status).toBe("error");
209+
expect(calls).toEqual([]);
210+
});
211+
212+
// A dropped ping must never fail the ingest that triggered it — the page just
213+
// stays stale until the 24h backstop in web's `lib/isr.ts`.
214+
it("swallows a network failure and reports it as an error", async () => {
215+
const fetchImpl = (async () => {
216+
throw new Error("connect ECONNREFUSED");
217+
}) as unknown as typeof fetch;
218+
const res = await notifyWebRevalidate(envOn(), DB, SOURCE, 1, { fetchImpl });
219+
220+
expect(res.status).toBe("error");
221+
expect(res.reason).toContain("ECONNREFUSED");
222+
});
223+
});

web/.env.example

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,13 @@ NEXT_PUBLIC_USER_API_KEYS=false
5858
# half of `releases login`). The backend always registers the device plugin, so
5959
# this flag alone gates whether the pages are revealed. Default: off.
6060
NEXT_PUBLIC_DEVICE_AUTH_ENABLED=false
61+
62+
# Bearer key for first-party backend callers of web's internal endpoints — the
63+
# API-worker -> web direction, mirroring RELEASES_PROXY_KEY on the way in.
64+
# Channel-scoped, NOT per-feature: POST /api/revalidate (on-demand ISR
65+
# invalidation, pinged when a release is ingested) is simply its first consumer,
66+
# and the next internal endpoint reuses this rather than adding another secret.
67+
# Must match the WEB_SERVICE_KEY Secrets Store binding on the API worker. Unset
68+
# -> those endpoints fail closed (503); for revalidate that means pages ride
69+
# their 24h ISR backstop (web/src/lib/isr.ts). Server-side only; set in Vercel.
70+
# RELEASES_SERVICE_KEY=

web/src/app/[orgSlug]/(org)/overview/page.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,10 @@ import { getOrg, getOrgOverview } from "../../_lib/org-data";
2222
import { getOrgReleases } from "../../_lib/org-releases-data";
2323

2424
// On-demand ISR: render once per org on first request, then serve from cache
25-
// (revalidated every 15 min). See `enableOnDemandIsr`. (#1607)
26-
// Keep in sync with applyCacheInit's default (src/lib/api.ts): the route
25+
// (regenerated on ingest via POST /api/revalidate; 24h backstop). (#1607)
26+
// Keep in sync with DEFAULT_REVALIDATE_SECONDS (src/lib/isr.ts): the route
2727
// revalidates at the min() of this and every fetch revalidate on it.
28-
export const revalidate = 900;
28+
export const revalidate = 86400;
2929
export const generateStaticParams = enableOnDemandIsr;
3030

3131
export async function generateMetadata({

web/src/app/[orgSlug]/(org)/page.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,10 @@ import { getOrgReleases } from "../_lib/org-releases-data";
1515
import { enableOnDemandIsr } from "@/lib/static-params";
1616

1717
// On-demand ISR: render once per org on first request, then serve from cache
18-
// (revalidated every 15 min). See `enableOnDemandIsr`. (#1607)
19-
// Keep in sync with applyCacheInit's default (src/lib/api.ts): the route
18+
// (regenerated on ingest via POST /api/revalidate; 24h backstop). (#1607)
19+
// Keep in sync with DEFAULT_REVALIDATE_SECONDS (src/lib/isr.ts): the route
2020
// revalidates at the min() of this and every fetch revalidate on it.
21-
export const revalidate = 900;
21+
export const revalidate = 86400;
2222
export const generateStaticParams = enableOnDemandIsr;
2323

2424
export async function generateMetadata({

web/src/app/[orgSlug]/(org)/releases/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { enableOnDemandIsr } from "@/lib/static-params";
33

44
// On-demand ISR segment config kept so the redirect route still participates
55
// in the same static-params / cache story as sibling org tabs.
6-
export const revalidate = 900;
6+
export const revalidate = 86400;
77
export const generateStaticParams = enableOnDemandIsr;
88

99
/**

0 commit comments

Comments
 (0)