How it is built
Next.js and TypeScript over Prisma and Solana. The interesting decisions are not in the stack, though — they are in which parts refuse to trust the browser, and in what is deliberately left out.
The shape of it
One Next.js application, server-rendered, with the rules of the product living in modules that import neither the database nor the framework. That separation is not decoration: it is what lets the rules be tested with no build step and no server, and it is why those modules are the ones worth reading first.
- lib/license.ts — canonical serialisation and hashing. Knows nothing about storage.
- lib/signing.ts — the message each party signs, and ed25519 verification.
- lib/payments.ts — settlement rules as pure functions, importing no network client. lib/payments-chain.ts holds the RPC client and decides nothing.
- lib/validation.ts, lib/status.ts, lib/auth-message.ts — what a valid licence is, what state it is in, and what signing in means.
The document is the record
A licence is serialised into canonical JSON — keys sorted in code-unit order at every depth, strings trimmed, platforms deduplicated and sorted, dates reduced to calendar days, money pinned to two decimals — and the SHA-256 of those exact bytes is its fingerprint.
The sorting is done in code rather than by convention, and that is the whole point. The documents used to depend on the order their object literals happened to be written in, which held right up until someone inserted a field in the middle: a one-line edit would have changed the hash of every licence issued afterwards, silently, with no test to catch it.
stableStringify(doc) → sha256 → documentHash
{"license_id":"HID-2026-8F3A92","likeness":{"face":true,"voice":true},
"parties":{"agency":"…","client":"…","creator":"…"},
"payment":{"amount":300,"currency":"USDC"},
"permissions":{"ai_generated_content":true,"ai_model_training":false,
"commercial_advertising":true,"sublicensing":false},
"restrictions":"…",
"schema":"human-id/ai-likeness-license",
"scope":{"campaign":"…","expiration_date":"2026-11-19",
"platforms":["Instagram","TikTok","YouTube"],
"start_date":"2026-08-21","territories":"EU"},
"version":"1.1"}Because the format is versioned, licences hashed under 1.0 keep their stored bytes and still verify against the hash they were signed with. A canonical format that cannot be revised is a format that will be revised by accident.
Signatures are verified, not recorded
Every signature is checked as ed25519 against the wallet claiming to have produced it, at the moment it is submitted, and stored only if it verifies. The message itself is kept verbatim rather than rebuilt on read — it is the exact byte sequence the wallet signed, so the signature stays checkable even if the licence row is later altered, and a mismatch between the two is precisely the tampering a verifier is looking for.
Four things the endpoint refuses, each of which was a real hole:
- A signature over a different message than the one this licence produces.
- One party's signature replayed as the other's — the role is bound into the signed bytes.
- A second wallet signing for a party that a first wallet has already bound.
- Re-signing. A signature is a commitment rather than a setting, and replacing one would silently discard evidence somebody may already have verified.
The chain is read, not believed
When an agency pays, the browser hands the server exactly one value: a transaction signature. Everything after that is the server's own work.
- Recipient, mint and amount are re-read from the licence row, never from the request.
- The transaction is fetched from an RPC node, with retries — a wallet returns a signature seconds before a node will admit to having seen it.
- The amount credited is derived from the chain's own pre and post token balance snapshots rather than by reading the instruction list, so a transfer split across instructions or routed through a CPI still counts correctly.
- The transfer must have executed, must have moved at least the licensed amount of the expected mint into the expected wallet, and if it carries one of our settlement memos, that memo must name this licence.
- Only then is a row written as verified. A failed check writes nothing at all.
The transaction signature is unique in the database, so one on-chain transfer settles exactly one licence: replaying somebody else's signature against a second licence hits the constraint before it can ever be believed. And because verification stands on its own, the same page accepts a pasted signature — a transfer sent from anywhere becomes a payment by being checked, not by being sent through this interface.
Status is derived, not stored
Only revocation is stored as a fact, because it is the one lifecycle event no other row can express. Every other state is computed from the rows that caused it: two signatures make a licence active, an expiration date in the past makes it expired.
The alternative — a status column updated by whoever remembers to — drifts away from its evidence the first time a code path forgets. Here it cannot: there is nothing to forget to update.
Validation happens before the hash
Every write goes through a pure validation layer that refuses documents which cannot mean anything, rather than documents that merely look unusual: a term that ends before it starts, a negative fee, a grant of neither face nor voice, a date string that JavaScript would quietly turn into an invalid date.
Judgement calls — a short term, a large fee — belong to the user. Before this existed, an unparseable date reached the database and surfaced as a server error, and a licence expiring before it began was accepted and hashed.
Demo mode is honest about itself
Without a funded wallet the entire walkthrough still runs, and every simulated step says so on its face. A demo signature is a genuine ed25519 signature over the real message, verified by the same code as any other — only the custody of the key differs. A demo payment is labelled Recorded, never Verified, everywhere it appears, including on the public certificate.
That distinction is load-bearing. A demonstration that quietly fakes its own guarantees teaches the reader the opposite of what it claims.
How it is tested
131 checks run under plain node --test with no build step and no database, covering canonical hashing, the lifecycle state machine, payment rules, the registry memo, validation and the sign-in message. A second suite drives the real HTTP endpoints with real ed25519 keypairs against a running server.
- npm test — the pure layers. Fast enough to run on every save.
- npm run test:flow — the paths a unit test cannot reach: a signature over the wrong message, a replayed signature, a second wallet signing for a bound party, signing out.
Known limits
Stated here rather than discovered later. None of these are subtle, and all of them are the difference between a demonstration and a deployment:
- SQLite serialises writes to one file, so two instances behind a load balancer will fight over it. Moving to Postgres is a datasource change rather than a code change, but the initial migration is SQLite-flavoured DDL and needs regenerating.
- No list endpoint paginates. Fine at catalogue scale, wrong at thousands.
- Marketplace filtering happens in memory, because platforms live in a JSON string column that SQLite cannot index into.
- The demo endpoints — seeding, demo payment, demo proof — are not gated behind the demo-mode flag, which currently guards only signing.
- Sign-in challenges are created without a rate limit and never swept, so the table grows.
This list is maintained by hand and is therefore incomplete by construction. Read the source before trusting any of it in production.