openvibers/openvibe.tours Commits

Trips: the one Share sheet, Only me by default (#78)

OP OpenVibers committed 6 days ago · d1def881fe · 20 files, +783 −88

* Trips: the one Share sheet, Only me by default

A trip is its owner's until they say otherwise. Everyone, Anyone with the
link, Specific people and Only me through openvibe-sdk/share; Specific people
can be Can view or Can edit, so the people going together plan the trip
together. Every read (the page, the API, the trip's JSON-LD page) asks
canView, and every edit asks canEdit; a trip is never in the sitemap or
llms.txt, whatever its audience. The old private/shared words map 1:1 and the
visibility column follows the audience, so an old reader fails closed.
/trips sets what a new trip starts as, with apply-to-all. Account deletion
takes the trip's share row and the person's memberships with it.

* Trips: the gate from markup, and only a signed-out copy is public

The specific-people gate is markup now: [data-ov-share-gate] with kind, id,
signed-in-as and sign-in-url, drawn by openvibe-shared 4.123.0's ov-share.js.
No inline script, so a strict CSP needs nothing and every value is escaped as
an attribute. openvibe-shared v4.123.0.

The cache leak: a public or link trip's page answered public, max-age=60 for
every reader. A signed-in reader's copy now stays private, no-store, and the
signed-out copy carries no Share button at all (no data-kind/data-id for a
shared cache to store).

A headless-Chrome test draws the gate both ways: Sign in signed out, Ask for
access for a signed-in stranger, with no console error or CSP refusal.

Changes

ddiff --git a/README.md b/README.md
iindex 145df27..2a0f443 100644
---- a/README.md
++++ b/README.md
@@@ -99,7 +99,7 @@ needs a service token.
| `GET /api/v1/openapi.json` | the OpenAPI 3.1 document |
| `GET /api/v1/trips` | the signed-in person's trips |
| `POST /api/v1/trips` | create `{ title, city_slug, starts_on, ends_on, notes?, visibility? }` |
--| `GET /api/v1/trips/:slug` | own trip, or a shared one read-only |
++| `GET /api/v1/trips/:slug` | own trip, or one shared with you |
| `PATCH /api/v1/trips/:slug` | edit title, city, dates, notes or visibility |
| `DELETE /api/v1/trips/:slug` | delete the trip (204) |
| `POST /api/v1/trips/:slug/places` | save a place `{ place_id, note? }` |
@@@ -177,6 +177,6 @@ AGPL-3.0-only. See [LICENSE](LICENSE).
<!-- versions:start -->
- openvibe-contracts: v0.289.1
- openvibe-sdk: v0.91.0
--- openvibe-shared: v4.122.0
++- openvibe-shared: v4.123.0
- openvibe-publishing: v1.4.1
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 03945c4..5cfee1c 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -10,12 +10,12 @@
"domain": "openvibe.tours",
"contracts": "openvibe-contracts v0.289.1",
"sdk": "openvibe-sdk v0.91.0",
-- "shared": "openvibe-shared v4.122.0",
++ "shared": "openvibe-shared v4.123.0",
"packages": [
"openvibe-contracts v0.289.1",
"openvibe-publishing v1.4.1",
"openvibe-sdk v0.91.0",
-- "openvibe-shared v4.122.0"
++ "openvibe-shared v4.123.0"
],
"summary": "OpenVibe.Tours is the travel room. The top 100 cities come from Wikidata coordinates and Wikivoyage pages; places are OpenStreetMap tourism=* through one bounded Overpass query per city; self-guided routes are generated from Wikivoyage See lists, marked as generated, and cite every stop. IRL streams, VODs and clips come from OpenVibe.Live's public list, stays from OpenVibe.Rent's public listings. There is no flight API key in v1: the form links the official airline-neutral search with the city pair filled in and no affiliate. A signed-in person can make a trip with a city, dates, saved places, one route and stays, and share it read-only. No route needs a service token; the source jobs are off under TOURS_DRILL.",
"works": [
ddiff --git a/migrations/0005_shares.sql b/migrations/0005_shares.sql
nnew file mode 100644
iindex 0000000..b7841d3
---- /dev/null
++++ b/migrations/0005_shares.sql
@@@ -0,0 +1,72 @@
++-- phase: expand
++-- The one sharing system (openvibe-sdk/share) on a trip: its audience, the people it is shared with, the log and the
++-- delivery queue the SDK writes. Kept verbatim from the SDK's own schema (node_modules/openvibe-sdk/src/share/
++-- schema.js at the pinned version), so its store finds the tables it expects and the test database has them.
++-- A trip's own row is tours_trips; there is one share row per trip, kind 'trip', id = the trip's slug.
++
++
++CREATE TABLE IF NOT EXISTS shares (
++ service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ owner_subject jsonb NOT NULL, audience text NOT NULL CHECK (audience IN ('public','link','people','private')),
++ revision integer NOT NULL DEFAULT 1,
++ lock text NOT NULL DEFAULT 'none',
++ reseal_nonce_base text,
++ PRIMARY KEY (service, kind, resource_id)
++);
++CREATE TABLE IF NOT EXISTS share_members (
++ service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ subject jsonb NOT NULL, role text NOT NULL CHECK (role IN ('viewer','editor')),
++ expires_at timestamptz, added_by jsonb NOT NULL, added_at timestamptz NOT NULL,
++ key_slot jsonb,
++ PRIMARY KEY (service, kind, resource_id, subject),
++ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)
++);
++CREATE TABLE IF NOT EXISTS share_transfers (
++ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ from_subject jsonb NOT NULL, to_subject jsonb NOT NULL, keep_access boolean NOT NULL DEFAULT false,
++ requested_at timestamptz NOT NULL, expires_at timestamptz NOT NULL,
++ state text NOT NULL CHECK (state IN ('pending','accepted','declined','cancelled','expired')),
++ key_slot jsonb,
++ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)
++);
++CREATE UNIQUE INDEX IF NOT EXISTS share_transfer_pending ON share_transfers(service,kind,resource_id) WHERE state='pending';
++CREATE TABLE IF NOT EXISTS share_access_requests (
++ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ subject jsonb NOT NULL, requested_at timestamptz NOT NULL,
++ state text NOT NULL CHECK (state IN ('pending','granted','dismissed')),
++ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)
++);
++CREATE UNIQUE INDEX IF NOT EXISTS share_request_pending ON share_access_requests(service,kind,resource_id,subject) WHERE state='pending';
++CREATE TABLE IF NOT EXISTS share_log (
++ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ actor jsonb NOT NULL, action text NOT NULL, details jsonb NOT NULL, occurred_at timestamptz NOT NULL,
++ sequence bigint GENERATED ALWAYS AS IDENTITY UNIQUE
++);
++CREATE OR REPLACE FUNCTION share_log_append_only() RETURNS trigger LANGUAGE plpgsql AS $$
++BEGIN RAISE EXCEPTION 'share_log is append-only'; END $$;
++DO $$ BEGIN
++ IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname='share_log_no_changes' AND tgrelid='share_log'::regclass) THEN
++ CREATE TRIGGER share_log_no_changes BEFORE UPDATE OR DELETE OR TRUNCATE ON share_log
++ FOR EACH STATEMENT EXECUTE FUNCTION share_log_append_only();
++ END IF;
++END $$;
++CREATE TABLE IF NOT EXISTS share_deliveries (
++ id uuid PRIMARY KEY, channel text NOT NULL CHECK (channel IN ('event','notify')),
++ body jsonb NOT NULL, created_at timestamptz NOT NULL, delivered_at timestamptz,
++ sequence bigint GENERATED ALWAYS AS IDENTITY UNIQUE
++);
++CREATE TABLE IF NOT EXISTS share_name_limits (
++ caller jsonb PRIMARY KEY, window_start bigint NOT NULL, attempts integer NOT NULL
++);
++CREATE INDEX IF NOT EXISTS share_members_subject ON share_members(subject);
++CREATE INDEX IF NOT EXISTS share_transfers_recipient ON share_transfers(to_subject) WHERE state='pending';
++CREATE INDEX IF NOT EXISTS share_deliveries_pending ON share_deliveries(created_at) WHERE delivered_at IS NULL;
++-- Expand-only for an existing deployment: the lock kind on the share, the member's key slot and the
++-- slot a transfer was offered with. Repeating this migration is a no-op.
++ALTER TABLE shares ADD COLUMN IF NOT EXISTS lock text NOT NULL DEFAULT 'none';
++DO $$ BEGIN
++ ALTER TABLE shares ADD CONSTRAINT shares_lock_allowed CHECK (lock IN ('none','people','password','link'));
++EXCEPTION WHEN duplicate_object THEN NULL; END $$;
++ALTER TABLE share_members ADD COLUMN IF NOT EXISTS key_slot jsonb;
++ALTER TABLE share_transfers ADD COLUMN IF NOT EXISTS key_slot jsonb;
++ALTER TABLE shares ADD COLUMN IF NOT EXISTS reseal_nonce_base text;
ddiff --git a/migrations/0006_share_defaults.sql b/migrations/0006_share_defaults.sql
nnew file mode 100644
iindex 0000000..e174af2
---- /dev/null
++++ b/migrations/0006_share_defaults.sql
@@@ -0,0 +1,20 @@
++-- phase: expand
++-- Visibility step 3 (design 2026-11-06): the person's default audience for new trips, and one share row for every
++-- trip that already exists. The old two words map 1:1: private -> Only me, shared -> Anyone with the link (a shared
++-- trip was readable by whoever held its slug, and only its owner could edit it).
++-- New trips are registered as they are made (server/sharing.js); a trip without a row is still its owner's, because
++-- every read asks canView and a missing row falls back to the old column. Repeating this file is a no-op. Expand-only.
++
++CREATE TABLE IF NOT EXISTS tours_share_defaults (
++ subject text COLLATE "C" PRIMARY KEY,
++ audience text COLLATE "C" NOT NULL CHECK (audience IN ('public', 'link', 'people', 'private')),
++ updated_at timestamptz NOT NULL DEFAULT now()
++);
++
++INSERT INTO shares (service, kind, resource_id, owner_subject, audience)
++SELECT
++ 'tours', 'trip', t.slug,
++ jsonb_build_object('type', 'user', 'id', t.owner_subject),
++ CASE t.visibility WHEN 'shared' THEN 'link' ELSE 'private' END
++FROM tours_trips t
++ON CONFLICT (service, kind, resource_id) DO NOTHING;
ddiff --git a/package-lock.json b/package-lock.json
iindex 9b950e8..23ff843 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -18,7 +18,7 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.122.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.123.0",
"pg": "^8.23.0"
},
"devDependencies": {
@@@ -1217,9 +1217,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "4.122.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.122.0",
-- "integrity": "sha512-/BneZ1PYDWUM/DBEb8EodvTbsK9a+5EGY+bM8zJi1vgL+D8ohau7EixB4uNcqD0RLXJMu4IUYtSdwb/QPPyPMA==",
++ "version": "4.123.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.123.0",
++ "integrity": "sha512-9XLlRm24VEf/amaSHm+3OEKYKKrDj9ic4Fii9qsKwtUV9HilQ8cw6H1TNi27m/VIdJcvFkZASpkmxnIhQW+rkw==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/package.json b/package.json
iindex 66a2cbb..6481ef2 100644
---- a/package.json
++++ b/package.json
@@@ -26,7 +26,7 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.1",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.122.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.123.0",
"pg": "^8.23.0"
},
"engines": {
ddiff --git a/public/css/tours.css b/public/css/tours.css
iindex da9b929..f70c409 100644
---- a/public/css/tours.css
++++ b/public/css/tours.css
@@@ -42,6 +42,10 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex
.tr-card, .tr-form.tr-card { border: 1px solid var(--border, rgba(255,255,255,.12)); border-radius: 16px; background: var(--bg-secondary, #101828); padding: 18px; }
.tr-form { display: grid; gap: 12px; margin: 0; }
.tr-form-inline { display: flex; flex-wrap: wrap; align-items: end; gap: 10px; margin-top: 10px; }
++/* The person's default for new trips (visibility step 3): a select and its "apply to all" box on one line. */
++.tr-check { display: inline-flex; align-items: center; gap: 8px; font-size: .88rem; color: var(--text-secondary, #96a7c2); }
++.tr-check input { width: auto; }
++.tr-visibility { font-weight: 700; color: var(--text-secondary, #96a7c2); }
.tr-field { display: grid; gap: 5px; font-size: .9rem; min-width: 0; }
.tr-field > span { color: var(--text-secondary, #96a7c2); font-size: .8rem; font-weight: 700; }
.tr-field input, .tr-field select, .tr-field textarea { width: 100%; padding: 9px 11px; border-radius: 10px; border: 1px solid var(--border, rgba(255,255,255,.16)); background: var(--bg-primary, #0a0f1c); color: inherit; font: inherit; }
ddiff --git a/server/app.js b/server/app.js
iindex 40c948c..135c512 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -28,6 +28,7 @@ const { createSso } = require('./auth/sso');
const { createPrincipal } = require('./http/principal');
const { createApi } = require('./http/api');
const { createPageRoutes } = require('./http/pages');
++const { createSharing } = require('./sharing');
const { createServiceReadiness } = require('./observability');
const { assetVersion, send } = require('./render/layout');
const { raw } = require('./render/html');
@@@ -52,7 +53,9 @@ async function createApp(opts = {}) {
const keys = createKeyStore({ config, fetchImpl, log });
const sso = createSso({ config, keys, fetchImpl, now: s.now, log });
const principal = createPrincipal({ config, keys });
-- const store = dataStore(s, { config });
++ // The one sharing system (design 2026-11-06): a trip is its owner's until the Share sheet says otherwise.
++ const sharing = opts.sharing || createSharing({ db: s.db, config, fetchImpl, log });
++ const store = dataStore(s, { config, sharing });
const jobs = createJobs({ config, s, store, fetchImpl, log });
// The places, cities and walks in OpenVibe.Search (started by server/index.js, never under TOURS_DRILL).
const searchIndex = createSearchIndex({ config, s, log });
@@@ -62,7 +65,7 @@ async function createApp(opts = {}) {
? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Tours cannot answer account events'); });
-- const ctx = { config, s, keys, sso, principal, store, jobs, searchIndex, accountData, accountSend, log };
++ const ctx = { config, s, keys, sso, principal, store, jobs, searchIndex, accountData, accountSend, sharing, log };
const app = express();
// /.well-known/agent.json (openvibe-shared/agent-layer, SEO-2): what this site serves, for agents.
@@@ -140,6 +143,26 @@ async function createApp(opts = {}) {
// ── API ─────────────────────────────────────────────────
app.use('/api/v1', rateLimit({ windowMs: 60_000, limit: Number(process.env.TOURS_API_RATE_LIMIT_PER_MIN) || 240, standardHeaders: true, legacyHeaders: false, handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }) }));
++ // ── /api/v1/trip/:slug/share — the one Share sheet's API (openvibe-sdk/share) ──
++ // GET/PUT .../share, .../share/requests, .../share/transfer[/accept|decline|cancel], GET /api/v1/share/people
++ // and /api/v1/shared-with-me. Access is still decided by canView on every read path; the router answers only
++ // its own paths.
++ {
++ const shareApi = express.Router();
++ shareApi.use(sharing.shares.router({
++ service: 'tours',
++ authenticate: (req) => sharing.shareViewer(req.viewer),
++ describe: sharing.describe,
++ }));
++ const SHARE_PATH = /^\/api\/v1\/(?:shared-with-me$|share\/people$|[^/]+\/[^/]+\/share(?:\/|$))/;
++ app.use((req, res, next) => (SHARE_PATH.test(req.path) ? shareApi(req, res, next) : next()));
++ }
++ // Expire members and transfers and send queued share events/notifications. Unref'd: it never holds a test open.
++ if (opts.startSharingWorker !== false) {
++ const timer = setInterval(() => { sharing.work().catch(() => { /* logged inside */ }); }, 15_000);
++ if (timer.unref) timer.unref();
++ app.locals.sharingTimer = timer;
++ }
app.use('/api/v1', createApi(ctx));
// GET /openapi.json (SEO-3): the API's OpenAPI 3.1 document, the same one /api/v1/openapi.json serves.
app.get('/openapi.json', (_req, res) => res.set('Cache-Control', 'public, max-age=3600').set('Access-Control-Allow-Origin', '*').json(require('./openapi').document));
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 8cd281e..ce042ae 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -149,22 +149,22 @@ function createApi(ctx) {
r.get('/trips/:slug', async (req, res) => {
const subject = req.principal.kind === 'user' ? req.principal.subject : null;
-- const out = await store.tripBySlug(String(req.params.slug || ''), { subject });
++ const out = await store.tripBySlug(String(req.params.slug || ''), { subject, viewer: req.principal.kind === 'user' ? { subject } : null });
if (!out) return problem(res, 404, 'tours.trip_not_found', 'No such trip, or it is private.');
-- res.set('Cache-Control', out.mine ? 'private, no-store' : 'public, max-age=60');
-- return res.json({ trip: out.trip, mine: out.mine, places: out.places, route: out.route, stays: out.stays });
++ res.set('Cache-Control', out.mine || out.audience === 'people' || out.audience === 'private' ? 'private, no-store' : 'public, max-age=60');
++ return res.json({ trip: out.trip, mine: out.mine, canEdit: out.canEdit, audience: out.audience, places: out.places, route: out.route, stays: out.stays });
});
r.patch('/trips/:slug', async (req, res) => {
const p = person(req, res); if (!p) return undefined;
-- const out = await store.updateTrip({ subject: p.subject, slug: String(req.params.slug || ''), input: req.body && typeof req.body === 'object' ? req.body : {} });
++ const out = await store.updateTrip({ subject: p.subject, viewer: p, slug: String(req.params.slug || ''), input: req.body && typeof req.body === 'object' ? req.body : {} });
if (!out.ok) return refuse(res, out);
return res.json(out.trip);
});
r.delete('/trips/:slug', async (req, res) => {
const p = person(req, res); if (!p) return undefined;
-- const out = await store.deleteTrip({ subject: p.subject, slug: String(req.params.slug || '') });
++ const out = await store.deleteTrip({ subject: p.subject, viewer: p, slug: String(req.params.slug || '') });
if (!out.ok) return refuse(res, out);
return res.status(204).end();
});
@@@ -173,14 +173,14 @@ function createApi(ctx) {
const p = person(req, res); if (!p) return undefined;
const b = req.body && typeof req.body === 'object' ? req.body : {};
if (!b.place_id) return invalid(res, 'place_id is required', 'place_id');
-- const out = await store.addTripPlace({ subject: p.subject, slug: String(req.params.slug || ''), placeId: b.place_id, note: b.note });
++ const out = await store.addTripPlace({ subject: p.subject, viewer: p, slug: String(req.params.slug || ''), placeId: b.place_id, note: b.note });
if (!out.ok) return refuse(res, out);
return res.status(201).json(out.place);
});
r.delete('/trips/:slug/places/:placeId', async (req, res) => {
const p = person(req, res); if (!p) return undefined;
-- const out = await store.removeTripPlace({ subject: p.subject, slug: String(req.params.slug || ''), placeId: String(req.params.placeId || '') });
++ const out = await store.removeTripPlace({ subject: p.subject, viewer: p, slug: String(req.params.slug || ''), placeId: String(req.params.placeId || '') });
if (!out.ok) return refuse(res, out);
return res.status(204).end();
});
@@@ -188,7 +188,7 @@ function createApi(ctx) {
r.post('/trips/:slug/route', async (req, res) => {
const p = person(req, res); if (!p) return undefined;
const b = req.body && typeof req.body === 'object' ? req.body : {};
-- const out = await store.setTripRoute({ subject: p.subject, slug: String(req.params.slug || ''), routeId: b.route_id || null });
++ const out = await store.setTripRoute({ subject: p.subject, viewer: p, slug: String(req.params.slug || ''), routeId: b.route_id || null });
if (!out.ok) return refuse(res, out);
return res.json({ ok: true });
});
@@@ -197,14 +197,14 @@ function createApi(ctx) {
const p = person(req, res); if (!p) return undefined;
const b = req.body && typeof req.body === 'object' ? req.body : {};
if (!b.stay_id) return invalid(res, 'stay_id is required', 'stay_id');
-- const out = await store.addTripStay({ subject: p.subject, slug: String(req.params.slug || ''), stayId: b.stay_id });
++ const out = await store.addTripStay({ subject: p.subject, viewer: p, slug: String(req.params.slug || ''), stayId: b.stay_id });
if (!out.ok) return refuse(res, out);
return res.status(201).json(out.stay);
});
r.delete('/trips/:slug/stays/:stayId', async (req, res) => {
const p = person(req, res); if (!p) return undefined;
-- const out = await store.removeTripStay({ subject: p.subject, slug: String(req.params.slug || ''), stayId: String(req.params.stayId || '') });
++ const out = await store.removeTripStay({ subject: p.subject, viewer: p, slug: String(req.params.slug || ''), stayId: String(req.params.stayId || '') });
if (!out.ok) return refuse(res, out);
return res.status(204).end();
});
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex 95a3d11..cfb3957 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -2,7 +2,7 @@
/**
* Crawl artifacts for openvibe.tours: robots.txt, sitemap.xml, llms.txt and llms-full.txt. Public cities, places and
-- * routes are for search engines and language models; sign-in, the API, a trip (private or shared) and the internal
++ * routes are for search engines and language models; sign-in, the API, a trip (Always private in search, whatever its audience) and the internal
* routes are not.
*/
const fs = require('fs');
@@@ -12,7 +12,7 @@ const cache = require('openvibe-shared/cache-policy');
const { asyncRouter } = require('./router');
const SITE_NAME = 'OpenVibe.Tours';
--const DESCRIPTION = 'OpenVibe.Tours is go anywhere: fly, stay, walk, watch. Cities, places and self-guided routes from OpenStreetMap, Wikivoyage and Wikidata; IRL tours live on OpenVibe.Live; stays from OpenVibe.Rent; flights through the official airline-neutral search; and trips a person can share read-only.';
++const DESCRIPTION = 'OpenVibe.Tours is go anywhere: fly, stay, walk, watch. Cities, places and self-guided routes from OpenStreetMap, Wikivoyage and Wikidata; IRL tours live on OpenVibe.Live; stays from OpenVibe.Rent; flights through the official airline-neutral search; and trips a person shares with the people going with them.';
const DISALLOW = ['/auth/', '/api/', '/internal', '/metrics', '/trips'];
const dayOf = (ts) => { const m = String(ts == null ? '' : ts).match(/^(\d{4}-\d{2}-\d{2})/); return m ? m[1] : null; };
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 65023b3..364d205 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -16,6 +16,9 @@ const views = require('../views');
const { createFlights } = require('../flights');
const PUBLIC_CACHE = 'public, max-age=60';
++const ovServe = require('openvibe-shared/serve');
++/** The one Share sheet, loaded only on a page that carries a Share button or a gate. */
++const SHARE_HEAD = `<link rel="stylesheet" href="${ovServe.url('ov-share.css')}"><script src="${ovServe.url('ov-share.js')}" defer></script>`;
function createPageRoutes(ctx) {
const { config, store } = ctx;
@@@ -210,8 +213,19 @@ ${routes.length ? `<ol class="tr-route-list tr-route-list-big">${routes.map((rt)
// ── Trips ────────────────────────────────────────────────
r.get('/trips', async (req, res) => {
if (!signedIn(req)) return page(req, res, { index: false, cache: null, title: 'Trips', body: raw(views.signIn({ title: 'Your trips', text: 'A trip belongs to a signed-in person. Sign in to see yours or plan one.', next: '/trips' })) }, 401);
-- const trips = await store.tripsForSubject(req.viewer.subject);
-- return page(req, res, { index: false, cache: null, title: 'Your trips', body: raw(views.tripsPage({ trips })) });
++ const [trips, defaultAudience] = await Promise.all([
++ store.tripsForSubject(req.viewer.subject),
++ ctx.sharing ? ctx.sharing.defaultOf(req.viewer.subject).catch(() => 'private') : 'private',
++ ]);
++ return page(req, res, { index: false, cache: null, title: 'Your trips', body: raw(views.tripsPage({ trips, defaultAudience })) });
++ });
++
++ r.post('/trips/defaults', guard, async (req, res) => {
++ if (!ctx.sharing) return res.redirect(303, '/trips');
++ const audience = ['private', 'people', 'link', 'public'].includes(req.body.audience) ? req.body.audience : 'private';
++ await ctx.sharing.setDefault(req.viewer.subject, audience, { applyAll: String(req.body.apply_all || '') === '1' })
++ .catch((err) => ctx.log.warn('[Tours] the trip default was not saved:', err.message));
++ return res.redirect(303, '/trips');
});
r.get('/trips/new', async (req, res) => {
@@@ -234,24 +248,37 @@ ${routes.length ? `<ol class="tr-route-list tr-route-list-big">${routes.map((rt)
r.get('/trips/:slug', async (req, res) => {
const subject = signedIn(req) ? req.viewer.subject : null;
-- const out = await store.tripBySlug(String(req.params.slug || ''), { subject });
-- if (!out) return page(req, res, { index: false, cache: null, title: 'Not found', body: raw(views.notFound()) }, 404);
-- const cities = out.mine ? await store.listCities({ limit: 100 }) : [];
++ const out = await store.tripBySlug(String(req.params.slug || ''), { subject, viewer: req.viewer });
++ if (!out) {
++ // A trip shared with specific people answers the gate: "Ask for access" when signed in, "Sign in"
++ // otherwise, and never its title, places or notes.
++ const audience = ctx.sharing ? await ctx.sharing.audienceOf(String(req.params.slug || '')).catch(() => null) : null;
++ if (audience === 'people') {
++ return page(req, res, {
++ index: false, cache: null, title: 'A trip shared with specific people', head: SHARE_HEAD,
++ body: raw(views.tripGate({ slug: String(req.params.slug || ''), signedInAs: signedIn(req) ? (req.viewer.username || '') : '',
++ signInUrl: `/auth/login?next=${encodeURIComponent(req.originalUrl)}` })),
++ }, 404);
++ }
++ return page(req, res, { index: false, cache: null, title: 'Not found', body: raw(views.notFound()) }, 404);
++ }
++ const cities = out.canEdit ? await store.listCities({ limit: 100 }) : [];
++ const reader = signedIn(req);
return page(req, res, {
-- index: false, cache: out.mine ? null : 'public, max-age=60', title: out.trip.title,
-- jsonLd: [tripJsonLd(out.trip, out.route)], body: raw(views.tripPage({ ...out, cities })),
++ // Only a signed-out reader's copy of a link/public trip may sit in a shared cache. A signed-in reader's
++ // copy carries the Share sheet's kind/id (and, for its owner, the people it is shared with), so it is
++ // private, no-store — send() also makes any signed-in page private.
++ index: false,
++ cache: !reader && (out.audience === 'link' || out.audience === 'public') ? 'public, max-age=60' : null,
++ head: SHARE_HEAD,
++ title: out.trip.title, jsonLd: [tripJsonLd(out.trip, out.route)],
++ body: raw(views.tripPage({ ...out, cities, canShare: reader })),
});
});
-- r.post('/trips/:slug/share', guard, async (req, res) => {
-- const out = await store.updateTrip({ subject: req.viewer.subject, slug: String(req.params.slug || ''), input: { visibility: req.body.shared === '1' ? 'shared' : 'private' } });
-- if (!out.ok) return page(req, res, { index: false, cache: null, title: 'Not found', body: raw(views.notFound()) }, out.status || 404);
-- return res.redirect(303, `/trips/${out.trip.slug}`);
-- });
--
r.post('/trips/:slug/edit', guard, async (req, res) => {
const out = await store.updateTrip({
-- subject: req.viewer.subject, slug: String(req.params.slug || ''),
++ subject: req.viewer.subject, viewer: req.viewer, slug: String(req.params.slug || ''),
input: { title: req.body.title, city_slug: req.body.city, starts_on: req.body.starts_on, ends_on: req.body.ends_on, notes: req.body.notes },
});
if (!out.ok) return page(req, res, { index: false, cache: null, title: 'Not found', body: raw(views.notFound()) }, out.status || 404);
@@@ -260,37 +287,37 @@ ${routes.length ? `<ol class="tr-route-list tr-route-list-big">${routes.map((rt)
r.post('/trips/:slug/places', guard, async (req, res) => {
const slug = /^trip-[a-z0-9]{16,32}$/.test(String(req.body.trip || '')) ? String(req.body.trip) : String(req.params.slug || '');
-- const out = await store.addTripPlace({ subject: req.viewer.subject, slug, placeId: req.body.place_id, note: req.body.note });
++ const out = await store.addTripPlace({ subject: req.viewer.subject, viewer: req.viewer, slug, placeId: req.body.place_id, note: req.body.note });
if (!out.ok) return page(req, res, { index: false, cache: null, title: 'Not found', body: raw(views.notFound()) }, out.status || 404);
return res.redirect(303, `/trips/${slug}`);
});
r.post('/trips/:slug/places/:placeId/remove', guard, async (req, res) => {
-- await store.removeTripPlace({ subject: req.viewer.subject, slug: String(req.params.slug || ''), placeId: String(req.params.placeId || '') });
++ await store.removeTripPlace({ subject: req.viewer.subject, viewer: req.viewer, slug: String(req.params.slug || ''), placeId: String(req.params.placeId || '') });
return res.redirect(303, `/trips/${String(req.params.slug || '')}`);
});
r.post('/trips/:slug/route', guard, async (req, res) => {
const slug = /^trip-[a-z0-9]{16,32}$/.test(String(req.body.trip || '')) ? String(req.body.trip) : String(req.params.slug || '');
-- const out = await store.setTripRoute({ subject: req.viewer.subject, slug, routeId: req.body.route_id || null });
++ const out = await store.setTripRoute({ subject: req.viewer.subject, viewer: req.viewer, slug, routeId: req.body.route_id || null });
if (!out.ok) return page(req, res, { index: false, cache: null, title: 'Not found', body: raw(views.notFound()) }, out.status || 404);
return res.redirect(303, `/trips/${slug}`);
});
r.post('/trips/:slug/stays', guard, async (req, res) => {
const slug = /^trip-[a-z0-9]{16,32}$/.test(String(req.body.trip || '')) ? String(req.body.trip) : String(req.params.slug || '');
-- const out = await store.addTripStay({ subject: req.viewer.subject, slug, stayId: req.body.stay_id });
++ const out = await store.addTripStay({ subject: req.viewer.subject, viewer: req.viewer, slug, stayId: req.body.stay_id });
if (!out.ok) return page(req, res, { index: false, cache: null, title: 'Not found', body: raw(views.notFound()) }, out.status || 404);
return res.redirect(303, `/trips/${slug}`);
});
r.post('/trips/:slug/stays/:stayId/remove', guard, async (req, res) => {
-- await store.removeTripStay({ subject: req.viewer.subject, slug: String(req.params.slug || ''), stayId: String(req.params.stayId || '') });
++ await store.removeTripStay({ subject: req.viewer.subject, viewer: req.viewer, slug: String(req.params.slug || ''), stayId: String(req.params.stayId || '') });
return res.redirect(303, `/trips/${String(req.params.slug || '')}`);
});
r.post('/trips/:slug/delete', guard, async (req, res) => {
-- await store.deleteTrip({ subject: req.viewer.subject, slug: String(req.params.slug || '') });
++ await store.deleteTrip({ subject: req.viewer.subject, viewer: req.viewer, slug: String(req.params.slug || '') });
return res.redirect(303, '/trips');
});
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex 2a68828..03d11c6 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -18,6 +18,29 @@ function create({ db, log = console } = {}) {
db,
service: 'tours',
tables: TABLES,
++ /**
++ * A share row names its owner and its members by canonical subject, which a table map cannot cover: the
++ * trip's own rows go, and so must the share row and everyone it named, or a deleted account would linger in
++ * somebody else's trip's member list. Children first: share_members, transfers and requests point at shares.
++ */
++ extraErase: async (t, subjects) => {
++ for (const subject of subjects) {
++ const id = String(subject).replace(/^user:/, '');
++ await t.exec("DELETE FROM share_members WHERE service='tours' AND subject->>'id' = $1", [id]);
++ await t.exec("DELETE FROM share_transfers WHERE service='tours' AND (from_subject->>'id' = $1 OR to_subject->>'id' = $1)", [id]);
++ await t.exec("DELETE FROM share_access_requests WHERE service='tours' AND subject->>'id' = $1", [id]);
++ }
++ for (const subject of subjects) {
++ const id = String(subject).replace(/^user:/, '');
++ const owned = await t.many("SELECT kind, resource_id FROM shares WHERE service='tours' AND owner_subject->>'id' = $1", [id]);
++ for (const row of owned) {
++ await t.exec("DELETE FROM share_members WHERE service='tours' AND kind=$1 AND resource_id=$2", [row.kind, row.resource_id]);
++ await t.exec("DELETE FROM share_transfers WHERE service='tours' AND kind=$1 AND resource_id=$2", [row.kind, row.resource_id]);
++ await t.exec("DELETE FROM share_access_requests WHERE service='tours' AND kind=$1 AND resource_id=$2", [row.kind, row.resource_id]);
++ await t.exec("DELETE FROM shares WHERE service='tours' AND kind=$1 AND resource_id=$2", [row.kind, row.resource_id]);
++ }
++ }
++ },
note: 'Your trips and everything saved on them are erased. Public cities, places and routes are open data and stay.',
log,
});
ddiff --git a/server/openapi.js b/server/openapi.js
iindex 9700003..c07d3ea 100644
---- a/server/openapi.js
++++ b/server/openapi.js
@@@ -76,7 +76,7 @@ const trip = {
properties: {
id: { type: 'string' }, slug: { type: 'string' }, title: { type: 'string' }, city_id: { type: ['string', 'null'] },
city_slug: { type: ['string', 'null'] }, city_name: { type: ['string', 'null'] }, starts_on: { type: 'string', format: 'date' },
-- ends_on: { type: 'string', format: 'date' }, notes: { type: ['string', 'null'] }, visibility: { type: 'string', enum: ['private', 'shared'] },
++ ends_on: { type: 'string', format: 'date' }, notes: { type: ['string', 'null'] }, visibility: { type: 'string', enum: ['private', 'shared'], deprecated: true, description: 'The old two words; the Share sheet names the audience (Everyone, Anyone with the link, Specific people, Only me).' },
revision: { type: 'integer' }, created_at: { type: 'string', format: 'date-time' }, updated_at: { type: 'string', format: 'date-time' },
},
};
@@@ -133,7 +133,7 @@ const document = {
},
'/api/v1/trips/{slug}': {
get: { summary: 'Your trip, or a shared one read-only', tags: ['Trips'], parameters: [pathParam('slug', 'trip-0123456789abcdef01234567')], responses: { ...json({ type: 'object', properties: { trip: { $ref: '#/components/schemas/Trip' }, mine: { type: 'boolean' }, places: { type: 'array', items: { $ref: '#/components/schemas/Place' } }, route: { oneOf: [{ $ref: '#/components/schemas/Route' }, { type: 'null' }] }, stays: { type: 'array', items: { $ref: '#/components/schemas/Stay' } } } }), '404': { description: 'No such trip, or it is private' } } },
-- patch: { summary: 'Edit your trip', tags: ['Trips'], parameters: [pathParam('slug', 'trip-0123456789abcdef01234567')], requestBody: { content: { 'application/json': { schema: { type: 'object', properties: { title: { type: 'string' }, city_slug: { type: 'string' }, starts_on: { type: 'string', format: 'date' }, ends_on: { type: 'string', format: 'date' }, notes: { type: 'string' }, visibility: { type: 'string', enum: ['private', 'shared'] } } } } } }, responses: { ...json({ $ref: '#/components/schemas/Trip' }), ...unauth } },
++ patch: { summary: 'Edit your trip', tags: ['Trips'], parameters: [pathParam('slug', 'trip-0123456789abcdef01234567')], requestBody: { content: { 'application/json': { schema: { type: 'object', properties: { title: { type: 'string' }, city_slug: { type: 'string' }, starts_on: { type: 'string', format: 'date' }, ends_on: { type: 'string', format: 'date' }, notes: { type: 'string' }, visibility: { type: 'string', enum: ['private', 'shared'], deprecated: true, description: 'Only its owner changes who can open a trip; the Share sheet is that way.' } } } } } }, responses: { ...json({ $ref: '#/components/schemas/Trip' }), ...unauth } },
delete: { summary: 'Delete your trip', tags: ['Trips'], parameters: [pathParam('slug', 'trip-0123456789abcdef01234567')], responses: { '204': { description: 'Deleted' }, ...unauth } },
},
'/api/v1/trips/{slug}/places': {
ddiff --git a/server/sharing.js b/server/sharing.js
nnew file mode 100644
iindex 0000000..1dc9c5d
---- /dev/null
++++ b/server/sharing.js
@@@ -0,0 +1,192 @@
++'use strict';
++
++/**
++ * Sharing on OpenVibe.Tours (design 2026-11-06, visibility step 3), through openvibe-sdk/share.
++ *
++ * A trip is Only me until its owner says otherwise. The Share sheet changes just that trip; the person's default for
++ * new trips lives on /trips ("New trips: Only me"). The trip's page and every API read ask the one check, canView;
++ * editing (places, route, stays, title) asks canEdit, so a trip shared with Specific people Can edit is one the
++ * household or the people going together can actually plan.
++ *
++ * The SDK owns the model and the rules (the shares row, members, requests, transfers, the log and the delivery
++ * queue). This file is the wiring: Network's name lookups and notifications, Events for share.* envelopes, the old
++ * visibility column kept in step (so an old reader fails closed), and the person's default.
++ */
++const { createShares } = require('openvibe-sdk/share');
++const { serviceAuth } = require('openvibe-contracts');
++
++const SERVICE = 'tours';
++const KIND = 'trip';
++const AUDIENCES = ['public', 'link', 'people', 'private'];
++const DEFAULT_AUDIENCE = 'private';
++/** The old two words, kept for readers and fixtures that predate the share row. */
++const VISIBILITY = { public: 'shared', link: 'shared', people: 'private', private: 'private' };
++
++const refOf = (slug) => ({ service: SERVICE, kind: KIND, id: String(slug) });
++
++/** Tours' viewer as the SDK wants it: a subject object. Tours has no staff bit on a page viewer. */
++function shareViewer(v) {
++ if (!v || !v.subject) return null;
++ if (String(v.subject).startsWith('usr_')) return { subject: { type: 'user', id: v.subject }, staff: !!v.staff };
++ return null;
++}
++
++function createSharing({ db, config, fetchImpl = globalThis.fetch, log = console } = {}) {
++ const base = String(config.networkInternalUrl || '').replace(/\/+$/, '');
++ const tokens = config.oauth && config.oauth.clientSecret ? serviceAuth.createTokenClient({
++ tokenUrl: base + '/oauth/token',
++ clientId: config.oauth.clientId,
++ clientSecret: config.oauth.clientSecret,
++ audience: 'openvibe.network',
++ scope: 'identity.subject.resolve network.notifications.push',
++ fetch: fetchImpl,
++ }) : null;
++
++ async function networkJson({ path, method = 'GET', json, query } = {}) {
++ if (!tokens) throw new Error('Network is not configured for this service');
++ const url = new URL(base + path);
++ for (const [k, v] of Object.entries(query || {})) if (v != null) url.searchParams.set(k, String(v));
++ const headers = { Accept: 'application/json', ...(await tokens.authHeaders()) };
++ if (json !== undefined) headers['Content-Type'] = 'application/json';
++ const res = await fetchImpl(url, { method, headers, body: json === undefined ? undefined : JSON.stringify(json), signal: AbortSignal.timeout(8000) });
++ const body = await res.json().catch(() => null);
++ if (!res.ok) {
++ const err = new Error('Network ' + method + ' ' + path + ' ' + res.status + ': ' + ((body && (body.detail || body.error)) || 'failed'));
++ err.status = res.status;
++ throw err;
++ }
++ return body;
++ }
++
++ /** One notification. It never carries the trip's title, its places or its notes. */
++ async function notify(n) {
++ const to = n && n.to;
++ if (!to || to.type !== 'user') return;
++ const words = {
++ 'share.added': ['A trip was shared with you', 'Open it on OpenVibe.Tours.'],
++ 'share.access_requested': ['Someone asked for access to your trip', 'Review the request on OpenVibe.Tours.'],
++ 'share.transfer.requested': ['Someone wants to give you a trip', 'Accept or decline it on OpenVibe.Tours.'],
++ 'share.transfer.answered': ['A trip transfer was answered', 'See what happened on OpenVibe.Tours.'],
++ }[n.kind] || ['Sharing on OpenVibe.Tours', 'There is something new for you.'];
++ try {
++ await networkJson({
++ path: '/internal/notifications/push', method: 'POST',
++ json: {
++ subject: to.id, type: 'GENERIC', title: words[0], message: words[1],
++ service: 'tours', url: n.ref && n.ref.id ? `${config.baseUrl}/trips/${encodeURIComponent(n.ref.id)}` : `${config.baseUrl}/trips`,
++ category: 'social',
++ },
++ });
++ } catch (err) {
++ // Network may not have seeded Tours' network.notifications.push grant yet: the share still happened
++ // (it is decided by canView), the person just hears about it elsewhere.
++ log.warn('[Sharing] notification not sent:', err.message);
++ }
++ }
++
++ /** OpenVibe.Events: one share.* envelope. A refusal only loses the event copy, never the setting. */
++ async function publish(envelope) {
++ if (!config.events || !config.events.url) { log.warn('[Sharing] no events URL configured; share envelope not sent'); return; }
++ const res = await fetchImpl(String(config.events.url).replace(/\/+$/, ''), {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json' },
++ body: JSON.stringify(envelope),
++ signal: AbortSignal.timeout(8000),
++ });
++ if (!res.ok) throw new Error('Events answered ' + res.status);
++ }
++
++ /**
++ * The old visibility column follows the audience, so a reader that only knows `tours_trips.visibility` fails
++ * closed: Everyone and link stay readable by whoever holds the slug, Specific people and Only me do not.
++ */
++ async function onAudienceChange(ref, audience, tx) {
++ if (ref.service !== SERVICE || ref.kind !== KIND) return;
++ await tx.exec('UPDATE tours_trips SET visibility = $1, updated_at = now() WHERE slug = $2',
++ [VISIBILITY[audience] || 'private', String(ref.id)]);
++ }
++
++ async function onOwnerChange(ref, from, to, tx) {
++ if (ref.service !== SERVICE || ref.kind !== KIND) return;
++ const owner = to && to.type === 'user' ? to.id : null;
++ if (!owner) return;
++ await tx.exec('UPDATE tours_trips SET owner_subject = $1, updated_at = now() WHERE slug = $2', [owner, String(ref.id)]);
++ }
++
++ const shares = createShares({
++ db,
++ network: { json: networkJson },
++ events: { publish },
++ notify,
++ onAudienceChange,
++ onOwnerChange,
++ });
++
++ return {
++ shares,
++ SERVICE,
++ KIND,
++ AUDIENCES,
++ DEFAULT_AUDIENCE,
++ refOf,
++ shareViewer,
++ networkJson,
++ /** Register a trip, at the owner's default until the Share sheet says otherwise. */
++ register: async (viewer, slug, audience = DEFAULT_AUDIENCE) => await shares.register(refOf(slug), {
++ owner_subject: { type: 'user', id: String(viewer.subject) },
++ audience: AUDIENCES.includes(audience) ? audience : DEFAULT_AUDIENCE,
++ }),
++ canView: async (viewer, slug) => await shares.canView(shareViewer(viewer), refOf(slug)),
++ canEdit: async (viewer, slug) => await shares.canEdit(shareViewer(viewer), refOf(slug)),
++ setAudience: async (viewer, slug, audience) => await shares.setAudience(shareViewer(viewer), refOf(slug), audience),
++ /** The share row, or null for a trip that predates the backfill (its old column decides). */
++ shareOf: async (slug) => await db.maybe('SELECT * FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3',
++ [SERVICE, KIND, String(slug)]),
++ /** Just the audience, for deciding between "Only me" (no hint) and "Specific people" (the gate). */
++ audienceOf: async (slug) => {
++ const row = await db.maybe('SELECT audience FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3',
++ [SERVICE, KIND, String(slug)]);
++ return row ? row.audience : null;
++ },
++ defaultOf: async (subject) => {
++ if (!subject) return DEFAULT_AUDIENCE;
++ const row = await db.maybe('SELECT audience FROM tours_share_defaults WHERE subject = $1', [String(subject)]);
++ return row && AUDIENCES.includes(row.audience) ? row.audience : DEFAULT_AUDIENCE;
++ },
++ /** Set the default, and with `applyAll` move the person's existing trips too (design rule 2). */
++ setDefault: async (subject, audience, { applyAll = false } = {}) => {
++ if (!subject) return false;
++ const want = AUDIENCES.includes(audience) ? audience : DEFAULT_AUDIENCE;
++ await db.exec(`INSERT INTO tours_share_defaults (subject, audience, updated_at) VALUES ($1, $2, now())
++ ON CONFLICT (subject) DO UPDATE SET audience = excluded.audience, updated_at = now()`, [String(subject), want]);
++ if (applyAll) {
++ await db.exec(`UPDATE shares SET audience = $1, revision = revision + 1
++ WHERE service = $2 AND kind = $3 AND owner_subject->>'id' = $4 AND owner_subject->>'type' = 'user'`,
++ [want, SERVICE, KIND, String(subject)]);
++ }
++ return true;
++ },
++ /** The uniform API's describe(ref): the trip's title and its page. */
++ describe: async (ref) => {
++ if (ref.service !== SERVICE || ref.kind !== KIND) return {};
++ const row = await db.maybe('SELECT slug, title FROM tours_trips WHERE slug = $1', [String(ref.id)]);
++ if (!row) return {};
++ return {
++ title: String(row.title || 'A trip').slice(0, 120),
++ link: `${config.baseUrl}/trips/${encodeURIComponent(row.slug)}`,
++ roles: ['viewer', 'editor'],
++ transferable: false,
++ };
++ },
++ /** Expire members and transfers, then send the queued events and notifications. */
++ work: async ({ flush = true } = {}) => {
++ let expired = 0;
++ try { expired = await shares.expireMembers(); } catch (err) { log.warn('[Sharing] expiry sweep:', err.message); }
++ let sent = 0;
++ if (flush) { try { sent = await shares.flush(); } catch (err) { log.warn('[Sharing] deliveries:', err.message); } }
++ return { expired, sent };
++ },
++ };
++}
++
++module.exports = { createSharing, shareViewer, refOf, AUDIENCES, DEFAULT_AUDIENCE, VISIBILITY, SERVICE, KIND };
ddiff --git a/server/store.js b/server/store.js
iindex f213f32..9194d46 100644
---- a/server/store.js
++++ b/server/store.js
@@@ -117,10 +117,29 @@ function wireTrip(row) {
};
}
--function createStore(s, { config = {} } = {}) {
++function createStore(s, { config = {}, sharing = null } = {}) {
const db = s.db;
const q = (t) => t || db;
++ /** The viewer as openvibe-sdk/share wants it. Tours' pages and API both name the person by subject. */
++ const asShareViewer = (viewer) => (viewer && viewer.subject ? { subject: viewer.subject } : null);
++ /**
++ * A trip's share row decides who may read it. A trip without one predates the backfill (a fixture, or a row made
++ * by a migration): its old column decides, and it fails closed unless it was shared by link.
++ */
++ async function shareAccess(viewer, row) {
++ if (!sharing) return { registered: false, view: row.visibility === 'shared', edit: false, audience: null };
++ let audience = null;
++ try { audience = await sharing.audienceOf(row.slug); } catch { audience = null; }
++ if (!audience) return { registered: false, view: row.visibility === 'shared', edit: false, audience: null };
++ const v = asShareViewer(viewer);
++ const [view, edit] = await Promise.all([
++ sharing.canView(v, row.slug).catch(() => false),
++ sharing.canEdit(v, row.slug).catch(() => false),
++ ]);
++ return { registered: true, view, edit, audience };
++ }
++
// ── Cities ──────────────────────────────────────────────
async function listCities({ limit = 100, offset = 0, q: query = null } = {}) {
const lim = Math.min(200, Math.max(1, int(limit, 100)));
@@@ -466,6 +485,16 @@ function createStore(s, { config = {} } = {}) {
const row = await db.maybe(`INSERT INTO tours_trips (id, slug, owner_subject, title, city_id, starts_on, ends_on, notes, visibility)
VALUES ($1,$2,$3,$4,$5,$6::date,$7::date,$8,$9) RETURNING *`,
[id, slug, subject, v.title, city.id, v.starts_on, v.ends_on, v.notes || null, v.visibility]);
++ // Sharing (design 2026-11-06): the trip is its owner's, at the person's default until the Share sheet changes
++ // it. A trip that does not register is still the owner's: every read falls back to its old column.
++ if (sharing) {
++ try {
++ const audience = await sharing.defaultOf(subject).catch(() => 'private');
++ await sharing.register({ subject }, slug, audience);
++ } catch (err) {
++ console.warn(`[Tours] trip ${slug}: not registered with the sharing system: ${String((err && err.message) || err).slice(0, 200)}`);
++ }
++ }
return { ok: true, trip: wireTrip(row), city };
}
@@@ -476,12 +505,13 @@ function createStore(s, { config = {} } = {}) {
return rows.map((r) => ({ ...wireTrip(r), city_slug: r.city_slug || null, city_name: r.city_name || null }));
}
-- async function tripBySlug(slug, { subject = null } = {}) {
++ async function tripBySlug(slug, { subject = null, viewer = null } = {}) {
const row = await db.maybe(`SELECT t.*, c.slug AS city_slug, c.name AS city_name, c.lat AS city_lat, c.lon AS city_lon
FROM tours_trips t LEFT JOIN tours_cities c ON c.id = t.city_id WHERE t.slug = $1`, [String(slug || '')]);
if (!row) return null;
-- const mine = subject && row.owner_subject === subject;
-- if (!mine && row.visibility !== 'shared') return null;
++ const mine = Boolean(subject && row.owner_subject === subject);
++ const access = await shareAccess(viewer || (subject ? { subject } : null), row);
++ if (!mine && !access.view) return null;
const [places, route, staysRows] = await Promise.all([
db.many(`SELECT p.*, c.slug AS city_slug, c.name AS city_name, tp.position, tp.note AS trip_note
FROM tours_trip_places tp JOIN tours_places p ON p.id = tp.place_id JOIN tours_cities c ON c.id = p.city_id
@@@ -498,22 +528,39 @@ function createStore(s, { config = {} } = {}) {
trip: { ...wireTrip(row), city_slug: row.city_slug || null, city_name: row.city_name || null,
city_lat: num(row.city_lat), city_lon: num(row.city_lon) },
mine: Boolean(mine),
++ canEdit: Boolean(mine || access.edit),
++ audience: access.audience || (row.visibility === 'shared' ? 'link' : 'private'),
places: places.map((p) => ({ ...wirePlace(p), position: Number(p.position), trip_note: p.trip_note || null })),
route: routeOut,
stays: staysRows.map(wireStay),
};
}
-- async function updateTrip({ subject, slug, input }) {
++ async function updateTrip({ subject, slug, input, viewer = null }) {
const row = await db.maybe('SELECT * FROM tours_trips WHERE slug = $1', [String(slug || '')]);
if (!row) return refusal(404, 'tours.trip_not_found', 'no such trip');
-- if (row.owner_subject !== subject) return refusal(403, 'tours.trip_owner', 'only the person who made this trip can change it');
++ const mine = row.owner_subject === subject;
++ const access = await shareAccess(viewer || (subject ? { subject } : null), row);
++ if (!mine && !access.edit) {
++ return refusal(403, 'tours.trip_owner', 'only the people this trip is shared with, with Can edit, can change it');
++ }
++ const body = { ...(input || {}) };
++ // Who can open it is the Share sheet's (the owner's), never an editor's; the old API field still maps onto it.
++ if (body.visibility !== undefined && !mine) delete body.visibility;
const base = {
title: row.title, city_slug: null, starts_on: day(row.starts_on), ends_on: day(row.ends_on),
notes: row.notes || '', visibility: row.visibility,
};
-- const merged = tripFields(input || {}, base);
-- if (input && input.city_slug !== undefined) {
++ const merged = tripFields(body, base);
++ if (merged.visibility !== row.visibility && mine && sharing) {
++ const want = merged.visibility === 'shared' ? 'link' : 'private';
++ try {
++ if (!access.registered) await sharing.register({ subject }, row.slug, want);
++ else await sharing.setAudience({ subject }, row.slug, want);
++ } catch (err) { return refusal(400, 'tours.trip.invalid', String((err && err.message) || 'that audience was not saved')); }
++ merged.visibility = want === 'link' ? 'shared' : 'private';
++ }
++ if (body.city_slug !== undefined) {
const city = await cityBySlug(merged.city_slug);
if (!city) return refusal(400, 'tours.trip.invalid', 'city_slug must name a city Tours knows');
merged.city_id = city.id;
@@@ -534,15 +581,18 @@ function createStore(s, { config = {} } = {}) {
return { ok: true };
}
-- async function ownedTrip(subject, slug) {
++ async function editableTrip(viewer, slug) {
++ const subject = viewer && viewer.subject ? viewer.subject : null;
const row = await db.maybe('SELECT * FROM tours_trips WHERE slug = $1', [String(slug || '')]);
if (!row) return { error: refusal(404, 'tours.trip_not_found', 'no such trip') };
-- if (row.owner_subject !== subject) return { error: refusal(403, 'tours.trip_owner', 'only the person who made this trip can change it') };
++ if (subject && row.owner_subject === subject) return { row };
++ const access = await shareAccess(viewer, row);
++ if (!access.edit) return { error: refusal(403, 'tours.trip_owner', 'only the person who made this trip, or somebody it is shared with with Can edit, can change it') };
return { row };
}
-- async function addTripPlace({ subject, slug, placeId, note = null }) {
-- const own = await ownedTrip(subject, slug);
++ async function addTripPlace({ viewer, subject = null, slug, placeId, note = null }) {
++ const own = await editableTrip(viewer || (subject ? { subject } : null), slug);
if (own.error) return own.error;
const place = await placeById(placeId);
if (!place) return refusal(404, 'tours.place_not_found', 'no such place');
@@@ -553,16 +603,16 @@ function createStore(s, { config = {} } = {}) {
return { ok: true, place };
}
-- async function removeTripPlace({ subject, slug, placeId }) {
-- const own = await ownedTrip(subject, slug);
++ async function removeTripPlace({ viewer, subject = null, slug, placeId }) {
++ const own = await editableTrip(viewer || (subject ? { subject } : null), slug);
if (own.error) return own.error;
await db.exec('DELETE FROM tours_trip_places WHERE trip_id = $1 AND place_id = $2', [own.row.id, String(placeId || '')]);
await db.exec('UPDATE tours_trips SET revision = revision + 1, updated_at = now() WHERE id = $1', [own.row.id]);
return { ok: true };
}
-- async function setTripRoute({ subject, slug, routeId }) {
-- const own = await ownedTrip(subject, slug);
++ async function setTripRoute({ viewer, subject = null, slug, routeId }) {
++ const own = await editableTrip(viewer || (subject ? { subject } : null), slug);
if (own.error) return own.error;
if (!routeId) {
await db.exec('DELETE FROM tours_trip_routes WHERE trip_id = $1', [own.row.id]);
@@@ -577,8 +627,8 @@ function createStore(s, { config = {} } = {}) {
return { ok: true };
}
-- async function addTripStay({ subject, slug, stayId }) {
-- const own = await ownedTrip(subject, slug);
++ async function addTripStay({ viewer, subject = null, slug, stayId }) {
++ const own = await editableTrip(viewer || (subject ? { subject } : null), slug);
if (own.error) return own.error;
const stay = await db.maybe('SELECT * FROM tours_stays WHERE id = $1', [String(stayId || '')]);
if (!stay) return refusal(404, 'tours.stay_not_found', 'no such stay');
@@@ -588,8 +638,8 @@ function createStore(s, { config = {} } = {}) {
return { ok: true, stay: wireStay(stay) };
}
-- async function removeTripStay({ subject, slug, stayId }) {
-- const own = await ownedTrip(subject, slug);
++ async function removeTripStay({ viewer, subject = null, slug, stayId }) {
++ const own = await editableTrip(viewer || (subject ? { subject } : null), slug);
if (own.error) return own.error;
await db.exec('DELETE FROM tours_trip_stays WHERE trip_id = $1 AND stay_id = $2', [own.row.id, String(stayId || '')]);
await db.exec('UPDATE tours_trips SET revision = revision + 1, updated_at = now() WHERE id = $1', [own.row.id]);
ddiff --git a/server/views/index.js b/server/views/index.js
iindex b1462fd..b17f8bd 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -148,7 +148,7 @@ const HOME_FAQ = [
['Are the routes real guided tours?', 'No. They are self-guided walks generated from Wikivoyage\'s See lists and marked as generated. Check opening hours and access before you go; the note at each stop is the Wikivoyage text, cited.'],
['Do you sell flights or stays?', 'No. There is no flight API key in v1: the flight form opens the official airline-neutral search with the city pair filled in, with no affiliate parameter. Stays link to the listing on OpenVibe.Rent; Tours never takes a payment.'],
['Is there audio on the stops?', 'Not yet. OpenVibe.AI has no speech-synthesis route today, so every stop says audio is off instead of shipping a broken player. The audio fields are in the schema for when an API exists.'],
-- ['Who can see my trip?', 'Only you, until you switch it to shared. A shared trip is readable by anyone with its link and editable only by you. Delete it at any time; account deletion erases your trips.'],
++ ['Who can see my trip?', 'Only you, until you share it. Everyone and Anyone with the link are readable by whoever has the address; Specific people is only the accounts you add, each Can view or Can edit; Only me is you. Your Share button changes one trip, and /trips sets what a new trip starts as. Delete it at any time; account deletion erases your trips.'],
['Why is a city missing?', 'The v1 catalogue is the top 100 cities by population; every city is fetched from Wikidata and Wikivoyage. A failed source run leaves the previous data and says so in the job receipt rather than inventing a place.'],
];
@@@ -195,7 +195,7 @@ function home({ counts = {}, live = [], recent = [], placeOfDay = null, cities =
},
{
title: 'Build a trip and share it read-only',
-- text: 'Sign in with OpenVibe, pick a city and dates, save places, add a route and a stay, then share the link. Only you can edit it; anyone with the link can read it. Group voting is not in v1.',
++ text: 'Sign in with OpenVibe, pick a city and dates, save places, add a route and a stay, then share it with the people going with you. Can edit means they plan it with you; Group voting is not in v1.',
html: `<div class="tr-demo-trip"><span class="tr-trip-day">Fri</span><b>Land · check in</b><span class="tr-trip-day">Sat</span><b>Walk the See list</b><span class="tr-trip-day">Sun</span><b>Market, then home</b><small>Private by default · shared on purpose</small></div>`,
action: { href: '/trips/new', label: 'Plan a trip' },
},
@@@ -208,7 +208,7 @@ function home({ counts = {}, live = [], recent = [], placeOfDay = null, cities =
{ title: 'Live tours', text: 'IRL streams now and recent VODs from OpenVibe.Live.', href: '/live', icon: 'live' },
{ title: 'Stays', text: 'Rentals from OpenVibe.Rent; no payment is handled here.', href: '/stays', icon: 'home' },
{ title: 'Flights', text: 'No key yet: a form opens the official airline-neutral search.', href: '/flights', icon: 'plane' },
-- { title: 'Trips', text: 'A city, dates, saved places, a route and stays — shareable read-only.', href: '/trips', icon: 'calendar' },
++ { title: 'Trips', text: 'A city, dates, saved places, a route and stays — shared with the people going with you.', href: '/trips', icon: 'calendar' },
],
},
dev: {
@@@ -369,7 +369,7 @@ function signIn({ title = 'Sign in', text = 'This page is for a signed-in person
function tripNew({ cities = [], values = {}, error = null } = {}) {
const v = { title: '', city: '', starts_on: '', ends_on: '', notes: '', ...values };
return `<div class="tr-wrap tr-narrow" id="tr-trip-new">
--<header class="tr-page-h"><p class="tr-kicker">Trips</p><h1>Plan a trip</h1><p class="tr-lede">A city, dates and a name. After this you can save places, add a self-guided route and pick a stay; share it read-only when it is ready.</p></header>
++<header class="tr-page-h"><p class="tr-kicker">Trips</p><h1>Plan a trip</h1><p class="tr-lede">A city, dates and a name. After this you can save places, add a self-guided route and pick a stay; share it with the people going with you when it is ready.</p></header>
${error ? note(esc(error), 'error') : ''}
<form class="tr-form tr-card" method="post" action="/trips">
<label class="tr-field"><span>Trip name</span><input name="title" value="${esc(v.title)}" maxlength="120" required placeholder="Lisbon in November"></label>
@@@ -381,21 +381,34 @@ ${error ? note(esc(error), 'error') : ''}
</form></div>`;
}
--function tripsPage({ trips = [] } = {}) {
++function tripsPage({ trips = [], defaultAudience = 'private' } = {}) {
++ const audienceText = { private: 'Only me', people: 'Specific people', link: 'Anyone with the link', public: 'Everyone' };
++ const audienceWord = (v) => (v === 'shared' ? 'Anyone with the link' : 'Only me');
++ const options = ['private', 'people', 'link', 'public']
++ .map((a) => `<option value="${a}"${a === defaultAudience ? ' selected' : ''}>${audienceText[a]}</option>`).join('');
return `<div class="tr-wrap" id="tr-trips-page">
--<header class="tr-page-h"><p class="tr-kicker">Trips</p><h1>Your trips</h1><p class="tr-lede">Private by default. A shared trip has a read-only link; only you can edit it. The API and forms never expose another person's trip.</p><p><a class="tr-btn" href="/trips/new">Plan a new trip</a></p></header>
--${trips.length ? `<ol class="tr-trip-list">${trips.map((t) => `<li><a href="/trips/${esc(t.slug)}"><strong>${esc(t.title)}</strong><span class="tr-muted">${esc(t.city_name || t.city_slug || '')} · ${day(t.starts_on)} to ${day(t.ends_on)} · ${esc(t.visibility)}</span></a></li>`).join('')}</ol>` : empty('No trip yet. Start with a city and two dates.', '/trips/new', 'Plan a trip')}
++<header class="tr-page-h"><p class="tr-kicker">Trips</p><h1>Your trips</h1><p class="tr-lede">Only me by default. Share a trip with the people going with you, with Can edit, and you plan it together. The API and forms never expose another person's trip.</p><p><a class="tr-btn" href="/trips/new">Plan a new trip</a></p></header>
++<section class="tr-sec tr-card"><h2>New trips</h2>
++<form class="tr-form tr-form-inline" method="post" action="/trips/defaults">
++<label class="tr-field"><span>Who can open a new trip</span><select name="audience">${options}</select></label>
++<label class="tr-check"><input type="checkbox" name="apply_all" value="1"> Apply to all my existing trips</label>
++<button class="tr-btn" type="submit">Save</button>
++</form><p class="tr-fine">A trip's Share button changes just that trip.</p></section>
++${trips.length ? `<ol class="tr-trip-list">${trips.map((t) => `<li><a href="/trips/${esc(t.slug)}"><strong>${esc(t.title)}</strong><span class="tr-muted">${esc(t.city_name || t.city_slug || '')} · ${day(t.starts_on)} to ${day(t.ends_on)} · ${esc(audienceWord(t.visibility))}</span></a></li>`).join('')}</ol>` : empty('No trip yet. Start with a city and two dates.', '/trips/new', 'Plan a trip')}
</div>`;
}
--function tripPage({ trip, mine = false, cities = [], places = [], route = null, stays = [] } = {}) {
++function tripPage({ trip, mine = false, canEdit = false, canShare = false, audience = null, cities = [], places = [], route = null, stays = [] } = {}) {
const shareUrl = `/trips/${trip.slug}`;
++ const audienceText = { public: 'Everyone', link: 'Anyone with the link', people: 'Specific people', private: 'Only me' };
++ const who = audienceText[audience] || (trip.visibility === 'shared' ? 'Anyone with the link' : 'Only me');
return `<div class="tr-wrap" id="tr-trip-page">
<nav class="crumbs" aria-label="Breadcrumbs"><a href="/trips">Trips</a> / ${esc(trip.title)}</nav>
<header class="tr-page-h"><p class="tr-kicker">${esc(trip.city_name || '')} · ${day(trip.starts_on)} to ${day(trip.ends_on)}</p><h1>${esc(trip.title)}</h1>
${trip.notes ? `<p class="tr-lede">${esc(trip.notes)}</p>` : ''}
--<p class="tr-fine">${esc(trip.visibility === 'shared' ? 'Shared read-only' : 'Private')}${mine ? '' : ' · you are reading a shared trip'} · last changed ${when(trip.updated_at)}</p></header>
--${mine ? `<section class="tr-sec tr-card"><h2>Share and edit</h2><p class="tr-muted">${trip.visibility === 'shared' ? 'Anyone with this link can read it.' : 'Only you can read it.'}</p><p><code>${esc(shareUrl)}</code></p><form class="tr-form tr-form-inline" method="post" action="/trips/${esc(trip.slug)}/share"><input type="hidden" name="shared" value="${trip.visibility === 'shared' ? '0' : '1'}"><button class="tr-btn tr-btn-quiet" type="submit">${trip.visibility === 'shared' ? 'Make it private' : 'Share read-only'}</button></form>
++<p class="tr-fine"><span class="tr-visibility">${esc(who)}</span>${mine ? '' : ' · you are reading a shared trip'} · last changed ${when(trip.updated_at)}</p>
++${canShare ? `<p><button type="button" class="tr-btn tr-btn-quiet" data-ov-share-sheet data-kind="trip" data-id="${esc(trip.slug)}" data-title="${esc(trip.title)}">Share</button></p>` : ''}</header>
++${canEdit ? `<section class="tr-sec tr-card"><h2>Share and edit</h2><p class="tr-muted">${who === 'Only me' ? 'Only you can read it.' : who === 'Anyone with the link' ? 'Anyone with this link can read it.' : who === 'Specific people' ? 'Only you and the people on the list can open it.' : 'Everyone can read it.'}</p><p><code>${esc(shareUrl)}</code></p>
<details class="tr-edit"><summary>Edit the name, city and dates</summary><form class="tr-form" method="post" action="/trips/${esc(trip.slug)}/edit">
<label class="tr-field"><span>Trip name</span><input name="title" value="${esc(trip.title)}" maxlength="120" required></label>
<label class="tr-field"><span>City</span><select name="city">${cities.map((c) => `<option value="${esc(c.slug)}"${c.id === trip.city_id ? ' selected' : ''}>${esc(c.name)}, ${esc(c.country)}</option>`).join('')}</select></label>
@@@ -403,21 +416,34 @@ ${mine ? `<section class="tr-sec tr-card"><h2>Share and edit</h2><p class="tr-mu
<label class="tr-field"><span>Ends</span><input type="date" name="ends_on" value="${esc(day(trip.ends_on))}" required></label>
<label class="tr-field"><span>Notes</span><textarea name="notes" rows="3" maxlength="2000">${esc(trip.notes || '')}</textarea></label>
<button class="tr-btn" type="submit">Save the trip</button></form></details></section>` : ''}
--<section class="tr-sec"><div class="tr-sec-head"><h2>Saved places</h2>${mine ? '' : `<span class="tr-muted">${places.length} places</span>`}</div>
--${places.length ? `<ol class="tr-trip-places">${places.map((p) => `<li><span class="tr-stop-num" aria-hidden="true">${Number(p.position)}</span><div><a href="/place/${esc(p.id)}"><strong>${esc(placeName(p))}</strong></a><span class="tr-muted">${localName(p) ? `${esc(localName(p))} · ` : ''}${esc(p.city_name)}${p.trip_note ? ` · ${esc(p.trip_note)}` : ''}</span>${mine ? `<form class="tr-inline-form" method="post" action="/trips/${esc(trip.slug)}/places/${esc(p.id)}/remove"><button class="tr-link-btn" type="submit">Remove</button></form>` : ''}</div></li>`).join('')}</ol>` : empty('No place saved yet. Open a city and a place, then save it here.', trip.city_slug ? `/city/${trip.city_slug}` : '/cities', 'Find places')}
--${mine ? `<form class="tr-form tr-form-inline" method="post" action="/trips/${esc(trip.slug)}/places"><label class="tr-field"><span>Place id</span><input name="place_id" required placeholder="plc_…" pattern="plc_[0-9A-HJKMNP-TV-Z]{26}"></label><label class="tr-field"><span>Note</span><input name="note" maxlength="500"></label><button class="tr-btn" type="submit">Save place</button></form><p class="tr-fine">The place id is on the place page. In the API: POST /api/v1/trips/${esc(trip.slug)}/places.</p>` : ''}
++<section class="tr-sec"><div class="tr-sec-head"><h2>Saved places</h2>${canEdit ? '' : `<span class="tr-muted">${places.length} places</span>`}</div>
++${places.length ? `<ol class="tr-trip-places">${places.map((p) => `<li><span class="tr-stop-num" aria-hidden="true">${Number(p.position)}</span><div><a href="/place/${esc(p.id)}"><strong>${esc(placeName(p))}</strong></a><span class="tr-muted">${localName(p) ? `${esc(localName(p))} · ` : ''}${esc(p.city_name)}${p.trip_note ? ` · ${esc(p.trip_note)}` : ''}</span>${canEdit ? `<form class="tr-inline-form" method="post" action="/trips/${esc(trip.slug)}/places/${esc(p.id)}/remove"><button class="tr-link-btn" type="submit">Remove</button></form>` : ''}</div></li>`).join('')}</ol>` : empty('No place saved yet. Open a city and a place, then save it here.', trip.city_slug ? `/city/${trip.city_slug}` : '/cities', 'Find places')}
++${canEdit ? `<form class="tr-form tr-form-inline" method="post" action="/trips/${esc(trip.slug)}/places"><label class="tr-field"><span>Place id</span><input name="place_id" required placeholder="plc_…" pattern="plc_[0-9A-HJKMNP-TV-Z]{26}"></label><label class="tr-field"><span>Note</span><input name="note" maxlength="500"></label><button class="tr-btn" type="submit">Save place</button></form><p class="tr-fine">The place id is on the place page. In the API: POST /api/v1/trips/${esc(trip.slug)}/places.</p>` : ''}
</section>
--<section class="tr-sec"><div class="tr-sec-head"><h2>Route</h2>${mine ? `<a class="tr-more" href="${trip.city_slug ? `/city/${trip.city_slug}` : '/routes'}">Find a route</a>` : ''}</div>
--${route ? `<p><a href="/routes/${esc(route.id)}"><strong>${esc(route.title)}</strong></a> <span class="tr-muted">${Number(route.stop_count)} stops · ${km(route.distance_m)}</span></p>${mine ? `<form method="post" action="/trips/${esc(trip.slug)}/route"><input type="hidden" name="route_id" value=""><button class="tr-btn tr-btn-quiet" type="submit">Remove route</button></form>` : ''}` : `<p class="tr-muted">No route added${mine ? '. Open a route and press “Add to trip”.' : '.'}</p>`}
++<section class="tr-sec"><div class="tr-sec-head"><h2>Route</h2>${canEdit ? `<a class="tr-more" href="${trip.city_slug ? `/city/${trip.city_slug}` : '/routes'}">Find a route</a>` : ''}</div>
++${route ? `<p><a href="/routes/${esc(route.id)}"><strong>${esc(route.title)}</strong></a> <span class="tr-muted">${Number(route.stop_count)} stops · ${km(route.distance_m)}</span></p>${canEdit ? `<form method="post" action="/trips/${esc(trip.slug)}/route"><input type="hidden" name="route_id" value=""><button class="tr-btn tr-btn-quiet" type="submit">Remove route</button></form>` : ''}` : `<p class="tr-muted">No route added${canEdit ? '. Open a route and press “Add to trip”.' : '.'}</p>`}
</section>
--<section class="tr-sec"><div class="tr-sec-head"><h2>Stays</h2>${mine ? `<a class="tr-more" href="/stays">Find a stay</a>` : ''}</div>
--${stays.length ? `<ol class="tr-stay-list">${stays.map((s0) => `<li><a href="${esc(s0.url)}" rel="noopener"><strong>${esc(s0.title)}</strong><span class="tr-muted">${money(s0.price, s0.currency)} / ${esc(s0.period)}</span></a>${mine ? `<form class="tr-inline-form" method="post" action="/trips/${esc(trip.slug)}/stays/${esc(s0.id)}/remove"><button class="tr-link-btn" type="submit">Remove</button></form>` : ''}</li>`).join('')}</ol>` : `<p class="tr-muted">No stay added${mine ? '. Open a stay and add it here.' : '.'}</p>`}
--${mine ? `<form class="tr-form tr-form-inline" method="post" action="/trips/${esc(trip.slug)}/stays"><label class="tr-field"><span>Stay id</span><input name="stay_id" required placeholder="sty_…" pattern="sty_[0-9A-HJKMNP-TV-Z]{26}"></label><button class="tr-btn" type="submit">Add stay</button></form>` : ''}
++<section class="tr-sec"><div class="tr-sec-head"><h2>Stays</h2>${canEdit ? `<a class="tr-more" href="/stays">Find a stay</a>` : ''}</div>
++${stays.length ? `<ol class="tr-stay-list">${stays.map((s0) => `<li><a href="${esc(s0.url)}" rel="noopener"><strong>${esc(s0.title)}</strong><span class="tr-muted">${money(s0.price, s0.currency)} / ${esc(s0.period)}</span></a>${canEdit ? `<form class="tr-inline-form" method="post" action="/trips/${esc(trip.slug)}/stays/${esc(s0.id)}/remove"><button class="tr-link-btn" type="submit">Remove</button></form>` : ''}</li>`).join('')}</ol>` : `<p class="tr-muted">No stay added${canEdit ? '. Open a stay and add it here.' : '.'}</p>`}
++${canEdit ? `<form class="tr-form tr-form-inline" method="post" action="/trips/${esc(trip.slug)}/stays"><label class="tr-field"><span>Stay id</span><input name="stay_id" required placeholder="sty_…" pattern="sty_[0-9A-HJKMNP-TV-Z]{26}"></label><button class="tr-btn" type="submit">Add stay</button></form>` : ''}
</section>
${mine ? `<section class="tr-sec tr-card"><h2>Delete this trip</h2><form method="post" action="/trips/${esc(trip.slug)}/delete"><button class="tr-btn tr-btn-quiet" type="submit">Delete the trip</button></form><p class="tr-fine">This erases the trip and its saved places, route and stays. Places and routes themselves are public data and stay.</p></section>` : ''}
</div>`;
}
++/**
++ * The page a person not on a Specific-people trip's list sees: the audience, never the title, the places, the notes
++ * or the route. openvibe-shared 4.123.0 draws the card from this markup (no inline script, so a strict CSP needs
++ * nothing and every value is escaped like any other attribute).
++ */
++function tripGate({ slug, signedInAs = '', signInUrl = '/auth/login' } = {}) {
++ return `<div class="tr-wrap tr-narrow tr-page-pad" id="tr-trip-gate">
++<header class="tr-page-h"><p class="tr-kicker">Trips</p><h1>A trip shared with specific people</h1>
++<p class="tr-lede">Sign in with the OpenVibe account it was shared with, or ask its owner for access.</p></header>
++<div id="trip-share-gate" data-ov-share-gate data-kind="trip" data-id="${esc(slug)}" data-signed-in-as="${esc(signedInAs)}" data-sign-in-url="${esc(signInUrl)}">This is shared with specific people. Sign in with the OpenVibe account it was shared with, or ask its owner for access.</div>
++</div>`;
++}
++
function developersPage({ jobs = [] } = {}) {
const routes = [
['GET /api/v1/cities', 'the catalogue, newest population first', '?limit=&offset=&q='],
@@@ -427,8 +453,8 @@ function developersPage({ jobs = [] } = {}) {
['GET /api/v1/live', 'IRL tours now and recent IRL VODs', ''],
['GET /api/v1/trips', 'the signed-in person\'s trips', ''],
['POST /api/v1/trips', 'create a trip { title, city_slug, starts_on, ends_on, notes? }', ''],
-- ['GET /api/v1/trips/:slug', 'your trip, or a shared one read-only', ''],
-- ['PATCH /api/v1/trips/:slug', 'edit title, city, dates, notes or visibility', ''],
++ ['GET /api/v1/trips/:slug', 'your trip, or one shared with you', ''],
++ ['PATCH /api/v1/trips/:slug', 'edit title, city, dates or notes (owner, or Specific people with Can edit); the audience is the Share sheet\'s', ''],
['DELETE /api/v1/trips/:slug', 'delete your trip', ''],
['POST /api/v1/trips/:slug/places', 'save a place { place_id, note? }', ''],
['POST /api/v1/trips/:slug/route', 'set the trip\'s route { route_id }', ''],
@@@ -449,4 +475,4 @@ function notFound() {
return `<div class="tr-wrap tr-narrow tr-page-pad" id="tr-not-found"><p class="tr-kicker">404</p><h1>Not here</h1><p class="tr-lede">That city, place, route or trip does not exist, or it is a private trip.</p><p><a class="tr-btn" href="/">Go home</a> <a class="tr-btn tr-btn-quiet" href="/cities">Browse cities</a></p></div>`;
}
--module.exports = { placeName, localName, kindUnlessNamed, HOME_FAQ, safeImage, home, citiesPage, cityPage, placePage, routePage, livePage, staysPage, flightsPage, signIn, tripNew, tripsPage, tripPage, developersPage, notFound, placeOfDayCard, heroMotif, miniMap, SOURCE };
++module.exports = { placeName, localName, kindUnlessNamed, HOME_FAQ, safeImage, home, citiesPage, cityPage, placePage, routePage, livePage, staysPage, flightsPage, signIn, tripNew, tripsPage, tripPage, tripGate, developersPage, notFound, placeOfDayCard, heroMotif, miniMap, SOURCE };
ddiff --git a/test/helpers/mocks.js b/test/helpers/mocks.js
iindex 33d3baf..e85d1d4 100644
---- a/test/helpers/mocks.js
++++ b/test/helpers/mocks.js
@@@ -118,9 +118,20 @@ async function startNetwork() {
return json(200, { items: [], next_cursor: null });
}
-- if (url.pathname === '/internal/identity/resolve-batch' && req.method === 'POST') return json(200, { results: {} });
++ if (url.pathname === '/internal/identity/resolve-batch' && req.method === 'POST') {
++ const ids0 = JSON.parse(raw.toString('utf8') || '{}').subject_ids || [];
++ const results = {};
++ for (const subjectId of ids0) {
++ const u = [...st.users.values()].find((x) => x.subject === subjectId);
++ if (u) results[subjectId] = { subject: { type: 'user', id: u.subject }, username: u.username, display_name: u.display_name };
++ }
++ return json(200, { results });
++ }
if (url.pathname === '/internal/notifications/push' && req.method === 'POST') return json(200, { ok: true });
if (url.pathname === '/internal/identity/resolve' && req.method === 'GET') {
++ const name = String(url.searchParams.get('username') || '').toLowerCase();
++ const u = st.byUsername.get(name);
++ if (u) return json(200, { subject: { type: 'user', id: u.subject }, username: u.username, display_name: u.display_name });
return json(200, { subject_id: url.searchParams.get('subject_id'), network_user_id: 42 });
}
if (url.pathname === '/internal/coins/credit' && req.method === 'POST') {
ddiff --git a/test/share-gate-chrome.test.js b/test/share-gate-chrome.test.js
nnew file mode 100644
iindex 0000000..f0e9a43
---- /dev/null
++++ b/test/share-gate-chrome.test.js
@@@ -0,0 +1,92 @@
++'use strict';
++/**
++ * The "not on the list" gate in a real headless Chrome (openvibe-shared 4.123.0's [data-ov-share-gate]): the trip page
++ * carries markup only, ov-share.js draws the gate when it loads, signed out it offers Sign in, and a signed-in person
++ * who is not on the list is offered Ask for access — with no console error and no CSP refusal.
++ *
++ * Skipped (exit 0) without Chrome, or with OV_SKIP_BROWSER=1. The page is served by this repo's own app on a
++ * temporary database, so the CSP headers the browser sees are Tours'.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const { boot, check, done } = require('./helpers/boot');
++const { seed } = require('./helpers/seed');
++
++let playwright = null;
++try { playwright = require('playwright'); } catch {
++ try { playwright = require(process.env.PLAYWRIGHT_MODULE || '/home/workstation/openvibe/work/OpenVibe.Games/node_modules/playwright'); } catch { playwright = null; }
++}
++const CHROME = process.env.CHROME_BIN || '/usr/bin/google-chrome';
++if (process.env.OV_SKIP_BROWSER === '1' || !playwright || !fs.existsSync(CHROME)) {
++ console.log('share-gate-chrome: skipped (no Chrome; set CHROME_BIN)');
++ process.exit(0);
++}
++
++const REFUSED = /Content Security Policy|Refused to (?:load|execute|apply)|Uncaught SyntaxError/i;
++
++(async () => {
++ const t = await boot();
++ const ada = t.network.addUser('ada');
++ const cy = t.network.addUser('cy');
++ const cookieOf = (user) => {
++ const [name, ...rest] = t.signIn(user).split('=');
++ return { name, value: rest.join('='), url: t.base };
++ };
++
++ let browser;
++ const errors = [];
++ const watch = (page) => {
++ page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
++ page.on('pageerror', (e) => errors.push(String(e)));
++ };
++ const settled = async (page) => {
++ await page.waitForSelector('#trip-share-gate .ov-share-gate', { timeout: 8000 });
++ return await page.$eval('#trip-share-gate', (el) => el.textContent.replace(/\s+/g, ' ').trim());
++ };
++
++ try {
++ let slug;
++ await check('a trip shared with Specific people, with nobody on the list yet', async () => {
++ await seed(t);
++ const made = await t.get('/api/v1/trips', { as: ada, json: { title: 'Lisbon in November', city_slug: 'lisbon', starts_on: '2026-11-10', ends_on: '2026-11-14' } });
++ assert.strictEqual(made.status, 201, made.text);
++ slug = made.json().slug;
++ await t.ctx.sharing.setAudience({ subject: ada.subject }, slug, 'people');
++ });
++
++ await check('headless Chrome: the gate draws itself, signed out, with Sign in', async () => {
++ browser = await playwright.chromium.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'] });
++ const ctx = await browser.newContext();
++ const page = await ctx.newPage();
++ watch(page);
++ const res = await page.goto(`${t.base}/trips/${slug}`, { waitUntil: 'load', timeout: 15000 });
++ assert.strictEqual(res.status(), 404, 'the gate answers 404, never the trip');
++ const text = await settled(page);
++ assert.ok(/Sign in/.test(text), `signed out the gate offers Sign in, got: ${text}`);
++ const href = await page.$eval('#trip-share-gate .ov-share-gate a', (a) => a.getAttribute('href'));
++ assert.match(href, /^\/auth\/login\?next=/);
++ await ctx.close();
++ });
++
++ await check('headless Chrome: signed in as somebody not on the list, Ask for access', async () => {
++ const ctx = await browser.newContext();
++ await ctx.addCookies([cookieOf(cy)]);
++ const page = await ctx.newPage();
++ watch(page);
++ await page.goto(`${t.base}/trips/${slug}`, { waitUntil: 'load', timeout: 15000 });
++ const text = await settled(page);
++ assert.ok(/Ask for access/.test(text), `a stranger is offered Ask for access, got: ${text}`);
++ assert.ok(/@cy/.test(text), 'the gate names the account it is signed in as');
++ await ctx.close();
++ });
++
++ await check('headless Chrome: no console error and no CSP refusal on the gate page', async () => {
++ const refused = errors.filter((e) => REFUSED.test(e));
++ assert.deepStrictEqual(refused, [], `the gate page was refused: ${refused.join(' | ')}`);
++ });
++ } finally {
++ if (browser) await browser.close().catch(() => {});
++ await t.close();
++ done();
++ }
++})();
ddiff --git a/test/sharing.test.js b/test/sharing.test.js
nnew file mode 100644
iindex 0000000..6a6ec5a
---- /dev/null
++++ b/test/sharing.test.js
@@@ -0,0 +1,151 @@
++'use strict';
++/**
++ * Sharing a trip (design 2026-11-06, visibility step 3): the one Share sheet (openvibe-sdk/share) and the one check,
++ * canView, on the trip page and the trip API. Editing asks canEdit, so Specific people with Can edit plan together.
++ *
++ * - a new trip is Only me; its page carries the Share button for its owner;
++ * - Everyone and Anyone with the link are readable by whoever has the address, and only the owner edits;
++ * - Specific people shows the gate, never the title, places or notes, to somebody not on the list;
++ * - Can edit really edits; Can view only reads;
++ * - the person's default for new trips, and "apply to all", work;
++ * - a trip is never on a gathered page: not the sitemap, not llms.txt.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const { seed } = require('./helpers/seed');
++
++(async () => {
++ const t = await boot();
++ try {
++ await seed(t);
++ const kim = t.network.addUser('kim');
++ const bo = t.network.addUser('bo');
++ const cy = t.network.addUser('cy');
++
++ const call = (pathname, o = {}) => {
++ const { headers, ...rest } = o;
++ return t.get(pathname, { ...rest, headers: { 'x-requested-with': 'ov-share', ...(headers || {}) } });
++ };
++ const share = (slug, o = {}) => call(`/api/v1/trip/${encodeURIComponent(slug)}/share`, o);
++ const setShare = async (slug, body, as) => {
++ const first = await share(slug, { as });
++ assert.strictEqual(first.status, 200, first.text);
++ const etag = first.headers.get('etag');
++ assert.ok(etag);
++ return await share(slug, { as, method: 'PUT', json: body, headers: { 'if-match': etag } });
++ };
++ const audienceRow = async (slug) => await t.ctx.s.db.maybe(
++ 'SELECT audience FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', ['tours', 'trip', slug]);
++ const newTrip = async (as, title = 'Lisbon in November') => {
++ const made = await t.get('/api/v1/trips', { as, json: { title, city_slug: 'lisbon', starts_on: '2026-11-10', ends_on: '2026-11-14', notes: 'Trams and tiles.' } });
++ assert.strictEqual(made.status, 201, made.text);
++ return made.json().slug;
++ };
++
++ let slug;
++ await check('a new trip is Only me, and its page carries the Share button for its owner', async () => {
++ slug = await newTrip(kim);
++ assert.deepStrictEqual(await audienceRow(slug), { audience: 'private' });
++ const mine = await t.get(`/trips/${slug}`, { as: kim });
++ assert.strictEqual(mine.status, 200, mine.text);
++ assert.ok(mine.text.includes('data-ov-share-sheet') && mine.text.includes('data-kind="trip"'));
++ assert.ok(mine.text.includes('ov-share.js'));
++ assert.ok(mine.text.includes('Only me'));
++ const other = await t.get(`/trips/${slug}`, { as: bo });
++ assert.strictEqual(other.status, 404);
++ assert.ok(!other.text.includes('Lisbon in November'), 'the trip is never named to somebody else');
++ assert.strictEqual((await t.get(`/trips/${slug}`)).status, 404);
++ });
++
++ await check('Anyone with the link reads the page and the API, and cannot edit', async () => {
++ const saved = await setShare(slug, { audience: 'link' }, kim);
++ assert.strictEqual(saved.status, 200, saved.text);
++ assert.strictEqual(saved.json().audience, 'link');
++ const guest = await t.get(`/trips/${slug}`);
++ assert.strictEqual(guest.status, 200, guest.text);
++ assert.ok(guest.text.includes('Lisbon in November'));
++ assert.ok(guest.text.includes('Anyone with the link'));
++ assert.ok(!guest.text.includes('Delete this trip'));
++ assert.ok(!guest.text.includes('action="/trips/' + slug + '/edit"'), 'a reader gets no edit form');
++ const api = await t.get(`/api/v1/trips/${slug}`);
++ assert.strictEqual(api.status, 200);
++ assert.strictEqual(api.json().canEdit, false);
++ assert.strictEqual((await t.get(`/api/v1/trips/${slug}`, { as: bo, method: 'PATCH', json: { title: 'Mine now' } })).status, 403);
++ });
++
++ await check('Specific people: an editor plans it, a viewer only reads, a stranger gets the gate', async () => {
++ const saved = await setShare(slug, { audience: 'people', members: [{ name: 'bo', role: 'editor' }, { name: 'cy', role: 'viewer' }] }, kim);
++ assert.strictEqual(saved.status, 200, saved.text);
++ assert.strictEqual(saved.json().audience, 'people');
++ const editor = await t.get(`/trips/${slug}`, { as: bo });
++ assert.strictEqual(editor.status, 200, editor.text);
++ assert.ok(editor.text.includes('Lisbon in November'));
++ assert.ok(editor.text.includes('action="/trips/' + slug + '/edit"'), 'Can edit opens the forms');
++ assert.ok(!editor.text.includes('Delete this trip'), 'only the owner deletes');
++ const edited = await t.get(`/api/v1/trips/${slug}`, { as: bo, method: 'PATCH', json: { title: 'Lisbon, together' } });
++ assert.strictEqual(edited.status, 200, edited.text);
++ assert.strictEqual(edited.json().title, 'Lisbon, together');
++ const viewer = await t.get(`/trips/${slug}`, { as: cy });
++ assert.strictEqual(viewer.status, 200);
++ assert.ok(!viewer.text.includes('action="/trips/' + slug + '/edit"'), 'Can view cannot edit');
++ assert.strictEqual((await t.get(`/api/v1/trips/${slug}`, { as: cy, method: 'PATCH', json: { title: 'No' } })).status, 403);
++ const stranger = t.network.addUser('di');
++ const gate = await t.get(`/trips/${slug}`, { as: stranger });
++ assert.strictEqual(gate.status, 404);
++ assert.ok(gate.text.includes('shared with specific people'));
++ assert.ok(!gate.text.includes('Lisbon, together'), 'the gate never shows the title');
++ assert.ok(!gate.text.includes('Trams and tiles'), 'the gate never shows the notes');
++ assert.strictEqual((await t.get(`/api/v1/trips/${slug}`, { as: stranger })).status, 404);
++ });
++
++ await check('the default applies to the next trip, and "apply to all" moves the existing one', async () => {
++ const saved = await t.get('/trips/defaults', { as: kim, form: { audience: 'link', apply_all: '1' } });
++ assert.strictEqual(saved.status, 303, saved.text);
++ assert.deepStrictEqual(await audienceRow(slug), { audience: 'link' });
++ const next = await newTrip(kim, 'Porto in December');
++ assert.deepStrictEqual(await audienceRow(next), { audience: 'link' });
++ const start = await t.get('/trips', { as: kim });
++ assert.ok(start.text.includes('Who can open a new trip'), 'the default is on the product page');
++ assert.strictEqual((await t.get(`/trips/${next}`)).status, 200, 'the next trip follows the default');
++ });
++
++ await check('a public trip: the owner\'s copy is private, no-store; the signed-out copy has no sheet data', async () => {
++ const next = await newTrip(kim, 'A public trip');
++ await setShare(next, { audience: 'public' }, kim);
++ const mine = await t.get(`/trips/${next}`, { as: kim });
++ assert.strictEqual(mine.status, 200);
++ assert.match(mine.headers.get('cache-control'), /private/);
++ assert.match(mine.headers.get('cache-control'), /no-store/);
++ assert.ok(mine.text.includes('data-kind="trip"'), 'the owner gets the Share sheet');
++ const anon = await t.get(`/trips/${next}`);
++ assert.strictEqual(anon.status, 200);
++ assert.match(anon.headers.get('cache-control'), /public/);
++ assert.ok(!anon.text.includes('data-kind='), 'a shared cache never stores the sheet\'s kind');
++ assert.ok(!anon.text.includes('data-id='), 'nor its id');
++ assert.ok(!anon.text.includes('data-ov-share-sheet'), 'and the signed-out page carries no Share button');
++ // Now the gathered-page rule on the same trip.
++ const sitemap = await t.get('/sitemap.xml');
++ assert.strictEqual(sitemap.status, 200);
++ assert.ok(!sitemap.text.includes(next), 'a trip is not in the sitemap, even Everyone');
++ const llms = await t.get('/llms.txt');
++ assert.ok(!llms.text.includes(next), 'a trip is not in llms.txt');
++ const robots = await t.get('/robots.txt');
++ assert.ok(/Disallow: \/trips/.test(robots.text));
++ });
++
++ await check('a trip is never on a gathered page: not the sitemap, not llms.txt', async () => {
++ const next = await newTrip(kim, 'A second public trip');
++ await setShare(next, { audience: 'public' }, kim);
++ const sitemap = await t.get('/sitemap.xml');
++ assert.strictEqual(sitemap.status, 200);
++ assert.ok(!sitemap.text.includes(next), 'a trip is not in the sitemap, even Everyone');
++ const llms = await t.get('/llms.txt');
++ assert.ok(!llms.text.includes(next), 'a trip is not in llms.txt');
++ const robots = await t.get('/robots.txt');
++ assert.ok(/Disallow: \/trips/.test(robots.text));
++ });
++ } finally {
++ await t.close();
++ done();
++ }
++})();
ddiff --git a/test/trips.test.js b/test/trips.test.js
iindex 209c31f..bcd692f 100644
---- a/test/trips.test.js
++++ b/test/trips.test.js
@@@ -79,7 +79,11 @@ const { seed } = require('./helpers/seed');
assert.strictEqual((await t.get(`/api/v1/trips/${trip.slug}`, { as: sam, method: 'DELETE' })).status, 403);
const page = await t.get(`/trips/${trip.slug}`);
assert.strictEqual(page.status, 200);
-- assert.match(page.text, /Shared read-only/);
++ assert.match(page.text, /Anyone with the link/);
++ assert.ok(!page.text.includes('data-ov-share-sheet'), 'a signed-out copy carries no Share button (a shared cache may store it)');
++ const reader = await t.get(`/trips/${trip.slug}`, { as: sam });
++ assert.ok(reader.text.includes('data-ov-share-sheet') && reader.text.includes('data-kind="trip"'), 'the one Share sheet for a signed-in reader');
++ assert.match(reader.headers.get('cache-control'), /private/);
assert.ok(!page.text.includes('Delete this trip'), 'a reader gets no owner controls');
assert.strictEqual((await t.get(`/api/v1/trips/${trip.slug}`, { as: kim, method: 'PATCH', json: { visibility: 'private' } })).json().visibility, 'private');
assert.strictEqual((await t.get(`/api/v1/trips/${trip.slug}`)).status, 404);