zklicensing 0.1.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/LICENSE +104 -0
  2. package/README.md +603 -0
  3. package/build/archiveBootstrap.d.ts +60 -0
  4. package/build/archiveBootstrap.js +234 -0
  5. package/build/buyClient.d.ts +58 -0
  6. package/build/buyClient.js +162 -0
  7. package/build/contractInterface.d.ts +55 -0
  8. package/build/contractInterface.js +213 -0
  9. package/build/countryStats.d.ts +37 -0
  10. package/build/countryStats.js +89 -0
  11. package/build/deployClient.d.ts +168 -0
  12. package/build/deployClient.js +172 -0
  13. package/build/freezeClient.d.ts +44 -0
  14. package/build/freezeClient.js +59 -0
  15. package/build/generationClient.d.ts +54 -0
  16. package/build/generationClient.js +113 -0
  17. package/build/index.d.ts +72 -0
  18. package/build/index.js +258 -0
  19. package/build/licenseProof.d.ts +26 -0
  20. package/build/licenseProof.js +41 -0
  21. package/build/licenseRecord.d.ts +39 -0
  22. package/build/licenseRecord.js +6 -0
  23. package/build/licenseStore.d.ts +49 -0
  24. package/build/licenseStore.js +303 -0
  25. package/build/manifestVerify.d.ts +30 -0
  26. package/build/manifestVerify.js +95 -0
  27. package/build/migrateClient.d.ts +43 -0
  28. package/build/migrateClient.js +47 -0
  29. package/build/ownership.d.ts +34 -0
  30. package/build/ownership.js +147 -0
  31. package/build/refundClient.d.ts +52 -0
  32. package/build/refundClient.js +92 -0
  33. package/build/renewClient.d.ts +49 -0
  34. package/build/renewClient.js +135 -0
  35. package/build/statsClient.d.ts +96 -0
  36. package/build/statsClient.js +28 -0
  37. package/build/verifyCore.d.ts +26 -0
  38. package/build/verifyCore.js +57 -0
  39. package/build/verifyService.d.ts +3 -0
  40. package/build/verifyService.js +690 -0
  41. package/package.json +118 -0
@@ -0,0 +1,690 @@
1
+ #!/usr/bin/env node
2
+ // Copyright (c) 2025-2026 zkLicensing project developers
3
+ // All rights reserved.
4
+ /**
5
+ * verifyService.ts
6
+ *
7
+ * Lightweight, standalone license verification service.
8
+ *
9
+ * Exposes the same state endpoints as the keeper but contains NO ZK prover —
10
+ * no circuit compilation, no o1js native backend. Starts in ~2 seconds and
11
+ * runs on any small VPS or container.
12
+ *
13
+ * No write endpoints — chain events are the source of truth. License state
14
+ * is rebuilt from the archive node in three ways:
15
+ * - auto-bootstrap on startup when licenses.json is empty,
16
+ * - lazy bootstrap inside GET / when the computed Merkle root
17
+ * disagrees with the on-chain root for the requested app (the canonical
18
+ * "we're stale" signal). One replay + one retry is enough — root is
19
+ * consensus. This catches licenses issued, renewed, or refunded after
20
+ * boot on the very next verify call, with zero work done when no one is
21
+ * asking. Concurrent verifies for the same app share one in-flight
22
+ * bootstrap so a popular license under load does not stampede the
23
+ * archive node.
24
+ * - POST /bootstrap (admin) for ad-hoc reasons: a newly registered zkApp
25
+ * (verify won't trigger a sync for an app no client knows about yet), a
26
+ * corrupted licenses.json, or repopulating the offline /license/...
27
+ * debug endpoint without first hitting /verify.
28
+ *
29
+ * Deployment modes
30
+ * ----------------
31
+ * 1. Same-machine as the keeper
32
+ * - Run from the keeper's working directory so apps.json and licenses.json
33
+ * are shared transparently. Optionally set LICENSE_STORE_PATH to point at
34
+ * a shared file.
35
+ *
36
+ * 2. Separate machine (replica)
37
+ * - Copy the keeper's apps.json into the verifyService working directory.
38
+ * apps.json tells the service which zkApp addresses to replay events for.
39
+ * - Start the service. On boot it auto-bootstraps every app in apps.json
40
+ * from the archive node. MINA_ARCHIVE_URL defaults to the Minascan public
41
+ * archive matching MINA_NETWORK_ID:
42
+ * mainnet → https://api.minascan.io/archive/mainnet/v1/graphql
43
+ * else → https://api.minascan.io/archive/devnet/v1/graphql
44
+ * Override by setting MINA_ARCHIVE_URL in the environment.
45
+ * - To register a NEW app on the replica afterwards:
46
+ * a) Re-copy apps.json from the keeper (it owns the registry), then
47
+ * b) POST /bootstrap with the new zkAppAddress to ingest its events.
48
+ * - Re-copying apps.json on a cron (or pulling it via GET /apps from the
49
+ * keeper) is the recommended way to keep the registry in sync.
50
+ *
51
+ * Usage:
52
+ * node build/src/verifyService.js
53
+ *
54
+ * Configuration is read from environment variables (see README §Configuration).
55
+ * Required: MINA_GRAPHQL_URL, PLATFORM_ADDRESS.
56
+ * Optional: MINA_NETWORK_ID (default testnet), MINA_ARCHIVE_URL (defaults by
57
+ * network), VERIFY_PORT (default 8081), VERIFY_ADMIN_TOKEN.
58
+ *
59
+ * Endpoints:
60
+ * GET /health
61
+ * GET /?licenseHash=...&zkAppAddress=... ← primary endpoint for apps
62
+ * GET /challenge?licenseHash=... ← ownership nonce
63
+ * POST /respond ← ownership proof + token
64
+ * POST /refresh ← extend token TTL (no re-prove)
65
+ * POST /releaseSeat ← release this device's seat
66
+ * POST /reset-sessions ← drop all sessions (re-prove)
67
+ * GET /root?zkApp=...
68
+ * GET /witness/:zkAppAddress/:licenseHash
69
+ * GET /license/:zkAppAddress/:licenseHash
70
+ * GET /apps
71
+ * POST /bootstrap ← replay events from archive
72
+ */
73
+ import fs from 'fs/promises';
74
+ import { mkdirSync } from 'fs';
75
+ import { homedir } from 'os';
76
+ import { join } from 'path';
77
+ import express from 'express';
78
+ import { Field, Mina, fetchLastBlock, fetchAccount, PublicKey, Cache, setBackend, verify, } from 'o1js';
79
+ import { buildMap, getLicense, licenseStatus, readRecords, serializeWitness, } from './licenseStore.js';
80
+ import { verifyLicenseCore } from './verifyCore.js';
81
+ import { bootstrapFromArchive, applyEventsToStore, lazyBootstrap } from './archiveBootstrap.js';
82
+ import { LicenseProof, LicenseProofProof } from './licenseProof.js';
83
+ import { CANONICAL_VK_HASHES, CANONICAL_LICENSE_PROOF_VK_HASH, SUPPORTED_LICENSING_APP_VERSIONS, fetchLicensingAppVersion, } from './contractInterface.js';
84
+ import { createChallengeStore, createSessionStore, newJti, signOwnershipToken, verifyOwnershipToken, newOwnershipKey, OWNERSHIP_TOKEN_TTL_MS, } from './ownership.js';
85
+ setBackend('native');
86
+ const CIRCUIT_CACHE_DIR = join(homedir(), '.cache', 'zklic-verifier');
87
+ mkdirSync(CIRCUIT_CACHE_DIR, { recursive: true });
88
+ // ------------------------------------------------------------
89
+ // CONFIG (env vars) & NETWORK
90
+ // ------------------------------------------------------------
91
+ function requireEnv(name, hint) {
92
+ const v = process.env[name];
93
+ if (!v) {
94
+ console.error(`❌ ${name} env var is required. ${hint}`);
95
+ process.exit(1);
96
+ }
97
+ return v;
98
+ }
99
+ const MINA_NETWORK_ID = process.env.MINA_NETWORK_ID ?? 'devnet';
100
+ const MINA_GRAPHQL_URL = requireEnv('MINA_GRAPHQL_URL', 'e.g. https://api.minascan.io/node/devnet/v1/graphql');
101
+ const PORT = parseInt(process.env.VERIFY_PORT ?? '8081', 10);
102
+ function defaultArchiveUrl(networkId) {
103
+ switch (networkId) {
104
+ case 'mainnet':
105
+ return 'https://api.minascan.io/archive/mainnet/v1/graphql';
106
+ case 'devnet':
107
+ return 'https://api.minascan.io/archive/devnet/v1/graphql';
108
+ default:
109
+ return null;
110
+ }
111
+ }
112
+ const archiveUrl = process.env.MINA_ARCHIVE_URL ?? defaultArchiveUrl(MINA_NETWORK_ID);
113
+ if (!archiveUrl) {
114
+ console.error(`❌ No archive URL for MINA_NETWORK_ID="${MINA_NETWORK_ID}" and no built-in default for this network.\n` +
115
+ ` Set MINA_ARCHIVE_URL to the archive node GraphQL endpoint.`);
116
+ process.exit(1);
117
+ }
118
+ console.log(`🔗 Mina node: ${MINA_GRAPHQL_URL}`);
119
+ console.log(`📚 Archive node: ${archiveUrl}${process.env.MINA_ARCHIVE_URL ? ' (from MINA_ARCHIVE_URL)' : ' (default — set MINA_ARCHIVE_URL to override)'}`);
120
+ Mina.setActiveInstance(Mina.Network({
121
+ networkId: MINA_NETWORK_ID,
122
+ mina: MINA_GRAPHQL_URL,
123
+ archive: archiveUrl,
124
+ }));
125
+ // ------------------------------------------------------------
126
+ // CIRCUIT — LicenseProof (ownership challenge/response)
127
+ // ------------------------------------------------------------
128
+ let licenseProofVk = null;
129
+ let licenseProofCompile = null;
130
+ async function ensureCompiled() {
131
+ if (licenseProofVk)
132
+ return;
133
+ licenseProofCompile ??= (async () => {
134
+ const cached = Cache.FileSystem(CIRCUIT_CACHE_DIR);
135
+ const t = Date.now();
136
+ console.log(`[verify-prover] Compiling LicenseProof (cache: ${CIRCUIT_CACHE_DIR})…`);
137
+ const lpVk = await LicenseProof.compile({ cache: cached });
138
+ const vk = lpVk.verificationKey;
139
+ if (CANONICAL_LICENSE_PROOF_VK_HASH && vk.hash.toString() !== CANONICAL_LICENSE_PROOF_VK_HASH) {
140
+ throw new Error(`LicenseProof VK hash ${vk.hash.toString()} does not match CANONICAL_LICENSE_PROOF_VK_HASH ${CANONICAL_LICENSE_PROOF_VK_HASH}`);
141
+ }
142
+ licenseProofVk = vk;
143
+ console.log(`[verify-prover] Ready in ${((Date.now() - t) / 1000).toFixed(1)}s — vk ${vk.hash.toString().slice(0, 16)}…`);
144
+ })();
145
+ await licenseProofCompile;
146
+ }
147
+ ensureCompiled().catch((e) => console.error('[verify-prover] Compile failed:', e));
148
+ // ------------------------------------------------------------
149
+ // OWNERSHIP — challenge + activation token (helpers live in ownership.ts)
150
+ // ------------------------------------------------------------
151
+ const ownershipChallenges = createChallengeStore();
152
+ setInterval(() => { /* opportunistic GC */ ownershipChallenges.size(); }, 30_000).unref();
153
+ // Per-license active-device session store. Backs the concurrent-device cap
154
+ // (AppRecord.maxConcurrentDevices), /releaseSeat, and /reset-sessions. In-memory
155
+ // by design — a keeper restart drops every session, forcing devices to
156
+ // re-prove ownership. That is a security feature: persisted sessions would
157
+ // let a stolen token bypass concurrency caps across restarts.
158
+ const ownershipSessions = createSessionStore();
159
+ setInterval(() => { /* opportunistic GC — activeCount touches prune */ ownershipSessions.size(); }, 30_000).unref();
160
+ async function loadAppByAddress(zkAppAddress) {
161
+ const apps = await readApps();
162
+ return apps.find((a) => a.zkAppAddress === zkAppAddress) ?? null;
163
+ }
164
+ // Resolve which generation a zkAppAddress claims to belong to by walking the
165
+ // keeper's AppRecord.deploymentHistory[]. The top-level address is the current
166
+ // generation (history.length + 1); older addresses live in history at
167
+ // increasing index. Returns null when the address is not present in any
168
+ // app's history — which is exactly what stops a "genuine v1 circuit
169
+ // redeployed at a fresh address" impersonation: unlisted address → no
170
+ // generation → verify rejects. See CANONICAL_VK_HASHES in contractInterface.ts.
171
+ async function resolveGenerationByAddress(zkAppAddress) {
172
+ const apps = await readApps();
173
+ for (const a of apps) {
174
+ const historyLen = a.deploymentHistory?.length ?? 0;
175
+ if (a.zkAppAddress === zkAppAddress)
176
+ return historyLen + 1;
177
+ if (a.deploymentHistory) {
178
+ for (let i = 0; i < a.deploymentHistory.length; i++) {
179
+ if (a.deploymentHistory[i].zkAppAddress === zkAppAddress)
180
+ return i + 1;
181
+ }
182
+ }
183
+ }
184
+ return null;
185
+ }
186
+ const OWNERSHIP_KEY_PATH = process.env.OWNERSHIP_KEY_PATH ?? join(CIRCUIT_CACHE_DIR, 'ownership-hmac.key');
187
+ let ownershipKey;
188
+ try {
189
+ ownershipKey = await fs.readFile(OWNERSHIP_KEY_PATH);
190
+ console.log(`🔑 Ownership HMAC key loaded from ${OWNERSHIP_KEY_PATH}`);
191
+ }
192
+ catch {
193
+ ownershipKey = newOwnershipKey();
194
+ await fs.writeFile(OWNERSHIP_KEY_PATH, ownershipKey, { mode: 0o600 });
195
+ console.log(`🔑 Ownership HMAC key generated → ${OWNERSHIP_KEY_PATH}`);
196
+ }
197
+ // ------------------------------------------------------------
198
+ // HELPERS
199
+ // ------------------------------------------------------------
200
+ async function currentSlot() {
201
+ try {
202
+ const block = await fetchLastBlock();
203
+ return Number(block.globalSlotSinceGenesis.toString());
204
+ }
205
+ catch {
206
+ return 0;
207
+ }
208
+ }
209
+ const APPS_FILE = 'apps.json';
210
+ async function readApps() {
211
+ try {
212
+ return JSON.parse(await fs.readFile(APPS_FILE, 'utf8'));
213
+ }
214
+ catch {
215
+ return [];
216
+ }
217
+ }
218
+ // ------------------------------------------------------------
219
+ // EXPRESS SERVER
220
+ // ------------------------------------------------------------
221
+ const app = express();
222
+ app.use(express.json());
223
+ app.use((_req, res, next) => {
224
+ res.setHeader('Access-Control-Allow-Origin', '*');
225
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
226
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, OPTIONS');
227
+ if (_req.method === 'OPTIONS') {
228
+ res.status(204).end();
229
+ return;
230
+ }
231
+ next();
232
+ });
233
+ const VERIFY_ADMIN_TOKEN = process.env.VERIFY_ADMIN_TOKEN ?? '';
234
+ function requireAdmin(req, res, next) {
235
+ if (!VERIFY_ADMIN_TOKEN) {
236
+ res.status(503).json({ error: 'Admin endpoint disabled (VERIFY_ADMIN_TOKEN env var not set)' });
237
+ return;
238
+ }
239
+ const auth = req.headers.authorization ?? '';
240
+ const match = auth.match(/^Bearer\s+(.+)$/);
241
+ if (!match || match[1] !== VERIFY_ADMIN_TOKEN) {
242
+ res.status(401).json({ error: 'Unauthorized' });
243
+ return;
244
+ }
245
+ next();
246
+ }
247
+ // Health
248
+ app.get('/health', (_req, res) => {
249
+ res.json({ status: 'ok', service: 'verify', network: MINA_GRAPHQL_URL });
250
+ });
251
+ // Current MerkleMap root for a specific zkApp.
252
+ app.get('/root', async (req, res) => {
253
+ const zkAppAddress = req.query.zkApp ?? '';
254
+ if (!zkAppAddress) {
255
+ return res.status(400).json({ error: 'Missing zkApp query param' });
256
+ }
257
+ const records = await readRecords();
258
+ const map = buildMap(records, zkAppAddress);
259
+ res.json({ root: map.getRoot().toString() });
260
+ });
261
+ // GET /challenge?licenseHash=...
262
+ // Issues a nonce for the ownership challenge-response flow.
263
+ app.get('/challenge', (req, res) => {
264
+ const licenseHash = String(req.query.licenseHash ?? '');
265
+ if (!licenseHash) {
266
+ return res.status(400).json({ error: 'Missing licenseHash' });
267
+ }
268
+ res.json(ownershipChallenges.issue(licenseHash));
269
+ });
270
+ // POST /respond — body: { zkAppAddress, proof }
271
+ // Verifies a LicenseProof against the issued nonce, consumes the nonce,
272
+ // enforces the vendor's concurrent-device cap (if any), and returns
273
+ // { ownership: 'verified', token, expiresAt, jti, activeSessions, deviceLimit }
274
+ // on success. On cap overflow returns 429 without minting a token — clients
275
+ // must release a seat (POST /releaseSeat), reset all sessions (POST
276
+ // /reset-sessions), or wait for the oldest to hit TTL.
277
+ app.post('/respond', async (req, res) => {
278
+ await ensureCompiled();
279
+ if (!licenseProofVk) {
280
+ return res.status(503).json({ error: 'Verify service not ready — circuits compiling' });
281
+ }
282
+ const { zkAppAddress, proof: proofJson } = req.body;
283
+ if (!zkAppAddress || !proofJson) {
284
+ return res.status(400).json({ error: 'Missing zkAppAddress or proof' });
285
+ }
286
+ let proof;
287
+ try {
288
+ proof = await LicenseProofProof.fromJSON(proofJson);
289
+ }
290
+ catch {
291
+ return res.status(400).json({ error: 'Invalid proof format' });
292
+ }
293
+ let proofValid = false;
294
+ try {
295
+ proofValid = await verify(proof, licenseProofVk);
296
+ }
297
+ catch {
298
+ return res.status(400).json({ error: 'Proof verification threw' });
299
+ }
300
+ if (!proofValid) {
301
+ return res.status(400).json({ error: 'Proof is cryptographically invalid' });
302
+ }
303
+ const licenseHash = proof.publicInput.licenseHash.toString();
304
+ const nonce = proof.publicInput.nonce.toString();
305
+ const nonceState = ownershipChallenges.consume(licenseHash, nonce);
306
+ if (nonceState !== 'ok') {
307
+ return res.status(400).json({ error: `Nonce ${nonceState} — request a fresh /challenge` });
308
+ }
309
+ // Concurrent-device cap enforcement. Read the vendor's per-app limit
310
+ // (0/undefined = unlimited) and try to reserve a session slot BEFORE
311
+ // minting the HMAC — a token without a matching session-store entry is
312
+ // dead on arrival at GET / anyway, so refusing here avoids handing the
313
+ // caller a bearer that they can only use to be rejected.
314
+ const app_record = await loadAppByAddress(zkAppAddress);
315
+ const cap = Math.max(0, Math.floor(app_record?.maxConcurrentDevices ?? 0));
316
+ const jti = newJti();
317
+ const exp = Date.now() + OWNERSHIP_TOKEN_TTL_MS;
318
+ const reserved = ownershipSessions.insert(licenseHash, jti, exp, cap);
319
+ if (reserved.state === 'at-cap') {
320
+ return res.status(429).json({
321
+ error: 'Concurrent-device limit reached — release a seat, run POST /reset-sessions, or wait for the oldest session to expire',
322
+ activeSessions: reserved.active,
323
+ deviceLimit: cap,
324
+ });
325
+ }
326
+ const token = signOwnershipToken(ownershipKey, { z: zkAppAddress, l: licenseHash, e: exp, jti });
327
+ res.json({
328
+ ownership: 'verified',
329
+ token,
330
+ expiresAt: exp,
331
+ jti,
332
+ activeSessions: reserved.active,
333
+ deviceLimit: cap,
334
+ });
335
+ });
336
+ // POST /releaseSeat — body: { token }
337
+ // Releases the seat identified by the token's jti. Possession of the token
338
+ // is authorization to release it (same threat model as any bearer token);
339
+ // no proof-of-ownership is required. Enables an in-app "log out this device"
340
+ // button. Idempotent — a second call with the same token silently no-ops.
341
+ app.post('/releaseSeat', (req, res) => {
342
+ const { token } = req.body;
343
+ if (!token)
344
+ return res.status(400).json({ error: 'Missing token' });
345
+ const payload = verifyOwnershipToken(ownershipKey, token);
346
+ if (!payload)
347
+ return res.status(401).json({ error: 'Invalid or expired token' });
348
+ if (!payload.jti) {
349
+ // Pre-jti tokens aren't tracked in the session store, so there's no seat
350
+ // to release. Return 200 for idempotency — the caller's intent (log this
351
+ // device out) is satisfied by the local token delete.
352
+ return res.json({ ok: true, released: false, reason: 'token has no jti (pre-jti issue)' });
353
+ }
354
+ const released = ownershipSessions.releaseSeat(payload.l, payload.jti);
355
+ res.json({ ok: true, released, jti: payload.jti });
356
+ });
357
+ // POST /refresh — body: { token }
358
+ // Extends the lifetime of the current session without a fresh ownership proof.
359
+ // The caller sends the token they already hold; the server verifies the HMAC,
360
+ // checks the jti is still active in the session store (i.e. not released or
361
+ // evicted), and issues a new token with the same jti and a fresh exp. The
362
+ // session store's insert is idempotent per jti, so no cap check is needed —
363
+ // the slot was already reserved at /respond time.
364
+ //
365
+ // Intended for background renewal from a running app: the client polls this
366
+ // endpoint before the token expires so a user who keeps the app open never
367
+ // has to redo the passphrase-gated challenge/response. A stolen token can be
368
+ // refreshed too, which is why /releaseSeat exists — losing a device means the
369
+ // owner explicitly logs it out, and subsequent refreshes fail.
370
+ //
371
+ // Pre-jti tokens (legacy /respond without session tracking) can also refresh:
372
+ // they're valid HMAC, they carry no jti, and there's no session-store row to
373
+ // gate against. The reissued token is identically pre-jti. Callers that want
374
+ // concurrency enforcement must reissue via /respond so a jti gets minted.
375
+ app.post('/refresh', (req, res) => {
376
+ const { token } = req.body;
377
+ if (!token)
378
+ return res.status(400).json({ error: 'Missing token' });
379
+ const payload = verifyOwnershipToken(ownershipKey, token);
380
+ if (!payload)
381
+ return res.status(401).json({ error: 'Invalid or expired token' });
382
+ const newExp = Date.now() + OWNERSHIP_TOKEN_TTL_MS;
383
+ if (payload.jti) {
384
+ if (!ownershipSessions.has(payload.l, payload.jti)) {
385
+ return res.status(401).json({ error: 'Seat released' });
386
+ }
387
+ // Idempotent for existing jti — updates the expiry, never trips the cap.
388
+ ownershipSessions.insert(payload.l, payload.jti, newExp, 0);
389
+ }
390
+ const next = {
391
+ z: payload.z,
392
+ l: payload.l,
393
+ e: newExp,
394
+ ...(payload.jti ? { jti: payload.jti } : {}),
395
+ };
396
+ const nextToken = signOwnershipToken(ownershipKey, next);
397
+ res.json({ token: nextToken, expiresAt: newExp, jti: payload.jti });
398
+ });
399
+ // POST /reset-sessions — body: { zkAppAddress, proof }
400
+ // Drops ALL active sessions for the licenseHash proved by the LicenseProof.
401
+ // Requires the same challenge/response as /respond — only the passphrase
402
+ // holder can invoke it. Intended for "reinstall unsticks a device that
403
+ // burned a slot without logging out" recovery. After this returns, the
404
+ // caller runs a fresh /respond to re-provision a session under a zero
405
+ // active count.
406
+ app.post('/reset-sessions', async (req, res) => {
407
+ await ensureCompiled();
408
+ if (!licenseProofVk) {
409
+ return res.status(503).json({ error: 'Verify service not ready — circuits compiling' });
410
+ }
411
+ const { zkAppAddress, proof: proofJson } = req.body;
412
+ if (!zkAppAddress || !proofJson) {
413
+ return res.status(400).json({ error: 'Missing zkAppAddress or proof' });
414
+ }
415
+ let proof;
416
+ try {
417
+ proof = await LicenseProofProof.fromJSON(proofJson);
418
+ }
419
+ catch {
420
+ return res.status(400).json({ error: 'Invalid proof format' });
421
+ }
422
+ let proofValid = false;
423
+ try {
424
+ proofValid = await verify(proof, licenseProofVk);
425
+ }
426
+ catch {
427
+ return res.status(400).json({ error: 'Proof verification threw' });
428
+ }
429
+ if (!proofValid) {
430
+ return res.status(400).json({ error: 'Proof is cryptographically invalid' });
431
+ }
432
+ const licenseHash = proof.publicInput.licenseHash.toString();
433
+ const nonce = proof.publicInput.nonce.toString();
434
+ const nonceState = ownershipChallenges.consume(licenseHash, nonce);
435
+ if (nonceState !== 'ok') {
436
+ return res.status(400).json({ error: `Nonce ${nonceState} — request a fresh /challenge` });
437
+ }
438
+ const dropped = ownershipSessions.reset(licenseHash);
439
+ res.json({ ok: true, dropped });
440
+ });
441
+ // GET /?licenseHash=...&zkAppAddress=...[&token=...]
442
+ // Primary endpoint for Android, iOS, PWA, and Node SDK license verification.
443
+ //
444
+ // Ownership semantics:
445
+ // - Without `token`, the response describes the state of the on-chain leaf
446
+ // only — it does NOT prove the caller owns the license. This is fine for
447
+ // status displays; gate access behind /respond or pass a fresh
448
+ // activation `token` in the query string.
449
+ // - With `token`, the service checks the HMAC and refuses if the token isn't
450
+ // for this (zkAppAddress, licenseHash) pair.
451
+ app.get('/', async (req, res) => {
452
+ const { licenseHash: licenseHashStr, zkAppAddress, token } = req.query;
453
+ if (!licenseHashStr || !zkAppAddress) {
454
+ return res.status(400).json({ error: 'Missing required query params: licenseHash, zkAppAddress' });
455
+ }
456
+ let ownershipVerified = false;
457
+ if (token) {
458
+ const payload = verifyOwnershipToken(ownershipKey, token);
459
+ if (!payload || payload.z !== zkAppAddress || payload.l !== licenseHashStr) {
460
+ return res.status(401).json({ error: 'Invalid or expired ownership token' });
461
+ }
462
+ // Session-store gate: a jti-bearing token must still be in the active-
463
+ // session set for its license. A token whose jti was released (via
464
+ // /releaseSeat, /reset-sessions, or evicted by TTL prune) is refused even if
465
+ // it's still HMAC-valid and inside its exp. Pre-jti tokens (issued
466
+ // before the session-tracking rollout) skip this check and remain
467
+ // usable until their exp — no forced logout across the migration.
468
+ if (payload.jti && !ownershipSessions.has(payload.l, payload.jti)) {
469
+ return res.status(401).json({ error: 'Seat released — call POST /respond to acquire a fresh token' });
470
+ }
471
+ ownershipVerified = true;
472
+ }
473
+ try {
474
+ const [records, { account }, lastBlock] = await Promise.all([
475
+ readRecords(),
476
+ fetchAccount({ publicKey: PublicKey.fromBase58(zkAppAddress) }),
477
+ fetchLastBlock(),
478
+ ]);
479
+ const onChainRoot = account?.zkapp?.appState?.[0]?.toString();
480
+ if (!onChainRoot) {
481
+ return res.status(404).json({ error: 'zkApp not found on chain' });
482
+ }
483
+ // Source-version tag from the already-fetched account state — free byte
484
+ // for the SDK / dashboard to label which protocol revision a license lives
485
+ // on. Not a security gate (that's the boot + bootstrap check); purely
486
+ // informational.
487
+ const zkAppVersion = Number(account?.zkapp?.appState?.[7]?.toString() ?? '0');
488
+ // Defense in depth against impersonation. The contract locks
489
+ // `setVerificationKey` via `Permissions.VerificationKey
490
+ // .impossibleDuringCurrentVersion()` plus deploy-key disposal, so VK
491
+ // rotation post-deploy is impossible for any *listed* address. The check
492
+ // below resolves the address's generation via keeper deploymentHistory
493
+ // and asserts its on-chain VK equals that generation's canonical hash.
494
+ // Two failure modes: (a) the address is unknown (not the current live
495
+ // deployment of any registered app, not in any deploymentHistory[]) —
496
+ // reject; that stops a genuine old circuit deployed at a fresh address.
497
+ // (b) the address is listed but its VK doesn't equal its generation's
498
+ // hash — reject; that's a loud index-corruption tripwire.
499
+ const onChainVk = account?.zkapp?.verificationKey?.hash?.toString();
500
+ const generation = await resolveGenerationByAddress(zkAppAddress);
501
+ if (generation === null) {
502
+ return res.json({
503
+ valid: false,
504
+ expiresAt: null,
505
+ expirySlot: 0,
506
+ inGracePeriod: false,
507
+ remainingDays: 0,
508
+ reason: 'zkAppAddress is not in any registered app deployment history',
509
+ ownership: ownershipVerified ? 'verified' : 'unverified',
510
+ });
511
+ }
512
+ const expectedVk = CANONICAL_VK_HASHES[generation];
513
+ if (expectedVk && onChainVk !== expectedVk) {
514
+ return res.json({
515
+ valid: false,
516
+ expiresAt: null,
517
+ expirySlot: 0,
518
+ inGracePeriod: false,
519
+ remainingDays: 0,
520
+ reason: `zkApp verification key does not match generation ${generation}'s canonical LicensingApp circuit`,
521
+ ownership: ownershipVerified ? 'verified' : 'unverified',
522
+ });
523
+ }
524
+ const currentSlot = Number(lastBlock.globalSlotSinceGenesis.toString());
525
+ let liveRecords = records;
526
+ let result = verifyLicenseCore({
527
+ zkAppAddress,
528
+ licenseHash: licenseHashStr,
529
+ records: liveRecords,
530
+ onChainRoot,
531
+ currentSlot,
532
+ });
533
+ // Lazy self-heal: a root mismatch means the local store is stale (a
534
+ // license has been bought, renewed, or refunded since we last replayed).
535
+ // One replay + one retry is enough — the on-chain root is the source of
536
+ // truth and the archive node will reflect it.
537
+ if (!result.valid && result.reason === 'License state does not match on-chain root') {
538
+ await lazyBootstrap(zkAppAddress);
539
+ liveRecords = await readRecords();
540
+ result = verifyLicenseCore({
541
+ zkAppAddress,
542
+ licenseHash: licenseHashStr,
543
+ records: liveRecords,
544
+ onChainRoot,
545
+ currentSlot,
546
+ });
547
+ }
548
+ const record = liveRecords.find(r => r.zkAppAddress === zkAppAddress && r.licenseHash === licenseHashStr);
549
+ return res.json({
550
+ ...result,
551
+ currentSlot,
552
+ purchaseSlot: record?.purchaseSlot ?? 0,
553
+ ownership: ownershipVerified ? 'verified' : 'unverified',
554
+ zkAppVersion,
555
+ });
556
+ }
557
+ catch (err) {
558
+ console.error('[verify]', err?.message ?? err);
559
+ res.status(500).json({ error: err?.message ?? 'Verification failed' });
560
+ }
561
+ });
562
+ // License map witness — composite (zkAppAddress, licenseHash) key.
563
+ app.get('/witness/:zkAppAddress/:licenseHash', async (req, res) => {
564
+ const { zkAppAddress, licenseHash } = req.params;
565
+ try {
566
+ const records = await readRecords();
567
+ const map = buildMap(records, zkAppAddress);
568
+ const key = Field(licenseHash);
569
+ const witness = map.getWitness(key);
570
+ res.json({
571
+ root: map.getRoot().toString(),
572
+ currentValue: map.get(key).toString(),
573
+ ...serializeWitness(witness),
574
+ });
575
+ }
576
+ catch (err) {
577
+ res.status(400).json({ error: err.message });
578
+ }
579
+ });
580
+ // License status — composite (zkAppAddress, licenseHash) key.
581
+ app.get('/license/:zkAppAddress/:licenseHash', async (req, res) => {
582
+ const { zkAppAddress, licenseHash } = req.params;
583
+ const record = await getLicense(zkAppAddress, licenseHash);
584
+ const slot = await currentSlot();
585
+ const status = licenseStatus(record, slot);
586
+ res.json({
587
+ found: record !== null,
588
+ expirySlot: record?.expirySlot ?? 0,
589
+ status,
590
+ txHash: record?.txHash ?? null,
591
+ });
592
+ });
593
+ // List registered apps
594
+ app.get('/apps', async (_req, res) => {
595
+ const apps = await readApps();
596
+ res.json({ apps });
597
+ });
598
+ // POST /bootstrap — admin
599
+ // Body: { zkAppAddress: string }
600
+ // Replays all canonical events from the archive node and rebuilds licenses.json.
601
+ // Gated because an open archive-replay endpoint is a DoS vector (each call hits
602
+ // the archive node and rewrites the local store).
603
+ app.post('/bootstrap', requireAdmin, async (req, res) => {
604
+ const { zkAppAddress } = req.body;
605
+ if (!zkAppAddress) {
606
+ return res.status(400).json({ error: 'Missing zkAppAddress' });
607
+ }
608
+ try {
609
+ // Refuse to replay a zkApp whose immutable source-version tag doesn't match
610
+ // one this verifier understands. Otherwise a v2-deployed contract's events
611
+ // could be decoded with a v1 schema and yield subtly wrong state.
612
+ const onChainVersion = await fetchLicensingAppVersion(zkAppAddress);
613
+ if (onChainVersion === null) {
614
+ return res.status(400).json({ error: `zkApp ${zkAppAddress.slice(0, 16)}… not visible on chain` });
615
+ }
616
+ if (!SUPPORTED_LICENSING_APP_VERSIONS.includes(onChainVersion)) {
617
+ return res.status(400).json({
618
+ error: `unsupported LicensingApp version — on-chain v${onChainVersion}, this verifier supports v${SUPPORTED_LICENSING_APP_VERSIONS.join(', v')}`,
619
+ });
620
+ }
621
+ console.log(`[bootstrap] Replaying events for ${zkAppAddress.slice(0, 16)}… (v${onChainVersion}) from ${archiveUrl}`);
622
+ const { events } = await bootstrapFromArchive(zkAppAddress);
623
+ const { count, warnings } = await applyEventsToStore(zkAppAddress, events);
624
+ for (const w of warnings)
625
+ console.warn('[bootstrap]', w);
626
+ console.log(`[bootstrap] Done — ${count} license records written`);
627
+ res.json({ ok: true, count, warnings, onChainVersion });
628
+ }
629
+ catch (err) {
630
+ console.error('[bootstrap]', err?.message ?? err);
631
+ res.status(500).json({ error: err?.message ?? 'Bootstrap failed' });
632
+ }
633
+ });
634
+ // ------------------------------------------------------------
635
+ // START
636
+ // ------------------------------------------------------------
637
+ let records = await readRecords();
638
+ // Auto-bootstrap from archive if store is empty.
639
+ if (records.length === 0) {
640
+ const apps = await readApps();
641
+ if (apps.length > 0) {
642
+ console.log(`📦 licenses.json is empty — bootstrapping ${apps.length} app(s) from archive node…`);
643
+ for (const appRecord of apps) {
644
+ try {
645
+ // Same version guard as POST /bootstrap — refuse to replay any address
646
+ // whose on-chain source-version tag this verifier doesn't understand.
647
+ const onChainVersion = await fetchLicensingAppVersion(appRecord.zkAppAddress);
648
+ if (onChainVersion === null) {
649
+ console.warn(` ⚠️ ${appRecord.name}: zkApp not visible on chain — skipping`);
650
+ continue;
651
+ }
652
+ if (!SUPPORTED_LICENSING_APP_VERSIONS.includes(onChainVersion)) {
653
+ console.warn(` ⚠️ ${appRecord.name}: on-chain v${onChainVersion} not supported (supports v${SUPPORTED_LICENSING_APP_VERSIONS.join(', v')}) — skipping`);
654
+ continue;
655
+ }
656
+ const { events } = await bootstrapFromArchive(appRecord.zkAppAddress);
657
+ const { count, warnings } = await applyEventsToStore(appRecord.zkAppAddress, events);
658
+ for (const w of warnings)
659
+ console.warn('⚠️', w);
660
+ console.log(` ✅ ${appRecord.name} (v${onChainVersion}): ${count} records`);
661
+ }
662
+ catch (err) {
663
+ console.warn(` ⚠️ ${appRecord.name}: bootstrap failed — ${err?.message ?? err}`);
664
+ }
665
+ }
666
+ records = await readRecords();
667
+ }
668
+ }
669
+ console.log(`📋 Loaded ${records.length} license records across ${new Set(records.map(r => r.zkAppAddress)).size} zkApp(s)`);
670
+ app.listen(PORT, () => {
671
+ console.log(`🔍 Verify service listening on http://localhost:${PORT}`);
672
+ console.log(` GET /?licenseHash=...&zkAppAddress=...[&token=...]`);
673
+ console.log(` GET /challenge?licenseHash=...`);
674
+ console.log(` POST /respond`);
675
+ console.log(` POST /refresh`);
676
+ console.log(` POST /releaseSeat`);
677
+ console.log(` POST /reset-sessions`);
678
+ console.log(` GET /witness/:zkAppAddress/:licenseHash`);
679
+ console.log(` GET /license/:zkAppAddress/:licenseHash`);
680
+ console.log(` GET /apps`);
681
+ console.log(` POST /bootstrap`);
682
+ if (VERIFY_ADMIN_TOKEN) {
683
+ console.log(`🔒 Admin endpoints (bootstrap) enabled`);
684
+ }
685
+ else {
686
+ console.log(`⚠️ Admin endpoints disabled (set VERIFY_ADMIN_TOKEN to enable)`);
687
+ }
688
+ console.log(`🔁 Lazy bootstrap on root mismatch (no periodic poller)`);
689
+ });
690
+ //# sourceMappingURL=verifyService.js.map