Bulk-issuance test artifacts
Quickstart
Start the two "ministry" containers (isolated registry + JSON API, no
impact on the main VC stack):
cd ~/cdpi/n8n-demo/verifiably-go/testdata/bulk-issuance
docker compose up -d
Expected:
[+] Running 2/2
โ Container ministry-citizens-db Healthy
โ Container ministry-citizens-service Running
Verify both containers are healthy and the service is answering:
docker compose ps
curl -s http://localhost:8199/health | jq
NAME STATUS PORTS
ministry-citizens-db Up X minutes (healthy) 0.0.0.0:5437->5432/tcp
ministry-citizens-service Up X minutes (healthy) 0.0.0.0:8199->8099/tcp
{
"ok": true,
"routes": [
"/api/farmer-id",
"/api/hotel-reservation",
"/api/mortgage-eligibility",
"/api/mortgage-simple",
"/api/verifiable-id"
]
}
That's it โ the API and DB sources in the VC platform's bulk-issue
form now have something to talk to. Jump to the recipes below to drive
each source. When you're finished:
docker compose down -v # tears down both containers and drops the synthetic citizens volume
Ports cheat-sheet โ
5437is postgres (DSN
postgres://citizens:citizens@localhost:5437/citizensfrom the host,
or@ministry-citizens-db:5432from inside docker).8199is the
HTTP API (http://localhost:8199/...from the host, or
http://ministry-citizens-service:8099/...from inside docker).
Three input sources are supported by the issuer's bulk form:
| chip | handler (bulk.go) | what it reads |
|---|---|---|
| csv | SimulateCSV |
multipart file upload |
| api | BulkFromAPI |
GET returning a JSON array (or {rows:[...]}) |
| db | BulkFromDB |
postgres DSN + SELECT query |
Every source produces the same []map[string]string, so the schema's
field-name โ row-key mapping is what determines whether a row issues
cleanly. Field names are case-sensitive (camelCase, matching the
walt.id template the schema was built from).
Everything below is dummy data โ 200 synthetic Kenyan + Trinidad &
Tobago citizens seeded into a throw-away postgres. Copy-paste the recipes
verbatim; nothing references real PII.
Layout
testdata/bulk-issuance/
โโโ csv/ # drop into the CSV source
โ โโโ mortgage-simple.csv 10 rows, single "holder" column
โ โโโ mortgage-simple-large.csv 50 rows โ scale / batching test
โ โโโ mortgage-eligibility.csv 10 rows, all 10 walt.id MortgageEligibility fields
โ โโโ verifiable-id.csv 10 rows, 8 VerifiableId fields
โ โโโ hotel-reservation.csv 10 rows, 5 HotelReservation fields
โ โโโ tax-receipt.csv 10 rows, 2 TaxReceipt fields
โ โโโ malformed-bad-quoting.csv unterminated quotes โ parseCSVRows error
โ โโโ malformed-wrong-columns.csv column-count drift โ per-row rejection
โ โโโ header-only-no-rows.csv zero data rows โ IssueBulk returns 0 accepted
โโโ db/
โ โโโ queries.sql # paste-ready SELECTs, one per schema, aliasing
โ citizens-db columns to schema field names
โโโ api/
โ โโโ citizen_service.py # stdlib-only HTTP server wrapping citizens-db
โ โโโ Dockerfile # container image for the service
โ โโโ run.sh # bare-Python launcher (optional bearer auth)
โโโ docker-compose.yml # "ministry" scenario: db + API in one stack
Common setup (do once per test)
- Open the VC platform (
http://172.24.0.1:8080local, or
http://<host>:8080remote). - Issuer role โ log in (
admin/adminvia keycloak). - Issuer โ DPG: pick Walt Community Stack, continue.
- Issuer โ Schema: pick the schema listed in the recipe below, then
the JWT ยท W3C (jwt_vc_json) chip. - Issuer โ Mode: select Bulk + Wallet, continue.
- You're now on the issuance screen with three chips:
CSV upload, Secured API, Database.
Swap step 4's schema pick to match whichever recipe you're running โ the
bulk form only accepts rows whose keys match the selected schema's
credentialSubject fields.
CSV source
Chip โ CSV upload โ upload the file โ Upload & issue.
| DPG ยท Schema (step 3 + 4) | File | Expect |
|---|---|---|
| Walt ยท Mortgage Eligibility (jwt_vc_json) | csv/mortgage-simple.csv |
10 rows ยท 10 issued |
| Walt ยท Mortgage Eligibility (jwt_vc_json) | csv/mortgage-simple-large.csv |
50 rows ยท 50 issued |
| Walt ยท Mortgage Eligibility (jwt_vc_json) | csv/mortgage-eligibility.csv |
10 rows ยท 10 issued |
| Walt ยท Verifiable Id (jwt_vc_json) | csv/verifiable-id.csv |
10 rows ยท 10 issued |
| Walt ยท Hotel Reservation (jwt_vc_json) | csv/hotel-reservation.csv |
10 rows ยท 10 issued |
| Walt ยท Tax Receipt (jwt_vc_json) | csv/tax-receipt.csv |
10 rows ยท 10 issued |
| Inji Certify Pre-Auth ยท Farmer Credential (V2) | csv/farmer-credential.csv |
10 rows ยท 10 issued |
| any | csv/malformed-bad-quoting.csv |
โ red error toast (parse failure) |
| Walt ยท Verifiable Id | csv/malformed-wrong-columns.csv |
Mixed โ per-row rejections visible in the table |
| Walt ยท Mortgage Eligibility | csv/header-only-no-rows.csv |
โ "no rows" error |
Secured API source
Note: The Secured API chip is hidden when the Issuer DPG is
set to Inji Certify ยท Pre-Auth. Per docs.inji.io,
Inji Certify's Data Provider Plugin currently supports PostgreSQL + CSV
only; an "API" reference implementation is listed as a 2025 roadmap
item. The verifiably-go UI reflects that by gating the chip via
the DPG'sKind:"bulk_source"capabilities.
Chip โ Secured API โ paste the URL โ leave auth header blank โ
Fetch & issue.
Use the URL that matches where the VC platform is running, not where
your laptop is:
- VC platform in docker on the same host (usual): use the service
name directly on thewaltid_defaultnetwork โ
http://ministry-citizens-service:8099/... - VC platform on bare metal (
go run ./cmd/server):
http://localhost:8199/... - VC platform on a different machine than the ministry:
http://<ministry-host>:8199/...
All five endpoints, paste-ready (same-host-docker flavour):
| Schema pick (step 4) | URL | Expect |
|---|---|---|
| Mortgage Eligibility (jwt_vc_json) | http://ministry-citizens-service:8099/api/mortgage-simple?limit=10 |
10 rows ยท 10 issued |
| Mortgage Eligibility (jwt_vc_json) | http://ministry-citizens-service:8099/api/mortgage-eligibility?limit=10 |
10 rows ยท 10 issued |
| Verifiable Id (jwt_vc_json) | http://ministry-citizens-service:8099/api/verifiable-id?limit=15 |
15 rows ยท 15 issued |
| Hotel Reservation (jwt_vc_json) | http://ministry-citizens-service:8099/api/hotel-reservation?limit=10 |
10 rows ยท 10 issued |
custom schema {holder, farmId, ...} |
http://ministry-citizens-service:8099/api/farmer-id?limit=10 |
10 rows ยท 10 issued |
Bearer-auth variant โ bring the service up with a token:
cd testdata/bulk-issuance
docker compose down citizens-service
CITIZENS_API_TOKEN=hunter2 docker compose up -d citizens-service
Now the same URL with the auth field blank returns HTTP 401 (the bulk
form shows the raw response), and with the field set to Bearer hunter2
it works again.
Error-path URLs to paste (same recipe, bad URLs):
| URL | Expect |
|---|---|
http://ministry-citizens-service:8099/api/does-not-exist |
โ HTTP 404 in toast |
http://localhost:8199/api/mortgage-simple?limit=5 |
โ connection refused โ wrong hostname (inside-docker localhost โ host) |
| stop the service, re-issue | โ connection refused from the adapter |
Database source
Chip โ Database โ paste the connection string + query โ Submit.
Connection strings
Pick the one that matches where the VC platform runs:
| VC platform location | DSN |
|---|---|
| In docker on same host (usual) | postgres://citizens:citizens@ministry-citizens-db:5432/citizens |
Bare metal go run |
postgres://citizens:citizens@localhost:5437/citizens |
| Different host | postgres://citizens:citizens@<ministry-host>:5437/citizens |
Paste-ready queries (one per schema)
Simple holder-only โ works with any one-field custom schema, or with
Mortgage Eligibility if you only care about the holder column:
SELECT first_name || ' ' || last_name AS holder
FROM citizens
ORDER BY id
LIMIT 10;
MortgageEligibility (walt.id catalog, 10 fields):
SELECT
CASE gender WHEN 'Male' THEN 'Mr' WHEN 'Female' THEN 'Mrs' ELSE '' END AS salutation,
first_name AS "firstName",
last_name AS "familyName",
email AS "emailAddress",
date_of_birth::text AS "dateOfBirth",
(400000 + (id * 1750) % 500000)::text AS "purchasePrice",
(60000 + (id * 320) % 120000)::text AS "totalIncome",
(320000 + (id * 1400) % 400000)::text AS "mortgageAmount",
CASE (id % 4) WHEN 0 THEN 'none' WHEN 1 THEN 'vehicle' WHEN 2 THEN 'savings' ELSE 'shares' END AS "additionalCollateral",
LPAD((id * 37)::text, 5, '0') AS "postCodeProperty"
FROM citizens
ORDER BY id
LIMIT 10;
VerifiableId (walt.id catalog, 8 fields):
SELECT
first_name AS "firstName",
last_name AS "familyName",
date_of_birth::text AS "dateOfBirth",
gender AS gender,
place_of_birth AS "placeOfBirth",
address AS "currentAddress",
national_id AS "personalIdentifier",
first_name || ' ' || last_name AS "nameAndFamilyNameAtBirth"
FROM citizens
WHERE address IS NOT NULL
ORDER BY id
LIMIT 15;
HotelReservation (walt.id catalog, 5 fields):
SELECT
first_name AS "firstName",
last_name AS "familyName",
date_of_birth::text AS "dateOfBirth",
place_of_birth AS "placeOfBirth",
'Suite ' || (100 + id % 400)::text || ', Hotel Sample' AS "currentAddress"
FROM citizens
ORDER BY id
LIMIT 10;
University degree โ only citizens who actually have a degree in the
seed data (match with a custom schema that has fields
holder, degree, major, graduationDate, issuer, gpa):
SELECT
first_name || ' ' || last_name AS holder,
degree_type AS degree,
major AS major,
graduation_date::text AS "graduationDate",
university AS issuer,
COALESCE(gpa::text, '') AS gpa
FROM citizens
WHERE university IS NOT NULL
ORDER BY id
LIMIT 15;
Farmer ID โ only registered farmers (match with a custom schema with
fields holder, farmId, location, hectares, crops, registeredOn):
SELECT
first_name || ' ' || last_name AS holder,
farm_id AS "farmId",
farm_location AS location,
COALESCE(farm_size_hectares::text, '') AS hectares,
COALESCE(primary_crops, '') AS crops,
farm_registration_date::text AS "registeredOn"
FROM citizens
WHERE farm_id IS NOT NULL
ORDER BY id
LIMIT 10;
Inji Certify ยท Farmer Credential (V2) โ 13 fields matching the live
Inji Certify instance's /v1/certify/.well-known/openid-credential-issuer
order array. Only includes citizens registered as farmers. Pair with
the Farmer Credential (V2) schema in the Inji Certify Pre-Auth DPG:
SELECT
first_name || ' ' || last_name AS "fullName",
COALESCE(phone, '+254000000000') AS "mobileNumber",
date_of_birth::text AS "dateOfBirth",
gender AS gender,
CASE country_code WHEN 'KE' THEN 'Kenya' WHEN 'TT' THEN 'Trinidad & Tobago' ELSE country_code END AS state,
place_of_birth AS district,
split_part(farm_location, ' from ', 2) AS "villageOrTown",
'00100' AS "postalCode",
COALESCE(farm_size_hectares::text, '1.0') AS "landArea",
'owned' AS "landOwnershipType",
COALESCE(split_part(primary_crops, ',', 1), 'Maize') AS "primaryCropType",
COALESCE(split_part(primary_crops, ',', 2), 'Beans') AS "secondaryCropType",
farm_id AS "farmerID"
FROM citizens
WHERE farm_id IS NOT NULL
ORDER BY id
LIMIT 10;
Error-path queries
Zero rows โ expect โ query returned 0 rows:
SELECT first_name || ' ' || last_name AS holder
FROM citizens
WHERE country_code = 'ZZ';
Non-SELECT โ blocked by bulk.go before it reaches postgres, expect
โ only SELECT queries allowed:
DELETE FROM citizens WHERE id < 0;
Bad DSN โ paste postgres://citizens:wrong@ministry-citizens-db:5432/citizens
with any valid query, expect โ password authentication failed.
After issuance โ what each row gives you
The result screen shows a table: row #, recipient name, โ/โ status,
full selectable offer URI, and per-row Copy link / QR buttons.
Plus at the bottom:
- Copy all offer links โ TSV (recipient โน offer URI) to the clipboard
- Download CSV โ audit file with
row, recipient, status, offer_uri, error
To verify a credential actually lands in a wallet:
- Copy any offer URI from the table.
- Sign out (top-right icon) or open a private browser window.
- Log in as Holder (keycloak admin/admin). Pick Walt Community Stack.
- Holder โ Wallet โ Paste offer link, paste, accept.
- The held credential's claim values should match the row's data
(holder: Grace Atieno, etc.).
Scripted regression
From the verifiably-go/ root:
BASE=http://172.24.0.1:8080 CSV=mortgage-simple.csv node e2e/walkBulkCSV.mjs
BASE=http://172.24.0.1:8080 API='http://ministry-citizens-service:8099/api/mortgage-simple?limit=5' \
node e2e/walkBulkAPI.mjs
BASE=http://172.24.0.1:8080 CONN='postgres://citizens:citizens@ministry-citizens-db:5432/citizens' \
QUERY="SELECT first_name || ' ' || last_name AS holder FROM citizens ORDER BY id LIMIT 5" \
node e2e/walkBulkDB.mjs
BASE=http://172.24.0.1:8080 node e2e/assert-bulk-ui.mjs
All four scripts exit non-zero on regression.
One-line row-count sanity
wc -l testdata/bulk-issuance/csv/*.csv