Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .github/workflows/pnpm-audit.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: pnpm audit

on:
workflow_dispatch:
pull_request:
types:
- opened
- synchronize
- reopened
- ready_for_review
paths:
- package.json
- pnpm-lock.yaml

permissions:
contents: read

jobs:
audit:
name: audit
runs-on: self-hosted
steps:
- name: Fetch Repository
uses: actions/checkout@v6

- name: Set up pnpm
uses: pnpm/action-setup@v4
with:
version: 10.32.1
run_install: false

- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: "lts/*"

- name: Run pnpm audit
run: pnpm audit --audit-level low
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ Observed entry points:
- `main.go`
- `api.New`
- `routes.RegisterRoutes`
- `api-spec/openapi.yaml`
- `api-spec/v0/openapi.yaml`

Observed deployment helpers:

Expand Down
146 changes: 72 additions & 74 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,108 +2,95 @@

## Overview

The server port is configured through `PORT` and defaults to `3000` in code;
the local example environment sets `PORT=8000`. The intended public API
The server port is configured through `PORT` and defaults to `3000` in code.
The local example environment sets `PORT=8000`. The intended public API
contract for this branch is defined in `api-spec/v0/openapi.yaml`; this page
documents the currently wired Go runtime endpoints and their operational
caveats.
documents the endpoints and middleware that are actually wired by the current
Go service.

## Authentication Behavior

- When `SKIP_AUTH=false`, Cognito middleware is enabled globally.
- Middleware reads access token from header: `x-amzn-oidc-accesstoken`.
- Token checks include:
- valid signature via JWKS
- issuer match
- `token_use=access`
- `client_id` claim equals configured app client ID
- `api.New` only adds `SkipAuthMiddleware` when `SKIP_AUTH=true`.
- That middleware injects a stable local identity into Fiber locals:
`sub`, `username`, `scope`, and `groups`.
- When `SKIP_AUTH=false`, the current branch does not add an alternate
in-app bearer-token validator.
- `GET /health` is registered before the optional skip-auth middleware and
remains unauthenticated in the current branch.

If auth fails, response is `401 Unauthorized`.
This applies to `/api/edu` and `/api/v0/veteran-disability-ratings` when auth is
enabled. `/health` is registered before the auth middleware and remains
unauthenticated in the current branch.
The checked-in OpenAPI contract still models OAuth 2.0 client credentials for
public integrations. Treat that contract and the current runtime behavior as
separate concerns until runtime auth enforcement lands.

## Circuit Breaker Behavior

`/health`, `/api/edu`, and `/api/v0/veteran-disability-ratings` are wrapped by
Redis-backed circuit breaker middleware.
`/health`, `POST /api/v0/education-enrollments`, and
`POST /api/v0/veteran-disability-ratings` are wrapped by Redis-backed circuit
breaker middleware.

- On breaker deny/open state: `503 Service Unavailable`.
- On Redis state read failures with fail-open (default): request is allowed.

## Runtime Endpoints

| Method | Path | Description | Success | Notes |
| ------ | ------------------------------ | -------------------------------------- | ----------- | ------------------------------------------------------------------ |
| `GET` | `/` | Liveness string | `200` text | Returns `Backend running!` |
| `GET` | `/health` | Redis health check | `200` empty | Registered before auth middleware; pings Redis with 2s timeout |
| `GET` | `/api/edu` | NSC education verification scaffold | `200` JSON | Uses a hardcoded request payload in handler; not the v0 contract |
| `POST` | `/api/v0/veteran-disability-ratings` | Veteran disability status from v0 spec | `200` JSON | Accepts caller-provided identity payload and matches the v0 route |

| Method | Path | Description | Success | Notes |
| ------ | --------------------- | ----------------------------------- | ----------- | ----- |
| `GET` | `/` | Liveness string | `200` text | Returns `Backend running!` |
| `GET` | `/status` | Redis health check | `200` empty | Uses 2s Redis ping timeout; wrapped by circuit breaker |
| `GET` | `/api-spec/v1/verify` | Bundled OpenAPI JSON artifact | `200` JSON | Returns `api-spec/dist/openapi.bundled.json` |
| `GET` | `/api/edu` | Education verification passthrough | `200` JSON | Uses hardcoded request payload in handler; wrapped by circuit breaker |

### NSC Submit Request model (`pkg/education/models_request.go`)

```go
type Request struct {
AccountID string `json:"accountId"`
OrganizationName string `json:"organizationName,omitempty"`
CaseReferenceID string `json:"caseReferenceId,omitempty"`
ContactEmail string `json:"contactEmail,omitempty"`
DateOfBirth string `json:"dateOfBirth"`
LastName string `json:"lastName"`
FirstName string `json:"firstName"`
SSN string `json:"ssn,omitempty"`
IdentityDetails []IdentityDetails `json:"identityDetails,omitempty"`
EndClient string `json:"endClient"`
PreviousNames []PreviousName `json:"previousNames,omitempty"`
Terms string `json:"terms"`
}
```

### NSC Submit Response model (`pkg/education/models_response.go`)

```go
type Response struct {
ClientData ClientDataResponse `json:"clientData"`
IdentityDetails []IdentityDetailsResponse `json:"identityDetails"`
Status StatusResponse `json:"status"`
StudentInfoProvided StudentInfoProvidedResponse `json:"studentInfoProvided"`
TransactionDetails TransactionDetailsResponse `json:"transactionDetails"`
}
```
| Method | Path | Description | Success | Notes |
|---|---|---|---|---|
| `GET` | `/` | Liveness string | `200` text | Returns `Backend running!`. |
| `GET` | `/health` | Redis health check | `200` empty | Pings Redis with a 2-second timeout. |
| `GET` | `/api-spec/v1/verify` | Bundled OpenAPI JSON artifact | `200` JSON | Serves `api-spec/v0/dist/openapi.bundled.json`. |
| `POST` | `/api/v0/education-enrollments` | Education enrollment lookup | `200` JSON | Requires `firstName`, `lastName`, and `dateOfBirth`; returns `enrollmentStatus`. |
| `POST` | `/api/v0/veteran-disability-ratings` | Veteran disability lookup | `200` JSON | Requires `firstName`, `lastName`, `dateOfBirth`, plus either `ssn` or a full `address`; returns `combinedDisabilityRating`. |

## Error Semantics

- Invalid JSON or missing required identity fields return `400`.
- Education and veteran lookups return `404` when the upstream service reports
the subject was not found.
- Veteran disability requests also return `404` when neither `ssn` nor a full
address is supplied.
- Upstream lookup failures return `502`.
- Circuit-breaker denies return `503`.
- Fiber-generated error bodies are plain text in the current branch.

## Example: `/health`

```bash
curl -i http://localhost:8000/health
```

### `/api-spec/v1/verify`
## Example: `/api-spec/v1/verify`

```bash
curl -i http://localhost:8000/api-spec/v1/verify
```

Returns the checked-in bundled OpenAPI JSON artifact with `Content-Type: application/json`.

## Example: `/api/edu` (auth skipped locally)
## Example: `POST /api/v0/education-enrollments`

```bash
curl -i http://localhost:8000/api/edu
curl -i --request POST http://localhost:8000/api/v0/education-enrollments \
--header 'Content-Type: application/json' \
--data '{
"firstName": "Lynette",
"middleName": "Marie",
"lastName": "Oyola",
"dateOfBirth": "1988-10-24",
"ssn": "123-45-6789"
}'
```

Example success body:

```json
{
"enrollmentStatus": "FULL_TIME"
}
```

## Example: `/api/v0/veteran-disability-ratings`
## Example: `POST /api/v0/veteran-disability-ratings`

```bash
curl -i --request POST http://localhost:8000/api/v0/veteran-disability-ratings \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer <ACCESS_TOKEN>' \
--data '{
"firstName": "Lynette",
"lastName": "Oyola",
Expand All @@ -118,16 +105,27 @@ curl -i --request POST http://localhost:8000/api/v0/veteran-disability-ratings \
}'
```

Example success body:

```json
{
"combinedDisabilityRating": 70
}
```

## Current-State Caveats

- `/api/edu` currently does not accept caller-provided payload; it submits a hardcoded sample request from handler code.
- `main` now injects Redis into `api.New`, so the health route has the Redis client it expects.
- The intended public contract for this branch is versioned under `api-spec/v0/`, and the veteran disability route matches that contract while `/api/edu` remains a runtime-only scaffold.
- Error response bodies come from Fiber error handling and may be plain text.
- The runtime and the checked-in OpenAPI contract both expose the same two POST
verification routes, but runtime auth enforcement does not yet match the
contract's bearer-token description.
- `main` injects Redis into `api.New`, so the health route and breaker
middleware share the same Redis dependency.
- The bundled spec route is an implementation convenience and should not be
treated as the authoring source of truth.

## Assumptions

- **High confidence:** This page is a runtime reference, not the public API
contract reference.
- **Medium confidence:** `/api/edu` will be removed or reshaped as the runtime
converges on the published v0 contract.
- **Medium confidence:** Real bearer-token enforcement will be added later to
align the running service with `api-spec/v0/openapi.yaml`.
67 changes: 46 additions & 21 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
- `api/middleware/middleware.go`
- `pkg/core/*.go`
- `pkg/education/*.go`
- `pkg/veteran/*.go`
- `pkg/circuitbreaker/*.go`
- `pkg/redis/*.go`
- `pkg/choice/choice.go`
Expand All @@ -33,22 +34,28 @@ flowchart LR
routes --> handlers
routes --> middleware
routes --> education
routes --> veteran
routes --> circuitbreaker

handlers --> education
handlers --> veteran
handlers --> redispkg

middleware --> circuitbreaker

education --> core
veteran --> core
circuitbreaker --> redisclient[go-redis]
redispkg --> redisclient
```

## Interfaces and Abstractions

- `pkg/education/service.go`
- `type EducationService interface { Submit(ctx context.Context, req Request) (Response, error) }`
- `type Service interface { LookupEnrollmentStatus(ctx context.Context, req Request) (Response, error) }`
- `type HTTPTransport interface { Do(req *http.Request) (*http.Response, error) }`
- `pkg/veteran/service.go`
- `type Service interface { LookupDisabilityRating(ctx context.Context, req Request) (Response, error) }`
- `type HTTPTransport interface { Do(req *http.Request) (*http.Response, error) }`
- `pkg/core/otel.go`
- `type OtelService interface { SpanFromContext; LoggerProvider; Shutdown }`
Expand All @@ -61,56 +68,74 @@ without route-layer rewrites.
## Concurrency Model

- Server lifecycle:
- `runServer` starts `app.Listen` in a goroutine and selects on server error or signal context cancellation.
- `runServer` starts `app.Listen` in a goroutine and selects on server error
or signal context cancellation.
- graceful shutdown uses `app.ShutdownWithTimeout(5 * time.Second)`.
- Request lifecycle:
- handlers create per-request contexts with timeout (`/health`: 2s, `/api/edu`: 5s, `/api/v0/veteran-disability-ratings`: 5s).
- handlers create per-request contexts with timeout (`/health`: 2s,
`/api/v0/education-enrollments`: 30s,
`/api/v0/veteran-disability-ratings`: 5s).
- Circuit-breaker middleware:
- breaker registry map guarded with `sync.RWMutex`.
- lazy breaker initialization via double-check lock pattern.

## Error Handling Strategy

- Global Fiber error handler (`api/errorHandler`) converts errors into HTTP status and message.
- `fiber.NewError` used for explicit gateway semantics in education handler.
- Wrapped errors with context (`fmt.Errorf("...: %w", err)`) in service layers.
- Circuit breaker denies with `503 Service Unavailable` when `Allow` returns `ErrCircuitOpen`.
- With default `FailOpen=true`, Redis state-read/parse errors in breaker checks allow request pass-through instead of denying.
- Panic recovery middleware logs stack traces (`recover.Config{EnableStackTrace:true}`).
- Global Fiber error handler (`api/errorHandler`) converts errors into HTTP
status and plain-text message bodies.
- `fiber.NewError` is used for explicit gateway semantics in the education and
veteran handlers.
- Wrapped errors with context (`fmt.Errorf("...: %w", err)`) are used in
service layers.
- Circuit breaker denies with `503 Service Unavailable` when `Allow` reports an
open circuit.
- With default `FailOpen=true`, Redis state-read or parse errors allow request
pass-through instead of denying.
- Panic recovery middleware logs stack traces
(`recover.Config{EnableStackTrace:true}`).

## Middleware Stack

Ordered middleware in `api.New`:

1. Recover
2. CORS (`*` origin/headers/methods)
3. OpenTelemetry Fiber middleware
4. Structured request logging (trace/span/request IDs)
5. Conditional Cognito auth middleware
1. Request ID propagation into Fiber user context
2. Structured request logging (trace/span/request IDs)
3. Panic recovery with stack traces
4. CORS (`*` origin/headers/methods)
5. OpenTelemetry Fiber middleware
6. Optional `SkipAuthMiddleware` when `SKIP_AUTH=true`

`GET /health` is registered before the optional skip-auth middleware, so it
does not receive the injected local identity locals.

## Dependency Injection Pattern

Observed constructor and options-based DI:

- `education.New(cfg, education.Options{HTTPClient, Logger, Timeout})`
- `veteran.New(cfg, veteran.Options{HTTPClient, Logger, Timeout})`
- Current `main` call: `api.New(&api.Config{Core, Logger, Otel, Redis})`
- Circuit breaker injection via higher-order middleware factory:
- `WithCircuitBreaker(func(name string) *RedisBreaker { ... })`

Note: `api.Config` includes a `Redis` field and the current `main` path now
injects it.
Note: `api.Config` includes a `Redis` field and the current `main` path injects
it.

## Technical Caveats (Current State)

- `/api/edu` handler builds a hardcoded request payload instead of binding user input.
- `/health` is registered before the auth middleware, so it remains a runtime-only unauthenticated health route.
- Current runtime routes are a mix of scaffold and contract-aligned paths: `GET /`, `GET /health`, `GET /api/edu`, and `POST /api/v0/veteran-disability-ratings`.
- `GET /api/edu` remains runtime scaffolding, while `POST /api/v0/veteran-disability-ratings` matches the checked-in v0 contract in `api-spec/v0/openapi.yaml`.
- The current branch exposes five runtime routes: `GET /`, `GET /health`,
`GET /api-spec/v1/verify`, `POST /api/v0/education-enrollments`, and
`POST /api/v0/veteran-disability-ratings`.
- `SkipAuthMiddleware` is a local identity injector, not a bearer-token
validator. The design-time auth contract in `api-spec/v0/openapi.yaml` is
ahead of the running service.
- `/health` is intentionally reachable without the optional skip-auth
middleware.
- Some tests require local Redis and fail when unavailable.

## Assumptions

- **High confidence:** Current layering is intentionally thin and
integration-oriented rather than strict clean-architecture separation.
- **Medium confidence:** Additional provider adapters are expected to follow
existing interface patterns in `pkg/education` and middleware wrappers.
existing interface patterns in `pkg/education` and `pkg/veteran`.
Loading
Loading