Skip to content

Local Development Runbook

Start

bash
cd go-platform
make dev

The compose stack starts:

  • PostgreSQL with service databases
  • service-owned Postgres roles such as hoctapaz_iam, hoctapaz_profile, and hoctapaz_question_bank; runtime services and migration jobs must not use the shared bootstrap role
  • one-shot *-migrations jobs for every service that has SQL migrations; each DB-backed service waits for its own migration job before starting
  • Redis
  • NATS
  • MinIO
  • Go Formula DOCX parser runtime on localhost:8095
  • API Gateway on localhost:8085
  • Go microservices from scripts/dev/services.sh

Check the Compose migration wiring after changing deploy/docker-compose.yml:

bash
make test-compose-migrations

To inspect the five authenticated frontend ports and every Compose-published Go runtime without changing process state, run:

bash
make local-dev-status

The check includes iam-service through classroom-service on 8081-8084, the API Gateway on 8085, course/import/document/question/exam/attempt/ notification/analytics/admin on 8086-8094, go-formula-docx on 8095, payment/wallet/billing/usage on 8096-8099, search/audit/BFF on 8100-8102, and ai-classifier-service on 8103. The Formula runtime uses GET /healthz; /health and /readyz are not its published health contract.

Stop

bash
make dev-down

Logs

bash
make dev-logs

Smoke Test

bash
BASE_URL=http://localhost:8085 scripts/test/smoke.sh

Storage MinIO E2E Smoke

scripts/test/storage-e2e.sh verifies the complete document-service object path:

  1. request /presigned-upload
  2. PUT bytes to the returned signed URL
  3. create /media-assets metadata
  4. read /media-assets/{id}/content
  5. compare downloaded bytes with the uploaded payload

Against Docker Compose document-service:

bash
cd go-platform
STORAGE_BASE_URL=http://localhost:8088 \
STORAGE_API_PREFIX=/v1/storage \
scripts/test/storage-e2e.sh

Against API Gateway with routes.storage-native-localhost-example.json:

bash
STORAGE_BASE_URL=http://localhost:8085 \
STORAGE_API_PREFIX=/api/storage \
scripts/test/storage-e2e.sh

scripts/test/storage-media-parity-smoke.sh compares a real legacy media asset read with the native document-service read path without mutating data:

bash
STORAGE_PARITY_MEDIA_ASSET_ID=<legacy-media-asset-id> \
STORAGE_PARITY_AUTHORIZATION='Bearer <token>' \
STORAGE_PARITY_ORGANIZATION_ID=<org-id> \
STORAGE_PARITY_NATIVE_MODE=auto \
scripts/test/storage-media-parity-smoke.sh

Use STORAGE_PARITY_NATIVE_MODE=storage-key before native media metadata is backfilled, or STORAGE_PARITY_NATIVE_MODE=asset-id plus STORAGE_PARITY_VARIANTS=original,trim,formula when the same media asset id is available in document-service.

DOCX Media Materialization Runtime Check

scripts/test/docx-materialization-parity.sh verifies that the live DOCX import path can materialize real corpus images through document-service:

  1. health-check Go Formula DOCX, docx-import-service, and document-service
  2. import the Physics 28Q corpus DOCX through /v1/import/docx/qas
  3. assert 28 questions and 7 renderable images
  4. assert all 7 images are rewritten to /api/storage/media-assets/{id}/content
  5. read each materialized asset back from document-service

For a repeatable parser-to-materializer check without starting the import and document services, run the isolated Physics 28Q integration against the local Go Formula runtime and the read-only corpus:

bash
GO_FORMULA_DOCX_URL=http://127.0.0.1:8095 \
HOCTAPAZ_DOCX_CORPUS_DIR=/Users/velikho/Desktop/test-hoctapaz \
DOCX_MATERIALIZATION_SELF_TEST=1 \
make test-docx-materialization

This verifies parser output, extraction of the seven renderable DOCX images, and owner-scoped media-reference rewriting with an in-memory uploader. It does not replace the live document-service read-back smoke below.

Against Docker Compose services, start the import/storage pair and the internal Go Formula DOCX runtime with a container-facing presigned upload endpoint. The host-facing default http://localhost:9902 is correct for storage-e2e.sh, but docx-import-service needs the signed URL to resolve from inside the Compose network:

bash
cd go-platform
S3_PRESIGNED_UPLOAD_ENDPOINT=http://minio:9000 \
docker compose -f deploy/docker-compose.yml up -d --build go-formula-docx document-service docx-import-service

# One-time for a fresh local hoctapaz_document_db when goose is unavailable.
awk '/^-- \+goose Down/{exit} {print}' services/document-service/migrations/000001_init.sql \
  | docker compose -f deploy/docker-compose.yml exec -T postgres-local \
      psql -U hoctapaz -d hoctapaz_document_db
awk '/^-- \+goose Down/{exit} {print}' services/document-service/migrations/000002_media_assets.sql \
  | docker compose -f deploy/docker-compose.yml exec -T postgres-local \
      psql -U hoctapaz -d hoctapaz_document_db

GO_FORMULA_DOCX_URL=http://localhost:8095 \
DOCX_IMPORT_SERVICE_URL=http://localhost:8087 \
DOCUMENT_SERVICE_URL=http://localhost:8088 \
HOCTAPAZ_DOCX_CORPUS_DIR=/Users/velikho/Desktop/test-hoctapaz \
make test-docx-materialization

Compose sets docx-import-service GO_FORMULA_DOCX_URL to http://go-formula-docx:8080 by default. Override it only when testing an external parser process.

DOCX Import Realtime Fanout

docx-import-service uses in-process SSE fanout when REDIS_URL is empty. In Docker Compose and local K8s, REDIS_URL is set and the service also publishes redacted job lifecycle updates to Redis pub/sub so multiple service processes can share job.updated events.

Native DOCX job uploads are parsed by a bounded in-process worker queue. While a job is waiting or active, the legacy-compatible SSE/list summary includes job.queue fields such as active, waiting, waitingPosition, and jobsAhead.

Queued uploads are written to DOCX_IMPORT_PAYLOAD_DIR before the job is persisted. The worker stores only an opaque payload reference on the job row and deletes the file after a terminal outcome. On startup, the service re-enqueues stored PENDING/PROCESSING jobs if the payload still exists, or fails them with DOCX_IMPORT_PAYLOAD_MISSING if it does not. Docker Compose uses a named volume. The offline K8s manifest and Helm chart mount a docx-import-payloads PersistentVolumeClaim by default so queued payload refs survive pod replacement in local clusters that provide dynamic storage.

Default settings:

bash
DOCX_IMPORT_EVENTS_CHANNEL=docx-import:jobs
DOCX_IMPORT_WORKER_CONCURRENCY=2
DOCX_IMPORT_PAYLOAD_DIR=/var/lib/hoctapaz/docx-import-payloads
GO_FORMULA_DOCX_URL=http://go-formula-docx:8080

Validate the payload persistence deployment wiring with:

bash
make test-docx-payload-persistence

When comparing native DOCX status reads against a legacy worker stack, enable the read-only BullMQ bridge so pending native summaries can report the legacy exam-import-algorithm queue snapshot if the job is not in the native in-process queue:

bash
DOCX_IMPORT_BULLMQ_BRIDGE_ENABLED=1
DOCX_IMPORT_BULLMQ_REDIS_URL=redis://host.docker.internal:6388
DOCX_IMPORT_BULLMQ_QUEUE_NAME=exam-import-algorithm
DOCX_IMPORT_BULLMQ_QUEUE_PREFIX=bull

The bridge reads BullMQ Redis keys only. It does not enqueue, remove, retry, or claim legacy jobs. Validate the deployment wiring with:

bash
make test-docx-bullmq-bridge

Validate a live pending/processing job row through the non-default status route table with:

bash
DOCX_BULLMQ_STATUS_JOB_ID=<job-id> \
DOCX_BULLMQ_STATUS_AUTHORIZATION='Bearer <token>' \
DOCX_BULLMQ_STATUS_ORGANIZATION_ID=<org-id> \
DOCX_BULLMQ_STATUS_EXPECT_QUEUE_STATUS=waiting \
make test-docx-bullmq-status

The live smoke reads GET /api/exam-import/teacher-library?q=<jobId> and asserts the row exposes legacy-shaped BullMQ queue fields. It does not mutate Redis or the import job.

Validate the real browser route with:

bash
DOCX_IMPORT_LIBRARY_BROWSER_AUTHORIZATION='Bearer <token>' \
DOCX_IMPORT_LIBRARY_BROWSER_ORGANIZATION_ID=<org-id> \
make test-docx-import-library-browser

Validate the static import-status route-table safety rules with:

bash
make test-import-status-routes

Validate the non-default DOCX Fast create route through a live gateway only when a DOCX has already been uploaded to document-service/MinIO and the gateway is running with routes.import-create-native-localhost-example.json:

bash
IMPORT_CREATE_LIVE_CONFIRM=create-native \
IMPORT_CREATE_LIVE_STORAGE_KEY=<uploaded-docx-storage-key> \
IMPORT_CREATE_LIVE_FILE_NAME=<file-name.docx> \
IMPORT_CREATE_LIVE_AUTHORIZATION='Bearer <token>' \
IMPORT_CREATE_LIVE_ORGANIZATION_ID=<org-id> \
make test-import-create-live

The live smoke writes one native DOCX Fast job, validates gateway route headers, checks the legacy success envelope, and then confirms the sibling job detail path still routes through the broad legacy import route. It does not run without IMPORT_CREATE_LIVE_CONFIRM=create-native.

Validate the materialized DOCX Fast temp-draft read branch through the same non-default route table only when the target native DOCX Fast job is completed and has stored parse output:

bash
IMPORT_TEMP_DRAFT_JOB_ID=<completed-native-docx-fast-job-id> \
IMPORT_TEMP_DRAFT_AUTHORIZATION='Bearer <token>' \
IMPORT_TEMP_DRAFT_ORGANIZATION_ID=<org-id> \
make test-import-temp-draft-live

The live smoke validates /v1/routes, confirms a sibling temp-draft asset metadata path still routes through the broad legacy import route, reads the temp-draft, checks gateway route headers, and asserts the legacy materialized draft envelope with token materialized-{id}.

Validate materialized DOCX Fast temp asset content through the same non-default route table only when the target native DOCX Fast job is completed and the asset id is present in the stored parse result:

bash
IMPORT_TEMP_ASSET_JOB_ID=<completed-native-docx-fast-job-id> \
IMPORT_TEMP_ASSET_ID=<materialized-temp-asset-id> \
IMPORT_TEMP_ASSET_AUTHORIZATION='Bearer <token>' \
IMPORT_TEMP_ASSET_ORGANIZATION_ID=<org-id> \
make test-import-temp-asset-live

The live smoke validates /v1/routes, confirms a sibling temp-draft asset metadata path still routes through the broad legacy import route, streams the asset content, checks gateway route headers, and asserts non-empty binary content with Content-Type plus Cache-Control: private, max-age=300.

Validate DOCX Fast reprocess through the same non-default route table only when the target native DOCX Fast job has a stored source DOCX and is not active in the native worker queue:

bash
IMPORT_REPROCESS_CONFIRM=reprocess-native \
IMPORT_REPROCESS_JOB_ID=<native-docx-fast-job-id> \
IMPORT_REPROCESS_AUTHORIZATION='Bearer <token>' \
IMPORT_REPROCESS_ORGANIZATION_ID=<org-id> \
make test-import-reprocess-live

The live smoke validates /v1/routes, confirms a sibling import job detail path still routes through the broad legacy import route, posts the reprocess request, checks gateway route headers, and asserts the legacy success envelope preserves the same job id with message DOCX Fast import reprocessed.

For browser-route evidence from the real import surface, start the web app with NEXT_PUBLIC_API_URL pointing at the gateway and run either UI-upload mode:

bash
IMPORT_CREATE_BROWSER_AUTHORIZATION='Bearer <token>' \
IMPORT_CREATE_BROWSER_ORGANIZATION_ID=<org-id> \
IMPORT_CREATE_BROWSER_DOCX_FILE=/Users/velikho/Desktop/test-hoctapaz/sample.docx \
make test-import-create-browser

or storage-key mode when the DOCX object already exists:

bash
IMPORT_CREATE_BROWSER_AUTHORIZATION='Bearer <token>' \
IMPORT_CREATE_BROWSER_ORGANIZATION_ID=<org-id> \
IMPORT_CREATE_BROWSER_STORAGE_KEY=<uploaded-docx-storage-key> \
IMPORT_CREATE_BROWSER_FILE_NAME=sample.docx \
make test-import-create-browser

Use IMPORT_CREATE_BROWSER_PATH=/teacher/questions/import plus IMPORT_CREATE_BROWSER_AUTO_APPROVE=1 to validate the question-bank bulk import surface without changing frontend source.

To search for a specific BullMQ-backed job from the page:

bash
DOCX_IMPORT_LIBRARY_BROWSER_AUTHORIZATION='Bearer <token>' \
DOCX_IMPORT_LIBRARY_BROWSER_ORGANIZATION_ID=<org-id> \
DOCX_IMPORT_LIBRARY_BROWSER_JOB_ID=<job-id> \
DOCX_IMPORT_LIBRARY_BROWSER_EXPECT_QUEUE_STATUS=waiting \
make test-docx-import-library-browser

Rollback for the Redis layer is removing REDIS_URL or pointing DOCX_IMPORT_EVENTS_CHANNEL at an unused channel. Stored-job replay and local process fanout still work.

Legacy Proxy

Gateway proxies legacy routes to LEGACY_API_BASE_URL, defaulting to http://localhost:4001.

Storage Native Route Smoke

The default route table keeps /api/storage/* on legacy. To test the strangler path locally without frontend changes, run the gateway with the storage native example:

bash
cd go-platform
GATEWAY_ROUTE_TABLE=deploy/gateway/routes.storage-native-localhost-example.json \
LEGACY_API_BASE_URL=http://localhost:4001 \
HTTP_ADDR=:8085 \
go run ./services/api-gateway/cmd/server

Run document-service separately on :8097. In Docker Compose, the container-facing deploy/gateway/routes.storage-native-example.json is the strict v2 variant: unmatched routes return 404, and browser storage writes use the multipart control plane instead of a legacy fallback.

Rollback is changing GATEWAY_ROUTE_TABLE back to deploy/gateway/routes.json or setting the storage route state to legacy_proxy.

Question Type DB Read Smoke

For a fresh local hoctapaz_question_bank_db, apply the service migrations and run the native read endpoint with Postgres enabled:

bash
cd go-platform
awk '/^-- \+goose Down/{exit} {print}' services/question-bank-service/migrations/000001_init.sql \
  | docker compose -f deploy/docker-compose.yml exec -T postgres-local \
      psql -U hoctapaz_question_bank -d hoctapaz_question_bank_db
awk '/^-- \+goose Down/{exit} {print}' services/question-bank-service/migrations/000002_question_type_definitions.sql \
  | docker compose -f deploy/docker-compose.yml exec -T postgres-local \
      psql -U hoctapaz_question_bank -d hoctapaz_question_bank_db
awk '/^-- \+goose Down/{exit} {print}' services/question-bank-service/migrations/000003_question_core.sql \
  | docker compose -f deploy/docker-compose.yml exec -T postgres-local \
      psql -U hoctapaz_question_bank -d hoctapaz_question_bank_db

DATABASE_URL=postgres://hoctapaz_question_bank:hoctapaz_question_bank@localhost:5433/hoctapaz_question_bank_db?sslmode=disable \
HTTP_ADDR=:8098 \
go run ./services/question-bank-service/cmd/server

curl -sS -H 'X-Organization-Id: org_local' \
  'http://localhost:8098/v1/question-types?summary=1'

curl -sS -X POST \
  -H 'Content-Type: application/json' \
  -H 'X-Organization-Id: org_local' \
  -H 'X-User-Id: teacher_local' \
  -H 'X-User-Role: TEACHER' \
  --data '{"code":"LOCAL_CUSTOM","name":"Loại local","baseType":"ESSAY"}' \
  'http://localhost:8098/v1/question-types'

curl -sS -X POST \
  -H 'Content-Type: application/json' \
  -H 'X-Organization-Id: org_local' \
  -H 'X-User-Id: teacher_local' \
  --data '{"document_id":"doc_local","questions":[{"id":"q1","type":"SINGLE_CHOICE","stem_text":"Question","options":[{"id":"A","content":"A"},{"id":"B","content":"B"}],"correct_answer":{"option_id":"A"},"solution_html":"Solution","media":[],"formulas":[{"id":"f1","latex":"x^2","html":"<span>x</span>"}]}]}' \
  'http://localhost:8098/v1/questions/import-docx-output'

curl -sS -H 'X-Organization-Id: org_local' \
  'http://localhost:8098/v1/questions?page=1&limit=5&q=Question&type=SINGLE_CHOICE&unclassifiedOnly=1'

The default gateway route table keeps /api/question-types on legacy. Shadow question-type route tables match only exact GET /api/question-types. Native question-type route tables match exact GET plus POST/PATCH/DELETE write methods and keep unlisted methods plus nested GET manager routes on the broad legacy fallback.

Run the static route-table preflight before local gateway rehearsal:

bash
cd go-platform
make test-question-types-routes
QUESTION_TYPES_SELF_TEST=1 make test-question-types-live

Use deploy/gateway/routes.question-types-shadow-localhost-example.json for shadow-read comparison, or deploy/gateway/routes.question-types-native-localhost-example.json for native read/write rehearsal with gateway auth enabled. Full default cutover remains blocked until /teacher/questions/types, import-editor consumers, and browser parity are proven.

After starting the gateway with the native localhost route table and preparing a valid legacy auth token or session cookie, run the opt-in live write smoke:

bash
QUESTION_TYPES_LIVE_CONFIRM=write-native \
QUESTION_TYPES_AUTHORIZATION='Bearer <token>' \
QUESTION_TYPES_ORGANIZATION_ID=org_local \
GATEWAY_BASE_URL=http://localhost:8085 \
make test-question-types-live

The script creates a unique custom question type, updates it, archives it via bulk status, hard-deletes it via bulk delete, and stores request/response artifacts in a temporary directory unless QUESTION_TYPES_ARTIFACT_DIR is set.

AI Classification Apply Gateway Smoke

The default gateway route table keeps /api/questions/ai-classify/* on legacy. Use deploy/gateway/routes.question-classification-apply-native-localhost-example.json only for the non-default apply-route rehearsal. This route table carves out exact PATCH /api/questions/ai-classify/apply while keeping job/status/SSE classification routes on the broad legacy questions fallback.

Run static route-table coverage before any live write rehearsal:

bash
cd go-platform
make test-question-classification-apply-routes
make test-ai-classification-job-route-guard

Run the hermetic assertion self-test first:

bash
cd go-platform
QUESTION_CLASSIFICATION_APPLY_SELF_TEST=1 make test-question-classification-apply-live

After starting the gateway with the native apply route table and preparing a native question row plus legacy auth token or session cookie, run:

bash
QUESTION_CLASSIFICATION_APPLY_CONFIRM=apply-native \
QUESTION_CLASSIFICATION_APPLY_QUESTION_ID=<native-question-id> \
QUESTION_CLASSIFICATION_APPLY_AUTHORIZATION='Bearer <token>' \
QUESTION_CLASSIFICATION_APPLY_ORGANIZATION_ID=<org-id> \
GATEWAY_BASE_URL=http://localhost:8085 \
make test-question-classification-apply-live

The smoke validates /v1/routes, confirms GET /api/questions/ai-classify/jobs?limit=1 still routes through broad legacy questions, applies the requested classification suggestion through the gateway, and checks the legacy-shaped success envelope.

Keep the job guard in place until ai-classifier-service proves BullMQ worker, taxonomy-rich prompt context, provider runtime, gateway RBAC, and browser parity. The guard allows the exact apply route above, but blocks public native routes for /api/questions/ai-classify/suggestions, /api/questions/ai-classify/jobs*, job SSE, job error history, and cancel.

Run the self-testable live legacy-route guard when checking a running gateway:

bash
AI_CLASSIFICATION_JOB_SELF_TEST=1 make test-ai-classification-job-live

Against a live gateway, provide a token or cookie:

bash
AI_CLASSIFICATION_JOB_LIVE_CONFIRM=legacy-job-routes \
AI_CLASSIFICATION_JOB_AUTHORIZATION='Bearer <token>' \
GATEWAY_BASE_URL=http://localhost:8085 \
make test-ai-classification-job-live

Set AI_CLASSIFICATION_JOB_ID=<legacy-job-id> only when you also want to check the SSE route headers. The script does not call cancel because cancel mutates job state; native cancel promotion needs a separate disposable-fixture smoke.

Question Read Shadow Parity Smoke

scripts/test/question-read-shadow-parity.sh verifies that the gateway question-read shadow route table keeps the client response identical to legacy while also checking the native question-bank-service compatibility adapter at /v1/legacy/questions.

Run the static route-table preflight first:

bash
cd go-platform
make test-question-read-routes

Start question-bank-service with Postgres on :8098 and start the gateway with the question read shadow route table:

bash
cd go-platform
DATABASE_URL=postgres://hoctapaz_question_bank:hoctapaz_question_bank@localhost:5433/hoctapaz_question_bank_db?sslmode=disable \
HTTP_ADDR=:8098 \
go run ./services/question-bank-service/cmd/server

GATEWAY_ROUTE_TABLE=deploy/gateway/routes.questions-read-shadow-localhost-example.json \
LEGACY_API_BASE_URL=http://localhost:4001 \
HTTP_ADDR=:8085 \
go run ./services/api-gateway/cmd/server

Then run:

bash
QUESTION_READ_PARITY_QUESTION_ID=<id-present-in-legacy-and-native> \
QUESTION_READ_PARITY_ORGANIZATION_ID=<org-id> \
QUESTION_READ_PARITY_USER_ID=<teacher-id> \
QUESTION_READ_PARITY_USER_ROLE=TEACHER \
make test-question-read-shadow

Use QUESTION_READ_PARITY_REQUIRE_NATIVE=0 only for a route-table/client-response smoke before native data has been backfilled. Cutover evidence should keep native checks enabled. The smoke expects shadow routes to rewrite to /v1/legacy/questions by default; set QUESTION_READ_PARITY_SHADOW_TARGET_PREFIX=/v1/questions only for older raw-native route-table experiments.

Rollback is changing GATEWAY_ROUTE_TABLE back to deploy/gateway/routes.json or setting questions-list and questions-detail to legacy_proxy.

To rehearse native question reads through the gateway after native data has been backfilled, use the non-default native route table instead:

bash
GATEWAY_ROUTE_TABLE=deploy/gateway/routes.questions-read-native-localhost-example.json \
LEGACY_API_BASE_URL=http://localhost:4001 \
AUTH_JWT_SECRET=<same-secret-as-legacy-token> \
HTTP_ADDR=:8085 \
go run ./services/api-gateway/cmd/server

This routes only authenticated GET /api/questions and GET /api/questions/:id to the compatibility adapter in question-bank-service. POST /api/questions, AI classification job routes, folders, groups, and other nested question routes still use the broad legacy fallback. Rollback is switching back to deploy/gateway/routes.json.

Then compare gateway native responses with direct adapter responses:

bash
QUESTION_READ_NATIVE_AUTHORIZATION='Bearer <access-token>' \
QUESTION_READ_NATIVE_ORGANIZATION_ID=<org-id> \
QUESTION_READ_NATIVE_USER_ID=<same-user-id-as-token> \
QUESTION_READ_NATIVE_USER_ROLE=TEACHER \
QUESTION_READ_NATIVE_QUESTION_ID=<id-present-in-native> \
make test-question-read-native

The native parity smoke also checks that writes and nested question routes still report the broad legacy-proxy route.

For browser-route evidence, start the web app with NEXT_PUBLIC_API_URL pointing at the gateway and run:

bash
QUESTION_READ_BROWSER_AUTHORIZATION='Bearer <teacher-or-admin-token>' \
QUESTION_READ_BROWSER_ORGANIZATION_ID=<org-id> \
make test-question-read-browser

Set QUESTION_READ_BROWSER_DETAIL_ID=auto and QUESTION_READ_BROWSER_REQUIRE_ITEMS=1 when the local question read model has real rows and the editor-detail path must be verified through the browser.

For admin all-organization browser evidence, use an admin token:

bash
QUESTION_READ_BROWSER_AUTHORIZATION='Bearer <admin-token>' \
QUESTION_READ_BROWSER_ORGANIZATION_ID=<org-id> \
make test-question-read-browser-admin

Auth Route Rehearsal

The default gateway route table keeps /api/auth/* on legacy. Use deploy/gateway/routes.auth-native-localhost-example.json only for a non-default native session rehearsal.

Run the static route-table preflight first:

bash
cd go-platform
make test-auth-routes

Start iam-service on :8081 and the gateway with the non-default route table:

bash
cd go-platform
AUTH_JWT_SECRET=<shared-local-secret> \
HTTP_ADDR=:8081 \
go run ./services/iam-service/cmd/server

GATEWAY_ROUTE_TABLE=deploy/gateway/routes.auth-native-localhost-example.json \
LEGACY_API_BASE_URL=http://localhost:4001 \
HTTP_ADDR=:8085 \
go run ./services/api-gateway/cmd/server

The route table rehearses only register, login, refresh, logout, and me. Google auth, test-login, forgot/reset password, profile, KYC, email-change, and other auth siblings stay on broad legacy auth routing. Rollback is switching GATEWAY_ROUTE_TABLE back to deploy/gateway/routes.json.

Profile Route Rehearsal

The default gateway route table keeps /api/auth/me/profile* on legacy. Use deploy/gateway/routes.profile-native-localhost-example.json only for a non-default current-user profile update rehearsal.

Run the static route-table preflight first:

bash
cd go-platform
make test-profile-routes

Start profile-service on :8082 and the gateway with the non-default route table:

bash
cd go-platform
HTTP_ADDR=:8082 \
go run ./services/profile-service/cmd/server

AUTH_JWT_SECRET=<shared-local-secret> \
GATEWAY_ROUTE_TABLE=deploy/gateway/routes.profile-native-localhost-example.json \
LEGACY_API_BASE_URL=http://localhost:4001 \
HTTP_ADDR=:8085 \
go run ./services/api-gateway/cmd/server

The route table rehearses only:

  • PATCH /api/auth/me/profile
  • PATCH /api/auth/me/teacher-profile
  • PATCH /api/auth/me/student-profile
  • PATCH /api/auth/me/parent-profile

The gateway verifies the JWT, injects X-User-* headers, rewrites {userId} to the authenticated account id, and proxies to profile-service. /api/auth/me, KYC, Google auth, test-login, forgot/reset password, email-change, and other auth siblings stay on broad legacy auth routing. Rollback is switching GATEWAY_ROUTE_TABLE back to deploy/gateway/routes.json.

Profile KYC Route Rehearsal

The default gateway route table keeps /api/auth/me/kyc/teacher on legacy. Use deploy/gateway/routes.profile-kyc-native-localhost-example.json only for a non-default teacher self-service KYC rehearsal.

Run the static route-table preflight first:

bash
cd go-platform
make test-profile-routes

Start profile-service on :8082 and the gateway with the non-default KYC route table:

bash
cd go-platform
HTTP_ADDR=:8082 \
go run ./services/profile-service/cmd/server

AUTH_JWT_SECRET=<shared-local-secret> \
GATEWAY_ROUTE_TABLE=deploy/gateway/routes.profile-kyc-native-localhost-example.json \
LEGACY_API_BASE_URL=http://localhost:4001 \
HTTP_ADDR=:8085 \
go run ./services/api-gateway/cmd/server

The route table rehearses only exact teacher self-service KYC calls:

  • GET /api/auth/me/kyc/teacher
  • POST /api/auth/me/kyc/teacher

The gateway verifies the JWT, requires the TEACHER role, rewrites {userId} to the authenticated account id, and proxies to /v1/users/{userId}/teacher-kyc. KYC child/sibling paths, /api/admin/users/kyc/teacher*, broader /api/auth, and fallback routes stay on legacy routing. Rollback is switching GATEWAY_ROUTE_TABLE back to deploy/gateway/routes.json.

Profile Admin KYC Route Rehearsal

The default gateway route table keeps /api/admin/users/kyc/teacher* on legacy. Use deploy/gateway/routes.profile-admin-kyc-native-localhost-example.json only for a non-default admin teacher KYC review rehearsal.

Run the static route-table preflight first:

bash
cd go-platform
make test-profile-routes

Start profile-service on :8082 and the gateway with the non-default admin KYC route table:

bash
cd go-platform
HTTP_ADDR=:8082 \
go run ./services/profile-service/cmd/server

AUTH_JWT_SECRET=<shared-local-secret> \
GATEWAY_ROUTE_TABLE=deploy/gateway/routes.profile-admin-kyc-native-localhost-example.json \
LEGACY_API_BASE_URL=http://localhost:4001 \
HTTP_ADDR=:8085 \
go run ./services/api-gateway/cmd/server

The route table rehearses only admin teacher KYC review calls:

  • GET /api/admin/users/kyc/teacher
  • GET /api/admin/users/kyc/teacher/{userId}
  • POST /api/admin/users/kyc/teacher/{userId}/approve
  • POST /api/admin/users/kyc/teacher/{userId}/reject

The gateway verifies the JWT, requires the ADMIN role, preserves review query/body payloads, and proxies to /v1/admin/users/kyc/teacher*. Self-service KYC, broader /api/auth, broader /api/admin, unrelated /api/admin/users*, and fallback routes stay on legacy routing. Rollback is switching GATEWAY_ROUTE_TABLE back to deploy/gateway/routes.json.

Organization Read Route Rehearsal

The default gateway route table keeps /api/organizations* on legacy. Use deploy/gateway/routes.organizations-read-native-localhost-example.json only for a non-default read-only organization rehearsal.

Run the static route-table preflight first:

bash
cd go-platform
make test-organization-routes

Start school-service on :8083 and the gateway with the non-default route table:

bash
cd go-platform
HTTP_ADDR=:8083 go run ./services/school-service/cmd/server

GATEWAY_ROUTE_TABLE=deploy/gateway/routes.organizations-read-native-localhost-example.json \
LEGACY_API_BASE_URL=http://localhost:4001 \
AUTH_JWT_SECRET=<same-secret-as-legacy-token> \
HTTP_ADDR=:8085 \
go run ./services/api-gateway/cmd/server

The route table rehearses only GET /api/organizations, GET /api/organizations/:id, GET /api/organizations/:id/units, and GET /api/organizations/:id/members. Organization writes, purge, unit/member mutations, student-imports, broad /api/organizations, and fallback stay on legacy. Rollback is switching GATEWAY_ROUTE_TABLE back to deploy/gateway/routes.json.

For the IAM-owned membership write rehearsal, start iam-service on :8081 as well and use deploy/gateway/routes.organizations-membership-write-native-localhost-example.json:

bash
HTTP_ADDR=:8081 go run ./services/iam-service/cmd/server

GATEWAY_ROUTE_TABLE=deploy/gateway/routes.organizations-membership-write-native-localhost-example.json \
LEGACY_API_BASE_URL=http://localhost:4001 \
AUTH_JWT_SECRET=<same-secret-as-legacy-token> \
HTTP_ADDR=:8085 \
go run ./services/api-gateway/cmd/server

That route table keeps the organization read rehearsal above and adds only POST /api/organizations/:id/members plus DELETE /api/organizations/:id/members/:userId through the gateway organization_membership_adapter. The adapter maps legacy userId to IAM accountId; PATCH member updates and other organization writes remain legacy.

After signing in as an IAM admin and choosing a disposable account id, run the opt-in add/remove smoke:

bash
ORGANIZATION_MEMBERSHIP_WRITE_CONFIRM=write-iam-membership \
ORGANIZATION_MEMBERSHIP_WRITE_ORGANIZATION_ID=<org-id> \
ORGANIZATION_MEMBERSHIP_WRITE_USER_ID=<disposable-account-id> \
ORGANIZATION_MEMBERSHIP_WRITE_AUTHORIZATION='Bearer <iam-admin-token>' \
ORGANIZATION_MEMBERSHIP_WRITE_ARTIFACT_DIR=output/smoke/organization-membership-write \
GATEWAY_BASE_URL=http://localhost:8085 \
make test-organization-membership-write-live

The script first validates that /v1/routes is using organization_membership_adapter, then posts the legacy public userId body to the gateway and deletes the same member through IAM. It requires bearer authorization because the target IAM management routes read the Authorization header directly.

Classroom Route Guard

The default gateway route table keeps /api/classrooms* and /api/admin/classrooms* on legacy. There is no non-default classroom public route-table rehearsal yet because legacy classroom surfaces still require teacher, organization, subject, user, lesson, assignment, attempt, and deferred counter hydration outside classroom-service.

Run the static no-cutover guard before adding or changing gateway route tables:

bash
cd go-platform
make test-classroom-route-guard

This guard scans deploy/gateway/routes.json and current non-default deploy/gateway/routes*.json examples. It fails if /api/classrooms* or /api/admin/classrooms* is promoted away from legacy_proxy. Future classroom adapters should update the guard only after response hydration, cross-service validation, auth/RBAC, and deferred count contracts are explicit.

Student Course Route Rehearsal

The default gateway route table keeps /api/student/courses*, /api/courses*, and /api/public* on legacy. Use deploy/gateway/routes.student-courses-read-native-localhost-example.json only for a non-default student course read rehearsal.

Run the static route-table preflight first:

bash
cd go-platform
make test-student-course-routes

Start course-service on :8086 and the gateway with the non-default route table:

bash
cd go-platform
HTTP_ADDR=:8086 go run ./services/course-service/cmd/server

GATEWAY_ROUTE_TABLE=deploy/gateway/routes.student-courses-read-native-localhost-example.json \
LEGACY_API_BASE_URL=http://localhost:4001 \
AUTH_JWT_SECRET=<same-secret-as-legacy-token> \
HTTP_ADDR=:8085 \
go run ./services/api-gateway/cmd/server

The route table rehearses only GET /api/student/courses and GET /api/student/courses/{courseId} with gateway auth, global STUDENT role checks, and organization header injection. Recommendation, purchase, lesson progress, video progress, material view writes, teacher/admin course management, public catalog, broad student-course/course/public routes, and fallback stay on legacy. Rollback is switching GATEWAY_ROUTE_TABLE back to deploy/gateway/routes.json.

Student Course Progress Route Rehearsal

The default gateway route table keeps /api/student/courses*, /api/courses*, and /api/public* on legacy. Use deploy/gateway/routes.student-course-progress-native-localhost-example.json only for a non-default student course progress write rehearsal.

Run the static route-table preflight first:

bash
cd go-platform
make test-student-course-progress-routes

Start course-service on :8086 and the gateway with the non-default route table:

bash
cd go-platform
HTTP_ADDR=:8086 go run ./services/course-service/cmd/server

GATEWAY_ROUTE_TABLE=deploy/gateway/routes.student-course-progress-native-localhost-example.json \
LEGACY_API_BASE_URL=http://localhost:4001 \
AUTH_JWT_SECRET=<same-secret-as-legacy-token> \
HTTP_ADDR=:8085 \
go run ./services/api-gateway/cmd/server

The route table rehearses only POST /api/student/courses/{courseId}/lessons/{lessonId}/progress, POST /api/student/courses/{courseId}/lessons/{lessonId}/video-progress, and POST /api/student/courses/{courseId}/materials/{materialId}/view with gateway auth, global STUDENT role checks, and organization header injection. Student course list/detail are covered by the separate read rehearsal and remain legacy in this progress-only table. Mastery, recommendation, purchase, teacher/admin course management, public catalog, broad student-course/course routes, and fallback stay on legacy. Rollback is switching GATEWAY_ROUTE_TABLE back to deploy/gateway/routes.json.

Notification Route Rehearsal

The default gateway route table keeps /api/notifications*, /api/parent/alerts*, /api/alerts*, and /api/admin/inbox* on legacy. Use deploy/gateway/routes.notifications-native-localhost-example.json only for a non-default inbox/preferences rehearsal.

Run the static route-table preflight first:

bash
cd go-platform
make test-notification-routes

Start notification-service on :8092 and the gateway with the non-default route table:

bash
cd go-platform
HTTP_ADDR=:8092 go run ./services/notification-service/cmd/server

GATEWAY_ROUTE_TABLE=deploy/gateway/routes.notifications-native-localhost-example.json \
LEGACY_API_BASE_URL=http://localhost:4001 \
AUTH_JWT_SECRET=<same-secret-as-legacy-token> \
HTTP_ADDR=:8085 \
go run ./services/api-gateway/cmd/server

The route table rehearses notification inbox list/detail, mark-read, delete, read-all/delete-all, and both preferences aliases: /api/notifications/preferences and /api/alerts/preferences. Notification create/batch/snapshot, parent alerts, weak-topic alerts, admin inbox, broad notification/alert/admin routes, and fallback stay on legacy. Rollback is switching GATEWAY_ROUTE_TABLE back to deploy/gateway/routes.json.

Parent Alert Route Rehearsal

The default gateway route table keeps /api/parent/alerts*, /api/notifications*, /api/alerts*, and /api/admin* on legacy. Use deploy/gateway/routes.parent-alerts-native-localhost-example.json only for a non-default parent alert read rehearsal.

Run the static route-table preflight first:

bash
cd go-platform
make test-parent-alert-routes

Start notification-service on :8092 and the gateway with the non-default route table:

bash
cd go-platform
HTTP_ADDR=:8092 go run ./services/notification-service/cmd/server

GATEWAY_ROUTE_TABLE=deploy/gateway/routes.parent-alerts-native-localhost-example.json \
LEGACY_API_BASE_URL=http://localhost:4001 \
AUTH_JWT_SECRET=<same-secret-as-legacy-token> \
HTTP_ADDR=:8085 \
go run ./services/api-gateway/cmd/server

The route table rehearses only GET /api/parent/alerts, POST /api/parent/alerts/{notificationId}/read, and POST /api/parent/alerts/read-all with gateway auth plus global PARENT role checks. Weak-topic alert creation, parent-child relationship lookup, notification inbox/preferences, admin inbox, broad parent-alert/alert/admin routes, and fallback stay on legacy. Rollback is switching GATEWAY_ROUTE_TABLE back to deploy/gateway/routes.json.

Feature Maintenance Route Rehearsal

The default gateway route table keeps /api/feature-maintenance*, /api/admin/feature-maintenance*, /api/admin/operations*, broad /api/admin*, and broad /api/questions* on legacy. Use deploy/gateway/routes.feature-maintenance-native-localhost-example.json only for a non-default feature-maintenance public/admin rehearsal.

Run the static route-table preflight first:

bash
cd go-platform
make test-feature-maintenance-routes

Start admin-service on :8094 and the gateway with the non-default route table:

bash
cd go-platform
HTTP_ADDR=:8094 go run ./services/admin-service/cmd/server

GATEWAY_ROUTE_TABLE=deploy/gateway/routes.feature-maintenance-native-localhost-example.json \
LEGACY_API_BASE_URL=http://localhost:4001 \
AUTH_JWT_SECRET=<same-secret-as-legacy-token> \
HTTP_ADDR=:8085 \
go run ./services/api-gateway/cmd/server

The route table rehearses only GET /api/feature-maintenance/public, GET /api/admin/feature-maintenance, and PATCH /api/admin/feature-maintenance/{key}. The public route stays unauthenticated; admin routes require a bearer token with global ADMIN role. Broad feature-maintenance/admin routes, AI classification job reads, admin operations/audit, users/settings/wallet/support/dashboard, and fallback stay on legacy. Rollback is switching GATEWAY_ROUTE_TABLE back to deploy/gateway/routes.json.

Admin Audit Route Rehearsal

The default gateway route table keeps /api/admin/operations* and broad /api/admin* on legacy. Use deploy/gateway/routes.admin-audit-native-localhost-example.json only for a non-default audit-log read rehearsal.

Run the static route-table preflight first:

bash
cd go-platform
make test-admin-audit-routes

Start admin-service on :8094 and the gateway with the non-default route table:

bash
cd go-platform
HTTP_ADDR=:8094 go run ./services/admin-service/cmd/server

GATEWAY_ROUTE_TABLE=deploy/gateway/routes.admin-audit-native-localhost-example.json \
LEGACY_API_BASE_URL=http://localhost:4001 \
AUTH_JWT_SECRET=<same-secret-as-legacy-token> \
HTTP_ADDR=:8085 \
go run ./services/api-gateway/cmd/server

The route table rehearses only GET /api/admin/operations/audit. It requires a bearer token with global ADMIN role. Admin operations health, queues, retry/clear commands, metrics, import jobs, outbox, AI settings, feature-maintenance routes not included in the table, and broad admin routes stay on legacy. Rollback is switching GATEWAY_ROUTE_TABLE back to deploy/gateway/routes.json.

Analytics Read Route Rehearsal

The default gateway route table keeps /api/analytics*, /api/exams*, /api/classrooms*, /api/students*, /api/parents*, and fallback on legacy. Use deploy/gateway/routes.analytics-read-native-localhost-example.json only for a non-default analytics result/weak-topic read rehearsal.

Run the static route-table preflight first:

bash
cd go-platform
make test-analytics-routes

Start analytics-service on :8093 and the gateway with the non-default route table:

bash
cd go-platform
HTTP_ADDR=:8093 go run ./services/analytics-service/cmd/server

GATEWAY_ROUTE_TABLE=deploy/gateway/routes.analytics-read-native-localhost-example.json \
LEGACY_API_BASE_URL=http://localhost:4001 \
AUTH_JWT_SECRET=<same-secret-as-legacy-token> \
HTTP_ADDR=:8085 \
go run ./services/api-gateway/cmd/server

The route table rehearses only GET /api/analytics/results and GET /api/analytics/weak-topics. Both require a bearer token with global ADMIN or TEACHER role and an organization id from X-Organization-Id, organizationId, or the token default organization. Snapshot/event writes, exam/classroom/student/parent analytics, classroom export, and broad route families stay on legacy. Rollback is switching GATEWAY_ROUTE_TABLE back to deploy/gateway/routes.json.

Attempt Route Rehearsal

The default gateway route table keeps /api/exams/* and /api/attempts/* on legacy. Use deploy/gateway/routes.attempt-native-localhost-example.json only for a non-default attempt runtime rehearsal.

Run the static route-table preflight first:

bash
cd go-platform
make test-attempt-routes

Start exam-service on :8090, attempt-service on :8091, and the gateway with the non-default route table:

bash
cd go-platform
HTTP_ADDR=:8090 go run ./services/exam-service/cmd/server

HTTP_ADDR=:8091 go run ./services/attempt-service/cmd/server

GATEWAY_ROUTE_TABLE=deploy/gateway/routes.attempt-native-localhost-example.json \
LEGACY_API_BASE_URL=http://localhost:4001 \
AUTH_JWT_SECRET=<same-secret-as-legacy-token> \
HTTP_ADDR=:8085 \
go run ./services/api-gateway/cmd/server

The route table rehearses POST /api/exams/:examId/start through the gateway exam_start_adapter: gateway calls exam-service/v1/exams/:examId/runtime-access first, returns the denial without calling attempt-service if access is denied, and forwards only data.startInput to attempt-service when allowed. It also rehearses GET /api/attempts/:attemptId, answer save, submit, event read/write, and result read. Broad /api/exams, broad /api/attempts, and fallback stay on legacy. Rollback is switching GATEWAY_ROUTE_TABLE back to deploy/gateway/routes.json.

Import Review Save/Detail Live Smoke

scripts/test/import-review-roundtrip-live-smoke.sh exercises the opt-in P4-020 gateway route table with a real import job:

  1. fetch GET /api/exam-import/jobs/{id}
  2. save a reviewed parse payload through PATCH /api/exam-import/jobs/{id}/review
  3. reload GET /api/exam-import/jobs/{id}
  4. verify questionTypeManualOverride and questionTypeReviewed

Start the gateway with the combined route table:

bash
cd go-platform
GATEWAY_ROUTE_TABLE=deploy/gateway/routes.import-review-roundtrip-native-localhost-example.json \
LEGACY_API_BASE_URL=http://localhost:4001 \
HTTP_ADDR=:8085 \
go run ./services/api-gateway/cmd/server

Then run the smoke with a completed native import job id and a valid teacher or admin session. By default the script restores the original parse payload after verification:

bash
IMPORT_REVIEW_ROUNDTRIP_JOB_ID=<native-job-id> \
IMPORT_REVIEW_ROUNDTRIP_AUTHORIZATION='Bearer <access-token>' \
IMPORT_REVIEW_ROUNDTRIP_ORGANIZATION_ID=<org-id> \
make test-import-review-roundtrip-live

Set IMPORT_REVIEW_ROUNDTRIP_RESTORE=0 only when intentionally leaving the reviewed payload in place for follow-up browser verification. Rollback is returning the gateway to deploy/gateway/routes.json; the default route table does not include these native review/detail routes.

Import Approval Route Preflight

scripts/test/import-approval-route-coverage.sh statically validates the non-default import approval route-table examples without executing approval or mutating data:

bash
cd go-platform
make test-import-approval-routes

The check verifies only POST /api/exam-import/jobs/{id}/approve is carved out to docx-import-service, while sibling import job actions stay on the broad legacy /api/exam-import route. Live approval parity still requires a completed import job, native question-bank/exam dependencies, and explicit target-write approval.

To exercise the approval write through the gateway, start the gateway with deploy/gateway/routes.import-approval-native-localhost-example.json and run:

bash
cd go-platform
IMPORT_APPROVAL_LIVE_CONFIRM=approve-native \
IMPORT_APPROVAL_JOB_ID=<completed-job-id> \
IMPORT_APPROVAL_AUTHORIZATION='Bearer <token>' \
IMPORT_APPROVAL_ORGANIZATION_ID=<org-id> \
make test-import-approval-live

Optional payload overrides:

bash
IMPORT_APPROVAL_BODY_JSON='{"target":"QUESTION_BANK","questionMetadata":{"subjectId":"math","gradeLevel":12}}'
IMPORT_APPROVAL_BODY_FILE=/path/to/approval-body.json

The live smoke first validates /v1/routes, checks GET /api/exam-import/jobs/{id} still uses the broad legacy route, then posts /api/exam-import/jobs/{id}/approve and asserts route headers plus the native approval response. It exits before any HTTP request unless IMPORT_APPROVAL_LIVE_CONFIRM=approve-native is set.

Go-platform documentation is generated from repository Markdown.