openvibers/openvibe.directory Commits
Add signed-in opt-in people listings (#2)
Changes
@@@ -1,2 +1,8 @@PORT=5281BASE_URL=https://openvibe.directory++DATABASE_URL=++DATABASE_DIRECT_URL=++OV_OAUTH_CLIENT_ID=++OV_OAUTH_CLIENT_SECRET=++OV_NETWORK_URL=https://openvibe.network++OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000@@@ -2,6 +2,8 @@Source-linked directories for OpenVibe sites and tools, AI models and providers, open-source projects, and local businesses from OpenStreetMap. Every entry has a source and a last checked date. Public sources are refreshed at startup and every six hours; a failed source keeps its last successful in-memory snapshot and exposes the failure in `/api/v1/kinds`.--Business listings are a bounded San Francisco pilot sourced from OpenStreetMap under ODbL 1.0. Unverified OpenStreetMap phone tags are omitted; a business number needs a business-owned source before publication. Private-person searches and reverse lookups are excluded. The people directory has no imported entries and remains empty until the opt-in claim flow is available.++Business listings are a bounded San Francisco pilot sourced from OpenStreetMap under ODbL 1.0. Unverified OpenStreetMap phone tags are omitted; a business number needs a business-owned source before publication. Private-person searches and reverse lookups are excluded. People can sign in to claim, edit, unpublish, and delete only their own entry; an entry is private until they explicitly publish it. The form offers no age, city, or phone fields.Run `npm ci`, then `node server/index.js`. Check `/api/v1/kinds`, `/api/v1/entries`, `/sitemap.xml`, and `/llms.txt`.++++The opt-in people table is created at startup through `DATABASE_DIRECT_URL` (owner connection). Listing reads and writes use `DATABASE_URL` (runtime connection).nnew file mode 100644@@@ -0,0 +1,11 @@++-- phase: expand++CREATE TABLE IF NOT EXISTS directory_people (++ subject_id TEXT PRIMARY KEY,++ display_name TEXT NOT NULL,++ bio TEXT NOT NULL DEFAULT '',++ website TEXT,++ visible BOOLEAN NOT NULL DEFAULT FALSE,++ confirmed_at TIMESTAMPTZ NOT NULL DEFAULT now(),++ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()++);++CREATE INDEX IF NOT EXISTS directory_people_visible_name ON directory_people (lower(display_name)) WHERE visible;@@@ -1,17 +1,19 @@{"name": "openvibe-directory",-- "version": "0.1.0",++ "version": "0.1.1","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-directory",-- "version": "0.1.0",++ "version": "0.1.1","license": "AGPL-3.0-only","dependencies": {"express": "^4.21.2","fast-xml-parser": "^5.2.5",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.202.0"++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.202.0",++ "pg": "^8.23.1"},"engines": {"node": ">=22"@@@ -1103,6 +1105,34 @@"url": "https://opencollective.com/express"}},++ "node_modules/openvibe-sdk": {++ "version": "0.74.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",++ "integrity": "sha512-DO9KVPnUriY/I4lX6ZEJtXaUvRDbJyqnqJxvlpl+zkNGFILpmuBXVJogNOCfK071Qci1gg7C4Iw1Xrseklcajg==",++ "license": "MIT",++ "bin": {++ "openvibe-download": "bin/openvibe-download.js"++ },++ "engines": {++ "node": ">=20"++ },++ "peerDependencies": {++ "@electric-sql/pglite": "^0.5.8",++ "iovalkey": "^0.4.0",++ "pg": "^8.23.0"++ },++ "peerDependenciesMeta": {++ "@electric-sql/pglite": {++ "optional": true++ },++ "iovalkey": {++ "optional": true++ },++ "pg": {++ "optional": true++ }++ }++ },"node_modules/openvibe-shared": {"version": "4.202.0","resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.202.0",@@@ -1148,6 +1178,134 @@"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==","license": "MIT"},++ "node_modules/pg": {++ "version": "8.23.1",++ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz",++ "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==",++ "license": "MIT",++ "dependencies": {++ "pg-connection-string": "^2.14.1",++ "pg-pool": "^3.14.0",++ "pg-protocol": "^1.16.1",++ "pg-types": "2.2.0",++ "pgpass": "1.0.5"++ },++ "engines": {++ "node": ">= 16.0.0"++ },++ "optionalDependencies": {++ "pg-cloudflare": "^1.4.1"++ },++ "peerDependencies": {++ "pg-native": ">=3.0.1"++ },++ "peerDependenciesMeta": {++ "pg-native": {++ "optional": true++ }++ }++ },++ "node_modules/pg-cloudflare": {++ "version": "1.4.1",++ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz",++ "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==",++ "license": "MIT",++ "optional": true++ },++ "node_modules/pg-connection-string": {++ "version": "2.14.1",++ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz",++ "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==",++ "license": "MIT"++ },++ "node_modules/pg-int8": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",++ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",++ "license": "ISC",++ "engines": {++ "node": ">=4.0.0"++ }++ },++ "node_modules/pg-pool": {++ "version": "3.14.0",++ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",++ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",++ "license": "MIT",++ "peerDependencies": {++ "pg": ">=8.0"++ }++ },++ "node_modules/pg-protocol": {++ "version": "1.16.1",++ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz",++ "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==",++ "license": "MIT"++ },++ "node_modules/pg-types": {++ "version": "2.2.0",++ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",++ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",++ "license": "MIT",++ "dependencies": {++ "pg-int8": "1.0.1",++ "postgres-array": "~2.0.0",++ "postgres-bytea": "~1.0.0",++ "postgres-date": "~1.0.4",++ "postgres-interval": "^1.1.0"++ },++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/pgpass": {++ "version": "1.0.5",++ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",++ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",++ "license": "MIT",++ "dependencies": {++ "split2": "^4.1.0"++ }++ },++ "node_modules/postgres-array": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",++ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",++ "license": "MIT",++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/postgres-bytea": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",++ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/postgres-date": {++ "version": "1.0.7",++ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",++ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/postgres-interval": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",++ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",++ "license": "MIT",++ "dependencies": {++ "xtend": "^4.0.0"++ },++ "engines": {++ "node": ">=0.10.0"++ }++ },"node_modules/proxy-addr": {"version": "2.0.8","resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",@@@ -1385,6 +1543,15 @@"source-map": "^0.6.0"}},++ "node_modules/split2": {++ "version": "4.2.0",++ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",++ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",++ "license": "ISC",++ "engines": {++ "node": ">= 10.x"++ }++ },"node_modules/statuses": {"version": "2.0.2","resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",@@@ -1496,6 +1663,15 @@"engines": {"node": ">=16.0.0"}++ },++ "node_modules/xtend": {++ "version": "4.0.2",++ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",++ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",++ "license": "MIT",++ "engines": {++ "node": ">=0.4"++ }}}}@@@ -1,15 +1,22 @@{"name": "openvibe-directory",-- "version": "0.1.0",++ "version": "0.1.1","private": true,"description": "Source-linked directories that refresh themselves","main": "server/index.js",-- "scripts": {"start": "node server/index.js", "test": "node --test test/*.test.js"},-- "engines": {"node": ">=22"},++ "scripts": {++ "start": "node server/index.js",++ "test": "node --test test/*.test.js"++ },++ "engines": {++ "node": ">=22"++ },"license": "AGPL-3.0-only","dependencies": {"express": "^4.21.2","fast-xml-parser": "^5.2.5",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.202.0"++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.202.0",++ "pg": "^8.23.1"}}nnew file mode 100644@@@ -0,0 +1,16 @@++'use strict';++const form=document.getElementById('people-form');++const result=document.getElementById('people-result');++form?.addEventListener('submit',async event=>{++ event.preventDefault();++ const data=new FormData(form);++ const body={display_name:String(data.get('display_name')||''),bio:String(data.get('bio')||''),website:String(data.get('website')||''),visible:data.has('visible')};++ const response=await fetch('/api/v1/people/me',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});++ result.textContent=response.ok?'Saved. Your visibility choice is now active.':'Could not save this listing.';++});++document.getElementById('delete-listing')?.addEventListener('click',async()=>{++ if(!confirm('Delete your listing?'))return;++ const response=await fetch('/api/v1/people/me',{method:'DELETE'});++ result.textContent=response.ok?'Listing deleted.':'Could not delete this listing.';++ if(response.ok)form.reset();++});@@@ -3,17 +3,28 @@ const express=require('express');const path=require('node:path');const {createStore,kinds}=require('./sources');const v=require('./views');--function createApp({store=createStore()}={}){++const {createAuth,writeGuard}=require('./people');++function createApp({store=createStore(),peopleStore=null,auth=createAuth()}={}){const app=express();app.disable('x-powered-by');app.use((_q,r,next)=>{r.set('X-Content-Type-Options','nosniff');r.set('Referrer-Policy','strict-origin-when-cross-origin');next();});++ app.use(express.static(path.join(__dirname,'../public')));app.use('/shared',require('openvibe-shared/serve').handler());++ if(auth)app.use('/auth',auth.router(express));app.get('/api/health',(_q,r)=>r.json({status:'ok',service:'directory'}));app.get('/api/ready',(_q,r)=>r.json({status:'ok',service:'directory'}));-- app.get('/release.json',(_q,r)=>r.json(require('openvibe-shared/release').createRelease({service:'directory',root:path.join(__dirname,'..')})));++ const release=require('openvibe-shared/release').createRelease({service:'directory',root:path.join(__dirname,'..')});++ app.get('/release.json',release.handler);const all=()=>store.list();const counts=()=>Object.fromEntries(Object.keys(kinds).map(k=>[k,store.list(k).length]));const find=(kind,slug)=>store.list(kind).find(e=>e.slug===slug);const search=(q,kind)=>{const terms=String(q||'').toLowerCase().trim().slice(0,120);const rows=kind?store.list(kind):all();return !terms?rows:rows.filter(e=>`${e.name} ${e.description} ${e.kind} ${e.group||''} ${e.provider||''} ${(e.topics||[]).join(' ')}`.toLowerCase().includes(terms));};++ const subject=q=>q.user&&typeof q.user.subject_id==='string'&&q.user.subject_id.startsWith('usr_')?q.user.subject_id:null;++ const signedIn=(q,r,next)=>{if(!auth||!peopleStore)return r.status(503).json({error:'people.unavailable'});return auth.requireAuth()(q,r,()=>subject(q)?next():r.status(403).json({error:'identity.invalid'}));};++ const handle=fn=>(q,r,next)=>Promise.resolve(fn(q,r)).catch(next);++ app.get('/people/me',auth?auth.optionalAuth():(_q,_r,next)=>next(),handle(async(q,r)=>{r.set('Cache-Control','private, no-store');r.set('X-Robots-Tag','noindex, nofollow');if(!auth||!peopleStore)return r.status(503).send('People listings are temporarily unavailable.');if(!subject(q))return r.redirect('/auth/login?next=/people/me');const row=await peopleStore.get(subject(q));r.send(v.page('Your listing',v.peopleForm(row),'/people/me'));}));++ app.get('/api/v1/people/me',signedIn,handle(async(q,r)=>{r.set('Cache-Control','private, no-store');r.json({listing:await peopleStore.get(subject(q))});}));++ app.put('/api/v1/people/me',signedIn,writeGuard,express.json({limit:'4kb'}),handle(async(q,r)=>{const row=await peopleStore.put(subject(q),q.body);return row?r.json({listing:row}):r.status(400).json({error:'listing.invalid'});}));++ app.delete('/api/v1/people/me',signedIn,writeGuard,handle(async(q,r)=>{await peopleStore.remove(subject(q));r.status(204).end();}));app.get('/api/v1/kinds',(_q,r)=>r.json({kinds:Object.entries(kinds).map(([id,name])=>({id,name,count:counts()[id]})),last_checked:store.state.refreshed_at,status:store.state.status}));app.get('/api/v1/entries',(q,r)=>{const kind=String(q.query.kind||'');if(kind&&!kinds[kind])return r.status(400).json({error:'invalid_kind'});const page=Math.max(1,Math.min(1000,Number(q.query.page)||1)),limit=50;const rows=search(q.query.q,kind);r.json({count:rows.length,page,entries:rows.slice((page-1)*limit,page*limit)});});app.get('/api/v1/entries/:kind/:slug',(q,r)=>{const e=find(q.params.kind,q.params.slug);return e?r.json(e):r.status(404).json({error:'entry.not_found'});});@@@ -23,7 +34,7 @@ function createApp({store=createStore()}={}){app.get('/',(q,r)=>r.send(v.page('Directories',v.home(kinds,counts(),all()),'/',[{'@context':'https://schema.org','@type':'WebSite',name:'OpenVibe.Directory',url:'https://openvibe.directory/',potentialAction:{'@type':'SearchAction',target:'https://openvibe.directory/search?q={search_term_string}','query-input':'required name=search_term_string'}}])));app.get('/search',(q,r)=>{const kind=String(q.query.kind||'');if(kind&&!kinds[kind])return r.sendStatus(404);const term=String(q.query.q||'').slice(0,120);const rows=search(term,kind).slice(0,200);r.send(v.page('Search directory',v.listing(kind||'all','Search directory',rows,term),`/search?q=${encodeURIComponent(term)}`));});app.get('/kinds/:kind',(q,r)=>{const kind=q.params.kind;if(!kinds[kind])return r.sendStatus(404);r.send(v.page(kinds[kind],v.listing(kind,kinds[kind],store.list(kind)),q.path));});-- app.get('/entries/:kind/:slug',(q,r)=>{const e=find(q.params.kind,q.params.slug);if(!e)return r.sendStatus(404);r.vary('Accept');if(q.accepts(['html','text/markdown'])==='text/markdown')return r.type('text/markdown').send(v.markdown(e));r.send(v.page(e.name,v.detail(e),q.path,[{'@context':'https://schema.org','@type':e.kind==='businesses'?'LocalBusiness':'Thing',name:e.name,description:e.description,url:`https://openvibe.directory${q.path}`,dateModified:e.last_checked,sameAs:e.sources.map(s=>s.url)}]));});++ app.get('/entries/:kind/:slug',(q,r)=>{const e=find(q.params.kind,q.params.slug);if(!e)return r.sendStatus(404);r.vary('Accept');if(q.accepts(['html','text/markdown'])==='text/markdown')return r.type('text/markdown').send(v.markdown(e));r.send(v.page(e.name,v.detail(e),q.path,[{'@context':'https://schema.org','@type':e.kind==='businesses'?'LocalBusiness':e.kind==='people'?'Person':'Thing',name:e.name,description:e.description,url:`https://openvibe.directory${q.path}`,dateModified:e.last_checked,sameAs:e.sources.map(s=>s.url)}]));});app.use((_q,r)=>r.status(404).send('Not found'));store.start();return app;@@@ -1,5 +1,12 @@'use strict';const {createApp} = require('./app');++const {createStore}=require('./sources');++const {createPeopleStore}=require('./people');const port = Number(process.env.PORT || 5281);--const server=createApp().listen(port, '127.0.0.1', () => process.stdout.write(`directory listening on ${port}\n`));--process.on('SIGTERM',()=>server.close(()=>process.exit(0)));++async function main(){++ const store=createStore();++ const peopleStore=process.env.DATABASE_URL?await createPeopleStore({entries:store.state.entries}):null;++ const server=createApp({store,peopleStore}).listen(port, '127.0.0.1', () => process.stdout.write(`directory listening on ${port}\n`));++ process.on('SIGTERM',()=>server.close(async()=>{store.stop();await peopleStore?.close();process.exit(0);}));++}++main().catch(err=>{console.error('directory startup failed:',err.message);process.exitCode=1;});nnew file mode 100644@@@ -0,0 +1,34 @@++'use strict';++const fs=require('node:fs');++const path=require('node:path');++const {Pool}=require('pg');++const {createSsoClient}=require('openvibe-sdk/sso');++const SUBJECT=/^usr_[A-Za-z0-9_-]{8,100}$/;++const nameRe=/^[^<>\r\n]{1,100}$/;++const websiteOf=value=>{if(!value)return null;try{const u=new URL(value);return u.protocol==='https:'&&u.username===''&&u.password===''?u.href:null;}catch{return null;}};++function validate(input){++ if(!input||typeof input!=='object'||Array.isArray(input)||!nameRe.test(String(input.display_name||'')))return null;++ const display_name=String(input.display_name).trim();if(!display_name)return null;++ const bio=String(input.bio||'').trim();if(bio.length>500||/[<>]/.test(bio))return null;++ const website=websiteOf(input.website);if(input.website&&!website)return null;++ if(typeof input.visible!=='boolean')return null;++ return {display_name,bio,website,visible:input.visible};++}++function publicEntry(row){const id=row.subject_id;const checked=new Date(row.confirmed_at).toISOString();return {kind:'people',id,slug:id,name:row.display_name,description:row.bio,website:row.website,sources:[{url:`https://openvibe.directory/entries/people/${encodeURIComponent(id)}`,checked_at:checked,label:'Owner confirmed'}],last_checked:checked};}++async function createPeopleStore({connectionString=process.env.DATABASE_URL,directConnectionString=process.env.DATABASE_DIRECT_URL,pool,migrationPool,entries}={}){++ if(!pool&&!connectionString)throw Error('DATABASE_URL required for people listings');++ const db=pool||new Pool({connectionString,max:4});++ const migration=fs.readFileSync(path.join(__dirname,'../migrations/0001_people.sql'),'utf8');++ if(!migrationPool&&!directConnectionString)throw Error('DATABASE_DIRECT_URL required for migrations');++ const owner=migrationPool||new Pool({connectionString:directConnectionString,max:1});++ try{await owner.query(migration);}finally{if(!migrationPool)await owner.end();}++ const sync=async()=>{const {rows}=await db.query('SELECT subject_id,display_name,bio,website,visible,confirmed_at FROM directory_people WHERE visible ORDER BY updated_at DESC LIMIT 10000');entries.set('people',rows.map(publicEntry));};++ await sync();++ const get=async subject=>{const {rows}=await db.query('SELECT subject_id,display_name,bio,website,visible,confirmed_at FROM directory_people WHERE subject_id=$1',[subject]);return rows[0]||null;};++ const put=async(subject,data)=>{if(!SUBJECT.test(subject))throw Error('invalid subject');const input=validate(data);if(!input)return null;const {rows}=await db.query(`INSERT INTO directory_people(subject_id,display_name,bio,website,visible,confirmed_at,updated_at) VALUES($1,$2,$3,$4,$5,now(),now()) ON CONFLICT(subject_id) DO UPDATE SET display_name=EXCLUDED.display_name,bio=EXCLUDED.bio,website=EXCLUDED.website,visible=EXCLUDED.visible,confirmed_at=now(),updated_at=now() RETURNING subject_id,display_name,bio,website,visible,confirmed_at`,[subject,input.display_name,input.bio,input.website,input.visible]);await sync();return rows[0];};++ const remove=async subject=>{if(!SUBJECT.test(subject))throw Error('invalid subject');await db.query('DELETE FROM directory_people WHERE subject_id=$1',[subject]);await sync();};++ return {get,put,remove,close:()=>db.end()};++}++function createAuth(env=process.env){if(!env.OV_OAUTH_CLIENT_ID||!env.OV_OAUTH_CLIENT_SECRET)return null;return createSsoClient({site:'directory',siteName:'OpenVibe.Directory',baseUrl:env.BASE_URL||'https://openvibe.directory',clientId:env.OV_OAUTH_CLIENT_ID,clientSecret:env.OV_OAUTH_CLIENT_SECRET,networkUrl:env.OV_NETWORK_URL||'https://openvibe.network',networkInternalUrl:env.OV_NETWORK_INTERNAL_URL||'http://127.0.0.1:4000',secureCookies:env.NODE_ENV==='production'});}++function writeGuard(req,res,next){const origin=req.get('Origin');const own=(process.env.BASE_URL||'https://openvibe.directory').replace(/\/$/,'');const bearer=/^Bearer\s+\S+$/i.test(req.get('Authorization')||'');if(origin!==own&&!(!origin&&bearer))return res.status(403).json({error:'origin.invalid'});if(!req.is('application/json')&&req.method!=='DELETE')return res.status(415).json({error:'content_type.invalid'});next();}++module.exports={createPeopleStore,createAuth,validate,publicEntry,writeGuard};@@@ -7,8 +7,9 @@ const json=v=>JSON.stringify(v).replace(/</g,'\\u003c');const entryPath=e=>`/entries/${encodeURIComponent(e.kind)}/${encodeURIComponent(e.slug)}`;function page(title,body,path='/',ld=[]){return shell.page({name:'OpenVibe.Directory',siteName:'OpenVibe.Directory',title,description:'Source-linked directories that keep themselves current.',canonical:`https://openvibe.directory${path}`,robots:'index, follow',jsonLd:ld,navbar:{service:'directory',apiBase:'https://openvibe.network',links:[{label:'Directories',href:'/'}]},footer:{service:'directory',variant:'full',mount:'#ov-footer'},head:`<link rel="stylesheet" href="${serve.url('kit.css')}">`,body:`<div id="navbar-mount"></div><main id="main" class="ovk-container">${body}</main>`});}function card(e){return `<article class="ovk-card"><h2><a href="${entryPath(e)}">${esc(e.name)}</a></h2><p>${esc(e.description)}</p><p><small>Checked <time datetime="${esc(e.last_checked)}">${esc(e.last_checked.slice(0,10))}</time></small></p></article>`;}--function home(kinds,counts,entries,q=''){return `${kit.head({title:'Directory',sub:'Listings with sources and a last checked date.',icon:'directory',sticky:false})}<form action="/search"><label>Search <input type="search" name="q" value="${esc(q)}"></label> <button class="ovk-btn ovk-btn--primary">Search</button></form><ul>${Object.entries(kinds).map(([id,name])=>`<li><a href="/kinds/${id}">${esc(name)}</a> (${counts[id]||0})</li>`).join('')}</ul><h2>Recently checked</h2>${entries.slice(0,20).map(card).join('')}`;}++function home(kinds,counts,entries,q=''){return `${kit.head({title:'Directory',sub:'Listings with sources and a last checked date.',icon:'directory',sticky:false})}<form action="/search"><label>Search <input type="search" name="q" value="${esc(q)}"></label> <button class="ovk-btn ovk-btn--primary">Search</button></form><p><a href="/people/me">Claim or manage your opt-in listing</a></p><ul>${Object.entries(kinds).map(([id,name])=>`<li><a href="/kinds/${id}">${esc(name)}</a> (${counts[id]||0})</li>`).join('')}</ul><h2>Recently checked</h2>${entries.slice(0,20).map(card).join('')}`;}function listing(kind,name,entries,q=''){return `${kit.head({title:name,sub:`${entries.length} listings`,icon:'directory',sticky:false})}<form action="/search"><label>Search <input type="search" name="q" value="${esc(q)}"></label><input type="hidden" name="kind" value="${esc(kind)}"> <button class="ovk-btn ovk-btn--quiet">Search</button></form>${entries.length?entries.map(card).join(''):'<p>No listings available yet. The source will be checked again on the next refresh.</p>'}`;}function detail(e){return `${kit.head({title:e.name,sub:e.description,icon:'directory',sticky:false})}<p>Directory: <a href="/kinds/${esc(e.kind)}">${esc(e.kind)}</a></p><p>Last checked: <time datetime="${esc(e.last_checked)}">${esc(e.last_checked)}</time></p>${e.website?`<p><a class="ovk-btn ovk-btn--primary" href="${esc(e.website)}">Visit website</a></p>`:''}${e.phone?`<p>Business-published phone: ${esc(e.phone)}</p>`:''}<h2>Sources</h2><ul>${e.sources.map(s=>`<li><a href="${esc(s.url)}">${esc(s.url)}</a> · checked ${esc(s.checked_at)}</li>`).join('')}</ul>${e.osm_attribution?`<p>${esc(e.osm_attribution)} · <a href="https://www.openstreetmap.org/copyright">Licence and attribution</a></p>`:''}<p><a href="/api/v1/entries/${esc(e.kind)}/${encodeURIComponent(e.slug)}">JSON data</a></p>`;}function markdown(e){return `# ${e.name}\n\n${e.description}\n\nKind: ${e.kind}\nLast checked: ${e.last_checked}\n${e.website?`Website: ${e.website}\n`:''}${e.phone?`Business-published phone: ${e.phone}\n`:''}\nSources:\n${e.sources.map(s=>`- ${s.url} (checked ${s.checked_at})`).join('\n')}\n`;}--module.exports={page,card,home,listing,detail,markdown,esc,json,entryPath};++function peopleForm(row){return `${kit.head({title:'Your listing',sub:'Choose what to publish in the opt-in people directory.',icon:'directory',sticky:false})}<form id="people-form"><label>Display name <input name="display_name" maxlength="100" required value="${esc(row?.display_name||'')}"></label><label>About <textarea name="bio" maxlength="500">${esc(row?.bio||'')}</textarea></label><label>Website <input name="website" type="url" value="${esc(row?.website||'')}"></label><label><input name="visible" type="checkbox" ${row?.visible?'checked':''}> Publish my listing</label><p>Only your display name, about text and website appear publicly. You can unpublish or delete this entry at any time.</p><button class="ovk-btn ovk-btn--primary" type="submit">Save listing</button> <button class="ovk-btn ovk-btn--quiet" type="button" id="delete-listing">Delete listing</button></form><p id="people-result" role="status"></p><script src="/people.js" defer></script>`;}++module.exports={page,card,home,listing,detail,markdown,peopleForm,esc,json,entryPath};@@@ -17,5 +17,5 @@ test('entry page emits structured data and escapes source text',async()=>{store.state.entries.set('sites',[{kind:'sites',id:'x',slug:'x',name:'<bad>',description:'Test',website:'https://example.com/',last_checked:'2026-11-10T00:00:00.000Z',sources:[{url:'https://example.com/',checked_at:'2026-11-10T00:00:00.000Z'}]}]);const server=createApp({store}).listen(0,'127.0.0.1');await new Promise(resolve=>server.once('listening',resolve));-- try{const r=await fetch(`http://127.0.0.1:${server.address().port}/entries/sites/x`);const html=await r.text();assert.equal(r.status,200);assert.match(html,/schema.org/);assert.ok(!html.includes('<bad>'));}finally{server.close();}++ try{const base=`http://127.0.0.1:${server.address().port}`;const r=await fetch(base+'/entries/sites/x');const html=await r.text();assert.equal(r.status,200);assert.match(html,/schema.org/);assert.ok(!html.includes('<bad>'));const release=await fetch(base+'/release.json').then(x=>x.json());assert.equal(release.service,'directory');}finally{server.close();}});nnew file mode 100644@@@ -0,0 +1,36 @@++'use strict';++const {test}=require('node:test');++const assert=require('node:assert/strict');++const {validate,publicEntry,createPeopleStore}=require('../server/people');++const {createStore}=require('../server/sources');++const {createApp}=require('../server/app');++test('opt-in fields exclude private phone, age and city, and default unlisted',()=>{++ assert.equal(validate({display_name:'Ada',visible:false,phone:'123',age:42,city:'SF'}).visible,false);++ assert.equal(validate({display_name:'Ada',visible:'yes'}),null);++ assert.equal(validate({display_name:'<script>',visible:true}),null);++ const e=publicEntry({subject_id:'usr_12345678',display_name:'Ada',bio:'Hello',website:null,confirmed_at:new Date('2026-11-10T00:00:00Z'),phone:'123',age:42,city:'SF'});++ assert.equal(e.phone,undefined);assert.equal(e.age,undefined);assert.equal(e.city,undefined);++});++test('migration uses owner connection while listing reads use runtime connection',async()=>{++ const ownerCalls=[],runtimeCalls=[];++ const migrationPool={query:async sql=>{ownerCalls.push(sql);return {rows:[]};}};++ const pool={query:async sql=>{runtimeCalls.push(sql);return {rows:[]};},end:async()=>{}};++ const people=await createPeopleStore({pool,migrationPool,entries:new Map()});++ assert.equal(ownerCalls.length,1);assert.match(ownerCalls[0],/CREATE TABLE/);++ assert.equal(runtimeCalls.length,1);assert.match(runtimeCalls[0],/^SELECT /);++ await people.close();++});++test('only the signed-in subject can write and unpublished entries stay out of search',async()=>{++ const store=createStore({fetchImpl:async()=>({ok:false,status:503})});store.start=()=>{};++ const rows=new Map();++ const peopleStore={get:async s=>rows.get(s)||null,put:async(s,b)=>{const valid=validate(b);if(!valid)return null;const row={subject_id:s,...valid,confirmed_at:new Date()};rows.set(s,row);store.state.entries.set('people',[...rows.values()].filter(r=>r.visible).map(publicEntry));return row;},remove:async s=>{rows.delete(s);store.state.entries.set('people',[...rows.values()].filter(r=>r.visible).map(publicEntry));}};++ const auth={router:()=>((_q,_r,next)=>next()),optionalAuth:()=>((q,_r,next)=>{q.user={subject_id:'usr_12345678'};next();}),requireAuth:()=>((q,r,next)=>{if(q.get('Authorization')==='Bearer valid'){q.user={subject_id:'usr_12345678'};next();}else r.sendStatus(401);})};++ const server=createApp({store,peopleStore,auth}).listen(0,'127.0.0.1');await new Promise(resolve=>server.once('listening',resolve));const base=`http://127.0.0.1:${server.address().port}`;++ try{++ let r=await fetch(base+'/api/v1/people/me',{method:'PUT',headers:{'Content-Type':'application/json','Origin':'https://openvibe.directory'},body:JSON.stringify({display_name:'Ada',visible:true})});assert.equal(r.status,401);++ r=await fetch(base+'/api/v1/people/me',{method:'PUT',headers:{'Content-Type':'application/json','Authorization':'Bearer valid','Origin':'https://evil.example'},body:JSON.stringify({display_name:'Ada',visible:true})});assert.equal(r.status,403);++ r=await fetch(base+'/api/v1/people/me',{method:'PUT',headers:{'Content-Type':'application/json','Authorization':'Bearer valid','Origin':'https://openvibe.directory'},body:JSON.stringify({display_name:'Ada',visible:false})});assert.equal(r.status,200);assert.equal(store.list('people').length,0);++ r=await fetch(base+'/api/v1/people/me',{method:'PUT',headers:{'Content-Type':'application/json','Authorization':'Bearer valid','Origin':'https://openvibe.directory'},body:JSON.stringify({display_name:'Ada',visible:true})});assert.equal(r.status,200);assert.equal(store.list('people').length,1);++ r=await fetch(base+'/api/v1/people/me',{method:'DELETE',headers:{'Authorization':'Bearer valid','Origin':'https://openvibe.directory'}});assert.equal(r.status,204);assert.equal(store.list('people').length,0);++ }finally{server.close();}++});
@@ -1,2 +1,8 @@
@@ -1,2 +1,8 @@
PORT=5281
PORT=5281
BASE_URL=https://openvibe.directory
BASE_URL=https://openvibe.directory
+DATABASE_URL=
+DATABASE_DIRECT_URL=
+OV_OAUTH_CLIENT_ID=
+OV_OAUTH_CLIENT_SECRET=
+OV_NETWORK_URL=https://openvibe.network
+OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
@@ -2,6 +2,8 @@
@@ -2,6 +2,8 @@
Source-linked directories for OpenVibe sites and tools, AI models and providers, open-source projects, and local businesses from OpenStreetMap. Every entry has a source and a last checked date. Public sources are refreshed at startup and every six hours; a failed source keeps its last successful in-memory snapshot and exposes the failure in `/api/v1/kinds`.
Source-linked directories for OpenVibe sites and tools, AI models and providers, open-source projects, and local businesses from OpenStreetMap. Every entry has a source and a last checked date. Public sources are refreshed at startup and every six hours; a failed source keeps its last successful in-memory snapshot and exposes the failure in `/api/v1/kinds`.
-Business listings are a bounded San Francisco pilot sourced from OpenStreetMap under ODbL 1.0. Unverified OpenStreetMap phone tags are omitted; a business number needs a business-owned source before publication. Private-person searches and reverse lookups are excluded. The people directory has no imported entries and remains empty until the opt-in claim flow is available.
+Business listings are a bounded San Francisco pilot sourced from OpenStreetMap under ODbL 1.0. Unverified OpenStreetMap phone tags are omitted; a business number needs a business-owned source before publication. Private-person searches and reverse lookups are excluded. People can sign in to claim, edit, unpublish, and delete only their own entry; an entry is private until they explicitly publish it. The form offers no age, city, or phone fields.
Run `npm ci`, then `node server/index.js`. Check `/api/v1/kinds`, `/api/v1/entries`, `/sitemap.xml`, and `/llms.txt`.
Run `npm ci`, then `node server/index.js`. Check `/api/v1/kinds`, `/api/v1/entries`, `/sitemap.xml`, and `/llms.txt`.
+
+The opt-in people table is created at startup through `DATABASE_DIRECT_URL` (owner connection). Listing reads and writes use `DATABASE_URL` (runtime connection).
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
+-- phase: expand
+CREATE TABLE IF NOT EXISTS directory_people (
+ subject_id TEXT PRIMARY KEY,
+ display_name TEXT NOT NULL,
+ bio TEXT NOT NULL DEFAULT '',
+ website TEXT,
+ visible BOOLEAN NOT NULL DEFAULT FALSE,
+ confirmed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS directory_people_visible_name ON directory_people (lower(display_name)) WHERE visible;
@@ -1,17 +1,19 @@
@@ -1,17 +1,19 @@
{
{
"name": "openvibe-directory",
"name": "openvibe-directory",
- "version": "0.1.0",
+ "version": "0.1.1",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-directory",
"name": "openvibe-directory",
- "version": "0.1.0",
+ "version": "0.1.1",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"express": "^4.21.2",
"express": "^4.21.2",
"fast-xml-parser": "^5.2.5",
"fast-xml-parser": "^5.2.5",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.202.0"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.202.0",
+ "pg": "^8.23.1"
},
},
"engines": {
"engines": {
"node": ">=22"
"node": ">=22"
@@ -1103,6 +1105,34 @@
@@ -1103,6 +1105,34 @@
"url": "https://opencollective.com/express"
"url": "https://opencollective.com/express"
}
}
},
},
+ "node_modules/openvibe-sdk": {
+ "version": "0.74.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
+ "integrity": "sha512-DO9KVPnUriY/I4lX6ZEJtXaUvRDbJyqnqJxvlpl+zkNGFILpmuBXVJogNOCfK071Qci1gg7C4Iw1Xrseklcajg==",
+ "license": "MIT",
+ "bin": {
+ "openvibe-download": "bin/openvibe-download.js"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "peerDependencies": {
+ "@electric-sql/pglite": "^0.5.8",
+ "iovalkey": "^0.4.0",
+ "pg": "^8.23.0"
+ },
+ "peerDependenciesMeta": {
+ "@electric-sql/pglite": {
+ "optional": true
+ },
+ "iovalkey": {
+ "optional": true
+ },
+ "pg": {
+ "optional": true
+ }
+ }
+ },
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
"version": "4.202.0",
"version": "4.202.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.202.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.202.0",
@@ -1148,6 +1178,134 @@
@@ -1148,6 +1178,134 @@
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/pg": {
+ "version": "8.23.1",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz",
+ "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-connection-string": "^2.14.1",
+ "pg-pool": "^3.14.0",
+ "pg-protocol": "^1.16.1",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.4.1"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz",
+ "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.14.1",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz",
+ "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==",
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-pool": {
+ "version": "3.14.0",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
+ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "pg": ">=8.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.16.1",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz",
+ "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pgpass": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.1.0"
+ }
+ },
+ "node_modules/postgres-array": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/proxy-addr": {
"node_modules/proxy-addr": {
"version": "2.0.8",
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
@@ -1385,6 +1543,15 @@
@@ -1385,6 +1543,15 @@
"source-map": "^0.6.0"
"source-map": "^0.6.0"
}
}
},
},
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
"node_modules/statuses": {
"node_modules/statuses": {
"version": "2.0.2",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -1496,6 +1663,15 @@
@@ -1496,6 +1663,15 @@
"engines": {
"engines": {
"node": ">=16.0.0"
"node": ">=16.0.0"
}
}
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
}
}
}
}
}
}
@@ -1,15 +1,22 @@
@@ -1,15 +1,22 @@
{
{
"name": "openvibe-directory",
"name": "openvibe-directory",
- "version": "0.1.0",
+ "version": "0.1.1",
"private": true,
"private": true,
"description": "Source-linked directories that refresh themselves",
"description": "Source-linked directories that refresh themselves",
"main": "server/index.js",
"main": "server/index.js",
- "scripts": {"start": "node server/index.js", "test": "node --test test/*.test.js"},
- "engines": {"node": ">=22"},
+ "scripts": {
+ "start": "node server/index.js",
+ "test": "node --test test/*.test.js"
+ },
+ "engines": {
+ "node": ">=22"
+ },
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"express": "^4.21.2",
"express": "^4.21.2",
"fast-xml-parser": "^5.2.5",
"fast-xml-parser": "^5.2.5",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.202.0"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.202.0",
+ "pg": "^8.23.1"
}
}
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,16 @@
@@ -0,0 +1,16 @@
+'use strict';
+const form=document.getElementById('people-form');
+const result=document.getElementById('people-result');
+form?.addEventListener('submit',async event=>{
+ event.preventDefault();
+ const data=new FormData(form);
+ const body={display_name:String(data.get('display_name')||''),bio:String(data.get('bio')||''),website:String(data.get('website')||''),visible:data.has('visible')};
+ const response=await fetch('/api/v1/people/me',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
+ result.textContent=response.ok?'Saved. Your visibility choice is now active.':'Could not save this listing.';
+});
+document.getElementById('delete-listing')?.addEventListener('click',async()=>{
+ if(!confirm('Delete your listing?'))return;
+ const response=await fetch('/api/v1/people/me',{method:'DELETE'});
+ result.textContent=response.ok?'Listing deleted.':'Could not delete this listing.';
+ if(response.ok)form.reset();
+});
@@ -3,17 +3,28 @@ const express=require('express');
@@ -3,17 +3,28 @@ const express=require('express');
const path=require('node:path');
const path=require('node:path');
const {createStore,kinds}=require('./sources');
const {createStore,kinds}=require('./sources');
const v=require('./views');
const v=require('./views');
-function createApp({store=createStore()}={}){
+const {createAuth,writeGuard}=require('./people');
+function createApp({store=createStore(),peopleStore=null,auth=createAuth()}={}){
const app=express();app.disable('x-powered-by');
const app=express();app.disable('x-powered-by');
app.use((_q,r,next)=>{r.set('X-Content-Type-Options','nosniff');r.set('Referrer-Policy','strict-origin-when-cross-origin');next();});
app.use((_q,r,next)=>{r.set('X-Content-Type-Options','nosniff');r.set('Referrer-Policy','strict-origin-when-cross-origin');next();});
+ app.use(express.static(path.join(__dirname,'../public')));
app.use('/shared',require('openvibe-shared/serve').handler());
app.use('/shared',require('openvibe-shared/serve').handler());
+ if(auth)app.use('/auth',auth.router(express));
app.get('/api/health',(_q,r)=>r.json({status:'ok',service:'directory'}));
app.get('/api/health',(_q,r)=>r.json({status:'ok',service:'directory'}));
app.get('/api/ready',(_q,r)=>r.json({status:'ok',service:'directory'}));
app.get('/api/ready',(_q,r)=>r.json({status:'ok',service:'directory'}));
- app.get('/release.json',(_q,r)=>r.json(require('openvibe-shared/release').createRelease({service:'directory',root:path.join(__dirname,'..')})));
+ const release=require('openvibe-shared/release').createRelease({service:'directory',root:path.join(__dirname,'..')});
+ app.get('/release.json',release.handler);
const all=()=>store.list();
const all=()=>store.list();
const counts=()=>Object.fromEntries(Object.keys(kinds).map(k=>[k,store.list(k).length]));
const counts=()=>Object.fromEntries(Object.keys(kinds).map(k=>[k,store.list(k).length]));
const find=(kind,slug)=>store.list(kind).find(e=>e.slug===slug);
const find=(kind,slug)=>store.list(kind).find(e=>e.slug===slug);
const search=(q,kind)=>{const terms=String(q||'').toLowerCase().trim().slice(0,120);const rows=kind?store.list(kind):all();return !terms?rows:rows.filter(e=>`${e.name} ${e.description} ${e.kind} ${e.group||''} ${e.provider||''} ${(e.topics||[]).join(' ')}`.toLowerCase().includes(terms));};
const search=(q,kind)=>{const terms=String(q||'').toLowerCase().trim().slice(0,120);const rows=kind?store.list(kind):all();return !terms?rows:rows.filter(e=>`${e.name} ${e.description} ${e.kind} ${e.group||''} ${e.provider||''} ${(e.topics||[]).join(' ')}`.toLowerCase().includes(terms));};
+ const subject=q=>q.user&&typeof q.user.subject_id==='string'&&q.user.subject_id.startsWith('usr_')?q.user.subject_id:null;
+ const signedIn=(q,r,next)=>{if(!auth||!peopleStore)return r.status(503).json({error:'people.unavailable'});return auth.requireAuth()(q,r,()=>subject(q)?next():r.status(403).json({error:'identity.invalid'}));};
+ const handle=fn=>(q,r,next)=>Promise.resolve(fn(q,r)).catch(next);
+ app.get('/people/me',auth?auth.optionalAuth():(_q,_r,next)=>next(),handle(async(q,r)=>{r.set('Cache-Control','private, no-store');r.set('X-Robots-Tag','noindex, nofollow');if(!auth||!peopleStore)return r.status(503).send('People listings are temporarily unavailable.');if(!subject(q))return r.redirect('/auth/login?next=/people/me');const row=await peopleStore.get(subject(q));r.send(v.page('Your listing',v.peopleForm(row),'/people/me'));}));
+ app.get('/api/v1/people/me',signedIn,handle(async(q,r)=>{r.set('Cache-Control','private, no-store');r.json({listing:await peopleStore.get(subject(q))});}));
+ app.put('/api/v1/people/me',signedIn,writeGuard,express.json({limit:'4kb'}),handle(async(q,r)=>{const row=await peopleStore.put(subject(q),q.body);return row?r.json({listing:row}):r.status(400).json({error:'listing.invalid'});}));
+ app.delete('/api/v1/people/me',signedIn,writeGuard,handle(async(q,r)=>{await peopleStore.remove(subject(q));r.status(204).end();}));
app.get('/api/v1/kinds',(_q,r)=>r.json({kinds:Object.entries(kinds).map(([id,name])=>({id,name,count:counts()[id]})),last_checked:store.state.refreshed_at,status:store.state.status}));
app.get('/api/v1/kinds',(_q,r)=>r.json({kinds:Object.entries(kinds).map(([id,name])=>({id,name,count:counts()[id]})),last_checked:store.state.refreshed_at,status:store.state.status}));
app.get('/api/v1/entries',(q,r)=>{const kind=String(q.query.kind||'');if(kind&&!kinds[kind])return r.status(400).json({error:'invalid_kind'});const page=Math.max(1,Math.min(1000,Number(q.query.page)||1)),limit=50;const rows=search(q.query.q,kind);r.json({count:rows.length,page,entries:rows.slice((page-1)*limit,page*limit)});});
app.get('/api/v1/entries',(q,r)=>{const kind=String(q.query.kind||'');if(kind&&!kinds[kind])return r.status(400).json({error:'invalid_kind'});const page=Math.max(1,Math.min(1000,Number(q.query.page)||1)),limit=50;const rows=search(q.query.q,kind);r.json({count:rows.length,page,entries:rows.slice((page-1)*limit,page*limit)});});
app.get('/api/v1/entries/:kind/:slug',(q,r)=>{const e=find(q.params.kind,q.params.slug);return e?r.json(e):r.status(404).json({error:'entry.not_found'});});
app.get('/api/v1/entries/:kind/:slug',(q,r)=>{const e=find(q.params.kind,q.params.slug);return e?r.json(e):r.status(404).json({error:'entry.not_found'});});
@@ -23,7 +34,7 @@ function createApp({store=createStore()}={}){
@@ -23,7 +34,7 @@ function createApp({store=createStore()}={}){
app.get('/',(q,r)=>r.send(v.page('Directories',v.home(kinds,counts(),all()),'/',[{'@context':'https://schema.org','@type':'WebSite',name:'OpenVibe.Directory',url:'https://openvibe.directory/',potentialAction:{'@type':'SearchAction',target:'https://openvibe.directory/search?q={search_term_string}','query-input':'required name=search_term_string'}}])));
app.get('/',(q,r)=>r.send(v.page('Directories',v.home(kinds,counts(),all()),'/',[{'@context':'https://schema.org','@type':'WebSite',name:'OpenVibe.Directory',url:'https://openvibe.directory/',potentialAction:{'@type':'SearchAction',target:'https://openvibe.directory/search?q={search_term_string}','query-input':'required name=search_term_string'}}])));
app.get('/search',(q,r)=>{const kind=String(q.query.kind||'');if(kind&&!kinds[kind])return r.sendStatus(404);const term=String(q.query.q||'').slice(0,120);const rows=search(term,kind).slice(0,200);r.send(v.page('Search directory',v.listing(kind||'all','Search directory',rows,term),`/search?q=${encodeURIComponent(term)}`));});
app.get('/search',(q,r)=>{const kind=String(q.query.kind||'');if(kind&&!kinds[kind])return r.sendStatus(404);const term=String(q.query.q||'').slice(0,120);const rows=search(term,kind).slice(0,200);r.send(v.page('Search directory',v.listing(kind||'all','Search directory',rows,term),`/search?q=${encodeURIComponent(term)}`));});
app.get('/kinds/:kind',(q,r)=>{const kind=q.params.kind;if(!kinds[kind])return r.sendStatus(404);r.send(v.page(kinds[kind],v.listing(kind,kinds[kind],store.list(kind)),q.path));});
app.get('/kinds/:kind',(q,r)=>{const kind=q.params.kind;if(!kinds[kind])return r.sendStatus(404);r.send(v.page(kinds[kind],v.listing(kind,kinds[kind],store.list(kind)),q.path));});
- app.get('/entries/:kind/:slug',(q,r)=>{const e=find(q.params.kind,q.params.slug);if(!e)return r.sendStatus(404);r.vary('Accept');if(q.accepts(['html','text/markdown'])==='text/markdown')return r.type('text/markdown').send(v.markdown(e));r.send(v.page(e.name,v.detail(e),q.path,[{'@context':'https://schema.org','@type':e.kind==='businesses'?'LocalBusiness':'Thing',name:e.name,description:e.description,url:`https://openvibe.directory${q.path}`,dateModified:e.last_checked,sameAs:e.sources.map(s=>s.url)}]));});
+ app.get('/entries/:kind/:slug',(q,r)=>{const e=find(q.params.kind,q.params.slug);if(!e)return r.sendStatus(404);r.vary('Accept');if(q.accepts(['html','text/markdown'])==='text/markdown')return r.type('text/markdown').send(v.markdown(e));r.send(v.page(e.name,v.detail(e),q.path,[{'@context':'https://schema.org','@type':e.kind==='businesses'?'LocalBusiness':e.kind==='people'?'Person':'Thing',name:e.name,description:e.description,url:`https://openvibe.directory${q.path}`,dateModified:e.last_checked,sameAs:e.sources.map(s=>s.url)}]));});
app.use((_q,r)=>r.status(404).send('Not found'));
app.use((_q,r)=>r.status(404).send('Not found'));
store.start();
store.start();
return app;
return app;
@@ -1,5 +1,12 @@
@@ -1,5 +1,12 @@
'use strict';
'use strict';
const {createApp} = require('./app');
const {createApp} = require('./app');
+const {createStore}=require('./sources');
+const {createPeopleStore}=require('./people');
const port = Number(process.env.PORT || 5281);
const port = Number(process.env.PORT || 5281);
-const server=createApp().listen(port, '127.0.0.1', () => process.stdout.write(`directory listening on ${port}\n`));
-process.on('SIGTERM',()=>server.close(()=>process.exit(0)));
+async function main(){
+ const store=createStore();
+ const peopleStore=process.env.DATABASE_URL?await createPeopleStore({entries:store.state.entries}):null;
+ const server=createApp({store,peopleStore}).listen(port, '127.0.0.1', () => process.stdout.write(`directory listening on ${port}\n`));
+ process.on('SIGTERM',()=>server.close(async()=>{store.stop();await peopleStore?.close();process.exit(0);}));
+}
+main().catch(err=>{console.error('directory startup failed:',err.message);process.exitCode=1;});
new file mode 100644
new file mode 100644
@@ -0,0 +1,34 @@
@@ -0,0 +1,34 @@
+'use strict';
+const fs=require('node:fs');
+const path=require('node:path');
+const {Pool}=require('pg');
+const {createSsoClient}=require('openvibe-sdk/sso');
+const SUBJECT=/^usr_[A-Za-z0-9_-]{8,100}$/;
+const nameRe=/^[^<>\r\n]{1,100}$/;
+const websiteOf=value=>{if(!value)return null;try{const u=new URL(value);return u.protocol==='https:'&&u.username===''&&u.password===''?u.href:null;}catch{return null;}};
+function validate(input){
+ if(!input||typeof input!=='object'||Array.isArray(input)||!nameRe.test(String(input.display_name||'')))return null;
+ const display_name=String(input.display_name).trim();if(!display_name)return null;
+ const bio=String(input.bio||'').trim();if(bio.length>500||/[<>]/.test(bio))return null;
+ const website=websiteOf(input.website);if(input.website&&!website)return null;
+ if(typeof input.visible!=='boolean')return null;
+ return {display_name,bio,website,visible:input.visible};
+}
+function publicEntry(row){const id=row.subject_id;const checked=new Date(row.confirmed_at).toISOString();return {kind:'people',id,slug:id,name:row.display_name,description:row.bio,website:row.website,sources:[{url:`https://openvibe.directory/entries/people/${encodeURIComponent(id)}`,checked_at:checked,label:'Owner confirmed'}],last_checked:checked};}
+async function createPeopleStore({connectionString=process.env.DATABASE_URL,directConnectionString=process.env.DATABASE_DIRECT_URL,pool,migrationPool,entries}={}){
+ if(!pool&&!connectionString)throw Error('DATABASE_URL required for people listings');
+ const db=pool||new Pool({connectionString,max:4});
+ const migration=fs.readFileSync(path.join(__dirname,'../migrations/0001_people.sql'),'utf8');
+ if(!migrationPool&&!directConnectionString)throw Error('DATABASE_DIRECT_URL required for migrations');
+ const owner=migrationPool||new Pool({connectionString:directConnectionString,max:1});
+ try{await owner.query(migration);}finally{if(!migrationPool)await owner.end();}
+ const sync=async()=>{const {rows}=await db.query('SELECT subject_id,display_name,bio,website,visible,confirmed_at FROM directory_people WHERE visible ORDER BY updated_at DESC LIMIT 10000');entries.set('people',rows.map(publicEntry));};
+ await sync();
+ const get=async subject=>{const {rows}=await db.query('SELECT subject_id,display_name,bio,website,visible,confirmed_at FROM directory_people WHERE subject_id=$1',[subject]);return rows[0]||null;};
+ const put=async(subject,data)=>{if(!SUBJECT.test(subject))throw Error('invalid subject');const input=validate(data);if(!input)return null;const {rows}=await db.query(`INSERT INTO directory_people(subject_id,display_name,bio,website,visible,confirmed_at,updated_at) VALUES($1,$2,$3,$4,$5,now(),now()) ON CONFLICT(subject_id) DO UPDATE SET display_name=EXCLUDED.display_name,bio=EXCLUDED.bio,website=EXCLUDED.website,visible=EXCLUDED.visible,confirmed_at=now(),updated_at=now() RETURNING subject_id,display_name,bio,website,visible,confirmed_at`,[subject,input.display_name,input.bio,input.website,input.visible]);await sync();return rows[0];};
+ const remove=async subject=>{if(!SUBJECT.test(subject))throw Error('invalid subject');await db.query('DELETE FROM directory_people WHERE subject_id=$1',[subject]);await sync();};
+ return {get,put,remove,close:()=>db.end()};
+}
+function createAuth(env=process.env){if(!env.OV_OAUTH_CLIENT_ID||!env.OV_OAUTH_CLIENT_SECRET)return null;return createSsoClient({site:'directory',siteName:'OpenVibe.Directory',baseUrl:env.BASE_URL||'https://openvibe.directory',clientId:env.OV_OAUTH_CLIENT_ID,clientSecret:env.OV_OAUTH_CLIENT_SECRET,networkUrl:env.OV_NETWORK_URL||'https://openvibe.network',networkInternalUrl:env.OV_NETWORK_INTERNAL_URL||'http://127.0.0.1:4000',secureCookies:env.NODE_ENV==='production'});}
+function writeGuard(req,res,next){const origin=req.get('Origin');const own=(process.env.BASE_URL||'https://openvibe.directory').replace(/\/$/,'');const bearer=/^Bearer\s+\S+$/i.test(req.get('Authorization')||'');if(origin!==own&&!(!origin&&bearer))return res.status(403).json({error:'origin.invalid'});if(!req.is('application/json')&&req.method!=='DELETE')return res.status(415).json({error:'content_type.invalid'});next();}
+module.exports={createPeopleStore,createAuth,validate,publicEntry,writeGuard};
@@ -7,8 +7,9 @@ const json=v=>JSON.stringify(v).replace(/</g,'\\u003c');
@@ -7,8 +7,9 @@ const json=v=>JSON.stringify(v).replace(/</g,'\\u003c');
const entryPath=e=>`/entries/${encodeURIComponent(e.kind)}/${encodeURIComponent(e.slug)}`;
const entryPath=e=>`/entries/${encodeURIComponent(e.kind)}/${encodeURIComponent(e.slug)}`;
function page(title,body,path='/',ld=[]){return shell.page({name:'OpenVibe.Directory',siteName:'OpenVibe.Directory',title,description:'Source-linked directories that keep themselves current.',canonical:`https://openvibe.directory${path}`,robots:'index, follow',jsonLd:ld,navbar:{service:'directory',apiBase:'https://openvibe.network',links:[{label:'Directories',href:'/'}]},footer:{service:'directory',variant:'full',mount:'#ov-footer'},head:`<link rel="stylesheet" href="${serve.url('kit.css')}">`,body:`<div id="navbar-mount"></div><main id="main" class="ovk-container">${body}</main>`});}
function page(title,body,path='/',ld=[]){return shell.page({name:'OpenVibe.Directory',siteName:'OpenVibe.Directory',title,description:'Source-linked directories that keep themselves current.',canonical:`https://openvibe.directory${path}`,robots:'index, follow',jsonLd:ld,navbar:{service:'directory',apiBase:'https://openvibe.network',links:[{label:'Directories',href:'/'}]},footer:{service:'directory',variant:'full',mount:'#ov-footer'},head:`<link rel="stylesheet" href="${serve.url('kit.css')}">`,body:`<div id="navbar-mount"></div><main id="main" class="ovk-container">${body}</main>`});}
function card(e){return `<article class="ovk-card"><h2><a href="${entryPath(e)}">${esc(e.name)}</a></h2><p>${esc(e.description)}</p><p><small>Checked <time datetime="${esc(e.last_checked)}">${esc(e.last_checked.slice(0,10))}</time></small></p></article>`;}
function card(e){return `<article class="ovk-card"><h2><a href="${entryPath(e)}">${esc(e.name)}</a></h2><p>${esc(e.description)}</p><p><small>Checked <time datetime="${esc(e.last_checked)}">${esc(e.last_checked.slice(0,10))}</time></small></p></article>`;}
-function home(kinds,counts,entries,q=''){return `${kit.head({title:'Directory',sub:'Listings with sources and a last checked date.',icon:'directory',sticky:false})}<form action="/search"><label>Search <input type="search" name="q" value="${esc(q)}"></label> <button class="ovk-btn ovk-btn--primary">Search</button></form><ul>${Object.entries(kinds).map(([id,name])=>`<li><a href="/kinds/${id}">${esc(name)}</a> (${counts[id]||0})</li>`).join('')}</ul><h2>Recently checked</h2>${entries.slice(0,20).map(card).join('')}`;}
+function home(kinds,counts,entries,q=''){return `${kit.head({title:'Directory',sub:'Listings with sources and a last checked date.',icon:'directory',sticky:false})}<form action="/search"><label>Search <input type="search" name="q" value="${esc(q)}"></label> <button class="ovk-btn ovk-btn--primary">Search</button></form><p><a href="/people/me">Claim or manage your opt-in listing</a></p><ul>${Object.entries(kinds).map(([id,name])=>`<li><a href="/kinds/${id}">${esc(name)}</a> (${counts[id]||0})</li>`).join('')}</ul><h2>Recently checked</h2>${entries.slice(0,20).map(card).join('')}`;}
function listing(kind,name,entries,q=''){return `${kit.head({title:name,sub:`${entries.length} listings`,icon:'directory',sticky:false})}<form action="/search"><label>Search <input type="search" name="q" value="${esc(q)}"></label><input type="hidden" name="kind" value="${esc(kind)}"> <button class="ovk-btn ovk-btn--quiet">Search</button></form>${entries.length?entries.map(card).join(''):'<p>No listings available yet. The source will be checked again on the next refresh.</p>'}`;}
function listing(kind,name,entries,q=''){return `${kit.head({title:name,sub:`${entries.length} listings`,icon:'directory',sticky:false})}<form action="/search"><label>Search <input type="search" name="q" value="${esc(q)}"></label><input type="hidden" name="kind" value="${esc(kind)}"> <button class="ovk-btn ovk-btn--quiet">Search</button></form>${entries.length?entries.map(card).join(''):'<p>No listings available yet. The source will be checked again on the next refresh.</p>'}`;}
function detail(e){return `${kit.head({title:e.name,sub:e.description,icon:'directory',sticky:false})}<p>Directory: <a href="/kinds/${esc(e.kind)}">${esc(e.kind)}</a></p><p>Last checked: <time datetime="${esc(e.last_checked)}">${esc(e.last_checked)}</time></p>${e.website?`<p><a class="ovk-btn ovk-btn--primary" href="${esc(e.website)}">Visit website</a></p>`:''}${e.phone?`<p>Business-published phone: ${esc(e.phone)}</p>`:''}<h2>Sources</h2><ul>${e.sources.map(s=>`<li><a href="${esc(s.url)}">${esc(s.url)}</a> · checked ${esc(s.checked_at)}</li>`).join('')}</ul>${e.osm_attribution?`<p>${esc(e.osm_attribution)} · <a href="https://www.openstreetmap.org/copyright">Licence and attribution</a></p>`:''}<p><a href="/api/v1/entries/${esc(e.kind)}/${encodeURIComponent(e.slug)}">JSON data</a></p>`;}
function detail(e){return `${kit.head({title:e.name,sub:e.description,icon:'directory',sticky:false})}<p>Directory: <a href="/kinds/${esc(e.kind)}">${esc(e.kind)}</a></p><p>Last checked: <time datetime="${esc(e.last_checked)}">${esc(e.last_checked)}</time></p>${e.website?`<p><a class="ovk-btn ovk-btn--primary" href="${esc(e.website)}">Visit website</a></p>`:''}${e.phone?`<p>Business-published phone: ${esc(e.phone)}</p>`:''}<h2>Sources</h2><ul>${e.sources.map(s=>`<li><a href="${esc(s.url)}">${esc(s.url)}</a> · checked ${esc(s.checked_at)}</li>`).join('')}</ul>${e.osm_attribution?`<p>${esc(e.osm_attribution)} · <a href="https://www.openstreetmap.org/copyright">Licence and attribution</a></p>`:''}<p><a href="/api/v1/entries/${esc(e.kind)}/${encodeURIComponent(e.slug)}">JSON data</a></p>`;}
function markdown(e){return `# ${e.name}\n\n${e.description}\n\nKind: ${e.kind}\nLast checked: ${e.last_checked}\n${e.website?`Website: ${e.website}\n`:''}${e.phone?`Business-published phone: ${e.phone}\n`:''}\nSources:\n${e.sources.map(s=>`- ${s.url} (checked ${s.checked_at})`).join('\n')}\n`;}
function markdown(e){return `# ${e.name}\n\n${e.description}\n\nKind: ${e.kind}\nLast checked: ${e.last_checked}\n${e.website?`Website: ${e.website}\n`:''}${e.phone?`Business-published phone: ${e.phone}\n`:''}\nSources:\n${e.sources.map(s=>`- ${s.url} (checked ${s.checked_at})`).join('\n')}\n`;}
-module.exports={page,card,home,listing,detail,markdown,esc,json,entryPath};
+function peopleForm(row){return `${kit.head({title:'Your listing',sub:'Choose what to publish in the opt-in people directory.',icon:'directory',sticky:false})}<form id="people-form"><label>Display name <input name="display_name" maxlength="100" required value="${esc(row?.display_name||'')}"></label><label>About <textarea name="bio" maxlength="500">${esc(row?.bio||'')}</textarea></label><label>Website <input name="website" type="url" value="${esc(row?.website||'')}"></label><label><input name="visible" type="checkbox" ${row?.visible?'checked':''}> Publish my listing</label><p>Only your display name, about text and website appear publicly. You can unpublish or delete this entry at any time.</p><button class="ovk-btn ovk-btn--primary" type="submit">Save listing</button> <button class="ovk-btn ovk-btn--quiet" type="button" id="delete-listing">Delete listing</button></form><p id="people-result" role="status"></p><script src="/people.js" defer></script>`;}
+module.exports={page,card,home,listing,detail,markdown,peopleForm,esc,json,entryPath};
@@ -17,5 +17,5 @@ test('entry page emits structured data and escapes source text',async()=>{
@@ -17,5 +17,5 @@ test('entry page emits structured data and escapes source text',async()=>{
store.state.entries.set('sites',[{kind:'sites',id:'x',slug:'x',name:'<bad>',description:'Test',website:'https://example.com/',last_checked:'2026-11-10T00:00:00.000Z',sources:[{url:'https://example.com/',checked_at:'2026-11-10T00:00:00.000Z'}]}]);
store.state.entries.set('sites',[{kind:'sites',id:'x',slug:'x',name:'<bad>',description:'Test',website:'https://example.com/',last_checked:'2026-11-10T00:00:00.000Z',sources:[{url:'https://example.com/',checked_at:'2026-11-10T00:00:00.000Z'}]}]);
const server=createApp({store}).listen(0,'127.0.0.1');
const server=createApp({store}).listen(0,'127.0.0.1');
await new Promise(resolve=>server.once('listening',resolve));
await new Promise(resolve=>server.once('listening',resolve));
- try{const r=await fetch(`http://127.0.0.1:${server.address().port}/entries/sites/x`);const html=await r.text();assert.equal(r.status,200);assert.match(html,/schema.org/);assert.ok(!html.includes('<bad>'));}finally{server.close();}
+ try{const base=`http://127.0.0.1:${server.address().port}`;const r=await fetch(base+'/entries/sites/x');const html=await r.text();assert.equal(r.status,200);assert.match(html,/schema.org/);assert.ok(!html.includes('<bad>'));const release=await fetch(base+'/release.json').then(x=>x.json());assert.equal(release.service,'directory');}finally{server.close();}
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
+'use strict';
+const {test}=require('node:test');
+const assert=require('node:assert/strict');
+const {validate,publicEntry,createPeopleStore}=require('../server/people');
+const {createStore}=require('../server/sources');
+const {createApp}=require('../server/app');
+test('opt-in fields exclude private phone, age and city, and default unlisted',()=>{
+ assert.equal(validate({display_name:'Ada',visible:false,phone:'123',age:42,city:'SF'}).visible,false);
+ assert.equal(validate({display_name:'Ada',visible:'yes'}),null);
+ assert.equal(validate({display_name:'<script>',visible:true}),null);
+ const e=publicEntry({subject_id:'usr_12345678',display_name:'Ada',bio:'Hello',website:null,confirmed_at:new Date('2026-11-10T00:00:00Z'),phone:'123',age:42,city:'SF'});
+ assert.equal(e.phone,undefined);assert.equal(e.age,undefined);assert.equal(e.city,undefined);
+});
+test('migration uses owner connection while listing reads use runtime connection',async()=>{
+ const ownerCalls=[],runtimeCalls=[];
+ const migrationPool={query:async sql=>{ownerCalls.push(sql);return {rows:[]};}};
+ const pool={query:async sql=>{runtimeCalls.push(sql);return {rows:[]};},end:async()=>{}};
+ const people=await createPeopleStore({pool,migrationPool,entries:new Map()});
+ assert.equal(ownerCalls.length,1);assert.match(ownerCalls[0],/CREATE TABLE/);
+ assert.equal(runtimeCalls.length,1);assert.match(runtimeCalls[0],/^SELECT /);
+ await people.close();
+});
+test('only the signed-in subject can write and unpublished entries stay out of search',async()=>{
+ const store=createStore({fetchImpl:async()=>({ok:false,status:503})});store.start=()=>{};
+ const rows=new Map();
+ const peopleStore={get:async s=>rows.get(s)||null,put:async(s,b)=>{const valid=validate(b);if(!valid)return null;const row={subject_id:s,...valid,confirmed_at:new Date()};rows.set(s,row);store.state.entries.set('people',[...rows.values()].filter(r=>r.visible).map(publicEntry));return row;},remove:async s=>{rows.delete(s);store.state.entries.set('people',[...rows.values()].filter(r=>r.visible).map(publicEntry));}};
+ const auth={router:()=>((_q,_r,next)=>next()),optionalAuth:()=>((q,_r,next)=>{q.user={subject_id:'usr_12345678'};next();}),requireAuth:()=>((q,r,next)=>{if(q.get('Authorization')==='Bearer valid'){q.user={subject_id:'usr_12345678'};next();}else r.sendStatus(401);})};
+ const server=createApp({store,peopleStore,auth}).listen(0,'127.0.0.1');await new Promise(resolve=>server.once('listening',resolve));const base=`http://127.0.0.1:${server.address().port}`;
+ try{
+ let r=await fetch(base+'/api/v1/people/me',{method:'PUT',headers:{'Content-Type':'application/json','Origin':'https://openvibe.directory'},body:JSON.stringify({display_name:'Ada',visible:true})});assert.equal(r.status,401);
+ r=await fetch(base+'/api/v1/people/me',{method:'PUT',headers:{'Content-Type':'application/json','Authorization':'Bearer valid','Origin':'https://evil.example'},body:JSON.stringify({display_name:'Ada',visible:true})});assert.equal(r.status,403);
+ r=await fetch(base+'/api/v1/people/me',{method:'PUT',headers:{'Content-Type':'application/json','Authorization':'Bearer valid','Origin':'https://openvibe.directory'},body:JSON.stringify({display_name:'Ada',visible:false})});assert.equal(r.status,200);assert.equal(store.list('people').length,0);
+ r=await fetch(base+'/api/v1/people/me',{method:'PUT',headers:{'Content-Type':'application/json','Authorization':'Bearer valid','Origin':'https://openvibe.directory'},body:JSON.stringify({display_name:'Ada',visible:true})});assert.equal(r.status,200);assert.equal(store.list('people').length,1);
+ r=await fetch(base+'/api/v1/people/me',{method:'DELETE',headers:{'Authorization':'Bearer valid','Origin':'https://openvibe.directory'}});assert.equal(r.status,204);assert.equal(store.list('people').length,0);
+ }finally{server.close();}
+});