diff --git a/README.md b/README.md
index 9568ed1..ce29578 100644
--- a/README.md
+++ b/README.md
@@ -212,9 +212,28 @@ 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, 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. 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
+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/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:
diff --git a/SPEC.md b/SPEC.md
index f85e2d3..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 | **205 tests**, 10 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, 1739 lines of
-TypeScript service, 4349 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
@@ -317,12 +317,12 @@ 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
-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.
+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.
| Method | Path |
|---|---|
@@ -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, 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
@@ -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 # 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
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..b7694fc 100644
--- a/registry/.env.example
+++ b/registry/.env.example
@@ -75,3 +75,28 @@ 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, 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, 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/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..5c225b5 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,60 @@ 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. 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(',')
+ .map((origin) => origin.trim())
+ .filter((origin) => origin.length > 0)
+ 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, http(s)://host[:port], or *`,
+ )
+ for (const entry of entries) {
+ if (entry === '*') continue
+ // 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 {
+ url = new URL(entry)
+ } catch {
+ 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`,
+ )
+ }
+ }
+ return entries
+ }
return {
ledgerApiUrl: require_('LEDGER_API_URL'),
ledgerApiToken: require_('LEDGER_API_TOKEN'),
@@ -93,5 +155,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/index.ts b/registry/src/index.ts
index 649ae2a..1359934 100644
--- a/registry/src/index.ts
+++ b/registry/src/index.ts
@@ -28,7 +28,17 @@ 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 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',
+ )
})
let shuttingDown = false
diff --git a/registry/src/server.ts b/registry/src/server.ts
index 22f935f..ae0590d 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,33 @@ 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: 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
+ // 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.
+ // 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,
+ methods: ['GET', 'HEAD', 'POST', 'OPTIONS'],
+ allowedHeaders: ['Content-Type'],
+ maxAge: 600,
+ }),
+ )
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..53262dd 100644
--- a/registry/test/config.test.ts
+++ b/registry/test/config.test.ts
@@ -171,3 +171,142 @@ 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(['*'])
+ })
+
+ // 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: '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 \*/,
+ )
+ })
+
+ // 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, http\(s\):\/\/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', () => {
+ 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\/"/)
+ })
+})
diff --git a/registry/test/cors.test.ts b/registry/test/cors.test.ts
new file mode 100644
index 0000000..b935099
--- /dev/null
+++ b/registry/test/cors.test.ts
@@ -0,0 +1,173 @@
+import request from 'supertest'
+import { describe, expect, it } from 'vitest'
+import { createServer } from '../src/server'
+import { cfgEntry, 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()
+ })
+
+ // 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)
+ .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)
+ // 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()
+ })
+
+ // 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)
+ .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)
+ })
+
+ // 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)
+ })
+
+ // 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')
+ 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' }