diff --git a/.github/workflows/_terraform-apply.yml b/.github/workflows/_terraform-apply.yml index 8f4ddb87..245cbea8 100644 --- a/.github/workflows/_terraform-apply.yml +++ b/.github/workflows/_terraform-apply.yml @@ -53,7 +53,7 @@ jobs: - name: Initialize Terraform CDK configuration shell: bash - working-directory: infra + working-directory: infra/cdktf run: | pnpm cdktf get pnpm build:tsc @@ -68,7 +68,7 @@ jobs: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} shell: bash - working-directory: infra + working-directory: infra/cdktf run: | cf api https://api.fr.cloud.gov DEPLOY_ENV=${DEPLOY_ENV} pnpm cdktf deploy --auto-approve diff --git a/.github/workflows/_terraform-plan-pr-comment.yml b/.github/workflows/_terraform-plan-pr-comment.yml index 5340be84..f48100da 100644 --- a/.github/workflows/_terraform-plan-pr-comment.yml +++ b/.github/workflows/_terraform-plan-pr-comment.yml @@ -55,7 +55,7 @@ jobs: - name: Initialize Terraform CDK configuration shell: bash - working-directory: infra + working-directory: infra/cdktf run: | pnpm cdktf get pnpm build:tsc @@ -70,13 +70,13 @@ jobs: cf api https://api.fr.cloud.gov - name: Synthesize Terraform configuration - working-directory: infra + working-directory: infra/cdktf run: | DEPLOY_ENV=${DEPLOY_ENV} pnpm cdktf synth - name: Get Terraform stack name id: get_stack_name - working-directory: infra + working-directory: infra/cdktf run: | DEPLOY_ENV=${DEPLOY_ENV} pnpm cdktf output --outputs-file outputs.json echo "stack_name=$(jq -r 'keys[0]' outputs.json)" >> $GITHUB_OUTPUT @@ -84,5 +84,5 @@ jobs: - name: Create Terraform plan uses: dflook/terraform-plan@v1 with: - path: infra/cdktf.out/stacks/${{ steps.get_stack_name.outputs.stack_name }} + path: infra/cdktf/cdktf.out/stacks/${{ steps.get_stack_name.outputs.stack_name }} label: ${{ steps.get_stack_name.outputs.stack_name }} diff --git a/.github/workflows/_validate.yml b/.github/workflows/_validate.yml index 9b863032..70edf56a 100644 --- a/.github/workflows/_validate.yml +++ b/.github/workflows/_validate.yml @@ -56,12 +56,12 @@ jobs: - name: Run test suite shell: bash - run: pnpm test:ci + run: AUTH_SECRET=not-super-secret pnpm test:ci - name: Initialize Terraform CDK configuration shell: bash run: | - cd infra + cd infra/cdktf pnpm cdktf get pnpm build:tsc @@ -69,6 +69,6 @@ jobs: shell: bash run: pnpm typecheck - #- name: Vitest Coverage Report - # if: always() - # uses: davelosert/vitest-coverage-report-action@v2.2.0 + # - name: Vitest Coverage Report + # if: always() + # uses: davelosert/vitest-coverage-report-action@v2.5.0 diff --git a/.github/workflows/add-terraform-plan-to-pr.yml b/.github/workflows/add-terraform-plan-to-pr.yml index fa0f3562..b072931f 100644 --- a/.github/workflows/add-terraform-plan-to-pr.yml +++ b/.github/workflows/add-terraform-plan-to-pr.yml @@ -15,4 +15,4 @@ jobs: uses: ./.github/workflows/_terraform-plan-pr-comment.yml secrets: inherit with: - deploy-env: staging + deploy-env: ${{ github.base_ref }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 8b884b8e..0c8e3e06 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -27,4 +27,4 @@ jobs: uses: ./.github/workflows/_terraform-apply.yml secrets: inherit with: - deploy-env: ${{ github.ref_name }} \ No newline at end of file + deploy-env: ${{ github.ref_name }} diff --git a/.nvmrc b/.nvmrc index c12134be..80a9956e 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v20.15.0 +v20.16.0 diff --git a/apps/cli/README.md b/apps/cli/README.md index 560a89f4..0be9620d 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -1,13 +1,11 @@ # @atj/cli-app -This package includes a very simple command-line interface. +This package defines the platform's command-line interface. -## Example commands - -Examples: +Commands are defined to aid with platform management operations. To see available commands, run: ```bash -pnpm run cli create-workspace-graph +pnpm cli --help ``` ## Development diff --git a/apps/cli/build.js b/apps/cli/build.js new file mode 100644 index 00000000..0180ce36 --- /dev/null +++ b/apps/cli/build.js @@ -0,0 +1,14 @@ +const esbuild = require('esbuild'); + +esbuild + .build({ + bundle: true, + entryPoints: ['./src/index.ts'], + format: 'cjs', + minify: true, + outdir: './dist', + platform: 'node', + sourcemap: true, + target: 'es2020', + }) + .catch(() => process.exit(1)); diff --git a/apps/cli/package.json b/apps/cli/package.json index fc6e41ae..8e4d6007 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -6,13 +6,14 @@ "license": "CC0", "main": "src/index.ts", "scripts": { - "build": "echo 'skipping...' #tsc -p .", - "cli": "ts-node src/index.ts", + "build": "node ./build.js", + "cli": "node dist/index.js", "dev": "tsup src/* --watch", "test": "vitest run --coverage" }, "dependencies": { "@atj/dependency-graph": "workspace:*", + "@atj/infra-core": "workspace:*", "commander": "^11.1.0" } } diff --git a/apps/cli/src/cli-controller.ts b/apps/cli/src/cli-controller.ts deleted file mode 100644 index 91c4bf10..00000000 --- a/apps/cli/src/cli-controller.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { Command } from 'commander'; - -import { createDependencyGraph } from '@atj/dependency-graph'; - -type Context = { - console: Console; - workspaceRoot: string; - //docassemble: DocassembleClientContext; -}; - -export const CliController = (ctx: Context) => { - const cli = new Command().description( - 'CLI to interact with the ATJ workspace' - ); - - cli - .command('hello') - .description('say hello') - .action(() => { - ctx.console.log('Hello!'); - }); - - cli - .command('create-workspace-graph') - .description('create a dependency graph of projects in the workspace') - .action(async () => { - await createDependencyGraph(ctx.workspaceRoot); - ctx.console.log('wrote workspace dependency graph'); - }); - - /* - const docassemble = cli - .command('docassemble') - .description('docassemble commands'); - - docassemble - .command('populate') - .description('populate a docassemble instance with test data') - .option( - '-r, --repository', - 'repository to populate from', - 'https://github.com/SuffolkLITLab/docassemble-MassAccess' - ) - .option('-b, --branch', 'branch of git repository to populate from', 'main') - .action(async ({ repository, branch }) => { - const client = new DocassembleClient(ctx.docassemble); - const result = await client.addPackage(repository, branch); - ctx.console.log('populated docassemble instance', result); - }); - - docassemble - .command('list-interviews') - .description('list docassemble interviews') - .action(async () => { - const client = new DocassembleClient(ctx.docassemble); - const interviews = await client.getInterviews(); - ctx.console.log('populated docassemble instance'); - }); - */ - - return cli; -}; diff --git a/apps/cli/src/cli-controller.test.ts b/apps/cli/src/cli-controller/cli-controller.test.ts similarity index 75% rename from apps/cli/src/cli-controller.test.ts rename to apps/cli/src/cli-controller/cli-controller.test.ts index 0e9c43f8..67ac22cc 100644 --- a/apps/cli/src/cli-controller.test.ts +++ b/apps/cli/src/cli-controller/cli-controller.test.ts @@ -1,18 +1,13 @@ import { describe, expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; -import { CliController } from './cli-controller'; +import { CliController } from '.'; describe('cli controller', () => { it('works', async () => { const ctx = { console: mock({ log: vi.fn() }), workspaceRoot: '.', - docassemble: { - fetch: fetch, - apiUrl: '', - apiKey: '', - }, }; const app = CliController(ctx); await app.parseAsync(['node.js', 'dist/index.js', 'hello']); diff --git a/apps/cli/src/cli-controller/index.ts b/apps/cli/src/cli-controller/index.ts new file mode 100644 index 00000000..da9815d7 --- /dev/null +++ b/apps/cli/src/cli-controller/index.ts @@ -0,0 +1,30 @@ +import { Command } from 'commander'; + +import { createDependencyGraph } from '@atj/dependency-graph'; +import type { Context } from './types'; +import { addSecretCommands } from './secrets'; + +export const CliController = (ctx: Context) => { + const cli = new Command().description( + 'CLI to interact with the ATJ workspace' + ); + + cli + .command('hello') + .description('say hello') + .action(() => { + ctx.console.log('Hello!'); + }); + + cli + .command('create-workspace-graph') + .description('create a dependency graph of projects in the workspace') + .action(async () => { + await createDependencyGraph(ctx.workspaceRoot); + ctx.console.log('wrote workspace dependency graph'); + }); + + addSecretCommands(ctx, cli); + + return cli; +}; diff --git a/apps/cli/src/cli-controller/secrets.ts b/apps/cli/src/cli-controller/secrets.ts new file mode 100644 index 00000000..749997dd --- /dev/null +++ b/apps/cli/src/cli-controller/secrets.ts @@ -0,0 +1,85 @@ +import { Command } from 'commander'; + +import { commands, getSecretsVault } from '@atj/infra-core'; +import { Context } from './types'; +import { type DeployEnv } from '@atj/infra-core/src/values'; +import path from 'path'; + +export const addSecretCommands = (ctx: Context, cli: Command) => { + const cmd = cli + .command('secrets') + .option('-f, --file ', 'Source JSON file for secrets.', path => { + ctx.file = path; + }) + .description('secrets management commands'); + + cmd + .command('delete') + .description('delete a secret') + .argument('', 'secret key name') + .action(async (key: string) => { + const vault = await getSecretsVault(ctx.file); + await commands.deleteSecret(vault, key); + }); + + cmd + .command('get') + .description('get a secret value') + .argument('', 'secret key name') + .action(async (key: string) => { + const vault = await getSecretsVault(ctx.file); + const secret = await commands.getSecret(vault, key); + console.log(secret); + }); + + cmd + .command('set') + .description('set a secret value') + .argument('', 'secret key name') + .argument('', 'secret value to set') + .action(async (key: string, value: string) => { + const vault = await getSecretsVault(ctx.file); + await commands.setSecret(vault, key, value); + }); + + cmd + .command('list') + .description('list all secret keys') + .action(async () => { + const vault = await getSecretsVault(ctx.file); + const secretKeys = await commands.getSecretKeyList(vault); + console.log(JSON.stringify(secretKeys, null, 2)); + }); + + cmd + .command('show') + .description('show all secrets') + .action(async () => { + const vault = await getSecretsVault(ctx.file); + const allSecrets = await commands.getSecrets(vault); + console.log(JSON.stringify(allSecrets, null, 2)); + }); + + cmd + .command('set-login-gov-keys') + .description( + 'generate and save login.gov keypair; if it already exists, it is not ' + + 'updated (future work might include adding key rotation)' + ) + .argument('', 'deployment environment (dev, staging)') + .argument('', 'application key') + .action(async (env: DeployEnv, appKey: string) => { + const vault = await getSecretsVault(ctx.file); + const secretsDir = path.resolve(__dirname, '../../../infra/secrets'); + const loginResult = await commands.setLoginGovSecrets( + { vault, secretsDir }, + env, + appKey + ); + if (loginResult.preexisting) { + console.log('Keypair already exists.'); + } else { + console.log(`New keypair added`); + } + }); +}; diff --git a/apps/cli/src/cli-controller/types.ts b/apps/cli/src/cli-controller/types.ts new file mode 100644 index 00000000..90b95261 --- /dev/null +++ b/apps/cli/src/cli-controller/types.ts @@ -0,0 +1,5 @@ +export type Context = { + console: Console; + workspaceRoot: string; + file?: string; +}; diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 4987c825..0c086493 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -1,6 +1,5 @@ -import { join } from 'path'; -import process from 'process'; -import { CliController } from './cli-controller'; +const { join } = require('path'); +const { CliController } = require('./cli-controller'); // This should map to the directory containing the package.json. // By convention, assume that the originating process was run from the root @@ -10,10 +9,5 @@ const workspaceRoot = join(process.cwd(), '../../'); const app = CliController({ console, workspaceRoot, - /*docassemble: { - fetch, - apiUrl: 'http://localhost:8011', - apiKey: process.env.VITE_DOCASSEMBLE_API_KEY || '', - },*/ }); -app.parseAsync(process.argv).then(() => console.log('Done')); +app.parseAsync(process.argv).then(() => console.error('Exiting...')); diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index a165fb86..ea22b13a 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "module": "CommonJS", + "module": "ESNext", "outDir": "./dist", "emitDeclarationOnly": true }, diff --git a/apps/cli/tsup.config.ts b/apps/cli/tsup.config.ts new file mode 100644 index 00000000..7b9a120f --- /dev/null +++ b/apps/cli/tsup.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entryPoints: ['src/index.ts'], + format: ['cjs', 'esm'], + target: 'es2020', + minify: true, + sourcemap: true, + clean: true, + bundle: true, +}); diff --git a/apps/rest-api/package.json b/apps/rest-api/package.json index 4d287506..019867e3 100644 --- a/apps/rest-api/package.json +++ b/apps/rest-api/package.json @@ -13,7 +13,7 @@ "@atj/forms": "workspace:*" }, "devDependencies": { - "@types/aws-lambda": "^8.10.109", + "@types/aws-lambda": "^8.10.143", "esbuild": "^0.20.2" } } diff --git a/apps/server-doj/package.json b/apps/server-doj/package.json index a897725c..35e5e103 100644 --- a/apps/server-doj/package.json +++ b/apps/server-doj/package.json @@ -12,10 +12,11 @@ "test": "vitest run --coverage" }, "dependencies": { + "@atj/database": "workspace:*", "@atj/server": "workspace:*" }, "devDependencies": { - "@types/node": "^20.14.8", + "@types/node": "^20.14.14", "@types/supertest": "^6.0.2", "supertest": "^7.0.0" } diff --git a/apps/server-doj/src/index.ts b/apps/server-doj/src/index.ts index 82e7c8ce..f5d64a6e 100644 --- a/apps/server-doj/src/index.ts +++ b/apps/server-doj/src/index.ts @@ -1,4 +1,4 @@ -import { createCustomServer } from './server'; +import { createCustomServer } from './server.js'; const port = process.env.PORT || 4321; const app = await createCustomServer(); diff --git a/apps/server-doj/src/server.ts b/apps/server-doj/src/server.ts index 0ea9d736..62837720 100644 --- a/apps/server-doj/src/server.ts +++ b/apps/server-doj/src/server.ts @@ -1,7 +1,38 @@ -import { createServer } from '@atj/server/dist/index.js'; +import path, { dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const getDirname = () => dirname(fileURLToPath(import.meta.url)); + +export const createCustomServer = async (): Promise => { + const { createDevDatabaseContext, createDatabaseGateway } = await import( + '@atj/database' + ); + const { createServer } = await import('@atj/server'); + + const dbCtx = await createDevDatabaseContext( + path.join(getDirname(), '../doj.db') + ); + const db = createDatabaseGateway(dbCtx); -export const createCustomServer = () => { return createServer({ title: 'DOJ Form Service', + db, + loginGovOptions: { + loginGovUrl: 'https://idp.int.identitysandbox.gov', + clientId: + 'urn:gov:gsa:openidconnect.profiles:sp:sso:gsa:tts-10x-atj-dev-server-doj', + //clientSecret: '', // secrets.loginGovClientSecret, + }, }); }; + +/* +const getServerSecrets = () => { + const services = JSON.parse(process.env.VCAP_SERVICES || '{}'); + const loginClientSecret = + services['user-provided']?.credentials?.SECRET_LOGIN_GOV_PRIVATE_KEY; + return { + loginGovClientSecret: loginClientSecret, + }; +}; +*/ diff --git a/apps/server-doj/tsconfig.json b/apps/server-doj/tsconfig.json index 1e4b75b9..a7c700ff 100644 --- a/apps/server-doj/tsconfig.json +++ b/apps/server-doj/tsconfig.json @@ -3,7 +3,8 @@ "compilerOptions": { "module": "ESNext", "emitDeclarationOnly": true, - "outDir": "./dist" + "outDir": "./dist", + "target": "es2022" }, "include": ["./src/**/*"], "exclude": ["./dist"], diff --git a/apps/server-kansas/package.json b/apps/server-kansas/package.json index 57692999..df2b53dc 100644 --- a/apps/server-kansas/package.json +++ b/apps/server-kansas/package.json @@ -12,10 +12,11 @@ "test": "vitest run --coverage" }, "dependencies": { + "@atj/database": "workspace:*", "@atj/server": "workspace:*" }, "devDependencies": { - "@types/node": "^20.14.8", + "@types/node": "^20.14.14", "@types/supertest": "^6.0.2", "supertest": "^7.0.0" } diff --git a/apps/server-kansas/src/server.ts b/apps/server-kansas/src/server.ts index c110b6ff..d8249180 100644 --- a/apps/server-kansas/src/server.ts +++ b/apps/server-kansas/src/server.ts @@ -1,7 +1,38 @@ -import { createServer } from '@atj/server/dist/index.js'; +import path, { dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const getDirname = () => dirname(fileURLToPath(import.meta.url)); + +export const createCustomServer = async (): Promise => { + const { createDevDatabaseContext, createDatabaseGateway } = await import( + '@atj/database' + ); + const { createServer } = await import('@atj/server'); + + const dbCtx = await createDevDatabaseContext( + path.join(getDirname(), '../doj.db') + ); + const db = createDatabaseGateway(dbCtx); -export const createCustomServer = () => { return createServer({ title: 'KS Courts Form Service', + db, + loginGovOptions: { + loginGovUrl: 'https://idp.int.identitysandbox.gov', + clientId: + 'urn:gov:gsa:openidconnect.profiles:sp:sso:gsa:tts-10x-atj-dev-server-doj', + //clientSecret: '', // secrets.loginGovClientSecret, + }, }); }; + +/* +const getServerSecrets = () => { + const services = JSON.parse(process.env.VCAP_SERVICES || '{}'); + const loginClientSecret = + services['user-provided']?.credentials?.SECRET_LOGIN_GOV_PRIVATE_KEY; + return { + loginGovClientSecret: loginClientSecret, + }; +}; +*/ diff --git a/apps/spotlight/astro.config.mjs b/apps/spotlight/astro.config.mjs index 64c13689..01f796f2 100644 --- a/apps/spotlight/astro.config.mjs +++ b/apps/spotlight/astro.config.mjs @@ -7,13 +7,16 @@ const githubRepository = await getGithubRepository(process.env); // https://astro.build/config export default defineConfig({ - trailingSlash: 'always', base: addTrailingSlash(process.env.BASEURL || ''), integrations: [ react({ include: ['src/components/react/**'], }), ], + security: { + checkOrigin: true, + }, + trailingSlash: 'always', vite: { define: { 'import.meta.env.GITHUB': JSON.stringify(githubRepository), diff --git a/apps/spotlight/package.json b/apps/spotlight/package.json index d31d8ab2..0a9a10f1 100644 --- a/apps/spotlight/package.json +++ b/apps/spotlight/package.json @@ -10,16 +10,16 @@ "preview": "astro preview" }, "dependencies": { - "@astrojs/react": "^3.0.9", + "@astrojs/react": "^3.6.1", "@atj/design": "workspace:*", "@atj/forms": "workspace:*", - "astro": "^4.3.3", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "react-error-boundary": "^4.0.12" + "astro": "^4.13.2", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-error-boundary": "^4.0.13" }, "devDependencies": { "@astrojs/check": "^0.4.1", - "@types/react": "^18.2.37" + "@types/react": "^18.3.3" } } diff --git a/documents/adr/0011-secrets-management.md b/documents/adr/0011-secrets-management.md new file mode 100644 index 00000000..73275bd3 --- /dev/null +++ b/documents/adr/0011-secrets-management.md @@ -0,0 +1,31 @@ +# 11. Secrets management + +Date: 2024-07-11 + +## Status + +Approved + +## Context + +The Form Platform requires a method of managing secrets. During the early prototyping phase, we used Terraform with AWS Systems Manager Parameter Store. Secrets were manually created via the AWS console, and lookups were handled by Terraform's corresponding data provider. + +As we look to operationalize management processes, tooling for working with secrets will become increasingly helpful. This has become apparent with our first scenario, the login.gov keypair, which needs to be unique for each deployed application. + +## Decision + +We will abstract secrets management into a package in the project's monorepo, and provide commands in the existing `cli` application for common operations. + +Additionally, adapters to the backend vault will be utilized. This will enable managing secrets in a production secret store, or via in-memory or local storage for testing purposes. + +We will continue to utilize AWS Systems Manager Parameter Store. + +The implementation will live in `infra/core`, which will be the home for other abstract infrastructure code, while `infra/cdktf` will include the concrete implementation of the deployment. + +## Consequences + +Secrets management that requires repeated manual operations will be automated. In the future, this will include things like secrets rotation via the same command-line interface. + +Writing secrets management code as Typescript (rather than Terraform or shell scripts), means we can easily write unit tests for the logic, as well as maintain type-safety across infrastructure code (handling secrets wiring) and the applications that utilize secrets. + +Additionally, this approach will make migrating from one secrets storage backend vault to another very easy. We may want to move from Parameter Store to Github Secrets, to limit the surface area of cloud services we utilize. diff --git a/e2e/Dockerfile b/e2e/Dockerfile index 2a2a23d9..018501ea 100644 --- a/e2e/Dockerfile +++ b/e2e/Dockerfile @@ -1,5 +1,5 @@ # base image with Node.js and playwright preinstalled -FROM mcr.microsoft.com/playwright:v1.43.1-jammy as base +FROM mcr.microsoft.com/playwright:v1.46.0-jammy as base ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" ENV NODE_ENV=test @@ -26,4 +26,4 @@ FROM base as serve ENV E2E_ENDPOINT=http://localhost:4321 EXPOSE 4321 9292 9323 9009 8080 RUN git config --global --add safe.directory /srv/apps/atj-platform -CMD ["pnpm", "dev"] \ No newline at end of file +CMD ["pnpm", "dev"] diff --git a/e2e/package.json b/e2e/package.json index db823b34..947c3973 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -7,9 +7,9 @@ "test": "export E2E_ENDPOINT=http://localhost:4321; pnpm playwright test --ui-port=8080 --ui-host=0.0.0.0" }, "devDependencies": { - "@playwright/test": "^1.43.1", - "@storybook/test-runner": "^0.16.0", - "path-to-regexp": "^7.0.0" + "@playwright/test": "^1.46.0", + "@storybook/test-runner": "^0.19.1", + "path-to-regexp": "^7.1.0" }, "dependencies": { "@atj/common": "workspace:*" diff --git a/esbuild.config.js b/esbuild.config.js new file mode 100644 index 00000000..b1e3e14c --- /dev/null +++ b/esbuild.config.js @@ -0,0 +1,12 @@ +module.exports = { + target: 'esnext', + format: 'esm', + bundle: true, + minify: true, + external: [], + loader: { + '.ts': 'ts', + '.tsx': 'tsx', + }, + outdir: 'dist', +}; diff --git a/infra/.gitignore b/infra/cdktf/.gitignore similarity index 96% rename from infra/.gitignore rename to infra/cdktf/.gitignore index 82c4875d..9683373c 100644 --- a/infra/.gitignore +++ b/infra/cdktf/.gitignore @@ -1,6 +1,7 @@ tsconfig.tsbuildinfo cdktf.out/ dist/ +secrets/ # This package does not build to a dist directory, so add explicity exclude # patterns for the Typescript artifacts. diff --git a/infra/README.md b/infra/cdktf/README.md similarity index 100% rename from infra/README.md rename to infra/cdktf/README.md diff --git a/infra/__tests__/main-test.ts b/infra/cdktf/__tests__/main-test.ts similarity index 100% rename from infra/__tests__/main-test.ts rename to infra/cdktf/__tests__/main-test.ts diff --git a/infra/cdktf.json b/infra/cdktf/cdktf.json similarity index 100% rename from infra/cdktf.json rename to infra/cdktf/cdktf.json diff --git a/infra/jest.config.js b/infra/cdktf/jest.config.js similarity index 100% rename from infra/jest.config.js rename to infra/cdktf/jest.config.js diff --git a/infra/jest.setup.js b/infra/cdktf/jest.setup.js similarity index 100% rename from infra/jest.setup.js rename to infra/cdktf/jest.setup.js diff --git a/infra/package.json b/infra/cdktf/package.json similarity index 77% rename from infra/package.json rename to infra/cdktf/package.json index cf455b17..d330b041 100644 --- a/infra/package.json +++ b/infra/cdktf/package.json @@ -1,5 +1,5 @@ { - "name": "infra", + "name": "@atj/infra-cdktf", "version": "1.0.0", "main": "src/index.js", "types": "src/index.ts", @@ -18,14 +18,15 @@ "test:watch": "jest --watch" }, "dependencies": { - "cdktf": "^0.20.7", - "cdktf-cli": "^0.20.7", + "@aws-sdk/client-ssm": "^3.624.0", + "cdktf": "^0.20.8", + "cdktf-cli": "^0.20.8", "constructs": "^10.3.0" }, "devDependencies": { - "@types/jest": "^29.5.6", - "@types/node": "^20.8.7", + "@types/jest": "^29.5.12", + "@types/node": "^20.14.14", "jest": "^29.7.0", - "ts-jest": "^29.1.1" + "ts-jest": "^29.2.4" } } diff --git a/infra/scripts/cloud.sh b/infra/cdktf/scripts/cloud.sh similarity index 100% rename from infra/scripts/cloud.sh rename to infra/cdktf/scripts/cloud.sh diff --git a/infra/scripts/recreate.sh b/infra/cdktf/scripts/recreate.sh similarity index 100% rename from infra/scripts/recreate.sh rename to infra/cdktf/scripts/recreate.sh diff --git a/infra/src/index.ts b/infra/cdktf/src/index.ts similarity index 100% rename from infra/src/index.ts rename to infra/cdktf/src/index.ts diff --git a/infra/src/lib/app-stack.ts b/infra/cdktf/src/lib/app-stack.ts similarity index 82% rename from infra/src/lib/app-stack.ts rename to infra/cdktf/src/lib/app-stack.ts index d171b876..0b1e2e3a 100644 --- a/infra/src/lib/app-stack.ts +++ b/infra/cdktf/src/lib/app-stack.ts @@ -8,15 +8,18 @@ import { withBackend } from './backend'; import { CloudGovSpace } from './cloud.gov/space'; import { DataAwsSsmParameter } from '../../.gen/providers/aws/data-aws-ssm-parameter'; -export const registerAppStack = (stackPrefix: string, deployEnv: string) => { +export const registerAppStack = ( + stackPrefix: string, + gitCommitHash: string +) => { const app = new App(); - const stack = new AppStack(app, stackPrefix, deployEnv); + const stack = new AppStack(app, stackPrefix, gitCommitHash); withBackend(stack, stackPrefix); app.synth(); }; class AppStack extends TerraformStack { - constructor(scope: Construct, id: string, deployEnv: string) { + constructor(scope: Construct, id: string, gitCommitHash: string) { super(scope, id); new AwsProvider(this, 'AWS', { @@ -45,7 +48,7 @@ class AppStack extends TerraformStack { password: cfPassword.value, }); - new CloudGovSpace(this, id, deployEnv); + new CloudGovSpace(this, id, gitCommitHash); //new Docassemble(this, `${id}-docassemble`); //new FormService(this, `${id}-rest-api`); diff --git a/infra/src/lib/backend.ts b/infra/cdktf/src/lib/backend.ts similarity index 100% rename from infra/src/lib/backend.ts rename to infra/cdktf/src/lib/backend.ts diff --git a/infra/src/lib/cloud.gov/config.ts b/infra/cdktf/src/lib/cloud.gov/config.ts similarity index 100% rename from infra/src/lib/cloud.gov/config.ts rename to infra/cdktf/src/lib/cloud.gov/config.ts diff --git a/infra/src/lib/cloud.gov/index.ts b/infra/cdktf/src/lib/cloud.gov/index.ts similarity index 100% rename from infra/src/lib/cloud.gov/index.ts rename to infra/cdktf/src/lib/cloud.gov/index.ts diff --git a/infra/src/lib/cloud.gov/node-astro.ts b/infra/cdktf/src/lib/cloud.gov/node-astro.ts similarity index 59% rename from infra/src/lib/cloud.gov/node-astro.ts rename to infra/cdktf/src/lib/cloud.gov/node-astro.ts index d437cdc2..a7ed8ed4 100644 --- a/infra/src/lib/cloud.gov/node-astro.ts +++ b/infra/cdktf/src/lib/cloud.gov/node-astro.ts @@ -6,7 +6,10 @@ export class AstroService extends Construct { scope: Construct, id: string, spaceId: string, - imageName: `${string}:${string}` + imageName: `${string}:${string}`, + secrets: { + loginGovPrivateKey: string; + } ) { super(scope, id); @@ -19,12 +22,25 @@ export class AstroService extends Construct { } ); - const route = new cloudfoundry.route.Route(scope, `${id}-route`, { + const route = new cloudfoundry.route.Route(this, `${id}-route`, { domain: domain.id, space: spaceId, hostname: id, }); + const loginGovService = + new cloudfoundry.userProvidedService.UserProvidedService( + this, + `${id}-login-gov-service`, + { + name: `${id}-login-gov-service`, + space: spaceId, + credentials: { + loginGovPrivateKey: secrets.loginGovPrivateKey, + }, + } + ); + new cloudfoundry.app.App(this, `${id}-app`, { name: `${id}-app`, space: spaceId, @@ -38,6 +54,11 @@ export class AstroService extends Construct { route: route.id, }, ], + serviceBinding: [ + { + serviceInstance: loginGovService.id, + }, + ], }); } } diff --git a/infra/src/lib/cloud.gov/space.ts b/infra/cdktf/src/lib/cloud.gov/space.ts similarity index 58% rename from infra/src/lib/cloud.gov/space.ts rename to infra/cdktf/src/lib/cloud.gov/space.ts index 4e2e4228..77c36925 100644 --- a/infra/src/lib/cloud.gov/space.ts +++ b/infra/cdktf/src/lib/cloud.gov/space.ts @@ -3,9 +3,10 @@ import { Construct } from 'constructs'; import * as cloudfoundry from '../../../.gen/providers/cloudfoundry'; import { CLOUD_GOV_ORG_NAME } from './config'; import { AstroService } from './node-astro'; +import { getSecret } from '../secrets'; export class CloudGovSpace extends Construct { - constructor(scope: Construct, id: string, deployEnv: string) { + constructor(scope: Construct, id: string, gitCommitHash: string) { super(scope, id); const space = new cloudfoundry.dataCloudfoundrySpace.DataCloudfoundrySpace( @@ -21,13 +22,25 @@ export class CloudGovSpace extends Construct { scope, `${id}-server-doj`, space.id, - `server-doj:${deployEnv}` + `server-doj:${gitCommitHash}`, + { + loginGovPrivateKey: getSecret( + this, + `/${id}/server-doj/login.gov/private-key` + ), + } ); new AstroService( scope, `${id}-server-kansas`, space.id, - `server-kansas:${deployEnv}` + `server-kansas:${gitCommitHash}`, + { + loginGovPrivateKey: getSecret( + this, + `/${id}/server-kansas/login.gov/private-key` + ), + } ); } } diff --git a/infra/src/lib/docassemble.ts b/infra/cdktf/src/lib/docassemble.ts similarity index 100% rename from infra/src/lib/docassemble.ts rename to infra/cdktf/src/lib/docassemble.ts diff --git a/infra/src/lib/rest-api.ts b/infra/cdktf/src/lib/rest-api.ts similarity index 100% rename from infra/src/lib/rest-api.ts rename to infra/cdktf/src/lib/rest-api.ts diff --git a/infra/cdktf/src/lib/secrets.ts b/infra/cdktf/src/lib/secrets.ts new file mode 100644 index 00000000..5aa4a310 --- /dev/null +++ b/infra/cdktf/src/lib/secrets.ts @@ -0,0 +1,9 @@ +import { Construct } from 'constructs'; +import { DataAwsSsmParameter } from '../../.gen/providers/aws/data-aws-ssm-parameter'; + +export const getSecret = (scope: Construct, name: string) => { + const parameter = new DataAwsSsmParameter(scope, name, { + name, + }); + return parameter.value; +}; diff --git a/infra/cdktf/src/spaces/main.ts b/infra/cdktf/src/spaces/main.ts new file mode 100644 index 00000000..839824f9 --- /dev/null +++ b/infra/cdktf/src/spaces/main.ts @@ -0,0 +1,6 @@ +import { execSync } from 'child_process'; + +import { registerAppStack } from '../lib/app-stack'; + +const gitCommitHash = execSync('git rev-parse HEAD').toString().trim(); +registerAppStack('tts-10x-atj-dev', gitCommitHash); diff --git a/infra/cdktf/src/spaces/staging.ts b/infra/cdktf/src/spaces/staging.ts new file mode 100644 index 00000000..ac44cf85 --- /dev/null +++ b/infra/cdktf/src/spaces/staging.ts @@ -0,0 +1,6 @@ +import { execSync } from 'child_process'; + +import { registerAppStack } from '../lib/app-stack'; + +const gitCommitHash = execSync('git rev-parse HEAD').toString().trim(); +registerAppStack('tts-10x-atj-staging', gitCommitHash); diff --git a/infra/tsconfig.json b/infra/cdktf/tsconfig.json similarity index 100% rename from infra/tsconfig.json rename to infra/cdktf/tsconfig.json diff --git a/infra/core/.gitignore b/infra/core/.gitignore new file mode 100644 index 00000000..849ddff3 --- /dev/null +++ b/infra/core/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/infra/core/README.md b/infra/core/README.md new file mode 100644 index 00000000..e69de29b diff --git a/infra/core/build.js b/infra/core/build.js new file mode 100644 index 00000000..2f942bc6 --- /dev/null +++ b/infra/core/build.js @@ -0,0 +1,14 @@ +import esbuild from 'esbuild'; + +esbuild + .build({ + //bundle: true, + entryPoints: ['./src/index.ts'], + format: 'esm', + minify: true, + outdir: './dist', + platform: 'node', + sourcemap: true, + target: 'es2020', + }) + .catch(() => process.exit(1)); diff --git a/infra/core/package.json b/infra/core/package.json new file mode 100644 index 00000000..ea4873ed --- /dev/null +++ b/infra/core/package.json @@ -0,0 +1,20 @@ +{ + "name": "@atj/infra-core", + "version": "1.0.0", + "description": "10x ATJ secrets storage backend", + "type": "module", + "license": "CC0", + "main": "dist/index.js", + "types": "dist/index.d.js", + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "test": "vitest run --coverage" + }, + "dependencies": { + "@atj/common": "workspace:*", + "@atj/forms": "workspace:*", + "@aws-sdk/client-ssm": "^3.624.0", + "zod": "^3.23.8" + } +} diff --git a/infra/core/src/commands/delete-secret.test.ts b/infra/core/src/commands/delete-secret.test.ts new file mode 100644 index 00000000..fe8f39ed --- /dev/null +++ b/infra/core/src/commands/delete-secret.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import { createInMemorySecretsVault } from '../lib'; +import { getSecretKeyList } from './get-secret-key-list'; +import { deleteSecret } from './delete-secret'; + +const getTestVault = (vaultData: any) => { + const result = createInMemorySecretsVault(JSON.stringify(vaultData)); + if (result.success) { + return result.data; + } else { + throw new Error('Error creating in-memory test vault'); + } +}; + +describe('delete-secret command', () => { + it('removes key', async () => { + const vault = getTestVault({ + 'secret-key-1': 'value-1', + }); + await deleteSecret(vault, 'secret-key-1'); + expect(await vault.getSecretKeys()).toEqual([]); + }); + + it('silently handles non-existent keys', async () => { + const vault = getTestVault({}); + await deleteSecret(vault, 'secret-key-1'); + expect(await vault.getSecretKeys()).toEqual([]); + }); +}); diff --git a/infra/core/src/commands/delete-secret.ts b/infra/core/src/commands/delete-secret.ts new file mode 100644 index 00000000..1de128f7 --- /dev/null +++ b/infra/core/src/commands/delete-secret.ts @@ -0,0 +1,5 @@ +import type { SecretKey, SecretsVault } from '../lib/types'; + +export const deleteSecret = async (vault: SecretsVault, key: SecretKey) => { + return await vault.deleteSecret(key); +}; diff --git a/infra/core/src/commands/get-secret-key-list.test.ts b/infra/core/src/commands/get-secret-key-list.test.ts new file mode 100644 index 00000000..8534b72f --- /dev/null +++ b/infra/core/src/commands/get-secret-key-list.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; + +import { createInMemorySecretsVault } from '../lib'; +import { getSecretKeyList } from './get-secret-key-list'; + +const getTestVault = (vaultData: any) => { + const result = createInMemorySecretsVault(JSON.stringify(vaultData)); + if (result.success) { + return result.data; + } else { + throw new Error('Error creating in-memory test vault'); + } +}; + +describe('list-secret-keys command', () => { + it('gets keys for vault', async () => { + const vault = getTestVault({ + 'secret-key-1': 'value-1', + 'secret-key-2': 'value-2', + 'secret-key-3': 'value-3', + }); + const keys = await getSecretKeyList(vault); + expect(keys).toEqual(['secret-key-1', 'secret-key-2', 'secret-key-3']); + }); + + it('returns empty array for empty vault', async () => { + const vault = getTestVault({}); + const keys = await getSecretKeyList(vault); + expect(keys).toEqual([]); + }); +}); diff --git a/infra/core/src/commands/get-secret-key-list.ts b/infra/core/src/commands/get-secret-key-list.ts new file mode 100644 index 00000000..d6787aa2 --- /dev/null +++ b/infra/core/src/commands/get-secret-key-list.ts @@ -0,0 +1,5 @@ +import { type SecretsVault } from '../lib/types'; + +export const getSecretKeyList = async (vault: SecretsVault) => { + return await vault.getSecretKeys(); +}; diff --git a/infra/core/src/commands/get-secret.test.ts b/infra/core/src/commands/get-secret.test.ts new file mode 100644 index 00000000..d3947938 --- /dev/null +++ b/infra/core/src/commands/get-secret.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; + +import { getSecret } from './get-secret'; +import { createInMemorySecretsVault } from '../lib'; + +const getTestVault = (vaultData: any) => { + const result = createInMemorySecretsVault(JSON.stringify(vaultData)); + if (result.success) { + return result.data; + } else { + throw new Error('Error creating in-memory test vault'); + } +}; + +describe('get-secret command', () => { + it('gets existing value', async () => { + const vault = getTestVault({ + 'secret-key-1': 'value-1', + }); + const value = await getSecret(vault, 'secret-key-1'); + expect(value).toEqual('value-1'); + }); + + it('return undefined with non-existing value', async () => { + const vault = getTestVault({ + 'secret-key-1': 'value-1', + }); + const value = await getSecret(vault, 'secret-key-2'); + expect(value).toEqual(undefined); + }); +}); diff --git a/infra/core/src/commands/get-secret.ts b/infra/core/src/commands/get-secret.ts new file mode 100644 index 00000000..c502fa65 --- /dev/null +++ b/infra/core/src/commands/get-secret.ts @@ -0,0 +1,5 @@ +import { type SecretsVault } from '../lib/types'; + +export const getSecret = async (vault: SecretsVault, key: string) => { + return await vault.getSecret(key); +}; diff --git a/infra/core/src/commands/get-secrets.test.ts b/infra/core/src/commands/get-secrets.test.ts new file mode 100644 index 00000000..5878fa67 --- /dev/null +++ b/infra/core/src/commands/get-secrets.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; + +import { getSecrets } from './get-secrets'; +import { createInMemorySecretsVault } from '../lib'; + +const getTestVault = (vaultData: any) => { + const result = createInMemorySecretsVault(JSON.stringify(vaultData)); + if (result.success) { + return result.data; + } else { + throw new Error('Error creating in-memory test vault'); + } +}; + +describe('get-secrets command', () => { + it('returns existing values', async () => { + const vaultSecrets = { + 'secret-key-1': 'value-1', + 'secret-key-2': 'value-2', + }; + const vault = getTestVault(vaultSecrets); + const secrets = await getSecrets(vault); + expect(secrets).toEqual(vaultSecrets); + }); + + it('returns empty object for empty vault', async () => { + const vault = getTestVault({}); + const value = await getSecrets(vault); + expect(value).toEqual({}); + }); +}); diff --git a/infra/core/src/commands/get-secrets.ts b/infra/core/src/commands/get-secrets.ts new file mode 100644 index 00000000..0667afdf --- /dev/null +++ b/infra/core/src/commands/get-secrets.ts @@ -0,0 +1,6 @@ +import { type SecretsVault } from '../lib/types'; + +export const getSecrets = async (vault: SecretsVault) => { + const allKeys = await vault.getSecretKeys(); + return await vault.getSecrets(allKeys); +}; diff --git a/infra/core/src/commands/index.ts b/infra/core/src/commands/index.ts new file mode 100644 index 00000000..d6fe8e09 --- /dev/null +++ b/infra/core/src/commands/index.ts @@ -0,0 +1,6 @@ +export { deleteSecret } from './delete-secret'; +export { getSecret } from './get-secret'; +export { getSecrets } from './get-secrets'; +export { getSecretKeyList } from './get-secret-key-list'; +export { setLoginGovSecrets } from './set-login-gov-secrets'; +export { setSecret } from './set-secret'; diff --git a/infra/core/src/commands/set-login-gov-secrets.test.ts b/infra/core/src/commands/set-login-gov-secrets.test.ts new file mode 100644 index 00000000..1c6b1303 --- /dev/null +++ b/infra/core/src/commands/set-login-gov-secrets.test.ts @@ -0,0 +1,78 @@ +import { randomUUID } from 'crypto'; +import { describe, expect, it } from 'vitest'; + +import { createInMemorySecretsVault } from '../lib'; +import { setLoginGovSecrets } from './set-login-gov-secrets'; +import path from 'path'; + +const getTestVault = (vaultData: any) => { + const result = createInMemorySecretsVault(JSON.stringify(vaultData)); + if (result.success) { + return result.data; + } else { + throw new Error('Error creating in-memory test vault'); + } +}; + +describe('set-login-gov-secrets command', () => { + it('sets app secrets when uninitialized', async () => { + const context = { + vault: getTestVault({}), + secretsDir: path.resolve(__dirname, '../../../../infra/secrets'), + generateLoginGovKey: async () => ({ + publicKey: 'mock public key', + privateKey: 'mock private key', + }), + }; + const appKey = randomUUID(); + const result = await setLoginGovSecrets(context, 'dev', appKey); + expect(result.preexisting).toEqual(false); + expect( + await context.vault.getSecrets(await context.vault.getSecretKeys()) + ).toEqual({ + [`/tts-10x-atj-dev/${appKey}/login.gov/public-key`]: 'mock public key', + [`/tts-10x-atj-dev/${appKey}/login.gov/private-key`]: 'mock private key', + }); + }); + + it('leaves initialized secrets as-is', async () => { + const context = { + vault: getTestVault({}), + secretsDir: path.resolve(__dirname, '../../../../infra/secrets'), + }; + const appKey = randomUUID(); + + await setLoginGovSecrets( + { + ...context, + generateLoginGovKey: async () => ({ + publicKey: 'mock public key - 1', + privateKey: 'mock private key - 1', + }), + }, + 'dev', + appKey + ); + const secondResult = await setLoginGovSecrets( + { + ...context, + generateLoginGovKey: async () => ({ + publicKey: 'mock public key - 2', + privateKey: 'mock private key - 2', + }), + }, + 'dev', + appKey + ); + + expect(secondResult.preexisting).toEqual(true); + expect( + await context.vault.getSecrets(await context.vault.getSecretKeys()) + ).toEqual({ + [`/tts-10x-atj-dev/${appKey}/login.gov/public-key`]: + 'mock public key - 1', + [`/tts-10x-atj-dev/${appKey}/login.gov/private-key`]: + 'mock private key - 1', + }); + }); +}); diff --git a/infra/core/src/commands/set-login-gov-secrets.ts b/infra/core/src/commands/set-login-gov-secrets.ts new file mode 100644 index 00000000..197bd591 --- /dev/null +++ b/infra/core/src/commands/set-login-gov-secrets.ts @@ -0,0 +1,81 @@ +import { exec } from 'child_process'; +import { promises as fs } from 'fs'; +import { promisify } from 'util'; + +import { type SecretsVault } from '../lib/types'; +import { type DeployEnv, getAppLoginGovKeys } from '../values'; + +const execPromise = promisify(exec); + +type GenerateLoginGovKey = ( + privateKeyPath: string, + publicKeyPath: string +) => Promise<{ + publicKey: string; + privateKey: string; +}>; + +type Context = { + vault: SecretsVault; + secretsDir: string; + generateLoginGovKey?: GenerateLoginGovKey; +}; + +export const setLoginGovSecrets = async ( + ctx: Context, + env: DeployEnv, + appKey: string +) => { + const loginKeys = getAppLoginGovKeys(env, appKey); + + // If the keypair is already set, do nothing and return it. + const existingPublicKey = await ctx.vault.getSecret(loginKeys.publicKey); + const existingPrivateKey = await ctx.vault.getSecret(loginKeys.privateKey); + if (existingPublicKey && existingPrivateKey) { + return { + preexisting: true, + publicKey: existingPublicKey, + privateKey: existingPrivateKey, + }; + } + + // Generate a new keypair and return it. + const myGenerateKey = ctx.generateLoginGovKey || generateLoginGovKey; + const { publicKey, privateKey } = await myGenerateKey( + loginGovPrivateKeyPath(ctx.secretsDir, appKey), + loginGovPublicKeyPath(ctx.secretsDir, appKey) + ); + await ctx.vault.setSecret(loginKeys.privateKey, privateKey); + await ctx.vault.setSecret(loginKeys.publicKey, publicKey); + return { + preexisting: false, + publicKey, + privateKey, + }; +}; + +const loginGovPublicKeyPath = (secretsDir: string, appKey: string) => + `${secretsDir}/login-gov-${appKey}-key.pem`; + +const loginGovPrivateKeyPath = (secretsDir: string, appKey: string) => + `${secretsDir}/login-gov-${appKey}-cert.pem`; + +const generateLoginGovKey: GenerateLoginGovKey = async ( + privateKeyPath: string, + publicKeyPath: string +) => { + const shellCommand = `openssl req \ + -nodes \ + -x509 \ + -days 365 \ + -newkey rsa:2048 \ + -new \ + -subj "/C=US/O=General Services Administration/OU=TTS/CN=gsa.gov" \ + -keyout ${privateKeyPath} \ + -out ${publicKeyPath}`; + await execPromise(shellCommand); + return { + publicKey: (await fs.readFile(publicKeyPath)).toString(), + privateKey: (await fs.readFile(privateKeyPath)).toString(), + }; +}; diff --git a/infra/core/src/commands/set-secret.test.ts b/infra/core/src/commands/set-secret.test.ts new file mode 100644 index 00000000..f79b30b0 --- /dev/null +++ b/infra/core/src/commands/set-secret.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; + +import { setSecret } from './set-secret'; +import { createInMemorySecretsVault } from '../lib'; + +const getTestVault = (vaultData: any) => { + const result = createInMemorySecretsVault(JSON.stringify(vaultData)); + if (result.success) { + return result.data; + } else { + throw new Error('Error creating in-memory test vault'); + } +}; + +describe('set-secret command', () => { + it('sets existing value', async () => { + const vault = getTestVault({ + 'secret-key-1': 'value-1', + }); + await setSecret(vault, 'secret-key-1', 'secret-value-updated'); + expect(await vault.getSecrets(await vault.getSecretKeys())).toEqual({ + 'secret-key-1': 'secret-value-updated', + }); + }); + + it('sets unset value', async () => { + const vault = getTestVault({ + 'secret-key-1': 'value-1', + }); + await setSecret(vault, 'secret-key-2', 'secret-value-updated'); + expect(await vault.getSecrets(await vault.getSecretKeys())).toEqual({ + 'secret-key-1': 'value-1', + 'secret-key-2': 'secret-value-updated', + }); + }); +}); diff --git a/infra/core/src/commands/set-secret.ts b/infra/core/src/commands/set-secret.ts new file mode 100644 index 00000000..e29467fd --- /dev/null +++ b/infra/core/src/commands/set-secret.ts @@ -0,0 +1,9 @@ +import { type SecretsVault } from '../lib/types'; + +export const setSecret = async ( + vault: SecretsVault, + key: string, + value: string +) => { + await vault.setSecret(key, value); +}; diff --git a/infra/core/src/index.ts b/infra/core/src/index.ts new file mode 100644 index 00000000..0b244e81 --- /dev/null +++ b/infra/core/src/index.ts @@ -0,0 +1,2 @@ +export * as commands from './commands'; +export { getSecretsVault } from './lib'; diff --git a/infra/core/src/lib/adapters/aws-param-store.ts b/infra/core/src/lib/adapters/aws-param-store.ts new file mode 100644 index 00000000..f25e3f1b --- /dev/null +++ b/infra/core/src/lib/adapters/aws-param-store.ts @@ -0,0 +1,123 @@ +import { + DeleteParameterCommand, + DescribeParametersCommand, + DescribeParametersCommandOutput, + GetParameterCommand, + GetParametersCommand, + ParameterNotFound, + PutParameterCommand, + SSMClient, +} from '@aws-sdk/client-ssm'; + +import type { SecretKey, SecretMap, SecretValue, SecretsVault } from '../types'; + +export class AWSParameterStoreSecretsVault implements SecretsVault { + client: SSMClient; + + constructor() { + this.client = new SSMClient(); + } + + async deleteSecret(key: SecretKey) { + try { + await this.client.send( + new DeleteParameterCommand({ + Name: key, + }) + ); + console.log(`Secret "${key}" deleted successfully.`); + } catch (error) { + console.warn('Skipped deleting parameter due to error:', error); + } + } + + async getSecret(key: SecretKey) { + try { + const response = await this.client.send( + new GetParameterCommand({ + Name: key, + WithDecryption: true, + }) + ); + return response.Parameter?.Value || ''; + } catch (error) { + if (error instanceof ParameterNotFound) { + return undefined; + } + console.error('Error getting parameter:', error); + throw error; + } + } + + async getSecrets(keys: SecretKey[]) { + try { + const response = await this.client.send( + new GetParametersCommand({ + Names: keys, + WithDecryption: true, + }) + ); + const values: { [key: SecretKey]: SecretValue } = {}; + if (response.Parameters) { + for (const parameter of response.Parameters) { + if (parameter.Name && parameter.Value) { + values[parameter.Name] = parameter.Value; + } + } + } + return values; + } catch (error) { + console.error('Error getting parameters:', error); + throw error; + } + } + + async setSecret(key: SecretKey, value: SecretValue) { + try { + await this.client.send( + new PutParameterCommand({ + Name: key, + Value: value, + Type: 'SecureString', + Overwrite: true, + }) + ); + console.log(`Secret "${key}" set successfully.`); + } catch (error) { + console.error('Error setting parameter:', error); + throw error; + } + } + + async setSecrets(secrets: SecretMap) { + const promises = Object.entries(secrets).map(([key, value]) => + this.setSecret(key, value) + ); + await Promise.all(promises); + } + + async getSecretKeys() { + let keys: string[] = []; + let nextToken: string | undefined; + do { + try { + const response: DescribeParametersCommandOutput = + await this.client.send( + new DescribeParametersCommand({ + NextToken: nextToken, + MaxResults: 50, + }) + ); + if (response.Parameters) { + keys.push(...response.Parameters.map(param => param.Name!)); + } + nextToken = response.NextToken; + } catch (error) { + console.error('Error describing parameters:', error); + throw error; + } + } while (nextToken); + + return keys; + } +} diff --git a/infra/core/src/lib/adapters/in-memory.ts b/infra/core/src/lib/adapters/in-memory.ts new file mode 100644 index 00000000..6fd9e57a --- /dev/null +++ b/infra/core/src/lib/adapters/in-memory.ts @@ -0,0 +1,30 @@ +import type { SecretMap, SecretsVault } from '../types'; + +export class InMemorySecretsVault implements SecretsVault { + constructor(private secretMap: SecretMap) {} + + async deleteSecret(key: string) { + delete this.secretMap[key]; + } + + async getSecret(key: string) { + return this.secretMap[key]; + } + + async getSecrets(keys: string[]) { + const entries = keys.map(key => [key, this.secretMap[key]]); + return Object.fromEntries(entries); + } + + async setSecret(key: string, value: string) { + this.secretMap[key] = value; + } + + async setSecrets(secretMap: SecretMap) { + this.secretMap = secretMap; + } + + async getSecretKeys() { + return Object.keys(this.secretMap); + } +} diff --git a/infra/core/src/lib/adapters/index.ts b/infra/core/src/lib/adapters/index.ts new file mode 100644 index 00000000..40472729 --- /dev/null +++ b/infra/core/src/lib/adapters/index.ts @@ -0,0 +1,43 @@ +import { promises as fs } from 'fs'; + +import * as r from '@atj/common'; + +import { AWSParameterStoreSecretsVault } from './aws-param-store'; +import { getSecretMapFromJsonString, type SecretsVault } from '../types'; +import { InMemorySecretsVault } from './in-memory'; + +/** + * Returns either a production vault or an in-memory vault initialized with the + * contents of a JSON file. + * @param jsonFilePath Optional path to a local JSON file that will stand-in + * for a secrets vault. + * @returns In-memory or production vault. + */ +export const getSecretsVault = async (jsonFilePath?: string) => { + if (jsonFilePath) { + const maybeJsonString = (await fs.readFile(jsonFilePath)).toString(); + const result = createInMemorySecretsVault(maybeJsonString); + if (result.success) { + return result.data; + } else { + throw new Error(result.error); + } + } else { + return getAWSSecretsVault(); + } +}; + +export const getAWSSecretsVault = (): SecretsVault => { + return new AWSParameterStoreSecretsVault(); +}; + +export const createInMemorySecretsVault = ( + jsonString?: any +): r.Result => { + const result = getSecretMapFromJsonString(jsonString); + if (result.success) { + return r.success(new InMemorySecretsVault(result.data)); + } else { + return r.failure(result.error); + } +}; diff --git a/infra/core/src/lib/index.ts b/infra/core/src/lib/index.ts new file mode 100644 index 00000000..d26c7aaf --- /dev/null +++ b/infra/core/src/lib/index.ts @@ -0,0 +1,10 @@ +import { type SecretMap, type SecretsVault } from './types'; + +export { getSecretMapFromJsonString } from './types'; +export * from './adapters'; + +export const getSecretMap = async (vault: SecretsVault): Promise => { + const secretKeys = await vault.getSecretKeys(); + const secretMap = await vault.getSecrets(secretKeys); + return secretMap; +}; diff --git a/infra/core/src/lib/types.ts b/infra/core/src/lib/types.ts new file mode 100644 index 00000000..9c3c956a --- /dev/null +++ b/infra/core/src/lib/types.ts @@ -0,0 +1,35 @@ +import * as z from 'zod'; +import { Result } from '@atj/common/src'; + +export type SecretKey = string; +export type SecretValue = string | undefined; +export type SecretMap = Record; + +const secretMap = z.record(z.string()); + +export const getSecretMapFromJsonString = ( + jsonString?: string +): Result => { + const inputObject = jsonString ? JSON.parse(jsonString) : null; + const result = secretMap.safeParse(inputObject); + if (result.success) { + return { + success: true, + data: result.data as SecretMap, + }; + } else { + return { + success: false, + error: result.error.message, + }; + } +}; + +export interface SecretsVault { + deleteSecret(key: SecretKey): Promise; + getSecret(key: SecretKey): Promise; + getSecrets(keys: SecretKey[]): Promise; + setSecret(key: SecretKey, value: SecretValue): Promise; + setSecrets(secrets: SecretMap): Promise; + getSecretKeys(): Promise; +} diff --git a/infra/core/src/values.ts b/infra/core/src/values.ts new file mode 100644 index 00000000..60c0a01d --- /dev/null +++ b/infra/core/src/values.ts @@ -0,0 +1,11 @@ +export type DeployEnv = 'dev' | 'staging'; + +const getPathPrefix = (env: DeployEnv) => `/tts-10x-atj-${env}`; + +export const getAppLoginGovKeys = (env: DeployEnv, appKey: string) => { + const prefix = getPathPrefix(env); + return { + privateKey: `${prefix}/${appKey}/login.gov/private-key`, + publicKey: `${prefix}/${appKey}/login.gov/public-key`, + }; +}; diff --git a/infra/core/tsconfig.json b/infra/core/tsconfig.json new file mode 100644 index 00000000..7fad7f24 --- /dev/null +++ b/infra/core/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "emitDeclarationOnly": false, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["./src"], + "references": [] +} diff --git a/infra/src/spaces/main.ts b/infra/src/spaces/main.ts deleted file mode 100644 index 34ed8c1c..00000000 --- a/infra/src/spaces/main.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { registerAppStack } from '../lib/app-stack'; - -registerAppStack('tts-10x-atj-dev', 'main'); diff --git a/infra/src/spaces/staging.ts b/infra/src/spaces/staging.ts deleted file mode 100644 index d6425f1e..00000000 --- a/infra/src/spaces/staging.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { registerAppStack } from '../lib/app-stack'; - -registerAppStack('tts-10x-atj-staging', 'staging'); diff --git a/package.json b/package.json index 542b30c2..b00e2961 100644 --- a/package.json +++ b/package.json @@ -6,15 +6,15 @@ "main": "index.js", "license": "CC0", "scripts": { - "build": "turbo run build --filter=!infra", + "build": "turbo run build --filter=!infra-cdktf", "clean": "turbo run clean", "dev": "turbo run dev --concurrency 14", "format": "prettier --write \"packages/*/src/**/*.{js,jsx,ts,tsx,scss}\"", "lint": "turbo run lint", "pages": "rm -rf node_modules && npm i -g pnpm turbo && pnpm i && pnpm build && ln -sf ./apps/spotlight/dist _site", "test": "vitest run", - "test:ci": "vitest run --coverage.enabled --coverage.reporter=text --coverage.reporter=json-summary --coverage.reporter=json --coverage.reportOnFailure --reporter vitest-github-actions-reporter", - "test:infra": "turbo run --filter=infra test", + "test:ci": "vitest run # --coverage.enabled --coverage.provider=v8 --coverage.reporter=text --coverage.reporter=json-summary --coverage.reporter=json --coverage.reportOnFailure", + "test:infra": "turbo run --filter=infra-cdktf test", "typecheck": "tsc --build", "prepare": "husky" }, @@ -22,23 +22,19 @@ "pre-commit": "pnpm format" }, "devDependencies": { - "@types/node": "^20.11.16", - "@vitest/coverage-v8": "^1.2.2", - "@vitest/ui": "^1.2.2", - "eslint": "^8.56.0", - "husky": "^9.0.11", + "@types/node": "^20.14.14", + "@vitest/coverage-v8": "^2.0.5", + "@vitest/ui": "^2.0.5", + "esbuild": "^0.23.0", + "eslint": "^8.57.0", + "husky": "^9.1.4", "npm-run-all": "^4.1.5", - "prettier": "^3.2.5", + "prettier": "^3.3.3", "ts-node": "^10.9.2", - "tsup": "^8.0.1", - "turbo": "^1.12.3", - "type-fest": "^4.10.2", - "typescript": "^5.3.3", - "vitest": "^1.6.0", - "vitest-github-actions-reporter": "^0.11.1", - "vitest-mock-extended": "^1.3.1" - }, - "dependencies": { - "astro": "^4.3.5" + "tsup": "^8.2.4", + "turbo": "^1.13.4", + "typescript": "^5.5.4", + "vitest": "^2.0.5", + "vitest-mock-extended": "^2.0.0" } } diff --git a/packages/auth/.gitignore b/packages/auth/.gitignore new file mode 100644 index 00000000..849ddff3 --- /dev/null +++ b/packages/auth/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/packages/auth/global.d.ts b/packages/auth/global.d.ts new file mode 100644 index 00000000..d85ddf6d --- /dev/null +++ b/packages/auth/global.d.ts @@ -0,0 +1 @@ +import 'vitest-fetch-mock'; diff --git a/packages/auth/package.json b/packages/auth/package.json new file mode 100644 index 00000000..af5046ca --- /dev/null +++ b/packages/auth/package.json @@ -0,0 +1,27 @@ +{ + "name": "@atj/auth", + "version": "1.0.0", + "description": "10x ATJ auth module", + "type": "module", + "license": "CC0", + "main": "dist/index.js", + "types": "dist/index.d.js", + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "test": "vitest run --coverage" + }, + "dependencies": { + "@atj/common": "workspace:^", + "@atj/database": "workspace:*", + "@lucia-auth/adapter-sqlite": "^3.0.2", + "arctic": "^1.9.2", + "better-sqlite3": "^11.1.2", + "lucia": "^3.2.0", + "oslo": "^1.2.1" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.11", + "vitest-fetch-mock": "^0.3.0" + } +} diff --git a/packages/auth/src/context/dev.ts b/packages/auth/src/context/dev.ts new file mode 100644 index 00000000..08f7e12d --- /dev/null +++ b/packages/auth/src/context/dev.ts @@ -0,0 +1,40 @@ +import { Cookie, Lucia } from 'lucia'; + +import { type DatabaseGateway } from '@atj/database'; + +import { type AuthContext, type UserSession } from '..'; +import { createTestLuciaAdapter } from '../lucia'; +import { LoginGov } from '../provider'; + +export class DevAuthContext implements AuthContext { + private lucia?: Lucia; + + constructor( + public db: DatabaseGateway, + public provider: LoginGov, + public getCookie: (name: string) => string | undefined, + public setCookie: (cookie: Cookie) => void, + public setUserSession: (userSession: UserSession) => void + ) {} + + async getLucia() { + const sqlite3Adapter = createTestLuciaAdapter( + await (this.db.getContext() as any).getSqlite3() + ); + if (!this.lucia) { + this.lucia = new Lucia(sqlite3Adapter, { + sessionCookie: { + attributes: { + secure: false, + }, + }, + getUserAttributes: attributes => { + return { + email: attributes.email, + }; + }, + }); + } + return this.lucia; + } +} diff --git a/packages/auth/src/context/test.ts b/packages/auth/src/context/test.ts new file mode 100644 index 00000000..d6f22691 --- /dev/null +++ b/packages/auth/src/context/test.ts @@ -0,0 +1,73 @@ +import { Cookie, Lucia } from 'lucia'; +import { vi } from 'vitest'; + +import { + type DatabaseGateway, + createTestDatabaseContext, + createDatabaseGateway, +} from '@atj/database'; + +import { type AuthContext, type UserSession } from '..'; +import { createTestLuciaAdapter } from '../lucia'; +import { LoginGov } from '../provider'; + +type Options = { + getCookie: (name: string) => string | undefined; + setCookie: (cookie: Cookie) => void; + setUserSession: (userSession: UserSession) => void; +}; + +export const createTestAuthContext = async (opts?: Partial) => { + const options: Options = { + getCookie: opts?.getCookie || vi.fn(), + setCookie: opts?.setCookie || vi.fn(), + setUserSession: opts?.setUserSession || vi.fn(), + }; + const dbContext = await createTestDatabaseContext(); + const database = createDatabaseGateway(dbContext); + return new TestAuthContext( + database, + new LoginGov({ + loginGovUrl: 'https://idp.int.identitysandbox.gov', + clientId: + 'urn:gov:gsa:openidconnect.profiles:sp:sso:gsa:tts-10x-atj-dev-server-doj', + //clientSecret: 'super-secret', + redirectURI: 'http://www.10x.gov/a2j/signin/callback', + }), + options.getCookie, + options.setCookie, + options.setUserSession + ); +}; + +export class TestAuthContext implements AuthContext { + private lucia?: Lucia; + + constructor( + public db: DatabaseGateway, + public provider: LoginGov, + public getCookie: (name: string) => string | undefined, + public setCookie: (cookie: Cookie) => void, + public setUserSession: (userSession: UserSession) => void + ) {} + + async getLucia() { + const sqlite3 = await (this.db.getContext() as any).getSqlite3(); + const sqlite3Adapter = createTestLuciaAdapter(sqlite3); + if (!this.lucia) { + this.lucia = new Lucia(sqlite3Adapter, { + sessionCookie: { + attributes: { + secure: false, + }, + }, + getUserAttributes: attributes => { + return { + email: attributes.email, + }; + }, + }); + } + return this.lucia; + } +} diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts new file mode 100644 index 00000000..9e1d6201 --- /dev/null +++ b/packages/auth/src/index.ts @@ -0,0 +1,26 @@ +import { type Cookie, type User, type Session, type Lucia } from 'lucia'; + +import { type DatabaseGateway } from '@atj/database'; + +export { DevAuthContext } from './context/dev'; +import { type LoginGovOptions, LoginGov } from './provider'; +export { type LoginGovOptions, LoginGov }; +export { getProviderRedirect } from './services/get-provider-redirect'; +export { logOut } from './services/logout'; +export { processProviderCallback } from './services/process-provider-callback'; +export { processSessionCookie } from './services/process-session-cookie'; +export { User, Session }; + +export type UserSession = { + user: User | null; + session: Session | null; +}; + +export type AuthContext = { + db: DatabaseGateway; + provider: LoginGov; + getCookie: (name: string) => string | undefined; + setCookie: (cookie: Cookie) => void; + setUserSession: (userSession: UserSession) => void; + getLucia: () => Promise; +}; diff --git a/packages/auth/src/lucia.ts b/packages/auth/src/lucia.ts new file mode 100644 index 00000000..4909a9c4 --- /dev/null +++ b/packages/auth/src/lucia.ts @@ -0,0 +1,49 @@ +import { BetterSqlite3Adapter } from '@lucia-auth/adapter-sqlite'; +import { type Database as Sqlite3Database } from 'better-sqlite3'; +import { Lucia } from 'lucia'; + +import { type Database } from '@atj/database'; + +export const createTestLuciaAdapter = (db: Sqlite3Database) => { + const adapter = new BetterSqlite3Adapter(db, { + user: 'users', + session: 'sessions', + }); + //const adapter = new KyselyAdapter(); + return adapter; +}; + +declare module 'lucia' { + interface Register { + Lucia: Lucia; + DatabaseUserAttributes: Omit; + } +} + +/* +export class KyselyAdapter implements Adapter { + getSessionAndUser( + sessionId: string + ): Promise<[session: DatabaseSession | null, user: DatabaseUser | null]> { + throw new Error('Method not implemented.'); + } + getUserSessions(userId: string): Promise { + throw new Error('Method not implemented.'); + } + setSession(session: DatabaseSession): Promise { + throw new Error('Method not implemented.'); + } + updateSessionExpiration(sessionId: string, expiresAt: Date): Promise { + throw new Error('Method not implemented.'); + } + deleteSession(sessionId: string): Promise { + throw new Error('Method not implemented.'); + } + deleteUserSessions(userId: string): Promise { + throw new Error('Method not implemented.'); + } + deleteExpiredSessions(): Promise { + throw new Error('Method not implemented.'); + } +} +*/ diff --git a/packages/auth/src/provider.ts b/packages/auth/src/provider.ts new file mode 100644 index 00000000..51ae383a --- /dev/null +++ b/packages/auth/src/provider.ts @@ -0,0 +1,99 @@ +import { OAuth2ProviderWithPKCE } from 'arctic'; +import { TimeSpan, createDate } from 'oslo'; +import { parseJWT } from 'oslo/jwt'; +import { OAuth2Client } from 'oslo/oauth2'; + +export type LoginGovUrl = + | 'https://idp.int.identitysandbox.gov' + | 'https://secure.login.gov'; + +const getTokenEndpoint = (url: LoginGovUrl) => + `${url}/api/openid_connect/token`; +const getAuthorizeEndpoint = (url: LoginGovUrl) => + `${url}/openid_connect/authorize?acr_values=http://idmanagement.gov/ns/assurance/ial/1`; + +export type LoginGovOptions = { + loginGovUrl: LoginGovUrl; + clientId: string; + //clientSecret: string; + redirectURI?: string; +}; + +export class LoginGov implements OAuth2ProviderWithPKCE { + private client: OAuth2Client; + //private clientSecret: string; + + constructor(opts: LoginGovOptions) { + this.client = new OAuth2Client( + opts.clientId, + getAuthorizeEndpoint(opts.loginGovUrl), + getTokenEndpoint(opts.loginGovUrl), + { + redirectURI: opts.redirectURI, + } + ); + //this.clientSecret = opts.clientSecret; + } + + public async createAuthorizationURL( + state: string, + codeVerifier: string, + options?: { + scopes?: string[]; + nonce?: string; + } + ): Promise { + const scopes = options?.scopes ?? []; + const url = await this.client.createAuthorizationURL({ + state, + codeVerifier, + codeChallengeMethod: 'S256', + // User attributes (scopes): https://developers.login.gov/attributes/ + scopes: [...scopes, 'openid', 'email'], + }); + if (options?.nonce) { + url.searchParams.set('nonce', options?.nonce); + } + return url; + } + + public async validateAuthorizationCode( + code: string, + codeVerifier: string + ): Promise { + const result = + await this.client.validateAuthorizationCode( + code, + { + authenticateWith: 'request_body', + //credentials: this.clientSecret, + codeVerifier, + } + ); + + const tokens: LoginGovTokens = { + accessToken: result.access_token, + refreshToken: result.refresh_token ?? null, + accessTokenExpiresAt: createDate(new TimeSpan(result.expires_in, 's')), + idToken: result.id_token, + decodedToken: parseJWT(result.id_token)!.payload, + }; + + return tokens; + } +} + +interface AuthorizationCodeResponseBody { + access_token: string; + refresh_token?: string; + expires_in: number; + id_token: string; +} + +export interface LoginGovTokens { + accessToken: string; + refreshToken: string | null; + accessTokenExpiresAt: Date; + idToken: string; + decodedToken: any; +} diff --git a/packages/auth/src/services/get-provider-redirect.test.ts b/packages/auth/src/services/get-provider-redirect.test.ts new file mode 100644 index 00000000..8626273e --- /dev/null +++ b/packages/auth/src/services/get-provider-redirect.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; + +import { createTestAuthContext } from '../context/test'; + +import { getProviderRedirect } from './get-provider-redirect'; + +describe('getProviderRedirect database gateway', () => { + it('returns cookies and redirect url', async () => { + const ctx = await createTestAuthContext(); + const result = await getProviderRedirect(ctx); + expect(Object.fromEntries(result.url.searchParams)).toEqual( + expect.objectContaining({ + acr_values: 'http://idmanagement.gov/ns/assurance/ial/1', + response_type: 'code', + client_id: + 'urn:gov:gsa:openidconnect.profiles:sp:sso:gsa:tts-10x-atj-dev-server-doj', + state: expect.any(String), + scope: 'openid email', + redirect_uri: 'http://www.10x.gov/a2j/signin/callback', + code_challenge: expect.any(String), + code_challenge_method: 'S256', + nonce: expect.any(String), + }) + ); + expect(result.cookies).toEqual([ + expect.objectContaining({ + name: 'oauth_state', + sameSite: 'lax', + value: expect.any(String), + }), + expect.objectContaining({ + name: 'code_verifier', + sameSite: expect.any(Boolean), + value: expect.any(String), + }), + expect.objectContaining({ + name: 'nonce_code', + sameSite: 'lax', + value: expect.any(String), + }), + ]); + }); +}); diff --git a/packages/auth/src/services/get-provider-redirect.ts b/packages/auth/src/services/get-provider-redirect.ts new file mode 100644 index 00000000..6cd8efb1 --- /dev/null +++ b/packages/auth/src/services/get-provider-redirect.ts @@ -0,0 +1,31 @@ +import { generateCodeVerifier, generateState } from 'arctic'; +import { AuthContext } from '..'; + +export const getProviderRedirect = async (ctx: AuthContext) => { + const state = generateState(); + const codeVerifier = generateCodeVerifier(); + const nonceCode = generateCodeVerifier(); + const url = await ctx.provider.createAuthorizationURL(state, codeVerifier, { + nonce: nonceCode, + }); + return { + cookies: [ + { + name: 'oauth_state', + value: state, + sameSite: 'lax' as const, + }, + { + name: 'code_verifier', + value: codeVerifier, + sameSite: false as const, + }, + { + name: 'nonce_code', + value: nonceCode, + sameSite: 'lax' as const, + }, + ], + url, + }; +}; diff --git a/packages/auth/src/services/logout.test.ts b/packages/auth/src/services/logout.test.ts new file mode 100644 index 00000000..bda56c67 --- /dev/null +++ b/packages/auth/src/services/logout.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createTestAuthContext } from '../context/test'; +import { logOut } from './logout'; + +describe('logOut database gateway', () => { + it('works', async () => { + const ctx = await createTestAuthContext(); + vi.setSystemTime(new Date(2024, 1, 1)); + const result = await logOut(ctx, { + expiresAt: new Date(2024, 1, 2), + fresh: true, + id: 'session-id', + userId: 'user-id', + }); + expect(result).toEqual({ + attributes: { + httpOnly: true, + maxAge: 0, + path: '/', + sameSite: 'lax', + secure: false, + }, + name: 'auth_session', + value: '', + }); + }); +}); diff --git a/packages/auth/src/services/logout.ts b/packages/auth/src/services/logout.ts new file mode 100644 index 00000000..bfba9bf5 --- /dev/null +++ b/packages/auth/src/services/logout.ts @@ -0,0 +1,9 @@ +import { type Session } from 'lucia'; +import { type AuthContext } from '..'; + +export const logOut = async (ctx: AuthContext, session: Session) => { + const lucia = await ctx.getLucia(); + await lucia.invalidateSession(session.id); + const sessionCookie = lucia.createBlankSessionCookie(); + return sessionCookie; +}; diff --git a/packages/auth/src/services/process-provider-callback.test.ts b/packages/auth/src/services/process-provider-callback.test.ts new file mode 100644 index 00000000..2d129f63 --- /dev/null +++ b/packages/auth/src/services/process-provider-callback.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { processProviderCallback } from './process-provider-callback'; +import { createTestAuthContext } from '../context/test'; +import { AuthContext } from '..'; + +describe('processProviderCallback', () => { + let ctx: AuthContext; + + beforeEach(async () => { + // Set up global mocks + //fetchMock.resetMocks(); + + // Create test auth context with a test user in the db + ctx = await createTestAuthContext(); + const user = await ctx.db.createUser('fake-user@gsa.com'); + if (!user) { + expect.fail('error creating test user'); + } + + // Mock the response from login.gov's `userinfo` endpoint. + fetchMock.doMock(async request => { + if (request.url.endsWith('/api/openid_connect/token')) { + return JSON.stringify({ + access_token: 'x1lKd1e3CIrsSN_rnu85SQ', + refresh_token: null, + id_token: + 'eyJraWQiOiJmNWNlMTIzOWUzOWQzZGE4MzZmOTYzYmNjZDg1Zjg1ZDU3ZDQzMzVjZmRjNmExNzAzOWYyNzQzNjFhMThiMTNjIiwiYWxnIjoiUlMyNTYifQ.eyJzdWIiOiI5YmY3MzRjNC01NGE0LTQ0MDYtYjJmMS00ZjBjNDZjMmE0YTYiLCJpc3MiOiJodHRwczovL2lkcC5pbnQuaWRlbnRpdHlzYW5kYm94Lmdvdi8iLCJlbWFpbCI6ImRhbmllbC5uYWFiQGdzYS5nb3YiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiaWFsIjoiaHR0cDovL2lkbWFuYWdlbWVudC5nb3YvbnMvYXNzdXJhbmNlL2lhbC8xIiwiYWFsIjoidXJuOmdvdjpnc2E6YWM6Y2xhc3NlczpzcDpQYXNzd29yZFByb3RlY3RlZFRyYW5zcG9ydDpkdW8iLCJub25jZSI6InQwajVBWTVrNG9MQWN4ZmFaZFJzTWZWWkdCQ2dCamxmaWhnb1ZxMzRZR28iLCJhdWQiOiJ1cm46Z292OmdzYTpvcGVuaWRjb25uZWN0LnByb2ZpbGVzOnNwOnNzbzpnc2E6dHRzLTEweC1hdGotZGV2LXNlcnZlci1kb2oiLCJqdGkiOiIyeEpaYlBsMmYxQlpxZmhhUG5aUlhBIiwiYXRfaGFzaCI6Im91U3JIdWhFQTdYX25UZ0VIeUlrM3ciLCJjX2hhc2giOiJWOUNDb1l1TElhTFd3VUZRelZwNS1RIiwiYWNyIjoiaHR0cDovL2lkbWFuYWdlbWVudC5nb3YvbnMvYXNzdXJhbmNlL2lhbC8xIiwiZXhwIjoxNzIyNTcxNTY4LCJpYXQiOjE3MjI1NzA2NjgsIm5iZiI6MTcyMjU3MDY2OH0.Aa8zNA5VyPAOR5hHObiO1c1n4Y2Tu43FF4ec4sgz2GEuHmr-N6q4OSg1icB7v7dX0Ekd2CrjieXx4p9qOE0UxNcEK6bXL0hpfmeu5qn3g6I435hyw-XNFw5QF7MCZD7tjwYSva6IVVmTsjPCELekcK1n_CzGXe31FiRVgyxyw9nttkymsAh48FxWzla2_PLcA4bwuSEJLwx_-YIYbvgEfVkqd1vcaK2QWr1grlIYFpsyobFFd8duBVco9UdJVuH_aBNjF92zZRG0CKLFnxF6AXP7iE6JCm0z8ppnA2__r3l-O9KPkOYe73D-K2U-kL_-aBpWPL1eioNTxG7Ah8ZDSg', + }); + } else if (request.url.endsWith('/api/openid_connect/userinfo')) { + return JSON.stringify({ + sub: 'ignored', + iss: 'ignored', + email: 'fake-user@gsa.gov', + email_verified: true, + ial: 'ignored', + aal: 'ignored', + }); + } else if (request.url.includes('openid_connect/authorize')) { + throw new Error('authorize endpoint: todo'); + } + throw new Error(`unexpected url: ${request.url}`); + }); + }); + + it('works with matching verification codes', async () => { + const ctx = await createTestAuthContext(); + const result = await processProviderCallback( + ctx, + { + code: 'params-code', + state: 'params-state', + }, + { + code: 'params-code', + state: 'params-state', + nonce: 't0j5AY5k4oLAcxfaZdRsMfVZGBCgBjlfihgoVq34YGo', + } + ); + expect(result).toEqual( + expect.objectContaining({ + success: true, + data: { + email: 'fake-user@gsa.gov', + sessionCookie: { + name: 'auth_session', + value: expect.any(String), + attributes: { + httpOnly: true, + secure: false, + sameSite: 'lax', + path: '/', + maxAge: 2592000, + }, + }, + }, + }) + ); + }); + + it('fails with non-matching verification codes', async () => { + const ctx = await createTestAuthContext(); + vi.setSystemTime(new Date(2024, 1, 1)); + const result = await processProviderCallback( + ctx, + { + code: 'params-code', + state: 'params-state', + }, + { + code: 'cookie-stored-code', + state: 'cookie-stored-state', + nonce: '123456789012345678901234567890', + } + ); + expect(result).toEqual({ + success: false, + error: { + message: 'bad request', + status: 400, + }, + }); + }); +}); diff --git a/packages/auth/src/services/process-provider-callback.ts b/packages/auth/src/services/process-provider-callback.ts new file mode 100644 index 00000000..4b983697 --- /dev/null +++ b/packages/auth/src/services/process-provider-callback.ts @@ -0,0 +1,111 @@ +import { OAuth2RequestError } from 'arctic'; + +import * as r from '@atj/common'; +import { type AuthContext } from '..'; +import { randomUUID } from 'crypto'; + +type LoginGovUser = { + sub: string; + iss: string; + email: string; + email_verified: boolean; + ial: string; + aal: string; +}; + +type Params = { + code?: string | null; + state?: string | null; +}; + +export const processProviderCallback = async ( + ctx: AuthContext, + params: Params, + storedParams: Params & { nonce: string | null }, + fetchUserData: typeof fetchUserDataImpl = fetchUserDataImpl +) => { + if ( + !params.code || + !storedParams.state || + !storedParams.code || + params.state !== storedParams.state + ) { + return r.failure({ status: 400, message: 'bad request' }); + } + + const validateResult = await ctx.provider + .validateAuthorizationCode(params.code, storedParams.code) + .then(result => { + return r.success(result); + }) + .catch(error => { + console.error(error, error.stack); + if ( + error instanceof OAuth2RequestError && + error.message === 'bad_verification_code' + ) { + return r.failure({ status: 400, message: 'bad verification code' }); + } + return r.failure({ + status: 500, + message: `unexpected error: ${error.message}`, + }); + }); + + if (validateResult.success === false) { + return validateResult; + } + + if (validateResult.data.decodedToken.nonce !== storedParams.nonce) { + return r.failure({ + status: 403, + message: 'nonce mismatch', + }); + } + + const userDataResult = await fetchUserData(validateResult.data.accessToken); + if (!userDataResult.success) { + return userDataResult; + } + let userId = await ctx.db.getUserId(userDataResult.data.email); + if (!userId) { + const newUser = await ctx.db.createUser(userDataResult.data.email); + if (!newUser) { + return r.failure({ status: 500, message: 'error creating new user' }); + } + userId = newUser.id; + } + const lucia = await ctx.getLucia(); + const session = await lucia.createSession(userId, { + session_token: randomUUID(), + }); + const sessionCookie = lucia.createSessionCookie(session.id); + + return r.success({ + email: userDataResult.data.email, + sessionCookie, + }); +}; + +const fetchUserDataImpl = (accessToken: string) => + fetch('https://idp.int.identitysandbox.gov/api/openid_connect/userinfo', { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + .then(response => response.json()) + .then((userData: LoginGovUser) => { + if (userData.email_verified === false) { + return r.failure({ + status: 403, + message: 'email address not verified', + }); + } + return r.success(userData); + }) + .catch(error => + r.failure({ + status: 500, + message: `error fetching user data: ${error.message}`, + }) + ); diff --git a/packages/auth/src/services/process-session-cookie.test.ts b/packages/auth/src/services/process-session-cookie.test.ts new file mode 100644 index 00000000..b85b4428 --- /dev/null +++ b/packages/auth/src/services/process-session-cookie.test.ts @@ -0,0 +1,133 @@ +import { randomUUID } from 'crypto'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createTestAuthContext } from '../context/test'; +import { processSessionCookie } from './process-session-cookie'; + +describe('processSessionCookie', () => { + const today = new Date(2020, 1, 1); + const tenYearsAgo = new Date(2010, 1, 1); + + beforeEach(async () => { + vi.setSystemTime(today); + }); + + it('sets null user session with unset session cookie', async () => { + const mocks = { + getCookie: vi.fn(() => undefined), + setUserSession: vi.fn(), + }; + const ctx = await createTestAuthContext(mocks); + + const result = await processSessionCookie( + ctx, + new Request('http://localhost', { + headers: { Origin: 'http://localhost', Host: 'http://www.google.com' }, + }) + ); + + expect(result.success).toEqual(true); + expect(mocks.setUserSession).toHaveBeenCalledWith({ + session: null, + user: null, + }); + }); + + it('resets session cookie and sets user session with fresh session cookie', async () => { + const { ctx, mocks, sessionId, user } = await setUpTest(today); + + const result = await processSessionCookie( + ctx, + new Request('http://localhost', { + headers: { Origin: 'http://localhost', Host: 'http://www.google.com' }, + }) + ); + + expect(result.success).toEqual(true); + expect(mocks.setCookie).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: { + httpOnly: true, + maxAge: 2592000, + path: '/', + sameSite: 'lax', + secure: false, + }, + name: 'auth_session', + value: sessionId, + }) + ); + expect(mocks.setUserSession).toHaveBeenCalledWith( + expect.objectContaining({ + session: { + expiresAt: expect.any(Date), + fresh: true, + id: sessionId, + userId: user.id, + }, + user: { + email: 'user@test.gov', + id: user.id, + }, + }) + ); + }); + + it('clears cookies with stale session cookie', async () => { + const { ctx, mocks } = await setUpTest(tenYearsAgo); + + const result = await processSessionCookie( + ctx, + new Request('http://localhost', { + headers: { Origin: 'http://localhost', Host: 'http://www.google.com' }, + }) + ); + + expect(result.success).toEqual(true); + expect(mocks.setCookie).toHaveBeenCalledWith( + expect.objectContaining({ + attributes: { + httpOnly: true, + maxAge: 0, + path: '/', + sameSite: 'lax', + secure: false, + }, + name: 'auth_session', + value: '', + }) + ); + expect(mocks.setUserSession).toHaveBeenCalledWith( + expect.objectContaining({ + session: null, + user: null, + }) + ); + }); +}); + +const addOneDay = (date: Date): Date => { + const newDate = new Date(date); + newDate.setDate(newDate.getDate() + 1); + return newDate; +}; + +const setUpTest = async (sessionExpirationDate: Date) => { + const mocks = { + getCookie: vi.fn(() => sessionId || ''), + setCookie: vi.fn(), + setUserSession: vi.fn(), + }; + const ctx = await createTestAuthContext(mocks); + const user = await ctx.db.createUser('user@test.gov'); + if (!user) { + expect.fail('error creating test user'); + } + const sessionId = await ctx.db.createSession({ + id: randomUUID(), + expiresAt: addOneDay(sessionExpirationDate), + sessionToken: 'my-token', + userId: user.id, + }); + return { ctx, mocks, sessionId, user }; +}; diff --git a/packages/auth/src/services/process-session-cookie.ts b/packages/auth/src/services/process-session-cookie.ts new file mode 100644 index 00000000..8bc66dcd --- /dev/null +++ b/packages/auth/src/services/process-session-cookie.ts @@ -0,0 +1,50 @@ +import { verifyRequestOrigin } from 'lucia'; + +import { type VoidResult } from '@atj/common'; + +import { type AuthContext } from '..'; + +export const processSessionCookie = async ( + ctx: AuthContext, + request: Request +): Promise> => { + if (request.method !== 'GET') { + const originHeader = request.headers.get('Origin'); + const hostHeader = request.headers.get('Host'); + if ( + !originHeader || + !hostHeader || + !verifyRequestOrigin(originHeader, [hostHeader]) + ) { + return { + success: false, + error: { + status: 403, + }, + }; + } + } + const lucia = await ctx.getLucia(); + + const sessionId = ctx.getCookie(lucia.sessionCookieName); + if (!sessionId) { + ctx.setUserSession({ user: null, session: null }); + return { + success: true, + }; + } + + const { session, user } = await lucia.validateSession(sessionId); + if (session && session.fresh) { + const sessionCookie = lucia.createSessionCookie(session.id); + ctx.setCookie(sessionCookie); + } + if (!session) { + const sessionCookie = lucia.createBlankSessionCookie(); + ctx.setCookie(sessionCookie); + } + ctx.setUserSession({ user, session }); + return { + success: true, + }; +}; diff --git a/packages/auth/tsconfig.json b/packages/auth/tsconfig.json new file mode 100644 index 00000000..24abc391 --- /dev/null +++ b/packages/auth/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "emitDeclarationOnly": false, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src", "global.d.ts"], + "exclude": ["./dist"], + "references": [] +} diff --git a/packages/auth/vitest.config.ts b/packages/auth/vitest.config.ts new file mode 100644 index 00000000..90197569 --- /dev/null +++ b/packages/auth/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig, mergeConfig } from 'vitest/config'; + +import sharedTestConfig from '../../vitest.shared'; + +export default mergeConfig( + sharedTestConfig, + defineConfig({ + test: { + setupFiles: ['./vitest.setup.ts'], + }, + }) +); diff --git a/packages/auth/vitest.setup.ts b/packages/auth/vitest.setup.ts new file mode 100644 index 00000000..5c4c5e0f --- /dev/null +++ b/packages/auth/vitest.setup.ts @@ -0,0 +1,7 @@ +import createFetchMock from 'vitest-fetch-mock'; +import { vi } from 'vitest'; + +const fetchMocker = createFetchMock(vi); + +// sets globalThis.fetch and globalThis.fetchMock to our mocked version +fetchMocker.enableMocks(); diff --git a/packages/common/package.json b/packages/common/package.json index 0c846ae7..044662a8 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -4,10 +4,11 @@ "description": "10x ATJ shared resources", "type": "module", "license": "CC0", - "main": "src/index.ts", + "main": "dist/index.js", + "types": "dist/index.d.ts", "scripts": { - "build": "tsup src/* --env.NODE_ENV production", - "dev": "tsup src/* --watch" + "build": "tsc", + "dev": "tsc --watch" }, "dependencies": {} } diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index ad6f4016..0331e4f1 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -1,3 +1,5 @@ +export { createService } from './service.js'; + export type Success = { success: true; data: T }; export type VoidSuccess = { success: true }; export type Failure = { success: false; error: E }; diff --git a/packages/common/src/service.ts b/packages/common/src/service.ts new file mode 100644 index 00000000..c3ff8bc0 --- /dev/null +++ b/packages/common/src/service.ts @@ -0,0 +1,53 @@ +/** + * Exports `createService`, which creates a service object from a context and a + * set of service functions. + * Each service function takes a context as its first argument, with subsequent + * arguments being the function's parameters. + */ +type ServiceFunction = ( + context: Context, + ...args: Args +) => Return; + +type ServiceFunctions = { + [key: string]: ServiceFunction; +}; + +type WithoutFirstArg = F extends (context: any, ...args: infer P) => infer R + ? (...args: P) => R + : never; + +type Service< + Context extends any, + Functions extends ServiceFunctions, +> = { + [K in keyof Functions]: WithoutFirstArg; +} & { getContext: () => Context }; + +export const createService = < + Context extends any, + Functions extends ServiceFunctions, +>( + ctx: Context, + serviceFunctions: Functions +): Service => { + const handler: ProxyHandler = { + get(target: Functions, prop: string | symbol) { + if (prop === 'getContext') { + return () => ctx; + } + const propKey = prop as keyof Functions; + const originalFn = target[propKey]; + if (originalFn === undefined) { + return undefined; + } + return (...args: any[]) => + (originalFn as Function).call(null, ctx, ...args); + }, + }; + + return new Proxy(serviceFunctions, handler) as unknown as Service< + Context, + Functions + >; +}; diff --git a/packages/common/tsconfig.json b/packages/common/tsconfig.json index a7c17353..7fad7f24 100644 --- a/packages/common/tsconfig.json +++ b/packages/common/tsconfig.json @@ -1,8 +1,9 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { + "emitDeclarationOnly": false, "outDir": "./dist", - "emitDeclarationOnly": true + "rootDir": "./src" }, "include": ["./src"], "references": [] diff --git a/packages/database/.gitignore b/packages/database/.gitignore new file mode 100644 index 00000000..849ddff3 --- /dev/null +++ b/packages/database/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/packages/database/build.js b/packages/database/build.js new file mode 100644 index 00000000..35fcfb42 --- /dev/null +++ b/packages/database/build.js @@ -0,0 +1,15 @@ +import esbuild from 'esbuild'; + +esbuild + .build({ + bundle: false, + entryPoints: ['./src/index.ts'], + packages: 'external', + format: 'esm', + minify: true, + outdir: './dist', + platform: 'node', + sourcemap: true, + target: 'es2020', + }) + .catch(() => process.exit(1)); diff --git a/packages/database/knexfile.mjs b/packages/database/knexfile.mjs new file mode 100644 index 00000000..a17822f7 --- /dev/null +++ b/packages/database/knexfile.mjs @@ -0,0 +1,55 @@ +const migrationsDirectory = path.resolve(__dirname, './migrations'); + +/** + * @type { Object. } + */ +export default { + test: { + client: 'better-sqlite3', + connection: { + filename: ':memory:', + //filename: './main.db', + }, + useNullAsDefault: true, + migrations: { + directory: migrationsDirectory, + loadExtensions: ['.mjs'], + }, + }, + development: { + client: 'better-sqlite3', + connection: { + filename: './dev.sqlite3', + }, + }, + staging: { + client: 'postgresql', + connection: { + database: 'my_db', + user: 'username', + password: 'password', + }, + pool: { + min: 2, + max: 10, + }, + migrations: { + tableName: 'knex_migrations', + }, + }, + production: { + client: 'postgresql', + connection: { + database: 'my_db', + user: 'username', + password: 'password', + }, + pool: { + min: 2, + max: 10, + }, + migrations: { + tableName: 'knex_migrations', + }, + }, +}; diff --git a/packages/database/migrations/20240722180545_initial_users_session.mjs b/packages/database/migrations/20240722180545_initial_users_session.mjs new file mode 100644 index 00000000..5a519c93 --- /dev/null +++ b/packages/database/migrations/20240722180545_initial_users_session.mjs @@ -0,0 +1,33 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +export async function up(knex) { + await knex.schema.createTable('users', table => { + table.uuid('id').primary(); //.defaultTo(knex.raw('gen_random_uuid()')); + table.string('email').notNullable().unique(); + table.timestamps(true, true); + }); + + await knex.schema.createTable('sessions', table => { + table.uuid('id').primary(); //.defaultTo(knex.raw('gen_random_uuid()')); + table + .uuid('user_id') + .notNullable() + .references('id') + .inTable('users') + .onDelete('CASCADE'); + table.string('session_token').notNullable().unique(); + table.datetime('expires_at').notNullable(); + table.timestamps(true, true); + }); +} + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +export async function down(knex) { + await knex.schema.dropTableIfExists('sessions'); + await knex.schema.dropTableIfExists('users'); +} diff --git a/packages/database/package.json b/packages/database/package.json new file mode 100644 index 00000000..943c79b9 --- /dev/null +++ b/packages/database/package.json @@ -0,0 +1,26 @@ +{ + "name": "@atj/database", + "version": "1.0.0", + "description": "10x ATJ database", + "type": "module", + "license": "CC0", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "test": "vitest run --coverage" + }, + "dependencies": { + "@atj/common": "workspace:*", + "@types/pg": "^8.11.6", + "better-sqlite3": "^11.1.2", + "knex": "^3.1.0", + "kysely": "^0.27.4", + "pg": "^8.12.0" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.11", + "vite-tsconfig-paths": "^4.3.2" + } +} diff --git a/packages/database/src/clients/knex.ts b/packages/database/src/clients/knex.ts new file mode 100644 index 00000000..577273fd --- /dev/null +++ b/packages/database/src/clients/knex.ts @@ -0,0 +1,37 @@ +import path, { dirname } from 'path'; +import { fileURLToPath } from 'url'; + +import knex, { type Knex } from 'knex'; + +const getDirname = () => dirname(fileURLToPath(import.meta.url)); +const migrationsDirectory = path.resolve(getDirname(), '../../migrations'); + +export const createKnex = (config: Knex.Config): Knex => knex(config); + +export const getTestKnex = (): Knex => { + return knex({ + client: 'better-sqlite3', + connection: { + filename: ':memory:', + }, + useNullAsDefault: true, + migrations: { + directory: migrationsDirectory, + loadExtensions: ['.mjs'], + }, + }); +}; + +export const getDevKnex = (path: string): Knex => { + return knex({ + client: 'better-sqlite3', + connection: { + filename: path, + }, + useNullAsDefault: true, + migrations: { + directory: migrationsDirectory, + loadExtensions: ['.mjs'], + }, + }); +}; diff --git a/packages/database/src/clients/kysely/index.ts b/packages/database/src/clients/kysely/index.ts new file mode 100644 index 00000000..2b8230b8 --- /dev/null +++ b/packages/database/src/clients/kysely/index.ts @@ -0,0 +1,2 @@ +export type { Database, DatabaseClient } from './types.js'; +export { createInMemoryDatabase, createSqliteDatabase } from './sqlite3.js'; diff --git a/packages/database/src/clients/kysely/sqlite3.ts b/packages/database/src/clients/kysely/sqlite3.ts new file mode 100644 index 00000000..7151b9c7 --- /dev/null +++ b/packages/database/src/clients/kysely/sqlite3.ts @@ -0,0 +1,31 @@ +import { Kysely, SqliteDialect } from 'kysely'; +import BetterSqliteDatabase, { + type Database as SqliteDatabase, +} from 'better-sqlite3'; + +import { type Database } from './types'; + +type TestDatabase = { + kysely: Kysely; + sqlite: SqliteDatabase; +}; + +export const createInMemoryDatabase = (): TestDatabase => { + const database = new BetterSqliteDatabase(':memory:'); + return { + kysely: new Kysely({ + dialect: new SqliteDialect({ + database, + }), + }), + sqlite: database, + }; +}; + +export const createSqliteDatabase = (database: SqliteDatabase) => { + return new Kysely({ + dialect: new SqliteDialect({ + database, + }), + }); +}; diff --git a/packages/database/src/clients/kysely/types.ts b/packages/database/src/clients/kysely/types.ts new file mode 100644 index 00000000..b1ab7d2b --- /dev/null +++ b/packages/database/src/clients/kysely/types.ts @@ -0,0 +1,38 @@ +import type { + Generated, + Insertable, + Kysely, + Selectable, + Updateable, +} from 'kysely'; + +export type Engine = 'sqlite' | 'postgres'; + +export interface Database { + users: UsersTable; + sessions: SessionsTable; +} + +interface UsersTable { + id: string; + email: string; + created_at: Generated; + updated_at: Generated; +} +export type UsersSelectable = Selectable; +export type UsersInsertable = Insertable; +export type UsersUpdateable = Updateable; + +interface SessionsTable { + id: string; + user_id: string; + session_token: string; + expires_at: T extends 'sqlite' ? number : T extends 'postgres' ? Date : never; + created_at: Generated; + updated_at: Generated; +} +export type SessionsSelectable = Selectable>; +export type SessionsInsertable = Insertable>; +export type SessionsUpdateable = Updateable>; + +export type DatabaseClient = Kysely; diff --git a/packages/database/src/context/dev.ts b/packages/database/src/context/dev.ts new file mode 100644 index 00000000..d7efcc5d --- /dev/null +++ b/packages/database/src/context/dev.ts @@ -0,0 +1,68 @@ +import path, { dirname } from 'path'; +import { fileURLToPath } from 'url'; + +import { type Database as SqliteDatabase } from 'better-sqlite3'; +import knex, { type Knex } from 'knex'; +import { type Kysely } from 'kysely'; + +import { + type Database, + createSqliteDatabase, +} from '../clients/kysely/index.js'; +import { migrateDatabase } from '../management/migrate-database.js'; + +import { type DatabaseContext } from './types.js'; + +const getDirname = () => dirname(fileURLToPath(import.meta.url)); +const migrationsDirectory = path.resolve(getDirname(), '../../migrations'); + +export class DevDatabaseContext implements DatabaseContext { + knex?: Knex; + kysely?: Kysely; + sqlite3?: SqliteDatabase; + + constructor(private path: string) {} + + async getKnex() { + if (!this.knex) { + this.knex = knex({ + client: 'better-sqlite3', + connection: { + filename: this.path, + }, + pool: { + min: 1, + max: 20, + }, + useNullAsDefault: true, + migrations: { + directory: migrationsDirectory, + loadExtensions: ['.mjs'], + }, + }); + } + return this.knex; + } + + async getSqlite3(): Promise { + const knex = await this.getKnex(); + if (!this.sqlite3) { + this.sqlite3 = (await knex.client.acquireConnection()) as SqliteDatabase; + } + return this.sqlite3; + } + + async getKysely() { + if (!this.kysely) { + const sqlite3 = await this.getSqlite3(); + this.kysely = createSqliteDatabase(sqlite3); + } + return this.kysely; + } +} + +export const createDevDatabaseContext = async (path: string) => { + const ctx = new DevDatabaseContext(path); + await migrateDatabase(ctx); + return ctx; +}; diff --git a/packages/database/src/context/test.ts b/packages/database/src/context/test.ts new file mode 100644 index 00000000..202af06a --- /dev/null +++ b/packages/database/src/context/test.ts @@ -0,0 +1,49 @@ +import { type Database as SqliteDatabase } from 'better-sqlite3'; +import { type Knex } from 'knex'; +import { type Kysely } from 'kysely'; + +import { getTestKnex } from '../clients/knex.js'; +import { + type Database, + createSqliteDatabase, +} from '../clients/kysely/index.js'; +import { migrateDatabase } from '../management/migrate-database.js'; + +import { type DatabaseContext } from './types.js'; + +export class TestDatabaseContext implements DatabaseContext { + knex?: Knex; + kysely?: Kysely; + sqlite3?: SqliteDatabase; + + constructor() {} + + async getKnex() { + if (!this.knex) { + this.knex = getTestKnex(); + } + return this.knex; + } + + async getSqlite3(): Promise { + const knex = await this.getKnex(); + if (!this.sqlite3) { + this.sqlite3 = (await knex.client.acquireConnection()) as SqliteDatabase; + } + return this.sqlite3; + } + + async getKysely() { + if (!this.kysely) { + const sqlite3 = await this.getSqlite3(); + this.kysely = createSqliteDatabase(sqlite3); + } + return this.kysely; + } +} + +export const createTestDatabaseContext = async () => { + const ctx = new TestDatabaseContext(); + await migrateDatabase(ctx); + return ctx; +}; diff --git a/packages/database/src/context/types.ts b/packages/database/src/context/types.ts new file mode 100644 index 00000000..1c2567b3 --- /dev/null +++ b/packages/database/src/context/types.ts @@ -0,0 +1,9 @@ +import { Knex } from 'knex'; +import { Kysely } from 'kysely'; + +import { Database } from '../clients/kysely'; + +export interface DatabaseContext { + getKnex: () => Promise; + getKysely: () => Promise>; +} diff --git a/packages/database/src/gateways/sessions/create-session.test.ts b/packages/database/src/gateways/sessions/create-session.test.ts new file mode 100644 index 00000000..5848107d --- /dev/null +++ b/packages/database/src/gateways/sessions/create-session.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; + +import { createTestDatabaseContext } from '../../context/test'; +import { createSession } from './create-session'; +import { createUser } from '../users/create-user'; + +describe('create session', () => { + it('fails with unknown userId', async () => { + const ctx = await createTestDatabaseContext(); + expect(() => + createSession(ctx, { + id: '1', + expiresAt: new Date(), + sessionToken: 'token', + userId: 'user-id', + }) + ).rejects.toThrow(); + }); + + it('works with existing user', async () => { + const ctx = await createTestDatabaseContext(); + const user = await createUser(ctx, 'user@test.gov'); + if (user === null) { + expect.fail('User was not created'); + } + const sessionId = await createSession(ctx, { + id: '1', + expiresAt: new Date(), + sessionToken: 'token', + userId: user.id, + }); + if (sessionId === null) { + expect.fail('Session was not created'); + } + + const db = await ctx.getKysely(); + const insertedSession = await db + .selectFrom('sessions') + .select(['id']) + .where('id', '=', sessionId) + .executeTakeFirst(); + expect(insertedSession?.id).toEqual(sessionId); + }); +}); diff --git a/packages/database/src/gateways/sessions/create-session.ts b/packages/database/src/gateways/sessions/create-session.ts new file mode 100644 index 00000000..b4793fc8 --- /dev/null +++ b/packages/database/src/gateways/sessions/create-session.ts @@ -0,0 +1,28 @@ +import { type DatabaseContext } from '../../context/types.js'; + +type Session = { + id: string; + expiresAt: Date; + sessionToken: string; + userId: string; +}; + +export const createSession = async (ctx: DatabaseContext, session: Session) => { + const db = await ctx.getKysely(); + const result = await db.transaction().execute(async trx => { + return await trx + .insertInto('sessions') + .values({ + id: session.id, + expires_at: Math.floor(session.expiresAt.getTime() / 1000), + session_token: session.sessionToken, + user_id: session.userId, + //...session.attributes, + }) + .execute(); + }); + if (result.length === 0) { + return null; + } + return session.id; +}; diff --git a/packages/database/src/gateways/users/create-user.test.ts b/packages/database/src/gateways/users/create-user.test.ts new file mode 100644 index 00000000..c33d4efb --- /dev/null +++ b/packages/database/src/gateways/users/create-user.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; + +import { createTestDatabaseContext } from '../../context/test'; +import { createUser } from './create-user'; + +describe('create user', () => { + it('works with unknown email address', async () => { + const ctx = await createTestDatabaseContext(); + const resultUser = await createUser(ctx, 'new-user@email.com'); + if (resultUser === null) { + expect.fail('User was not created'); + } + + const db = await ctx.getKysely(); + const insertedUser = await db + .selectFrom('users') + .select(['email', 'id']) + .where('id', '=', resultUser.id) + .executeTakeFirst(); + + expect(resultUser.id).not.to.be.null; + expect(insertedUser?.id).toEqual(resultUser.id); + }); + + it('fails with known email address', async () => { + const ctx = await createTestDatabaseContext(); + const existingUserResult = await createUser(ctx, 'new-user@email.com'); + if (existingUserResult === null) { + expect.fail('User was not created'); + } + const resultUser = await createUser(ctx, 'new-user@email.com'); + expect(resultUser).toBeNull(); + + // Check that there is only one row in the users table + const db = await ctx.getKysely(); + const insertedUser = await db + .selectFrom('users') + .select(db.fn.count('id').as('count')) + .executeTakeFirst(); + expect(Number(insertedUser?.count)).toEqual(1); + }); +}); diff --git a/packages/database/src/gateways/users/create-user.ts b/packages/database/src/gateways/users/create-user.ts new file mode 100644 index 00000000..0208b8e9 --- /dev/null +++ b/packages/database/src/gateways/users/create-user.ts @@ -0,0 +1,26 @@ +import { randomUUID } from 'crypto'; + +import { type DatabaseContext } from '../../context/types.js'; + +export const createUser = async (ctx: DatabaseContext, email: string) => { + const id = randomUUID(); + + const db = await ctx.getKysely(); + const result = await db + .insertInto('users') + .values({ + id, + email, + }) + .onConflict(oc => oc.doNothing()) + .executeTakeFirst(); + + if (!result.numInsertedOrUpdatedRows) { + return null; + } + + return { + id, + email, + }; +}; diff --git a/packages/database/src/gateways/users/get-user-id.test.ts b/packages/database/src/gateways/users/get-user-id.test.ts new file mode 100644 index 00000000..640b73ce --- /dev/null +++ b/packages/database/src/gateways/users/get-user-id.test.ts @@ -0,0 +1,32 @@ +import { randomUUID } from 'crypto'; +import { describe, expect, it } from 'vitest'; + +import { createTestDatabaseContext } from '../../context/test'; + +import { getUserId } from './get-user-id'; + +describe('get user id', () => { + it('returns null for non-existent user', async () => { + const ctx = await createTestDatabaseContext(); + const userId = await getUserId(ctx, 'new-user@email.com'); + expect(userId).to.be.null; + }); + + it('returns null for non-existent user', async () => { + const ctx = await createTestDatabaseContext(); + const id = randomUUID(); + + const db = await ctx.getKysely(); + await db + .insertInto('users') + .values({ + id, + email: 'user@agency.gov', + }) + .executeTakeFirst(); + + const userId = await getUserId(ctx, 'user@agency.gov'); + expect(userId).not.to.be.null; + expect(userId).to.be.equal(id); + }); +}); diff --git a/packages/database/src/gateways/users/get-user-id.ts b/packages/database/src/gateways/users/get-user-id.ts new file mode 100644 index 00000000..802b51eb --- /dev/null +++ b/packages/database/src/gateways/users/get-user-id.ts @@ -0,0 +1,18 @@ +import { type DatabaseContext } from '../..'; + +export const getUserId = async (ctx: DatabaseContext, email: string) => { + const db = await ctx.getKysely(); + const user = await db.transaction().execute(trx => { + return trx + .selectFrom('users') + .select('id') + .where('email', '=', email) + .executeTakeFirst(); + }); + + if (!user) { + return null; + } + + return user.id; +}; diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts new file mode 100644 index 00000000..60f41e53 --- /dev/null +++ b/packages/database/src/index.ts @@ -0,0 +1,24 @@ +import { createService } from '@atj/common'; + +import { DatabaseContext } from './context/types.js'; +import { createSession } from './gateways/sessions/create-session.js'; +import { createUser } from './gateways/users/create-user.js'; +import { getUserId } from './gateways/users/get-user-id.js'; + +export { + type DevDatabaseContext, + createDevDatabaseContext, +} from './context/dev.js'; +export { createTestDatabaseContext } from './context/test.js'; +export { type Database } from './clients/kysely/index.js'; +export { type DatabaseContext } from './context/types.js'; +export { migrateDatabase } from './management/migrate-database.js'; + +export const createDatabaseGateway = (ctx: DatabaseContext) => + createService(ctx, { + createSession, + createUser, + getUserId, + }); + +export type DatabaseGateway = ReturnType; diff --git a/packages/database/src/management/migrate-database.test.ts b/packages/database/src/management/migrate-database.test.ts new file mode 100644 index 00000000..8382ba00 --- /dev/null +++ b/packages/database/src/management/migrate-database.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; + +import { createTestDatabaseContext } from '../context/test'; + +import { migrateDatabase } from './migrate-database'; + +describe('Knex migrations', {}, () => { + it('migrate and rollback successfully', async () => { + const ctx = await createTestDatabaseContext(); + const rollback = await migrateDatabase(ctx); + const db = await ctx.getKnex(); + expect(await db.schema.hasTable('users')).to.be.true; + expect(await db.schema.hasTable('sessions')).to.be.true; + + await rollback(); + expect(await db.schema.hasTable('users')).to.be.false; + expect(await db.schema.hasTable('sessions')).to.be.false; + + await db.destroy(); + }); +}); diff --git a/packages/database/src/management/migrate-database.ts b/packages/database/src/management/migrate-database.ts new file mode 100644 index 00000000..e1aa341c --- /dev/null +++ b/packages/database/src/management/migrate-database.ts @@ -0,0 +1,7 @@ +import { type DatabaseContext } from '../context/types'; + +export const migrateDatabase = async (ctx: DatabaseContext) => { + const db = await ctx.getKnex(); + await db.migrate.latest(); + return () => db.migrate.rollback(); +}; diff --git a/packages/database/src/schema.ts b/packages/database/src/schema.ts new file mode 100644 index 00000000..319fe17a --- /dev/null +++ b/packages/database/src/schema.ts @@ -0,0 +1,8 @@ +export type SessionSchema = { + id: string; + user_id: string; + session_token: string; + expires_at: number; + created_at: number; + updated_at: number; +}; diff --git a/packages/database/tsconfig.json b/packages/database/tsconfig.json new file mode 100644 index 00000000..f1b9b550 --- /dev/null +++ b/packages/database/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "emitDeclarationOnly": false, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src"], + "exclude": ["./dist"], + "references": [] +} diff --git a/packages/database/vitest.config.ts b/packages/database/vitest.config.ts new file mode 100644 index 00000000..dd6c589c --- /dev/null +++ b/packages/database/vitest.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from 'vitest/config'; + +import sharedTestConfig from '../../vitest.shared'; + +export default defineConfig(sharedTestConfig); diff --git a/packages/dependency-graph/package.json b/packages/dependency-graph/package.json index 70c715f6..cd6f6fa1 100644 --- a/packages/dependency-graph/package.json +++ b/packages/dependency-graph/package.json @@ -2,19 +2,21 @@ "name": "@atj/dependency-graph", "version": "1.0.0", "description": "generates a dependency graph of projects in a pnpm workspace", - "type": "commonjs", + "type": "module", "license": "CC0", - "main": "src/index.ts", + "main": "dist/index.js", + "types": "dist/index.d.js", "scripts": { - "build": "tsup src/* --env.NODE_ENV production", + "build": "tsc", + "dev": "tsc --watch", "test": "echo no @atj/dependency-graph tests" }, "dependencies": { "@pnpm/find-workspace-packages": "^6.0.9", - "@pnpm/logger": "^5.0.0", + "@pnpm/logger": "^5.2.0", "graphviz": "^0.0.9" }, "devDependencies": { - "@types/graphviz": "^0.0.37" + "@types/graphviz": "^0.0.39" } } diff --git a/packages/dependency-graph/tsconfig.json b/packages/dependency-graph/tsconfig.json index a7c17353..a374c527 100644 --- a/packages/dependency-graph/tsconfig.json +++ b/packages/dependency-graph/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "./dist", - "emitDeclarationOnly": true + "emitDeclarationOnly": false, + "rootDir": "./src" }, "include": ["./src"], "references": [] diff --git a/packages/design/.storybook/main.ts b/packages/design/.storybook/main.ts index 8a89b70f..38b60665 100644 --- a/packages/design/.storybook/main.ts +++ b/packages/design/.storybook/main.ts @@ -19,13 +19,16 @@ const config: StorybookConfig = { getAbsolutePath('@storybook/addon-a11y'), getAbsolutePath('@storybook/addon-coverage'), ], - framework: { - name: getAbsolutePath('@storybook/react-vite') as '@storybook/react-vite', - options: {}, + core: { + disableTelemetry: true, }, docs: { autodocs: 'tag', }, + framework: { + name: getAbsolutePath('@storybook/react-vite') as '@storybook/react-vite', + options: {}, + }, staticDirs: ['../static'], }; export default config; diff --git a/packages/design/package.json b/packages/design/package.json index 860a4c4b..7c564972 100644 --- a/packages/design/package.json +++ b/packages/design/package.json @@ -23,39 +23,39 @@ "dist/**/*" ], "devDependencies": { - "@playwright/test": "^1.43.1", - "@storybook/addon-a11y": "^7.6.10", - "@storybook/addon-coverage": "^1.0.0", - "@storybook/addon-essentials": "^7.6.10", - "@storybook/addon-interactions": "^7.6.10", - "@storybook/addon-links": "^7.6.10", - "@storybook/blocks": "^7.6.10", - "@storybook/preview-api": "^7.6.10", - "@storybook/react": "^7.6.10", - "@storybook/react-vite": "^7.6.10", - "@storybook/test": "^7.6.10", - "@storybook/test-runner": "^0.16.0", - "@storybook/types": "^7.6.10", - "@testing-library/react": "^15.0.7", + "@playwright/test": "^1.46.0", + "@storybook/addon-a11y": "^8.2.8", + "@storybook/addon-coverage": "^1.0.4", + "@storybook/addon-essentials": "^8.2.8", + "@storybook/addon-interactions": "^8.2.8", + "@storybook/addon-links": "^8.2.8", + "@storybook/blocks": "^8.2.8", + "@storybook/preview-api": "^8.2.8", + "@storybook/react": "^8.2.8", + "@storybook/react-vite": "^8.2.8", + "@storybook/test": "^8.2.8", + "@storybook/test-runner": "^0.17.0", + "@storybook/types": "^8.2.8", + "@testing-library/jest-dom": "^6.4.8", + "@testing-library/react": "^16.0.0", "@types/deep-equal": "^1.0.4", "@types/prop-types": "^15.7.12", - "@types/react": "^18.2.79", - "@typescript-eslint/eslint-plugin": "^7.7.0", - "@typescript-eslint/parser": "^7.7.0", + "@types/react": "^18.3.3", + "@typescript-eslint/eslint-plugin": "^7.18.0", + "@typescript-eslint/parser": "^7.18.0", "@uswds/compile": "1.1.0", - "@vitejs/plugin-react": "^4.3.0", + "@vitejs/plugin-react": "^4.3.1", "concurrently": "^8.2.2", - "eslint": "^8.56.0", - "eslint-plugin-react": "^7.34.1", - "glob": "^10.3.12", + "eslint": "^8.57.0", + "eslint-plugin-react": "^7.35.0", "gulp": "^5.0.0", "http-server": "^14.1.1", "install": "^0.13.0", - "jsdom": "^24.1.0", + "jsdom": "^24.1.1", "prop-types": "^15.8.1", - "react-dom": "^18.2.0", - "vite": "^5.2.11", - "vite-plugin-dts": "^3.9.1", + "react-dom": "^18.3.1", + "vite": "^5.4.0", + "vite-plugin-dts": "^4.0.1", "wait-on": "^7.2.0" }, "dependencies": { @@ -64,14 +64,14 @@ "@dnd-kit/core": "^6.1.0", "@dnd-kit/sortable": "^8.0.0", "@dnd-kit/utilities": "^3.2.2", - "@uswds/uswds": "^3.8.0", + "@uswds/uswds": "^3.8.1", "classnames": "^2.5.1", "deep-equal": "^2.2.3", - "react": "^18.2.0", - "react-hook-form": "^7.51.3", - "react-router-dom": "^6.22.3", - "storybook": "^7.6.10", - "zustand": "^4.5.2", + "react": "^18.3.1", + "react-hook-form": "^7.52.2", + "react-router-dom": "^6.26.0", + "storybook": "^8.2.8", + "zustand": "^4.5.4", "zustand-utils": "^1.3.2" } } diff --git a/packages/design/src/FormManager/FormEdit/store.ts b/packages/design/src/FormManager/FormEdit/store.ts index 7db549c4..c180ad31 100644 --- a/packages/design/src/FormManager/FormEdit/store.ts +++ b/packages/design/src/FormManager/FormEdit/store.ts @@ -1,10 +1,10 @@ import { StateCreator } from 'zustand'; import { + type FormSession, type Pattern, type PatternId, type PatternMap, - type FormSession, BlueprintBuilder, getPattern, getSessionPage, diff --git a/packages/design/src/test1.test.ts b/packages/design/src/test1.test.ts new file mode 100644 index 00000000..91c6de65 --- /dev/null +++ b/packages/design/src/test1.test.ts @@ -0,0 +1,7 @@ +import { describe, expect, it } from 'vitest'; + +describe('test1', () => { + it('should work', () => { + expect(1).toBe(1); + }); +}); diff --git a/packages/design/tsconfig.build.json b/packages/design/tsconfig.build.json index 7d7c65ec..6739a56a 100644 --- a/packages/design/tsconfig.build.json +++ b/packages/design/tsconfig.build.json @@ -1,11 +1,10 @@ { - "extends": "../../tsconfig.json", + "extends": "../../tsconfig.base.json", "compilerOptions": { "baseUrl": "./src", "emitDeclarationOnly": true, "jsx": "react", "outDir": "./dist", - "module": "esnext", "types": ["vite/client", "node"] }, "include": ["./src"], diff --git a/packages/design/tsconfig.json b/packages/design/tsconfig.json index db466585..a85819fa 100644 --- a/packages/design/tsconfig.json +++ b/packages/design/tsconfig.json @@ -1,11 +1,11 @@ { - "extends": "../../tsconfig.json", + "extends": "../../tsconfig.base.json", "compilerOptions": { "baseUrl": "./src", "emitDeclarationOnly": true, "jsx": "react", - "module": "esnext", "outDir": "./dist", + "rootDir": "./src", "types": ["vite/client", "node"] }, "include": ["./src"], diff --git a/packages/design/vitest.config.ts b/packages/design/vitest.config.ts index c58ffabd..95d4723e 100644 --- a/packages/design/vitest.config.ts +++ b/packages/design/vitest.config.ts @@ -10,6 +10,7 @@ export default mergeConfig( test: { ...sharedTestConfig.test, environment: 'jsdom', + setupFiles: './vitest.setup.ts', }, }) ); diff --git a/packages/design/vitest.setup.ts b/packages/design/vitest.setup.ts new file mode 100644 index 00000000..bb02c60c --- /dev/null +++ b/packages/design/vitest.setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom/vitest'; diff --git a/packages/docassemble/package.json b/packages/docassemble/package.json index ada1ad12..981a974c 100644 --- a/packages/docassemble/package.json +++ b/packages/docassemble/package.json @@ -4,17 +4,14 @@ "description": "10x ATJ docassemble adapter", "type": "module", "license": "CC0", - "main": "src/index.ts", + "main": "dist/index.js", + "types": "dist/index.d.ts", "scripts": { - "build": "tsup src/* --env.NODE_ENV production", - "dev": "tsup src/* --watch", + "build": "tsc", + "dev": "tsc --watch", "test:integration": "vitest run --coverage" }, "dependencies": { "@atj/forms": "workspace:*" - }, - "devDependencies": { - "@vitest/coverage-v8": "^0.34.6", - "vitest": "^1.6.0" } } diff --git a/packages/docassemble/tsconfig.json b/packages/docassemble/tsconfig.json index ea22b13a..0117b567 100644 --- a/packages/docassemble/tsconfig.json +++ b/packages/docassemble/tsconfig.json @@ -1,9 +1,10 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { + "emitDeclarationOnly": false, "module": "ESNext", "outDir": "./dist", - "emitDeclarationOnly": true + "rootDir": "./src" }, "include": ["./src"], "references": [] diff --git a/packages/forms/package.json b/packages/forms/package.json index 9a5fc9e2..a39e2054 100644 --- a/packages/forms/package.json +++ b/packages/forms/package.json @@ -4,17 +4,18 @@ "description": "10x ATJ form handling", "type": "module", "license": "CC0", - "main": "src/index.ts", + "main": "dist/index.js", + "types": "dist/index.d.ts", "scripts": { - "build": "tsup src/* --env.NODE_ENV production", - "dev": "tsup src/* --watch", + "build": "tsc", + "dev": "tsc --watch", "test": "vitest run --coverage" }, "dependencies": { "@atj/common": "workspace:*", "pdf-lib": "^1.17.1", - "qs": "^6.12.1", - "zod": "^3.22.4" + "qs": "^6.13.0", + "zod": "^3.23.8" }, "devDependencies": { "@types/qs": "^6.9.15" diff --git a/packages/forms/src/pattern.ts b/packages/forms/src/pattern.ts index b4dbf08a..15517ea7 100644 --- a/packages/forms/src/pattern.ts +++ b/packages/forms/src/pattern.ts @@ -1,5 +1,6 @@ import * as r from '@atj/common'; import { + addPatternToFieldset, type Blueprint, type FormError, type FormErrors, diff --git a/packages/forms/src/service/operations/add-form.ts b/packages/forms/src/service/operations/add-form.ts index 7222d1cd..d7d1e756 100644 --- a/packages/forms/src/service/operations/add-form.ts +++ b/packages/forms/src/service/operations/add-form.ts @@ -1,5 +1,5 @@ import { Result } from '@atj/common'; -import { Blueprint } from '../../..'; +import { Blueprint } from '../../index.js'; import { addFormToStorage } from '../context/browser/form-repo'; diff --git a/packages/forms/src/service/operations/get-form.ts b/packages/forms/src/service/operations/get-form.ts index f7b25de4..b1f32e5e 100644 --- a/packages/forms/src/service/operations/get-form.ts +++ b/packages/forms/src/service/operations/get-form.ts @@ -1,5 +1,5 @@ import { Result } from '@atj/common'; -import { type Blueprint } from '../../..'; +import { type Blueprint } from '../../index.js'; import { getFormFromStorage } from '../context/browser/form-repo'; diff --git a/packages/forms/src/service/operations/save-form.ts b/packages/forms/src/service/operations/save-form.ts index 61d9ff7c..b3a1b170 100644 --- a/packages/forms/src/service/operations/save-form.ts +++ b/packages/forms/src/service/operations/save-form.ts @@ -1,5 +1,5 @@ import { Result } from '@atj/common'; -import { Blueprint } from '../../..'; +import { Blueprint } from '../../index.js'; import { saveFormToStorage } from '../context/browser/form-repo'; diff --git a/packages/forms/src/service/operations/submit-form.test.ts b/packages/forms/src/service/operations/submit-form.test.ts index 15f13b25..4a389636 100644 --- a/packages/forms/src/service/operations/submit-form.test.ts +++ b/packages/forms/src/service/operations/submit-form.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { createForm, createFormSession } from '../../..'; +import { createForm, createFormSession } from '../../index.js'; import { createTestFormService } from '../context/test'; describe('submitForm', () => { diff --git a/packages/forms/src/service/operations/submit-form.ts b/packages/forms/src/service/operations/submit-form.ts index 3be1037c..6b235487 100644 --- a/packages/forms/src/service/operations/submit-form.ts +++ b/packages/forms/src/service/operations/submit-form.ts @@ -7,9 +7,9 @@ import { createFormOutputFieldData, fillPDF, sessionIsComplete, -} from '../../..'; +} from '../../index.js'; -import { getFormFromStorage } from '../context/browser/form-repo'; +import { getFormFromStorage } from '../context/browser/form-repo.js'; export const submitForm = async ( ctx: { storage: Storage; config: FormConfig }, diff --git a/packages/forms/tsconfig.json b/packages/forms/tsconfig.json index a78c2b25..73454b0b 100644 --- a/packages/forms/tsconfig.json +++ b/packages/forms/tsconfig.json @@ -1,8 +1,9 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "emitDeclarationOnly": true, - "outDir": "./dist" + "emitDeclarationOnly": false, + "outDir": "./dist", + "rootDir": "./src" }, "include": ["./src/**/*"], "exclude": ["./dist"], diff --git a/packages/server/README.md b/packages/server/README.md index 640bfb6d..bdc53ef5 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -21,7 +21,7 @@ pnpm dev To start the provided Express server: ```typescript -import { createServer } from '@atj/server/dist/index.js'; +import { createServer } from '@atj/server'; const port = process.env.PORT || 4321; diff --git a/packages/server/astro.config.mjs b/packages/server/astro.config.mjs index ca1aac85..516c509f 100644 --- a/packages/server/astro.config.mjs +++ b/packages/server/astro.config.mjs @@ -19,6 +19,9 @@ export default defineConfig({ include: ['src/components/react/**'], }), ], + security: { + checkOrigin: true, + }, server: { port: 4322, }, diff --git a/packages/server/build-handler.js b/packages/server/build-handler.js new file mode 100644 index 00000000..df821bcb --- /dev/null +++ b/packages/server/build-handler.js @@ -0,0 +1,15 @@ +import esbuild from 'esbuild'; + +esbuild + .build({ + bundle: false, + entryPoints: ['./handler.ts'], + packages: 'external', + format: 'esm', + minify: false, + outdir: './dist', + platform: 'node', + sourcemap: true, + target: 'es2020', + }) + .catch(() => process.exit(1)); diff --git a/packages/server/src/index.ts b/packages/server/handler.ts similarity index 59% rename from packages/server/src/index.ts rename to packages/server/handler.ts index 9c07df5c..364000be 100644 --- a/packages/server/src/index.ts +++ b/packages/server/handler.ts @@ -1,11 +1,14 @@ +/** + * This is the entrypoint for the server. It provides a `createServer` factory + * that returns an Express handler, which in turn wraps the Astro web server. + * This en + */ import path, { dirname } from 'path'; import { fileURLToPath } from 'url'; import express from 'express'; -export type ServerOptions = { - title: string; -}; +import { type ServerOptions } from './src/context.js'; export const createServer = async ( serverOptions: ServerOptions @@ -15,10 +18,13 @@ export const createServer = async ( app.use((req, res, next) => { // Pass ServerOptions as request locals. handler(req, res, next, { + ctx: null, serverOptions, - } satisfies App.Locals); + session: null, + user: null, + }); }); - app.use(express.static(path.join(getDirname(), '../dist/client'))); + app.use(express.static(path.join(getDirname(), './client'))); return app; }; @@ -30,6 +36,6 @@ const getDirname = () => { const getHandler = async () => { // @ts-ignore - const { handler } = await import('../dist/server/entry.mjs'); + const { handler } = await import('./server/entry.mjs'); return handler; }; diff --git a/packages/server/package.json b/packages/server/package.json index 1bb32792..844230b5 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -2,30 +2,38 @@ "name": "@atj/server", "type": "module", "version": "0.0.1", - "main": "dist/server/index.js", + "main": "dist/handler.js", + "types": "handler.ts", + "files": [ + "dist" + ], "scripts": { "dev": "astro dev", "start": "astro dev", - "build": "astro check && astro build && pnpm build:entry", - "build:entry": "tsup --dts --format esm src/index.ts", + "build": "astro check && astro build && pnpm build:handler", + "build:handler": "node ./build-handler.js", "preview": "astro preview", "astro": "astro" }, "dependencies": { - "@astrojs/check": "^0.7.0", - "@astrojs/node": "^8.3.1", - "@astrojs/react": "^3.6.0", + "@astrojs/check": "^0.9.2", + "@astrojs/node": "^8.3.2", + "@astrojs/react": "^3.6.1", + "@atj/auth": "workspace:^", + "@atj/common": "workspace:*", + "@atj/database": "workspace:*", "@atj/design": "workspace:*", "@atj/forms": "workspace:*", - "astro": "^4.10.3", + "astro": "^4.13.2", "express": "^4.19.2", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "react-error-boundary": "^4.0.12", - "typescript": "^5.3.3" + "jwt-decode": "^4.0.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-error-boundary": "^4.0.13", + "typescript": "^5.5.4" }, "devDependencies": { "@types/express": "^4.17.21", - "@types/react": "^18.2.62" + "@types/react": "^18.3.3" } } diff --git a/packages/server/src/components/Footer.astro b/packages/server/src/components/Footer.astro index a9e76999..6c9044c2 100644 --- a/packages/server/src/components/Footer.astro +++ b/packages/server/src/components/Footer.astro @@ -2,7 +2,7 @@ import { getBranchTreeUrl } from '../lib/github'; import { getAstroAppContext } from '../context'; -const { github } = getAstroAppContext(Astro); +const { github } = await getAstroAppContext(Astro); ---