Design · docs/CONTRACTS.md
Interface contracts
Every component builds against these. Change them here first, then in code.
Participants and hostnames
| Participant | Folder | Railway service(s) | Local port |
|---|---|---|---|
| Mock CDR Register | participants/register |
register |
8084 |
| Westpac (DH) | participants/dh-westpac |
dh-westpac-pf, dh-westpac-api, dh-westpac-consent |
PF 9031 / API 7101 / consent 7201 |
| NAB (DH) | participants/dh-nab |
dh-nab-pf, dh-nab-api, dh-nab-consent |
PF 9032 / API 7102 / consent 7202 |
| ANZ (DH) | participants/dh-anz |
dh-anz-pf, dh-anz-api, dh-anz-consent |
PF 9033 / API 7103 / consent 7203 |
| CBA as ADR | participants/adr-cba |
adr-cba-pf, adr-cba-cdr-kit |
PF 9034 / cdr-kit 7300 |
| CBA issuer | participants/cba-issuer |
offers-api, offers-gateway (PAZ), cba-core-banking (v1.9) |
7400 / 7401 / 7800 |
| Pod runtime | packages/pod-runtime |
pod-runtime |
7500 |
| Redis | - | redis |
6379 |
| ZK verifier | apps/zk-verifier |
zk-verifier |
7600 |
| QR landing | apps/qr-landing |
qr-landing |
7700 |
All base URLs come from env vars named <SERVICE>_URL (e.g. POD_RUNTIME_URL, ADR_CDR_KIT_URL, REGISTER_URL). Never hardcode.
Data holder brand ids (Register dataHolderBrandId): westpac, nab, anz.
ADR: legal entity Commonwealth Bank of Australia, brand CommBank, accreditation number ADRBNK000001, software product id cba-income-verify.
Customers
data/customers.json is the single source. Each participant's seed is generated from it by data/generate-seeds.mjs. Mobiles are placeholders until the real list arrives (format 04xxxxxxxx). Fixed OTP everywhere: 000789.
Identity of the CBA customer
CBA PingFederate (adr-cba-pf) is the CBA OP. Access tokens are JWTs (RS256/PS256) with sub = CBA customer id (e.g. cba-cust-001), iss = CBA_ISSUER env, JWKS at CBA_JWKS_URL. For local dev without PF, adr-cba/cdr-kit exposes POST /dev/token {sub} that mints an equivalent token from a dev key (disabled when NODE_ENV=production unless DEV_TOKENS=1).
Redis Streams
Stream cdr.arrangements (written by adr-cba cdr-kit, consumer group pods):
// every event: { "type", "eventId" (uuid), "occurredAt" (ISO), "customerSub", "cdrArrangementId", "dataHolderBrandId" }
{ "type": "ArrangementEstablished",
"scopes": ["bank:accounts.basic:read","bank:transactions:read","common:customer.basic:read"],
"consentType": "once-off" | "ongoing",
"sharingExpiresAt": "ISO" | null, // null for once-off
"useConsents": ["income-verification","income-proof"],
"deletionElection": "delete" | "deidentify",
"purpose": "rental-income-verification",
"listingId": "string|null" }
{ "type": "ArrangementRevoked", "revokedBy": "consumer" | "data-holder" | "adr" }
{ "type": "ArrangementExpired" }Redis Streams store field/value pairs: publish a single field event holding the JSON string.
Stream pod.events (written by pod-runtime; read by apps via SSE/polling, and for the demo console):
{ "type": "PodCreated" | "PodLoading" | "PodLoaded" | "IncomeReady" | "ProofIssued"
| "ArrangementDataDeleted" | "PodDestroyed" | "PodFailed",
"eventId", "occurredAt", "customerSub", "cdrArrangementId"?, "step"?, "detail"? }Tokens are never placed on a stream.
Token manager (adr-cba cdr-kit, internal)
POST {ADR_CDR_KIT_URL}/internal/arrangements/{cdrArrangementId}/access-token
Header Authorization: Bearer {INTERNAL_API_KEY}
→ 200 { "access_token", "token_type": "«redacted»"|"DPoP", "expires_in", "dataHolderBrandId", "resourceBaseUri" }
→ 404 unknown arrangement, 410 revoked/expired.
The refresh token stays with the ADR (PF data-in / cdr-kit store), never leaves it.
Pod runtime HTTP API (POD_RUNTIME_URL)
Auth: either Authorization: Bearer {INTERNAL_API_KEY} (services: offers, MATTR claim source, PAZ) or a CBA access token whose sub must equal {sub} (the app).
GET /pods/{sub}/status→{ "sub", "state": "none"|"created"|"loading"|"loaded"|"income_ready"|"failed"|"destroyed", "steps": [{ "name", "status": "pending"|"running"|"done"|"failed", "at" }], "arrangements": [{ "cdrArrangementId", "dataHolderBrandId", "consentType", "sharingExpiresAt", "useConsents", "status": "active"|"revoked"|"expired"|"deleted" }], "updatedAt" }GET /pods/{sub}/outputs/income?purpose=credential-issuance→ requires an active arrangement with use consentincome-verification; else403 {"error":"use_consent_missing"};404no pod;409not ready.{ "income": { "annualised": 98500, "currency": "AUD", "frequency": "FORTNIGHTLY"|"MONTHLY"|"WEEKLY", "averageNetPay": 2900.12, "employer": "Acme Pty Ltd", "monthsObserved": 12, "confidence": 0.94, "source": "CDR", "dataHolderBrandId": "nab", "verifiedAt": "ISO", "commitment": "0x…" } }(The MATTR claim source variantGET /claims/mattr?sub=…returns the same fields flattened asincome_annualised,income_frequency,income_employer,income_verified_at,income_source,income_commitment.)POST /pods/{sub}/proofs/income{ "threshold": 39000, "listingId"? }→ requiresincome-proofuse consent →{ "proof", "publicSignals", "commitment", "threshold", "signature" /* JWS over {commitment,sub,iat} */, "kid", "verifierUrl" }DELETE /pods/{sub}/arrangements/{cdrArrangementId}→ consumer "Stop sharing" (also tells adr-cba to revoke at the DH).GET /pods/{sub}/events→ SSE stream ofpod.eventsfor that sub.GET /.well-known/jwks.json→ pod-runtime signing keys.
Offers gateway (OFFERS_GATEWAY_URL) — what the app calls
POST /offers with CBA access token, body { "profile": "commbank-id" | "commbank-id-income", "listingId"? }
302 Location: {ADR_CDR_KIT_URL}/consent/start?return=cbaapp%3A%2F%2Fconsent-complete&listingId=…&session=…when profile needs income and the pod has no active arrangement (statenone/destroyed).409 { "error": "pod_loading", "state" }while state iscreated|loading|loaded.200 { "credentialOffer": "«redacted»" }whenincome_ready(or profile iscommbank-id).
The app must NOT auto-follow the 302; it opens Location in ASWebAuthenticationSession with callback scheme cbaapp.
ADR consent start
GET {ADR_CDR_KIT_URL}/consent/start?return=…&session=… — the session is a short-lived signed handle (issued by the offers API) carrying the CBA sub, so the browser leg knows who the customer is without a cookie. Final redirect: cbaapp://consent-complete?status=success|cancelled|error&cdrArrangementId=….
Pod store
One encrypted SQLite per pod, object key pods/{sub}.db.enc in the bucket (POD_BUCKET_* env; local dev uses ./.pods/). Data key: random 256-bit, AES-256-GCM, wrapped with POD_MASTER_KEY; wrapped key stored in the pod manifest index (pods/{sub}.key). Destroy = delete both objects; deletion record kept in pods/_deleted/{sub}.json (no CDR data).
Amendments (v1.1, from the pod build)
income.annualisedis net pay annualised (after tax), e.g. Alex Chan ≈ 75,849 net vs 98,000 gross. Listing thresholds must be expressed in net terms.- Pod status is additive: per arrangement
retainUntil,endReason,endedAt,failure; adeletionblock when the pod is destroyed. Step names are fixed human-readable strings ("Pod created", "Loading accounts", "Calculating income", "Preparing income proof"). DELETE /pods/{sub}/arrangements/{id}→{ status, podDestroyed, adrRevoke, deletionReceipt }.purposeis an allowlist:credential-issuance,offer-eligibility,consumer-view; anything else → 403 (audited).POST /pods/{sub}/proofs/incomeerrors: 403use_consent_missing, 409not_ready, 422threshold_not_met.- The internal key may also be sent as
x-api-key(for the MATTR claim source). SSE/pods/{sub}/eventsaccepts?access_token=. - Token manager returns Bearer tokens to the pod (the pod can't mint DPoP proofs). Where a DH requires DPoP, adr-cba proxies the DH call instead.
- Pod JWS:
typ: pod-commitment+jwt, payload{commitment, sub, iat}. - pod-runtime runs as a single replica (in-memory actors).
Amendments (v1.2, from the iOS build)
POST /dev/token→{ access_token, token_type: "Bearer", expires_in }.GET {QR_LANDING_URL}/api/listings/{id}→{ id, address, rentPerWeek, incomeThreshold /* annual, NET */, agency }.- Proof sharing: the app uses the pod's
verifierUrlfromPOST /proofs/incomeas-is; the pod owns the bundle encoding. - Consent callback status values:
success | cancelled | denied | error(denied= consumer declined at the data holder). - AASA lists
<TEAMID>.au.com.cba.hackathon.walletfor paths/rent/verify*.
Amendments (v1.3, from the ADR build)
- ADR brand id
commbank. DH redirect URI:{ADR_CDR_KIT_URL}/cdr/callback. ADR JWKS:{ADR_CDR_KIT_URL}/.well-known/jwks.json— the Register must setADR_JWKS_URIto this (its default is a Register dev key). sessionhandle: HS256 JWT, secretOFFERS_SESSION_SECRET, claims{sub, listingId, return, exp}, lifetime ≤ 15 min,returnmust start withcbaapp://.CBA_JWKS_URL={adr-cba-pf}/ext/cba/jwkswith PF;{ADR_CDR_KIT_URL}/dev/jwks.jsonfor dev tokens.- Success redirect also carries
dataHolderBrandIdandlistingId. Once-off arrangements expire afterONCE_OFF_USE_SECONDS(default 24h). - Extra cdr-kit endpoints:
GET /internal/arrangements/{id}(internal),GET /api/arrangements(app, CBA token). - PingFederate version: Docker Hub no longer publishes 11.2 images; all PFs use 13.1.3.
Amendments (v1.4, from the issuer build)
- Pod state
failed→ offers returns 302 (start consent again). - 302/409 bodies:
{ status, error, state }. - MATTR claim source points at offers-api
GET /claims/mattr(not pod-runtime): it merges identity fromdata/customers.json(CBA PF id_token carries onlysub) with pod income, and answers MATTR's registration probe (nosub) with200 {}. Must be publicly reachable by MATTR; authx-api-key. - MATTR has no
issuer_stateon auth-code offers: the customer is bound withrequest_parameters.login_hint = sub→ CBA PF →sub→ claim source. - Offer URIs are inline (
openid-credential-offer://?credential_offer=…); the app treats them as opaque. - PAZ reads
CBA_JWKS_BASE_URL+CBA_JWKS_PATH; keep PAZ policy tracing off (internal key travels in the policy request).
Amendments (v1.5, from the DH/Register build)
private_key_jwtclient assertions use headertyp: client-authentication+jwt(PF 13.1 rejectsJWT);aud= DH issuer or endpoint.- DH access-token claims:
sub= DH customerId,account_ids(space-delimited),cdr_arrangement_id,sharing_expires_at. - DCR at
{DH infosec}/register,client_id = software_id; signed request objects required at PAR. DCR state lives in each DH consent app (/datavolume) — the ADR re-registers oninvalid_client. - Register
authDetails.jwksEndpoint= each DH consent app's JWKS (it signs DH-initiated revocations to cdr-kitPOST /arrangements/revoke). - DH host ports are overridable:
DH_<BRAND>_PF_PORT,DH_<BRAND>_PF_URL. In local compose server-side calls usedh-<brand>-pf:8080while the issuer stayslocalhost. - Demo listings (qr-landing):
newtown-studio-7$280/wk,surry-hills-123$420/wk (default QR),bondi-beach-42$520/wk; threshold = rent×52/0.30 on net income.
Amendments (v1.6, adr-cba rebuilt as an authentic Ping CDR Kit data-in participant)
Supersedes the cdr-kit parts of v1.1 §7, v1.3 §15–19 and the "Token manager" section. The hand-rolled Node cdr-kit is archived at archive/non-kit/adr-cdr-kit/.
- What adr-cba runs. The kit's data-in stack, each with its own server profile under
participants/adr-cba/server-profiles/: PingFederate 13.0.3 (pf-cdr-au-data-in-modules: DataHolderSelector, callback,/arrangements/revoke), PingAccess 8.3.2 (pa-cdr-au-data-in-modules: CDRInjectDataHolderTokenRule), PingDirectory 11.0.0.4 (pd-cdr-au-data-in-modules: SCIM token store + refresh plugin), PingDataSync 11.0.0.4 (pds-cdr-au-data-in-modules: revocation at the DH, plus the CBA event pipe). The kit'stamatping/datain-configure-pfruns as a one-shotconfigureservice (Register CA, network certs, DCR).adr-cba-webis a thin CBA layer: CX screens, the customer hand-off, the dashboard and/dev/token. It does no CDR protocol work. - ADR public base (= CDR
recipient_base_uri):ADR_PUBLIC_BASE_URL, localhttps://sso.commbank.localhost:9444(kit PF, same name inside Docker oncdr). Register settings:ADR_BASE_URL= that,ADR_JWKS_URI={ADR_PUBLIC_BASE_URL}/pf/JWKS(the kit PF's static OIDC keys = CBA's software product key), redirect{ADR_PUBLIC_BASE_URL}/ext/cdr/data-in/callback, DH→ADR revocation{ADR_PUBLIC_BASE_URL}/arrangements/revoke(kit servlet; DH-signed bearer JWT, aud = that URL). ADR_CDR_KIT_URLnow meansadr-cba-web(port 7300, unchanged). It still servesGET /consent/start?session&return(HS256 handle, v1.3 §16), the final redirectcbaapp://consent-complete?status=success|cancelled|denied|error&cdrArrangementId&dataHolderBrandId&listingId,POST /dev/token,GET /dev/jwks.json,GET /dashboard,GET /api/arrangements,POST /api/arrangements/{id}/stop,GET /internal/arrangements/{id},POST /internal/arrangements/{id}/revoke.GET /cdr/callback,POST /arrangements/revokeand/.well-known/jwks.jsonmove to the kit PF (§34).- Token manager endpoint retired.
POST /internal/arrangements/{id}/access-tokenno longer exists: DH tokens never leave the kit. The pod reads DH data through the kit's PingAccess:- token: «redacted»
- data:
GET {ADR_DATA_PROXY_URL}/dh/{dataHolderBrandId}/cds-au/v1/banking/...withAuthorization: Bearer <that token>,X-CDR-Subject: {customerSub},X-CDR-Context: cba-adr-web(ADR_CDR_CONTEXT) and the usual CDSx-v/x-fapi-*headers. The kit rule finds the arrangement for subject + DH + context + software id in PD, refreshes the DH token there if due, swaps theAuthorizationheader and forwards over the kit's mTLS site authenticator. A customer without an active arrangement → 403. - pod-runtime env:
ADR_DATA_PROXY_URL(localhttps://adr-cba-pa:3000, hosthttps://localhost:7310),ADR_TOKEN_URL,ADR_POD_CLIENT_ID,ADR_POD_CLIENT_SECRET,ADR_CDR_CONTEXT. WithoutADR_DATA_PROXY_URLthe pod falls back to the legacy endpoint (dev fakes only).
- One active arrangement per customer per DH. The kit keys arrangements by subject + DH + context; a new consent with the same DH amends the existing arrangement (the kit sends its
cdr_arrangement_idin the request object). - Events come from PingDataSync, not application code: the CBA sync pipe watches the kit's token store and publishes to
cdr.arrangements(same JSON schema, singleeventfield, no tokens).ArrangementEstablished= a new kit token record;ArrangementRevoked.revokedBy=consumer/adrwhen CBA flagged it, otherwisedata-holder(DH called the kit's revocation servlet);ArrangementExpired= the pipe's expiry sweep set the record toexpired(ongoing: sharing period over; once-off:ONCE_OFF_USE_SECONDS, default 24 h, after consent). PDS env:CBA_EVENTS_REDIS_URL. - Consent choices travel through the kit: the CBA layer sends them as tracked authorize parameters (
use_consents,deletion_election,purpose,listing_id,consent_type,override_sharing_duration) and the kit stores them as arrangement metadata ("Metadata Params").dataHolderBrandIdin events is the kit DH record's Register brand id. - CBA customer tokens: unchanged for now (
/dev/tokenon adr-cba-web withCBA_JWKS_URL={ADR_CDR_KIT_URL}/dev/jwks.json, or the existing CBA OP PFparticipants/adr-cba/pingfederateon :9034 with/ext/cba/jwks). It is not yet folded into the kit PF. - Ports (adr-cba): kit PF 9444 (admin 9934), PA engine 7310 (admin 7311), PD 7312, web 7300. All overridable (
ADR_*_PORT). The pod reaches PA athttps://localhost:7310from the host orhttps://adr-cba-pa:3000on the adr-cba network; both are PA virtual hosts and adrATM resource URIs. 42a. ADR TLS. The kit PF's runtime certificate is issued by the Register CA (kit PKI), so DHs can fetch{ADR_PUBLIC_BASE_URL}/pf/JWKS. Regenerate it (FORCE_TLS=1 scripts/gen-keys.sh) when the Register CA changes. 42b. Networking follows v1.7 §50: adr-cba mapscdrregisterandsso|api.<brand>.localhosttohost-gateway, and joins no shared network.
Amendments (v1.7, Register + Data Holders rebuilt as authentic Ping CDR Kit participants)
Supersedes v1.5 §27–31 and the DH/Register rows of the participants table. The Node Register, the Node DH kit inside PF, the Node consent app and dh-banking-api are archived under archive/non-kit/.
- Register = the kit's test harness (
tamatping/cdr-register-testharness:20231123_1),participants/register/, host port 8084, containerregister, oncdrwith the kit aliasescdrregister/mockregister. Its model is rendered at boot fromparticipants/register/cache-templates/(DH brandswestpac,nab,anz; legal entitycommonwealth-bank-of-australia, accreditationADRBNK000001, brandcommbank, software productscba-income-verifyand the test-onlycba-dh-smoke). SSAs are PS256,iss=cdr-register, signed by the harness key_GLOBAL_(JWKS/.well-known/openid-configuration/JWKS; regenerated at each Register start, so DHs fetch it live). The harness also runs the CDR PKI:/public/download(CA),/public/sign(CSR → client/server cert). The CA persists in theregister-cachevolume. - Register env for the ADR (all optional):
ADR_BASE_URL(defaulthttps://sso.commbank.localhost:9444),ADR_JWKS_URI(default: the harness helper JWKS),ADR_REVOCATION_JWKS_URI,ADR_REDIRECT_URIS(comma-separated; default{ADR_BASE_URL}/ext/cdr/data-in/callback),ADR_RECIPIENT_BASE_URI,ADR_LOGO_URI,ADR_POLICY_URI,ADR_TERMS_URI,ADR_SOFTWARE_PRODUCT_STATUS(ACTIVE/INACTIVE; the DHs' PingDataSync re-reads status every 10 s). DH URLs:DH_<BRAND>_SSO_URL,DH_<BRAND>_API_URL,DH_<BRAND>_LOGO_URI. Note the kit sets the SSAorg_nameto the brand id (commbank). - Each DH is a full kit stack,
participants/dh-<brand>/: PingFederate 13.0.0 (pf-cdr-au-modules), PingAccess 8.3.2 (pa-cdr-au-modules), PingDirectory 10.3.0.4 (clients, grants, consents, customers), PingDataSync 10.3.0.4 (pds-cdr-au-modules: Register status cache, DH→ADR revocation), PingAuthorize 10.3.0.4 (the kit's "PingDataGovernance" API gateway policy),tamatping/agentless-consentapp,tamatping/mock-dh-apis, and the kit'stamatping/datain-configure-pfside-car. Profiles are generated from the vendored kit bydeploy/kit/build-dh.mjsand committed per participant;server-profiles/KIT-CHANGES.mdlists every deviation. - DH public URLs (PingAccess is the only public entry point):
https://sso.<brand>.localhost:<port>(issuer, PAR, authorize, token, DCR at/register,/data-holder/arrangements/revoke, JWKS/pf/JWKS),https://api.<brand>.localhost:<port>/cds-au/v1/banking/...,https://consent.<brand>.localhost:<port>. Ports: Westpac 9441, NAB 9442, ANZ 9443. The port is part of the issuer, so it is not freely overridable (changeDH_PUBLIC_PORTinenv/dh.envand the Register'sDH_<BRAND>_*_URLtogether). On Railway each DH gets a real domain on 443. - Trust profile = the kit's. DCR:
POST {issuer}/register,Content-Type: application/jwt, signed with the software product key, carrying the Register SSA, over mTLS with a Register-CA-signed client cert (/public/sign). The kit issuesclient_id = dcr-sso-<UUID.nameUUIDFromBytes(software_id)>(CBA:dcr-sso-53f1b838-cba2-3a60-90de-4eba13dbe573) and allows one registration per software_id per DH (a second POST → 400Duplicate registrations…); after a DH restart (PD is ephemeral) the ADR must register again. PAR (/as/par.oauth2) with a PS256 request object (claims.sharing_duration,claims.id_token.acr), PKCE S256,private_key_jwt; tokens are certificate-bound (cnf.x5t#S256), so every token, API and revocation call must use the same client cert.response_type=code id_token(id_token encrypted RSA-OAEP/A256GCM) as in the kit's tests. Revocation:POST {issuer}/data-holder/arrangements/revoke(cdr_arrangement_id, client assertion, mTLS) → 204. - Login = kit identifier-first page (mobile or customer id) → OTP page (PF HTML Form adapter + LDAP PCV against PingDirectory; fixed demo code
000789) → kit agentless consent app. Consent = the kit consent app with a branded, CX-laid-out template (same model). Ineligible accounts = the kit'sisOwned=false(shown greyed with a reason). - Access-token subject is the customer's PingDirectory
entryUUID(kit design, pseudonymous), =nameUUIDFromBytes(customerId), e.g. Alex @ Westpac4cd4c135-0ca6-334d-9fbe-fe1b782f6916. Account ids are the seed ids (westpac-acc-…). Consented accounts are enforced by PingAuthorize (X-ACCOUNTS), not a token claim. - mock-dh-apis quirks (kit image):
/accounts/{id}/transactionsreturns the customer's whole transaction list (every item carries its realaccountId) - consumers must filter byaccountId/ de-duplicate bytransactionId. Balances are synthesised by the kit from the account id, not seeded. Nocommon/customer. - Networking. Participants do not share Docker networks with the DHs. Kit stacks reuse the same hostnames (
pingfederate,pingaccess, …) and Compose adds service names as aliases on every network, so a shared network makes kit stacks resolve each other's servers. Cross-participant calls use the published host ports (as on Railway): containers map names withextra_hosts: ["<name>:host-gateway"]. The DHs mapcdrregister/mockregister(Register, :8084) andsso.commbank.localhost(ADR, :9444). adr-cba must mapsso.westpac.localhost,api.westpac.localhost,sso.nab.localhost,api.nab.localhost,sso.anz.localhost,api.anz.localhost→host-gatewayin its PF, PA and PDS containers. The Register stays oncdr(aliascdrregister) for adr-cba.
Amendments (v1.8, Railway deployment of the kit participants)
- Railway hostnames (
deploy/railway/README.mdhas the map). Each DH's PingAccess sits behind a Railway TCP proxy, with mTLS end to end. Its kitBASE_HOSTNAMEis<brand>.<proxy-ip-dashed>.sslip.ioandDH_PUBLIC_PORTis the proxy port. Westpachttps://sso.westpac.66-33-22-234.sslip.io:47740, NABhttps://sso.nab.66-33-22-224.sslip.io:49950, ANZhttps://sso.anz.66-33-22-232.sslip.io:25120(api./consent.likewise). The DH pages carry the CDR Register CA's certificate, so browsers warn. - ADR on Railway:
ADR_PUBLIC_BASE_URL=https://adr-cba-pf-production.up.railway.app(kit PF behind the Railway edge; ADR endpoints are TLS-only in the CDR).ADR_CDR_KIT_URL=https://adr-cba-web-production.up.railway.app. pod-runtime:ADR_TOKEN_URL={ADR_PUBLIC_BASE_URL}/as/token.oauth2,ADR_DATA_PROXY_URL=https://adr-cba-pa-production.up.railway.app(also the adrATM resource URI and the PA vhostADR_DATA_PROXY_HOST:ADR_DATA_PROXY_PORT; local defaultlocalhost:7310). - adr-cba-web cookies are
SameSite=None; SecurewhenADR_WEB_URLis HTTPS, because the kit PF hands back to/pf/authnwith a cross-site form POST. Plain-HTTP local dev keepsLax.
Amendments (v1.9, income from CBA's own accounts plus CDR)
The income credential can now draw on the customer's own CommBank accounts and, optionally, on other banks through the CDR. Design rule: the pod is for CDR data only. CBA income is worked out from CBA's core banking (mock) and never enters a pod. The issuer adds the two at issuance. A CBA-only customer gets no pod. Each source signs its own income commitment, and the ZK income proof covers the total (§59), so CBA-only customers can prove income too.
CBA core banking (mock) -
participants/cba-issuer/core-banking, Railwaycba-core-banking, port 7800,CBA_CORE_BANKING_URL. Data: thecbablock of each customer indata/customers.json(accounts with CommBank product names, opening balances, income streams) →data/generate-cba-seed.mjs→participants/cba-issuer/core-banking/data/seed.json(deterministic, 12 months to2026-09-30; dates shifted forward at runtime so the window ends today, as the DH mock APIs do). DH seeds are unaffected.GET /accounts- CBA access token (CBA_JWKS_URL= CBA PF/ext/cba/jwks,CBA_ISSUER); the token'ssubis the customer →200 { "sub", "accounts": [ { "accountId", "displayName", "nickname"?, "productName", "productCategory", "maskedNumber", "bsb"?, "balance": { "current", "available", "currency" }, "receivesIncome": bool } ] }.receivesIncome= a regular income is paid into it (the app preselects these).GET /accounts/{accountId}/transactions?limit=- CBA token, the account must be the caller's →{ "accountId", "transactions": [CDS-shaped, newest first] }; another customer's account → 404.GET /customers/{sub}/accounts- internal key (Authorization: Bearer {INTERNAL_API_KEY}), same body as/accounts.POST /internal/income- internal key,{ "sub", "accountIds": [...] }→200 { "sub", "accountIds", "income": CbaIncome | null, "reason"?: "no_recurring_income" };CbaIncomealso carriescommitment,commitmentSignature,kid(§59).400empty or foreign account ids,404unknown customer. It runs the pod's income aggregator (@cba/pod-programs/income,detectIncome) over the selected accounts' posted transactions, after removing transfers between the customer's own CBA accounts (CBA's ledger knows them).CbaIncome= the aggregator's result withsource: "CBA"andaccountIds:{ annualised, currency, frequency, averageNetPay, employer, monthsObserved, confidence, source, accountIds, verifiedAt }(net, like §1).
POST /offersforcommbank-id-incometakesincomeSources: { "cbaAccountIds": string[], "cdr": boolean, "addBank"?: boolean }. Omitted = the old behaviour ({ cbaAccountIds: [], cdr: true }). Decision (PingAuthorize policy on Railway, mirrored by offers-api's local PDP):Sources Pod Answer cdr: falsenot read permit → offers-api computes CBA income: 200offer, or422 {"error":"no_cba_income"};cbaAccountIdsempty →400cdr: truecreated/loading/loaded409 pod_loadingcdr: true, addBank: trueanything else 302into ADR consent (connect another bank)cdr: truenone/destroyed/failed302into ADR consentcdr: trueincome_readypermit → 200(CBA income added when accounts were selected)The 200 body adds
"incomeSources": { "cba": { "annualised", "frequency", "employer" } | null, "cdr": bool }. offers-api remembers the customer's selection (accounts, cdr, computed CBA income) for an hour, in memory, for the claim source.More than one other bank. Each extra bank is another consent (
addBank: true→ 302); the kit keeps one arrangement per customer per DH (§37). The pod sums income across its active arrangements (one income stream per DH):outputs/income→annualised= the sum;frequency,employer,dataHolderBrandId= the largest source; additivesources: [{ cdrArrangementId, dataHolderBrandId, annualised, frequency, employer }]. The commitment isPoseidon(sum, salt), with the salt in one anchor arrangement's compartment (the earliest active one withincome-proof); proofs prove the sum. PodGET /claims/mattraddsincome_data_holders(comma-separated brand ids) andincome_employers. Podstateisincome_readyonly when no active arrangement is stillcreated/loading/loaded; a bank with no recurring income (failed) does not block the others.Claim source (offers-api
GET /claims/mattr?sub=, income config) returns identity plus:income_total_annualised,income_annualised(= total, backward compatible),income_cba_annualised,income_cdr_annualised(0 when that source is absent),income_sources(display, e.g."CommBank, Westpac via CDR"),income_source(CBA|CDR|CBA+CDR),income_frequencyandincome_employer(largest source),income_employers(all,", "),income_verified_at, and per sourceincome_commitment_cba(when CBA income) andincome_commitment_cdr(when CDR income) - see §59. (income_commitmentandincome_commitment_scope, briefly used during this build, are gone.) Withcdr: falsethe pod is never called. Without a remembered selection: CBA = the customer'sreceivesIncomeaccounts, CDR = included when the customer has a pod that isn'tnone/destroyed/failed(a loading pod passes its 409 through). No income from any source →409 {"error":"no_income"}.Income proof (app): offered whenever the credential has income from any source; the listing threshold is checked against the total. When the total is below the threshold the app says so and makes no request. The app calls
POST {CBA_CORE_BANKING_URL}/proofs/income(§59), not the pod.Total-income ZK proof. Pod-only-for-CDR still holds: no CBA data enters the pod, and nothing but an opening leaves it.
- Commitments. Core banking:
C_cba = Poseidon(cbaIncome, saltCba)over the selected accounts.saltCba= HMAC(CBA_COMMITMENT_SECRET, sub + sorted account ids) truncated to 248 bits, so the same accounts give the same commitment without storage; income is evaluated at a fixed point relative to the shifted seed, so it is stable. Signed ES256 withCBA_SIGNING_JWK, headertyp: cba-commitment+jwt, payload{ commitment, sub, iat }, JWKS at{CBA_CORE_BANKING_URL}/.well-known/jwks.json. Pod:C_cdras today (§56, summed across banks;pod-commitment+jwt). A missing source is the zero commitmentPoseidon(0, 0), unsigned. - Opening from the pod.
POST {POD_RUNTIME_URL}/pods/{sub}/proofs/income-share- internal key only →200 { annualIncome, salt, commitment, signature, kid, dataHolders }(the combined CDR figure). Needs theincome-proofuse consent (else403 use_consent_missing);404no pod;409 not_ready. Audited per arrangement asdisclosureofincome-commitment-opening(fieldsannualIncome,salt) tocba-income-proof. No accounts, transactions or other data leave the pod. - Circuit
packages/zk-circuits/circuits/income_sum_gte.circom: privatecbaIncome, cbaSalt, cdrIncome, cdrSalt; public[threshold, commitmentCba, commitmentCdr](that order); both openings checked, amounts 32-bit,cbaIncome + cdrIncome ≥ thresholdat 33 bits. ~1,170 constraints, demo-only beacon setup on 2^11 powers of tau; committedbuild-artifacts/verification_key_sum.json.income_gte(one commitment) is unchanged. - Prover = CBA core banking (the pod can't see CBA data; CBA-only customers have no pod).
POST {CBA_CORE_BANKING_URL}/proofs/income- CBA access token,{ threshold, listingId?, cbaAccountIds? }(default: thereceivesIncomeaccounts) →200 { v: 2, proof, publicSignals, threshold, commitments: { cba, cdr }, sources: { cba, cdr }, signatures: { cba?, cdr? }, kids: { cba?, cdr? }, listingId?, cdrNote?, verifierUrl }.cdrNote=no_pod|income_proof_consent_missingwhen the CDR side is left out. Errors:400bad threshold,401,409 pod_not_ready,422 threshold_not_met | no_income,502pod unreachable. No amounts in the response. - Verifier (
zk-verifier,CBA_CORE_BANKING_URL+POD_RUNTIME_URL): a bundle withv: 2is checked againstverification_key_sum.json; public signals must equal the stated threshold and commitments; each included source's JWS must verify against its JWKS (CBA key forcba, pod key forcdr) and sign that commitment; an excluded source must be the zero commitment with no signature; at least one source signed; both signatures name the samesub; freshness = the oldestiat. The browser re-checks the Groth16 proof with snarkjs. v1 bundles (pod-only) still verify as before. - Credential binding. The claim source puts
income_commitment_cba(from core banking's/internal/income) andincome_commitment_cdr(the pod's) in the CommBank ID; a verifier binds a proof to the credential by matchingcommitments.cba/.cdr.
- Commitments. Core banking:
Income detection tightened (
packages/pod-programsincome aggregator, shared by the pod and core banking), because sources are now summed: (a) credits to card and loan accounts (productCategorynot a transaction/savings type) are repayments, not income (incomeTransactions,canReceiveIncome); (b) a regular stream with unstable amounts (stability < 0.5) and no pay wording is a top-up from the customer's own money (savings transfers, household kitty), not income. Before this, Alex's NAB savings transfers ($6,855) would have been added to his Westpac salary.