{
  "markdown": "# Stockroom — Authentication Service\n\nA NestJS authentication service: registration, sign-in, refresh-token rotation, and a published\nJWKS for neighbouring services to verify tokens without being able to issue them. See\n`specs/001-authentication/spec.md` for the full functional spec and\n`specs/001-authentication/contract.md` for how each acceptance criterion is proven.\n\nThis README takes a clean clone to a running application and a passing test suite. It was\nverified by actually following it, step by step, on a fresh clone with no `node_modules`, no\n`keys/`, and no containers already running — see \"The local dev server's real limitation\" below\nfor the one thing that does **not** work out of the box.\n\n## Prerequisites\n\n- Node.js and npm (verified with Node v24.15.0 / npm 11.12.1 — no other version is pinned by this\n  repo, but that combination is the known-good baseline)\n- Docker with the `docker compose` plugin (verified with Docker 29.4.3 / Compose v5.1.3), for\n  local DynamoDB\n\n## 1. Install dependencies\n\n```sh\nnpm install\n```\n\n## 2. Bring up local DynamoDB\n\n```sh\ndocker compose up -d\n```\n\nStarts `dynamodb-local` (service `dynamodb` in `docker-compose.yml`) on the fixed host port\n`8000`, in-memory only — no volume, so `docker compose down` (or a container restart) wipes it.\n\nIf `docker compose up -d` fails with \"port is already allocated\", something else on the host is\nalready bound to `8000` (for example, a container from a previous run of this same project that\nwas never stopped). Stop whatever holds the port, or run `docker compose down` first, then retry.\n\n## 3. Configure environment variables\n\n```sh\ncp .env.example .env\n```\n\nThe copy arrives filled with working local defaults — none of them are real secrets. Only the\noptional variables are left blank, and blank means \"use the default declared in\n`src/shared/config/environment.schema.ts`\". `specs/001-authentication/contract.md`'s Environment\nsection explains what each one is for.\n\n**`.env` is not loaded automatically.** This project parses configuration straight from\n`process.env` (`src/shared/config/environment.schema.ts` → `configuration.module.ts`) — there is\nno `dotenv` dependency and no framework wiring that reads a `.env` file for you. Every command\nbelow that touches configuration (`db:create-table`, `start:dev`) needs those variables actually\nexported into the shell that runs it, e.g.:\n\n```sh\nset -a\nsource .env\nset +a\n```\n\n(`set -a` marks every variable `source` assigns for export, so this is equivalent to hand-writing\nan `export` line per variable; run it in the same shell you'll run the next commands in, or export\nper-command with `env $(cat .env | xargs) <command>` if you'd rather not touch the current shell.)\n\n**Two more variables are needed that are not in `.env.example`, and are not part of this app's own\nconfig schema at all:** `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`. The AWS SDK v3 credential\nchain refuses to send a request — even to the local `dynamodb-local` container, which does not\nvalidate credentials — without something present. Any non-empty value works locally:\n\n```sh\nexport AWS_ACCESS_KEY_ID=local\nexport AWS_SECRET_ACCESS_KEY=local\n```\n\n## 4. Create the local table\n\n```sh\nnpm run db:create-table\n```\n\nRuns `scripts/create-table.ts` directly against `process.env` (`TABLE_NAME`, `AWS_REGION`,\n`DYNAMODB_ENDPOINT`) — needs step 3's variables exported first, same reason as above. Idempotent:\nsafe to run again against an existing table.\n\n## 5. Generate a local RS256 dev key pair\n\n```sh\nnpm run keys:generate\n```\n\nWrites `keys/private.pem` and `keys/public.pem` (gitignored) and prints the key's RFC 7638\nthumbprint (`kid`). Nothing in this repo's local dev path currently consumes these two files\nautomatically — see \"The local dev server's real limitation\" below for why, and what the\nthumbprint is for if you do wire up a Parameter Store equivalent yourself.\n\n## 6. Run the test suites\n\n```sh\nnpm run test:unit\nnpm run test:integration\nnpm run test:e2e\n```\n\n- `test:unit` needs nothing else running — pure unit tests, no network calls.\n- `test:integration` and `test:e2e` need the local DynamoDB container from step 2 up (they create\n  the table themselves on first use via `ensureTableExists`, so step 4 is not strictly required\n  before them, though running it first is harmless).\n- `test:e2e` does **not** need your `.env` file or step 3's exports: every e2e spec transitively\n  imports `test/e2e/auth/support/set-test-environment.ts` first, which fills in every variable\n  `ConfigurationModule` needs with dummy/local values (`setIfAbsent`, so a real value you did\n  export is not overridden). The signing/verification key providers are also swapped for an\n  in-memory double for the whole e2e run (`test/e2e/auth/support/build-test-app.ts`), so no AWS\n  account is needed for any test suite.\n- A single spec file can be targeted with a positional filter, e.g. `npm run test:e2e -- login` or\n  `npm run test:unit -- raw-password` — matches `contract.md`'s per-AC commands.\n\n**Known intermittent failure on a genuinely fresh table:** if `test:e2e` is the very first thing to\ntouch a brand-new table (TTL not yet enabled on it), running the full suite in parallel can produce\na `TimeToLive is already enabled` `ValidationException` in one spec, because more than one Jest\nworker's `ensureTableExists` call races the same table's `DescribeTimeToLiveCommand` /\n`UpdateTimeToLiveCommand` pair at once. It is transient — rerunning `npm run test:e2e` succeeds\nonce TTL is enabled, and `npm run test:e2e -- <spec>` (targeting one file) or\n`npm run test:e2e -- --runInBand` (forcing sequential workers) avoids it outright on a fresh table.\nThis is a pre-existing race in the shared table bootstrap, not a step this README got wrong.\n\n## 7. Start the local dev server\n\n```sh\nnpm run start:dev\n```\n\nBoots `src/main.ts` with the variables from step 3 exported in the same shell. **Read the next\nsection before expecting a full login flow to work.**\n\n## The local dev server's real limitation\n\n`GET /health`, `POST /auth/register`, and `GET /openapi.json` work against nothing but the local\nDynamoDB container — verified directly against a running `start:dev` instance on a clean clone:\n\n```\nGET  /health            -> 200 {\"status\":\"ok\"}\nPOST /auth/register      -> 201 {accountId, email}\nGET  /openapi.json       -> 200\n```\n\n`POST /auth/login`, `POST /auth/refresh`, `GET /auth/me`, and `GET /.well-known/jwks.json` do\n**not** work locally out of the box, and this is by design, not a bug to fix. Verified directly:\n\n```\nPOST /auth/login -> 503 {\"code\":\"SERVICE_UNAVAILABLE\",\"detail\":\"The security token included in\n                     the request is invalid.\"}\nGET  /.well-known/jwks.json -> 503 (same code)\n```\n\n`SigningKeyProvider`/`VerificationKeySetProvider`'s production adapters\n(`src/auth/infrastructure/keys/parameter-store-*.provider.ts`) read from real AWS Systems Manager\nParameter Store — there is no local Parameter Store emulator in this repo (`docker-compose.yml`\nonly runs DynamoDB Local), and `src/auth/auth.module.ts` wires the real\n`ParameterStoreSigningKeyProvider`/`ParameterStoreVerificationKeySetProvider` for `start:dev`\nexactly as it does for production; nothing swaps them locally the way\n`test/e2e/auth/support/build-test-app.ts` does for the test suite. `POST /auth/refresh` and\n`GET /auth/me` share the same access-token-signer / verification-key-set dependency chain\n(`src/auth/auth.module.ts`'s `accessTokenSignerProvider` and `jwtAuthGuardProvider`), so they fail\nthe same way even though they weren't hit with a valid credential in this verification.\n\nTo make the full login flow work locally, put a real RS256 private key and its matching public-key\narray into two real AWS SSM `SecureString` parameters, in an AWS account you have credentials for,\nnamed exactly what `SIGNING_KEY_PARAMETER_NAME` and `VERIFICATION_KEYS_PARAMETER_NAME` in your\n`.env` say (a personal/sandbox account works fine — `npm run keys:generate`'s output is exactly\nthe key material and `kid` those two parameters expect), and export real AWS credentials for that\naccount instead of `local`/`local` before `npm run start:dev`. No such account was set up as part\nof writing this README — this is documented as the correct, current state rather than something\nthis task is meant to route around: **the full test suite (unit, integration, e2e) needs no AWS\naccount and runs completely standalone; only the live local dev server's login/refresh/me/jwks\nroutes need real AWS SSM access.** This is expected for a Lambda-behind-Parameter-Store production\ndesign (`src/lambda.ts`), where the real target for a login flow is a deployed environment, not a\nlaptop.\n\n## 8. Browse or exercise the API\n\n```sh\nbash scripts/swagger-ui.sh\n```\n\nServes the running app's OpenAPI document in a local Swagger UI at `http://localhost:8080`,\nreverse-proxied so `Authorize` and try-it-out calls hit the real routes without a CORS error.\nRestart the script after changing a route or a schema — the spec is a snapshot taken at startup.\n\n`example-requests.http` has the same three routes (register, login, list products) chained with\nREST Client's request-variable syntax, for editors that support running `.http` files directly.\n\n## Troubleshooting\n\n- **`docker compose up -d` fails with \"port is already allocated\"** — see step 2.\n- **`db:create-table` or `start:dev` fails with `TABLE_NAME environment variable is required` (or\n  similarly for another variable)** — the shell running the command doesn't have step 3's\n  variables exported; `.env` on disk is not enough by itself.\n- **`db:create-table` or `start:dev` fails with `CredentialsProviderError: Could not load\n  credentials from any providers`** — export the two dummy AWS credential variables from step 3;\n  they are not in `.env.example` because they aren't part of this app's own config schema, only\n  the AWS SDK's.\n- **`test:e2e` fails once with a `TimeToLive is already enabled` error on a brand-new table** —\n  see step 6's note; rerun the command.\n\n## AWS deployment runbook\n\nThese commands create the AWS infrastructure declared in `terraform/`, write the real RS256 key\nmaterial, exercise the deployed endpoint, redeploy code, and tear the stack down. They need real\nAWS credentials and a `terraform` binary; the repository's own `npm run infra:check` needs neither\nand never applies anything.\n\n**Read this before applying.** Without `certificate_arn` the stack serves `POST /auth/login`,\n`POST /auth/refresh` and `GET /.well-known/jwks.json` over **plaintext HTTP** — passwords and\nrefresh tokens travel in the clear, and the JWKS document is substitutable in transit. That default\nexists so the repository is complete without a domain. Anything beyond a throwaway stack should set\n`certificate_arn`. The reasoning is in\n[ADR-0008](docs/architecture/adr/0008-alb-lambda-transport.md).\n\n### 1. Build the deployment artifact\n\n`nest build` transpiles and bundles nothing, so the archive needs production dependencies too.\n`@node-rs/argon2` ships a platform-specific native binary and the function pins `x86_64`, so the\ninstall has to resolve the linux-x64 build even when you are on macOS or arm64.\n\n```sh\nrm -f lambda.zip          # zip appends; a stale archive would keep old entries\nnpm ci\nnpm run build\nnpm ci --omit=dev --cpu=x64 --os=linux\n(cd dist && zip -qr ../lambda.zip .)\nzip -qr lambda.zip node_modules\n```\n\nThe archive lands at the repository root, not in `dist/`, because `nest-cli.json` sets\n`deleteOutDir` and the next build would delete it. `terraform.tfvars.example` points\n`lambda_package_path` at `../lambda.zip` to match.\n\nThe archive root then holds `src/lambda.js` and `node_modules/`, which is why the function's\nhandler is `src/lambda.handler`: `tsconfig.build.json` excludes only `test`, so `scripts/` is\ncompiled too and the emitted tree is `dist/src/...`, not `dist/...`.\n\n### 2. Choose your variables\n\n```sh\ncp terraform/terraform.tfvars.example terraform/terraform.tfvars\n```\n\nEdit it. `lambda_package_path` has no default and must point at the zip from step 1. Uncomment\n`certificate_arn` unless you accept the plaintext default described above. `jwt_issuer` is baked\ninto every access token and checked by `JwtAuthGuard`, so changing it later invalidates every\noutstanding token — pick it once.\n\n`terraform.tfvars` is gitignored. Do not commit it.\n\n### 3. Initialize against the state bucket\n\nThe bucket is a prerequisite Terraform cannot create for itself, since it holds Terraform's own\nstate.\n\n```sh\nterraform -chdir=terraform init \\\n  -backend-config=\"bucket=<state-bucket>\" \\\n  -backend-config=\"key=stockroom/terraform.tfstate\" \\\n  -backend-config=\"region=us-east-1\" \\\n  -backend-config=\"encrypt=true\" \\\n  -backend-config=\"use_lockfile=true\"\n```\n\n### 4. Review and apply\n\n```sh\nterraform -chdir=terraform plan -var-file=terraform.tfvars\nterraform -chdir=terraform apply -var-file=terraform.tfvars\n```\n\n### 5. Write the real key material\n\nTerraform creates the two SSM parameters with inert placeholders and never learns their real\ncontents — that is what keeps the private key out of the state file. **Generate a key pair for this\ndeployment; do not reuse `keys/private.pem`,** which `npm run keys:generate` writes for local\ndevelopment and which sits unencrypted in every developer's working tree.\n\n```sh\nmkdir -p .deploy-keys && chmod 700 .deploy-keys\nopenssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out .deploy-keys/private.pem\nopenssl rsa -in .deploy-keys/private.pem -pubout -out .deploy-keys/public.pem\n```\n\nThe verification parameter takes a **JSON array of SPKI PEM strings**, not a bare PEM.\n`parsePublicKeyPems` runs `JSON.parse`, rejects anything that is not an array, then rejects any\nelement that is not a string — hand it a bare PEM and every token verification fails. Build the\narray with the Node you already have; this repository cannot produce the artifact without it, so\nit is a safer assumption than `jq`:\n\n```sh\nnode -e 'const fs=require(\"fs\"); const dir=\".deploy-keys\";\n  fs.writeFileSync(dir + \"/verification-keys.json\",\n    JSON.stringify([fs.readFileSync(dir + \"/public.pem\", \"utf8\")]))'\n```\n\nWrite both, reading the parameter names from the stack rather than re-deriving them:\n\n```sh\naws ssm put-parameter --overwrite --type SecureString \\\n  --name \"$(terraform -chdir=terraform output -raw signing_key_parameter_name)\" \\\n  --value \"file://.deploy-keys/private.pem\"\naws ssm put-parameter --overwrite --type SecureString \\\n  --name \"$(terraform -chdir=terraform output -raw verification_keys_parameter_name)\" \\\n  --value \"file://.deploy-keys/verification-keys.json\"\n```\n\nThen move `.deploy-keys/private.pem` somewhere it belongs — a password manager, or a KMS-encrypted\nbucket — and delete the local copy. It is the production signing key.\n\n### 6. Exercise the endpoint\n\n```sh\ncurl -fsS \"http://$(terraform -chdir=terraform output -raw load_balancer_dns_name)/health\"\n```\n\nUse `https://` instead if you supplied a certificate.\n\n### 7. Redeploy application code\n\nTerraform does not own the code-deploy loop: the function declares no `source_code_hash`, so a\nrebuilt zip at the same path produces \"No changes\" while the deployed code goes stale. Publish code\ndirectly instead, and never with `apply -replace` — replacing the function invalidates the ALB\npermission and the target-group registration, and opens a 502 window.\n\n```sh\naws lambda update-function-code \\\n  --function-name \"$(terraform -chdir=terraform output -raw function_name)\" \\\n  --zip-file fileb://lambda.zip\n```\n\n### 8. Tear the stack down\n\n**This deletes the DynamoDB table and everything in it: every account, refresh token and throttle\ncounter. There is no backup step in this runbook.** The table deliberately carries no deletion\nprotection, because teardown is a documented goal of this stack; point-in-time recovery is enabled,\nbut a deleted table takes its PITR window with it.\n\nThe two SSM parameters carry `prevent_destroy`, so teardown refuses to run until they are released.\nThat guard exists because recreating a parameter overwrites your real signing key with the\nplaceholder, silently. Release them deliberately, tear the stack down, then remove them by hand:\n\n```sh\nterraform -chdir=terraform state rm aws_ssm_parameter.signing_key\nterraform -chdir=terraform state rm aws_ssm_parameter.verification_keys\nterraform -chdir=terraform apply -destroy -var-file=terraform.tfvars\naws ssm delete-parameter --name \"/<project_name>/jwt/signing-key\"\naws ssm delete-parameter --name \"/<project_name>/jwt/verification-keys\"\n```\n\n### Recovering from a lost or mismatched state file\n\nEvery resource name is a fixed string, so applying against an empty state fails with \"already\nexists\" rather than adopting what is already there. Do **not** delete the live resources to get\npast it — that destroys the table. Import them instead:\n\n```sh\nterraform -chdir=terraform import -var-file=terraform.tfvars \\\n  aws_dynamodb_table.stockroom \"<project_name>-table\"\n```\n\nRepeat for each resource the plan reports as new, then re-run `plan` until it comes back empty.\n\n**Do not import the two SSM parameters.** Importing records no `value_wo_version` in state, while\n`data.tf` declares `value_wo_version = 1`, so the next apply sees a version change and writes the\nplaceholder over your real signing key. `prevent_destroy` does not stop this: it guards destroy\nand replacement, not an in-place update. Leave the parameters out of state — the running function\nreads them from SSM directly and never consults Terraform — and if you have already imported one,\nre-run step 5 to rewrite the real key material **before** the next apply.\n",
  "bytes": 17654,
  "sha": "e86e9e257b57c17c1d6a6595618d9faaf5ded3a2b2a8b782bba1dc2c9b31e120",
  "repo_slug": "caiowf/stockroom-api",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/okf_caiowf_stockroom_api_docs_index_md_0305cd90/readme"
}