openvibers/openvibe.host Commits

Host: public hosting waits for its launch switch (HOST_PUBLIC_HOSTING, off): staff-only projects and an honest front page until then (#47)

OP OpenVibers committed 10 Oct 2026 · 873a5f0216 · 7 files, +80 −8

Public hosting is owner-gated in the plan (T20 / O34: the owner's go-ahead O15, a designated DMCA agent O25 and a Public Suffix List entry for openvibe.website come first). It says "Host keeps Stage B untouched and unlaunched" until then. The front page nevertheless invited anyone to "Sign in with OpenVibe" and publish, and any signed-in person could create a project (production has 0 so far).

This adds the launch switch `HOST_PUBLIC_HOSTING` (default **off**):

- **Off:** `projects.create` refuses anyone but OpenVibe staff with 403 `host.not_launched` (API and dashboard form alike). The signed-in dashboard explains why instead of showing the form. The front page reads "not open yet / opening after review": it describes what the engine will do and still publishes the limits it enforces, but makes no sign-in-to-publish call. Staff can still create projects to test the engine.
- **On:** exactly today's behaviour and copy.

Nothing else in the engine changes. Turning hosting on at launch is one env line. The Stage B suites boot with it on; `test/host-launch-switch.test.js` covers off (front page, API 403, dashboard notice and refused form post, staff allowed).

Changes

ddiff --git a/.env.example b/.env.example
iindex e8457ae..be7e1fe 100644
---- a/.env.example
++++ b/.env.example
@@@ -18,6 +18,10 @@ TRUST_PROXY=1
# (never under openvibe.network / openvibe.live). Development: localhost (http://<site>.localhost:4910)
HOST_SITES_DOMAIN=openvibe.host
++# Public hosting is owner-gated (plan T20/O34). Off: only OpenVibe staff can create projects and the front page
++# says hosting is not open yet. Set to on at launch.
++HOST_PUBLIC_HOSTING=off
++
# SQLite database and content-addressed objects (the production unit sets both under
# /var/lib/openvibe-host-api)
# PostgreSQL (ADR-035), written by OpenVibe.Host roles/data/add-service.sh host: the runtime role through PgBouncer,
ddiff --git a/server/config.js b/server/config.js
iindex 1d285b1..ce2837f 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -111,6 +111,10 @@ function load(env = process.env) {
projects: {
maxPerOwner: int(env.HOST_MAX_PROJECTS_PER_OWNER, 10),
},
++ // Public hosting is owner-gated (plan T20/O34: the owner's go-ahead, a DMCA designated agent and a Public
++ // Suffix List entry come first). Until HOST_PUBLIC_HOSTING=on only OpenVibe staff can create projects, and the
++ // front page says hosting is not open yet; everything else about the engine is unchanged.
++ publicHosting: String(env.HOST_PUBLIC_HOSTING || 'off').trim().toLowerCase() === 'on',
// Per-project quotas (a row in host_quotas overrides them per project; only staff set it).
quotas: {
ddiff --git a/server/domain/projects.js b/server/domain/projects.js
iindex 3be56c2..97ae4ca 100644
---- a/server/domain/projects.js
++++ b/server/domain/projects.js
@@@ -66,6 +66,9 @@ function createProjects({ store, config, access, blobs, takedowns, log = console
}
async function create(viewer, input = {}) {
++ if (!config.publicHosting && !viewer.staff) {
++ throw new ApiError(403, 'host.not_launched', 'OpenVibe.Host is not open for hosting yet: it opens after a launch review, and only OpenVibe staff can create projects until then');
++ }
const owner = principalOf(viewer);
if (!owner || !ids.isSubjectId('user', owner)) {
throw new ApiError(403, 'project.owner_must_be_user', 'a project is owned by a person (usr_…): an app or service must name the owner with X-OV-Subject');
ddiff --git a/server/http/dashboard.js b/server/http/dashboard.js
iindex 0f6196f..6bfe14d 100644
---- a/server/http/dashboard.js
++++ b/server/http/dashboard.js
@@@ -72,10 +72,10 @@ function createDashboard(ctx) {
// The signed-out front page is the public page of openvibe.host (sitemap.xml): indexable.
// Everything behind sign-in stays noindex and private.
if (req.viewer.kind !== 'user') {
-- return page(req, res, 200, null, pages.signedOut({ limits: limitsOf(config), limitsUrl: `${config.baseUrl}/limits.json` }), null,
++ return page(req, res, 200, null, pages.signedOut({ limits: limitsOf(config), limitsUrl: `${config.baseUrl}/limits.json`, launched: config.publicHosting }), null,
{ indexable: !req.query || !Object.keys(req.query).length, styles: [showcase.STYLESHEET] });
}
-- page(req, res, 200, 'Projects', pages.home({ projects: await projects.listFor(req.viewer), csrf: csrfToken(config, req.viewer) }), notice(req));
++ page(req, res, 200, 'Projects', pages.home({ projects: await projects.listFor(req.viewer), csrf: csrfToken(config, req.viewer), canCreate: config.publicHosting || Boolean(req.viewer.staff) }), notice(req));
}));
router.get('/projects/:id', view(async (req, res) => {
ddiff --git a/server/render/pages.js b/server/render/pages.js
iindex 023b371..768a8b1 100644
---- a/server/render/pages.js
++++ b/server/render/pages.js
@@@ -22,16 +22,24 @@ const limitValue = (n, unit) => (n === 0 ? 'none' : unit === 'bytes' ? bytes(n)
/**
* The signed-out front page: the public page of openvibe.host, built with openvibe-shared/showcase. The limits table
* is limitsOf(config), the same object /limits.json serves, so the page never promises more than Host enforces.
++ * Until public hosting is launched (config.publicHosting) the page says so and invites no one to publish.
*/
--function signedOut({ limits, limitsUrl } = {}) {
-- return showcase.hero({
++function signedOut({ limits, limitsUrl, launched = false } = {}) {
++ return showcase.hero(launched ? {
eyebrow: 'OpenVibe.Host · alpha',
title: 'Publish a static site.', accent: 'Roll back in one step.',
lede: 'Upload a folder or a .tar.gz of HTML, CSS, JavaScript, images and fonts. Every deploy is kept as an immutable, content-addressed artifact; the active one is served at <site>.openvibe.host and on custom domains you verify.',
actions: [{ label: 'Sign in with OpenVibe', href: '/auth/login?next=%2F', primary: true }, { label: 'See the limits', href: '#limits' }],
note: 'Alpha. Static files only: there is no build step and no server-side code, and nothing you upload is ever executed. Hosted bots, mods and apps are not available.',
++ } : {
++ eyebrow: 'OpenVibe.Host · not open yet',
++ title: 'Static sites, every deploy kept.', accent: 'Opening after review.',
++ lede: 'The hosting engine is built and tested: upload a folder or a .tar.gz, every deploy is kept as an immutable, content-addressed artifact, and the active one is served at <site>.openvibe.host and on custom domains you verify. It is not open to the public yet.',
++ actions: [{ label: 'What it will do', href: '#what-you-get', primary: true }, { label: 'See the limits', href: '#limits' }],
++ note: 'Public hosting opens after a launch review: abuse handling and a designated DMCA agent come first. Until then only OpenVibe staff can create projects.',
}) + showcase.features({
-- title: 'What you get',
++ id: 'what-you-get',
++ title: launched ? 'What you get' : 'What it will do',
items: [
{ icon: 'ov:upload', title: 'Upload from the browser or the API', text: 'A folder or an archive from the dashboard, or POST it to /api/v1. Archives are checked in memory and never extracted.' },
{ icon: 'ov:history', title: 'Every deploy kept', text: 'Deploys never change once made. Activate an earlier one to roll back, from the dashboard or the API.' },
@@@ -39,7 +47,7 @@ function signedOut({ limits, limitsUrl } = {}) {
{ icon: 'ov:account', title: 'Projects for a team', text: 'Owners add maintainers and members per project. Sandbox projects have smaller quotas and stay out of search.' },
],
}) + showcase.steps({
-- title: 'Get a site online',
++ title: launched ? 'Get a site online' : 'How it will work',
items: [
{ title: 'Sign in', text: 'With your OpenVibe account.' },
{ title: 'Create a project', text: 'Production, or a sandbox to try things out.' },
@@@ -56,12 +64,12 @@ function signedOut({ limits, limitsUrl } = {}) {
}) : '');
}
--function home({ projects, csrf }) {
++function home({ projects, csrf, canCreate = true }) {
const rows = projects.map((p) => `<tr><td><a href="/projects/${esc(p.id)}">${esc(p.name)}</a></td><td>${esc(p.environment)}</td><td>${esc(p.role || '')}</td><td>${when(p.created_at)}</td></tr>`).join('');
return `<h1>Your projects</h1>
${projects.length ? `<table><thead><tr><th>Project</th><th>Environment</th><th>Your role</th><th>Created</th></tr></thead><tbody>${rows}</tbody></table>` : '<p>You have no projects yet.</p>'}
<h2>New project</h2>
--${form('/projects', csrf, `<label>Name <input name="name" required maxlength="80"></label>
++${!canCreate ? '<p>OpenVibe.Host is not open for hosting yet. It opens after a launch review (abuse handling and a designated DMCA agent first); until then only OpenVibe staff can create projects.</p>' : form('/projects', csrf, `<label>Name <input name="name" required maxlength="80"></label>
<label>Environment <select name="environment"><option value="production">production</option><option value="sandbox">sandbox (smaller quotas, noindex, no custom domains)</option></select></label>
<button type="submit">Create project</button>`)}`;
}
ddiff --git a/test/host-launch-switch.test.js b/test/host-launch-switch.test.js
nnew file mode 100644
iindex 0000000..9559634
---- /dev/null
++++ b/test/host-launch-switch.test.js
@@@ -0,0 +1,52 @@
++'use strict';
++/**
++ * Public hosting is owner-gated (plan T20/O34): until HOST_PUBLIC_HOSTING=on, the front page says hosting is not
++ * open yet and invites no one to publish, a person cannot create a project (403 host.not_launched), the dashboard
++ * shows why instead of the form, and OpenVibe staff can still create projects to test the engine.
++ */
++const assert = require('assert');
++const { boot, check, done, DASHBOARD } = require('./stageb/boot');
++const { csrfToken } = require('../server/auth/forms');
++
++(async () => {
++ const t = await boot({ env: { HOST_PUBLIC_HOSTING: 'off' } });
++ const alice = t.user('alice');
++ const staff = t.user('root', { role: 'admin' });
++
++ await check('the signed-out front page says hosting is not open yet and invites no one to publish', async () => {
++ const r = await t.api('GET', '/');
++ assert.strictEqual(r.status, 200);
++ assert.match(r.text, /not open yet/);
++ assert.match(r.text, /launch review/);
++ assert.match(r.text, /How it will work/);
++ assert.doesNotMatch(r.text, /Get a site online/);
++ assert.doesNotMatch(r.text, /class="sc-btn sc-primary" href="\/auth\/login/, 'no primary sign-in-to-publish call');
++ assert.match(r.text, /id="limits"/, 'the limits the engine enforces are still published');
++ });
++
++ await check('a person cannot create a project until launch', async () => {
++ const r = await t.api('POST', '/api/v1/projects', { as: alice, json: { name: 'Too early' } });
++ assert.strictEqual(r.status, 403);
++ assert.strictEqual(r.json().code, 'host.not_launched');
++ });
++
++ await check('the signed-in dashboard explains instead of offering the form, and its form post is refused', async () => {
++ const r = await t.api('GET', '/', { session: alice });
++ assert.strictEqual(r.status, 200);
++ assert.match(r.text, /Your projects/);
++ assert.match(r.text, /not open for hosting yet/);
++ assert.doesNotMatch(r.text, /<form[^>]+action="\/projects"/);
++ const csrf = csrfToken({ formSecret: 'test-form-secret' }, { subject: alice.subject });
++ const post = await t.api('POST', '/projects', { session: alice, form: { csrf, name: 'Too early', environment: 'production' }, headers: { origin: `https://${DASHBOARD}` } });
++ assert.ok(!/\/projects\/prj_/.test(post.headers.location || ''), `no project page to go to: ${post.status} ${post.headers.location}`);
++ const mine = (await t.api('GET', '/api/v1/projects', { as: alice })).json().projects;
++ assert.strictEqual(mine.length, 0, 'nothing was created');
++ });
++
++ await check('OpenVibe staff can still create a project to test the engine', async () => {
++ const p = await t.project(staff, 'Staff test');
++ assert.match(p.id, /^prj_/);
++ });
++
++ done();
++})();
ddiff --git a/test/stageb/boot.js b/test/stageb/boot.js
iindex 6744fc3..ab43841 100644
---- a/test/stageb/boot.js
++++ b/test/stageb/boot.js
@@@ -50,6 +50,7 @@ async function boot(opts = {}) {
OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url,
OV_OAUTH_CLIENT_ID: 'host', OV_OAUTH_CLIENT_SECRET: SECRET, COOKIE_SECURE: 'false',
HOST_FORM_SECRET: 'test-form-secret', HOST_WORKER: 'off', HOST_MIN_FREE_BYTES: '0',
++ HOST_PUBLIC_HOSTING: 'on', // the suites exercise the engine as launched; test/host-launch-switch.test.js covers off
...(opts.env || {}),
};
const configLib = require('../../server/config');