Let me be honest with you, I missed a lot of previous week's blog post.
I had been a lot busy in doing a lot of stuffs of my university (My B.Tech presentations and internship companies visiting on campus)
But here we are, and I think what happened in these two weeks is genuinely worth talking about.
So let me take you through it properly.
Where we left off
By the end of Week 7, I had a working Chrome extension — the Heka Web Wallet. It could generate a cryptographic key pair, derive a did:jwk identity from it, talk to the Heka identity service, and store SD-JWT Verifiable Credentials locally. The OID4VCI pre-authorized code flow worked end-to-end. I was pretty happy with it.
What I hadn't done was clean it up. The code worked, but the PRs were a mess — layers of fix commits stacked on top of each other, TypeScript errors silently lurking, and some architectural decisions that my mentor Alexander correctly pointed out didn't quite make sense.
These two weeks were about fixing all of that. And I learned more from fixing it than I did from building it the first time.
The architectural conversation
Alexander had flagged something important in his review of PR #184: the GPG challenge module and the GitHub OAuth module were both living inside heka-identity-service. His point was simple and correct — those are authentication concerns, and they belong in heka-auth-service. The Identity Service should be a consumer of contributor data, not the place where that data is created.
At the time I read it, I understood intellectually. When I actually moved the modules, I felt it. Suddenly the Identity Service's ContributorBinding relationship made sense — it's a read-only reference to something the Auth Service owns. The API boundary became cleaner. The dependency direction made sense. The kind of thing that seems like bikeshedding until you do it, and then you can't unsee it.
If you're building something on top of a framework like NestJS with multi-service architecture: the first version you write is almost never right about where things live. And that's fine, as long as you fix it before it calcifies.
The Credo question
The second big thing Alexander raised was about the web wallet and Credo-TS. The current PR #195 implements the OID4VCI holder flow manually — about 350 lines of fetch calls, JWT proof-of-possession building, and base64url encoding. Alexander's question was: should we use @credo-ts/openid4vc instead? It would reduce that to ~20 lines.
That's a fair question. And honestly, my first instinct was to say yes — Credo is the framework the project is built on, it's the right abstraction layer, and using it for the holder would be consistent.
But I ran the spike first.
The express import problem that had originally made me hesitant? Completely resolved in Credo 0.7.0. The package ships a browser-field shim that Vite picks up automatically. No hacks needed. The build was clean.
The startup time was acceptable. The holder API (receiveCredentialFromOpenId4VciOffer) worked correctly against the real Heka issuer.
But then I checked the bundle size.
Adding @credo-ts/core brought the gzipped popup bundle from 53 KB to over 400 KB. That's a seven-fold increase in what the browser has to parse and execute before the popup renders. For a Chrome extension popup — something the user opens every time they want to see their credentials or receive a new one — that's a real UX problem. A popup that takes a noticeable moment to appear is a popup people stop using.
So I kept the hand-rolled implementation. It's more code for me to maintain, but it starts in milliseconds and it's self-contained. I documented the tradeoff clearly in the PR.
The lesson I keep relearning in this project: the right tool is the one that fits the constraints of where it runs. Credo is a great framework. It's just built for contexts where startup time and bundle size aren't the primary concern — like a server-side agent or a native mobile app. Not a Chrome extension popup.
The TypeScript trap
Here's something that cost me about two hours and I want to document it for anyone who runs into it.
In the contributor credential service, I had this:
const CONTRIBUTOR_CREDENTIAL_DISCLOSURE_FRAME = {
_sd: [...CONTRIBUTOR_CREDENTIAL_SELECTIVE_CLAIMS],
} as const
And I was passing it directly as the disclosureFrame argument to Credo's issuance API. Seemed clean, right? The object is a constant, the _sd array is derived from another constant.
The error:
Type 'readonly ["githubUsername", "gpgFingerprint"]' is not assignable to mutable type 'string[]'
The problem: as const makes _sd a readonly tuple. But Credo's type for the disclosure frame expects a mutable string[]. TypeScript treats these as fundamentally incompatible — a readonly type cannot be passed where a mutable one is expected, even if the values are the same.
The fix was simple once I understood it:
private buildDisclosureFrame(): { _sd: string[] } {
return { _sd: [...CONTRIBUTOR_CREDENTIAL_SELECTIVE_CLAIMS] }
}
Spread into a new mutable array, return with an explicit type annotation. Done.
The second error was related: I had removed a type cast that looked redundant:
format: CredentialFormat.SdJwt, // looks fine, right?
But TypeScript inferred this as CredentialFormat (the whole enum union), not CredentialFormat.SdJwt (the specific literal). And Credo's credential supported type uses a discriminated union keyed on the format field — so TypeScript couldn't match it. The fix:
format: CredentialFormat.SdJwt as CredentialFormat.SdJwt,
It looks like a no-op. It isn't. It forces TypeScript to narrow the type to the literal. These are the kinds of things that are genuinely non-obvious until you hit them.
The commit history lesson
I also spent real time this week cleaning up git history. The PRs had 6–7 fix commits stacked on the original feature commit. My mentor would have had to scroll through all of them to understand what the PR actually did.
Now each PR has a clean, meaningful history:
-
PR #192:
feat:+test:— two commits, clean -
PR #195:
feat:— one commit
This sounds like a small thing. It's not. A clear commit history is a gift to the person reviewing your work. It tells a coherent story instead of a stream of consciousness. And in open source especially, your reviewer's time is limited — the less cognitive load you put on them, the more likely they are to engage deeply with the actual substance of what you're building.
DCO sign-off, GPG signing, no unnecessary files in the diff — these aren't bureaucratic overhead. They're signals that you took the review seriously before asking someone else to take it seriously.
What's next
With PRs #192 and #195 in a clean state, the next piece is the verifier side — implementing OID4VP (OpenID for Verifiable Presentations). This is where a holder (the extension) proves something to a verifier (another website or service) without revealing more than necessary. It's the other half of the privacy story.
I'm also thinking about the side-panel architecture. Right now the wallet lives in a popup — which has the cold-start bundle size constraint I talked about. A Chrome side panel persists between navigations, which means initialization happens once and the UX is much smoother. If we move to that, the Credo argument becomes much stronger.
More on that next week.
Thanks for reading. If you're following along with the Hiero/LFDT project and have questions, feel free to reach out.
Top comments (0)