Design · docs/issuer-notes.md
CBA issuer notes - offers, gateway, MATTR, QR landing
Covers participants/cba-issuer/ (offers-api, offers-gateway, mattr config) and apps/qr-landing/. Contracts: docs/CONTRACTS.md up to v1.3.
The flow
- The app calls
POST {OFFERS_GATEWAY_URL}/offers {profile, listingId?}with its CBA access token. - PingAuthorize validates the JWT (
CBA_JWKS_URL), readsGET {POD_RUNTIME_URL}/pods/{sub}/status?purpose=offer-eligibilityand decides:commbank-id-incomewith no usable pod (none,destroyed,failed) → 302 to{ADR_CDR_KIT_URL}/consent/start?return=cbaapp%3A%2F%2Fconsent-complete&listingId=…&session=…- pod
created,loadingorloaded→ 409{"error":"pod_loading","state":…} income_ready, orcommbank-id→ forwarded to offers-api, which returns 200{credentialOffer}
- offers-api creates a MATTR authorization-code offer and returns MATTR's
uriverbatim. - The wallet logs in at CBA PingFederate (MATTR's auth provider). At issuance MATTR calls
GET {OFFERS_API_URL}/claims/mattr?sub=…, and offers-api merges identity (data/customers.json) with the pod's income claims.
With OFFERS_LOCAL_PDP=true (the default), offers-api makes the same 302/409/200 decision itself, so the demo still runs without PingAuthorize. Set it to false behind the gateway. On Railway it's false, with OFFERS_GATEWAY_ONLY=true, so /offers only answers the gateway over the private network (see participants/cba-issuer/gateway/deploy/README.md).
Income from CommBank accounts and other banks (v1.9)
The income credential draws on the customer's own CommBank accounts and, optionally, other banks through the CDR. Pods are for CDR only.
- Core banking mock (
core-banking/, Railwaycba-core-banking, https://cba-core-banking-production.up.railway.app, privatehttp://cba-core-banking.railway.internal:7800). Seed: thecbablock per customer indata/customers.json→node data/generate-cba-seed.mjs→core-banking/data/seed.json(CommBank products: Smart Access, NetBank Saver, GoalSaver, Everyday Offset, Low Rate Mastercard, Standard Variable Rate Home Loan; 12 months, dates shifted to today). Patterns: Alex mostly elsewhere (Westpac salary, $16k tutoring at CommBank), Priya split (ANZ salary, $73k locum work at CommBank), Jordan mostly CommBank ($82k council salary, NAB cafe job), Sam rent ($26k) into an offset, salary at Westpac.GET /accountsand/accounts/{id}/transactionsfor the app (CBA token),POST /internal/income(internal key) runs@cba/pod-programs/incomeover the chosen accounts and returns a signed commitment,POST /proofs/incomemakes the total-income proof, JWKS at/.well-known/jwks.json. Env:INTERNAL_API_KEY,CBA_JWKS_URL,CBA_ISSUER,CBA_SIGNING_JWK(ES256 private JWK),CBA_COMMITMENT_SECRET,POD_RUNTIME_URL,ZK_VERIFIER_URL. - Offers.
POST /offerstakesincomeSources: { cbaAccountIds, cdr, addBank? }(CONTRACTS §55).cdr: falsegoes straight through the gateway (no pod read) and offers-api computes CBA income (422no_cba_incomeif there is none).cdr: truekeeps the 302/409/200 path;addBank: truealways starts another consent unless the pod is loading. offers-api remembers the choice for an hour (in memory) for the claim source;CBA_CORE_BANKING_URLpoints at core banking over the private network. - PingAuthorize.
gateway/build-policy.pyaddsOffer.cdrandOffer.addBank($.incomeSources.*, defaultstrue/false, so old requests behave as before) and 15 decision tests; rebuilt against the local Policy Editor (pbd-pap) and exported togateway/policy/. - Claim source. Identity +
income_total_annualised(=income_annualised),income_cba_annualised,income_cdr_annualised,income_sources,income_source,income_frequency,income_employer(s),income_verified_at,income_commitment_cba,income_commitment_cdr. The pod is called only when the customer chose CDR, so a CBA-only issuance never hitspod_not_found. - Total-income proof (CONTRACTS §59). Core banking proves CBA + CDR ≥ threshold (
income_sum_gte), with the CDR opening from the pod'sincome-shareendpoint; the zk-verifier checks the proof and both signatures. CBA-only customers get a proof (CDR = the zero commitment). - MATTR.
mattr/templates/.../commbank-id-income.yamlcarries the new claims (commitments optional). Apply just the configs withMATTR_ENV_FILE=../../../mattr.env.ignore python3 setup-tenant.py --configs-only(links to the live claim sources by name, touches nothing else). - E2E.
deploy/e2e-income-sources.mjs(CBA PF log-on, CBA-only, CBA + Westpac with Playwright consent, add NAB, proofs through the verifier, optional real MATTR issuance withE2E_ISSUE=1, Stop sharing at the end).
The session handle
An HS256 JWT over OFFERS_SESSION_SECRET (shared with adr-cba), with claims {sub, listingId, return, exp} plus iat. It lasts 10 minutes by default and is capped at 15. return must start with cbaapp://, and offers-api refuses to start otherwise. The gateway gets it from GET /internal/consent-location, because PingAuthorize can't sign HS256 itself.
MATTR endpoints used
Confirmed against the Platform API OpenAPI spec v12.2.0 (learn.mattr.global/api/api-reference/platform-latest.yml), the mattr-vii skills, and read-only calls to cba-demo1:
| Call | Use |
|---|---|
POST {MATTR_AUTH_URL}/oauth/token |
client credentials, audience = tenant URL |
GET /v2/credentials/mobile/configurations |
finds the config id by type (cached) |
POST /v1/openid/offers |
auth-code offer: {credentials:[id], request_parameters:{login_hint: sub}} → 200 {uri} |
There is no issuer_state on MATTR's auth-code offer API. The offer is reusable, and the holder is whoever logs in at the auth provider. offers-api binds the customer by sending login_hint = sub. The auth-provider template forwards login_hint to PF. The binding that actually holds is PF's sub flowing into the claim source.
The URI is openid-credential-offer://?credential_offer=… (inline), not credential_offer_uri=. The app should treat it as opaque.
MATTR config (participants/cba-issuer/mattr/)
platform-config/mirrors cba-demo1 as exported on 2026-10-07. It holds no interaction-hook file, because that one carries a plaintext secret. The "Commbank ID" (financial-passport) config is unchanged.templates/holds what's new:- credential config
au.com.cba.commbank-id-income("CommBank ID – Income Verified"), with namespacesau.com.cba.commbank_id.1(identity) andau.com.cba.income.1(income_annualisedNET,income_frequency,income_employer,income_verified_at,income_source,income_commitment) - claim source
cba-issuer-claims→{OFFERS_API_URL}/claims/mattr, authenticated with an api key (MATTR sends it asx-api-key=INTERNAL_API_KEY) - auth provider = adr-cba PF client
mattr-vii
- credential config
render.mjssubstitutes env values, because mattrctl applies YAML verbatim. The rendered files that hold secrets are gitignored.plan-output.txtis the dry run: 2 creates. With--swap-auth-providerit's 3 creates and 1 drift.
Not applied. Apply in two phases, after reading the plan:
cd participants/cba-issuer/mattr
set -a; . /path/to/mattr.env; set +a # MATTR_CLIENT_ID / MATTR_CLIENT_SECRET
OFFERS_API_URL=https://<offers-api public> INTERNAL_API_KEY=… node render.mjs && ./plan.sh
MATTR_CONFIG_ROOT=$PWD/platform-config python3 <mattrctl.py> sync --env cba-demo1 # creates the claim source
CLAIM_SOURCE_ID_CBA_ISSUER=<id from sync / list> … node render.mjs && ./plan.sh && <same sync> # links itAuth provider swap. MATTR allows one provider per tenant. Today that's idp-mattr-pf, which other cba-demo1 demos use. Swapping to adr-cba PF breaks them. The cleanest route is a direct PUT /v1/users/authentication-providers/{id} with the new url, client and secret, so the id stays the same. After that, copy MATTR's redirectUrl into adr-cba's var.mattr_redirect_uris. render.mjs --swap-auth-provider plus sync --prune also works, but prune deletes every live resource that isn't in git.
Listing threshold rule
incomeThreshold = ceil(rentPerWeek × 52 / RENT_TO_INCOME_RATIO). The ratio defaults to 0.30 and is applied to net income, because the pod's income.annualised is net. For $750/wk that's $130,000 net a year.
The demo customer Alex Chan earns about $75,849 net, so he fails that listing at 0.30. For the proof to pass on stage, set RENT_TO_INCOME_RATIO=0.52 (threshold $75,000) or lower the rent.
QR landing
/listings/surry-hills-123 shows the listing and a server-rendered QR for https://{QR_LANDING_HOST}/rent/verify?listing=surry-hills-123. /rent/verify is the fallback page, with a cbaapp://income-verify?listing=… button. /.well-known/apple-app-site-association lists {APPLE_TEAM_ID}.au.com.cba.hackathon.wallet for /rent/verify*. /api/listings/{id} returns {id, address, rentPerWeek, incomeThreshold, agency}.
Run
cd participants/cba-issuer/offers && pnpm install && pnpm test # 67 tests
cd participants/cba-issuer/core-banking && pnpm test # 20 tests
cd apps/qr-landing && pnpm install && pnpm test # 5 tests
# gateway: see participants/cba-issuer/gateway/deploy/README.md (15 PAP decision tests + 10 e2e)