The Summer EBT (SUN Bucks) Self-Service Portal is an application that allows parents/guardians of children eligible for Summer EBT manage their benefit, including the following core features:
- Verifying a child's eligibility
- Verifying when and how the benefit will be received (which EBT card)
- Changing mailing address on file
- Requesting a replacement EBT card
This is a monorepo containing the portal app and the shared + CO state connectors. The DC connector
remains an external repo (sebt-self-service-portal-dc-connector).
apps/
portal/ # the deployable portal: .NET API + Next.js web + enrollment checker
src/, test/, SEBT.Portal.sln
connectors/
state/ # MEF plugin contract (interfaces), NuGet-packaged for external consumers
co/ # Colorado connector implementation
dc/ # placeholder README β the DC connector lives in its external repo
packages/ # shared JS libraries: @sebt/design-system, @sebt/analytics
scripts/ # repo-wide dev, CI, and git helper scripts
tofu/ # infrastructure (OpenTofu)
SEBT.slnx # top-level solution: portal + in-repo connectors
Repo-wide config lives at the root: pnpm-workspace.yaml, package.json, nuget.config,
global.json, Directory.Build.props, and .github/.
- Build everything (root):
dotnet build SEBT.slnxfor the backend;pnpm ci:build:frontendandpnpm ci:build:enrollment-checkerfor the web apps (the enrollment checker needsNEXT_PUBLIC_*values at build time, which those scripts provide). - Run for a state:
pnpm dev:dcorpnpm dev:co(builds the relevant connector plugins, then starts the API + web). DC uses the externaldc-connectorchecked out alongside this repo; CO and the state contract build fromapps/connectors/*. - Windows: enable long paths (
git config core.longpaths true) β the nestedapps/portal/...paths can exceed the legacy 260-char limit.
state-ci.yamlbuilds/tests the portal + connectors on PRs and pushes. PRs are path-filtered: portal-only changes skip connector-irrelevant jobs and vice versa; pushes always run everything.deploy-ecr.yamlbuilds Docker images and deploys DC and CO to their dev environments; it builds the in-repo state + CO connectors and checks out the external DC connector.release-iis-dc.yamlproduces the DC IIS release bundle (validated on every PR).deploy-enrollment-checker.yamlbuilds and deploys the static enrollment checker.playwright-e2e.yamlruns Playwright E2E (per state) and Pa11y accessibility checks.build-and-seed-dc-source.yamlbuilds the DC seed/source image from the external DC repo.
Backend
- Language/framework: C# with .NET 10
- Key libraries: ASP.NET Core, Serilog, Managed Extensibility Framework (MEF), EntityFramework (EF) Core
- Package manager: NuGet
Frontend
- Language/framework: NextJS 16 with TypeScript
- Key libraries: next, react, i18next, react-i18next, tanstack/react-query, zod
- Package manager: pnpm
- Design system: USWDS, with design tokens specified for each state
Infrastructure
- Infrastructure as Code using OpenTofu (Terraform) - see tofu
- Docker with docker-compose for local development
Note: The following steps assume you are working on macOS. Steps may differ if you are working on a different operating system.
- .NET 10 SDK for running the back end
- The latest version of nodeJS
- pnpm for managing front end packages and development scripts
- Docker Desktop for running and managing containers (includes MSSQL database)
The portal, the plugin contract, and the Colorado connector all live in this monorepo β one clone covers CO development end to end:
git clone https://github.com/codeforamerica/sebt-self-service-portal.gitWorking on DC? The DC connector is maintained in its own repository (see
apps/connectors/dc/README.md). Clone it as a sibling (same parent
folder) so pnpm dev:dc can build it:
git clone https://github.com/codeforamerica/sebt-self-service-portal-dc-connector.gitThe old standalone
-state-connectorand-co-connectorrepositories were merged into this repo (apps/connectors/) and are archived β don't clone them for new work.
.env files are used in this project to set environment variables (eg, database configs). This is a preferred pattern for 12-factor Apps. They are also set to fallback to a generic default. You'll need to create .env files for your local environment, based on the example file.
To create your local .env file with configurations for the database and API, run this command in the root of the repo:
cp .env.example .envYou'll want do the same from within apps/portal/src/SEBT.Portal.Web:
cp .env.example .env.localYou'll also need an API appsettings file for your local machine with certain values set (see state specific configuration below):
cd apps/portal/src/SEBT.Portal.Api
cp appsettings.Development.example.json appsettings.Development.jsonFront end
- To install all javascript package dependencies, run
pnpm installfrom the root of this repository. - You can learn more about the front end in the SEBT.Portal.Web README
Back end
- .NET tools are CLI utilities installed and managed using NuGet. Currently, we are using the
nuget-licensetool for auditing backend dependency license. Needed tools are defined in the tools manifest in.config/dotnet-tools.json. To install them, rundotnet tool restoreonce from the repo root. - You'll also want to run
dotnet build SEBT.slnxfrom the repo root before starting up the app for the first time β it builds the portal and the in-repo connectors together.
Make sure Docker is installed and the docker daemon is running. When the database spins up locally, all migrations will be run and db seeded automatically (see database setup section below).
docker compose up -d # Start all docker containers, including MSSQL Database and Mailpit for testingpnpm dev # Script to start both API (ie, `dotnet watch`) and frontend (ie, `next dev`)To open the app, navigate to https://localhost:3000
# Start frontend only
pnpm web:dev
# View logs
docker compose logs -f
# Stop all services
docker compose down
# Stop and remove volumes (clears database - do this only if you're OK with dropping your seeded data)
docker compose down -vMailpit captures all outgoing emails in local development. Once the Mailpit docker container is running on your machine, you can access its UI in your browser at http://localhost:8025
Redis is used as an optional distributed cache backing for HybridCache. It's included in Docker Compose and runs with TLS enabled to mirror AWS Elasticache in-transit encryption.
Before running docker compose up for the first time, generate the local TLS certificates:
./scripts/dev/gen-redis-certs.shThis writes self-signed certs to certs/ (gitignored). The script is idempotent β existing certs are not overwritten. Re-run it if Redis TLS stops working (certs expire after one year).
| Port | Protocol | Used by |
|---|---|---|
| 6379 | plain | redis-commander, direct redis-cli |
| 6380 | TLS | portal API |
Add the following to your local appsettings.{state}.json to connect to the TLS port:
"Redis": {
"Host": "localhost",
"Port": 6380,
"Ssl": true,
"SslHost": "redis",
"AcceptSelfSignedCertificates": true
}SslHost should match the hostname in the server certificate β redis locally (the Docker service name), or the Elasticache cluster endpoint in production. An optional Password field supports Redis AUTH tokens.
AcceptSelfSignedCertificates: true bypasses CA trust for the local self-signed cert. Never set it in production β Elasticache presents an AWS-signed cert that .NET trusts natively. See appsettings.co.example.json for the full example.
The legacy ConnectionStrings:Redis connection string is still accepted as a fallback, but new deployments should use the structured form.
When neither is configured, the application falls back to in-memory caching only. See appsettings.co.example.json for a full example.
Jaeger acts as a local OTLP collector for OpenTelemetry tracing. The default configuration for the portal sends traces and metrics via OTLP over gRPC to http://localhost:4317, which is the standard port. Local traces can be viewed in the Jaeger UI at http://localhost:16686.
pnpm api:build # Build backend only (Debug)
pnpm api:test # Test backend onlypnpm ci:build # Build frontend + backend (Release)
pnpm ci:test # Test frontend + backend
# Individual components
pnpm ci:build:frontend # Build frontend only
pnpm ci:build:backend # Build backend only
pnpm ci:test:frontend # Test frontend only
pnpm ci:test:backend # Test backend onlyThe .NET solution is configured with <TreatWarningsAsErrors>true</TreatWarningsAsErrors> in Directory.Build.props. Any compiler warning will fail the build.
If you need to allow a specific warning code, demote it back to a warning in the affected .csproj:
<PropertyGroup>
<WarningsNotAsErrors>$(WarningsNotAsErrors);CS1591</WarningsNotAsErrors>
</PropertyGroup>Prefer this over <NoWarn>, which silences the warning entirely.
# State-based CI testing
pnpm ci:test:states # Test all states
pnpm ci:test:state:dc # Test DC state
pnpm ci:test:state:co # Test CO state
# Utility commands
pnpm ci:list # List all ACT workflows
pnpm ci:validate # Validate workflows (dry-run)State-Specific Development:
deploy/dc-* # DC-only changes (only DC builds in CI)
deploy/co-* # CO-only changes (only CO builds in CI)Shared Development:
feature/* # Changes for all states (all states build in CI)
main # Production source for all statesHow it works: main contains all code (shared + state-specific). Each state deployment uses only what it needs via configuration and feature flags.
See docs/development/state-ci.md for detailed CI documentation.
The API loads state-specific configuration based on the STATE environment variable:
appsettings.json: Base configuration (always loaded)appsettings.{STATE}.json: State overrides (loaded whenSTATEis set)
When STATE is set, the API looks for appsettings.{state}.json in the application directory. Values in the state file override those in appsettings.json if present.
Example: With STATE=dc, the API loads appsettings.dc.json. With STATE=co, it loads appsettings.co.json.
# Build and run for DC (loads appsettings.dc.json (if present))
STATE=dc dotnet run --project apps/portal/src/SEBT.Portal.Api
# Docker Compose uses STATE from .env
docker compose upOnly include sections you want to override; other settings fall back to appsettings.json!
States can use an external OpenID Connect (OIDC) provider for sign-in. OIDC is configured in the API under flat Oidc keys (DiscoveryEndpoint, ClientId, CallbackRedirectUri); the portal uses generic endpoints and config rather than state-specific auth code paths. Code exchange and id_token validation run in the Next.js server; the .NET API performs "complete-login" (validates a short-lived callback token and returns a portal JWT that includes IdP claims such as phone and name).
For a deployment that uses OIDC, in .env.local under SEBT.Portal.Web, set:
OIDC_DISCOVERY_ENDPOINTOIDC_CLIENT_IDOIDC_CLIENT_SECRETOIDC_REDIRECT_URIOIDC_COMPLETE_LOGIN_SIGNING_KEY(at least 32 characters)
In appsettings under SEBT.Portal.Api, set:
Oidc:CompleteLoginSigningKey(same value asOIDC_COMPLETE_LOGIN_SIGNING_KEY)Oidc:DiscoveryEndpointOidc:ClientIdOidc:CallbackRedirectUriOidc:LanguageParam(optional)
The API serves public config via GET /api/auth/oidc/{stateCode}/config (no secrets in that response).
See apps/portal/src/SEBT.Portal.Api/appsettings.Development.example.json and ADR-0008.
For states that use phone number as their primary Household ID and OIDC, local development sometimes requires bypassing MFA. You can override the phone number used for household lookup in appsettings.Development.json.
Only active when ASPNETCORE_ENVIRONMENT=Development. Example:
"DevelopmentPhoneOverride": {
"Phone": "8185558437"
}The resolver then uses this phone for household lookup instead of the one from the JWT or user record. You can still complete the OIDC flow as usual; the phone number used to satisfy MFA may differ from the one the portal uses for lookups.
To let SEBT's DAST (Dynamic Application Security Testing) scanner exercise the email login flow without receiving a one-time password, the portal can bypass OTP validation for a single, well-known scanner identity. The bypass is gated by all of the following criteria β if any one fails, normal OTP validation applies:
- The
bypass_otpfeature flag is enabled (FeatureManagementinappsettings.json; defaults tofalse). - The application is running in a non-production environment (
ASPNETCORE_ENVIRONMENTis anything other thanProduction). - The request email matches the scanner-specific address (
OtpBypassSettings.Email). - (Validation only) The submitted OTP matches the fixed scanner code (
OtpBypassSettings.OtpCode).
Never enable bypass_otp in production, and never use the scanner email for a real user account. The settings live in OtpBypassSettings; the gating is enforced in OtpController.
The IdProofingRequirements config section controls which IAL (Identity Assurance Level) a user needs to view or modify each type of PII. Keys use a resource+action format (e.g. address+view, card+write). Values can be a uniform level ("IAL1plus") or a per-case-type object for granular control. Unconfigured keys default to IAL1plus (fail-safe). Users below the view threshold see masked data (e.g. **** for street addresses); users below the write threshold are blocked from modifications.
See the full configuration guide for all available keys, per-case-type syntax, coherence validation rules, and state-specific examples. See appsettings.dc.example.json and appsettings.co.example.json for working state configurations.
The application uses Microsoft SQL Server as its database. This is propped up via a Docker container for local development.
Configuration is managed through environment variables.
Available environment variables for .env in the respository root:
Database (for Docker Compose):
MSSQL_SA_PASSWORD- SQL Server SA passwordMSSQL_DATABASE- Database nameMSSQL_USER- Database userMSSQL_SERVER- Server hostname (for local)MSSQL_PORT- Server port
API
JWTSETTINGS__SECRETKEY- Secret key for JWT token signing. Must be at least 32 characters.IDENTIFIERHASHER__SECRETKEY- Secret key for HMAC-SHA256 hashing of Household Identifiers as needed. Must be at least 32 characters.
The application uses EF, or Entity Framework Core migrations to manage database schema changes.
Migrations run automatically on application startup. When the API starts, it checks for pending migrations and applies them automatically. This ensures the database schema is always up-to-date.
While migrations run automatically, you can also manage them manually by installing ef on your local machine:
List all migrations:
dotnet ef migrations list \
--project apps/portal/src/SEBT.Portal.Infrastructure/SEBT.Portal.Infrastructure.csproj \
--startup-project apps/portal/src/SEBT.Portal.Api/SEBT.Portal.Api.csprojApply pending migrations:
dotnet ef database update \
--project apps/portal/src/SEBT.Portal.Infrastructure/SEBT.Portal.Infrastructure.csproj \
--startup-project apps/portal/src/SEBT.Portal.Api/SEBT.Portal.Api.csprojCreate a new migration:
dotnet ef migrations add MigrationName \
--project apps/portal/src/SEBT.Portal.Infrastructure/SEBT.Portal.Infrastructure.csproj \
--startup-project apps/portal/src/SEBT.Portal.Api/SEBT.Portal.Api.csprojRemove the last migration (if not applied):
dotnet ef migrations remove \
--project apps/portal/src/SEBT.Portal.Infrastructure/SEBT.Portal.Infrastructure.csproj \
--startup-project apps/portal/src/SEBT.Portal.Api/SEBT.Portal.Api.csprojMigrations are stored in apps/portal/src/SEBT.Portal.Infrastructure/Migrations/:
- Each migration has a timestamp prefix (e.g.,
20251212171249_AddUserOptInTable.cs) - The
PortalDbContextModelSnapshot.csfile tracks the current model state - Migration files should be committed to version control
The database is automatically seeded with test users when running in the Development environment. Seeding occurs automatically during:
- Database migrations (
dotnet ef database update) - Application startup (when migrations are applied)
DbContext.EnsureCreated()calls
The automatic seeding uses EF Core's UseSeeding mechanism under the hood. See https://learn.microsoft.com/en-us/ef/core/modeling/data-seeding
To help test different workflows and users in different states, the seeder will create the following users unless instructed otherwise:
co-loaded@example.com- A co-loaded user with completed ID proofingnon-co-loaded@example.com- A non-co-loaded user with in-progress ID proofingnot-started@example.com- A user who hasn't started ID proofing
Seeding only runs if no users exist in the database, preventing duplicate data on subsequent runs.
There's occasionally going to be instances where you'd want have the auto-seeded data be not be created for certain types of testing. For those instances, there's a small console app to help with this.
To clear all seeded data from the database, use the ClearSeededData console application:
dotnet run --project scripts/ClearSeededDataThis will prompt for confirmation before deleting all seeded records from the database. This is irreversable; once done, you'll have to reseed.
View database tables example:
docker exec -it sebt_mssql /opt/mssql-tools18/bin/sqlcmd \
-S localhost -U sa -P YourStrong@Passw0rd -d SebtPortal -C \
-Q "SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE = 'BASE TABLE'"Alternatively, I'd highly recommend a tool like LINQPad to help with DB-related tasks.
More documentation can be found in the docs folder.
See also:
We use Lightweight Architecture Decision Records for tracking architectural decisions, using adr tools to store them in source control. These can be found in the docs/adr folder.