openvibers/openvibe.zone Commits

Zone's home shows Media's real limits (read from its /limits.json), adds AWS SDK and openvibe-sdk samples and the questions people ask; the size rule is 256 MB per request now that s3.openvibe.media is off Cloudflare (it said 100 MB) (#28)

OP OpenVibers committed 22 days ago · 102b478aeb · 7 files, +195 −17

Changes

ddiff --git a/public/css/app.css b/public/css/app.css
iindex 3bb02cb..14d277f 100644
---- a/public/css/app.css
++++ b/public/css/app.css
@@@ -80,3 +80,13 @@ details input { font: inherit; padding: 7px 9px; border-radius: 8px; border: 1px
.zn-secret dd { margin: 0; }
.zn-sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
@media (max-width: 600px) { .zn-form { flex-direction: column; align-items: stretch; } }
++
++/* The home: Media's limits beside the hero, and the questions (their summaries are not the danger-zone red). */
++.zn-stats { margin: 0; display: grid; gap: 10px; }
++.zn-stats div { border: 1px solid var(--border, #1f2d47); border-radius: 12px; padding: 12px 16px; background: var(--bg-card, #131c2e); }
++.zn-stats dt { font-size: 1.25rem; font-weight: 700; color: var(--text-primary, #e6edf7); }
++.zn-stats dd { margin: 2px 0 0; color: var(--text-secondary, #96a7c2); font-size: .92rem; }
++.zn-faq details { border: 1px solid var(--border, #1f2d47); border-radius: 10px; padding: 4px 16px; margin: 8px 0; background: var(--bg-card, #131c2e); }
++.zn-faq details[open] { padding-bottom: 12px; }
++.zn-faq summary { color: var(--text-primary, #e6edf7); font-weight: 600; padding: 8px 0; }
++.zn-faq p { margin: 4px 0 0; color: var(--text-secondary, #96a7c2); }
ddiff --git a/server/app.js b/server/app.js
iindex 9c378e7..4984b5d 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -57,7 +57,8 @@ async function createApp(opts = {}) {
const clients = opts.clients || createClients({ config, fetchImpl });
const usage = createUsage({ s, log });
const zones = createZones({ s, clients, config, log, metering: usage });
-- const ctx = { config, s, keys, sso, principal, log, clients, zones, usage };
++ const mediaLimits = opts.mediaLimits || require('./zone/media-limits').createMediaLimits({ config, fetchImpl, log });
++ const ctx = { config, s, keys, sso, principal, log, clients, zones, usage, mediaLimits };
const app = express();
app.disable('x-powered-by');
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 49947dd..2bd79d6 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -55,6 +55,11 @@ function createPageRoutes(ctx) {
const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
const person = (req) => (req.viewer && req.viewer.kind === 'user' && /^usr_/.test(String(req.viewer.subject || '')) ? req.viewer : null);
const s3 = config.media.s3Endpoint;
++ /** Media's published limits (zone/media-limits.js), or an empty map: the figures that need them then hide. */
++ const limitsOf = async () => { try { return ctx.mediaLimits ? await ctx.mediaLimits.get() : new Map(); } catch { return new Map(); } };
++ const sizeRule = (L) => (L.get('single_upload_bytes') && L.get('multipart_bytes')
++ ? `one request carries at most ${bytes(L.get('single_upload_bytes').production)}, so larger files go up in parts (the AWS CLI, the SDKs and rclone do this by default); an object is at most ${bytes(L.get('multipart_bytes').production)}`
++ : 'larger files go up in parts, as the AWS CLI, the SDKs and rclone do by default');
const signInPage = (req, res, next) => page(req, res, {
title: 'Sign in', body: html`<h1>Sign in to manage your zones</h1>
@@@ -63,8 +68,23 @@ function createPageRoutes(ctx) {
}, 401);
// ── Home ─────────────────────────────────────────────────
-- r.get('/', (req, res) => {
++ r.get('/', async (req, res) => {
const me = person(req);
++ const L = await limitsOf();
++ const prod = L.get('storage_bytes'), sandbox = L.get('storage_bytes');
++ const stats = [];
++ if (prod) stats.push([bytes(prod.production), 'per project in production, at no charge']);
++ if (sandbox && sandbox.sandbox) stats.push([bytes(sandbox.sandbox), 'more in each project\'s sandbox']);
++ if (L.get('multipart_bytes')) stats.push([bytes(L.get('multipart_bytes').production), 'the largest object, uploaded in parts']);
++ if (L.get('deleted_retention_days')) stats.push([`${L.get('deleted_retention_days').production} days`, 'to restore a deleted object']);
++ const faq = [
++ { q: 'Which S3 operations work?', a: 'Buckets (zones) and objects: put, get, head, list, delete, presigned URLs, multipart uploads and server-side copies (CopyObject, UploadPartCopy). ACLs, bucket policies and versioning are not supported: zones are private and access is the key\'s.' },
++ { q: 'Which tools can I use?', a: `Any S3 client that signs with Signature Version 4 and uses path-style addressing: the AWS CLI and SDKs, rclone, restic and backup tools. Point it at ${s3}, region us-east-1.` },
++ { q: 'Who can read my objects?', a: 'Only requests signed with a key you issued for that zone, or a presigned URL you made, until it expires or its key is revoked. A zone has no public links.' },
++ { q: 'What does it cost?', a: prod ? `Each project includes ${bytes(prod.production)} in production and ${bytes(prod.sandbox)} in its sandbox at no charge. A zone can have its own quota below that.` : 'Each project includes storage in production and in its sandbox at no charge, and a zone can have its own quota below that.' },
++ { q: 'What happens when I delete something?', a: L.get('deleted_retention_days') ? `A deleted object can be restored for ${L.get('deleted_retention_days').production} days. Deleting a whole zone removes every object and every copy of it, and its name is never given to another zone.` : 'Deleting a whole zone removes every object and every copy of it, and its name is never given to another zone.' },
++ { q: 'Where are my files kept?', a: 'Every object is an OpenVibe.Media object, stored and served by Media and readable through its API as well as over S3.' },
++ ];
const hero = showcase.hero({
eyebrow: `${SITE_NAME} · part of OpenVibe`,
title: 'Private storage zones:',
@@@ -73,10 +93,12 @@ function createPageRoutes(ctx) {
actions: me
? [{ label: 'Your projects', href: '/projects', primary: true }, { label: 'How to use it', href: '/docs' }]
: [{ label: 'Sign in to start', href: '/auth/login?next=%2Fprojects', primary: true }, { label: 'How to use it', href: '/docs' }],
-- note: 'Each project includes 1 GB in production and 100 MB in its sandbox at no charge.',
++ note: prod ? `Each project includes ${bytes(prod.production)} in production and ${bytes(prod.sandbox)} in its sandbox at no charge.` : 'Each project includes storage in production and in its sandbox at no charge.',
++ html: stats.length ? `<dl class="zn-stats">${stats.map(([dt, dd]) => `<div><dt>${dt}</dt><dd>${dd}</dd></div>`).join('')}</dl>` : undefined,
});
page(req, res, {
-- index: true, cache: me ? null : PUBLIC_CACHE, jsonLd: homeJsonLd(config), styles: [showcase.STYLESHEET],
++ index: true, cache: me ? null : PUBLIC_CACHE, styles: [showcase.STYLESHEET],
++ jsonLd: [...homeJsonLd(config), { '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: faq.map((f) => ({ '@type': 'Question', name: f.q, acceptedAnswer: { '@type': 'Answer', text: f.a } })) }],
description: 'Private S3 buckets for your OpenVibe projects: create zones, issue access keys scoped to them, and use the AWS SDKs, rclone or any S3 tool.',
body: html`${raw(hero)}
${raw(showcase.steps({
@@@ -88,16 +110,35 @@ ${raw(showcase.steps({
{ title: 'Use any S3 tool', text: `Point it at ${s3} with path-style addressing.`, href: '/docs' },
],
}))}
--<section class="zn-section" aria-labelledby="zn-try"><h2 id="zn-try">With the tools you have</h2>
--<pre tabindex="0" class="zn-code"><code>aws configure set aws_access_key_id ovk_…
++${raw(showcase.code({
++ id: 'tools',
++ title: 'With the tools you have',
++ lede: `Path-style addressing, region us-east-1, Signature Version 4: ${sizeRule(L)}.`,
++ samples: [
++ { label: 'AWS CLI', lang: 'bash', code: `aws configure set aws_access_key_id ovk_…
aws configure set aws_secret_access_key …
aws --endpoint-url ${s3} s3 cp ./site s3://assets/ --recursive
--aws --endpoint-url ${s3} s3 ls s3://assets/</code></pre>
--<pre tabindex="0" class="zn-code"><code>rclone config create oz s3 provider Other endpoint ${s3} \\
++aws --endpoint-url ${s3} s3 ls s3://assets/` },
++ { label: 'rclone', lang: 'bash', code: `rclone config create oz s3 provider Other endpoint ${s3} \\
access_key_id ovk_… secret_access_key … no_check_bucket true
--rclone sync ./backups oz:backups</code></pre>
--<p class="zn-muted">Large files go up in parts, as these tools do by default: each part up to 100 MB, an object up to 20 GB.</p>
--</section>
++rclone sync ./backups oz:backups` },
++ { label: 'Node (AWS SDK v3)', lang: 'js', code: `const { S3Client, PutObjectCommand, GetObjectCommand } = require('@aws-sdk/client-s3');
++const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
++
++const s3 = new S3Client({
++ endpoint: '${s3}', region: 'us-east-1', forcePathStyle: true,
++ credentials: { accessKeyId: 'ovk_…', secretAccessKey: process.env.ZONE_SECRET },
++});
++await s3.send(new PutObjectCommand({ Bucket: 'assets', Key: 'hello.txt', Body: 'Hello' }));
++const url = await getSignedUrl(s3, new GetObjectCommand({ Bucket: 'assets', Key: 'hello.txt' }), { expiresIn: 3600 });` },
++ { label: 'Manage zones (openvibe-sdk)', lang: 'js', code: `const { createClient } = require('openvibe-sdk/core');
++const { createZoneClient } = require('openvibe-sdk/zone');
++
++const zone = createZoneClient(createClient({ tokenProvider: tokens })); // a project member's or app's tokens
++const z = await zone.zones.create('prj_…', { env: 'production', name: 'assets', quota_bytes: 512 * 1024 ** 2 });
++console.log(await zone.zones.usage(z.id));` },
++ ],
++ }))}
<section class="zn-section" aria-labelledby="zn-what"><h2 id="zn-what">What a zone gives you</h2>
<ul class="zn-list">
<li><strong>Private by default.</strong> A named zone's objects are reached only with a signed request or a presigned URL, never a public link.</li>
@@@ -105,7 +146,19 @@ rclone sync ./backups oz:backups</code></pre>
<li><strong>Scoped keys.</strong> A key reads, writes or both, in one environment, for every zone or only the ones you name. Revoking it takes effect at once.</li>
<li><strong>Quotas you set.</strong> Each zone may have its own ceiling below the environment's.</li>
<li><strong>One deletion path.</strong> Deleting a zone removes every object and every copy of it.</li>
--</ul></section>`,
++</ul></section>
++${L.size ? raw(showcase.limits({
++ title: 'Limits',
++ lede: 'What one project gets in each environment, as OpenVibe.Media, which stores the objects, enforces them.',
++ columns: ['Production', 'Sandbox'],
++ rows: ['storage_bytes', 'single_upload_bytes', 'multipart_bytes', 'part_bytes', 'unfinished_upload_hours', 'deleted_retention_days'].filter((id) => L.get(id)).map((id) => {
++ const l = L.get(id);
++ const fmt = (v) => (l.unit === 'bytes' ? bytes(v) : `${Number(v).toLocaleString('en-US')} ${l.unit}`);
++ return { label: l.label, values: [fmt(l.production), fmt(l.sandbox != null ? l.sandbox : l.production)] };
++ }),
++ source: 'https://openvibe.media/limits.json',
++ })) : ''}
++<section class="zn-section zn-faq" aria-labelledby="zn-faq-h"><h2 id="zn-faq-h">Questions</h2>${faq.map((f) => html`<details><summary>${f.q}</summary><p>${f.a}</p></details>`)}</section>`,
});
});
@@@ -266,7 +319,7 @@ ${keysSection}
}));
// ── Docs ─────────────────────────────────────────────────
-- r.get('/docs', (req, res) => page(req, res, {
++ r.get('/docs', async (req, res) => { const L = await limitsOf(); page(req, res, {
index: true, cache: PUBLIC_CACHE, title: 'Using OpenVibe.Zone',
description: 'Create zones, issue S3 access keys and reach your buckets with the AWS SDKs, rclone and any S3 tool: the OpenVibe.Zone guide and API.',
body: html`<h1>Using OpenVibe.Zone</h1>
@@@ -283,7 +336,7 @@ ${keysSection}
<li><strong>Multipart uploads:</strong> CreateMultipartUpload, UploadPart, CompleteMultipartUpload, AbortMultipartUpload, ListParts and ListMultipartUploads, with S3's rules (every part but the last at least 5 MiB). An unfinished upload expires after a day.</li>
<li><strong>Server-side copies:</strong> CopyObject and UploadPartCopy, within a zone or from another zone of the same environment the key may read, so <code>aws s3 cp s3://a/x s3://b/x</code> and rclone's server-side moves never download the bytes.</li>
<li><strong>Not supported:</strong> ACLs and bucket policies (zones are private; access is the key's), versioning (a zone keeps the current object), and deleting a zone over S3 (do that here).</li>
--<li><strong>Size:</strong> one request carries at most 100 MB through this endpoint, so larger files go up in parts (the AWS CLI, the SDKs and rclone do this by default); an object is at most 20 GB.</li>
++<li><strong>Size:</strong> ${sizeRule(L)}.</li>
<li><strong>Privacy:</strong> zones are private. Every request is signed. A presigned URL reaches only its own key, until it expires or its key is revoked.</li>
</ul>
<h2 id="keys">Access keys</h2>
@@@ -305,7 +358,7 @@ ${keysSection}
<li><code>GET /zones/:zone_id/usage</code> (<code>zone.usage.read</code>): Media's live snapshot, and this month's metered series (requests, egress, byte-hours) as Media reports each closed hour.</li>
</ul>
<p>The objects of a zone are OpenVibe.Media objects. Media's own API (<code>/api/v2/:project_id/objects</code>, with <code>namespace</code> set to the zone's id) reads and writes the same objects.</p>`,
-- }));
++ }); });
r.get('/updates', (req, res) => page(req, res, {
index: true, cache: PUBLIC_CACHE,
ddiff --git a/server/zone/media-limits.js b/server/zone/media-limits.js
nnew file mode 100644
iindex 0000000..83f14ae
---- /dev/null
++++ b/server/zone/media-limits.js
@@@ -0,0 +1,40 @@
++'use strict';
++
++/**
++ * The storage limits a zone meets, as OpenVibe.Media (the service that enforces them) publishes them at /limits.json:
++ * bytes per project environment, upload and part sizes, how long a deleted object can be restored. The home shows
++ * them instead of numbers typed into Zone. Read on loopback at most every ten minutes; a slow or failing Media never
++ * slows the page (2 s timeout) and the last good list, or none, is used: the sections that need it then hide.
++ */
++const TTL_MS = 10 * 60_000;
++const TIMEOUT_MS = 2_000;
++
++function createMediaLimits({ config, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console } = {}) {
++ let cache = { at: 0, byId: null };
++ let inflight = null;
++
++ async function refresh() {
++ try {
++ const res = await fetchImpl(`${config.media.internalUrl}/limits.json`, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(TIMEOUT_MS) });
++ if (!res.ok) throw new Error(`Media answered ${res.status}`);
++ const body = await res.json();
++ const list = Array.isArray(body && body.limits) ? body.limits : [];
++ cache = { at: now(), byId: new Map(list.filter((l) => l && typeof l.id === 'string' && Number.isFinite(Number(l.production))).map((l) => [l.id, l])) };
++ } catch (err) {
++ cache = { at: now(), byId: cache.byId }; // keep the last good list; try again after the TTL
++ log.warn && log.warn(`[Zone] media limits: ${err.message}`);
++ }
++ return cache.byId || new Map();
++ }
++
++ return {
++ /** Map of limit id → { id, label, unit, production, sandbox }; empty when Media has not answered yet. */
++ async get() {
++ if (cache.byId && now() - cache.at < TTL_MS) return cache.byId;
++ inflight = inflight || refresh().finally(() => { inflight = null; });
++ return await inflight;
++ },
++ };
++}
++
++module.exports = { createMediaLimits, TTL_MS };
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex fa45356..cf81790 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -38,7 +38,9 @@ async function boot(opts = {}) {
const { createStore } = require('../../server/db');
const testdb = await require('./db').testDb();
const store = createStore(testdb.db, { now: opts.now });
-- const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, clients: opts.clients });
++ const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, clients: opts.clients,
++ // Media's /limits.json: none unless a test hands some in (no Media runs in tests).
++ mediaLimits: opts.mediaLimits || { get: async () => new Map() } });
await built.ctx.keys.ensure();
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;
ddiff --git a/test/home-limits.test.js b/test/home-limits.test.js
nnew file mode 100644
iindex 0000000..d8c7acd
---- /dev/null
++++ b/test/home-limits.test.js
@@@ -0,0 +1,72 @@
++'use strict';
++/**
++ * The home and the guide show OpenVibe.Media's published limits, never numbers typed into Zone: the figures beside the
++ * hero, the limits table, the size rule ("one request carries at most …") and the cost answer all come from Media's
++ * /limits.json (zone/media-limits.js). Without Media's answer those figures hide and the words stay true. The FAQ shown
++ * on the page is the FAQPage data. media-limits reads at most every ten minutes and keeps the last good list.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const { createMediaLimits, TTL_MS } = require('../server/zone/media-limits');
++
++const MB = 1024 ** 2;
++const LIMITS = [
++ { id: 'storage_bytes', label: 'Stored bytes (every namespace of the tenant)', unit: 'bytes', production: 1024 * MB, sandbox: 100 * MB },
++ { id: 'single_upload_bytes', label: 'Size of a single-part upload', unit: 'bytes', production: 256 * MB, sandbox: 256 * MB },
++ { id: 'multipart_bytes', label: 'Size of a multipart upload', unit: 'bytes', production: 20 * 1024 * MB, sandbox: 2 * 1024 * MB },
++ { id: 'part_bytes', label: 'Size of one multipart part', unit: 'bytes', production: 256 * MB, sandbox: 256 * MB },
++ { id: 'deleted_retention_days', label: 'Days a deleted object can be restored', unit: 'days', production: 30, sandbox: 30 },
++];
++
++(async () => {
++ const withLimits = await boot({ mediaLimits: { get: async () => new Map(LIMITS.map((l) => [l.id, l])) } });
++ await check('with Media\'s limits: the figures, the table, the size rule and the questions', async () => {
++ const r = await withLimits.get('/');
++ assert.strictEqual(r.status, 200);
++ assert.match(r.text, /<dl class="zn-stats">/);
++ assert.match(r.text, /<dt>1\.0 GB<\/dt><dd>per project in production, at no charge<\/dd>/);
++ assert.match(r.text, /<dt>100 MB<\/dt><dd>more in each project&#39;s sandbox<\/dd>|<dt>100 MB<\/dt><dd>more in each project's sandbox<\/dd>/);
++ assert.match(r.text, /<dt>30 days<\/dt><dd>to restore a deleted object<\/dd>/);
++ assert.match(r.text, /one request carries at most 256 MB/, 'the size rule is Media\'s, not the old 100 MB');
++ assert.ok(!/at most 100 MB/.test(r.text));
++ assert.match(r.text, /sc-limits/, 'the limits table');
++ assert.match(r.text, /href="https:\/\/openvibe\.media\/limits\.json"/, 'and where it is read from');
++ const ld = [...r.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1])).find((j) => j['@type'] === 'FAQPage');
++ const shown = [...r.text.matchAll(/<summary>([^<]*)<\/summary>/g)].map((m) => m[1].replace(/&#39;/g, "'"));
++ assert.deepStrictEqual(ld.mainEntity.map((q) => q.name), shown, 'the FAQPage data asks what the page asks');
++ assert.match(ld.mainEntity.find((q) => q.name === 'What does it cost?').acceptedAnswer.text, /1\.0 GB in production and 100 MB in its sandbox/);
++ const docs = await withLimits.get('/docs');
++ assert.match(docs.text, /one request carries at most 256 MB/, 'the guide says the same');
++ });
++ await withLimits.close();
++
++ const without = await boot();
++ await check('without Media\'s answer: the figures and the table hide, and the words stay true', async () => {
++ const r = await without.get('/');
++ assert.strictEqual(r.status, 200);
++ assert.ok(!r.text.includes('zn-stats') && !r.text.includes('sc-limits'));
++ assert.match(r.text, /larger files go up in parts/);
++ assert.match(r.text, /Each project includes storage in production and in its sandbox at no charge\./);
++ });
++ await without.close();
++
++ await check('media-limits: read once per ten minutes, the last good list kept when Media fails', async () => {
++ let t = 0, calls = 0, fail = false;
++ const fetchImpl = async () => {
++ calls++;
++ if (fail) throw new Error('refused');
++ return { ok: true, json: async () => ({ limits: [...LIMITS, { id: 'broken' }] }) };
++ };
++ const ml = createMediaLimits({ config: { media: { internalUrl: 'http://media.test' } }, fetchImpl, now: () => t, log: {} });
++ const first = await ml.get();
++ assert.strictEqual(first.get('storage_bytes').production, 1024 * MB);
++ assert.ok(!first.has('broken'), 'an entry without a number is dropped');
++ await ml.get();
++ assert.strictEqual(calls, 1, 'cached');
++ t += TTL_MS + 1; fail = true;
++ assert.strictEqual((await ml.get()).get('part_bytes').production, 256 * MB, 'Media down: the last good list');
++ const empty = createMediaLimits({ config: { media: { internalUrl: 'http://media.test' } }, fetchImpl: async () => { throw new Error('refused'); }, log: {} });
++ assert.strictEqual((await empty.get()).size, 0, 'never answered: nothing');
++ });
++ done();
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/layout.test.js b/test/layout.test.js
iindex bdfb2ad..dbe2310 100644
---- a/test/layout.test.js
++++ b/test/layout.test.js
@@@ -44,7 +44,7 @@ const BOOST = /<script src="\/shared\/boost\.js\?v=[0-9a-f]{12}" data-main="#mai
assert.ok(head.includes('<link rel="canonical" href="https://openvibe.zone/">'), 'canonical');
assert.ok(head.includes('<meta name="robots" content="index, follow">'), 'robots');
const ld = [...head.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1])['@type']);
-- assert.deepStrictEqual(ld, ['WebSite', 'WebPage'], 'the home JSON-LD');
++ assert.deepStrictEqual(ld, ['WebSite', 'WebPage', 'FAQPage'], 'the home JSON-LD: the site, the page and the questions it shows');
for (const tag of ['<meta property="og:title"', 'data-ov-icon="zone"', '/css/app.css?v=', '/shared/theme-loader.js', '/shared/navbar.js', '/shared/footer.js', '<meta name="referrer"']) {
assert.ok(head.includes(tag), `head has ${tag}`);
}