diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 5b87097..851a0a0 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -174,3 +174,21 @@ jobs:
echo "and the two are different registrable domains, or there is no boundary"
test "$(echo "$SERVICE" | sed 's#https\?://##')" != "$(echo "$SANDBOX" | sed 's#https\?://##')"
+
+ # The frame has no allow-same-origin, so its document is an opaque
+ # origin and every request it makes is cross-origin with Origin:
+ # null. A linked module script is fetched with CORS and silently
+ # refused, which renders as a blank frame and an empty console. The
+ # script has to be inline, and this is the only place that can tell.
+ echo "and the sandbox page carries its script inline, fetching nothing"
+ curl -fsS "$SANDBOX/sandbox.html" -o /tmp/sandbox.html
+
+ if ! grep -q 'relic:sandbox-ready' /tmp/sandbox.html; then
+ echo "sandbox.html does not carry its handler inline; the frame will never render"
+ exit 1
+ fi
+
+ if grep -qE '';
+
+const shell = await Bun.file('./public/sandbox.html').text();
+if (!shell.includes(SCRIPT_TAG)) {
+ console.error(
+ 'sandbox.html no longer contains the expected script tag, so the bundle ' +
+ 'would ship linked instead of inlined and the frame would never render'
+ );
+ process.exit(1);
+}
+
+const sandboxJs = await Bun.file(`${out}sandbox.js`).text();
+await Bun.write(
+ `${out}sandbox.html`,
+ shell.replace(
+ SCRIPT_TAG,
+ // A `${sandboxJs.replace(/<\/script/gi, '<\\/script')}`
+ )
+);
+
const names = (await readdir(out)).sort();
console.log(`built ${names.length} assets: ${names.join(', ')}`);
diff --git a/packages/relic-viewer/src/main.ts b/packages/relic-viewer/src/main.ts
index e194d32..789179e 100644
--- a/packages/relic-viewer/src/main.ts
+++ b/packages/relic-viewer/src/main.ts
@@ -17,6 +17,7 @@ import { highlightCode, renderMarkdown } from './markdown.ts';
import {
type DeadView,
formatBytes,
+ type KeyVault,
load,
type ReadyView,
type ViewerDeps,
@@ -453,10 +454,109 @@ function renderDead(dead: DeadView): void {
document.body.appendChild(main);
}
+const VAULT_PREFIX = 'relic:key:';
+
+/**
+ * Keys remembered in this browser's storage for the service origin.
+ *
+ * Storage can be absent or refuse to write: private browsing, a quota, an
+ * embedded webview, or a user who has blocked site data. None of that should
+ * cost somebody the relic they are currently looking at, so every operation
+ * degrades to doing nothing. The worst case is the behaviour that existed
+ * before this: a reload asks for the original link.
+ *
+ * Entries carry their relic's expiry and are swept on every read, so storage
+ * does not accumulate keys to relics that stopped existing days ago.
+ */
+export function localStorageKeyVault(
+ storage: Storage | undefined = globalThis.localStorage,
+ now: () => number = Date.now
+): KeyVault {
+ const read = (): Storage | undefined => {
+ try {
+ // Touching localStorage throws outright in some embedded contexts,
+ // rather than being absent, so the guard has to be a try and not a null
+ // check.
+ return storage ?? undefined;
+ } catch {
+ return undefined;
+ }
+ };
+
+ const sweep = (store: Storage): void => {
+ const stale: string[] = [];
+ for (let i = 0; i < store.length; i++) {
+ const name = store.key(i);
+ if (name === null || !name.startsWith(VAULT_PREFIX)) continue;
+ try {
+ const entry = JSON.parse(store.getItem(name) ?? '') as {
+ expiresAt?: number;
+ };
+ if (typeof entry.expiresAt !== 'number' || entry.expiresAt <= now()) {
+ stale.push(name);
+ }
+ } catch {
+ // Unreadable entry. Not ours to interpret, and not worth keeping.
+ stale.push(name);
+ }
+ }
+ for (const name of stale) store.removeItem(name);
+ };
+
+ return {
+ remember(relicId, fragment, expiresAt) {
+ const store = read();
+ if (store === undefined) return;
+ if (!Number.isFinite(expiresAt) || expiresAt <= now()) return;
+ try {
+ store.setItem(
+ `${VAULT_PREFIX}${relicId}`,
+ JSON.stringify({ fragment, expiresAt })
+ );
+ } catch {
+ // Quota, or storage disabled mid-session. A remembered key is a
+ // convenience; failing to store one is not worth an error page.
+ }
+ },
+
+ recall(relicId) {
+ const store = read();
+ if (store === undefined) return undefined;
+ try {
+ sweep(store);
+ const raw = store.getItem(`${VAULT_PREFIX}${relicId}`);
+ if (raw === null) return undefined;
+ const entry = JSON.parse(raw) as {
+ fragment?: unknown;
+ expiresAt?: unknown;
+ };
+ if (typeof entry.fragment !== 'string') return undefined;
+ if (typeof entry.expiresAt !== 'number' || entry.expiresAt <= now()) {
+ return undefined;
+ }
+ return entry.fragment;
+ } catch {
+ return undefined;
+ }
+ },
+
+ forget(relicId) {
+ const store = read();
+ if (store === undefined) return;
+ try {
+ store.removeItem(`${VAULT_PREFIX}${relicId}`);
+ } catch {
+ // Nothing to do, and nothing worth telling the reader about.
+ }
+ },
+ };
+}
+
export function makeBrowserDeps(): ViewerDeps {
return {
serviceOrigin: SERVICE_ORIGIN,
fetch: globalThis.fetch.bind(globalThis),
+ keyVault: localStorageKeyVault(),
takeFragment: () => window.location.hash,
stripFragment: () => {
// Replace the current entry with the fragment removed. The URL that
diff --git a/packages/relic-viewer/src/viewer.ts b/packages/relic-viewer/src/viewer.ts
index 8a932ca..cd642c4 100644
--- a/packages/relic-viewer/src/viewer.ts
+++ b/packages/relic-viewer/src/viewer.ts
@@ -78,6 +78,26 @@ export interface MintResponse {
readonly mints_remaining: number;
}
+/**
+ * Somewhere to keep a key so a reload does not lose it.
+ *
+ * The fragment is stripped from the address bar the moment it is read, which
+ * is what makes a reload land on a page with no key. That is a real cost paid
+ * for a real benefit, and remembering the key on the recipient's own machine
+ * buys back the reload without putting the key back in the URL.
+ *
+ * It does change who can open the relic. Anyone with this browser profile can
+ * reopen it for as long as the entry lives, without ever having the link.
+ * That is a deliberate trade, it is scoped to the service origin, and it is
+ * stated in the disclosure rather than done quietly.
+ */
+export interface KeyVault {
+ /** Remember a key against a relic, until the relic expires. */
+ remember(relicId: string, fragment: string, expiresAt: number): void;
+ recall(relicId: string): string | undefined;
+ forget(relicId: string): void;
+}
+
export interface ViewerDeps {
readonly serviceOrigin: string;
readonly fetch: typeof globalThis.fetch;
@@ -86,6 +106,7 @@ export interface ViewerDeps {
/** Calls `history.replaceState` to drop the fragment from the address bar. */
readonly stripFragment: () => void;
readonly locationHref: string;
+ readonly keyVault: KeyVault;
}
export async function load(
@@ -100,12 +121,22 @@ export async function load(
// existed before the replace, so browser history sync, an extension with
// host permissions, and the application the link was clicked from all
// already saw it.
- const fragment = deps.takeFragment();
+ const fromUrl = deps.takeFragment();
const shareUrl = deps.locationHref;
deps.stripFragment();
+ // A reload arrives with no fragment, because reading it stripped it. Fall
+ // back to what this browser was told last time.
+ const recalled =
+ fromUrl.length === 0 || fromUrl === '#'
+ ? deps.keyVault.recall(relicId)
+ : undefined;
+ const fragment = recalled ?? fromUrl;
+ /** Whether the key came from storage, so a bad one can be evicted. */
+ const fromVault = recalled !== undefined;
+
if (fragment.length === 0 || fragment === '#') {
- // A reload loses the key. The reloaded page is dead and says so, pointing
+ // No key in the URL and none remembered here. Say so plainly, pointing
// back at the original link rather than showing a decrypt error.
return {
kind: 'dead',
@@ -136,6 +167,9 @@ export async function load(
'unknown_version'
);
}
+ // A remembered key that no longer parses is corrupt storage, not a bad
+ // link. Drop it so the next attempt is a clean one.
+ if (fromVault) deps.keyVault.forget(relicId);
if (error instanceof MalformedFragmentError) {
return dead(
'This link looks truncated',
@@ -150,9 +184,24 @@ export async function load(
}
const minted = await mint(relicId, deps);
- if ('dead' in minted) return { kind: 'dead', dead: minted.dead };
+ if ('dead' in minted) {
+ // Expired, removed, or never published. Whatever this browser remembered
+ // is worthless now, and keeping a dead key is keeping a secret for no
+ // reason at all.
+ deps.keyVault.forget(relicId);
+ return { kind: 'dead', dead: minted.dead };
+ }
const mintResponse = minted.mint;
+ // The relic is real and this key reached it, so it is worth remembering
+ // until the relic itself expires. Done after the mint rather than before,
+ // so a key for a relic that does not exist is never written down.
+ deps.keyVault.remember(
+ relicId,
+ fragment,
+ Date.parse(mintResponse.relic_expires_at)
+ );
+
// Refuse before allocating, using a bound computed from the object length
// and the record size rather than anything the server declared.
const upperBound = plaintextSizeUpperBound(mintResponse.object_length);
diff --git a/packages/relic-viewer/test/keyvault.test.ts b/packages/relic-viewer/test/keyvault.test.ts
new file mode 100644
index 0000000..2682eb5
--- /dev/null
+++ b/packages/relic-viewer/test/keyvault.test.ts
@@ -0,0 +1,160 @@
+import { describe, expect, test } from 'bun:test';
+import { localStorageKeyVault } from '../src/main.ts';
+
+/** Enough of the Storage interface for the vault, with no browser involved. */
+function memoryStorage(): Storage {
+ const map = new Map();
+ return {
+ get length() {
+ return map.size;
+ },
+ key: (i: number) => [...map.keys()][i] ?? null,
+ getItem: (k: string) => map.get(k) ?? null,
+ setItem: (k: string, v: string) => {
+ map.set(k, v);
+ },
+ removeItem: (k: string) => {
+ map.delete(k);
+ },
+ clear: () => map.clear(),
+ } as Storage;
+}
+
+/** Storage that refuses every write, as a full quota or blocked site data does. */
+function refusingStorage(): Storage {
+ const base = memoryStorage();
+ return {
+ ...base,
+ get length() {
+ return base.length;
+ },
+ key: (i: number) => base.key(i),
+ getItem: () => {
+ throw new Error('SecurityError');
+ },
+ setItem: () => {
+ throw new Error('QuotaExceededError');
+ },
+ removeItem: () => {
+ throw new Error('SecurityError');
+ },
+ } as Storage;
+}
+
+const HOUR = 3_600_000;
+
+describe('localStorageKeyVault', () => {
+ test('a remembered key comes back, which is the whole point', () => {
+ const now = () => 1000;
+ const vault = localStorageKeyVault(memoryStorage(), now);
+
+ vault.remember('relic1', '#r1abc', 1000 + HOUR);
+ expect(vault.recall('relic1')).toBe('#r1abc');
+ });
+
+ test('an unknown relic recalls nothing', () => {
+ const vault = localStorageKeyVault(memoryStorage(), () => 1000);
+ expect(vault.recall('never-seen')).toBeUndefined();
+ });
+
+ test('keys are scoped per relic', () => {
+ const vault = localStorageKeyVault(memoryStorage(), () => 1000);
+ vault.remember('relic1', '#r1aaa', 1000 + HOUR);
+ vault.remember('relic2', '#r1bbb', 1000 + HOUR);
+
+ expect(vault.recall('relic1')).toBe('#r1aaa');
+ expect(vault.recall('relic2')).toBe('#r1bbb');
+ });
+
+ test('forget removes it', () => {
+ const vault = localStorageKeyVault(memoryStorage(), () => 1000);
+ vault.remember('relic1', '#r1abc', 1000 + HOUR);
+ vault.forget('relic1');
+
+ expect(vault.recall('relic1')).toBeUndefined();
+ });
+
+ // A key outliving its relic is a secret kept for nothing.
+ test('an entry past the relic expiry is not returned', () => {
+ const storage = memoryStorage();
+ let clock = 1000;
+ const vault = localStorageKeyVault(storage, () => clock);
+
+ vault.remember('relic1', '#r1abc', 1000 + HOUR);
+ clock = 1000 + HOUR + 1;
+
+ expect(vault.recall('relic1')).toBeUndefined();
+ });
+
+ test('an already expired key is never written down at all', () => {
+ const storage = memoryStorage();
+ const vault = localStorageKeyVault(storage, () => 5000);
+
+ vault.remember('relic1', '#r1abc', 4000);
+ expect(storage.length).toBe(0);
+ });
+
+ test('a non-finite expiry is refused rather than stored forever', () => {
+ const storage = memoryStorage();
+ const vault = localStorageKeyVault(storage, () => 1000);
+
+ vault.remember('relic1', '#r1abc', Number.NaN);
+ expect(storage.length).toBe(0);
+ });
+
+ // Otherwise storage fills with keys to relics that stopped existing.
+ test('reading sweeps every expired entry, not just the one asked for', () => {
+ const storage = memoryStorage();
+ let clock = 1000;
+ const vault = localStorageKeyVault(storage, () => clock);
+
+ vault.remember('old1', '#r1aaa', 1000 + HOUR);
+ vault.remember('old2', '#r1bbb', 1000 + HOUR);
+ vault.remember('fresh', '#r1ccc', 1000 + 10 * HOUR);
+ expect(storage.length).toBe(3);
+
+ clock = 1000 + 2 * HOUR;
+ vault.recall('fresh');
+
+ expect(storage.length).toBe(1);
+ expect(vault.recall('fresh')).toBe('#r1ccc');
+ });
+
+ test('a corrupt entry is swept rather than thrown over', () => {
+ const storage = memoryStorage();
+ storage.setItem('relic:key:relic1', 'not json');
+ const vault = localStorageKeyVault(storage, () => 1000);
+
+ expect(vault.recall('relic1')).toBeUndefined();
+ expect(storage.length).toBe(0);
+ });
+
+ test('the sweep leaves other applications keys alone', () => {
+ const storage = memoryStorage();
+ storage.setItem('unrelated', 'keep me');
+ const vault = localStorageKeyVault(storage, () => 1000);
+
+ vault.remember('relic1', '#r1abc', 1000 + HOUR);
+ vault.recall('relic1');
+
+ expect(storage.getItem('unrelated')).toBe('keep me');
+ });
+
+ // Private browsing, a full quota, or blocked site data. None of these should
+ // cost somebody the relic they are looking at right now.
+ test('storage that throws on every call degrades to doing nothing', () => {
+ const vault = localStorageKeyVault(refusingStorage(), () => 1000);
+
+ expect(() => vault.remember('relic1', '#r1abc', 1000 + HOUR)).not.toThrow();
+ expect(vault.recall('relic1')).toBeUndefined();
+ expect(() => vault.forget('relic1')).not.toThrow();
+ });
+
+ test('absent storage degrades to doing nothing', () => {
+ const vault = localStorageKeyVault(undefined, () => 1000);
+
+ expect(() => vault.remember('relic1', '#r1abc', 1000 + HOUR)).not.toThrow();
+ expect(vault.recall('relic1')).toBeUndefined();
+ expect(() => vault.forget('relic1')).not.toThrow();
+ });
+});
diff --git a/packages/relic-viewer/test/viewer.test.ts b/packages/relic-viewer/test/viewer.test.ts
index 6360ac8..f6b6492 100644
--- a/packages/relic-viewer/test/viewer.test.ts
+++ b/packages/relic-viewer/test/viewer.test.ts
@@ -34,10 +34,35 @@ function shimFetch(): typeof globalThis.fetch {
}) as typeof globalThis.fetch;
}
-function deps(fragment: string, href = `${SERVICE}/x#${fragment}`): ViewerDeps {
+/** An in-memory stand-in for the browser's storage, shared across a test. */
+export function fakeVault(seed: Record = {}) {
+ const entries = new Map(Object.entries(seed));
+ return {
+ entries,
+ vault: {
+ // No expiry check. The app under test runs on its own clock, so
+ // comparing its expiry to real wall time would reject everything.
+ // Expiry belongs to the storage implementation and is tested there.
+ remember(relicId: string, fragment: string, _expiresAt: number) {
+ entries.set(relicId, fragment);
+ },
+ recall: (relicId: string) => entries.get(relicId),
+ forget: (relicId: string) => {
+ entries.delete(relicId);
+ },
+ },
+ };
+}
+
+function deps(
+ fragment: string,
+ href = `${SERVICE}/x#${fragment}`,
+ vault = fakeVault().vault
+): ViewerDeps {
return {
serviceOrigin: SERVICE,
fetch: shimFetch(),
+ keyVault: vault,
takeFragment: () => {
fragmentReads += 1;
return fragment;
@@ -175,6 +200,95 @@ describe('the fragment', () => {
});
});
+/**
+ * The reload path.
+ *
+ * Reading the fragment strips it, so a reload arrives with nothing in the URL.
+ * Before this the page simply died and told the reader to find the original
+ * link, which is a bad answer to a refresh.
+ */
+describe('remembering the key', () => {
+ test('a reload with no fragment opens from the remembered key', async () => {
+ const { id, fragment } = await seed(
+ utf8('# Still here\n'),
+ 'report.md',
+ 'text/markdown'
+ );
+ const { vault } = fakeVault();
+
+ const first = await load(
+ id,
+ deps(fragment, `${SERVICE}/x#${fragment}`, vault)
+ );
+ expect(first.kind).toBe('ready');
+
+ // Same browser, same relic, no fragment: a refresh.
+ const reloaded = await load(id, deps('', `${SERVICE}/x`, vault));
+ expect(reloaded.kind).toBe('ready');
+ if (reloaded.kind !== 'ready') return;
+ expect(new TextDecoder().decode(reloaded.view.content)).toBe(
+ '# Still here\n'
+ );
+ });
+
+ test('nothing is remembered for a relic that does not exist', async () => {
+ const { entries, vault } = fakeVault();
+ const { fragment } = await seed(utf8('x'), 'a.md', 'text/markdown');
+
+ // A well-formed key against an id that was never published.
+ const state = await load(
+ generateRelicId(),
+ deps(fragment, undefined, vault)
+ );
+
+ expect(state.kind).toBe('dead');
+ expect(entries.size).toBe(0);
+ });
+
+ test('a dead relic evicts whatever this browser remembered', async () => {
+ const { id, fragment } = await seed(
+ utf8('# Gone soon\n'),
+ 'report.md',
+ 'text/markdown'
+ );
+ const { entries, vault } = fakeVault();
+
+ await load(id, deps(fragment, `${SERVICE}/x#${fragment}`, vault));
+ expect(entries.size).toBe(1);
+
+ await app.fetch(
+ new Request(`${SERVICE}/api/relics/${id}`, {
+ method: 'DELETE',
+ headers: { authorization: 'Bearer operator-secret' },
+ })
+ );
+
+ const after = await load(id, deps('', `${SERVICE}/x`, vault));
+ expect(after.kind).toBe('dead');
+ // Keeping a key to a relic that no longer exists is keeping a secret for
+ // no reason at all.
+ expect(entries.size).toBe(0);
+ });
+
+ test('a remembered key that is corrupt is dropped, not retried forever', async () => {
+ const { id } = await seed(utf8('x'), 'a.md', 'text/markdown');
+ const { entries, vault } = fakeVault({ [id]: '#r1notavalidkey' });
+
+ const state = await load(id, deps('', `${SERVICE}/x`, vault));
+
+ expect(state.kind).toBe('dead');
+ expect(entries.size).toBe(0);
+ });
+
+ test('with nothing remembered, a reload still says what to do', async () => {
+ const state = await load(generateRelicId(), deps('', `${SERVICE}/x`));
+
+ expect(state.kind).toBe('dead');
+ if (state.kind !== 'dead') return;
+ expect(state.dead.code).toBe('fragment_missing');
+ });
+});
+
describe('rendering', () => {
test('opens markdown and hands back the exact bytes', async () => {
const body = '# Q3\n\nRevenue was up.\n';