feat(backend-js): add a Node.js implementation of the Quickstart backend#347
feat(backend-js): add a Node.js implementation of the Quickstart backend#347fernandomg wants to merge 9 commits into
Conversation
…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 |
There was a problem hiding this comment.
how do you determine this isAdmin check?
There was a problem hiding this comment.
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' | |||
There was a problem hiding this comment.
why are we relying on JSESSIONID?
There was a problem hiding this comment.
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') |
There was a problem hiding this comment.
what are these seedings for?
There was a problem hiding this comment.
There are two things going on here, both just OAuth2 setup for the default AppProvider rather than data seeding.
- 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));
- then per login (line 31) we generate the usual Authorization-Code + PKCE values:
statefor CSRF,noncefor 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 itsoauth2Loginfilter (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'), |
There was a problem hiding this comment.
why must SESSION_SECRET be in env var?
There was a problem hiding this comment.
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) => ({ |
There was a problem hiding this comment.
Can this code be gen'd from the daml to typescript using dpm codegen-js?
There was a problem hiding this comment.
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
|
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. |
Closes #156
Contributed by
@BootNodeDev — bootnode.dev
Summary
BACKEND=js(persisted in.env.localbymake setup).3975instead of gRPC, reads PQS Postgres directly, and uses the dpmcodegen-jsDaml bindings for typed command construction. The Compose stack swaps in the JS service via an override file whenBACKEND=js.Test plan
make clean-all && make setupand answeredjsto the new interactive Backend prompt — alongside the existing prompts forAUTH_MODE,OBSERVABILITY_ENABLED,TEST_MODE, andPARTY_HINT. The selection is written to.env.localasBACKEND=js, so every subsequentmake start/make restart-backend/make stopautomatically targets the JS service without needing to repeat the flag. Re-ranmake setupand chosejavato confirm the prompt round-trips and that the stack swaps back to the Java backend cleanly. Verified under bothAUTH_MODE=oauth2andAUTH_MODE=shared-secret.register-app-user-tenant,AppInstallRequestaccept/reject,Licenseissuance, 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.
SESSION_SECRETfalls back to random bytes per restart inbackend-js/src/auth/session.ts, invalidating signed session cookies.HttpSessionproduces 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.application.ymlexplicitly enablesinclude-message: always,include-stacktrace: always,include-exception: truefor dev — leaks at least as much, with stacktraces. A sanitising error mapper needs to land on both backends together.Duration.parsethrowsDateTimeParseExceptionand there is no@ControllerAdvicemapping it to 400. Fix on both sides in one PR.DELETE /admin/tenant-registrations/:tenantIdremoves the OAuth2 registry entry before the tenant repo entry; if step 2 fails the registry is gone but the tenant remains.deleteTenantRegistrationuses the same ordering and has the same risk window. Proper transactional/rollback handling needs to land on both backends.AppInstallRequest_Acceptdoes not include the new AppInstall'scontractId.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:
mappers.tsread raw JSON Ledger API / PQS payloads through untyped helpers (str/num/metaoverRecord<string, unknown>).dpm codegen-jsonly 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 generatedSerializablebindings for type-safe field access, bringing the read path closer to Java'sLicenseApiImpl, which maps by hand but reads from generated typed objects.