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)
Changes
@@@ -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); }@@@ -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');@@@ -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,nnew file mode 100644@@@ -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 };@@@ -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}`;nnew file mode 100644@@@ -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'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(/'/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); });@@@ -44,7 +44,7 @@ const BOOST = /<script src="\/shared\/boost\.js\?v=[0-9a-f]{12}" data-main="#maiassert.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}`);}
@@ -80,3 +80,13 @@ details input { font: inherit; padding: 7px 9px; border-radius: 8px; border: 1px
@@ -80,3 +80,13 @@ details input { font: inherit; padding: 7px 9px; border-radius: 8px; border: 1px
.zn-secret dd { margin: 0; }
.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; }
.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; } }
@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); }
@@ -57,7 +57,8 @@ async function createApp(opts = {}) {
@@ -57,7 +57,8 @@ async function createApp(opts = {}) {
const clients = opts.clients || createClients({ config, fetchImpl });
const clients = opts.clients || createClients({ config, fetchImpl });
const usage = createUsage({ s, log });
const usage = createUsage({ s, log });
const zones = createZones({ s, clients, config, log, metering: usage });
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();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
@@ -55,6 +55,11 @@ function createPageRoutes(ctx) {
@@ -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 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 person = (req) => (req.viewer && req.viewer.kind === 'user' && /^usr_/.test(String(req.viewer.subject || '')) ? req.viewer : null);
const s3 = config.media.s3Endpoint;
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, {
const signInPage = (req, res, next) => page(req, res, {
title: 'Sign in', body: html`<h1>Sign in to manage your zones</h1>
title: 'Sign in', body: html`<h1>Sign in to manage your zones</h1>
@@ -63,8 +68,23 @@ function createPageRoutes(ctx) {
@@ -63,8 +68,23 @@ function createPageRoutes(ctx) {
}, 401);
}, 401);
// ── Home ─────────────────────────────────────────────────
// ── Home ─────────────────────────────────────────────────
- r.get('/', (req, res) => {
+ r.get('/', async (req, res) => {
const me = person(req);
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({
const hero = showcase.hero({
eyebrow: `${SITE_NAME} · part of OpenVibe`,
eyebrow: `${SITE_NAME} · part of OpenVibe`,
title: 'Private storage zones:',
title: 'Private storage zones:',
@@ -73,10 +93,12 @@ function createPageRoutes(ctx) {
@@ -73,10 +93,12 @@ function createPageRoutes(ctx) {
actions: me
actions: me
? [{ label: 'Your projects', href: '/projects', primary: true }, { label: 'How to use it', href: '/docs' }]
? [{ 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' }],
: [{ 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, {
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.',
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)}
body: html`${raw(hero)}
${raw(showcase.steps({
${raw(showcase.steps({
@@ -88,16 +110,35 @@ ${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' },
{ 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 configure set aws_secret_access_key …
aws --endpoint-url ${s3} s3 cp ./site s3://assets/ --recursive
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
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>
<section class="zn-section" aria-labelledby="zn-what"><h2 id="zn-what">What a zone gives you</h2>
<ul class="zn-list">
<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>
<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>
@@ -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>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>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>
<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}
@@ -266,7 +319,7 @@ ${keysSection}
}));
}));
// ── Docs ─────────────────────────────────────────────────
// ── 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',
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.',
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>
body: html`<h1>Using OpenVibe.Zone</h1>
@@ -283,7 +336,7 @@ ${keysSection}
@@ -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>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>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>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>
<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>
</ul>
<h2 id="keys">Access keys</h2>
<h2 id="keys">Access keys</h2>
@@ -305,7 +358,7 @@ ${keysSection}
@@ -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>
<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>
</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>`,
<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, {
r.get('/updates', (req, res) => page(req, res, {
index: true, cache: PUBLIC_CACHE,
index: true, cache: PUBLIC_CACHE,
new file mode 100644
new file mode 100644
@@ -0,0 +1,40 @@
@@ -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 };
@@ -38,7 +38,9 @@ async function boot(opts = {}) {
@@ -38,7 +38,9 @@ async function boot(opts = {}) {
const { createStore } = require('../../server/db');
const { createStore } = require('../../server/db');
const testdb = await require('./db').testDb();
const testdb = await require('./db').testDb();
const store = createStore(testdb.db, { now: opts.now });
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();
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 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}`;
const base = `http://127.0.0.1:${server.address().port}`;
new file mode 100644
new file mode 100644
@@ -0,0 +1,72 @@
@@ -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'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(/'/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); });
@@ -44,7 +44,7 @@ const BOOST = /<script src="\/shared\/boost\.js\?v=[0-9a-f]{12}" data-main="#mai
@@ -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('<link rel="canonical" href="https://openvibe.zone/">'), 'canonical');
assert.ok(head.includes('<meta name="robots" content="index, follow">'), 'robots');
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']);
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"']) {
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}`);
assert.ok(head.includes(tag), `head has ${tag}`);
}
}