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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/nomination-worker/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
"typecheck": "tsc -b --noEmit",
"lint": "eslint src",
"test": "vitest run",
"dev": "echo 'nomination-worker dev entry TBD'"
"dev": "echo 'nomination-worker dev entry TBD'",
"build:lambda": "node ../../scripts/bundle-lambda.mjs ."
},
"dependencies": {
"@nominate/configuration": "workspace:*",
Expand Down
7 changes: 2 additions & 5 deletions apps/nomination-worker/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto";
import { loadEnv } from "@nominate/configuration";
import { loadEnv, resolveBotToken } from "@nominate/configuration";
import {
decodeEvent,
UnsupportedSchemaVersionError,
Expand Down Expand Up @@ -30,10 +30,7 @@ let cachedDeps: WorkerDeps | undefined;
async function getDeps(): Promise<WorkerDeps> {
if (cachedDeps) return cachedDeps;
const env = loadEnv();
const botToken = env.SLACK_BOT_TOKEN;
if (!botToken) {
throw new Error("SLACK_BOT_TOKEN must be resolved before handling messages");
}
const botToken = await resolveBotToken(env);
const logger = createLogger({
service: env.SERVICE_NAME,
environment: env.NODE_ENV,
Expand Down
3 changes: 2 additions & 1 deletion apps/reminder-job/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
"typecheck": "tsc -b --noEmit",
"lint": "eslint src",
"test": "vitest run",
"dev": "echo 'reminder-job dev entry TBD'"
"dev": "echo 'reminder-job dev entry TBD'",
"build:lambda": "node ../../scripts/bundle-lambda.mjs ."
},
"dependencies": {
"@nominate/configuration": "workspace:*",
Expand Down
7 changes: 2 additions & 5 deletions apps/reminder-job/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { loadEnv } from "@nominate/configuration";
import { loadEnv, resolveBotToken } from "@nominate/configuration";
import type { WeeklyReminderRequestedV1 } from "@nominate/contracts";
import { createLogger, type Logger } from "@nominate/observability";
import {
Expand All @@ -21,10 +21,7 @@ let cachedDeps: HandlerDeps | undefined;
async function getDeps(): Promise<HandlerDeps> {
if (cachedDeps) return cachedDeps;
const env = loadEnv();
const botToken = env.SLACK_BOT_TOKEN;
if (!botToken) {
throw new Error("SLACK_BOT_TOKEN must be resolved before handling reminders");
}
const botToken = await resolveBotToken(env);
const logger = createLogger({
service: env.SERVICE_NAME,
environment: env.NODE_ENV,
Expand Down
3 changes: 2 additions & 1 deletion apps/report-job/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
"typecheck": "tsc -b --noEmit",
"lint": "eslint src",
"test": "vitest run",
"dev": "echo 'report-job dev entry TBD'"
"dev": "echo 'report-job dev entry TBD'",
"build:lambda": "node ../../scripts/bundle-lambda.mjs ."
},
"dependencies": {
"@nominate/configuration": "workspace:*",
Expand Down
7 changes: 2 additions & 5 deletions apps/report-job/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { loadEnv } from "@nominate/configuration";
import { loadEnv, resolveBotToken } from "@nominate/configuration";
import type { BiweeklyReportRequestedV1 } from "@nominate/contracts";
import { createLogger, type Logger } from "@nominate/observability";
import {
Expand All @@ -25,10 +25,7 @@ let cachedDeps: HandlerDeps | undefined;
async function getDeps(): Promise<HandlerDeps> {
if (cachedDeps) return cachedDeps;
const env = loadEnv();
const botToken = env.SLACK_BOT_TOKEN;
if (!botToken) {
throw new Error("SLACK_BOT_TOKEN must be resolved before handling reports");
}
const botToken = await resolveBotToken(env);
const logger = createLogger({
service: env.SERVICE_NAME,
environment: env.NODE_ENV,
Expand Down
3 changes: 2 additions & 1 deletion apps/slack-ingress/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
"typecheck": "tsc -b --noEmit",
"lint": "eslint src",
"test": "vitest run",
"dev": "tsx watch src/dev.ts"
"dev": "tsx watch src/dev.ts",
"build:lambda": "node ../../scripts/bundle-lambda.mjs ."
},
"dependencies": {
"@nominate/configuration": "workspace:*",
Expand Down
14 changes: 5 additions & 9 deletions apps/slack-ingress/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto";
import { loadEnv } from "@nominate/configuration";
import { loadEnv, resolveBotToken, resolveSigningSecret } from "@nominate/configuration";
import { createLogger, type Logger } from "@nominate/observability";
import { createSlackClient, type SlackClient, type SlashCommandPayload } from "@nominate/slack";
import type { APIGatewayProxyEventV2, APIGatewayProxyStructuredResultV2 } from "aws-lambda";
Expand Down Expand Up @@ -29,14 +29,10 @@ let cachedDeps: Deps | undefined;
async function getDeps(): Promise<Deps> {
if (cachedDeps) return cachedDeps;
const env = loadEnv();
const signingSecret = env.SLACK_SIGNING_SECRET;
const botToken = env.SLACK_BOT_TOKEN;
if (!signingSecret) {
throw new Error("SLACK_SIGNING_SECRET must be resolved before handling requests");
}
if (!botToken) {
throw new Error("SLACK_BOT_TOKEN must be resolved before handling requests");
}
const [signingSecret, botToken] = await Promise.all([
resolveSigningSecret(env),
resolveBotToken(env),
]);
const logger = createLogger({
service: env.SERVICE_NAME,
environment: env.NODE_ENV,
Expand Down
18 changes: 14 additions & 4 deletions docs/14-deployment-and-environments.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,17 +86,27 @@ Runs once per AWS account, per environment. `dev` first, then `production` with

9. **Configure Slack.** `terraform output slack_request_url` prints the API Gateway URL. Paste it into the Slack app's Request URL (see `docs/18`). Invite the bot to the configured recognition channel.

10. **Swap the stubs for real Lambda code.**

```bash
pnpm build:lambda
pnpm infra:upload-lambdas <env>
```

`build:lambda` bundles each `apps/*/src/index.ts` into `apps/*/dist/lambda.zip` via esbuild (`@aws-sdk/*` externalized because the Lambda runtime provides SDK v3). `infra:upload-lambdas` uploads each zip to the same S3 keys the stubs occupied and calls `aws lambda update-function-code` so the running Lambdas pick up the new code — no `terraform apply` needed.

## Deployment order

Once an environment is stood up, subsequent releases follow:

1. Validate and plan infrastructure.
2. Deploy backward-compatible table/index changes.
3. Build versioned Lambda artifacts.
3. Build versioned Lambda artifacts (`pnpm build:lambda`).
4. Apply infrastructure using reviewed artifacts.
5. Run smoke tests against the Slack development app.
6. Promote to production with approval.
7. Verify command, reminder/report configuration, logs, alarms, and queue health.
5. Publish new Lambda code (`pnpm infra:upload-lambdas <env>`).
6. Run smoke tests against the Slack development app.
7. Promote to production with approval.
8. Verify command, reminder/report configuration, logs, alarms, and queue health.

## Database changes

Expand Down
47 changes: 19 additions & 28 deletions infra/modules/compute/main.tf
Original file line number Diff line number Diff line change
@@ -1,62 +1,53 @@
locals {
name_prefix = "${var.project}-${var.environment}"

# Shared config injected into every Lambda. packages/configuration expects
# this full set at cold start regardless of the function's role; scoping
# environment variables per-Lambda used to trip loadEnv on ingress. The
# secrets are still IAM-scoped per role, so a function that doesn't need a
# given credential can't read it even though the ARN is present.
shared_env = {
SLACK_SIGNING_SECRET_ARN = var.slack_signing_secret_arn
SLACK_BOT_TOKEN_ARN = var.slack_bot_token_arn
SLACK_RECOGNITION_CHANNEL_ID = var.recognition_channel_id
DYNAMODB_TABLE_NAME = var.dynamodb_table_name
NOMINATION_QUEUE_URL = var.nomination_queue_url
PROGRAM_TIMEZONE = var.program_timezone
PROGRAM_START_AT = var.program_start_at
}

functions = {
slack_ingress = {
name = "${local.name_prefix}-slack-ingress"
artifact = coalesce(try(var.artifact_object_keys.slack_ingress, null), "${local.name_prefix}-slack-ingress.zip")
handler = "dist/index.handler"
timeout = var.default_timeout_seconds
memory_size = 256
env = {
SLACK_SIGNING_SECRET_ARN = var.slack_signing_secret_arn
SLACK_BOT_TOKEN_ARN = var.slack_bot_token_arn
NOMINATION_QUEUE_URL = var.nomination_queue_url
PROGRAM_TIMEZONE = var.program_timezone
PROGRAM_START_AT = var.program_start_at
}
env = local.shared_env
}
nomination_worker = {
name = "${local.name_prefix}-nomination-worker"
artifact = coalesce(try(var.artifact_object_keys.nomination_worker, null), "${local.name_prefix}-nomination-worker.zip")
handler = "dist/index.handler"
timeout = var.worker_timeout_seconds
memory_size = 512
env = {
DYNAMODB_TABLE_NAME = var.dynamodb_table_name
SLACK_BOT_TOKEN_ARN = var.slack_bot_token_arn
SLACK_RECOGNITION_CHANNEL_ID = var.recognition_channel_id
PROGRAM_TIMEZONE = var.program_timezone
PROGRAM_START_AT = var.program_start_at
}
env = local.shared_env
}
reminder = {
name = "${local.name_prefix}-reminder"
artifact = coalesce(try(var.artifact_object_keys.reminder, null), "${local.name_prefix}-reminder.zip")
handler = "dist/index.handler"
timeout = var.default_timeout_seconds
memory_size = 256
env = {
DYNAMODB_TABLE_NAME = var.dynamodb_table_name
SLACK_BOT_TOKEN_ARN = var.slack_bot_token_arn
SLACK_RECOGNITION_CHANNEL_ID = var.recognition_channel_id
PROGRAM_TIMEZONE = var.program_timezone
PROGRAM_START_AT = var.program_start_at
}
env = local.shared_env
}
report = {
name = "${local.name_prefix}-report"
artifact = coalesce(try(var.artifact_object_keys.report, null), "${local.name_prefix}-report.zip")
handler = "dist/index.handler"
timeout = var.default_timeout_seconds
memory_size = 512
env = {
DYNAMODB_TABLE_NAME = var.dynamodb_table_name
SLACK_BOT_TOKEN_ARN = var.slack_bot_token_arn
SLACK_RECOGNITION_CHANNEL_ID = var.recognition_channel_id
PROGRAM_TIMEZONE = var.program_timezone
PROGRAM_START_AT = var.program_start_at
}
env = local.shared_env
}
}
}
Expand Down
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"test": "vitest run --project unit --passWithNoTests",
"test:integration": "vitest run --project integration --passWithNoTests",
"build": "pnpm -r --parallel build",
"build:lambda": "pnpm --filter \"@nominate/app-*\" --parallel build:lambda",
"verify": "pnpm lint && pnpm typecheck && pnpm test && pnpm build",
"prepare": "husky",
"dev": "pnpm -r --parallel --stream dev",
Expand All @@ -21,6 +22,7 @@
"infra:bootstrap": "bash scripts/bootstrap-tfstate.sh",
"infra:artifacts": "bash scripts/bootstrap-artifacts.sh",
"infra:stub-lambdas": "bash scripts/upload-lambda-stubs.sh",
"infra:upload-lambdas": "bash scripts/upload-lambdas.sh",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
Expand All @@ -29,6 +31,8 @@
"@types/node": "^20.14.10",
"@typescript-eslint/eslint-plugin": "^8.4.0",
"@typescript-eslint/parser": "^8.4.0",
"adm-zip": "^0.6.0",
"esbuild": "^0.28.1",
"eslint": "^9.9.0",
"eslint-config-prettier": "^9.1.0",
"husky": "^9.1.7",
Expand Down
128 changes: 128 additions & 0 deletions packages/configuration/src/__tests__/secrets.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import type { GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";
import { describe, expect, it, vi } from "vitest";
import {
createSecretsProvider,
resolveBotToken,
resolveSigningSecret,
type SecretsClient,
} from "../secrets.js";

function stubClient(map: Record<string, string | undefined>): SecretsClient {
return {
send: vi.fn(async (cmd: GetSecretValueCommand) => {
const arn = cmd.input.SecretId ?? "";
const value = map[arn];
return value === undefined ? {} : { SecretString: value };
}),
};
}

describe("createSecretsProvider", () => {
it("fetches each secret once and caches the promise across getters", async () => {
const client = stubClient({
"arn:sig": "signing-value",
"arn:bot": "xoxb-value",
});
const provider = createSecretsProvider({
signingSecretArn: "arn:sig",
botTokenArn: "arn:bot",
region: "us-east-1",
client,
});

const [sig1, sig2, bot1, bot2] = await Promise.all([
provider.getSlackSigningSecret(),
provider.getSlackSigningSecret(),
provider.getSlackBotToken(),
provider.getSlackBotToken(),
]);

expect(sig1).toBe("signing-value");
expect(sig2).toBe("signing-value");
expect(bot1).toBe("xoxb-value");
expect(bot2).toBe("xoxb-value");
expect(client.send).toHaveBeenCalledTimes(2);
});

it("throws when Secrets Manager returns an empty SecretString", async () => {
const client = stubClient({ "arn:sig": undefined });
const provider = createSecretsProvider({
signingSecretArn: "arn:sig",
botTokenArn: "arn:bot",
region: "us-east-1",
client,
});

await expect(provider.getSlackSigningSecret()).rejects.toThrow(/signing secret/);
});
});

const baseEnv = {
NODE_ENV: "production" as const,
SERVICE_NAME: "slack-ingress",
AWS_REGION: "us-east-1",
SLACK_RECOGNITION_CHANNEL_ID: "C1",
SLACK_MAINTAINER_IDS: ["U1"] as const,
PROGRAM_TIMEZONE: "America/New_York",
PROGRAM_START_AT: "2026-07-31T00:00:00-04:00",
DYNAMODB_TABLE_NAME: "app",
NOMINATION_QUEUE_URL: "https://sqs/example",
};

describe("resolveSigningSecret / resolveBotToken", () => {
it("prefers inline env vars over Secrets Manager", async () => {
const client = stubClient({});
const sig = await resolveSigningSecret(
{ ...baseEnv, SLACK_SIGNING_SECRET: "inline-sig" },
client,
);
const bot = await resolveBotToken(
{ ...baseEnv, SLACK_BOT_TOKEN: "inline-bot" },
client,
);
expect(sig).toBe("inline-sig");
expect(bot).toBe("inline-bot");
expect(client.send).not.toHaveBeenCalled();
});

it("fetches from Secrets Manager when only ARN is set", async () => {
const client = stubClient({ "arn:sig": "sm-sig", "arn:bot": "sm-bot" });
const sig = await resolveSigningSecret(
{ ...baseEnv, SLACK_SIGNING_SECRET_ARN: "arn:sig" },
client,
);
const bot = await resolveBotToken(
{ ...baseEnv, SLACK_BOT_TOKEN_ARN: "arn:bot" },
client,
);
expect(sig).toBe("sm-sig");
expect(bot).toBe("sm-bot");
expect(client.send).toHaveBeenCalledTimes(2);
});

it("does not fetch the other secret when only one is requested", async () => {
const client = stubClient({ "arn:sig": "sm-sig", "arn:bot": "sm-bot" });
await resolveBotToken(
{
...baseEnv,
SLACK_SIGNING_SECRET_ARN: "arn:sig",
SLACK_BOT_TOKEN_ARN: "arn:bot",
},
client,
);
// The worker/reminder/report roles are only allowed to read the bot token
// per docs/07 §IAM boundaries. Fetching the signing secret would 403.
expect(client.send).toHaveBeenCalledTimes(1);
const sentCommand = (client.send as ReturnType<typeof vi.fn>).mock.calls[0]?.[0] as
| GetSecretValueCommand
| undefined;
expect(sentCommand?.input.SecretId).toBe("arn:bot");
});

it("throws when neither plaintext nor ARN is available", async () => {
const client = stubClient({});
await expect(resolveSigningSecret({ ...baseEnv }, client)).rejects.toThrow(
/neither plaintext env var nor ARN/,
);
});
});
Loading
Loading