From d574164d1ce00ac3247d088fcec63d4fd442ceea Mon Sep 17 00:00:00 2001 From: Eugene Aseev Date: Tue, 22 Sep 2026 18:09:58 +0800 Subject: [PATCH 1/3] publish: give every project a stable URL that renders A release directory is named after its content digest, so republishing the same inputs lands on the same URL and different inputs get a new one. That is what makes a release immutable, and it is also why a link to one rots: a new viewer bundle is enough to move it, since the bundle is part of the digest. `latest.json` never moves but serves JSON, so it was not a link anyone could be given. `publish` now writes a forwarding page beside the pointer, and reports it as `entry_url`. The page reads `latest.json` in the browser rather than naming a release, so its bytes depend only on the target's prefix: every publish writes the same page, and the entry point cannot fall behind the pointer whatever order concurrent publishers finish in, or if a publish promotes and then fails. Nothing can drift because nothing is remembered. It needs script, as does the viewer it forwards to. It is written at two keys, `/index.html` and `/`, which is what makes the bare URL portable rather than a property of one CDN. A host that resolves directories looks for index.html, the name every static host agrees on; an object store serves keys and needs one of exactly that name. Both travel with the bucket. A prefix-less target has no directory key and no filesystem allows a file named that way, so those report the explicit index.html URL, and a store that rejects the key loses only the tidier form. An index.html already at the key without chainplot's marker is left alone and `entry_point_written` comes back false, so publishing into a bucket that serves a site of its own does not replace its front page. On s3 that refusal is a conditional write and holds against a concurrent writer; on directory it is a check and a rename, which docs/capabilities.md says rather than claiming otherwise. A precondition failure is re-read before reporting, so another chainplot publisher is not mistaken for a foreign owner. The page and the pointer are written no-cache, must-revalidate. A cache that serves either without asking would show an older release, which is the one thing the stable URL exists to prevent. Co-Authored-By: Claude Opus 5 --- README.md | 33 +++ docs/capabilities.md | 1 + src/publish/directory.ts | 58 +++++ src/publish/entryPoint.ts | 140 +++++++++++++ src/publish/publishRelease.ts | 23 ++ src/publish/s3.ts | 90 +++++++- src/publish/target.ts | 31 +++ tests/publish/entryPoint.test.ts | 349 +++++++++++++++++++++++++++++++ 8 files changed, 724 insertions(+), 1 deletion(-) create mode 100644 src/publish/entryPoint.ts create mode 100644 tests/publish/entryPoint.test.ts diff --git a/README.md b/README.md index 2775aa7..514a4d2 100644 --- a/README.md +++ b/README.md @@ -244,6 +244,39 @@ as a second agent. Both ingest examples are published live: Publishing more than one project into a single bucket needs a `prefix` on the target; without one, each project's `latest.json` overwrites the others'. +## The link you can share + +A release directory is named after its content digest, so the same inputs +republish to the same URL and different inputs get a new one. That makes a +release immutable, and it also means a link to one moves whenever the release +does — a new viewer bundle is enough, since the bundle is part of the digest. + +So `publish` writes an `index.html` beside `latest.json` that reads the +pointer in the browser and forwards to whichever release it names. The page +holds no release of its own, so it is the same bytes every publish and cannot +fall behind. The publish root is the stable link: + +```text +https://// → always the current release +https:////latest.json → the pointer it reads +``` + +The page is written twice, at `/index.html` and at `/`, which +is what makes the bare URL portable. A host that resolves a directory looks +for `index.html`, the one name every static host agrees on; an object store +serves keys and nothing else, so it needs a key of that exact name. Writing +both means the same link works either way, and it travels with the bucket +rather than living in a rewrite rule at whichever CDN is in front of it. + +A target publishing to the bucket root has no directory key to write, and no +filesystem allows a file named `/`, so `directory` targets and +prefix-less ones report the explicit `index.html` URL instead. + +`publish` returns it as `entry_url`. If an `index.html` is already at that key +and chainplot did not write it, it is left alone and `entry_point_written` +comes back false — a bucket that serves a site of its own keeps its front +page. + ## Look and feel The viewer transcribes the Chainstack design tokens from `cp-ui-kit` diff --git a/docs/capabilities.md b/docs/capabilities.md index 3d68929..957030f 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -31,6 +31,7 @@ Machine-readable source of truth: `chainplot capabilities --json`. - Event sources: explicit address lists, one chain per project, rindexer adapter (pinned image, linux/amd64) - End policies: `pinned` (finality-checked at plan time), `follow_finalized` (requires `finalized` chain policy) - Publish targets: `directory` (atomic `latest.json`), `s3` (conditional write; verified on Cloudflare R2). Set `prefix` on a target when one bucket or directory holds more than one project — `latest.json` is otherwise a single key at the root and the projects overwrite each other's pointer. +- Publish root: beside `latest.json`, `publish` writes an `index.html` that forwards to the release the pointer names. A release directory is content-addressed, so its URL moves whenever the release does — a change to the viewer bundle is enough, since the bundle is part of the digest. The root is the URL that stays put, and `publish` returns it as `entry_url`. The page is written both at `/index.html` and at `/`: a host that resolves directories finds the former, an object store that serves keys needs the latter, and writing both keeps the bare URL working on either without a rewrite rule at the CDN. A prefix-less target has no directory key and a `directory` target cannot name a file that way, so those report the explicit `index.html` URL. An `index.html` already at that key without chainplot's marker is left alone and `entry_point_written` comes back false, so publishing into a bucket that serves a site of its own does not replace that site's front page. On `s3` that refusal is enforced by a conditional write and holds against a concurrent writer; on `directory` it is a check followed by a rename, so a foreign file written into the same key mid-publish is overwritten. The page reads `latest.json` in the browser rather than naming a release, so its bytes depend only on the prefix: every publish writes the same page, and the entry point cannot fall behind the pointer whatever order concurrent publishers finish in. It needs script, as does the viewer it forwards to. Both the page and `latest.json` are written `no-cache, must-revalidate`, since a cache that serves either without asking would show an older release. - Charts: `line`, `bar`, `area`, `kpi`, `table` (allowlisted encodings only) - Dataset modes: `results_only` (default), `dataset_referenced`, `dataset_included`. The default publishes the page and its results only. `dataset_referenced` uploads the parquet beside the release and records a release-relative path and checksum, so `fork` fetches and verifies it on demand. `dataset_included` copies it into the release. Set per build (`--mode`) or per project (`policy.release_mode`). - Panel presentation: `title`, `description`, `span` (`half`/`full`), `hide_columns`, `unit` diff --git a/src/publish/directory.ts b/src/publish/directory.ts index 9050253..a2ffa3d 100644 --- a/src/publish/directory.ts +++ b/src/publish/directory.ts @@ -2,9 +2,11 @@ import fs from "node:fs"; import path from "node:path"; import type { LatestPointer } from "./target.js"; import type { PublishTarget } from "./target.js"; +import { ENTRY_POINT_MARKER } from "./entryPoint.js"; const LATEST = "latest.json"; const TEMP_PREFIX = ".latest-tmp-"; +const ENTRY_POINT = "index.html"; export class DirectoryTarget implements PublishTarget { constructor( @@ -17,6 +19,11 @@ export class DirectoryTarget implements PublishTarget { return path.join(this.rootDir, this.keyPrefix, LATEST); } + /** The forwarding page, beside the pointer. */ + private entryPointPath(): string { + return path.join(this.rootDir, this.keyPrefix, ENTRY_POINT); + } + async uploadFiles( releaseDir: string, prefix: string, @@ -68,6 +75,57 @@ export class DirectoryTarget implements PublishTarget { return JSON.parse(fs.readFileSync(file, "utf8")) as LatestPointer; } + async promoteEntryAlias(): Promise { + // A file cannot be named `/`. Anything serving a directory of + // files resolves the bare path to index.html by itself anyway. + return false; + } + + async promoteEntryPoint(html: string): Promise { + const entry = this.entryPointPath(); + const existed = fs.existsSync(entry); + if (existed && !fs.readFileSync(entry, "utf8").includes(ENTRY_POINT_MARKER)) { + return false; + } + // Temp-then-publish, so a reader never sees a half-written page. + // + // Claiming a free key is atomic below; replacing our own page is not, + // because POSIX has no compare-and-replace. A foreign writer that + // replaces our page between the marker read above and the rename below + // loses its file. That window is a local directory being written by two + // processes at once, which the S3 target rules out with a conditional + // write and this one cannot; `docs/capabilities.md` says so rather than + // claiming a guarantee that is not here. + const temp = path.join( + path.dirname(entry), + `${TEMP_PREFIX}entry-${process.pid}-${Date.now()}`, + ); + fs.mkdirSync(path.dirname(entry), { recursive: true }); + fs.writeFileSync(temp, html); + try { + if (existed) { + // Replacing our own page: rename overwrites, atomically. + fs.renameSync(temp, entry); + } else { + // Claiming a free key: link fails if anything appeared since the + // check above, so a site's own index.html is never erased. + fs.linkSync(temp, entry); + fs.unlinkSync(temp); + } + } catch (err) { + fs.rmSync(temp, { force: true }); + if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err; + // Something appeared since the check. Another chainplot publisher wrote + // the same bytes, which is the outcome we wanted; anything else means + // there is no entry point to report. + return ( + fs.existsSync(entry) && + fs.readFileSync(entry, "utf8").includes(ENTRY_POINT_MARKER) + ); + } + return true; + } + async promoteLatest(pointer: LatestPointer): Promise { // Atomic on the same filesystem: write temp, rename over latest.json. const latest = this.latestPath(); diff --git a/src/publish/entryPoint.ts b/src/publish/entryPoint.ts new file mode 100644 index 0000000..80e66ad --- /dev/null +++ b/src/publish/entryPoint.ts @@ -0,0 +1,140 @@ +// The stable entry point a reader can bookmark. +// +// A release directory is content-addressed, so its URL changes whenever the +// release does — including when only the viewer bundle changed, since the +// bundle is part of the digest. That is what makes a release immutable, and +// it is also why a link to one rots. `latest.json` never moves but serves +// JSON, so it cannot be the link you hand someone. +// +// This page sits beside `latest.json` at the publish root and forwards to +// whichever release that pointer names, so `https://host//index.html` +// renders the current dashboard and keeps doing so across republishes. +// +// It reads the pointer in the browser rather than carrying a release baked +// into it, and that is the whole design: the bytes depend only on the +// target's prefix, so every publish writes the same page and there is no +// state to keep in step with the pointer. A page that named a release would +// have to be rewritten on every publish and could fall behind — two +// publishers racing, or a publish that promoted and then failed before +// rewriting the page — and a dashboard silently showing an older release is +// the worst way for this to break. Nothing here can drift, because nothing +// here remembers anything. +// +// Reading the pointer needs script. So does the release it forwards to: the +// viewer renders nothing without it. That costs no reader anything, and the +// no-script path says so rather than linking somewhere equally unusable. + +/** + * Marks the page as ours. `promoteEntryPoint` refuses to overwrite an + * index.html without it, so publishing into a bucket that already serves a + * site of its own does not clobber that site's front page. + */ +export const ENTRY_POINT_MARKER = ""; + +/** How long the page stays blank before admitting something went wrong. */ +const FALLBACK_DELAY_SECONDS = 4; + +/** + * Embed a string in a script as a JSON literal. + * + * `` inside a string ends the element early whatever JSON thinks, so + * the slash is escaped too. + */ +function scriptLiteral(value: string): string { + return JSON.stringify(value).replaceAll("/", "\\/"); +} + +/** + * The path from the publish root to a release, which is what the page + * forwards to. Relative on purpose: the same bytes work under a bucket root, + * a custom domain, or a host that serves the bucket under some path of its + * own. + * + * The result goes into a URL without percent-encoding, which is safe because + * neither half can carry a character that would change the URL's shape: a + * target prefix is `^[A-Za-z0-9][A-Za-z0-9._-]*(/[A-Za-z0-9._-]+)*$` in the + * project schema, and a release id is hex. No `#`, `?`, `%` or space can + * reach here. Widen that pattern and this needs encoding. + */ +export function relativeReleasePath( + releasePrefix: string, + keyPrefix: string | null, +): string { + if (!keyPrefix) return releasePrefix; + const base = `${keyPrefix}/`; + if (!releasePrefix.startsWith(base)) { + throw new Error( + `release prefix ${releasePrefix} is not under the target prefix ${keyPrefix}`, + ); + } + return releasePrefix.slice(base.length); +} + +/** + * The forwarding page for a target. + * + * Depends only on the prefix, so republishing a project writes identical + * bytes however many times it runs. + */ +export function entryPointHtml(keyPrefix: string | null): string { + const prefix = scriptLiteral(keyPrefix ?? ""); + const delay = FALLBACK_DELAY_SECONDS; + // `replace` rather than `assign`: the forwarding page must not become a + // history entry, or Back from the dashboard lands here and bounces forward + // again. `no-store` because a cached pointer is the staleness this page + // exists to avoid. + return ` +${ENTRY_POINT_MARKER} + + + + +Chainplot + + + + + +
+

This page forwards to the current release.

+

It did not, which means latest.json could not be read. +

+ +
+ + +`; +} diff --git a/src/publish/publishRelease.ts b/src/publish/publishRelease.ts index 6f221cd..2a32604 100644 --- a/src/publish/publishRelease.ts +++ b/src/publish/publishRelease.ts @@ -12,6 +12,7 @@ import type { export type { PublishResult, LatestPointer }; import { latestPointer } from "./latestPointer.js"; +import { entryPointHtml } from "./entryPoint.js"; import { DirectoryTarget } from "./directory.js"; import { S3Target, s3EnvFromProcess } from "./s3.js"; @@ -170,8 +171,28 @@ export async function publishRelease( const pointer: LatestPointer = latestPointer(prefix, body); await target.promoteLatest(pointer); + // The release directory is content-addressed, so its URL moves whenever the + // release does — a viewer change is enough. The forwarding page at the + // publish root is what stays put, so there is a link worth sharing. + // + // The page reads the pointer in the browser, so its bytes depend only on the + // prefix. Every publish writes the same page, and nothing can drift out of + // step with the pointer because the page holds no release of its own. + const entryHtml = entryPointHtml(targetDoc.prefix ?? null); + const entryWritten = await target.promoteEntryPoint(entryHtml); + // The same page at `/` as well, so the bare URL resolves on a store + // that serves keys. Where it is not written the `index.html` above still is, + // which is the name every static host agrees on. + const aliasWritten = entryWritten ? await target.promoteEntryAlias(entryHtml) : false; + const publicBase = targetDoc.public_base_url?.replace(/\/$/, "") ?? null; const dashboardUrl = publicBase ? `${publicBase}/${prefix}/index.html` : null; + // The bare form once the directory key exists: a store that serves keys + // finds it there, and a host that resolves directories finds index.html by + // itself. Without that key only the explicit name is safe to hand out. + const entryUrl = publicBase + ? `${publicBase}/${base}${aliasWritten ? "" : "index.html"}` + : null; // verifyFiles proved the bytes are in the bucket. It says nothing about the // URL handed back: a base URL naming a different bucket, a bucket with public // access off, or an endpoint that folded the bucket into every key all pass @@ -185,6 +206,8 @@ export async function publishRelease( release_prefix: prefix, latest_url: publicBase ? `${publicBase}/${base}latest.json` : null, dashboard_url: dashboardUrl, + entry_url: entryWritten ? entryUrl : null, + entry_point_written: entryWritten, files_uploaded: files.length + referenced.length, datasets_referenced: referenced.map((r) => r.path), promoted: true, diff --git a/src/publish/s3.ts b/src/publish/s3.ts index a65a41e..4c02f26 100644 --- a/src/publish/s3.ts +++ b/src/publish/s3.ts @@ -9,6 +9,7 @@ import { createHash } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import type { LatestPointer, PublishTarget } from "./target.js"; +import { ENTRY_POINT_MARKER } from "./entryPoint.js"; import { CONTENT_TYPES } from "./serve.js"; import { commandError } from "../plan/errors.js"; @@ -62,13 +63,28 @@ export function s3EnvFromProcess( } export const LATEST_KEY = "latest.json"; +const ENTRY_POINT_KEY = "index.html"; +/** + * Revalidate rather than reuse. + * + * The pointer changes on every publish and the page is what reads it, so a + * cache that serves either without asking makes the stable URL show an older + * release — the one thing it exists to prevent. `no-cache` still allows a + * conditional request, so the usual answer is a 304 and almost no traffic. + */ +const MUST_REVALIDATE = "no-cache, must-revalidate"; // Minimal storage surface so unit tests can mock without the AWS SDK. export interface S3Ops { put( key: string, body: string | Uint8Array, - conditions?: { ifMatch?: string; ifNoneMatch?: string; contentType?: string }, + conditions?: { + ifMatch?: string; + ifNoneMatch?: string; + contentType?: string; + cacheControl?: string; + }, ): Promise<{ etag: string }>; get(key: string): Promise<{ body: Buffer; etag: string } | null>; head(key: string): Promise<{ size: number; etag: string } | null>; @@ -94,6 +110,7 @@ export function makeS3Ops(env: S3TargetEnv): S3Ops { IfMatch?: string; IfNoneMatch?: string; ContentType?: string; + CacheControl?: string; } = { Bucket: env.bucket, Key: key, @@ -106,6 +123,9 @@ export function makeS3Ops(env: S3TargetEnv): S3Ops { if (conditions?.contentType !== undefined) { input.ContentType = conditions.contentType; } + if (conditions?.cacheControl !== undefined) { + input.CacheControl = conditions.cacheControl; + } try { const out = await client.send(new PutObjectCommand(input)); return { etag: String(out.ETag ?? "") }; @@ -190,6 +210,72 @@ export class S3Target implements PublishTarget { return this.keyPrefix ? `${this.keyPrefix}/${LATEST_KEY}` : LATEST_KEY; } + private entryPointKey(): string { + return this.keyPrefix ? `${this.keyPrefix}/${ENTRY_POINT_KEY}` : ENTRY_POINT_KEY; + } + + /** + * The same page again at `/`. + * + * An object store serves keys, so the bare directory URL only resolves if a + * key of that exact name exists; a host that does resolve directories finds + * `index.html` regardless, which makes this inert rather than wrong there. + * Writing it travels with the bucket, unlike a rewrite rule configured at + * whichever CDN happens to be in front of it today. + */ + async promoteEntryAlias(html: string): Promise { + if (!this.keyPrefix) return false; + const key = `${this.keyPrefix}/`; + try { + const existing = await this.ops.get(key); + if (existing && !existing.body.toString("utf8").includes(ENTRY_POINT_MARKER)) { + return false; + } + await this.ops.put(key, html, { + contentType: "text/html; charset=utf-8", + cacheControl: MUST_REVALIDATE, + ...(existing ? { ifMatch: existing.etag } : { ifNoneMatch: "*" }), + }); + return true; + } catch (err) { + if (isPreconditionFailed(err)) { + const now = await this.ops.get(key); + return now !== null && now.body.toString("utf8").includes(ENTRY_POINT_MARKER); + } + // A store that will not take a key ending in a separator simply does + // not get the tidier URL. It is not a reason to fail a publish. + return false; + } + } + + async promoteEntryPoint(html: string): Promise { + const key = this.entryPointKey(); + const existing = await this.ops.get(key); + if (existing && !existing.body.toString("utf8").includes(ENTRY_POINT_MARKER)) { + return false; + } + // Conditional, because reading and then writing is not one step: a site's + // own index.html can appear between the two, and an unconditional put + // would erase it. `If-None-Match` claims the key only if it is still + // free; `If-Match` replaces only the page we just read. + try { + await this.ops.put(key, html, { + contentType: "text/html; charset=utf-8", + cacheControl: MUST_REVALIDATE, + ...(existing ? { ifMatch: existing.etag } : { ifNoneMatch: "*" }), + }); + } catch (err) { + if (!isPreconditionFailed(err)) throw err; + // Someone wrote the key between the read and the write. If it was + // another chainplot publisher the entry point exists and is correct — + // the page names no release, so theirs and ours are the same bytes. + // Only a foreign page means there is no entry point to report. + const now = await this.ops.get(key); + return now !== null && now.body.toString("utf8").includes(ENTRY_POINT_MARKER); + } + return true; + } + async uploadFiles( releaseDir: string, prefix: string, @@ -263,11 +349,13 @@ export class S3Target implements PublishTarget { await this.ops.put(this.latestKey(), body, { ifMatch: this.lastPointerETag ?? undefined, contentType: "application/json", + cacheControl: MUST_REVALIDATE, }); } else { await this.ops.put(this.latestKey(), body, { ifNoneMatch: "*", contentType: "application/json", + cacheControl: MUST_REVALIDATE, }); } } catch (err) { diff --git a/src/publish/target.ts b/src/publish/target.ts index 0e219c9..efbdab7 100644 --- a/src/publish/target.ts +++ b/src/publish/target.ts @@ -10,6 +10,18 @@ export interface PublishResult { latest_url: string | null; /** Direct link to this exact release's dashboard, for a human to open. */ dashboard_url: string | null; + /** + * The publish root, which forwards to whichever release is current. Stable + * across republishes, unlike `dashboard_url`. Null when the target declares + * no public base URL, which is the normal case for a directory target. + */ + entry_url: string | null; + /** + * Whether the forwarding page was written. False only when an index.html + * chainplot did not write already occupies the publish root, which is a + * different thing from a target that simply has no public URL to report. + */ + entry_point_written: boolean; /** Release-relative keys of datasets uploaded beside the release. */ datasets_referenced?: string[]; files_uploaded: number; @@ -36,6 +48,25 @@ export interface PublishTarget { ): Promise; readLatest(): Promise; promoteLatest(pointer: LatestPointer): Promise; + /** + * Write the forwarding page beside `latest.json`, so the publish root is a + * stable URL that renders the current release. + * + * Returns false when an index.html is already there that chainplot did not + * write: a bucket may serve a site of its own, and its front page is not + * ours to replace. + */ + promoteEntryPoint(html: string): Promise; + /** + * Write the same page at the directory key, `/`, so the bare URL + * works on a store that serves keys rather than resolving directories. + * + * Returns false where there is nothing to write: a target with no prefix + * has no directory key, and no filesystem allows a name ending in a + * separator. Never fatal — the `index.html` written beside it is what every + * host agrees on, and this only widens where the tidier URL works. + */ + promoteEntryAlias(html: string): Promise; /** Whether a release published under `prefix` is still there. */ releaseExists(prefix: string): Promise; } diff --git a/tests/publish/entryPoint.test.ts b/tests/publish/entryPoint.test.ts new file mode 100644 index 0000000..5fd2025 --- /dev/null +++ b/tests/publish/entryPoint.test.ts @@ -0,0 +1,349 @@ +import { describe, expect, it, vi } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + ENTRY_POINT_MARKER, + entryPointHtml, + relativeReleasePath, +} from "../../src/publish/entryPoint.js"; +import { DirectoryTarget } from "../../src/publish/directory.js"; +import { S3Target, type S3Ops } from "../../src/publish/s3.js"; + +function tmp(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "chainplot-entry-")); +} + +function preconditionError(): Error & { $metadata: { httpStatusCode: number } } { + const err = new Error("precondition failed") as Error & { + $metadata: { httpStatusCode: number }; + }; + err.name = "PreconditionFailed"; + err.$metadata = { httpStatusCode: 412 }; + return err; +} + +function mockOps(): S3Ops & { keys(): string[]; cacheOf(key: string): string | undefined } { + const store = new Map(); + let counter = 0; + return { + async put(key, body, conditions) { + const existing = store.get(key); + if (conditions?.ifNoneMatch === "*" && existing) throw preconditionError(); + if ( + conditions?.ifMatch !== undefined && + (!existing || existing.etag !== conditions.ifMatch) + ) { + throw preconditionError(); + } + const etag = `"e${++counter}"`; + store.set(key, { + body: typeof body === "string" ? body : Buffer.from(body).toString("utf8"), + etag, + cache: conditions?.cacheControl, + }); + return { etag }; + }, + async get(key) { + const hit = store.get(key); + return hit === undefined ? null : { body: Buffer.from(hit.body), etag: hit.etag }; + }, + async head(key) { + const hit = store.get(key); + return hit === undefined ? null : { size: hit.body.length, etag: hit.etag }; + }, + async delete(key) { + store.delete(key); + }, + keys: () => [...store.keys()], + cacheOf: (key: string) => store.get(key)?.cache, + }; +} + +const ENV = { + endpoint: "https://example.r2.cloudflarestorage.com", + bucket: "b", + region: "auto", + accessKeyId: "k", + secretAccessKey: "s", +}; + +describe("relativeReleasePath", () => { + it("strips the target prefix, leaving a path relative to the publish root", () => { + expect(relativeReleasePath("arc-inflows/releases/abc123", "arc-inflows")).toBe( + "releases/abc123", + ); + }); + + it("leaves the path alone when the target has no prefix", () => { + expect(relativeReleasePath("releases/abc123", null)).toBe("releases/abc123"); + expect(relativeReleasePath("releases/abc123", "")).toBe("releases/abc123"); + }); + + it("refuses a release that is not under the prefix", () => { + expect(() => relativeReleasePath("other/releases/abc", "arc-inflows")).toThrow( + /not under the target prefix/, + ); + }); + + // "arc" is a prefix of "arc-inflows" as a string but not as a path. + it("does not treat a sibling prefix as a parent", () => { + expect(() => relativeReleasePath("arc-inflows/releases/abc", "arc")).toThrow( + /not under the target prefix/, + ); + }); +}); + +describe("entryPointHtml", () => { + it("carries the marker that proves the page is ours", () => { + expect(entryPointHtml("arc-inflows")).toContain(ENTRY_POINT_MARKER); + }); + + // The page names no release, which is what makes it impossible for the + // entry point to fall behind the pointer. + it("names no release, so republishing writes identical bytes", () => { + expect(entryPointHtml("arc-inflows")).toBe(entryPointHtml("arc-inflows")); + expect(entryPointHtml("arc-inflows")).not.toContain("releases/"); + }); + + it("carries the prefix it has to strip from the pointer", () => { + expect(entryPointHtml("arc-inflows")).toContain('PREFIX = "arc-inflows"'); + expect(entryPointHtml(null)).toContain('PREFIX = ""'); + }); + + // A cached pointer is the staleness this page exists to avoid, and a + // history entry here makes Back from the dashboard bounce forward again. + it("reads the pointer uncached and replaces rather than pushes history", () => { + const html = entryPointHtml("p"); + expect(html).toContain('cache: "no-store"'); + expect(html).toContain("location.replace("); + expect(html).not.toContain("location.assign("); + expect(html).not.toContain('http-equiv="refresh"'); + }); + + it("keeps the fallback hidden until a redirect has had time to happen", () => { + const html = entryPointHtml("p"); + expect(html).toContain("visibility: hidden"); + expect(html).toMatch(/animation: reveal 0s \d+s forwards/); + }); + + it("does not ask a crawler to index the forwarding page", () => { + expect(entryPointHtml("p")).toContain('name="robots" content="noindex"'); + }); + + // The schema forbids these characters in a prefix, but the escaping must + // not be what depends on that. + it("cannot be escaped by a prefix carrying a script end tag", () => { + const html = entryPointHtml("a"); + expect(html).not.toContain("