Credentials service (apps/credentials)
Status: issues a credential; does not yet manage identity, keys, or trust.
Pillar 5 is W3C Verifiable Credentials: every degree, certificate, and verified achievement cryptographically signed and independently checkable by an employer or another institution without calling the college. In production elsewhere already (University of Monterrey, AKTU's planned ~50,000 blockchain degrees in a single convocation), and the natural landing point for India's NEP2020 rail — ABC IDs, now merged into APAAR, wired to DigiLocker and the National Academic Depository.
What exists
A Hono service on port 4002 with two routes:
| Method | Path | Behaviour |
|---|---|---|
| GET | /health | { status: "ok", service: "credentials" } — public, for healthchecks |
| POST | /credentials/issue | Takes { issuerDid, subjectDid, claims } plus x-internal-token and x-tenant-id headers; returns a signed credential via Veramo with proofFormat: "jwt" |
Authentication
Like the agents service, this one is outside Better Auth and authenticates with the shared
INTERNAL_SERVICE_TOKEN as the x-internal-token header, gated on a sub-app so every route is
protected by construction. It fails closed (503) if the token is unset, and /health stays
public. It must additionally run with no published ports on an internal network — a credential
issuer reachable from the internet is a credential issuer anyone can use.
Because the route now verifies the tenant against the database, this service requires
RUNTIME_DATABASE_URL to start. It did not before — @campus/db was a declared dependency that
was never imported.
const verifiableCredential = await veramo.createVerifiableCredential({
credential: {
issuer: { id: body.issuerDid },
credentialSubject: { id: body.subjectDid, ...body.claims },
},
proofFormat: "jwt",
});
apps/site (Next.js) holds the public verification route at /credentials/[id]. The split is
deliberate: this service issues, the public site verifies, because verification has to be reachable
by an anonymous external party with no session. Note that the route is still a placeholder — it
renders the requested id and states that verification is not wired up yet. It performs no lookup and
no proof check.
What is missing, in order of importance
- Key management.
VERAMO_KMS_SECRETis declared in.env.examplebut read by nothing.src/agent.tswires Veramo'sMemoryKeyStore,MemoryPrivateKeyStore, andMemoryDIDStorebehinddefaultProvider: "did:key"— withproviders: {}, so no DID method provider is registered at all. In-memory keys do not survive a restart, and with no provider registered the agent cannot create a DID. The source comment flags the swap explicitly. This is the single blocker between "demo" and "usable": a credential signed by a key nobody retains cannot be verified later. - DID lifecycle.
issuerDidandsubjectDidare supplied by the caller. Nothing creates, resolves, or registers a DID, and nothing maps asubjectDidto apersonsrow, so a credential cannot currently be issued for a known student without an external system supplying the DID. - Persistence. Issued credentials are returned but not stored. There is no
credential_recordstable, so there is no list of what was issued, no revocation, and no re-issue path. - Tenant scoping, partially done. The tenant now arrives as a validated
x-tenant-idheader behind the internal token (a body-suppliedtenantIdis rejected rather than ignored, so a caller cannot believe they issued for one institution while the credential was scoped to another), is confirmed to exist intenants, and is threaded into the Sentry scope. What is still missing is persistence: there is nocredential_recordstable, so nothing is stored against that tenant. When it exists, that table will need to be inTENANT_SCOPED_TABLES— or deliberately excluded with a documented reason, since credentials are meant to be verifiable across institutions. - Real verification.
/credentials/[id]inapps/siteis a placeholder that prints the id and says so. There is no public verify route on the credentials service either — only/credentials/issue. Genuine verification means checking the JWT proof against the issuer's resolved DID document, which needs no database lookup and is the actual point of the format; a revocation check againstcredential_recordscan then be layered on top. - ABC / APAAR / DigiLocker integration. The three
ABC_API_*env vars are declared and unused. The architecture's guidance is to build this with graceful degradation (queue and retry) rather than as a hard dependency on the critical path, because it depends on government infrastructure reliability.
There is one more production warning baked into startup: with Veramo's in-memory stores, a restart
silently invalidates every credential signed with the previous key, and a credential whose issuer
key no longer exists cannot be verified by anyone later. apps/credentials therefore logs a loud
warning when NODE_ENV=production.
To finish it
The narrowest useful increment, in order:
- Back Veramo's key manager with a persistent, KMS-backed store (
VERAMO_KMS_SECRETis already provisioned as the seam), and create one issuer DID per tenant at onboarding rather than per request. - Add a
credential_recordstable:(id, tenant_id, subject_person_id, credential_type, claims, jwt, issued_at, revoked_at), written throughmutation-service.tsso issuance lands in the audit trail. - Make
POST /credentials/issueresolve the subject from apersonIdinstead of demanding asubjectDidthe caller has to invent. - Add a public verify route on the credentials service (the source comment on the site page notes
issuance and verification are deliberately separate concerns) and have
apps/sitecall it to proof-check against the resolved issuer DID document, optionally cross-checked againstcredential_recordsfor revocation. That gives an employer a URL that works with no account and no phone call, which is the entire value proposition. - Then wire ABC/APAAR deposit as a queued, retryable side effect.