Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
597d83e
feat(registry): serve CORS headers for configured browser origins
lmcorbalan Sep 7, 2026
4b11588
fix(registry): reject a CORS_ORIGINS entry no browser could ever send
lmcorbalan Sep 7, 2026
1058325
fix(registry): log the origins the service will accept at boot
lmcorbalan Sep 7, 2026
6406495
test(registry): pin the exact method list the preflight advertises
lmcorbalan Sep 7, 2026
00a7e3b
fix(registry): stop advertising methods and headers the service does …
lmcorbalan Sep 7, 2026
3e8574f
docs: say what CORS_ORIGINS accepts and that empty means the default
lmcorbalan Sep 7, 2026
8768632
docs: record the CORS_ORIGINS key and refresh the registry counts
lmcorbalan Sep 7, 2026
095d748
fix(registry): stop naming "null" as the value to write in a CORS_ORI…
lmcorbalan Sep 7, 2026
ad32b9a
test(registry): pin that a CORS_ORIGINS entry with no origin is refus…
lmcorbalan Sep 7, 2026
9d39724
docs(registry): correct what a refused origin and an off-route OPTION…
lmcorbalan Sep 7, 2026
5c9f261
test(registry): pin that a refused origin is served in full and that …
lmcorbalan Sep 7, 2026
ee5412f
docs: refresh the registry counts and record the CORS coverage the un…
lmcorbalan Sep 7, 2026
a3d77c5
fix(registry): refuse a CORS_ORIGINS pattern and a scheme no browser …
lmcorbalan Sep 7, 2026
4620bb1
docs(registry): say that a refused preflight stops a POST before it i…
lmcorbalan Sep 7, 2026
c3991e7
test(registry): pin that the service allows no credentials on either …
lmcorbalan Sep 7, 2026
8a9d8bf
docs: record what CORS_ORIGINS has to match among the boot-fatal sett…
lmcorbalan Sep 7, 2026
7c30875
docs: rewrap the registry service paragraph left ragged by the cors edit
lmcorbalan Sep 7, 2026
f7d3749
docs: refresh the registry counts and record the CORS boot and creden…
lmcorbalan Sep 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions RUNBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
27 changes: 14 additions & 13 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 <dir> -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
Expand Down Expand Up @@ -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 |
|---|---|
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
35 changes: 35 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
25 changes: 25 additions & 0 deletions registry/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
35 changes: 35 additions & 0 deletions registry/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions registry/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,15 @@
"lint:fix": "biome check --write"
},
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"express-openapi-validator": "^5.6.2",
"pino": "^10.3.1"
},
"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",
Expand Down
Loading
Loading