openvibers/openvibe.connect Commits

A share can close itself (CON-6: expiry) (#8)

OP OpenVibers committed 11 days ago · 3d8f268d72 · 11 files, +131 −17

The contract's website.share@1 expires_at, built: 'Closes by itself' on the make-a-share form and the
share page (and expires_in on POST/PATCH, 600 s to 30 days, null for 'when you close it'). When the
time is up the address answers 410 'This share has ended' at once; a 30-second sweep marks it closed,
disconnects the computer and forgets its request list; the share page says when it closes, or ended.
Migration 0002 adds the column (expand: null for every row today).

Changes

ddiff --git a/README.md b/README.md
iindex f210fa2..0c21c73 100644
---- a/README.md
++++ b/README.md
@@@ -49,9 +49,9 @@ With a person's OpenVibe token as a Bearer (or the session on connect.openvibe.w
| Call | What |
|---|---|
| `GET /api/v1/shares` | your shares, with `connected` |
--| `POST /api/v1/shares` `{ label, port, mode?, named? }` | make one → `201 { share, key }` (the key is in this answer only) |
++| `POST /api/v1/shares` `{ label, port, mode?, named?, expires_in? }` | make one → `201 { share, key }` (the key is in this answer only); `expires_in` (600 s to 30 days) closes it by itself |
| `GET /api/v1/shares/:id` | one share |
--| `PATCH /api/v1/shares/:id` `{ mode?, named?, port?, note? }` | change it |
++| `PATCH /api/v1/shares/:id` `{ mode?, named?, port?, note?, expires_in? }` | change it; `expires_in: null` keeps it open until you close it |
| `DELETE /api/v1/shares/:id` | close it (410 from then on) |
| `POST /api/v1/shares/:id/key` | a new key → `{ share, key }`; the connected computer is disconnected |
| `GET /api/v1/shares/:id/visits` | who opened it in the last 7 days |
ddiff --git a/migrations/0002_share_expiry.sql b/migrations/0002_share_expiry.sql
nnew file mode 100644
iindex 0000000..0d518ac
---- /dev/null
++++ b/migrations/0002_share_expiry.sql
@@@ -0,0 +1,6 @@
++-- phase: expand
++-- A share can close itself (CON-6, the contract's website.share@1 expires_at): "Closes in 1 hour / 8 hours / 1 day /
++-- 7 days" on the share page. Null is "until you close it", every row's value today, and the code before this one
++-- never reads it. The sweep (app.js) finds the due ones by this index.
++ALTER TABLE shares ADD COLUMN IF NOT EXISTS expires_at timestamptz;
++CREATE INDEX IF NOT EXISTS shares_expiring ON shares (expires_at) WHERE status = 'active' AND expires_at IS NOT NULL;
ddiff --git a/public/css/connect.css b/public/css/connect.css
iindex 0b1c4f1..74bc3f1 100644
---- a/public/css/connect.css
++++ b/public/css/connect.css
@@@ -76,11 +76,11 @@
.cn-field { display: grid; gap: 6px; min-width: 0; align-content: start; }
.cn-field > span:first-child { font-size: 14px; font-weight: 600; }
.cn-field small { font-size: 12.5px; color: var(--ovk-muted); }
--.cn-field input, .cn-addr { box-sizing: border-box; width: 100%; min-height: var(--ovk-tap, 44px); padding: 10px 12px; border-radius: 10px; border: 1px solid var(--ovk-line); background: var(--ovk-input); color: var(--ovk-text); font: inherit; font-size: 16px; }
++.cn-field input, .cn-field select, .cn-addr { box-sizing: border-box; width: 100%; min-height: var(--ovk-tap, 44px); padding: 10px 12px; border-radius: 10px; border: 1px solid var(--ovk-line); background: var(--ovk-input); color: var(--ovk-text); font: inherit; font-size: 16px; }
.cn-addr { display: flex; align-items: center; gap: 0; padding: 0 12px; min-width: 0; cursor: text; }
.cn-addr input { flex: 0 1 auto; field-sizing: content; width: 10ch; min-width: 6ch; max-width: 100%; border: 0; padding: 10px 0; min-height: 0; background: transparent; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 16px; }
.cn-addr input:focus { outline: 0; }
--.cn-addr:focus-within, .cn-field input:focus-visible { outline: 2px solid var(--room-accent); outline-offset: 1px; }
++.cn-addr:focus-within, .cn-field input:focus-visible, .cn-field select:focus-visible { outline: 2px solid var(--room-accent); outline-offset: 1px; }
.cn-addr-tail { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ovk-muted); font: 14px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.cn-modes { display: grid; gap: 8px; margin: 0; padding: 0; border: 0; min-width: 0; }
.cn-modes legend { font-size: 14px; font-weight: 600; margin-bottom: 8px; padding: 0; }
ddiff --git a/server/app.js b/server/app.js
iindex 42ecd85..4f39ad8 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -68,6 +68,19 @@ async function createApp(opts = {}) {
const ctx = { config, s, keys, sso, principal, shares, tunnels, gate, proxy, accountData, accountSend, log };
++ /** Shares whose time is up close themselves (CON-6): their computers are disconnected and their lists forgotten. */
++ ctx.expireSweep = async () => {
++ let due = [];
++ try { due = await shares.expireDue(); } catch (err) { log.warn('[OpenVibe.Connect] expiry sweep failed:', err.message); return []; }
++ due.forEach((r) => { tunnels.close(r.id, 4003, 'the share ended'); proxy.forget(r.host); proxy.inspector.forget(r.id); });
++ if (due.length) log.log(`[OpenVibe.Connect] ${due.length} share(s) ended on time`);
++ return due;
++ };
++ if (opts.expirySweep !== false) {
++ ctx.expiryTimer = setInterval(() => { ctx.expireSweep(); }, 30_000);
++ ctx.expiryTimer.unref();
++ }
++
/** A WebSocket upgrade: a share host goes through the gate; /tunnel on Connect's own host is a computer. */
ctx.onUpgrade = (req, socket, head) => {
if (proxy.isShareHost(req.headers.host)) { proxy.upgrade(req, socket, head).catch(() => socket.destroy()); return; }
ddiff --git a/server/edge/proxy.js b/server/edge/proxy.js
iindex 9c49db2..56696e0 100644
---- a/server/edge/proxy.js
++++ b/server/edge/proxy.js
@@@ -207,6 +207,9 @@ function createProxy({ config, shares, tunnels, gate, log = console, now = () =>
return null;
}
if (!share) { sendProblem(req, res, 404, 'no_share', 'No share here', `Nothing is shared at ${parsed.host}.`, { link: { href: config.baseUrl, text: 'What is OpenVibe Connect?' } }); return null; }
++ // Its time is up: closed now, even before the sweep (app.js) marks it.
++ const expired = share.expired || (share.status === 'active' && share.expires_at && Date.parse(share.expires_at) <= now());
++ if (expired) { sendProblem(req, res, 410, 'expired', 'This share has ended', `${share.host} was shared until ${new Date(share.expires_at).toUTCString().replace(' GMT', ' UTC')}, and that time is up.`, { link: { href: config.baseUrl, text: 'What is OpenVibe Connect?' } }); return null; }
if (share.status !== 'active') { sendProblem(req, res, 410, 'closed', 'This share is closed', `${share.owner_username} closed ${share.host}.`, { link: { href: config.baseUrl, text: 'What is OpenVibe Connect?' } }); return null; }
return share;
}
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 383c864..b031f5a 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -5,9 +5,10 @@
* Bearer). A session write must come from connect.openvibe.website itself (403 connect.cross_site).
*
* GET /shares your shares (website.share@1 shapes, plus connected)
-- * POST /shares { label, port, mode?, named? } → 201 { share, key } the key is in this answer only
++ * POST /shares { label, port, mode?, named?, expires_in? } → 201 { share, key } the key is in this answer only
* GET /shares/:id one of your shares
-- * PATCH /shares/:id { mode?, named?, port?, note? }
++ * PATCH /shares/:id { mode?, named?, port?, note?, expires_in? } expires_in: seconds from now (600 to 30 days),
++ * or null to stay open until you close it
* DELETE /shares/:id close it (the host answers 410 from then on)
* POST /shares/:id/key a new key → { share, key }; the connected computer is disconnected
* GET /shares/:id/visits who opened it in the last 7 days
@@@ -44,7 +45,7 @@ function createApi(ctx) {
r.post('/shares', guard(async (req, res) => {
if (!req.principal.username) return fail(res, req, 403, 'identity.no_username', 'Your account needs a username before it can share.');
const b = req.body || {};
-- const { share, key } = await shares.create(owner(req), { label: b.label, port: b.port, mode: b.mode, named: b.named, note: b.note });
++ const { share, key } = await shares.create(owner(req), { label: b.label, port: b.port, mode: b.mode, named: b.named, note: b.note, expires_in: b.expires_in });
return res.status(201).json({ share: shape(share), key });
}));
@@@ -63,7 +64,7 @@ function createApi(ctx) {
const share = await mine(req, res);
if (!share) return undefined;
const b = req.body || {};
-- const updated = await shares.setPolicy(owner(req), share.id, { mode: b.mode, named: b.named, port: b.port, note: b.note });
++ const updated = await shares.setPolicy(owner(req), share.id, { mode: b.mode, named: b.named, port: b.port, note: b.note, expires_in: b.expires_in });
proxy.forget(updated.host);
return res.json({ share: shape(updated) });
}));
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 8fa82be..02b802c 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -56,7 +56,7 @@ function createPageRoutes(ctx) {
r.post('/shares', async (req, res) => {
if (!guard(req, res)) return undefined;
-- const input = { label: req.body.label, port: req.body.port, mode: req.body.mode, named: req.body.named };
++ const input = { label: req.body.label, port: req.body.port, mode: req.body.mode, named: req.body.named, expires_in: req.body.expires_in };
try {
const { share, key } = await shares.create(owner(req), input);
return page(req, res, { title: share.host, body: views.sharePage({ share, connected: false, key, baseUrl: config.baseUrl, staff: req.viewer.role === 'admin', notice: 'Your share is made. Run the command below on your computer.' }) }, 201);
@@@ -96,7 +96,7 @@ function createPageRoutes(ctx) {
const share = await mine(req, res);
if (!share) return undefined;
try {
-- const updated = await shares.setPolicy(owner(req), share.id, { mode: req.body.mode, named: req.body.named, port: req.body.port === undefined || req.body.port === '' ? undefined : Number(req.body.port) });
++ const updated = await shares.setPolicy(owner(req), share.id, { mode: req.body.mode, named: req.body.named, port: req.body.port === undefined || req.body.port === '' ? undefined : Number(req.body.port), expires_in: req.body.expires_in === undefined ? undefined : req.body.expires_in });
proxy.forget(updated.host);
return res.redirect(303, `/shares/${encodeURIComponent(share.id)}?saved=1`);
} catch (err) {
ddiff --git a/server/index.js b/server/index.js
iindex 147835e..50e8af5 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -21,6 +21,7 @@ function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }
() => { for (const t of timers) clearInterval(t); },
() => ctx.tunnels.closeAll(),
() => ctx.proxy.stop(),
++ () => clearInterval(ctx.expiryTimer),
() => ctx.gate.stop(),
() => ctx.keys.client.stop(),
async () => { clearInterval(ctx.telemetryTick); if (ctx.retention) await ctx.retention.stop(); if (ctx.telemetry) await ctx.telemetry.stop(); },
ddiff --git a/server/shares.js b/server/shares.js
iindex 5c03575..88e5f21 100644
---- a/server/shares.js
++++ b/server/shares.js
@@@ -26,6 +26,9 @@ const LABEL_RE = /^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,39}$/;
const MODES = ['owner', 'named', 'signin'];
const RESERVED_LABELS = new Set(['www', 'api', 'app', 'admin', 'connect', 'hooks', 'edge', 'share', 'status', 'mail', 'space', 'auth', 'login', 'openvibe']);
const USERNAME_RE = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}$/;
++// How long a share may stay open by itself (CON-6): 10 minutes to 30 days, or until its owner closes it (null).
++const EXPIRES_MIN = 600;
++const EXPIRES_MAX = 30 * 86400;
const SIGNIN_NOT_YET = '"Anyone signed in" opens with the abuse check. For now, name the people who may open it.';
const isStaff = (owner) => Boolean(owner && owner.role === 'admin');
@@@ -79,6 +82,16 @@ function normalizeNamed(list) {
return out;
}
++/** `expires_in` as given (seconds; '' / null / 0 / 'never' = no end) → seconds or null; a bad one throws. */
++function expiresIn(value) {
++ if (value == null || value === '' || value === 0 || value === '0' || value === 'never') return null;
++ const n = Number(value);
++ if (!Number.isInteger(n) || n < EXPIRES_MIN || n > EXPIRES_MAX) {
++ throw new ShareError(422, 'share.invalid_expiry', 'A share can close itself after 10 minutes to 30 days, or stay open until you close it.');
++ }
++ return n;
++}
++
function createShares(s, { config }) {
const { db } = s;
const domain = config.shares.domain;
@@@ -104,6 +117,9 @@ function createShares(s, { config }) {
created_at: iso(row.created_at),
updated_at: iso(row.updated_at),
revoked_at: iso(row.revoked_at),
++ expires_at: iso(row.expires_at),
++ // It closed itself: revoked at (or just after) the time it was given.
++ expired: row.status === 'revoked' && Boolean(row.expires_at && row.revoked_at && new Date(row.revoked_at) >= new Date(row.expires_at)),
last_connected_at: iso(row.last_connected_at),
last_visit_at: iso(row.last_visit_at),
requests: Number(row.requests || 0),
@@@ -146,6 +162,7 @@ function createShares(s, { config }) {
const host = hostFor(label, owner.username, domain);
if (!host) throw new ShareError(422, 'share.name_too_long', 'That name and your username make an address longer than 63 characters. Pick a shorter name.');
const note = input.note ? String(input.note).slice(0, 200) : null;
++ const ttl = expiresIn(input.expires_in);
const live = await db.one("SELECT count(*)::int AS n FROM shares WHERE owner_subject = $1 AND status = 'active'", [owner.subject]);
if (live.n >= config.shares.perPerson) throw new ShareError(409, 'share.limit', `You have ${live.n} shares open, the most for now. Close one first.`);
@@@ -160,9 +177,9 @@ function createShares(s, { config }) {
const id = s.newId('shr');
try {
await db.exec(
-- `INSERT INTO shares (id, owner_subject, owner_username, label, host, local_port, mode, named, key_prefix, key_hash, note)
-- VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10, $11)`,
-- [id, owner.subject, String(owner.username).slice(0, 64), label, host, port, mode, JSON.stringify(named), key.slice(0, 10), sha256(key), note],
++ `INSERT INTO shares (id, owner_subject, owner_username, label, host, local_port, mode, named, key_prefix, key_hash, note, expires_at)
++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10, $11, CASE WHEN $12::int IS NULL THEN NULL ELSE now() + make_interval(secs => $12::int) END)`,
++ [id, owner.subject, String(owner.username).slice(0, 64), label, host, port, mode, JSON.stringify(named), key.slice(0, 10), sha256(key), note, ttl],
);
} catch (err) {
if (err && err.code === '23505') throw new ShareError(409, 'share.taken', 'That address is taken. Pick another name.');
@@@ -183,10 +200,15 @@ function createShares(s, { config }) {
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new ShareError(422, 'share.invalid_port', 'The port is a number from 1 to 65535.');
const note = input.note === undefined ? share.note : (input.note ? String(input.note).slice(0, 200) : null);
const policyChanged = mode !== share.mode || JSON.stringify(named) !== JSON.stringify(share.named);
++ // expires_in: undefined keeps the time it has; null / '' / 'never' takes it away; seconds set it from now.
++ const keepExpiry = input.expires_in === undefined || input.expires_in === 'keep';
++ const ttl = keepExpiry ? null : expiresIn(input.expires_in);
await db.exec(
`UPDATE shares SET mode = $2, named = $3::jsonb, local_port = $4, note = $5, updated_at = now(),
-- policy_version = policy_version + $6 WHERE id = $1`,
-- [share.id, mode, JSON.stringify(named), port, note, policyChanged ? 1 : 0],
++ policy_version = policy_version + $6,
++ expires_at = CASE WHEN $7::boolean THEN expires_at WHEN $8::int IS NULL THEN NULL ELSE now() + make_interval(secs => $8::int) END
++ WHERE id = $1`,
++ [share.id, mode, JSON.stringify(named), port, note, policyChanged ? 1 : 0, keepExpiry, ttl],
);
return get(share.id);
}
@@@ -206,6 +228,14 @@ function createShares(s, { config }) {
return get(share.id);
}
++ /** Close every share whose time is up (the sweep in app.js); returns them, so their computers are let go. */
++ async function expireDue() {
++ return db.many(
++ `UPDATE shares SET status = 'revoked', revoked_at = now(), updated_at = now(), policy_version = policy_version + 1
++ WHERE status = 'active' AND expires_at IS NOT NULL AND expires_at <= now() RETURNING id, host`,
++ );
++ }
++
async function touchConnected(id) { await db.exec('UPDATE shares SET last_connected_at = now() WHERE id = $1', [id]); }
/** Flush counters gathered in memory: { [shareId]: { requests, bytesIn, bytesOut, visits: { [subject]: { username, requests, denied } } } } */
@@@ -245,7 +275,7 @@ function createShares(s, { config }) {
return false;
}
-- return { create, list, get, byHost, byKey, setPolicy, rotateKey, revoke, touchConnected, flushCounters, visits, allows, view, domain };
++ return { create, list, get, byHost, byKey, setPolicy, rotateKey, revoke, expireDue, touchConnected, flushCounters, visits, allows, view, domain };
}
function iso(v) {
ddiff --git a/server/views/index.js b/server/views/index.js
iindex 54e0d55..7074bf0 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -73,6 +73,27 @@ function modeChoices(current, staff) {
}).join('');
}
++// How long a share stays open by itself (CON-6), as the forms offer it: seconds → words.
++const CLOSES = [['', 'When you close it'], ['3600', 'In 1 hour'], ['28800', 'In 8 hours'], ['86400', 'In 1 day'], ['604800', 'In 7 days']];
++
++/** When a time to come is, in words: "in 3 hours", "in 2 days", "in 12 minutes". */
++function until(iso, now = Date.now()) {
++ const ms = Date.parse(iso) - now;
++ if (!Number.isFinite(ms)) return '';
++ if (ms <= 0) return 'now';
++ const m = Math.round(ms / 60000);
++ if (m < 60) return `in ${m} minute${m === 1 ? '' : 's'}`;
++ const h = Math.round(m / 60);
++ if (h < 48) return `in ${h} hour${h === 1 ? '' : 's'}`;
++ const d = Math.round(h / 24);
++ return `in ${d} days`;
++}
++
++function closesField(current, { keep = null } = {}) {
++ const opts = (keep ? [['keep', keep]] : []).concat(keep ? CLOSES.map(([v, t]) => [v || 'never', v ? `${t.replace('In ', '')} from now` : 'Only when you close it']) : CLOSES);
++ return `<label class="cn-field cn-field-closes"><span>Closes by itself</span><select name="expires_in">${opts.map(([v, t]) => `<option value="${esc(v)}"${String(current) === v ? ' selected' : ''}>${esc(t)}</option>`).join('')}</select><small>The address answers "ended" after that, and the computer is disconnected.</small></label>`;
++}
++
function createForm({ values = {}, error = null, username = '', staff = false } = {}) {
const handle = String(username || 'you').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'you';
return `<form class="cn-form" method="post" action="/shares" id="new">
@@@ -82,6 +103,7 @@ ${error ? `<p class="cn-error" role="alert">${esc(error)}</p>` : ''}
<label class="cn-field"><span>Name</span><span class="cn-addr"><input name="label" required maxlength="40" pattern="[a-z0-9]([a-z0-9]|-(?=[a-z0-9])){0,39}" value="${esc(values.label || '')}" placeholder="agents" autocapitalize="none" autocomplete="off" spellcheck="false"><span class="cn-addr-tail">--${esc(handle)}.openvibe.website</span></span><small>a–z, 0–9 and hyphens</small></label>
<label class="cn-field cn-field-port"><span>Port on your computer</span><input name="port" required inputmode="numeric" pattern="[0-9]{1,5}" maxlength="5" value="${esc(values.port || '')}" placeholder="8080"></label>
</div>
++${closesField(values.expires_in == null ? '' : values.expires_in)}
<fieldset class="cn-modes"><legend>Who can open it</legend>
${modeChoices(values.mode || 'owner', staff)}
</fieldset>
@@@ -223,6 +245,7 @@ ${modeChoices(share.mode, staff)}
</fieldset>
<label class="cn-field"><span>People (for "the people you name")</span><input name="named" maxlength="2000" value="${esc(share.named.join(', '))}" placeholder="sam, riley" autocapitalize="none" autocomplete="off" spellcheck="false"><small>OpenVibe usernames, separated by commas</small></label>
<label class="cn-field cn-field-port"><span>Port on your computer</span><input name="port" inputmode="numeric" pattern="[0-9]{1,5}" maxlength="5" value="${esc(share.local_port)}"></label>
++${closesField('keep', { keep: share.expires_at ? `As it is: ${until(share.expires_at, now)}` : 'As it is: only when you close it' })}
<button class="cn-btn" type="submit">${kit.icon('check')} Save</button>
<p class="cn-fine">Changing who can open it signs everyone out of this share; the people still allowed are let back in when they next open it.</p>
</form>`;
@@@ -245,6 +268,7 @@ ${key ? keyBlock(share, key, baseUrl) : ''}
<div><dt>Data</dt><dd>${esc(fmtBytes(share.bytes_in))} in · ${esc(fmtBytes(share.bytes_out))} out</dd></div>
<div><dt>Key</dt><dd><code>${esc(share.key_prefix)}…</code></dd></div>
<div><dt>Made</dt><dd>${esc(ago(share.created_at, now))}</dd></div>
++${share.expires_at ? `<div><dt>${closed ? 'Ended' : 'Closes'}</dt><dd><time datetime="${esc(share.expires_at)}">${esc(closed ? ago(share.expires_at, now) : until(share.expires_at, now))}</time></dd></div>` : ''}
</dl>
${!key && !closed ? `<section class="cn-run"><h2>Connect it</h2><div class="cn-copy"><code id="cn-run">node ov-connect.js --key ovc_… --port ${esc(share.local_port)}</code></div><p class="cn-fine">The key was shown once, when it was made. <a href="${esc(CLIENT_URL(baseUrl))}">Download ov-connect.js</a> (Node 22 or newer).</p></section>` : ''}
<section aria-labelledby="cn-visits-h"><h2 id="cn-visits-h">Who opened it (7 days)</h2>${visitRows}</section>
@@@ -270,4 +294,4 @@ function message({ title, text, link = null }) {
function notFound() { return message({ title: 'Not found', text: 'There is no page here.', link: { href: '/', label: 'OpenVibe Connect' } }); }
--module.exports = { home, sharePage, grantDenied, message, notFound, MODE_TEXT, fmtBytes, ago, requestRow, size };
++module.exports = { home, sharePage, grantDenied, message, notFound, MODE_TEXT, fmtBytes, ago, until, requestRow, size };
ddiff --git a/test/share.test.js b/test/share.test.js
iindex 59b6145..97666ab 100644
---- a/test/share.test.js
++++ b/test/share.test.js
@@@ -277,6 +277,42 @@ async function until(fn, ms = 3000) {
assert.deepStrictEqual([refused.status, refused.error], [502, 'local_refused'], 'nothing listening is named');
});
++ await check('a share can close itself (CON-6): set, changed, kept, refused when odd, and ended on time', async () => {
++ const made2 = await t.get('/api/v1/shares', { as: alex, method: 'POST', json: { label: 'brief', port: app.port, expires_in: 3600 } });
++ assert.strictEqual(made2.status, 201, made2.text);
++ const { share: brief, key } = made2.json();
++ const left = Date.parse(brief.expires_at) - Date.now();
++ assert.ok(left > 3500 * 1000 && left <= 3600 * 1000, `about an hour (${left} ms)`);
++ assert.strictEqual((await t.get('/api/v1/shares', { as: alex, method: 'POST', json: { label: 'blink', port: app.port, expires_in: 5 } })).json().code, 'share.invalid_expiry');
++ const kept = (await t.get(`/api/v1/shares/${brief.id}`, { as: alex, method: 'PATCH', json: { note: 'still brief' } })).json().share;
++ assert.strictEqual(kept.expires_at, brief.expires_at, 'a change without expires_in keeps the time');
++ const open = (await t.get(`/api/v1/shares/${brief.id}`, { as: alex, method: 'PATCH', json: { expires_in: null } })).json().share;
++ assert.strictEqual(open.expires_at, null, 'null: open until closed');
++ const again = (await t.get(`/api/v1/shares/${brief.id}`, { as: alex, method: 'PATCH', json: { expires_in: 600 } })).json().share;
++ assert.ok(Date.parse(again.expires_at) - Date.now() <= 600 * 1000);
++ const pageText = (await t.get(`/shares/${brief.id}`, { as: alex })).text;
++ assert.match(pageText, /<dt>Closes<\/dt><dd><time datetime="[^"]+">in 10 minutes<\/time>/);
++ assert.match(pageText, /<option value="keep" selected>As it is: in 10 minutes<\/option>/);
++
++ const cBrief = t.client({ key, port: app.port });
++ await cBrief.ready();
++ const cookie = (await t.enter(alex, brief)).cookie;
++ assert.strictEqual((await t.share(brief.host, '/', { cookie, headers: { accept: 'application/json' } })).status, 200);
++ // The time is up: the address says so at once, and the sweep closes it and lets the computer go.
++ await t.ctx.s.db.exec("UPDATE shares SET expires_at = now() - interval '1 second' WHERE id = $1", [brief.id]);
++ t.ctx.proxy.forget(brief.host);
++ const ended = await t.share(brief.host, '/', { cookie, headers: { accept: 'application/json' } });
++ assert.strictEqual(ended.status, 410);
++ assert.match(ended.text, /expired/);
++ const due = await t.ctx.expireSweep();
++ assert.deepStrictEqual(due.map((r) => r.id), [brief.id]);
++ await until(() => cBrief.lines.some((l) => /Stopped/.test(l)));
++ const after = (await t.get(`/api/v1/shares/${brief.id}`, { as: alex })).json().share;
++ assert.deepStrictEqual([after.status, after.expired], ['revoked', true]);
++ assert.match((await t.get(`/shares/${brief.id}`, { as: alex })).text, /<dt>Ended<\/dt>/);
++ assert.deepStrictEqual(await t.ctx.expireSweep(), [], 'once');
++ });
++
await check('another person cannot see, change or close someone else\'s share', async () => {
assert.strictEqual((await t.get(`/api/v1/shares/${made.share.id}`, { as: bob })).status, 404);
assert.strictEqual((await t.get(`/api/v1/shares/${made.share.id}`, { as: bob, method: 'DELETE' })).status, 404);