Welcome to Camp Sasquatch, a campground that does not exist, run by a cryptid on a diet, where site 42 is called "Mossy Nook" and the raccoons have their own policy page. It was vibe coded in an afternoon and it is very silly.
It also does one entirely serious thing: it proves the BC Services Card app (BCSC) v4.1 can hold a Digital Services Card and present it to a real verifier over a real DIDComm connection.
The sasquatch is fake. The tent is fake. Gary at the front desk is fake. The Aries agent, the DIDComm connection, the AnonCreds proof request, and the credential that comes back off your phone are all completely real.
Locals camp cheaper β it's the law of the land (and also our pricing policy). So before you can book, Camp Sasquatch wants to know you're a British Columbian.
Instead of a form, a photocopier, and a conversation with Gary, you scan a QR code. Your wallet shares your Digital Services Card, and the resident rate unlocks: $122.00 β $96.00, live on the page.
That's the whole point of the silliness β it turns "a credential was verified" from an abstract green tick into a number that visibly drops. Requesting the Digital Services Card is also what triggers BCSC v4.1 to automatically fetch it if the holder doesn't already have one.
A note on names. The credential is the Digital Services Card (formerly "Person"). Its on-ledger objects still carry the old name β the schema is
Person:1.0and the credential definition isPersonSITβ so you'll seePersonthroughout the identifiers in the code. Those are ledger facts and are left untouched; only the human-facing name changed.
Demo only. This talks to a BC Digital Trust sandbox tenant and cuts corners that are called out under Demo shortcuts. Do not use it as a template for a production verifier.
Requires pnpm and Node 20+.
pnpm install
cp .env.sample .env.local # then fill in your Traction tenant id + api key
pnpm devOpen http://localhost:3000 and click Prove I'm a British Columbian, then scan the QR code with the BC Services Card app.
You need a Traction tenant and a wallet holding a Digital Services Card.
pnpm install prints Ignored build scripts: sharp. That's expected and safe to
leave β sharp only matters for next/image optimization, and this app renders the
QR with a plain <img> and a data URI. Production builds succeed without it.
POST /api/session
ββ POST /out-of-band/create-invitation (handshake: didexchange/1.0,
ββ returns invitation_url, rendered as a QR goal_code: aries.vc.verify)
GET /api/session/:id (browser polls every 2s)
1. GET /connections?invitation_msg_id=β¦ wait for state active|completed
2. POST /present-proof-2.0/send-request sent once, on connect
3. GET /present-proof-2.0/records/:id until state=done
verified === "true" β reveal given_names, apply discount
UI statuses: awaiting_scan β connecting β sending_request β awaiting_proof β
verifying β verified, or presented_unverified / declined / rejected /
error.
All optional except the tenant credentials. See .env.sample for the full list.
| Variable | Default | Purpose |
|---|---|---|
TRACTION_BASE_URL |
sandbox proxy | Traction tenant proxy base URL |
TRACTION_TENANT_ID |
β | Tenant UUID (required) |
TRACTION_API_KEY |
β | Tenant API key (required) |
VERIFIER_LABEL |
Camp Sasquatch |
Name the wallet shows for the connection |
PROOF_FORMAT |
indy |
indy or anoncreds attachment format |
RESTRICT_BY |
creddef |
Pin to a cred_def_id or a schema_id |
REQUIRE_NON_REVOKED |
true |
Send a non_revoked interval |
DEMO_TRUST_PRESENTED |
true |
Unlock the discount without a signature check |
WEBHOOK_API_KEY |
β | Shared secret for the webhook receiver |
Next.js does not hot-reload .env.local β restart the dev server after changes.
The credential exchange works end-to-end against a real BC Services Card wallet: the
connection forms, the proof request arrives, and the holder shares given_names.
The final signature check does not run.
The Digital Services Card (PersonSIT on the ledger) is revocable. Verifying a
proof from it requires resolving its revocation registry from the ledger, and on this
tenant that fails:
POST /present-proof-2.0/records/{id}/verify-presentation
β 500 Server got itself in trouble
auto_verify hits the same exception on receipt, so the exchange record stalls at
presentation-received and never reaches done.
This reproduces with a raw curl straight at Traction, with this app entirely out of
the loop β it is a Traction/ACA-Py issue, not an application bug. Everything
verification needs resolves except the revocation registry: schema β
, cred def β
,
issuer DID β
, revocation registry β.
| Attempt | Result |
|---|---|
non_revoked interval |
Fixed the holder side β timestamp went null β real value. Verifier still 500s. |
anoncreds request format |
Matches the tenant's askar-anoncreds profile. Did not work. |
Person 2.0 schema (QEquAHkM35w4XVT3Ku5yat:2:Person:2.0) |
Did not work. |
The first is kept (it is correct regardless). The other two were reverted but survive
as PROOF_FORMAT and RESTRICT_BY so they are one env var away.
Settling the root cause needs the ACA-Py server logs from the sandbox, which are not accessible from here.
lib/flow.js does not wait forever. After VERIFY_GRACE_MS it surfaces the revealed
attributes as presented_unverified rather than spinning.
DEMO_TRUST_PRESENTED=true then lets that state unlock the discount, so the demo
still lands. It is a presentation-layer concession only:
- The server never rewrites the status.
presented_unverifiedstayspresented_unverified, andverifiedstill means a real signature check. The flag only tells the UI it may celebrate. - The panel still tells the truth: amber dashed box, heading reads "Received as" rather than "Verified as", and the body says the signature was never checked. A genuine verification shows a solid green box and "Verified as".
If verification ever starts working, the real green panel appears automatically with no code change.
Webhooks work and were verified end-to-end, driving the full
invitation β request β response β active β request-sent lifecycle.
They are still not the better choice for a live demo. During testing the
trycloudflare.com quick-tunnel hostname changed while the tenant pointed at the old
one, and delivery silently stopped. The demo carried on because polling took over β
which is the argument for polling. A tunnel adds a moving part that fails quietly
mid-presentation, and buys about two seconds of latency.
Both drivers call the same state machine in lib/flow.js, so they compose: whichever
signal arrives first wins, and if the tunnel dies polling resumes within ~6s
(WEBHOOK_TRUST_MS).
cloudflared tunnel --url http://localhost:3000 # 1. expose localhost
node scripts/webhook.mjs set https://<random>.trycloudflare.com # 2. register
node scripts/webhook.mjs clear # 3. always clean upscripts/webhook.mjs show prints the current config without changing anything.
- The tenant ships with
wallet.dispatch_type: "base", which dispatches only to the innkeeper. Settingwallet_webhook_urlsalone does nothing. The script setsboth, adding our URL while leaving base dispatch intact. - ACA-Py POSTs to
<url>/topic/<topic>/β with a trailing slash.next.config.mjssetsskipTrailingSlashRedirectso the route matches directly rather than relying on the agent to follow a 308. WEBHOOK_API_KEYbecomes a#fragmenton the URL; ACA-Py returns it asx-api-key. Without it, anyone who finds the tunnel could POST a forged event.- Quick-tunnel hostnames change on restart and can change while running.
- Always
clearwhen finished, or the tenant is left pointing at a dead host.
| Path | Purpose |
|---|---|
lib/traction.js |
Traction/ACA-Py client; caches the tenant JWT |
lib/proofRequest.js |
Builds the proof request; restriction lives here |
lib/flow.js |
The connect β prove β verify state machine |
lib/sessions.js |
In-memory session store + webhook lookup indices |
app/page.js |
The booking page and its UI states |
app/api/session/route.js |
Mints the invitation + QR |
app/api/session/[id]/route.js |
Reports status; polls Traction when needed |
app/api/webhook/[...path]/route.js |
ACA-Py webhook receiver |
scripts/webhook.mjs |
Set / clear / show the tenant webhook config |
- Sessions are in-memory: a restart loses them, and it will not survive more than one server process.
- The nonce is fixed at
1234567890to match the originally supplied payload. A real verifier must generate a fresh random nonce per request. RESTRICT_BY=schemaaccepts a Digital Services Card from any issuer on that schema. The defaultcreddefis the tighter, more correct restriction.auto_remove: falseon proof requests keeps exchange records for inspection, so they accumulate in the tenant and never self-clean.DEMO_TRUST_PRESENTEDβ see above. Never enable outside a demo.- "Lock in my spot" is inert; there is no booking or payment behind it.
Vibe coded, as advertised. Roughly this brief:
Vibe-code me a single-page demo: a silly fake campsite booking site
(Next.js, plain CSS) where you scan a QR to prove you're a BC resident,
and the price drops when it works.
Flow: establish a DIDComm connection first, then send this proof request:
{
"name": "proof-request",
"nonce": "1234567890",
"version": "1.0",
"requested_attributes": {
"studentInfo": {
"names": ["given_names"],
"restrictions": [
{ "cred_def_id": "7xjfawcnyTUcduWVysLww5:3:CL:28075:PersonSIT" }
]
}
},
"requested_predicates": {}
}
Traction sandbox β use it for all the credential heavy lifting. Docs at
https://traction-sandbox-tenant-proxy.apps.silver.devops.gov.bc.ca/api/doc
tenant 976173eb-β¦, key 3d8313fdβ¦ (sandbox creds, don't worry about them,
but put them in .env.local, not in .env.sample, use placeholders there).
Check feasibility first before building. pnpm, git repo, Apache 2.0.
The one thing no prompt could have anticipated is the verification limitation β that only surfaced once a real phone presented a real credential, and finding it was most of the work.
Copyright 2026 Fullboar Creative Corp.
Licensed under the Apache License, Version 2.0 β see LICENSE.