Skip to content

feat(backend-js): add a Node.js implementation of the Quickstart backend#347

Open
fernandomg wants to merge 9 commits into
digital-asset:mainfrom
BootNodeDev:js-parity
Open

feat(backend-js): add a Node.js implementation of the Quickstart backend#347
fernandomg wants to merge 9 commits into
digital-asset:mainfrom
BootNodeDev:js-parity

Conversation

@fernandomg

@fernandomg fernandomg commented May 5, 2026

Copy link
Copy Markdown

Closes #156

Contributed by

@BootNodeDevbootnode.dev

Summary

  • Adds a Node.js / TypeScript / Fastify backend that mirrors the Java backend's HTTP API surface, selectable at build time via BACKEND=js (persisted in .env.local by make setup).
  • Both backends share the same OpenAPI contract, env files, onboarding volume, listening port, and cookie/CSRF auth surface (OAuth2 and shared-secret). The JS backend talks to Canton via the JSON Ledger API on port 3975 instead of gRPC, reads PQS Postgres directly, and uses the dpm codegen-js Daml bindings for typed command construction. The Compose stack swaps in the JS service via an override file when BACKEND=js.

Test plan

  • Ran make clean-all && make setup and answered js to the new interactive Backend prompt — alongside the existing prompts for AUTH_MODE, OBSERVABILITY_ENABLED, TEST_MODE, and PARTY_HINT. The selection is written to .env.local as BACKEND=js, so every subsequent make start / make restart-backend / make stop automatically targets the JS service without needing to repeat the flag. Re-ran make setup and chose java to confirm the prompt round-trips and that the stack swaps back to the Java backend cleanly. Verified under both AUTH_MODE=oauth2 and AUTH_MODE=shared-secret.
  • Walked through the entire flow described in the Explore the Demo guide (https://docs.digitalasset.com/build/3.5/quickstart/operate/explore-the-demo.html): App User and App Provider login, tenant pre-registration via register-app-user-tenant, AppInstallRequest accept/reject, License issuance, the renewal flow with wallet allocation, and license expiry. Each step produced behaviour identical to the Java backend.

Deferred to follow-up

The items below were surfaced during review and consciously left out of this PR. Each one is either parity-equal with the Java backend (so a JS-only fix would create drift) or a production-hardening concern that needs a cross-cutting design touching both backends. They should be tracked in a follow-up issue against both implementations.

# Issue Why deferred
1 SESSION_SECRET falls back to random bytes per restart in backend-js/src/auth/session.ts, invalidating signed session cookies. Java's in-memory HttpSession produces the same observable behaviour: every restart logs all users out (signature mismatch in JS, lost server-side state in Java). Fix needs a stable signing secret and a persistent session store applied to both backends.
2 Raw Canton ledger error text is forwarded to HTTP clients (party IDs, template IDs, choice arguments). Java's application.yml explicitly enables include-message: always, include-stacktrace: always, include-exception: true for dev — leaks at least as much, with stacktraces. A sanitising error mapper needs to land on both backends together.
3 Malformed duration strings throw → HTTP 500 where 400 would be the correct status. Same behaviour in Java: Duration.parse throws DateTimeParseException and there is no @ControllerAdvice mapping it to 400. Fix on both sides in one PR.
4 DELETE /admin/tenant-registrations/:tenantId removes the OAuth2 registry entry before the tenant repo entry; if step 2 fails the registry is gone but the tenant remains. Java's deleteTenantRegistration uses the same ordering and has the same risk window. Proper transactional/rollback handling needs to land on both backends.
5 The 201 response body for AppInstallRequest_Accept does not include the new AppInstall's contractId. Same omission in Java's AppInstallRequestsApiImpl#acceptAppInstallRequest. The OpenAPI response shape and the choice-result extraction need to be updated together on both sides.

JS-only follow-ups

Unlike the deferred items above (which the JS backend shares with Java and are best fixed on both sides together) the following is a JS-only refinement that wouldn't change behaviour or create drift with Java, so it's tracked separately:

  • Type the licensing read path via the generated bindings. The mappers in mappers.ts read raw JSON Ledger API / PQS payloads through untyped helpers (str/num/meta over Record<string, unknown>). dpm codegen-js only emits Daml-shaped types, so the OpenAPI response shaping and derived fields stay hand-written regardless; but the raw payloads could be decoded through the generated Serializable bindings for type-safe field access, bringing the read path closer to Java's LicenseApiImpl, which maps by hand but reads from generated typed objects.

fernandomg and others added 8 commits April 29, 2026 12:25
…mbing

Adds BACKEND=java|js as a make-level selector, persisted in .env.local
via make setup, with a compose override, Dockerfile, and start.sh for
the JS service. The :daml gradle build emits both Java and TS codegen
and skips the irrelevant one per active BACKEND. Root .gitignore gains
an npm-prevention block to keep package.json/lock from leaking up to
the repo root.

Co-Authored-By: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com>
Project skeleton: package + lockfile, NodeNext tsconfig, runtime config
loader, server entrypoint, app builder with ErrorResponse-shaped error
handler and an empty-body JSON parser to accept the frontend's logout
post.

Co-Authored-By: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com>
Wires session, CSRF, and cookie middleware; OAuth2 (with plain-HTTP
issuer opt-in for local Keycloak, JWKS cache per issuer, partyId
resolved from the tenant repository); shared-secret form login that
resolves the username against the tenant repository and seeds the
AppProvider tenant with admin users; CSRF bypass for Bearer-
authenticated service traffic; TEST_MODE party_id override applied at
session population; admin-gate distinguishing 401/403 by Bearer or
session presence; and the /admin, /user, and /login-links routes that
sit on top of all of it.

Co-Authored-By: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com>
Adds the read-side data access used by the licensing routes: a pg
connection pool, a typed contract row helper that decodes the JSONB
payload, and a licensing repository that finds active contracts by id
and lists them by predicate.

Co-Authored-By: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com>
…nd bindings

Adds the write-side ledger access: a client-credentials token provider
with a 30s expiry buffer, a JSON Ledger API client wrapping submit-and-
wait-for-transaction, and command builders typed via the dpm codegen-js
output (damlTypes.Template/Choice). Template ids carry the
'#<package-name>:' prefix the JSON Ledger API requires, every submit is
wrapped in the JsCommands envelope, and userId is matched to the JWT
subject claim. A token-standard client adds the registry admin id and
allocation transfer context lookup.

Co-Authored-By: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com>
Adds the business surface exposed over the OpenAPI contract: a
licensing service that orchestrates PQS reads and ledger writes,
mappers that mirror the Java backend's DTO shapes, and the routes for
feature-flags, app-install-requests, app-installs, licenses, and
license-renewal-requests. Daml-choice routes register one wildcard per
resource to dodge find-my-way's colon-route limitation while preserving
the URL contract. Microsecond-precision timestamps are emitted on
License_Renew, the new License contract id is read from the
CreatedEvent walk (since submit-and-wait returns ACS_DELTA events
without exerciseResult), and admin-only choice endpoints are gated by
checkAdmin.

Co-Authored-By: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com>
Adds a Backend implementations section that contrasts the Java and JS
backends, documents the BACKEND selector and switching workflow, and
notes the Java-only debug port and the per-backend codegen behaviour.

Co-Authored-By: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com>
// Accepts either a valid Bearer JWT or an admin session cookie.
// On failure, replies with 401 (no auth) or 403 (authenticated but not admin) and returns false.
export const checkAdmin = async (cfg: BackendConfig, req: FastifyRequest, reply: FastifyReply): Promise<boolean> => {
const sessionIsAdmin = req.session.user?.isAdmin

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how do you determine this isAdmin check?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The isAdmin flag is set at login and the gate just reads it back. In oauth2 mode a user is admin when their registration is the AppProvider (oauth2.ts:78), and in shared-secret mode when the matched tenant is internal (shared-secret.ts:20).

Beyond that session flag, the gate also lets through a Bearer JWT that's validly signed by the configured issuer (admin-gate.ts:17 / jwt-admin.ts:22).

We leaned on the Java backend's model so the two stay in step. It gives ROLE_ADMIN to the AppProvider login (OAuth2AuthenticationSuccessHandler.java:65, SharedSecretConfig.java:108) and treats any issuer-signed JWT as admin (OAuth2Config.java:108).

@@ -0,0 +1,3 @@
export const SESSION_COOKIE = 'JSESSIONID'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why are we relying on JSESSIONID?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing special about the name here; JSESSIONID is just what we call the session cookie, kept the same as the Java backend.

We set it here (applied in session.ts:18), and Spring uses the same name and clears it on logout (OAuth2Config.java:92, SharedSecretConfig.java:70).

Keeping them matched is mostly so either backend can sit behind the same frontend/proxy without anything noticing the swap.

Happy to rename it on the JS side if you'd rather it not carry the Java-ism.

if (entry === undefined) { reply.code(404); return { message: 'unknown_registration' } }
const codeVerifier = openid.randomPKCECodeVerifier()
const codeChallenge = await openid.calculatePKCECodeChallenge(codeVerifier)
const state = randomBytes(16).toString('hex')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what are these seedings for?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are two things going on here, both just OAuth2 setup for the default AppProvider rather than data seeding.

  1. at startup we register the AppProvider OAuth2 client (oauth2.ts:19, wired in from server.ts:19) (that mirrors Java's static client registration (application-oauth2.yml:26) over its tenant baseline (application.yml:39));
  2. then per login (line 31) we generate the usual Authorization-Code + PKCE values: state for CSRF, nonce for ID-token replay protection, and the PKCE verifier/challenge against code interception, kept in oauth2.ts:29-33 and checked back on the callback (oauth2.ts:56-60). Spring handles that part under the hood in its oauth2Login filter (OAuth2Config.java:83); it's explicit here because we drive openid-client directly.

export const registerSession = async (app: FastifyInstance): Promise<void> => {
await app.register(cookie)
await app.register(session, {
secret: process.env['SESSION_SECRET'] ?? randomBytes(32).toString('hex'),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why must SESSION_SECRET be in env var?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't actually have to be. SESSION_SECRET is optional.

When it's set we use it as a stable signing secret; when it isn't, we fall back to random bytes per boot (line 17 after ??), which does mean a restart logs everyone out.

That's the same behavior you get from Java's in-memory HttpSession (it drops sessions on restart too), so instead of patching it on just one side we flagged it under "Deferred to follow-up" in the PR description.

meta: meta(payload, 'meta')
})

export const mapAppInstall = (contractId: string, payload: P) => ({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this code be gen'd from the daml to typescript using dpm codegen-js?

@fernandomg fernandomg Jun 1, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partly, yes. We do use codegen-js where it fits, like the typed command construction (service.ts:4).

These mappers are a slightly different job, though: they shape the ledger payloads into our OpenAPI response and add a few derived fields, working over joined PQS rows (mappers.ts:12, repository.ts:24).

IIUC codegen-js only generates the Daml-shaped types, so the REST shaping isn't something it can produce for us. The Java backend hand-writes the same mapping (LicenseApiImpl.java:212), so this is parity rather than a JS shortcut.

I've captured this as a "JS-only follow-up" in the description

@fernandomg

Copy link
Copy Markdown
Author

Hi @dasormeter, thanks for taking the time to review this

A bit of context that ties most of the comments together: the goal of this PR is behavioural parity with the existing Java backend, so throughout, the intent was to mirror Java's current auth/session semantics rather than introduce a new model.

That's also why the description carries a "Deferred to follow-up" section ( to flag, up front, the items that either match Java's current behaviour or need a cross-cutting fix touching both backends).

Your feedback surfaced a good one, which I've noted under a new "JS-only follow-ups" section.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add a Node.js backend example to the QS

2 participants