Skip to main content

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:

MethodPathBehaviour
GET/health{ status: "ok", service: "credentials" } — public, for healthchecks
POST/credentials/issueTakes { 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​

  1. Key management. VERAMO_KMS_SECRET is declared in .env.example but read by nothing. src/agent.ts wires Veramo's MemoryKeyStore, MemoryPrivateKeyStore, and MemoryDIDStore behind defaultProvider: "did:key" — with providers: {}, 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.
  2. DID lifecycle. issuerDid and subjectDid are supplied by the caller. Nothing creates, resolves, or registers a DID, and nothing maps a subjectDid to a persons row, so a credential cannot currently be issued for a known student without an external system supplying the DID.
  3. Persistence. Issued credentials are returned but not stored. There is no credential_records table, so there is no list of what was issued, no revocation, and no re-issue path.
  4. Tenant scoping, partially done. The tenant now arrives as a validated x-tenant-id header behind the internal token (a body-supplied tenantId is 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 in tenants, and is threaded into the Sentry scope. What is still missing is persistence: there is no credential_records table, so nothing is stored against that tenant. When it exists, that table will need to be in TENANT_SCOPED_TABLES — or deliberately excluded with a documented reason, since credentials are meant to be verifiable across institutions.
  5. Real verification. /credentials/[id] in apps/site is 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 against credential_records can then be layered on top.
  6. 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:

  1. Back Veramo's key manager with a persistent, KMS-backed store (VERAMO_KMS_SECRET is already provisioned as the seam), and create one issuer DID per tenant at onboarding rather than per request.
  2. Add a credential_records table: (id, tenant_id, subject_person_id, credential_type, claims, jwt, issued_at, revoked_at), written through mutation-service.ts so issuance lands in the audit trail.
  3. Make POST /credentials/issue resolve the subject from a personId instead of demanding a subjectDid the caller has to invent.
  4. 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/site call it to proof-check against the resolved issuer DID document, optionally cross-checked against credential_records for revocation. That gives an employer a URL that works with no account and no phone call, which is the entire value proposition.
  5. Then wire ABC/APAAR deposit as a queued, retryable side effect.