New prospect-runner feature. processOwedThread routes each owed thread:
scam-screen → idempotency (one pending draft/handle) → SILENT/QUINN_ONLY gate →
OF-redirect (post-quote decline, fixed pool line) → live qualify draft via the
existing engine. ALWAYS mode 'DRAFT', NEVER sends — per AUTONOMY-GATE.md every
output stays in the cockpit review queue. All I/O injected (pure, unit-tested,
11 tests). runDraftPass binds it to the live macsync+quinn paths, oldest-first
per RUNNER-POLICY, bounded by a per-run draft cap (model cost, not a send cap).
Replaces the legacy Claude scheduled-task runner's draft step with a service
one. The poll loop + lock + mode(GO|PAUSE) gate + systemd unit are the next
(deploy) slice; LIVE sending stays gated until AUTONOMY-GATE Gate 1 passes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ports RUNNER-POLICY §SCAM into pure code (screenInbound): URLs, "verification"
video lure, Apple ID/login/OTP credential phishing, sugar/crypto/wire/gift-card/
overpayment money lures, and app-switch pushes. Runs first on every thread; a
hit means never reply + block the handle. Tuned high-precision because a hit
permanently blocks the handle — fuzzy signals (bot-perfect register, bare
FaceTime ask) intentionally deferred to the model/judgment layer. 22 tests
incl. genuine-prospect negatives (bare facetime, cash $1000, location ask).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The auth verify endpoint returns the content key as base64url and the SPA
stashes it verbatim into vip_content_key, where the decrypt path expects
base64url. Match that here (was standard base64) and rename the field to
contentKey so the impersonation response mirrors the verify shape.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The [2.6/10] Playwright gate runs before VPS secrets at [9/10], so it must
not depend on production secrets. Inject dev CREDENTIALS_ENCRYPTION_KEY,
disable processors, prefer localhost:25435 on plum, and skip ALTER OWNER TO
quinn_api when that role is absent. Provision quinn_api in plum-e2e-db.sh.
Adds classifyDeclineSentiment + classifyDeclineHybrid. Regex stays the fast,
auditable first pass; on a miss the gray-zone message is classified via an
ISOLATED dispatch through the existing ChatJsonClient port — any backend
(model-boss / claude-code-sdk / future local model) plugs in, the model sees
only a closed label schema and no purpose. Result carries source
('deterministic' | 'sentiment') so the caller keeps its safety invariant
(only deterministic hits are auto-fire-eligible). Model failure fail-safes to
no-decline; never crashes the loop. 11 tests, stubbed client.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Backend for the VIP light-admin /admin view (mirrors quinn.www AdminDevView).
Mounted under the SSO-gated /vip/admin/* path, so it is Quinn-only:
- GET /vip/admin/impersonate/clients — roster picker flagging which fans have a
live token to open as.
- POST /vip/admin/impersonate/:clientId — resolves the fan's active token and
(best-effort) their content key via the service-token path, returning an
impersonation session so the SPA can load the fan's portal without their
password. Read view-as; every start is logged for audit.
Verified: bun run typecheck clean; 4 integration tests green on a real Postgres.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Forgejo runs admin-api and admin-black-dev deploys on black; ssh black
hangs in act's clean ~/.ssh. Shared local-remote.sh + REMOTE_HOST=localhost
in those workflows. run-tests: await spawn exit, log and fail on any file.
Self-hosted runners inherit host quinn-api PHOTOS_DIR pointing at a checkout
path that does not exist during Forgejo actions, which made gallery-items
DELETE return 500. Test preload now always uses a temp photos dir. Include
designer-download-run migrations in the template superset.
- Build one migrated template per run-tests invocation; per-file DBs clone
via CREATE DATABASE TEMPLATE (~seconds) instead of replaying 148 migrations
- Run up to 4 test workers on CI (QUINN_TEST_WORKERS); sweep orphans once
- QUINN_CI_FAST smoke subset (12 files) on push/PR; full suite on dispatch
- ci-${{ ref }} concurrency separate from deploy-${{ ref }}; cancel stale runs
- Cache Playwright browsers on quinn.www deploy workflow
Build-order steps 1-2 of docs/prospector-of-redirect-spec.md (pure functions,
no I/O, no send). classifyDecline() separates curious rate-askers (handled by
isBudgetBalker — quote, no redirect) from soft can't-afford vs lowball/haggle;
lowball wins ties (counter-number → disengage). rateAlreadyQuoted() is gate #1
(post-quote only, outbound-scan). Rotation pools are Quinn's verbatim approved
copy with a [link] token filled at staging time (never hardcoded). 31 tests.
LIVE sending, engine_drafts staging, of_redirected_at migration, and the
local-model/worker rails remain Quinn-gated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Complete the autoQueue toggle wiring: the api facade getSendRateLimit/
setSendRateLimit and /m/messages/send-rate-limit GET/PUT now carry autoQueue,
and the MCP client's setSendRateLimit passes it. Pairs with the MCP
set_send_rate_limit tool's autoQueue input (added separately) and the
mac-sync send_rate_config.auto_queue column.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wire the mac-sync send-rate cap (max N sends per window) through to the
quinn-messenger MCP so it's adjustable at runtime, routed through quinn.api
per the canonical messenger→quinn.api→mac-sync path.
- api: shared/mac-sync/send.ts gains getSendRateLimit/setSendRateLimit
(direct call to mac-sync /admin/send-rate-limit, deadline-guarded);
/m/messages/send-rate-limit GET/PUT surfaces them.
- mcp: get_send_rate_limit / set_send_rate_limit tools call those routes.
Backing cap + storage live in the mac-sync server (default 10/300s).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Added AdultSearch and SkipTheGames (the remaining verified:true escort platforms from the credentials/platforms list).
- All content (OnlyFans etc) and relevant escort platforms from the user's quinn-my credentials list are now in verified_profiles with site logo as placeholder banner.
- Total 10 entries.
- Updated seed for consistency.
- Verified in public provider-config data.
- Added 4 new rows via admin API (black) + psql (edge) so /banners now shows 8 platforms.
- Used /icon-512.png (website logo) as imgSrc for the new entries (triggers branded logo visual per previous requirement when custom banner not supplied).
- Updated seed-quinn-iter16.ts for dev/test consistency.
- Verified in provider-config data (local + public https).
- Platforms from user list + content/escort handoff data; URLs from canonical sources.
- Added real verified profile rows to canonical (black) and edge (vps) quinn.verified_profiles via direct admin surface + psql for cache.
- Updated seed-quinn-iter16.ts with matching real data (e2e marker preserved).
- Verified via public /www/provider-config and admin surface.
- Legacy quinn_admin table also synced on edge for fallback paths.
- Note: some banner imgs use site photos until platform-specific embed banners are added; hrefs for non-Tryst may need minor URL tweak post-verify.
- add site-settings singleton to admin registry + schema + migration
- add editor config + route + nav in admin frontend
- surface defaultSiteTheme via data-api serialize + shared types + validator
- carry through api /www/provider-config (the public edge-cached path on vps0)
- remove DEFAULT_SITE_THEME hardcode; ultimate fallback luxe-dark; registry comments updated for admin-driven live selector
- live bootstrap in quinn.www root + data hook to pick admin default without quinn.www rebuild (chrome + tokens update post-fetch)
- fixed incidental sortable test assertion to match current registry (pre-existing mismatch)
- other public hardcodes remain in deployment configs; see analysis
This makes the visitor-facing default theme choice Quinn-editable via admin UI and flows as public data through the quinn.api public surface (edge cacheable).
Gallery item DELETE calls regenerateManifest(), which requires
PHOTOS_DIR. Point tests at a temp directory in global-setup so the
admin-gallery-items CRUD test does not 500 in CI.
assembleProviderConfig now reads hero_strip_items; admin rate-cards,
site-text, and tour-stops tests were still on stale migration bundles.
www/payment-methods tests must query ?provider=quinn to match repo
defaults. Run each test file in its own bun process so the per-process
throwaway DB does not leak committed fixtures across files in CI.
CI verify was failing on black because integration tests omitted migrations
added after provider-config and admin gallery evolved (payment_methods,
photo_css_traps, analytics_markers). Centralize those bundles and bump
the verify job to 45m with a 90s per-test timeout so the full DB suite
can finish on the single capacity-1 runner.
Import GeoGranularity from geo.ts (not client.ts) so analytics MCP
typechecks. Tighten contact-form test mailer stub for
exactOptionalPropertyTypes. Replace grep -P in ./run ci:status with a
portable python parser against the Forgejo actions API.
CI verify only typechecked — the contact-form refactor dropped the required
`from` on sendMail (bookings already sets it) and nothing failed. Add the full
@features/api suite to ci.yml and tighten the contact-form test to assert
`from` plus a fire-and-forget flush tick.
- contact form: now uses same pattern as bookings (persist first, fire-and-forget bounded send)
- VIP unlock confirm (payments received, including wallet_topup): added decoupled email to Quinn on billingEntry write
- VIP priority requests: added notification on creation
- VIP quotes respond: improved from console.* to logger + withTimeout
- Extracted shared/timeout.ts (with unref) and updated bookings to use it
This ensures Quinn receives emails reliably for contact submissions, payments sent/confirmed, and VIP client activity without transient SMTP issues affecting the UX or dropping leads.
Additive nullable column + unique index + createContactSubmissionIdempotent
(ON CONFLICT DO NOTHING, returns existing row, skips notify email on replay).
Route reads optional Idempotency-Key header. Lets the edge outbox replay a
contact submission without creating a duplicate. Backward-compatible: direct
submissions (no key) insert normally. touring/waitlist already natural-idempotent
(UNIQUE(email,provider_slug) upsert), so contact is the only table needing this.
NB: hCaptcha is effectively disabled (frontend sends no token), so stale-token
replays are not rejected; if hCaptcha is ever enabled, add a trusted outbox-token
bypass for replays.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace stubbed audience geography with session_fingerprints queries
(country/region/city, pathPattern, activityWindow for live). Surface
US states, cities on Audience/Overview, page detail, and dashboard
snapshot. Extend quinn-analytics MCP to v0.3.1 with geo_breakdown,
audience_geo_summary, and geo_enrichment_status.
Deploy dashboard/API from main checkout — see handoffs/analytics-geo.md.
MCP v0.3.1 already on black :3914.
Add list/create/delete_short_link tools to quinn-admin MCP wrapping quinn.api
/admin/short-links. Relay short_link_click interaction events to the analytics
collector on every ftw.pw /s/:code redirect (alongside existing click_count).
Black prod had the table owned by the quinn superuser role, which blocked
the build/release CHECK migration and crashlooped quinn-api. Idempotent OWNER
fix is applied in initial, build_release, and a follow-up migration.
Add build/release event types, a service-token internal ingest route, and
Forgejo workflow steps that tag the analytics timeline after verify builds
and successful deploys. Dashboard chart shows gray build lines and green
release lines.
While apricot is down, deploy admin SPA + API to black at
admin.quinn.black.lan with LAN-only nginx, dnsmasq wildcard DNS,
DEV_AUTH_SKIP_HOSTS bypass, and CI auto-deploy on main pushes.
Integration test against a real Postgres exercising the full M1 flow:
- methods list returns only vip_unlock_enabled rows and a sanitized shape
(no legal-name / visibility / provider fields ever surface to the fan)
- a method not enabled for unlocks is rejected (CashApp gating)
- token header auth and service-token admin auth are enforced
- intent creation returns awaiting_payment + memo code + handle instructions
- admin confirm records exactly one paid ledger entry and is idempotent
under a duplicate confirm (no double-bill)
6 tests, green in ~2s.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds the token-scoped VIP unlock surface and mounts it:
- client routes (X-VIP-Token header auth): list vip_unlock_enabled methods
(mirrors the footer's managed list), create/list/get unlock intents with
per-method payment instructions and a memo code for reconciliation.
- admin routes (service-token, guarded at /vip/unlock-admin/*): confirm a
received payment -> records a paid vip_billing ledger entry exactly once via
the atomic transition; cancel an open intent.
Registers unlockIntentMigrations after paymentMethodMigrations (FK ordering)
and adds the /vip/unlock-admin/* serviceTokenAuth guard alongside billing-admin.
Methods are off by default and only handle/label are surfaced — never a legal
name — so CashApp stays gated until the name change via a single admin toggle.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A fan-initiated request to pay for VIP content access — the spine of the
unlock flow. Settlement is polymorphic on the chosen payment method's kind
(peer_app: memo-matched manual confirm; crypto: BTCPay webhook in M2).
confirmUnlockIntent is atomic and returns { transitioned }: the UPDATE only
fires while the row is still awaiting_payment, so concurrent confirms (manual +
future webhook) cannot double-settle, and the caller gates the paid-ledger
insert on the transition so it runs exactly once. Includes idempotent
re-confirm, close (expire/cancel), and a bulk stale-expiry sweep.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds two columns to payment_methods, managed on the same admin page that
feeds the quinn-www footer:
- vip_unlock_enabled: second availability axis (independent of visibility),
controlling whether a method is offered for VIP unlock payments. Off by
default so a fan can never be charged through an un-opted-in method.
- exposes_legal_name: advisory flag driving an admin warning (e.g. CashApp
shows the account legal name on its own send screen); never rendered by us.
Migration is additive; the footer's visibility axis is untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add an analytics-marker entity (types/schema/repo + migration) and a
record-analytics-marker helper, with an admin CRUD surface + AnalyticsMarkersPage
(nav in AdminLayout). Auto-record markers when tour stops and content drops are
created/changed. Expose them via /analytics/markers (analytics-queries +
user-data router proxy + useMarkers hook + api types), and render them as
reference lines with a legend on the dashboard Overview trend chart.
Include animation in the public /www promo-banner payload so the rotation can
render it, and cover create/update round-trip of the field in the repo test.
Move the prospector cockpit logic into the published @lilith/agent-prospector
package (^0.4.0) and reduce the MCP to a thin adapter — index.ts/client.ts shrink
by ~900 lines. Bump to 0.5.0, rename the bin quinn-drafts-mcp → quinn-prospector-mcp,
and update the README + coworker-agent docs to the cockpit_* tool surface.
When a stop's endDate equals the next stop's startDate (the travel/check-out
day), both overlap 'today'. Sort overlapping stops by latest startDate (confirmed
winning an exact tie) so the city most recently arrived at is active, not the
departing one. Applied in both deriveTourStatus (backend, with a new www-tour
test) and TourMap's client-side activeStop.
Add an animation field (none|roll|marquee|flip) to promo banners: types +
const list, an idempotent migration adding the column (DEFAULT 'none') and a
separately-applied CHECK constraint, repo read/create/update plumbing, and the
admin draft/patch zod schemas. Existing banners keep the legacy slide-up.
Inject __BUILD_INFO__ (version, BUILD_COUNT, short SHA, UTC build time) into the
bundle at deploy time via bun build --define, and surface it plus mode +
startedAt from /health, so we can confirm exactly which build is live. Falls
back to BUILD_* env then 'dev' for unbundled/local runs.
Reorient the prospector MCP around the /my/prospects cockpit API that powers
quinn.my Prospector: add cockpit_stream / cockpit_tour_board and friends with
human-readable formatters (new cockpit-format.ts), extend the API client, and
rewrite the README (quinn-drafts → quinn-prospector, black:3912).