From 597d83e205947b280b4d2942259db8d86eba4c29 Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Mon, 7 Sep 2026 14:14:29 +0000 Subject: [PATCH 01/18] feat(registry): serve CORS headers for configured browser origins --- package-lock.json | 35 ++++++++++++ package.json | 2 + registry/.env.example | 8 +++ registry/package-lock.json | 35 ++++++++++++ registry/package.json | 2 + registry/src/config.ts | 16 ++++++ registry/src/server.ts | 13 +++++ registry/test/config.test.ts | 34 +++++++++++ registry/test/cors.test.ts | 85 ++++++++++++++++++++++++++++ registry/test/e2e/helpers/fixture.ts | 1 + registry/test/helpers/fixtures.ts | 3 + 11 files changed, 234 insertions(+) create mode 100644 registry/test/cors.test.ts diff --git a/package-lock.json b/package-lock.json index 3f586f5..58ed9c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.2.0", "license": "MIT", "dependencies": { + "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.19.2", "express-openapi-validator": "^5.6.2", @@ -18,6 +19,7 @@ "canton-token-forge-registry": "registry/dist/index.js" }, "devDependencies": { + "@types/cors": "^2.8.19", "@types/express": "^4.17.21", "@types/node": "^26.1.1", "typescript": "^5.5.4" @@ -70,6 +72,15 @@ "@types/node": "*" } }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/express": { "version": "4.17.25", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", @@ -360,6 +371,22 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==" }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -846,6 +873,14 @@ "node": ">= 0.6" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", diff --git a/package.json b/package.json index ebbc59e..d8e91fd 100644 --- a/package.json +++ b/package.json @@ -23,12 +23,14 @@ "bin": { "canton-token-forge-registry": "registry/dist/index.js" }, "files": ["registry/dist", "registry/openapi", "registry/.env.example"], "dependencies": { + "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.19.2", "express-openapi-validator": "^5.6.2", "pino": "^10.3.1" }, "devDependencies": { + "@types/cors": "^2.8.19", "@types/express": "^4.17.21", "@types/node": "^26.1.1", "typescript": "^5.5.4" diff --git a/registry/.env.example b/registry/.env.example index ead6a26..a38ffc6 100644 --- a/registry/.env.example +++ b/registry/.env.example @@ -75,3 +75,11 @@ SHUTDOWN_TIMEOUT_MS=8000 # sender submits. Preapprovals expiring within it are served as offers instead. # Zero disables the margin. DIRECT_TRANSFER_MARGIN_MS=30000 + +# Comma-separated list of origins a browser dApp may call this service from. +# The service reflects an origin back only if it is on this list (or the list +# is "*", meaning any origin), because a browser will not hand a cross-origin +# response to the page otherwise. Optional; defaults to +# http://localhost:3012, the dApp dev server this exists for. Any real +# deployment sets this to its own origin(s). +CORS_ORIGINS=http://localhost:3012 diff --git a/registry/package-lock.json b/registry/package-lock.json index 2f5659f..36d1cfa 100644 --- a/registry/package-lock.json +++ b/registry/package-lock.json @@ -8,6 +8,7 @@ "name": "canton-token-forge-registry", "version": "0.0.1", "dependencies": { + "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.19.2", "express-openapi-validator": "^5.6.2", @@ -15,6 +16,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.4.10", + "@types/cors": "^2.8.19", "@types/express": "^4.17.21", "@types/node": "^26.1.1", "@types/supertest": "^6.0.2", @@ -1023,6 +1025,15 @@ "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", "dev": true }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1535,6 +1546,22 @@ "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", "dev": true }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -2227,6 +2254,14 @@ "node": ">= 0.6" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", diff --git a/registry/package.json b/registry/package.json index f0fec3d..62d801d 100644 --- a/registry/package.json +++ b/registry/package.json @@ -17,6 +17,7 @@ "lint:fix": "biome check --write" }, "dependencies": { + "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.19.2", "express-openapi-validator": "^5.6.2", @@ -24,6 +25,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.4.10", + "@types/cors": "^2.8.19", "@types/express": "^4.17.21", "@types/node": "^26.1.1", "@types/supertest": "^6.0.2", diff --git a/registry/src/config.ts b/registry/src/config.ts index 07b3575..ca6c93d 100644 --- a/registry/src/config.ts +++ b/registry/src/config.ts @@ -14,6 +14,10 @@ export interface Config { port: number shutdownTimeoutMs: number directTransferMarginMs: number + // A browser will not hand a cross-origin response to the page unless the + // service names the requesting origin back in the response, so the origins + // a dApp may call from have to be configured rather than inferred. + corsOrigins: string[] } const DEFAULT_PORT = 8080 @@ -36,6 +40,10 @@ const DEFAULT_DIRECT_TRANSFER_MARGIN_MS = 30_000 // intent; rejecting it at boot beats silently disabling the direct path. const MAX_DIRECT_TRANSFER_MARGIN_MS = 3_600_000 +// The dApp dev server the CORS report was filed from, so the reported case +// works with no configuration. Any real deployment sets CORS_ORIGINS itself. +const DEFAULT_CORS_ORIGINS = 'http://localhost:3012' + export function loadConfig(env: NodeJS.ProcessEnv): Config { const require_ = (k: string): string => { const v = env[k] @@ -80,6 +88,13 @@ export function loadConfig(env: NodeJS.ProcessEnv): Config { } return n } + const parseOrigins = (raw: string | undefined): string[] => { + const value = raw || DEFAULT_CORS_ORIGINS + return value + .split(',') + .map((origin) => origin.trim()) + .filter((origin) => origin.length > 0) + } return { ledgerApiUrl: require_('LEDGER_API_URL'), ledgerApiToken: require_('LEDGER_API_TOKEN'), @@ -93,5 +108,6 @@ export function loadConfig(env: NodeJS.ProcessEnv): Config { port: parsePort(env.PORT), shutdownTimeoutMs: parseTimeoutMs(env.SHUTDOWN_TIMEOUT_MS), directTransferMarginMs: parseMarginMs(env.DIRECT_TRANSFER_MARGIN_MS), + corsOrigins: parseOrigins(env.CORS_ORIGINS), } } diff --git a/registry/src/server.ts b/registry/src/server.ts index 22f935f..ef9610b 100644 --- a/registry/src/server.ts +++ b/registry/src/server.ts @@ -1,4 +1,5 @@ import path from 'node:path' +import cors from 'cors' import express, { type Express, type NextFunction, type Request, type Response } from 'express' import * as OpenApiValidator from 'express-openapi-validator' import type { Config } from './config.js' @@ -73,6 +74,18 @@ export function createServer(deps: ServerDeps): Express { const app = express() const logger = deps.logger ?? createLogger() app.use(canonicalizeRequestTarget) + // A browser only reads a cross-origin response if that response names its + // origin back, and a rejection is a response too, so this has to run ahead + // of the body parser and the validators below: otherwise their own 400s + // reach the page as an opaque network error instead of the message they + // carry. It also answers the preflight itself, which is why no route below + // ever sees an OPTIONS request. + app.use( + cors({ + origin: deps.config.corsOrigins.includes('*') ? true : deps.config.corsOrigins, + methods: ['GET', 'HEAD', 'POST', 'OPTIONS'], + }), + ) app.use(express.json()) // One validator per vendored standard spec, requests only, each mounted on diff --git a/registry/test/config.test.ts b/registry/test/config.test.ts index dab631b..f630d30 100644 --- a/registry/test/config.test.ts +++ b/registry/test/config.test.ts @@ -171,3 +171,37 @@ describe('loadConfig direct transfer margin parsing', () => { ) }) }) + +describe('loadConfig CORS origins parsing', () => { + it('defaults to the dApp dev server when CORS_ORIGINS is unset', () => { + expect(loadConfig({ ...baseEnv }).corsOrigins).toEqual(['http://localhost:3012']) + }) + + it('defaults to the dApp dev server when CORS_ORIGINS is an empty string', () => { + expect(loadConfig({ ...baseEnv, CORS_ORIGINS: '' }).corsOrigins).toEqual([ + 'http://localhost:3012', + ]) + }) + + it('parses a single origin', () => { + expect(loadConfig({ ...baseEnv, CORS_ORIGINS: 'http://a' }).corsOrigins).toEqual(['http://a']) + }) + + it('splits and trims a comma-separated list', () => { + expect(loadConfig({ ...baseEnv, CORS_ORIGINS: 'http://a, http://b' }).corsOrigins).toEqual([ + 'http://a', + 'http://b', + ]) + }) + + it('drops empty entries left by stray or trailing commas', () => { + expect(loadConfig({ ...baseEnv, CORS_ORIGINS: 'http://a,,http://b,' }).corsOrigins).toEqual([ + 'http://a', + 'http://b', + ]) + }) + + it('keeps "*" verbatim: the server, not the config, interprets it', () => { + expect(loadConfig({ ...baseEnv, CORS_ORIGINS: '*' }).corsOrigins).toEqual(['*']) + }) +}) diff --git a/registry/test/cors.test.ts b/registry/test/cors.test.ts new file mode 100644 index 0000000..ce3a686 --- /dev/null +++ b/registry/test/cors.test.ts @@ -0,0 +1,85 @@ +import request from 'supertest' +import { describe, expect, it } from 'vitest' +import { createServer } from '../src/server' +import { config, ledgerFrom } from './helpers/fixtures' + +const ALLOWED_ORIGIN = config.corsOrigins[0] +const DISALLOWED_ORIGIN = 'http://not-allowed.example' + +describe('cors', () => { + it('reflects an allowed origin on a real GET, with Vary: Origin', async () => { + const app = createServer({ ledger: ledgerFrom({}), config }) + const res = await request(app).get('/healthz').set('Origin', ALLOWED_ORIGIN) + expect(res.status).toBe(200) + expect(res.headers['access-control-allow-origin']).toBe(ALLOWED_ORIGIN) + expect(res.headers.vary).toBe('Origin') + }) + + // The service does not reject a disallowed origin: it answers the request + // normally and simply omits the header that would let the browser hand the + // body to the page. Asserting the status stays 200 is what tells the two + // apart. + it('answers a disallowed origin with 200 and no Access-Control-Allow-Origin', async () => { + const app = createServer({ ledger: ledgerFrom({}), config }) + const res = await request(app).get('/healthz').set('Origin', DISALLOWED_ORIGIN) + expect(res.status).toBe(200) + expect(res.headers['access-control-allow-origin']).toBeUndefined() + }) + + it('answers a preflight from an allowed origin with 204 and no Allow header', async () => { + const app = createServer({ ledger: ledgerFrom({}), config }) + const res = await request(app) + .options('/registry/transfer-instruction/v1/transfer-factory') + .set('Origin', ALLOWED_ORIGIN) + .set('Access-Control-Request-Method', 'POST') + expect(res.status).toBe(204) + expect(res.headers['access-control-allow-origin']).toBe(ALLOWED_ORIGIN) + expect(res.headers['access-control-allow-methods']).toMatch(/POST/) + // express's default OPTIONS handler is what sets Allow; its absence is + // what proves cors answered the preflight itself, ahead of routing. + expect(res.headers.allow).toBeUndefined() + }) + + it('answers a preflight from a disallowed origin with 204 and no Access-Control-Allow-Origin', async () => { + const app = createServer({ ledger: ledgerFrom({}), config }) + const res = await request(app) + .options('/registry/transfer-instruction/v1/transfer-factory') + .set('Origin', DISALLOWED_ORIGIN) + .set('Access-Control-Request-Method', 'POST') + expect(res.status).toBe(204) + expect(res.headers['access-control-allow-origin']).toBeUndefined() + }) + + // The important test: a browser can only show the validator's own rejection + // message if that rejection is itself readable cross-origin, which is true + // only while the CORS layer runs ahead of the OpenAPI validators. + it('carries Access-Control-Allow-Origin on a request the OpenAPI validator rejects', async () => { + const app = createServer({ ledger: ledgerFrom({}), config }) + const res = await request(app) + .get('/registry/metadata/v1/instruments?pageSize=abc') + .set('Origin', ALLOWED_ORIGIN) + expect(res.status).toBe(400) + expect(res.headers['access-control-allow-origin']).toBe(ALLOWED_ORIGIN) + }) + + // Same argument, pinned against express.json() instead of the validators: + // its malformed-body 400 is raised the same way, so it has to be reachable + // cross-origin too. + it('carries Access-Control-Allow-Origin on a malformed JSON body', async () => { + const app = createServer({ ledger: ledgerFrom({}), config }) + const res = await request(app) + .post('/registry/transfer-instruction/v1/transfer-factory') + .set('Origin', ALLOWED_ORIGIN) + .set('content-type', 'application/json') + .send('{"choiceArguments":') + expect(res.status).toBe(400) + expect(res.headers['access-control-allow-origin']).toBe(ALLOWED_ORIGIN) + }) + + it('reflects whatever origin asks when corsOrigins is ["*"]', async () => { + const app = createServer({ ledger: ledgerFrom({}), config: { ...config, corsOrigins: ['*'] } }) + const res = await request(app).get('/healthz').set('Origin', 'http://anything.example') + expect(res.status).toBe(200) + expect(res.headers['access-control-allow-origin']).toBe('http://anything.example') + }) +}) diff --git a/registry/test/e2e/helpers/fixture.ts b/registry/test/e2e/helpers/fixture.ts index 15ad5b6..3b0fecc 100644 --- a/registry/test/e2e/helpers/fixture.ts +++ b/registry/test/e2e/helpers/fixture.ts @@ -56,6 +56,7 @@ export async function setupInstrument(): Promise { port: 0, shutdownTimeoutMs: 8_000, directTransferMarginMs: 30_000, + corsOrigins: ['http://localhost:3012'], } const ledger = new HttpLedgerClient(config) diff --git a/registry/test/helpers/fixtures.ts b/registry/test/helpers/fixtures.ts index 16d3d8c..38a725f 100644 --- a/registry/test/helpers/fixtures.ts +++ b/registry/test/helpers/fixtures.ts @@ -27,6 +27,9 @@ export const config: Config = { port: 0, shutdownTimeoutMs: 8_000, directTransferMarginMs: 30_000, + // Deliberately not the production default: a binding that ignores this + // field and hardcodes DEFAULT_CORS_ORIGINS would otherwise pass by luck. + corsOrigins: ['http://allowed.example'], } export const instrumentId: InstrumentIdValue = { admin: 'admin::1', id: 'CC' } From 4b11588556adc16ff684360e8966a5af191f1603 Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Mon, 7 Sep 2026 14:28:55 +0000 Subject: [PATCH 02/18] fix(registry): reject a CORS_ORIGINS entry no browser could ever send --- registry/src/config.ts | 29 +++++++++++++++++++-- registry/test/config.test.ts | 49 ++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/registry/src/config.ts b/registry/src/config.ts index ca6c93d..2e0b801 100644 --- a/registry/src/config.ts +++ b/registry/src/config.ts @@ -88,12 +88,37 @@ export function loadConfig(env: NodeJS.ProcessEnv): Config { } return n } + // A browser sends the origin it computed, so an entry the browser can never + // send matches nothing and blocks the dApp with the same opaque failure this + // list exists to prevent, from a service that started clean. The two ways to + // write one are a trailing slash (what the address bar gives you) and a host + // that is not already lower case, and URL normalizes both, so comparing an + // entry against its own origin rejects exactly the values that cannot match. const parseOrigins = (raw: string | undefined): string[] => { - const value = raw || DEFAULT_CORS_ORIGINS - return value + const entries = (raw || DEFAULT_CORS_ORIGINS) .split(',') .map((origin) => origin.trim()) .filter((origin) => origin.length > 0) + if (entries.length === 0) { + throw new Error(`invalid CORS_ORIGINS: names no origin, got "${raw}"`) + } + for (const entry of entries) { + if (entry === '*') continue + let normalized: string + try { + normalized = new URL(entry).origin + } catch { + throw new Error( + `invalid CORS_ORIGINS entry "${entry}": expected an origin, scheme://host[:port], or *`, + ) + } + if (normalized !== entry) { + throw new Error( + `invalid CORS_ORIGINS entry "${entry}": a browser would send "${normalized}", so write that instead`, + ) + } + } + return entries } return { ledgerApiUrl: require_('LEDGER_API_URL'), diff --git a/registry/test/config.test.ts b/registry/test/config.test.ts index f630d30..46bf052 100644 --- a/registry/test/config.test.ts +++ b/registry/test/config.test.ts @@ -204,4 +204,53 @@ describe('loadConfig CORS origins parsing', () => { it('keeps "*" verbatim: the server, not the config, interprets it', () => { expect(loadConfig({ ...baseEnv, CORS_ORIGINS: '*' }).corsOrigins).toEqual(['*']) }) + + // A value that is non-empty but names nothing does not reach the default, + // and the empty list it used to produce matched every origin against + // nothing: the service started clean and no browser could read a response. + it.each([ + ',', + ' ', + ' , , ', + ',,,', + ])('throws when CORS_ORIGINS is %j, which names no origin', (value) => { + expect(() => loadConfig({ ...baseEnv, CORS_ORIGINS: value })).toThrow( + /invalid CORS_ORIGINS: names no origin/, + ) + }) + + // The two shapes an operator actually writes by hand. A browser sends + // neither, and cors compares origins by exact string, so both would match + // nothing at all. + it('throws on an entry with a trailing slash, naming what a browser would send', () => { + expect(() => loadConfig({ ...baseEnv, CORS_ORIGINS: 'http://localhost:3012/' })).toThrow( + /a browser would send "http:\/\/localhost:3012"/, + ) + }) + + it('throws on an entry whose host is not lower case', () => { + expect(() => loadConfig({ ...baseEnv, CORS_ORIGINS: 'http://LOCALHOST:3012' })).toThrow( + /a browser would send "http:\/\/localhost:3012"/, + ) + }) + + it('throws on an entry that is not a URL at all', () => { + expect(() => loadConfig({ ...baseEnv, CORS_ORIGINS: '*.example.com' })).toThrow( + /expected an origin, scheme:\/\/host\[:port\], or \*/, + ) + }) + + // A default port is part of what URL normalizes away, so naming it is the + // same class of unmatchable entry as a trailing slash. + it('throws on an entry that spells out the scheme default port', () => { + expect(() => loadConfig({ ...baseEnv, CORS_ORIGINS: 'http://app.example:80' })).toThrow( + /a browser would send "http:\/\/app.example"/, + ) + }) + + it('rejects a bad entry even when a good one precedes it', () => { + expect(() => + loadConfig({ ...baseEnv, CORS_ORIGINS: 'http://localhost:3012, http://app.example/' }), + ).toThrow(/invalid CORS_ORIGINS entry "http:\/\/app.example\/"/) + }) }) From 1058325ec1b41d52ada3434b15a4dfd5957cf612 Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Mon, 7 Sep 2026 14:31:55 +0000 Subject: [PATCH 03/18] fix(registry): log the origins the service will accept at boot --- registry/src/index.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/registry/src/index.ts b/registry/src/index.ts index 649ae2a..6a25557 100644 --- a/registry/src/index.ts +++ b/registry/src/index.ts @@ -28,7 +28,14 @@ if (!(await checkAdminParty(ledger, config, logger))) process.exit(1) const app = createServer({ ledger, config, logger }) const server = app.listen(config.port, () => { - logger.info({ port: config.port }, 'canton-token-forge registry listening') + // The origin list is the one setting whose effect is invisible from the + // service side: a browser that is refused reports an opaque network error to + // the page and sends nothing here to log. Recording what was accepted at boot + // is what lets an operator tell a rejected origin from an unreachable service. + logger.info( + { port: config.port, corsOrigins: config.corsOrigins }, + 'canton-token-forge registry listening', + ) }) let shuttingDown = false From 6406495a957748f885f765da7b71e5b8122d879b Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Mon, 7 Sep 2026 14:33:45 +0000 Subject: [PATCH 04/18] test(registry): pin the exact method list the preflight advertises --- registry/test/cors.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/registry/test/cors.test.ts b/registry/test/cors.test.ts index ce3a686..644f5df 100644 --- a/registry/test/cors.test.ts +++ b/registry/test/cors.test.ts @@ -34,7 +34,11 @@ describe('cors', () => { .set('Access-Control-Request-Method', 'POST') expect(res.status).toBe(204) expect(res.headers['access-control-allow-origin']).toBe(ALLOWED_ORIGIN) - expect(res.headers['access-control-allow-methods']).toMatch(/POST/) + // The exact list, not a substring: the four vendored specs declare only GET + // and POST operations and express serves HEAD for every GET, so advertising + // PUT, PATCH or DELETE would name methods no route answers. Asserting only + // that POST is present cannot see that. + expect(res.headers['access-control-allow-methods']).toBe('GET,HEAD,POST,OPTIONS') // express's default OPTIONS handler is what sets Allow; its absence is // what proves cors answered the preflight itself, ahead of routing. expect(res.headers.allow).toBeUndefined() From 00a7e3b8db94dbe04cfb633e27368558caf8230a Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Mon, 7 Sep 2026 14:34:01 +0000 Subject: [PATCH 05/18] fix(registry): stop advertising methods and headers the service does not serve --- registry/src/server.ts | 9 +++++++++ registry/test/cors.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/registry/src/server.ts b/registry/src/server.ts index ef9610b..686dc92 100644 --- a/registry/src/server.ts +++ b/registry/src/server.ts @@ -80,10 +80,19 @@ export function createServer(deps: ServerDeps): Express { // reach the page as an opaque network error instead of the message they // carry. It also answers the preflight itself, which is why no route below // ever sees an OPTIONS request. + // The four vendored specs declare only GET and POST operations and express + // serves HEAD for every GET, so the methods and the one header a handler can + // reach are both named rather than left at the cors defaults, which advertise + // methods no route answers and echo back whatever headers a caller asks for. + // Every factory route is a POST and so preflights on every call; a browser + // caches a preflight carrying no max-age for seconds, which would make each + // call two round trips. app.use( cors({ origin: deps.config.corsOrigins.includes('*') ? true : deps.config.corsOrigins, methods: ['GET', 'HEAD', 'POST', 'OPTIONS'], + allowedHeaders: ['Content-Type'], + maxAge: 600, }), ) app.use(express.json()) diff --git a/registry/test/cors.test.ts b/registry/test/cors.test.ts index 644f5df..c92f7f9 100644 --- a/registry/test/cors.test.ts +++ b/registry/test/cors.test.ts @@ -44,6 +44,31 @@ describe('cors', () => { expect(res.headers.allow).toBeUndefined() }) + // Every factory route is a POST, so every factory call preflights, and a + // browser caches a preflight carrying no max-age for a few seconds at most. + // Without this the dApp pays two round trips for each call it makes. + it('lets a browser cache the preflight', async () => { + const app = createServer({ ledger: ledgerFrom({}), config }) + const res = await request(app) + .options('/registry/transfer-instruction/v1/transfer-factory') + .set('Origin', ALLOWED_ORIGIN) + .set('Access-Control-Request-Method', 'POST') + expect(res.headers['access-control-max-age']).toBe('600') + }) + + // Left at the cors default, this echoes whatever the request asks for, which + // advertises headers no handler reads. Nothing in src/ reads a request header + // at all; the body parser and the validators need only the content type. + it('advertises only the request header the service reads', async () => { + const app = createServer({ ledger: ledgerFrom({}), config }) + const res = await request(app) + .options('/registry/transfer-instruction/v1/transfer-factory') + .set('Origin', ALLOWED_ORIGIN) + .set('Access-Control-Request-Method', 'POST') + .set('Access-Control-Request-Headers', 'content-type, authorization') + expect(res.headers['access-control-allow-headers']).toBe('Content-Type') + }) + it('answers a preflight from a disallowed origin with 204 and no Access-Control-Allow-Origin', async () => { const app = createServer({ ledger: ledgerFrom({}), config }) const res = await request(app) From 3e8574f80ba65e5817346dfefb806c3d17f7b808 Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Mon, 7 Sep 2026 14:34:17 +0000 Subject: [PATCH 06/18] docs: say what CORS_ORIGINS accepts and that empty means the default --- registry/.env.example | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/registry/.env.example b/registry/.env.example index a38ffc6..a5c0488 100644 --- a/registry/.env.example +++ b/registry/.env.example @@ -77,9 +77,18 @@ SHUTDOWN_TIMEOUT_MS=8000 DIRECT_TRANSFER_MARGIN_MS=30000 # Comma-separated list of origins a browser dApp may call this service from. -# The service reflects an origin back only if it is on this list (or the list -# is "*", meaning any origin), because a browser will not hand a cross-origin -# response to the page otherwise. Optional; defaults to +# The service reflects an origin back only if it is on this list, because a +# browser will not hand a cross-origin response to the page otherwise. An entry +# of "*" anywhere in the list means any origin at all. Optional; defaults to # http://localhost:3012, the dApp dev server this exists for. Any real # deployment sets this to its own origin(s). +# +# Unset and empty both mean the default, so there is no value that allows no +# origin: the narrowest setting is one origin nothing is served from. +# +# Write each entry exactly as a browser computes an origin, scheme://host with +# a port only when it is not the scheme's default, and with no path, query or +# trailing slash. The service refuses to start on an entry that is not in that +# form, naming the value a browser would have sent instead, because the origin +# is compared as an exact string and a near miss matches nothing at all. CORS_ORIGINS=http://localhost:3012 From 8768632fda43d0dfcbff6546bb378bf69120d257 Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Mon, 7 Sep 2026 14:41:07 +0000 Subject: [PATCH 07/18] docs: record the CORS_ORIGINS key and refresh the registry counts --- README.md | 17 ++++++++++++++--- SPEC.md | 21 +++++++++++---------- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 9568ed1..df9cbcf 100644 --- a/README.md +++ b/README.md @@ -212,9 +212,20 @@ loaded from the working directory it is started in. Required, eight: `LEDGER_API_URL`, `LEDGER_API_TOKEN`, `ADMIN_PARTY`, and the five template ids (`INSTRUMENT_CONFIG_TEMPLATE_ID`, `PREAPPROVAL_TEMPLATE_ID`, `LOCKED_TOKEN_TEMPLATE_ID`, `TRANSFER_INSTRUCTION_TEMPLATE_ID`, -`ALLOCATION_TEMPLATE_ID`). Optional, four: `PORT`, `LEDGER_USER_ID`, -`SHUTDOWN_TIMEOUT_MS`, `DIRECT_TRANSFER_MARGIN_MS`. The package ships -`registry/.env.example` with the full list and what each variable is for. +`ALLOCATION_TEMPLATE_ID`). Optional, five: `PORT`, `LEDGER_USER_ID`, +`SHUTDOWN_TIMEOUT_MS`, `DIRECT_TRANSFER_MARGIN_MS`, `CORS_ORIGINS`. The package +ships `registry/.env.example` with the full list and what each variable is for. + +`CORS_ORIGINS` is the comma-separated list of origins a browser dApp may call +the service from, defaulting to `http://localhost:3012`; an entry of `*` +anywhere in it means any origin. A browser reads a cross-origin response only +if the service names the requesting origin back, so an origin missing from this +list fails in the page with an opaque network error and no trace in the request +log; the list the service accepted is on its startup line instead. Each entry is +written as a browser computes an origin, `scheme://host` +with a port only when it is not the scheme's default and with no path, query or +trailing slash; the service refuses to start on anything else, since the +comparison is an exact string match and a near miss matches nothing. Quote all five template ids in a `.env` file. Every one of them begins with `#`, which dotenv reads as the start of a comment, so an unquoted diff --git a/SPEC.md b/SPEC.md index f85e2d3..a51467d 100644 --- a/SPEC.md +++ b/SPEC.md @@ -53,7 +53,7 @@ All three suites were re-run against the tree this document ships with, exit 0: | Suite | Result | Needs | |---|---|---| | Daml Script | **80 scenarios**, 12 modules | nothing, runs in-process | -| Registry unit | **205 tests**, 10 files | nothing, in-process server with a stub ledger | +| Registry unit | **229 tests**, 11 files | nothing, in-process server with a stub ledger | | End-to-end | **18 tests**, 4 files | a live participant, verified against Canton 3.5.12 | The end-to-end suite drives both transfer paths against a real participant: it @@ -63,8 +63,8 @@ resulting exercise itself over the JSON Ledger API, forwarding the service's ### Size and status -976 lines of production Daml, 2508 lines of Daml tests, 1739 lines of -TypeScript service, 4349 lines of TypeScript tests, each figure a +976 lines of production Daml, 2508 lines of Daml tests, 1809 lines of +TypeScript service, 4550 lines of TypeScript tests, each figure a `find -name '*.daml'` (or `'*.ts'`) `| xargs wc -l` count over `daml/canton-token-forge/daml`, `daml/canton-token-forge-test/daml`, `registry/src` and `registry/test` respectively. The two Daml paths name the @@ -317,9 +317,9 @@ holding for any surplus, so no value is created or destroyed. ## 6. Registry HTTP service -A TypeScript service (Express, `express-openapi-validator`, pino; Node 20+) that -validates incoming requests against the four CN Token Standard OpenAPI specs it -ships. Responses are covered by the unit suite rather than by runtime schema +A TypeScript service (Express, `express-openapi-validator`, `cors`, pino; +Node 20+) that validates incoming requests against the four CN Token Standard +OpenAPI specs it ships. Responses are covered by the unit suite rather than by runtime schema validation. The service is **read-only**: it queries the JSON Ledger API for active contracts and submits nothing. The client holds the keys and sends the exercise itself. @@ -362,9 +362,10 @@ about rather than fatal, so a ledger outage does not turn into a crashloop. Configuration is entirely by environment: eight required variables (ledger URL and token, admin party, and five concrete template ids in package-name form) and -four optional ones. The service refuses to start if any required variable is +five optional ones. The service refuses to start if any required variable is missing, if a template id is not in package-name form or names nothing the -participant hosts, or if the admin party fails the boot check above, rather than +participant hosts, if an allowed browser origin is not written in the form a +browser sends, or if the admin party fails the boot check above, rather than serving empty results from a filter that matches nothing. ### Choice contexts and disclosure @@ -448,7 +449,7 @@ exist. | Level | What it covers | |---|---| | Daml Script, 80 scenarios | Every choice and both factory paths, including negative cases: wrong `expectedAdmin`, a batch transfer routed through another instrument of the same admin, non-positive amounts, duplicate and locked inputs, cross-instrument spending, an escrow that does not back the transfer it settles, both sides of every deadline instant, missing authority, the `decimals` bound, and the batch transfer's own refusals: outputs whose total exceeds the inputs and a lock output already past its expiry | -| Registry unit, 205 tests | Every route against an in-process server with a stub ledger: response shapes, error schemas, 404 and 409 behaviour, context and disclosure contents, the state an escrow lookup has to be in before a context may report a reclaim, config validation, and that each request is validated against the one spec that describes it, whichever form its request target arrives in and even when it carries a fragment, which is no form at all | +| Registry unit, 229 tests | Every route against an in-process server with a stub ledger: response shapes, error schemas, 404 and 409 behaviour, context and disclosure contents, the state an escrow lookup has to be in before a context may report a reclaim, config validation, that a configured browser origin is answered and an unconfigured one is not, on rejections as well as on successes, and that each request is validated against the one spec that describes it, whichever form its request target arrives in and even when it carries a fragment, which is no form at all | | End-to-end, 18 tests | Both transfer paths and the faucet against a live participant, submitting real exercises built from the service's own answers, including a misconfigured escrow template id that must not produce a reclaim report | The end-to-end suite allocates its own parties and instrument per run, so it @@ -466,7 +467,7 @@ instrument, then prints a ready-to-paste service configuration. ```bash npm run setup # vendors the Splice interface DARs into deps/ npm test # builds the production DAR, runs 80 Daml scenarios -cd registry && npm install && npm test # 205 unit tests, no ledger needed +cd registry && npm install && npm test # 229 unit tests, no ledger needed npm run sandbox # a local Canton sandbox with the JSON Ledger API npm run seed # an admin, demo users, one instrument From 095d74848d9dd189d32414b6480e499c7169390f Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Mon, 7 Sep 2026 15:08:38 +0000 Subject: [PATCH 08/18] fix(registry): stop naming "null" as the value to write in a CORS_ORIGINS refusal --- registry/.env.example | 6 ++++-- registry/src/config.ts | 14 +++++++++++--- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/registry/.env.example b/registry/.env.example index a5c0488..3a7194b 100644 --- a/registry/.env.example +++ b/registry/.env.example @@ -89,6 +89,8 @@ DIRECT_TRANSFER_MARGIN_MS=30000 # Write each entry exactly as a browser computes an origin, scheme://host with # a port only when it is not the scheme's default, and with no path, query or # trailing slash. The service refuses to start on an entry that is not in that -# form, naming the value a browser would have sent instead, because the origin -# is compared as an exact string and a near miss matches nothing at all. +# form, because the origin is compared as an exact string and a near miss +# matches nothing at all. Where the entry is a near miss of a real origin the +# refusal names what a browser would have sent, and where it names no origin at +# all, such as a host written without its scheme, it says so instead. CORS_ORIGINS=http://localhost:3012 diff --git a/registry/src/config.ts b/registry/src/config.ts index 2e0b801..3e276c8 100644 --- a/registry/src/config.ts +++ b/registry/src/config.ts @@ -102,16 +102,24 @@ export function loadConfig(env: NodeJS.ProcessEnv): Config { if (entries.length === 0) { throw new Error(`invalid CORS_ORIGINS: names no origin, got "${raw}"`) } + const notAnOrigin = (entry: string) => + new Error( + `invalid CORS_ORIGINS entry "${entry}": expected an origin, scheme://host[:port], or *`, + ) for (const entry of entries) { if (entry === '*') continue let normalized: string try { normalized = new URL(entry).origin } catch { - throw new Error( - `invalid CORS_ORIGINS entry "${entry}": expected an origin, scheme://host[:port], or *`, - ) + throw notAnOrigin(entry) } + // URL accepts anything carrying a colon, so an entry that omits the + // scheme parses as a non-special URL whose origin is the string "null". + // Naming that back as the value to write would be a remedy the operator + // cannot take: "null" carries no colon and is refused as not a URL, so + // following the message costs a second failed boot. + if (normalized === 'null') throw notAnOrigin(entry) if (normalized !== entry) { throw new Error( `invalid CORS_ORIGINS entry "${entry}": a browser would send "${normalized}", so write that instead`, From ad32b9a70130de9fbad30869b3d18cf5b1a7535c Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Mon, 7 Sep 2026 15:08:38 +0000 Subject: [PATCH 09/18] test(registry): pin that a CORS_ORIGINS entry with no origin is refused as one --- registry/test/config.test.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/registry/test/config.test.ts b/registry/test/config.test.ts index 46bf052..9bcd9e5 100644 --- a/registry/test/config.test.ts +++ b/registry/test/config.test.ts @@ -240,6 +240,28 @@ describe('loadConfig CORS origins parsing', () => { ) }) + // URL accepts anything carrying a colon, so an entry that omits the scheme + // parses as a non-special URL whose origin is the literal string "null". + // These have to land on the message above rather than the one that names a + // replacement, because "null" is not a value the operator can write: it is + // itself refused as not a URL, so naming it costs a second failed boot. + it.each([ + 'localhost:3012', + 'app.example:8080', + 'file:///x', + 'chrome-extension://abc', + ])('throws on %j, which has no origin to name back', (value) => { + expect(() => loadConfig({ ...baseEnv, CORS_ORIGINS: value })).toThrow( + /expected an origin, scheme:\/\/host\[:port\], or \*/, + ) + }) + + it('never tells the operator to write "null"', () => { + expect(() => loadConfig({ ...baseEnv, CORS_ORIGINS: 'localhost:3012' })).not.toThrow( + /a browser would send "null"/, + ) + }) + // A default port is part of what URL normalizes away, so naming it is the // same class of unmatchable entry as a trailing slash. it('throws on an entry that spells out the scheme default port', () => { From 9d39724d5cb99f8cfdb76d725356015b87d45c1d Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Mon, 7 Sep 2026 15:08:38 +0000 Subject: [PATCH 10/18] docs(registry): correct what a refused origin and an off-route OPTIONS leave behind --- README.md | 7 ++++--- registry/src/index.ts | 8 +++++--- registry/src/server.ts | 3 ++- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index df9cbcf..8927a06 100644 --- a/README.md +++ b/README.md @@ -220,9 +220,10 @@ ships `registry/.env.example` with the full list and what each variable is for. the service from, defaulting to `http://localhost:3012`; an entry of `*` anywhere in it means any origin. A browser reads a cross-origin response only if the service names the requesting origin back, so an origin missing from this -list fails in the page with an opaque network error and no trace in the request -log; the list the service accepted is on its startup line instead. Each entry is -written as a browser computes an origin, `scheme://host` +list fails in the page with an opaque network error while the service delivers +and answers the request as it would any other, leaving nothing behind that +tells the two apart; the list the service accepted is on its startup line +instead. Each entry is written as a browser computes an origin, `scheme://host` with a port only when it is not the scheme's default and with no path, query or trailing slash; the service refuses to start on anything else, since the comparison is an exact string match and a near miss matches nothing. diff --git a/registry/src/index.ts b/registry/src/index.ts index 6a25557..2c2ba50 100644 --- a/registry/src/index.ts +++ b/registry/src/index.ts @@ -29,9 +29,11 @@ if (!(await checkAdminParty(ledger, config, logger))) process.exit(1) const app = createServer({ ledger, config, logger }) const server = app.listen(config.port, () => { // The origin list is the one setting whose effect is invisible from the - // service side: a browser that is refused reports an opaque network error to - // the page and sends nothing here to log. Recording what was accepted at boot - // is what lets an operator tell a rejected origin from an unreachable service. + // service side: a refused request is still delivered and answered in full, + // and the browser withholds the response from the page afterwards, so + // nothing observable here separates it from one that was allowed. Recording + // what was accepted at boot is what lets an operator tell a rejected origin + // from an unreachable service. logger.info( { port: config.port, corsOrigins: config.corsOrigins }, 'canton-token-forge registry listening', diff --git a/registry/src/server.ts b/registry/src/server.ts index 686dc92..8907cfd 100644 --- a/registry/src/server.ts +++ b/registry/src/server.ts @@ -79,7 +79,8 @@ export function createServer(deps: ServerDeps): Express { // of the body parser and the validators below: otherwise their own 400s // reach the page as an opaque network error instead of the message they // carry. It also answers the preflight itself, which is why no route below - // ever sees an OPTIONS request. + // ever sees an OPTIONS request: every one of them is terminated here, so a + // path that routes nowhere answers 204 to an OPTIONS where it 404s to a GET. // The four vendored specs declare only GET and POST operations and express // serves HEAD for every GET, so the methods and the one header a handler can // reach are both named rather than left at the cors defaults, which advertise From 5c9f261ec4d7d759cf598efe3848b7a200c7806f Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Mon, 7 Sep 2026 15:08:38 +0000 Subject: [PATCH 11/18] test(registry): pin that a refused origin is served in full and that OPTIONS answers off-route --- registry/test/cors.test.ts | 39 +++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/registry/test/cors.test.ts b/registry/test/cors.test.ts index c92f7f9..9f9c806 100644 --- a/registry/test/cors.test.ts +++ b/registry/test/cors.test.ts @@ -1,7 +1,7 @@ import request from 'supertest' import { describe, expect, it } from 'vitest' import { createServer } from '../src/server' -import { config, ledgerFrom } from './helpers/fixtures' +import { cfgEntry, config, ledgerFrom } from './helpers/fixtures' const ALLOWED_ORIGIN = config.corsOrigins[0] const DISALLOWED_ORIGIN = 'http://not-allowed.example' @@ -26,6 +26,30 @@ describe('cors', () => { expect(res.headers['access-control-allow-origin']).toBeUndefined() }) + // A simple request is not preflighted, so nothing stops it: it is delivered, + // routed, and served off the ledger like any other, and only the browser + // withholds the body from the page. Counting the ledger read is what shows + // the work was done, which /healthz above cannot: an operator cannot tell a + // refused origin from an allowed one by watching the service. + it('serves a disallowed origin in full, refusing it only in the browser', async () => { + const base = ledgerFrom({ [config.instrumentConfigTemplateId]: [cfgEntry()] }) + let reads = 0 + const ledger = { + ...base, + activeContracts: (templateId: string, party: string) => { + reads += 1 + return base.activeContracts(templateId, party) + }, + } + const res = await request(createServer({ ledger, config })) + .get('/registry/metadata/v1/instruments') + .set('Origin', DISALLOWED_ORIGIN) + expect(res.status).toBe(200) + expect(res.body.instruments).toHaveLength(1) + expect(reads).toBe(1) + expect(res.headers['access-control-allow-origin']).toBeUndefined() + }) + it('answers a preflight from an allowed origin with 204 and no Allow header', async () => { const app = createServer({ ledger: ledgerFrom({}), config }) const res = await request(app) @@ -105,6 +129,19 @@ describe('cors', () => { expect(res.headers['access-control-allow-origin']).toBe(ALLOWED_ORIGIN) }) + // cors terminates every OPTIONS, not only preflights and not only on paths + // that route, so a path the service does not serve answers 204 here where + // express used to 404. The GET is what still reports the path as missing, + // and pinning both is what keeps the difference deliberate. + it('answers OPTIONS on an unrouted path with 204, whose GET still 404s', async () => { + const app = createServer({ ledger: ledgerFrom({}), config }) + const preflight = await request(app).options('/no/such/path') + expect(preflight.status).toBe(204) + expect(preflight.headers['access-control-allow-methods']).toBe('GET,HEAD,POST,OPTIONS') + const get = await request(app).get('/no/such/path') + expect(get.status).toBe(404) + }) + it('reflects whatever origin asks when corsOrigins is ["*"]', async () => { const app = createServer({ ledger: ledgerFrom({}), config: { ...config, corsOrigins: ['*'] } }) const res = await request(app).get('/healthz').set('Origin', 'http://anything.example') From ee5412f7711f038e231c67726a95efa2b717e813 Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Mon, 7 Sep 2026 15:14:59 +0000 Subject: [PATCH 12/18] docs: refresh the registry counts and record the CORS coverage the unit suite adds --- SPEC.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/SPEC.md b/SPEC.md index a51467d..09a6487 100644 --- a/SPEC.md +++ b/SPEC.md @@ -53,7 +53,7 @@ All three suites were re-run against the tree this document ships with, exit 0: | Suite | Result | Needs | |---|---|---| | Daml Script | **80 scenarios**, 12 modules | nothing, runs in-process | -| Registry unit | **229 tests**, 11 files | nothing, in-process server with a stub ledger | +| Registry unit | **236 tests**, 11 files | nothing, in-process server with a stub ledger | | End-to-end | **18 tests**, 4 files | a live participant, verified against Canton 3.5.12 | The end-to-end suite drives both transfer paths against a real participant: it @@ -63,8 +63,8 @@ resulting exercise itself over the JSON Ledger API, forwarding the service's ### Size and status -976 lines of production Daml, 2508 lines of Daml tests, 1809 lines of -TypeScript service, 4550 lines of TypeScript tests, each figure a +976 lines of production Daml, 2508 lines of Daml tests, 1820 lines of +TypeScript service, 4609 lines of TypeScript tests, each figure a `find -name '*.daml'` (or `'*.ts'`) `| xargs wc -l` count over `daml/canton-token-forge/daml`, `daml/canton-token-forge-test/daml`, `registry/src` and `registry/test` respectively. The two Daml paths name the @@ -449,7 +449,7 @@ exist. | Level | What it covers | |---|---| | Daml Script, 80 scenarios | Every choice and both factory paths, including negative cases: wrong `expectedAdmin`, a batch transfer routed through another instrument of the same admin, non-positive amounts, duplicate and locked inputs, cross-instrument spending, an escrow that does not back the transfer it settles, both sides of every deadline instant, missing authority, the `decimals` bound, and the batch transfer's own refusals: outputs whose total exceeds the inputs and a lock output already past its expiry | -| Registry unit, 229 tests | Every route against an in-process server with a stub ledger: response shapes, error schemas, 404 and 409 behaviour, context and disclosure contents, the state an escrow lookup has to be in before a context may report a reclaim, config validation, that a configured browser origin is answered and an unconfigured one is not, on rejections as well as on successes, and that each request is validated against the one spec that describes it, whichever form its request target arrives in and even when it carries a fragment, which is no form at all | +| Registry unit, 236 tests | Every route against an in-process server with a stub ledger: response shapes, error schemas, 404 and 409 behaviour, context and disclosure contents, the state an escrow lookup has to be in before a context may report a reclaim, config validation, that a configured browser origin is answered and an unconfigured one is not, on rejections as well as on successes, that a request from an unconfigured origin is served in full regardless and refused only in the browser, that a path the service does not route answers a preflight all the same, and that each request is validated against the one spec that describes it, whichever form its request target arrives in and even when it carries a fragment, which is no form at all | | End-to-end, 18 tests | Both transfer paths and the faucet against a live participant, submitting real exercises built from the service's own answers, including a misconfigured escrow template id that must not produce a reclaim report | The end-to-end suite allocates its own parties and instrument per run, so it @@ -467,7 +467,7 @@ instrument, then prints a ready-to-paste service configuration. ```bash npm run setup # vendors the Splice interface DARs into deps/ npm test # builds the production DAR, runs 80 Daml scenarios -cd registry && npm install && npm test # 229 unit tests, no ledger needed +cd registry && npm install && npm test # 236 unit tests, no ledger needed npm run sandbox # a local Canton sandbox with the JSON Ledger API npm run seed # an admin, demo users, one instrument From a3d77c5a115b181671b29bf006bc6c4e90976fd8 Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Mon, 7 Sep 2026 15:35:16 +0000 Subject: [PATCH 13/18] fix(registry): refuse a CORS_ORIGINS pattern and a scheme no browser sends --- README.md | 17 ++++++++------- registry/.env.example | 20 +++++++++++------- registry/src/config.ts | 40 ++++++++++++++++++++++++------------ registry/test/config.test.ts | 40 +++++++++++++++++++++++++++++++++--- 4 files changed, 87 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 8927a06..a178640 100644 --- a/README.md +++ b/README.md @@ -218,13 +218,16 @@ ships `registry/.env.example` with the full list and what each variable is for. `CORS_ORIGINS` is the comma-separated list of origins a browser dApp may call the service from, defaulting to `http://localhost:3012`; an entry of `*` -anywhere in it means any origin. A browser reads a cross-origin response only -if the service names the requesting origin back, so an origin missing from this -list fails in the page with an opaque network error while the service delivers -and answers the request as it would any other, leaving nothing behind that -tells the two apart; the list the service accepted is on its startup line -instead. Each entry is written as a browser computes an origin, `scheme://host` -with a port only when it is not the scheme's default and with no path, query or +anywhere in it means any origin, and it is the only wildcard there is: a +pattern such as `https://*.app.example.com` is compared literally, matches no +origin a browser sends, and is refused at boot rather than accepted as a list +that allows nothing. A browser reads a cross-origin response only if the +service names the requesting origin back, so an origin missing from this list +fails in the page with an opaque network error while the service delivers and +answers the request as it would any other, leaving nothing behind that tells +the two apart; the list the service accepted is on its startup line instead. +Each entry is written as a browser computes an origin, `http(s)://host` with a +port only when it is not the scheme's default and with no path, query or trailing slash; the service refuses to start on anything else, since the comparison is an exact string match and a near miss matches nothing. diff --git a/registry/.env.example b/registry/.env.example index 3a7194b..b7694fc 100644 --- a/registry/.env.example +++ b/registry/.env.example @@ -86,11 +86,17 @@ DIRECT_TRANSFER_MARGIN_MS=30000 # Unset and empty both mean the default, so there is no value that allows no # origin: the narrowest setting is one origin nothing is served from. # -# Write each entry exactly as a browser computes an origin, scheme://host with -# a port only when it is not the scheme's default, and with no path, query or -# trailing slash. The service refuses to start on an entry that is not in that -# form, because the origin is compared as an exact string and a near miss -# matches nothing at all. Where the entry is a near miss of a real origin the -# refusal names what a browser would have sent, and where it names no origin at -# all, such as a host written without its scheme, it says so instead. +# Write each entry exactly as a browser computes an origin, http:// or https:// +# followed by the host, with a port only when it is not the scheme's default, +# and with no path, query or trailing slash. The service refuses to start on an +# entry that is not in that form, because the origin is compared as an exact +# string and a near miss matches nothing at all. Where the entry is a near miss +# of a real origin the refusal names what a browser would have sent, and where +# it names no origin at all, such as a host written without its scheme, it says +# so instead. +# +# The "*" above is the only wildcard there is. No entry may carry one inside it: +# a pattern such as https://*.app.example.com is compared literally, matches no +# origin a browser sends, and is refused at boot rather than accepted as a list +# that allows nothing. List each origin the dApp is served from instead. CORS_ORIGINS=http://localhost:3012 diff --git a/registry/src/config.ts b/registry/src/config.ts index 3e276c8..5c225b5 100644 --- a/registry/src/config.ts +++ b/registry/src/config.ts @@ -90,10 +90,13 @@ export function loadConfig(env: NodeJS.ProcessEnv): Config { } // A browser sends the origin it computed, so an entry the browser can never // send matches nothing and blocks the dApp with the same opaque failure this - // list exists to prevent, from a service that started clean. The two ways to - // write one are a trailing slash (what the address bar gives you) and a host - // that is not already lower case, and URL normalizes both, so comparing an - // entry against its own origin rejects exactly the values that cannot match. + // list exists to prevent, from a service that started clean. URL normalizes + // the near misses an operator writes by hand, a trailing slash, a host that + // is not already lower case, a spelled-out default port, a path, so + // comparing an entry against its own origin catches all of those. Two kinds + // survive that comparison unchanged and are refused ahead of it instead: a + // pattern, which parses as a host that happens to carry a "*", and a scheme + // a browser never sends an Origin for. const parseOrigins = (raw: string | undefined): string[] => { const entries = (raw || DEFAULT_CORS_ORIGINS) .split(',') @@ -104,22 +107,33 @@ export function loadConfig(env: NodeJS.ProcessEnv): Config { } const notAnOrigin = (entry: string) => new Error( - `invalid CORS_ORIGINS entry "${entry}": expected an origin, scheme://host[:port], or *`, + `invalid CORS_ORIGINS entry "${entry}": expected an origin, http(s)://host[:port], or *`, ) for (const entry of entries) { if (entry === '*') continue - let normalized: string + // The list is matched by exact string, so a pattern matches nothing at + // all, and "*" meaning any origin is what invites one. URL parses + // "https://*.example.com" happily and reports itself as its own origin, + // so this is the only place it can be caught. + if (entry.includes('*')) { + throw new Error( + `invalid CORS_ORIGINS entry "${entry}": no pattern is matched, list each origin, or "*" alone for any`, + ) + } + let url: URL try { - normalized = new URL(entry).origin + url = new URL(entry) } catch { throw notAnOrigin(entry) } - // URL accepts anything carrying a colon, so an entry that omits the - // scheme parses as a non-special URL whose origin is the string "null". - // Naming that back as the value to write would be a remedy the operator - // cannot take: "null" carries no colon and is refused as not a URL, so - // following the message costs a second failed boot. - if (normalized === 'null') throw notAnOrigin(entry) + // Only these two schemes reach the service as an Origin, and confining + // the entry to them is also what keeps the message honest: URL accepts + // anything carrying a colon, so a scheme-less entry parses as a + // non-special URL whose origin is the literal string "null", and naming + // that back as the value to write would be a remedy the operator cannot + // take, since "null" is itself refused as not a URL. + if (url.protocol !== 'http:' && url.protocol !== 'https:') throw notAnOrigin(entry) + const normalized = url.origin if (normalized !== entry) { throw new Error( `invalid CORS_ORIGINS entry "${entry}": a browser would send "${normalized}", so write that instead`, diff --git a/registry/test/config.test.ts b/registry/test/config.test.ts index 9bcd9e5..53262dd 100644 --- a/registry/test/config.test.ts +++ b/registry/test/config.test.ts @@ -235,8 +235,42 @@ describe('loadConfig CORS origins parsing', () => { }) it('throws on an entry that is not a URL at all', () => { - expect(() => loadConfig({ ...baseEnv, CORS_ORIGINS: '*.example.com' })).toThrow( - /expected an origin, scheme:\/\/host\[:port\], or \*/, + expect(() => loadConfig({ ...baseEnv, CORS_ORIGINS: 'not a url' })).toThrow( + /expected an origin, http\(s\):\/\/host\[:port\], or \*/, + ) + }) + + // The entry the "*" spelling invites, and the one URL cannot catch: a + // pattern parses, and its own origin is itself, so it reaches cors and is + // compared to a real origin as a literal string, matching nothing. That is + // the empty-allowlist boot this validation exists to refuse. + it.each([ + 'https://*.app.example.com', + 'http://*.example.com', + 'https://*', + ])('throws on the pattern %j, which cors would match literally', (value) => { + expect(() => loadConfig({ ...baseEnv, CORS_ORIGINS: value })).toThrow( + /no pattern is matched, list each origin, or "\*" alone for any/, + ) + }) + + it('still accepts "*" alone, which is not a pattern but the any-origin spelling', () => { + expect(loadConfig({ ...baseEnv, CORS_ORIGINS: 'https://a.example, *' }).corsOrigins).toEqual([ + 'https://a.example', + '*', + ]) + }) + + // A special scheme other than http(s) round-trips through URL.origin, so + // these pass the near-miss comparison and would be accepted on its word + // alone. No browser sends an Origin in any of them. + it.each([ + 'ws://a.example', + 'wss://a.example', + 'ftp://a.example', + ])('throws on %j, a scheme no browser sends an Origin for', (value) => { + expect(() => loadConfig({ ...baseEnv, CORS_ORIGINS: value })).toThrow( + /expected an origin, http\(s\):\/\/host\[:port\], or \*/, ) }) @@ -252,7 +286,7 @@ describe('loadConfig CORS origins parsing', () => { 'chrome-extension://abc', ])('throws on %j, which has no origin to name back', (value) => { expect(() => loadConfig({ ...baseEnv, CORS_ORIGINS: value })).toThrow( - /expected an origin, scheme:\/\/host\[:port\], or \*/, + /expected an origin, http\(s\):\/\/host\[:port\], or \*/, ) }) From 4620bb1257fbd29f10dc765d5c9b2464ae696e76 Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Mon, 7 Sep 2026 15:35:53 +0000 Subject: [PATCH 14/18] docs(registry): say that a refused preflight stops a POST before it is sent --- README.md | 10 +++++++--- SPEC.md | 2 +- registry/src/index.ts | 11 ++++++----- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index a178640..ce29578 100644 --- a/README.md +++ b/README.md @@ -223,9 +223,13 @@ pattern such as `https://*.app.example.com` is compared literally, matches no origin a browser sends, and is refused at boot rather than accepted as a list that allows nothing. A browser reads a cross-origin response only if the service names the requesting origin back, so an origin missing from this list -fails in the page with an opaque network error while the service delivers and -answers the request as it would any other, leaving nothing behind that tells -the two apart; the list the service accepted is on its startup line instead. +fails in the page with an opaque network error. What reaches the service +differs by route: the three `GET` routes are simple requests, delivered and +answered in full with only the browser withholding the body from the page, +while every `POST` route carries a JSON body and is therefore preflighted, and +a refused preflight ends the call before the `POST` is ever sent. Neither +leaves anything behind that names the origin, because the service logs no +requests at all; the list it accepted is on its startup line instead. Each entry is written as a browser computes an origin, `http(s)://host` with a port only when it is not the scheme's default and with no path, query or trailing slash; the service refuses to start on anything else, since the diff --git a/SPEC.md b/SPEC.md index 09a6487..d3822df 100644 --- a/SPEC.md +++ b/SPEC.md @@ -449,7 +449,7 @@ exist. | Level | What it covers | |---|---| | Daml Script, 80 scenarios | Every choice and both factory paths, including negative cases: wrong `expectedAdmin`, a batch transfer routed through another instrument of the same admin, non-positive amounts, duplicate and locked inputs, cross-instrument spending, an escrow that does not back the transfer it settles, both sides of every deadline instant, missing authority, the `decimals` bound, and the batch transfer's own refusals: outputs whose total exceeds the inputs and a lock output already past its expiry | -| Registry unit, 236 tests | Every route against an in-process server with a stub ledger: response shapes, error schemas, 404 and 409 behaviour, context and disclosure contents, the state an escrow lookup has to be in before a context may report a reclaim, config validation, that a configured browser origin is answered and an unconfigured one is not, on rejections as well as on successes, that a request from an unconfigured origin is served in full regardless and refused only in the browser, that a path the service does not route answers a preflight all the same, and that each request is validated against the one spec that describes it, whichever form its request target arrives in and even when it carries a fragment, which is no form at all | +| Registry unit, 236 tests | Every route against an in-process server with a stub ledger: response shapes, error schemas, 404 and 409 behaviour, context and disclosure contents, the state an escrow lookup has to be in before a context may report a reclaim, config validation, that a configured browser origin is answered and an unconfigured one is not, on rejections as well as on successes, that a simple request from an unconfigured origin is served in full regardless and refused only in the browser, a preflighted one being stopped in the browser before it is sent, that a path the service does not route answers a preflight all the same, and that each request is validated against the one spec that describes it, whichever form its request target arrives in and even when it carries a fragment, which is no form at all | | End-to-end, 18 tests | Both transfer paths and the faucet against a live participant, submitting real exercises built from the service's own answers, including a misconfigured escrow template id that must not produce a reclaim report | The end-to-end suite allocates its own parties and instrument per run, so it diff --git a/registry/src/index.ts b/registry/src/index.ts index 2c2ba50..1359934 100644 --- a/registry/src/index.ts +++ b/registry/src/index.ts @@ -29,11 +29,12 @@ if (!(await checkAdminParty(ledger, config, logger))) process.exit(1) const app = createServer({ ledger, config, logger }) const server = app.listen(config.port, () => { // The origin list is the one setting whose effect is invisible from the - // service side: a refused request is still delivered and answered in full, - // and the browser withholds the response from the page afterwards, so - // nothing observable here separates it from one that was allowed. Recording - // what was accepted at boot is what lets an operator tell a rejected origin - // from an unreachable service. + // service side. A refused simple request is delivered and answered in full, + // with only the browser withholding the body afterwards, while a refused + // preflight ends its POST before it is sent, so what arrives differs but + // neither leaves a trace: nothing here is logged per request. Recording what + // was accepted at boot is what lets an operator tell a rejected origin from + // an unreachable service. logger.info( { port: config.port, corsOrigins: config.corsOrigins }, 'canton-token-forge registry listening', From c3991e71a6c731fca2ced9bf5ddb061ee2b6d37b Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Mon, 7 Sep 2026 15:37:09 +0000 Subject: [PATCH 15/18] test(registry): pin that the service allows no credentials on either origin mode --- registry/src/server.ts | 5 +++++ registry/test/cors.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/registry/src/server.ts b/registry/src/server.ts index 8907cfd..ae0590d 100644 --- a/registry/src/server.ts +++ b/registry/src/server.ts @@ -88,6 +88,11 @@ export function createServer(deps: ServerDeps): Express { // Every factory route is a POST and so preflights on every call; a browser // caches a preflight carrying no max-age for seconds, which would make each // call two round trips. + // Credentials are deliberately not allowed, and a "*" entry is safe only + // while that holds: it selects the reflected-origin mode, and reflecting an + // origin while allowing credentials makes any page a credentialed reader of + // this service. The reference service this configuration was modelled on + // does allow them, so a test pins the omission. app.use( cors({ origin: deps.config.corsOrigins.includes('*') ? true : deps.config.corsOrigins, diff --git a/registry/test/cors.test.ts b/registry/test/cors.test.ts index 9f9c806..b935099 100644 --- a/registry/test/cors.test.ts +++ b/registry/test/cors.test.ts @@ -142,6 +142,28 @@ describe('cors', () => { expect(get.status).toBe(404) }) + // The one option whose value is its absence, on both origin modes. The + // reference service this configuration was modelled on allows credentials + // with the same reflected-origin line, and allowing them here would make any + // page a credentialed reader of this service under a "*" entry, so the + // omission is pinned rather than left to whoever edits those options next. + it.each([ + [config.corsOrigins], + [['*']], + ])('allows no credentials with corsOrigins %j', async (corsOrigins) => { + const app = createServer({ ledger: ledgerFrom({}), config: { ...config, corsOrigins } }) + const get = await request(app).get('/healthz').set('Origin', ALLOWED_ORIGIN) + expect(get.status).toBe(200) + expect(get.headers['access-control-allow-origin']).toBe(ALLOWED_ORIGIN) + expect(get.headers['access-control-allow-credentials']).toBeUndefined() + const preflight = await request(app) + .options('/registry/transfer-instruction/v1/transfer-factory') + .set('Origin', ALLOWED_ORIGIN) + .set('Access-Control-Request-Method', 'POST') + expect(preflight.status).toBe(204) + expect(preflight.headers['access-control-allow-credentials']).toBeUndefined() + }) + it('reflects whatever origin asks when corsOrigins is ["*"]', async () => { const app = createServer({ ledger: ledgerFrom({}), config: { ...config, corsOrigins: ['*'] } }) const res = await request(app).get('/healthz').set('Origin', 'http://anything.example') From 8a9d8bf0b113da45232f6f133b427111704a4bd8 Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Mon, 7 Sep 2026 15:37:28 +0000 Subject: [PATCH 16/18] docs: record what CORS_ORIGINS has to match among the boot-fatal settings --- RUNBOOK.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/RUNBOOK.md b/RUNBOOK.md index 2ef7f01..72136c0 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -276,6 +276,19 @@ reason the seed script looks the way it does. They are concrete template ids, never interface ids: the choice-context handlers read payload fields that exist on the template create arguments and not on the standard interface views. +- `CORS_ORIGINS` entries are compared against the `Origin` a browser sends, as + exact strings, so the service refuses to start on any entry no browser could + ever send: a near miss of a real origin, such as a trailing slash, a host + that is not lower case, a spelled-out default port or a path; a scheme other + than `http` or `https`; or a pattern such as `https://*.app.example.com`, + which is matched literally and so matches nothing. A near miss is refused + naming what a browser would have sent, so the message is the value to write. + Unset and empty both mean the default, `http://localhost:3012`, and an entry + of `*` anywhere in the list means any origin, so there is no value that + allows none. A refused origin leaves nothing here to find: a simple request + is served in full and only the browser withholds the body, a preflighted one + never arrives at all, and the service logs no requests either way. The list + it accepted is on the startup line instead. - `LEDGER_USER_ID` has no effect on the running service, which submits nothing. The seed prints it as a record of the user it submitted under, not as an input the service reads back. Setting it in `registry/.env` changes nothing at all: From 7c30875a1ccb8bb55b464a06a6be165644461cc1 Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Mon, 7 Sep 2026 15:37:36 +0000 Subject: [PATCH 17/18] docs: rewrap the registry service paragraph left ragged by the cors edit --- SPEC.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/SPEC.md b/SPEC.md index d3822df..2e98608 100644 --- a/SPEC.md +++ b/SPEC.md @@ -319,10 +319,10 @@ holding for any surplus, so no value is created or destroyed. A TypeScript service (Express, `express-openapi-validator`, `cors`, pino; Node 20+) that validates incoming requests against the four CN Token Standard -OpenAPI specs it ships. Responses are covered by the unit suite rather than by runtime schema -validation. The service is **read-only**: it queries the JSON Ledger API for -active contracts and submits nothing. The client holds the keys and sends the -exercise itself. +OpenAPI specs it ships. Responses are covered by the unit suite rather than +by runtime schema validation. The service is **read-only**: it queries the +JSON Ledger API for active contracts and submits nothing. The client holds +the keys and sends the exercise itself. | Method | Path | |---|---| From f7d3749aa56eec023e49195f59f2b09fdf21285c Mon Sep 17 00:00:00 2001 From: Lisandro Corbalan Date: Mon, 7 Sep 2026 15:44:02 +0000 Subject: [PATCH 18/18] docs: refresh the registry counts and record the CORS boot and credentials coverage --- SPEC.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/SPEC.md b/SPEC.md index 2e98608..be32d20 100644 --- a/SPEC.md +++ b/SPEC.md @@ -53,7 +53,7 @@ All three suites were re-run against the tree this document ships with, exit 0: | Suite | Result | Needs | |---|---|---| | Daml Script | **80 scenarios**, 12 modules | nothing, runs in-process | -| Registry unit | **236 tests**, 11 files | nothing, in-process server with a stub ledger | +| Registry unit | **245 tests**, 11 files | nothing, in-process server with a stub ledger | | End-to-end | **18 tests**, 4 files | a live participant, verified against Canton 3.5.12 | The end-to-end suite drives both transfer paths against a real participant: it @@ -63,8 +63,8 @@ resulting exercise itself over the JSON Ledger API, forwarding the service's ### Size and status -976 lines of production Daml, 2508 lines of Daml tests, 1820 lines of -TypeScript service, 4609 lines of TypeScript tests, each figure a +976 lines of production Daml, 2508 lines of Daml tests, 1840 lines of +TypeScript service, 4665 lines of TypeScript tests, each figure a `find -name '*.daml'` (or `'*.ts'`) `| xargs wc -l` count over `daml/canton-token-forge/daml`, `daml/canton-token-forge-test/daml`, `registry/src` and `registry/test` respectively. The two Daml paths name the @@ -449,7 +449,7 @@ exist. | Level | What it covers | |---|---| | Daml Script, 80 scenarios | Every choice and both factory paths, including negative cases: wrong `expectedAdmin`, a batch transfer routed through another instrument of the same admin, non-positive amounts, duplicate and locked inputs, cross-instrument spending, an escrow that does not back the transfer it settles, both sides of every deadline instant, missing authority, the `decimals` bound, and the batch transfer's own refusals: outputs whose total exceeds the inputs and a lock output already past its expiry | -| Registry unit, 236 tests | Every route against an in-process server with a stub ledger: response shapes, error schemas, 404 and 409 behaviour, context and disclosure contents, the state an escrow lookup has to be in before a context may report a reclaim, config validation, that a configured browser origin is answered and an unconfigured one is not, on rejections as well as on successes, that a simple request from an unconfigured origin is served in full regardless and refused only in the browser, a preflighted one being stopped in the browser before it is sent, that a path the service does not route answers a preflight all the same, and that each request is validated against the one spec that describes it, whichever form its request target arrives in and even when it carries a fragment, which is no form at all | +| Registry unit, 245 tests | Every route against an in-process server with a stub ledger: response shapes, error schemas, 404 and 409 behaviour, context and disclosure contents, the state an escrow lookup has to be in before a context may report a reclaim, config validation, that a configured browser origin is answered and an unconfigured one is not, on rejections as well as on successes, that an entry no browser could ever send is refused at boot, a pattern and a scheme a browser sends no Origin in included, rather than accepted as a list that allows nothing, that no response allows credentials under either origin mode, that a simple request from an unconfigured origin is served in full regardless and refused only in the browser, a preflighted one being stopped in the browser before it is sent, that a path the service does not route answers a preflight all the same, and that each request is validated against the one spec that describes it, whichever form its request target arrives in and even when it carries a fragment, which is no form at all | | End-to-end, 18 tests | Both transfer paths and the faucet against a live participant, submitting real exercises built from the service's own answers, including a misconfigured escrow template id that must not produce a reclaim report | The end-to-end suite allocates its own parties and instrument per run, so it @@ -467,7 +467,7 @@ instrument, then prints a ready-to-paste service configuration. ```bash npm run setup # vendors the Splice interface DARs into deps/ npm test # builds the production DAR, runs 80 Daml scenarios -cd registry && npm install && npm test # 236 unit tests, no ledger needed +cd registry && npm install && npm test # 245 unit tests, no ledger needed npm run sandbox # a local Canton sandbox with the JSON Ledger API npm run seed # an admin, demo users, one instrument