openvibers/openvibe.rent Commits
Rent: an AI screen for rental scam signs, flagged for staff (#50)
Changes
@@@ -38,6 +38,9 @@ OV_OAUTH_CLIENT_SECRET=# are created at boot (off when unset); RENT_EVENTS_SUBSCRIBE=0 turns that off.RENT_EVENTS_SECRET=RENT_EVENTS_URL=++# OpenVibe.AI for the listing screen (server/listings/screen.js): each posted or edited listing is screened for the signs of a++# rental scam, for staff (needs OV_OAUTH_CLIENT_SECRET too). Unset: off.++#OV_AI_INTERNAL_URL=http://127.0.0.1:4700#RENT_EVENTS_ENDPOINT=http://127.0.0.1:5010/internal/events# optional: defaults to true in production@@@ -44,6 +44,8 @@ Rent's own data is its PostgreSQL database ([server/db.js](server/db.js)); the `([migrations/0002_rent.sql](migrations/0002_rent.sql)).- **`rent_reports`** — one report per person per listing (unique index `rent_reports_once`), what hides a listing atthree. `rpt_<ULID>` ids.++- **`rent_listing_checks`** (0008) — the latest AI screen of each listing (below): the content hash it read, `allow` or++ `review`, the signs and the quoted words, and which member of staff marked it reviewed. It goes with its listing.- **`rent_saved_searches`** — a person's saved filter sets, one per distinct set, with `last_seen_at` for the "newsince you last looked" count. `ssv_<ULID>` ids.- **`account_data_events`** — the receipt of each ADR-033 account export or deletion delivery Rent has applied, so a@@@ -112,6 +114,7 @@ Index master CSV — paced and cached in [server/housing/upstream.js](server/hou| One listing | `/listings/:id` | The listing, its safety note, and a contact link **only for a signed-in viewer** — everyone else is asked to sign in. JSON-LD is published as an `Offer` (price, currency, `priceValidUntil`) only when the kind has a clear schema.org type; `other` gets none. || Reports | [server/listings/store.js](server/listings/store.js), `/staff` | Anyone signed in reports a listing once (reason + note ≤500). **3 distinct reports hide it** from search until staff look; staff restore, hide or remove it, and a listing staff restored is not hidden again by the same reports. || Staff | [server/listings/staff.js](server/listings/staff.js) | Who is staff is the Network **role claim** (`admin`, `global_mod`), decided in one place, exactly as at OpenVibe.Help. The pages and the API cannot drift apart. |++| AI screen | [server/listings/screen.js](server/listings/screen.js), `/staff?state=flagged` | Each listing a person posts or edits is read by OpenVibe.AI's `rent.screen_listing` for the signs of a rental scam (payment before a viewing, wire/gift-card/crypto payment, an owner who cannot show the place, pressure, identity or bank details up front, only a private channel), never with the poster or the contact link. `review` puts it in the staff page's **flagged** queue with the signs and the quoted words; nothing is hidden or refused, and staff decide (**Looks fine**, hide, remove). The same words are never screened twice; a failed run is retried by a sweep every 30 minutes. Off without `OV_AI_INTERNAL_URL` (and the client secret). || Saving a search | `/saved` | Save the current filters (signed in) and see, each time you open the page, how many listings have appeared since you last looked. || Alerts | `/listings.atom`, Get alerts | Every search is an Atom feed with the same filters (the newest 25, cached 5 minutes), linked from the listings page's head. **Get alerts** on the listings page and on each saved search opens OpenVibe.Watch's feed form with that feed filled in, so Watch tells you, or runs your agent, when a listing that fits appears. || Affordable housing | [server/housing/places.js](server/housing/places.js), [upstream.js](server/housing/upstream.js) | `/housing` geocodes a place with Nominatim, then reads six HUD ArcGIS layers — public housing, LIHTC, HUD-assisted multifamily, public housing authorities, housing counselling agencies and HUD homes for sale (`SF_REO`, HUD's March 2026 snapshot, each linked to HUD Homestore to confirm it is still for sale) — normalised, distance-sorted and grouped, with the HUD attribution on the page. |nnew file mode 100644@@@ -0,0 +1,18 @@++-- phase: expand++-- OpenVibe.Rent: the latest AI screen of each listing for the signs of a rental scam (server/listings/screen.js, the++-- `rent.screen_listing` workflow on OpenVibe.AI). Staff only: `review` puts the listing in the staff page's flagged queue++-- with the signs and the quoted words until a member of staff marks it reviewed (or hides or removes it). One row per++-- listing, replaced when what the listing says changes (`content_hash`); it goes with its listing.++CREATE TABLE IF NOT EXISTS rent_listing_checks (++ listing_id text COLLATE "C" PRIMARY KEY REFERENCES rent_listings (id) ON DELETE CASCADE,++ content_hash text COLLATE "C" NOT NULL, -- what a renter reads, hashed (screen.js contentHash)++ action text COLLATE "C" NOT NULL CHECK (action IN ('allow', 'review')),++ signals jsonb NOT NULL DEFAULT '[]'::jsonb, -- the signs named, e.g. ["payment_before_viewing"]++ spans jsonb NOT NULL DEFAULT '[]'::jsonb, -- [{ quote, signal }] the words behind each sign++ rationale text,++ run_id text COLLATE "C", -- the OpenVibe.AI run that answered++ checked_at text COLLATE "C" NOT NULL,++ reviewed_at text COLLATE "C", -- set when staff mark a flagged listing reviewed++ reviewed_by text COLLATE "C" -- user:usr_… of that member of staff++);++CREATE INDEX IF NOT EXISTS rent_listing_checks_flagged ON rent_listing_checks (checked_at DESC) WHERE action = 'review' AND reviewed_at IS NULL;@@@ -19,6 +19,8 @@ const helmet = require('helmet');const cookieParser = require('cookie-parser');const rateLimit = require('express-rate-limit');const contracts = require('openvibe-contracts');++const { createAiClient } = require('openvibe-publishing/ai');++const { createScreen } = require('./listings/screen');const cache = require('openvibe-shared/cache-policy');const { createSearchIndex } = require('./search-index');@@@ -93,6 +95,17 @@ async function createApp(opts = {}) {ctx.accountData = accountData;ctx.accountSend = accountSend;++ // The AI listing screen (server/listings/screen.js): with OV_AI_INTERNAL_URL and a client secret, Rent asks++ // OpenVibe.AI's rent.screen_listing about each posted or edited listing with its own service token (Network grants++ // rent ai.run.* on rent.*); the answer is for staff. Off otherwise. A test injects a stand-in through opts.ai.++ const aiTokenClient = config.aiUrl && config.oauth.clientSecret ? contracts.serviceAuth.createTokenClient({++ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,++ audience: 'openvibe.ai', scope: 'ai.run.create ai.run.read', fetchImpl,++ }) : null;++ const ai = opts.ai !== undefined ? opts.ai : (aiTokenClient ? createAiClient({ baseUrl: config.aiUrl, tokenClient: aiTokenClient, fetch: fetchImpl }) : null);++ ctx.screen = createScreen({ s, ai, log });++ s.screen = ctx.screen;++const app = express();app.disable('x-powered-by');app.set('trust proxy', config.trustProxy);@@@ -86,6 +86,9 @@ function load(env = process.env) {// for rotation, 32+ characters each); unset turns POST /internal/events off (503). The url is where the// network.account.export_requested and network.account.deleted subscriptions are created at boot, off when// unset. Both topics are ADR-033: account export and deletion (server/identity/account-data.js).++ // OpenVibe.AI → the listing screen (server/listings/screen.js): each posted or edited listing is read by the++ // rent.screen_listing workflow for the signs of a rental scam, for staff. Unset: off (nothing is screened).++ aiUrl: trim(env.OV_AI_INTERNAL_URL || ''),events: {secrets: String(env.RENT_EVENTS_SECRET || '').split(',').map((x) => x.trim()).filter(Boolean),url: trim(env.RENT_EVENTS_URL || env.EVENTS_URL || ''),@@@ -785,6 +785,17 @@ ${it.newCount ? html` <span class="badge info">${it.newCount} new</span>` : html});// ── The report queue (staff) ─────────────────────────────++ // The AI screen's signs, in staff's words (server/listings/screen.js names them).++ const SIGN_TEXT = {++ payment_before_viewing: 'payment before a viewing',++ unusual_payment: 'wire, gift card, crypto or a payment app',++ owner_unavailable: 'an owner who cannot show the place',++ pressure: 'pressure to decide or pay',++ personal_data_request: 'identity or bank details up front',++ off_platform_only: 'only a private channel, no calls or visits',++ };++ const listOf = (v) => (Array.isArray(v) ? v : (() => { try { return JSON.parse(v || '[]'); } catch { return []; } })());++r.get('/staff', async (req, res) => {if (!signedIn(req)) return page(req, res, {title: 'Report queue',@@@ -796,9 +807,10 @@ ${it.newCount ? html` <span class="badge info">${it.newCount} new</span>` : htmlcrumbs: [{ label: 'Home', href: '/' }, { label: 'Report queue' }],body: html`<h1>Staff only</h1><p class="muted">The report queue is for OpenVibe staff — a Network role of <code>admin</code> or <code>global_mod</code>. Yours is <code>${truncate(req.viewer.role || 'user', 32)}</code>. <a href="/mine">Your listings</a>.</p>`,}, 403);-- const state = ['hidden', 'active', 'removed', 'all'].includes(String(req.query.state)) ? String(req.query.state) : 'hidden';-- const out = await store.staffQueue(s, { state, limit: 50 });++ const state = ['flagged', 'hidden', 'active', 'removed', 'all'].includes(String(req.query.state)) ? String(req.query.state) : 'hidden';++ const out = state === 'flagged' ? { rows: await store.flaggedQueue(s, { limit: 50 }) } : await store.staffQueue(s, { state, limit: 50 });const counts = await store.staffCounts(s);++ const flagged = await store.countFlagged(s);const items = [];for (const row of out.rows) items.push({ row, reports: await store.reportsFor(s, row.id) });return page(req, res, {@@@ -807,9 +819,10 @@ ${it.newCount ? html` <span class="badge info">${it.newCount} new</span>` : htmlcrumbs: [{ label: 'Home', href: '/' }, { label: 'Report queue' }],body: html`<h1>The report queue</h1><p class="sc-lede">${counts.hidden} hidden by reports, ${counts.active} reported but still active, ${counts.removed} removed. A listing is hidden automatically at ${listings.HIDE_AT_REPORTS} distinct reports.</p>++<p class="small">${flagged ? html`<a href="/staff?state=flagged">${flagged} flagged by the AI screen</a>` : 'Nothing flagged by the AI screen.'} <span class="muted">OpenVibe.AI reads each listing a person posts or edits for the signs of a rental scam; a flag only asks for a look, and the listing stays up until you decide.</span></p><form class="card filters" method="get" action="/staff"><div class="field"><label for="state">Show</label><select id="state" name="state">--${['hidden', 'active', 'removed', 'all'].map((x) => html`<option value="${x}"${x === state ? raw(' selected') : ''}>${x === 'hidden' ? 'hidden (needs a look)' : x}</option>`)}++${['flagged', 'hidden', 'active', 'removed', 'all'].map((x) => html`<option value="${x}"${x === state ? raw(' selected') : ''}>${x === 'hidden' ? 'hidden (needs a look)' : x === 'flagged' ? 'flagged by the AI screen' : x}</option>`)}</select></div><p><button class="sc-btn sc-primary" type="submit">Filter</button> <a class="sc-btn" href="/staff">Clear</a></p></form>@@@ -817,6 +830,10 @@ ${items.length ? items.map((it) => html`<section class="card staff-item"><h2><a href="/listings/${it.row.id}">${truncate(it.row.title, 80)}</a> ${stateBadge(it.row.state)}</h2><p class="small muted">Posted by <code>${it.row.owner}</code> · ${whereOf(it.row)} · ${priceOf(it.row)} · ${it.row.report_count} report${Number(it.row.report_count) === 1 ? '' : 's'}</p>${textBlock(truncate(it.row.description, 400))}++${it.row.check_signals !== undefined ? html`<div class="screen-flag small"><p><b>The AI screen saw:</b> ${listOf(it.row.check_signals).map((x) => SIGN_TEXT[x] || x).join('; ')}</p>++${it.row.check_rationale ? html`<p class="muted">${truncate(it.row.check_rationale, 400)}</p>` : ''}++${listOf(it.row.check_spans).length ? html`<ul>${listOf(it.row.check_spans).map((sp) => html`<li>“${truncate(sp.quote, 200)}”${sp.signal && SIGN_TEXT[sp.signal] ? html` <span class="muted">(${SIGN_TEXT[sp.signal]})</span>` : ''}</li>`)}</ul>` : ''}++<form class="inline" method="post" action="/listings/${it.row.id}/check"><button class="sc-btn" type="submit">Looks fine</button></form></div>` : ''}<ul class="report-list small">${it.reports.map((rp) => html`<li><b>${rp.reason}</b> · <code>${rp.reporter}</code> · ${time(rp.created_at)}${rp.note ? html` — ${rp.note}` : ''}</li>`)}</ul><form class="inline" method="post" action="/listings/${it.row.id}/state"><input type="hidden" name="state" value="active"><button class="sc-btn" type="submit">Restore it</button></form><form class="inline" method="post" action="/listings/${it.row.id}/state"><input type="hidden" name="state" value="hidden"><button class="sc-btn" type="submit">Hide it</button></form>@@@ -843,6 +860,19 @@ ${textBlock(truncate(it.row.description, 400))}return res.redirect(303, '/staff');});++ // A member of staff looked at a listing the AI screen flagged and left it up: it leaves the flagged queue until its++ // words change and the screen flags it again.++ r.post('/listings/:id/check', async (req, res) => {++ if (!signedIn(req) || !staffViewer(req)) return page(req, res, {++ title: 'Staff only',++ crumbs: [{ label: 'Home', href: '/' }, { label: 'Report queue' }],++ body: html`<h1>Staff only</h1><p class="muted">Reviewing a flagged listing is for OpenVibe staff.</p>`,++ }, signedIn(req) ? 403 : 401);++ if (!sameSite(req, res, '/staff?state=flagged')) return;++ if (listings.isListingId(req.params.id)) await store.markCheckReviewed(s, req.params.id, requesterOf(req));++ return res.redirect(303, '/staff?state=flagged');++ });++// ── Safety ───────────────────────────────────────────────r.get('/safety', (req, res) => page(req, res, {index: true, cache: PUBLIC_CACHE,@@@ -15,7 +15,9 @@* Nothing is anonymized: Rent keeps no row by this person that another person's page must still read. A listing is* the poster's own; the reports other people left on it cascade away with it (rent_reports.listing_id REFERENCES* rent_listings(id) ON DELETE CASCADE in migrations/0002_rent.sql), which is right — a report about a listing that no-- * longer exists has nothing left to say. Nothing here is a secret: no token, key or credential is stored, so every++ * longer exists has nothing left to say. So does the AI screen's check of a listing (rent_listing_checks, migration++ * 0008): a staff-only moderation note about the listing, not a record of the person, gone with the listing and not++ * exported. Nothing here is a secret: no token, key or credential is stored, so every* column of the three tables may be exported.*/const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');@@@ -21,7 +21,7 @@ const { startHpiTimer } = require('./housing/hpi');function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }) {return gracefulStop({name: 'OpenVibe.Rent', server, deadlineExitCode: 0, exit, signals, deadlineMs: 10_000,-- close: [() => { for (const t of timers) clearInterval(t); }, () => ctx.keys.client.stop(), () => ctx.search.stop(), async () => { clearInterval(ctx.telemetryTick); if (ctx.retention) await ctx.retention.stop(); if (ctx.telemetry) await ctx.telemetry.stop(); }, () => ctx.s.close(), ...extra],++ close: [() => { for (const t of timers) clearInterval(t); }, () => ctx.keys.client.stop(), () => ctx.search.stop(), () => ctx.screen.stop(), async () => { clearInterval(ctx.telemetryTick); if (ctx.retention) await ctx.retention.stop(); if (ctx.telemetry) await ctx.telemetry.stop(); }, () => ctx.s.close(), ...extra],});}@@@ -41,6 +41,7 @@ async function start() {ctx.telemetryTick.unref();ctx.keys.client.start();ctx.search.start();++ ctx.screen.start(); // the AI listing screen's sweep (off without OpenVibe.AI)// One sweep at boot (a restart must not leave a stale listing up a moment longer than it should), then a timer.const expiry = startExpiryTimer(ctx.s);nnew file mode 100644@@@ -0,0 +1,154 @@++'use strict';++++/**++ * The listing screen (the plan's Rent composition: Sources + Search + Watch + AI): every active listing a person posts++ * or edits is read by OpenVibe.AI's `rent.screen_listing` workflow for the signs of a rental scam — payment before a++ * viewing, wire or gift-card payment, an owner who cannot show the place, pressure, identity documents up front, a++ * conversation pushed off the site. The answer is for staff only: `review` puts the listing in the staff page's++ * "flagged" queue with the signs and the quoted words; nothing is hidden, refused or shown to anyone else, and a person++ * can always post. Staff decide (Looks fine, Hide, Remove) exactly as for a report.++ *++ * Every store write names the listing it touched (store.js calls s.screen.touch(id)); touched listings are screened a++ * moment later, never inside the request. A listing is screened again only when what it says changed (a hash of the++ * words and the price a renter reads), so a renewal or a report never costs a run. A run that fails is logged and++ * retried by the sweep, which also covers listings posted while OpenVibe.AI was unreachable.++ *++ * Off without OV_AI_INTERNAL_URL and a client secret (server/app.js passes no `ai`): then touch() does nothing.++ */++const crypto = require('crypto');++const store = require('./store');++++const FLUSH_MS = 2_000;++const START_DELAY_MS = 90_000;++const INTERVAL_MS = 30 * 60_000;++const SWEEP_LIMIT = 25;++const SIGNALS = ['payment_before_viewing', 'unusual_payment', 'owner_unavailable', 'pressure', 'personal_data_request', 'off_platform_only'];++++/** What a renter reads, hashed: the screen runs again only when this changes. */++function contentHash(row) {++ const parts = [row.kind, row.title, row.description, row.price, row.currency, row.period, row.city, row.region, row.country, row.neighbourhood];++ return crypto.createHash('sha256').update(JSON.stringify(parts.map((v) => (v == null ? null : String(v))))).digest('hex').slice(0, 32);++}++++/** The workflow's input: the listing as a renter sees it (never the poster, never the contact link). */++function screenInput(row) {++ return {++ listing: {++ title: String(row.title || '').slice(0, 200),++ kind: String(row.kind || '').slice(0, 40),++ price: row.price == null ? undefined : Number(row.price),++ currency: row.currency ? String(row.currency).slice(0, 8) : undefined,++ period: row.period ? String(row.period).slice(0, 20) : undefined,++ city: row.city ? String(row.city).slice(0, 120) : undefined,++ country: row.country ? String(row.country).slice(0, 8) : undefined,++ description: String(row.description || '').slice(0, 5000),++ },++ };++}++++/** Bound and validate the workflow's output: unknown signs are dropped; `review` without a sign is `allow`. */++function readOutput(output) {++ const o = output && typeof output === 'object' ? output : {};++ const signals = [...new Set((Array.isArray(o.signals) ? o.signals : []).filter((x) => SIGNALS.includes(x)))];++ const action = o.action === 'review' && signals.length ? 'review' : 'allow';++ const spans = (Array.isArray(o.spans) ? o.spans : []).slice(0, 10)++ .filter((x) => x && typeof x.quote === 'string' && x.quote.trim())++ .map((x) => ({ quote: x.quote.trim().slice(0, 300), signal: SIGNALS.includes(x.signal) ? x.signal : null }));++ const rationale = typeof o.rationale === 'string' ? o.rationale.trim().slice(0, 1000) : '';++ return { action, signals: action === 'review' ? signals : [], spans: action === 'review' ? spans : [], rationale };++}++++function createScreen({ s, ai = null, log = console }) {++ const pending = new Set();++ const timers = [];++ let flushTimer = null;++ let flushing = null;++++ /** Screen one listing now. Returns 'off' | 'gone' | 'inactive' | 'unchanged' | 'allow' | 'review' | 'failed'. */++ async function screenId(id) {++ if (!ai) return 'off';++ const row = await store.get(s, id);++ if (!row) return 'gone';++ if (row.state !== 'active') return 'inactive';++ const hash = contentHash(row);++ const prev = await store.getCheck(s, id);++ if (prev && prev.content_hash === hash) {++ if (prev.checked_at < row.updated_at) await store.recheckUnchanged(s, id);++ return 'unchanged';++ }++ let run;++ try {++ run = await ai.run('rent.screen_listing', screenInput(row), {++ target: { service: 'rent', type: 'listing', id: row.id },++ idempotencyKey: `rent-screen-${row.id}-${hash}`,++ });++ } catch (err) {++ log.warn(`[Screen] listing ${id}: ${(err && err.message) || err}`);++ return 'failed';++ }++ const out = readOutput(run && run.output);++ await store.putCheck(s, { listingId: row.id, contentHash: hash, ...out, runId: (run && run.runId) || null });++ if (out.action === 'review') log.log(`[Screen] listing ${row.id} flagged for staff: ${out.signals.join(', ')}`);++ return out.action;++ }++++ async function flush() {++ flushTimer = null;++ if (flushing) await flushing;++ const ids = [...pending];++ pending.clear();++ flushing = (async () => {++ for (const id of ids) {++ try { await screenId(id); } catch (err) { log.warn(`[Screen] listing ${id}: ${(err && err.message) || err}`); }++ }++ })();++ try { await flushing; } finally { flushing = null; }++ }++++ /** A store write changed this listing: screen it shortly (never awaited by the request, never fatal). */++ function touch(id) {++ if (!ai || !id) return;++ pending.add(String(id));++ if (!flushTimer) {++ flushTimer = setTimeout(() => { flush().catch(() => {}); }, FLUSH_MS);++ if (flushTimer.unref) flushTimer.unref();++ }++ }++++ /** Active listings never screened, or changed since (a run failed, or AI was unreachable): a few at a time. */++ async function sweep() {++ if (!ai) return { screened: 0 };++ const ids = await store.unscreenedActive(s, SWEEP_LIMIT);++ let screened = 0;++ for (const id of ids) {++ const r = await screenId(id).catch(() => 'failed');++ if (r === 'allow' || r === 'review') screened++;++ }++ return { screened, seen: ids.length };++ }++ const quietly = () => { sweep().catch((err) => log.warn(`[Screen] sweep failed: ${(err && err.message) || err}`)); };++++ function start() {++ if (!ai || timers.length) return false;++ const kick = setTimeout(quietly, START_DELAY_MS);++ const tick = setInterval(quietly, INTERVAL_MS);++ for (const t of [kick, tick]) if (t.unref) t.unref();++ timers.push(kick, tick);++ return true;++ }++++ function stop() {++ for (const t of timers.splice(0)) { clearTimeout(t); clearInterval(t); }++ if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }++ }++++ /** Wait for touched listings to be screened (tests, and a graceful stop). */++ async function settle() {++ if (flushTimer) { clearTimeout(flushTimer); await flush(); }++ if (flushing) await flushing;++ }++++ return { enabled: !!ai, touch, screenId, sweep, settle, start, stop };++}++++module.exports = { createScreen, contentHash, screenInput, readOutput, SIGNALS };@@@ -15,8 +15,11 @@ const listings = require('./listings');const DAY_MS = 86_400_000;--/** A write changed this listing: OpenVibe.Search syncs it shortly (server/search-index.js; absent in some tests). */--const touched = (s, id) => { if (s.search) s.search.touch(id); };++/**++ * A write changed this listing: OpenVibe.Search syncs it shortly (server/search-index.js) and the AI screen reads it++ * again if its words changed (server/listings/screen.js); either may be absent in a test.++ */++const touched = (s, id) => { if (s.search) s.search.touch(id); if (s.screen) s.screen.touch(id); };/** The WHERE clause for a filter set. `exclude` leaves one dimension out — how the kind and country facets count. */function buildWhere(f, { exclude = [], sinceISO = null } = {}) {@@@ -186,6 +189,42 @@ const staffCounts = async (s) => ({removed: Number(await s.db.value("SELECT count(*) FROM rent_listings WHERE report_count > 0 AND state = 'removed'")),});++// ── The AI screen (server/listings/screen.js): the latest check of each listing, for staff ──++++const getCheck = (s, listingId) => s.db.maybe('SELECT * FROM rent_listing_checks WHERE listing_id = $1', [listingId]);++++/** Record a listing's latest screen. A verdict on new words is unreviewed again, whatever staff said before. */++async function putCheck(s, { listingId, contentHash, action, signals = [], spans = [], rationale = '', runId = null }) {++ await s.db.query(++ `INSERT INTO rent_listing_checks (listing_id, content_hash, action, signals, spans, rationale, run_id, checked_at, reviewed_at, reviewed_by)++ VALUES ($1, $2, $3, $4::jsonb, $5::jsonb, $6, $7, $8, NULL, NULL)++ ON CONFLICT (listing_id) DO UPDATE SET content_hash = EXCLUDED.content_hash, action = EXCLUDED.action, signals = EXCLUDED.signals,++ spans = EXCLUDED.spans, rationale = EXCLUDED.rationale, run_id = EXCLUDED.run_id, checked_at = EXCLUDED.checked_at,++ reviewed_at = NULL, reviewed_by = NULL`,++ [listingId, contentHash, action, JSON.stringify(signals), JSON.stringify(spans), rationale || null, runId, s.iso()]);++}++++/** The words did not change (a renewal, a report): the check stands; only its time moves, so the sweep skips it. */++const recheckUnchanged = (s, listingId) => s.db.exec('UPDATE rent_listing_checks SET checked_at = $2 WHERE listing_id = $1', [listingId, s.iso()]);++++/** Active listings the screen has not read since they last changed (a failed run, AI unreachable), oldest first. */++const unscreenedActive = async (s, limit) => (await s.db.many(++ `SELECT l.id FROM rent_listings l LEFT JOIN rent_listing_checks c ON c.listing_id = l.id++ WHERE l.state = 'active' AND (c.listing_id IS NULL OR c.checked_at < l.updated_at) ORDER BY l.id LIMIT $1`, [limit])).map((r) => r.id);++++/** The flagged queue: listings the screen put up for review that no member of staff has looked at, newest check first. */++const flaggedQueue = (s, { limit = 50 } = {}) => s.db.many(++ `SELECT l.*, c.signals AS check_signals, c.spans AS check_spans, c.rationale AS check_rationale, c.checked_at AS check_checked_at++ FROM rent_listing_checks c JOIN rent_listings l ON l.id = c.listing_id++ WHERE c.action = 'review' AND c.reviewed_at IS NULL AND l.state <> 'removed' ORDER BY c.checked_at DESC, l.id DESC LIMIT $1`, [limit]);++++const countFlagged = async (s) => Number(await s.db.value(++ "SELECT count(*) FROM rent_listing_checks c JOIN rent_listings l ON l.id = c.listing_id WHERE c.action = 'review' AND c.reviewed_at IS NULL AND l.state <> 'removed'"));++++/** A member of staff looked at a flagged listing and left it up. */++const markCheckReviewed = (s, listingId, by) => s.db.exec(++ "UPDATE rent_listing_checks SET reviewed_at = $2, reviewed_by = $3 WHERE listing_id = $1 AND action = 'review' AND reviewed_at IS NULL", [listingId, s.iso(), by]);++// ── Saved searches ─────────────────────────────────────────────────────────────────────────────────────────────async function saveSearch(s, { id, subject, filters, key, createdAt }) {@@@ -205,6 +244,7 @@ module.exports = {insert, get, search, countMatching, facets, update, expireListings, renew, setState, remove,countActiveByOwner, countCreatedToday, listByOwner,reportsFor, addReport, staffQueue, staffCounts,++ getCheck, putCheck, recheckUnchanged, unscreenedActive, flaggedQueue, countFlagged, markCheckReviewed,saveSearch, listSaved, getSaved, deleteSaved, markSavedSeen,DAY_MS,};@@@ -38,7 +38,7 @@ async function boot(opts = {}) {const { createStore } = require('../../server/db');const testdb = await require('./db').testDb();const store = createStore(testdb.db, { now: opts.now });-- const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, indexnow: opts.indexnow, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, accountSend: opts.accountSend, housingFetchImpl: opts.housingFetchImpl });++ const built = await createApp({ config, store, log, ai: opts.ai, telemetrySink: opts.telemetrySink, indexnow: opts.indexnow, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, accountSend: opts.accountSend, housingFetchImpl: opts.housingFetchImpl });await built.ctx.keys.ensure();const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });const base = `http://127.0.0.1:${server.address().port}`;nnew file mode 100644@@@ -0,0 +1,132 @@++'use strict';++/**++ * The AI listing screen (server/listings/screen.js): each listing a person posts or edits is read by OpenVibe.AI's++ * rent.screen_listing (a stand-in here) for the signs of a rental scam, never with the poster or the contact link; a++ * `review` puts it in the staff page's flagged queue with the signs and the quoted words, and nothing else changes for++ * anyone; staff mark it "Looks fine"; the same words are never screened twice, new words are; a failed run is picked++ * up by the sweep; the output is bounded; without OpenVibe.AI nothing is screened.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { SAME, post } = require('./helpers/rent');++const store = require('../server/listings/store');++const { createScreen, readOutput } = require('../server/listings/screen');++++const SCAM = 'I am abroad for work so I cannot show it. Send the deposit by wire transfer today and I will mail you the keys.';++++(async () => {++ const calls = [];++ let failing = false;++ const ai = {++ run: async (key, input, opts) => {++ calls.push({ key, input, opts });++ if (failing) throw new Error('AI unreachable');++ const scam = /wire transfer/.test(input.listing.description);++ return {++ runId: `run_${calls.length}`,++ output: scam++ ? { action: 'review', signals: ['owner_unavailable', 'payment_before_viewing', 'unusual_payment', 'made_up'], rationale: 'Pay by wire before any viewing; the owner is away.', spans: [{ quote: 'Send the deposit by wire transfer today', signal: 'payment_before_viewing' }] }++ : { action: 'allow', signals: ['none'], rationale: '', spans: [] },++ };++ },++ };++ const t = await boot({ ai });++ const s = t.ctx.s;++ const kim = t.network.addUser('kim');++ const ada = t.network.addUser('ada', { role: 'admin' });++ let fine;++ let scam;++++ try {++ await check('a posted listing is screened once, as a renter reads it: never the poster or the contact link', async () => {++ fine = (await post(t, kim)).json();++ await t.ctx.screen.settle();++ assert.strictEqual(calls.length, 1);++ assert.strictEqual(calls[0].key, 'rent.screen_listing');++ assert.strictEqual(calls[0].input.listing.title, 'Sunny two-bedroom flat near the park');++ const sent = JSON.stringify(calls[0].input);++ assert.ok(!sent.includes('example.com/rooms') && !sent.includes('usr_'), 'no contact link and no poster go to AI');++ assert.deepStrictEqual(calls[0].opts.target, { service: 'rent', type: 'listing', id: fine.id });++ assert.match(calls[0].opts.idempotencyKey, new RegExp(`^rent-screen-${fine.id}-[0-9a-f]{32}$`));++ const c = await store.getCheck(s, fine.id);++ assert.strictEqual(c.action, 'allow');++ assert.deepStrictEqual(c.signals, []);++ assert.strictEqual(await store.countFlagged(s), 0);++ });++++ await check('a listing with scam signs is flagged for staff with the signs and the quoted words; it stays listed', async () => {++ scam = (await post(t, kim, { title: 'Room in the centre', description: SCAM })).json();++ await t.ctx.screen.settle();++ const c = await store.getCheck(s, scam.id);++ assert.strictEqual(c.action, 'review');++ assert.deepStrictEqual(c.signals, ['owner_unavailable', 'payment_before_viewing', 'unusual_payment'], 'an unknown sign is dropped');++ assert.strictEqual(await store.countFlagged(s), 1);++ assert.strictEqual((await t.get(`/api/v1/listings/${scam.id}`)).status, 200, 'still public: a flag only asks staff to look');++ const page = await t.get('/staff?state=flagged', { as: ada });++ assert.strictEqual(page.status, 200);++ assert.ok(page.text.includes('Room in the centre'));++ assert.ok(page.text.includes('payment before a viewing') && page.text.includes('an owner who cannot show the place'));++ assert.ok(page.text.includes('Send the deposit by wire transfer today'), 'the quoted words');++ assert.ok(page.text.includes(`action="/listings/${scam.id}/check"`), 'Looks fine');++ assert.ok(!page.text.includes('Sunny two-bedroom flat'), 'an allowed listing is not in the flagged queue');++ assert.ok((await t.get('/staff', { as: ada })).text.includes('1 flagged by the AI screen'));++ });++++ await check('only staff see the flagged queue or mark a listing reviewed', async () => {++ assert.strictEqual((await t.get('/staff?state=flagged', { as: kim })).status, 403);++ assert.strictEqual((await t.get(`/listings/${scam.id}/check`, { as: kim, method: 'POST', form: {}, headers: SAME })).status, 403);++ assert.strictEqual(await store.countFlagged(s), 1);++ });++++ await check('Looks fine takes it out of the queue; the same words are not screened again', async () => {++ const r = await t.get(`/listings/${scam.id}/check`, { as: ada, method: 'POST', form: {}, headers: SAME });++ assert.strictEqual(r.status, 303);++ assert.strictEqual(await store.countFlagged(s), 0);++ assert.match((await store.getCheck(s, scam.id)).reviewed_by, /^user:usr_/);++ const before = calls.length;++ await store.update(s, scam.id, { bedrooms: 1 }); // not something the screen reads: same words++ await t.ctx.screen.settle();++ assert.strictEqual(calls.length, before, 'unchanged words: no run');++ assert.strictEqual(await store.countFlagged(s), 0, 'and the review stands');++ assert.deepStrictEqual(await store.unscreenedActive(s, 10), [], 'nor does the sweep pick it up');++ });++++ await check('new words are screened again, and a new flag is unreviewed', async () => {++ await store.update(s, scam.id, { description: `${SCAM} Also, no calls please.` });++ await t.ctx.screen.settle();++ assert.strictEqual(calls[calls.length - 1].input.listing.description.endsWith('no calls please.'), true);++ assert.strictEqual(await store.countFlagged(s), 1);++ });++++ await check('a failed run leaves no check; the sweep screens the listing once AI answers again', async () => {++ failing = true;++ const late = (await post(t, kim, { title: 'Parking space downtown', kind: 'parking', description: 'Covered space, monthly.' })).json();++ await t.ctx.screen.settle();++ assert.strictEqual(await store.getCheck(s, late.id), null);++ assert.deepStrictEqual(await store.unscreenedActive(s, 10), [late.id]);++ failing = false;++ const out = await t.ctx.screen.sweep();++ assert.deepStrictEqual(out, { screened: 1, seen: 1 });++ assert.strictEqual((await store.getCheck(s, late.id)).action, 'allow');++ });++++ await check('the output is bounded: review without a known sign is allow; quotes are trimmed and capped', () => {++ assert.deepStrictEqual(readOutput({ action: 'review', signals: ['none'] }), { action: 'allow', signals: [], spans: [], rationale: '' });++ assert.strictEqual(readOutput(null).action, 'allow');++ const many = readOutput({ action: 'review', signals: ['pressure'], spans: Array.from({ length: 30 }, () => ({ quote: ` ${'x'.repeat(400)} `, signal: 'nope' })) });++ assert.strictEqual(many.spans.length, 10);++ assert.strictEqual(many.spans[0].quote.length, 300);++ assert.strictEqual(many.spans[0].signal, null);++ });++++ await check('without OpenVibe.AI nothing is screened and nothing starts', async () => {++ const off = createScreen({ s, ai: null });++ off.touch(fine.id);++ assert.strictEqual(off.enabled, false);++ assert.strictEqual(off.start(), false);++ assert.deepStrictEqual(await off.sweep(), { screened: 0 });++ assert.strictEqual(await off.screenId(fine.id), 'off');++ });++ } finally { await t.close(); }++ done();++})().catch((e) => { console.error(e); process.exit(1); });
@@ -38,6 +38,9 @@ OV_OAUTH_CLIENT_SECRET=
@@ -38,6 +38,9 @@ OV_OAUTH_CLIENT_SECRET=
# are created at boot (off when unset); RENT_EVENTS_SUBSCRIBE=0 turns that off.
# are created at boot (off when unset); RENT_EVENTS_SUBSCRIBE=0 turns that off.
RENT_EVENTS_SECRET=
RENT_EVENTS_SECRET=
RENT_EVENTS_URL=
RENT_EVENTS_URL=
+# OpenVibe.AI for the listing screen (server/listings/screen.js): each posted or edited listing is screened for the signs of a
+# rental scam, for staff (needs OV_OAUTH_CLIENT_SECRET too). Unset: off.
+#OV_AI_INTERNAL_URL=http://127.0.0.1:4700
#RENT_EVENTS_ENDPOINT=http://127.0.0.1:5010/internal/events
#RENT_EVENTS_ENDPOINT=http://127.0.0.1:5010/internal/events
# optional: defaults to true in production
# optional: defaults to true in production
@@ -44,6 +44,8 @@ Rent's own data is its PostgreSQL database ([server/db.js](server/db.js)); the `
@@ -44,6 +44,8 @@ Rent's own data is its PostgreSQL database ([server/db.js](server/db.js)); the `
([migrations/0002_rent.sql](migrations/0002_rent.sql)).
([migrations/0002_rent.sql](migrations/0002_rent.sql)).
- **`rent_reports`** — one report per person per listing (unique index `rent_reports_once`), what hides a listing at
- **`rent_reports`** — one report per person per listing (unique index `rent_reports_once`), what hides a listing at
three. `rpt_<ULID>` ids.
three. `rpt_<ULID>` ids.
+- **`rent_listing_checks`** (0008) — the latest AI screen of each listing (below): the content hash it read, `allow` or
+ `review`, the signs and the quoted words, and which member of staff marked it reviewed. It goes with its listing.
- **`rent_saved_searches`** — a person's saved filter sets, one per distinct set, with `last_seen_at` for the "new
- **`rent_saved_searches`** — a person's saved filter sets, one per distinct set, with `last_seen_at` for the "new
since you last looked" count. `ssv_<ULID>` ids.
since you last looked" count. `ssv_<ULID>` ids.
- **`account_data_events`** — the receipt of each ADR-033 account export or deletion delivery Rent has applied, so a
- **`account_data_events`** — the receipt of each ADR-033 account export or deletion delivery Rent has applied, so a
@@ -112,6 +114,7 @@ Index master CSV — paced and cached in [server/housing/upstream.js](server/hou
@@ -112,6 +114,7 @@ Index master CSV — paced and cached in [server/housing/upstream.js](server/hou
| One listing | `/listings/:id` | The listing, its safety note, and a contact link **only for a signed-in viewer** — everyone else is asked to sign in. JSON-LD is published as an `Offer` (price, currency, `priceValidUntil`) only when the kind has a clear schema.org type; `other` gets none. |
| One listing | `/listings/:id` | The listing, its safety note, and a contact link **only for a signed-in viewer** — everyone else is asked to sign in. JSON-LD is published as an `Offer` (price, currency, `priceValidUntil`) only when the kind has a clear schema.org type; `other` gets none. |
| Reports | [server/listings/store.js](server/listings/store.js), `/staff` | Anyone signed in reports a listing once (reason + note ≤500). **3 distinct reports hide it** from search until staff look; staff restore, hide or remove it, and a listing staff restored is not hidden again by the same reports. |
| Reports | [server/listings/store.js](server/listings/store.js), `/staff` | Anyone signed in reports a listing once (reason + note ≤500). **3 distinct reports hide it** from search until staff look; staff restore, hide or remove it, and a listing staff restored is not hidden again by the same reports. |
| Staff | [server/listings/staff.js](server/listings/staff.js) | Who is staff is the Network **role claim** (`admin`, `global_mod`), decided in one place, exactly as at OpenVibe.Help. The pages and the API cannot drift apart. |
| Staff | [server/listings/staff.js](server/listings/staff.js) | Who is staff is the Network **role claim** (`admin`, `global_mod`), decided in one place, exactly as at OpenVibe.Help. The pages and the API cannot drift apart. |
+| AI screen | [server/listings/screen.js](server/listings/screen.js), `/staff?state=flagged` | Each listing a person posts or edits is read by OpenVibe.AI's `rent.screen_listing` for the signs of a rental scam (payment before a viewing, wire/gift-card/crypto payment, an owner who cannot show the place, pressure, identity or bank details up front, only a private channel), never with the poster or the contact link. `review` puts it in the staff page's **flagged** queue with the signs and the quoted words; nothing is hidden or refused, and staff decide (**Looks fine**, hide, remove). The same words are never screened twice; a failed run is retried by a sweep every 30 minutes. Off without `OV_AI_INTERNAL_URL` (and the client secret). |
| Saving a search | `/saved` | Save the current filters (signed in) and see, each time you open the page, how many listings have appeared since you last looked. |
| Saving a search | `/saved` | Save the current filters (signed in) and see, each time you open the page, how many listings have appeared since you last looked. |
| Alerts | `/listings.atom`, Get alerts | Every search is an Atom feed with the same filters (the newest 25, cached 5 minutes), linked from the listings page's head. **Get alerts** on the listings page and on each saved search opens OpenVibe.Watch's feed form with that feed filled in, so Watch tells you, or runs your agent, when a listing that fits appears. |
| Alerts | `/listings.atom`, Get alerts | Every search is an Atom feed with the same filters (the newest 25, cached 5 minutes), linked from the listings page's head. **Get alerts** on the listings page and on each saved search opens OpenVibe.Watch's feed form with that feed filled in, so Watch tells you, or runs your agent, when a listing that fits appears. |
| Affordable housing | [server/housing/places.js](server/housing/places.js), [upstream.js](server/housing/upstream.js) | `/housing` geocodes a place with Nominatim, then reads six HUD ArcGIS layers — public housing, LIHTC, HUD-assisted multifamily, public housing authorities, housing counselling agencies and HUD homes for sale (`SF_REO`, HUD's March 2026 snapshot, each linked to HUD Homestore to confirm it is still for sale) — normalised, distance-sorted and grouped, with the HUD attribution on the page. |
| Affordable housing | [server/housing/places.js](server/housing/places.js), [upstream.js](server/housing/upstream.js) | `/housing` geocodes a place with Nominatim, then reads six HUD ArcGIS layers — public housing, LIHTC, HUD-assisted multifamily, public housing authorities, housing counselling agencies and HUD homes for sale (`SF_REO`, HUD's March 2026 snapshot, each linked to HUD Homestore to confirm it is still for sale) — normalised, distance-sorted and grouped, with the HUD attribution on the page. |
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
+-- phase: expand
+-- OpenVibe.Rent: the latest AI screen of each listing for the signs of a rental scam (server/listings/screen.js, the
+-- `rent.screen_listing` workflow on OpenVibe.AI). Staff only: `review` puts the listing in the staff page's flagged queue
+-- with the signs and the quoted words until a member of staff marks it reviewed (or hides or removes it). One row per
+-- listing, replaced when what the listing says changes (`content_hash`); it goes with its listing.
+CREATE TABLE IF NOT EXISTS rent_listing_checks (
+ listing_id text COLLATE "C" PRIMARY KEY REFERENCES rent_listings (id) ON DELETE CASCADE,
+ content_hash text COLLATE "C" NOT NULL, -- what a renter reads, hashed (screen.js contentHash)
+ action text COLLATE "C" NOT NULL CHECK (action IN ('allow', 'review')),
+ signals jsonb NOT NULL DEFAULT '[]'::jsonb, -- the signs named, e.g. ["payment_before_viewing"]
+ spans jsonb NOT NULL DEFAULT '[]'::jsonb, -- [{ quote, signal }] the words behind each sign
+ rationale text,
+ run_id text COLLATE "C", -- the OpenVibe.AI run that answered
+ checked_at text COLLATE "C" NOT NULL,
+ reviewed_at text COLLATE "C", -- set when staff mark a flagged listing reviewed
+ reviewed_by text COLLATE "C" -- user:usr_… of that member of staff
+);
+CREATE INDEX IF NOT EXISTS rent_listing_checks_flagged ON rent_listing_checks (checked_at DESC) WHERE action = 'review' AND reviewed_at IS NULL;
@@ -19,6 +19,8 @@ const helmet = require('helmet');
@@ -19,6 +19,8 @@ const helmet = require('helmet');
const cookieParser = require('cookie-parser');
const cookieParser = require('cookie-parser');
const rateLimit = require('express-rate-limit');
const rateLimit = require('express-rate-limit');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
+const { createAiClient } = require('openvibe-publishing/ai');
+const { createScreen } = require('./listings/screen');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
const { createSearchIndex } = require('./search-index');
const { createSearchIndex } = require('./search-index');
@@ -93,6 +95,17 @@ async function createApp(opts = {}) {
@@ -93,6 +95,17 @@ async function createApp(opts = {}) {
ctx.accountData = accountData;
ctx.accountData = accountData;
ctx.accountSend = accountSend;
ctx.accountSend = accountSend;
+ // The AI listing screen (server/listings/screen.js): with OV_AI_INTERNAL_URL and a client secret, Rent asks
+ // OpenVibe.AI's rent.screen_listing about each posted or edited listing with its own service token (Network grants
+ // rent ai.run.* on rent.*); the answer is for staff. Off otherwise. A test injects a stand-in through opts.ai.
+ const aiTokenClient = config.aiUrl && config.oauth.clientSecret ? contracts.serviceAuth.createTokenClient({
+ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
+ audience: 'openvibe.ai', scope: 'ai.run.create ai.run.read', fetchImpl,
+ }) : null;
+ const ai = opts.ai !== undefined ? opts.ai : (aiTokenClient ? createAiClient({ baseUrl: config.aiUrl, tokenClient: aiTokenClient, fetch: fetchImpl }) : null);
+ ctx.screen = createScreen({ s, ai, log });
+ s.screen = ctx.screen;
+
const app = express();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
app.set('trust proxy', config.trustProxy);
app.set('trust proxy', config.trustProxy);
@@ -86,6 +86,9 @@ function load(env = process.env) {
@@ -86,6 +86,9 @@ function load(env = process.env) {
// for rotation, 32+ characters each); unset turns POST /internal/events off (503). The url is where the
// for rotation, 32+ characters each); unset turns POST /internal/events off (503). The url is where the
// network.account.export_requested and network.account.deleted subscriptions are created at boot, off when
// network.account.export_requested and network.account.deleted subscriptions are created at boot, off when
// unset. Both topics are ADR-033: account export and deletion (server/identity/account-data.js).
// unset. Both topics are ADR-033: account export and deletion (server/identity/account-data.js).
+ // OpenVibe.AI → the listing screen (server/listings/screen.js): each posted or edited listing is read by the
+ // rent.screen_listing workflow for the signs of a rental scam, for staff. Unset: off (nothing is screened).
+ aiUrl: trim(env.OV_AI_INTERNAL_URL || ''),
events: {
events: {
secrets: String(env.RENT_EVENTS_SECRET || '').split(',').map((x) => x.trim()).filter(Boolean),
secrets: String(env.RENT_EVENTS_SECRET || '').split(',').map((x) => x.trim()).filter(Boolean),
url: trim(env.RENT_EVENTS_URL || env.EVENTS_URL || ''),
url: trim(env.RENT_EVENTS_URL || env.EVENTS_URL || ''),
@@ -785,6 +785,17 @@ ${it.newCount ? html` <span class="badge info">${it.newCount} new</span>` : html
@@ -785,6 +785,17 @@ ${it.newCount ? html` <span class="badge info">${it.newCount} new</span>` : html
});
});
// ── The report queue (staff) ─────────────────────────────
// ── The report queue (staff) ─────────────────────────────
+ // The AI screen's signs, in staff's words (server/listings/screen.js names them).
+ const SIGN_TEXT = {
+ payment_before_viewing: 'payment before a viewing',
+ unusual_payment: 'wire, gift card, crypto or a payment app',
+ owner_unavailable: 'an owner who cannot show the place',
+ pressure: 'pressure to decide or pay',
+ personal_data_request: 'identity or bank details up front',
+ off_platform_only: 'only a private channel, no calls or visits',
+ };
+ const listOf = (v) => (Array.isArray(v) ? v : (() => { try { return JSON.parse(v || '[]'); } catch { return []; } })());
+
r.get('/staff', async (req, res) => {
r.get('/staff', async (req, res) => {
if (!signedIn(req)) return page(req, res, {
if (!signedIn(req)) return page(req, res, {
title: 'Report queue',
title: 'Report queue',
@@ -796,9 +807,10 @@ ${it.newCount ? html` <span class="badge info">${it.newCount} new</span>` : html
@@ -796,9 +807,10 @@ ${it.newCount ? html` <span class="badge info">${it.newCount} new</span>` : html
crumbs: [{ label: 'Home', href: '/' }, { label: 'Report queue' }],
crumbs: [{ label: 'Home', href: '/' }, { label: 'Report queue' }],
body: html`<h1>Staff only</h1><p class="muted">The report queue is for OpenVibe staff — a Network role of <code>admin</code> or <code>global_mod</code>. Yours is <code>${truncate(req.viewer.role || 'user', 32)}</code>. <a href="/mine">Your listings</a>.</p>`,
body: html`<h1>Staff only</h1><p class="muted">The report queue is for OpenVibe staff — a Network role of <code>admin</code> or <code>global_mod</code>. Yours is <code>${truncate(req.viewer.role || 'user', 32)}</code>. <a href="/mine">Your listings</a>.</p>`,
}, 403);
}, 403);
- const state = ['hidden', 'active', 'removed', 'all'].includes(String(req.query.state)) ? String(req.query.state) : 'hidden';
- const out = await store.staffQueue(s, { state, limit: 50 });
+ const state = ['flagged', 'hidden', 'active', 'removed', 'all'].includes(String(req.query.state)) ? String(req.query.state) : 'hidden';
+ const out = state === 'flagged' ? { rows: await store.flaggedQueue(s, { limit: 50 }) } : await store.staffQueue(s, { state, limit: 50 });
const counts = await store.staffCounts(s);
const counts = await store.staffCounts(s);
+ const flagged = await store.countFlagged(s);
const items = [];
const items = [];
for (const row of out.rows) items.push({ row, reports: await store.reportsFor(s, row.id) });
for (const row of out.rows) items.push({ row, reports: await store.reportsFor(s, row.id) });
return page(req, res, {
return page(req, res, {
@@ -807,9 +819,10 @@ ${it.newCount ? html` <span class="badge info">${it.newCount} new</span>` : html
@@ -807,9 +819,10 @@ ${it.newCount ? html` <span class="badge info">${it.newCount} new</span>` : html
crumbs: [{ label: 'Home', href: '/' }, { label: 'Report queue' }],
crumbs: [{ label: 'Home', href: '/' }, { label: 'Report queue' }],
body: html`<h1>The report queue</h1>
body: html`<h1>The report queue</h1>
<p class="sc-lede">${counts.hidden} hidden by reports, ${counts.active} reported but still active, ${counts.removed} removed. A listing is hidden automatically at ${listings.HIDE_AT_REPORTS} distinct reports.</p>
<p class="sc-lede">${counts.hidden} hidden by reports, ${counts.active} reported but still active, ${counts.removed} removed. A listing is hidden automatically at ${listings.HIDE_AT_REPORTS} distinct reports.</p>
+<p class="small">${flagged ? html`<a href="/staff?state=flagged">${flagged} flagged by the AI screen</a>` : 'Nothing flagged by the AI screen.'} <span class="muted">OpenVibe.AI reads each listing a person posts or edits for the signs of a rental scam; a flag only asks for a look, and the listing stays up until you decide.</span></p>
<form class="card filters" method="get" action="/staff">
<form class="card filters" method="get" action="/staff">
<div class="field"><label for="state">Show</label><select id="state" name="state">
<div class="field"><label for="state">Show</label><select id="state" name="state">
-${['hidden', 'active', 'removed', 'all'].map((x) => html`<option value="${x}"${x === state ? raw(' selected') : ''}>${x === 'hidden' ? 'hidden (needs a look)' : x}</option>`)}
+${['flagged', 'hidden', 'active', 'removed', 'all'].map((x) => html`<option value="${x}"${x === state ? raw(' selected') : ''}>${x === 'hidden' ? 'hidden (needs a look)' : x === 'flagged' ? 'flagged by the AI screen' : x}</option>`)}
</select></div>
</select></div>
<p><button class="sc-btn sc-primary" type="submit">Filter</button> <a class="sc-btn" href="/staff">Clear</a></p>
<p><button class="sc-btn sc-primary" type="submit">Filter</button> <a class="sc-btn" href="/staff">Clear</a></p>
</form>
</form>
@@ -817,6 +830,10 @@ ${items.length ? items.map((it) => html`<section class="card staff-item">
@@ -817,6 +830,10 @@ ${items.length ? items.map((it) => html`<section class="card staff-item">
<h2><a href="/listings/${it.row.id}">${truncate(it.row.title, 80)}</a> ${stateBadge(it.row.state)}</h2>
<h2><a href="/listings/${it.row.id}">${truncate(it.row.title, 80)}</a> ${stateBadge(it.row.state)}</h2>
<p class="small muted">Posted by <code>${it.row.owner}</code> · ${whereOf(it.row)} · ${priceOf(it.row)} · ${it.row.report_count} report${Number(it.row.report_count) === 1 ? '' : 's'}</p>
<p class="small muted">Posted by <code>${it.row.owner}</code> · ${whereOf(it.row)} · ${priceOf(it.row)} · ${it.row.report_count} report${Number(it.row.report_count) === 1 ? '' : 's'}</p>
${textBlock(truncate(it.row.description, 400))}
${textBlock(truncate(it.row.description, 400))}
+${it.row.check_signals !== undefined ? html`<div class="screen-flag small"><p><b>The AI screen saw:</b> ${listOf(it.row.check_signals).map((x) => SIGN_TEXT[x] || x).join('; ')}</p>
+${it.row.check_rationale ? html`<p class="muted">${truncate(it.row.check_rationale, 400)}</p>` : ''}
+${listOf(it.row.check_spans).length ? html`<ul>${listOf(it.row.check_spans).map((sp) => html`<li>“${truncate(sp.quote, 200)}”${sp.signal && SIGN_TEXT[sp.signal] ? html` <span class="muted">(${SIGN_TEXT[sp.signal]})</span>` : ''}</li>`)}</ul>` : ''}
+<form class="inline" method="post" action="/listings/${it.row.id}/check"><button class="sc-btn" type="submit">Looks fine</button></form></div>` : ''}
<ul class="report-list small">${it.reports.map((rp) => html`<li><b>${rp.reason}</b> · <code>${rp.reporter}</code> · ${time(rp.created_at)}${rp.note ? html` — ${rp.note}` : ''}</li>`)}</ul>
<ul class="report-list small">${it.reports.map((rp) => html`<li><b>${rp.reason}</b> · <code>${rp.reporter}</code> · ${time(rp.created_at)}${rp.note ? html` — ${rp.note}` : ''}</li>`)}</ul>
<form class="inline" method="post" action="/listings/${it.row.id}/state"><input type="hidden" name="state" value="active"><button class="sc-btn" type="submit">Restore it</button></form>
<form class="inline" method="post" action="/listings/${it.row.id}/state"><input type="hidden" name="state" value="active"><button class="sc-btn" type="submit">Restore it</button></form>
<form class="inline" method="post" action="/listings/${it.row.id}/state"><input type="hidden" name="state" value="hidden"><button class="sc-btn" type="submit">Hide it</button></form>
<form class="inline" method="post" action="/listings/${it.row.id}/state"><input type="hidden" name="state" value="hidden"><button class="sc-btn" type="submit">Hide it</button></form>
@@ -843,6 +860,19 @@ ${textBlock(truncate(it.row.description, 400))}
@@ -843,6 +860,19 @@ ${textBlock(truncate(it.row.description, 400))}
return res.redirect(303, '/staff');
return res.redirect(303, '/staff');
});
});
+ // A member of staff looked at a listing the AI screen flagged and left it up: it leaves the flagged queue until its
+ // words change and the screen flags it again.
+ r.post('/listings/:id/check', async (req, res) => {
+ if (!signedIn(req) || !staffViewer(req)) return page(req, res, {
+ title: 'Staff only',
+ crumbs: [{ label: 'Home', href: '/' }, { label: 'Report queue' }],
+ body: html`<h1>Staff only</h1><p class="muted">Reviewing a flagged listing is for OpenVibe staff.</p>`,
+ }, signedIn(req) ? 403 : 401);
+ if (!sameSite(req, res, '/staff?state=flagged')) return;
+ if (listings.isListingId(req.params.id)) await store.markCheckReviewed(s, req.params.id, requesterOf(req));
+ return res.redirect(303, '/staff?state=flagged');
+ });
+
// ── Safety ───────────────────────────────────────────────
// ── Safety ───────────────────────────────────────────────
r.get('/safety', (req, res) => page(req, res, {
r.get('/safety', (req, res) => page(req, res, {
index: true, cache: PUBLIC_CACHE,
index: true, cache: PUBLIC_CACHE,
@@ -15,7 +15,9 @@
@@ -15,7 +15,9 @@
* Nothing is anonymized: Rent keeps no row by this person that another person's page must still read. A listing is
* Nothing is anonymized: Rent keeps no row by this person that another person's page must still read. A listing is
* the poster's own; the reports other people left on it cascade away with it (rent_reports.listing_id REFERENCES
* the poster's own; the reports other people left on it cascade away with it (rent_reports.listing_id REFERENCES
* rent_listings(id) ON DELETE CASCADE in migrations/0002_rent.sql), which is right — a report about a listing that no
* rent_listings(id) ON DELETE CASCADE in migrations/0002_rent.sql), which is right — a report about a listing that no
- * longer exists has nothing left to say. Nothing here is a secret: no token, key or credential is stored, so every
+ * longer exists has nothing left to say. So does the AI screen's check of a listing (rent_listing_checks, migration
+ * 0008): a staff-only moderation note about the listing, not a record of the person, gone with the listing and not
+ * exported. Nothing here is a secret: no token, key or credential is stored, so every
* column of the three tables may be exported.
* column of the three tables may be exported.
*/
*/
const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
@@ -21,7 +21,7 @@ const { startHpiTimer } = require('./housing/hpi');
@@ -21,7 +21,7 @@ const { startHpiTimer } = require('./housing/hpi');
function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }) {
function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }) {
return gracefulStop({
return gracefulStop({
name: 'OpenVibe.Rent', server, deadlineExitCode: 0, exit, signals, deadlineMs: 10_000,
name: 'OpenVibe.Rent', server, deadlineExitCode: 0, exit, signals, deadlineMs: 10_000,
- close: [() => { for (const t of timers) clearInterval(t); }, () => ctx.keys.client.stop(), () => ctx.search.stop(), async () => { clearInterval(ctx.telemetryTick); if (ctx.retention) await ctx.retention.stop(); if (ctx.telemetry) await ctx.telemetry.stop(); }, () => ctx.s.close(), ...extra],
+ close: [() => { for (const t of timers) clearInterval(t); }, () => ctx.keys.client.stop(), () => ctx.search.stop(), () => ctx.screen.stop(), async () => { clearInterval(ctx.telemetryTick); if (ctx.retention) await ctx.retention.stop(); if (ctx.telemetry) await ctx.telemetry.stop(); }, () => ctx.s.close(), ...extra],
});
});
}
}
@@ -41,6 +41,7 @@ async function start() {
@@ -41,6 +41,7 @@ async function start() {
ctx.telemetryTick.unref();
ctx.telemetryTick.unref();
ctx.keys.client.start();
ctx.keys.client.start();
ctx.search.start();
ctx.search.start();
+ ctx.screen.start(); // the AI listing screen's sweep (off without OpenVibe.AI)
// One sweep at boot (a restart must not leave a stale listing up a moment longer than it should), then a timer.
// One sweep at boot (a restart must not leave a stale listing up a moment longer than it should), then a timer.
const expiry = startExpiryTimer(ctx.s);
const expiry = startExpiryTimer(ctx.s);
new file mode 100644
new file mode 100644
@@ -0,0 +1,154 @@
@@ -0,0 +1,154 @@
+'use strict';
+
+/**
+ * The listing screen (the plan's Rent composition: Sources + Search + Watch + AI): every active listing a person posts
+ * or edits is read by OpenVibe.AI's `rent.screen_listing` workflow for the signs of a rental scam — payment before a
+ * viewing, wire or gift-card payment, an owner who cannot show the place, pressure, identity documents up front, a
+ * conversation pushed off the site. The answer is for staff only: `review` puts the listing in the staff page's
+ * "flagged" queue with the signs and the quoted words; nothing is hidden, refused or shown to anyone else, and a person
+ * can always post. Staff decide (Looks fine, Hide, Remove) exactly as for a report.
+ *
+ * Every store write names the listing it touched (store.js calls s.screen.touch(id)); touched listings are screened a
+ * moment later, never inside the request. A listing is screened again only when what it says changed (a hash of the
+ * words and the price a renter reads), so a renewal or a report never costs a run. A run that fails is logged and
+ * retried by the sweep, which also covers listings posted while OpenVibe.AI was unreachable.
+ *
+ * Off without OV_AI_INTERNAL_URL and a client secret (server/app.js passes no `ai`): then touch() does nothing.
+ */
+const crypto = require('crypto');
+const store = require('./store');
+
+const FLUSH_MS = 2_000;
+const START_DELAY_MS = 90_000;
+const INTERVAL_MS = 30 * 60_000;
+const SWEEP_LIMIT = 25;
+const SIGNALS = ['payment_before_viewing', 'unusual_payment', 'owner_unavailable', 'pressure', 'personal_data_request', 'off_platform_only'];
+
+/** What a renter reads, hashed: the screen runs again only when this changes. */
+function contentHash(row) {
+ const parts = [row.kind, row.title, row.description, row.price, row.currency, row.period, row.city, row.region, row.country, row.neighbourhood];
+ return crypto.createHash('sha256').update(JSON.stringify(parts.map((v) => (v == null ? null : String(v))))).digest('hex').slice(0, 32);
+}
+
+/** The workflow's input: the listing as a renter sees it (never the poster, never the contact link). */
+function screenInput(row) {
+ return {
+ listing: {
+ title: String(row.title || '').slice(0, 200),
+ kind: String(row.kind || '').slice(0, 40),
+ price: row.price == null ? undefined : Number(row.price),
+ currency: row.currency ? String(row.currency).slice(0, 8) : undefined,
+ period: row.period ? String(row.period).slice(0, 20) : undefined,
+ city: row.city ? String(row.city).slice(0, 120) : undefined,
+ country: row.country ? String(row.country).slice(0, 8) : undefined,
+ description: String(row.description || '').slice(0, 5000),
+ },
+ };
+}
+
+/** Bound and validate the workflow's output: unknown signs are dropped; `review` without a sign is `allow`. */
+function readOutput(output) {
+ const o = output && typeof output === 'object' ? output : {};
+ const signals = [...new Set((Array.isArray(o.signals) ? o.signals : []).filter((x) => SIGNALS.includes(x)))];
+ const action = o.action === 'review' && signals.length ? 'review' : 'allow';
+ const spans = (Array.isArray(o.spans) ? o.spans : []).slice(0, 10)
+ .filter((x) => x && typeof x.quote === 'string' && x.quote.trim())
+ .map((x) => ({ quote: x.quote.trim().slice(0, 300), signal: SIGNALS.includes(x.signal) ? x.signal : null }));
+ const rationale = typeof o.rationale === 'string' ? o.rationale.trim().slice(0, 1000) : '';
+ return { action, signals: action === 'review' ? signals : [], spans: action === 'review' ? spans : [], rationale };
+}
+
+function createScreen({ s, ai = null, log = console }) {
+ const pending = new Set();
+ const timers = [];
+ let flushTimer = null;
+ let flushing = null;
+
+ /** Screen one listing now. Returns 'off' | 'gone' | 'inactive' | 'unchanged' | 'allow' | 'review' | 'failed'. */
+ async function screenId(id) {
+ if (!ai) return 'off';
+ const row = await store.get(s, id);
+ if (!row) return 'gone';
+ if (row.state !== 'active') return 'inactive';
+ const hash = contentHash(row);
+ const prev = await store.getCheck(s, id);
+ if (prev && prev.content_hash === hash) {
+ if (prev.checked_at < row.updated_at) await store.recheckUnchanged(s, id);
+ return 'unchanged';
+ }
+ let run;
+ try {
+ run = await ai.run('rent.screen_listing', screenInput(row), {
+ target: { service: 'rent', type: 'listing', id: row.id },
+ idempotencyKey: `rent-screen-${row.id}-${hash}`,
+ });
+ } catch (err) {
+ log.warn(`[Screen] listing ${id}: ${(err && err.message) || err}`);
+ return 'failed';
+ }
+ const out = readOutput(run && run.output);
+ await store.putCheck(s, { listingId: row.id, contentHash: hash, ...out, runId: (run && run.runId) || null });
+ if (out.action === 'review') log.log(`[Screen] listing ${row.id} flagged for staff: ${out.signals.join(', ')}`);
+ return out.action;
+ }
+
+ async function flush() {
+ flushTimer = null;
+ if (flushing) await flushing;
+ const ids = [...pending];
+ pending.clear();
+ flushing = (async () => {
+ for (const id of ids) {
+ try { await screenId(id); } catch (err) { log.warn(`[Screen] listing ${id}: ${(err && err.message) || err}`); }
+ }
+ })();
+ try { await flushing; } finally { flushing = null; }
+ }
+
+ /** A store write changed this listing: screen it shortly (never awaited by the request, never fatal). */
+ function touch(id) {
+ if (!ai || !id) return;
+ pending.add(String(id));
+ if (!flushTimer) {
+ flushTimer = setTimeout(() => { flush().catch(() => {}); }, FLUSH_MS);
+ if (flushTimer.unref) flushTimer.unref();
+ }
+ }
+
+ /** Active listings never screened, or changed since (a run failed, or AI was unreachable): a few at a time. */
+ async function sweep() {
+ if (!ai) return { screened: 0 };
+ const ids = await store.unscreenedActive(s, SWEEP_LIMIT);
+ let screened = 0;
+ for (const id of ids) {
+ const r = await screenId(id).catch(() => 'failed');
+ if (r === 'allow' || r === 'review') screened++;
+ }
+ return { screened, seen: ids.length };
+ }
+ const quietly = () => { sweep().catch((err) => log.warn(`[Screen] sweep failed: ${(err && err.message) || err}`)); };
+
+ function start() {
+ if (!ai || timers.length) return false;
+ const kick = setTimeout(quietly, START_DELAY_MS);
+ const tick = setInterval(quietly, INTERVAL_MS);
+ for (const t of [kick, tick]) if (t.unref) t.unref();
+ timers.push(kick, tick);
+ return true;
+ }
+
+ function stop() {
+ for (const t of timers.splice(0)) { clearTimeout(t); clearInterval(t); }
+ if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
+ }
+
+ /** Wait for touched listings to be screened (tests, and a graceful stop). */
+ async function settle() {
+ if (flushTimer) { clearTimeout(flushTimer); await flush(); }
+ if (flushing) await flushing;
+ }
+
+ return { enabled: !!ai, touch, screenId, sweep, settle, start, stop };
+}
+
+module.exports = { createScreen, contentHash, screenInput, readOutput, SIGNALS };
@@ -15,8 +15,11 @@ const listings = require('./listings');
@@ -15,8 +15,11 @@ const listings = require('./listings');
const DAY_MS = 86_400_000;
const DAY_MS = 86_400_000;
-/** A write changed this listing: OpenVibe.Search syncs it shortly (server/search-index.js; absent in some tests). */
-const touched = (s, id) => { if (s.search) s.search.touch(id); };
+/**
+ * A write changed this listing: OpenVibe.Search syncs it shortly (server/search-index.js) and the AI screen reads it
+ * again if its words changed (server/listings/screen.js); either may be absent in a test.
+ */
+const touched = (s, id) => { if (s.search) s.search.touch(id); if (s.screen) s.screen.touch(id); };
/** The WHERE clause for a filter set. `exclude` leaves one dimension out — how the kind and country facets count. */
/** The WHERE clause for a filter set. `exclude` leaves one dimension out — how the kind and country facets count. */
function buildWhere(f, { exclude = [], sinceISO = null } = {}) {
function buildWhere(f, { exclude = [], sinceISO = null } = {}) {
@@ -186,6 +189,42 @@ const staffCounts = async (s) => ({
@@ -186,6 +189,42 @@ const staffCounts = async (s) => ({
removed: Number(await s.db.value("SELECT count(*) FROM rent_listings WHERE report_count > 0 AND state = 'removed'")),
removed: Number(await s.db.value("SELECT count(*) FROM rent_listings WHERE report_count > 0 AND state = 'removed'")),
});
});
+// ── The AI screen (server/listings/screen.js): the latest check of each listing, for staff ──
+
+const getCheck = (s, listingId) => s.db.maybe('SELECT * FROM rent_listing_checks WHERE listing_id = $1', [listingId]);
+
+/** Record a listing's latest screen. A verdict on new words is unreviewed again, whatever staff said before. */
+async function putCheck(s, { listingId, contentHash, action, signals = [], spans = [], rationale = '', runId = null }) {
+ await s.db.query(
+ `INSERT INTO rent_listing_checks (listing_id, content_hash, action, signals, spans, rationale, run_id, checked_at, reviewed_at, reviewed_by)
+ VALUES ($1, $2, $3, $4::jsonb, $5::jsonb, $6, $7, $8, NULL, NULL)
+ ON CONFLICT (listing_id) DO UPDATE SET content_hash = EXCLUDED.content_hash, action = EXCLUDED.action, signals = EXCLUDED.signals,
+ spans = EXCLUDED.spans, rationale = EXCLUDED.rationale, run_id = EXCLUDED.run_id, checked_at = EXCLUDED.checked_at,
+ reviewed_at = NULL, reviewed_by = NULL`,
+ [listingId, contentHash, action, JSON.stringify(signals), JSON.stringify(spans), rationale || null, runId, s.iso()]);
+}
+
+/** The words did not change (a renewal, a report): the check stands; only its time moves, so the sweep skips it. */
+const recheckUnchanged = (s, listingId) => s.db.exec('UPDATE rent_listing_checks SET checked_at = $2 WHERE listing_id = $1', [listingId, s.iso()]);
+
+/** Active listings the screen has not read since they last changed (a failed run, AI unreachable), oldest first. */
+const unscreenedActive = async (s, limit) => (await s.db.many(
+ `SELECT l.id FROM rent_listings l LEFT JOIN rent_listing_checks c ON c.listing_id = l.id
+ WHERE l.state = 'active' AND (c.listing_id IS NULL OR c.checked_at < l.updated_at) ORDER BY l.id LIMIT $1`, [limit])).map((r) => r.id);
+
+/** The flagged queue: listings the screen put up for review that no member of staff has looked at, newest check first. */
+const flaggedQueue = (s, { limit = 50 } = {}) => s.db.many(
+ `SELECT l.*, c.signals AS check_signals, c.spans AS check_spans, c.rationale AS check_rationale, c.checked_at AS check_checked_at
+ FROM rent_listing_checks c JOIN rent_listings l ON l.id = c.listing_id
+ WHERE c.action = 'review' AND c.reviewed_at IS NULL AND l.state <> 'removed' ORDER BY c.checked_at DESC, l.id DESC LIMIT $1`, [limit]);
+
+const countFlagged = async (s) => Number(await s.db.value(
+ "SELECT count(*) FROM rent_listing_checks c JOIN rent_listings l ON l.id = c.listing_id WHERE c.action = 'review' AND c.reviewed_at IS NULL AND l.state <> 'removed'"));
+
+/** A member of staff looked at a flagged listing and left it up. */
+const markCheckReviewed = (s, listingId, by) => s.db.exec(
+ "UPDATE rent_listing_checks SET reviewed_at = $2, reviewed_by = $3 WHERE listing_id = $1 AND action = 'review' AND reviewed_at IS NULL", [listingId, s.iso(), by]);
+
// ── Saved searches ─────────────────────────────────────────────────────────────────────────────────────────────
// ── Saved searches ─────────────────────────────────────────────────────────────────────────────────────────────
async function saveSearch(s, { id, subject, filters, key, createdAt }) {
async function saveSearch(s, { id, subject, filters, key, createdAt }) {
@@ -205,6 +244,7 @@ module.exports = {
@@ -205,6 +244,7 @@ module.exports = {
insert, get, search, countMatching, facets, update, expireListings, renew, setState, remove,
insert, get, search, countMatching, facets, update, expireListings, renew, setState, remove,
countActiveByOwner, countCreatedToday, listByOwner,
countActiveByOwner, countCreatedToday, listByOwner,
reportsFor, addReport, staffQueue, staffCounts,
reportsFor, addReport, staffQueue, staffCounts,
+ getCheck, putCheck, recheckUnchanged, unscreenedActive, flaggedQueue, countFlagged, markCheckReviewed,
saveSearch, listSaved, getSaved, deleteSaved, markSavedSeen,
saveSearch, listSaved, getSaved, deleteSaved, markSavedSeen,
DAY_MS,
DAY_MS,
};
};
@@ -38,7 +38,7 @@ async function boot(opts = {}) {
@@ -38,7 +38,7 @@ async function boot(opts = {}) {
const { createStore } = require('../../server/db');
const { createStore } = require('../../server/db');
const testdb = await require('./db').testDb();
const testdb = await require('./db').testDb();
const store = createStore(testdb.db, { now: opts.now });
const store = createStore(testdb.db, { now: opts.now });
- const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, indexnow: opts.indexnow, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, accountSend: opts.accountSend, housingFetchImpl: opts.housingFetchImpl });
+ const built = await createApp({ config, store, log, ai: opts.ai, telemetrySink: opts.telemetrySink, indexnow: opts.indexnow, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, accountSend: opts.accountSend, housingFetchImpl: opts.housingFetchImpl });
await built.ctx.keys.ensure();
await built.ctx.keys.ensure();
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;
const base = `http://127.0.0.1:${server.address().port}`;
new file mode 100644
new file mode 100644
@@ -0,0 +1,132 @@
@@ -0,0 +1,132 @@
+'use strict';
+/**
+ * The AI listing screen (server/listings/screen.js): each listing a person posts or edits is read by OpenVibe.AI's
+ * rent.screen_listing (a stand-in here) for the signs of a rental scam, never with the poster or the contact link; a
+ * `review` puts it in the staff page's flagged queue with the signs and the quoted words, and nothing else changes for
+ * anyone; staff mark it "Looks fine"; the same words are never screened twice, new words are; a failed run is picked
+ * up by the sweep; the output is bounded; without OpenVibe.AI nothing is screened.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { SAME, post } = require('./helpers/rent');
+const store = require('../server/listings/store');
+const { createScreen, readOutput } = require('../server/listings/screen');
+
+const SCAM = 'I am abroad for work so I cannot show it. Send the deposit by wire transfer today and I will mail you the keys.';
+
+(async () => {
+ const calls = [];
+ let failing = false;
+ const ai = {
+ run: async (key, input, opts) => {
+ calls.push({ key, input, opts });
+ if (failing) throw new Error('AI unreachable');
+ const scam = /wire transfer/.test(input.listing.description);
+ return {
+ runId: `run_${calls.length}`,
+ output: scam
+ ? { action: 'review', signals: ['owner_unavailable', 'payment_before_viewing', 'unusual_payment', 'made_up'], rationale: 'Pay by wire before any viewing; the owner is away.', spans: [{ quote: 'Send the deposit by wire transfer today', signal: 'payment_before_viewing' }] }
+ : { action: 'allow', signals: ['none'], rationale: '', spans: [] },
+ };
+ },
+ };
+ const t = await boot({ ai });
+ const s = t.ctx.s;
+ const kim = t.network.addUser('kim');
+ const ada = t.network.addUser('ada', { role: 'admin' });
+ let fine;
+ let scam;
+
+ try {
+ await check('a posted listing is screened once, as a renter reads it: never the poster or the contact link', async () => {
+ fine = (await post(t, kim)).json();
+ await t.ctx.screen.settle();
+ assert.strictEqual(calls.length, 1);
+ assert.strictEqual(calls[0].key, 'rent.screen_listing');
+ assert.strictEqual(calls[0].input.listing.title, 'Sunny two-bedroom flat near the park');
+ const sent = JSON.stringify(calls[0].input);
+ assert.ok(!sent.includes('example.com/rooms') && !sent.includes('usr_'), 'no contact link and no poster go to AI');
+ assert.deepStrictEqual(calls[0].opts.target, { service: 'rent', type: 'listing', id: fine.id });
+ assert.match(calls[0].opts.idempotencyKey, new RegExp(`^rent-screen-${fine.id}-[0-9a-f]{32}$`));
+ const c = await store.getCheck(s, fine.id);
+ assert.strictEqual(c.action, 'allow');
+ assert.deepStrictEqual(c.signals, []);
+ assert.strictEqual(await store.countFlagged(s), 0);
+ });
+
+ await check('a listing with scam signs is flagged for staff with the signs and the quoted words; it stays listed', async () => {
+ scam = (await post(t, kim, { title: 'Room in the centre', description: SCAM })).json();
+ await t.ctx.screen.settle();
+ const c = await store.getCheck(s, scam.id);
+ assert.strictEqual(c.action, 'review');
+ assert.deepStrictEqual(c.signals, ['owner_unavailable', 'payment_before_viewing', 'unusual_payment'], 'an unknown sign is dropped');
+ assert.strictEqual(await store.countFlagged(s), 1);
+ assert.strictEqual((await t.get(`/api/v1/listings/${scam.id}`)).status, 200, 'still public: a flag only asks staff to look');
+ const page = await t.get('/staff?state=flagged', { as: ada });
+ assert.strictEqual(page.status, 200);
+ assert.ok(page.text.includes('Room in the centre'));
+ assert.ok(page.text.includes('payment before a viewing') && page.text.includes('an owner who cannot show the place'));
+ assert.ok(page.text.includes('Send the deposit by wire transfer today'), 'the quoted words');
+ assert.ok(page.text.includes(`action="/listings/${scam.id}/check"`), 'Looks fine');
+ assert.ok(!page.text.includes('Sunny two-bedroom flat'), 'an allowed listing is not in the flagged queue');
+ assert.ok((await t.get('/staff', { as: ada })).text.includes('1 flagged by the AI screen'));
+ });
+
+ await check('only staff see the flagged queue or mark a listing reviewed', async () => {
+ assert.strictEqual((await t.get('/staff?state=flagged', { as: kim })).status, 403);
+ assert.strictEqual((await t.get(`/listings/${scam.id}/check`, { as: kim, method: 'POST', form: {}, headers: SAME })).status, 403);
+ assert.strictEqual(await store.countFlagged(s), 1);
+ });
+
+ await check('Looks fine takes it out of the queue; the same words are not screened again', async () => {
+ const r = await t.get(`/listings/${scam.id}/check`, { as: ada, method: 'POST', form: {}, headers: SAME });
+ assert.strictEqual(r.status, 303);
+ assert.strictEqual(await store.countFlagged(s), 0);
+ assert.match((await store.getCheck(s, scam.id)).reviewed_by, /^user:usr_/);
+ const before = calls.length;
+ await store.update(s, scam.id, { bedrooms: 1 }); // not something the screen reads: same words
+ await t.ctx.screen.settle();
+ assert.strictEqual(calls.length, before, 'unchanged words: no run');
+ assert.strictEqual(await store.countFlagged(s), 0, 'and the review stands');
+ assert.deepStrictEqual(await store.unscreenedActive(s, 10), [], 'nor does the sweep pick it up');
+ });
+
+ await check('new words are screened again, and a new flag is unreviewed', async () => {
+ await store.update(s, scam.id, { description: `${SCAM} Also, no calls please.` });
+ await t.ctx.screen.settle();
+ assert.strictEqual(calls[calls.length - 1].input.listing.description.endsWith('no calls please.'), true);
+ assert.strictEqual(await store.countFlagged(s), 1);
+ });
+
+ await check('a failed run leaves no check; the sweep screens the listing once AI answers again', async () => {
+ failing = true;
+ const late = (await post(t, kim, { title: 'Parking space downtown', kind: 'parking', description: 'Covered space, monthly.' })).json();
+ await t.ctx.screen.settle();
+ assert.strictEqual(await store.getCheck(s, late.id), null);
+ assert.deepStrictEqual(await store.unscreenedActive(s, 10), [late.id]);
+ failing = false;
+ const out = await t.ctx.screen.sweep();
+ assert.deepStrictEqual(out, { screened: 1, seen: 1 });
+ assert.strictEqual((await store.getCheck(s, late.id)).action, 'allow');
+ });
+
+ await check('the output is bounded: review without a known sign is allow; quotes are trimmed and capped', () => {
+ assert.deepStrictEqual(readOutput({ action: 'review', signals: ['none'] }), { action: 'allow', signals: [], spans: [], rationale: '' });
+ assert.strictEqual(readOutput(null).action, 'allow');
+ const many = readOutput({ action: 'review', signals: ['pressure'], spans: Array.from({ length: 30 }, () => ({ quote: ` ${'x'.repeat(400)} `, signal: 'nope' })) });
+ assert.strictEqual(many.spans.length, 10);
+ assert.strictEqual(many.spans[0].quote.length, 300);
+ assert.strictEqual(many.spans[0].signal, null);
+ });
+
+ await check('without OpenVibe.AI nothing is screened and nothing starts', async () => {
+ const off = createScreen({ s, ai: null });
+ off.touch(fine.id);
+ assert.strictEqual(off.enabled, false);
+ assert.strictEqual(off.start(), false);
+ assert.deepStrictEqual(await off.sweep(), { screened: 0 });
+ assert.strictEqual(await off.screenId(fine.id), 'off');
+ });
+ } finally { await t.close(); }
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });