diff --git a/.env.example b/.env.example index a8571c6..254620f 100644 --- a/.env.example +++ b/.env.example @@ -1,58 +1,30 @@ -# HTTP port the webhook server listens on +# ─────────────────────────────────────────────────────────────────────── +# AsyncUp bootstrap configuration. +# Everything else — Google Chat credentials, AI keys, integrations, +# access tokens, default timezone — is configured in the web dashboard +# and stored (encrypted) in the database. +# ─────────────────────────────────────────────────────────────────────── + +# HTTP port the webhook server + dashboard listen on PORT=8080 -# Path to the SQLite database file (the zero-config default) +# Storage: embedded SQLite file (default), or bring your own PostgreSQL +# by setting DATABASE_URL (managed Postgres, or the bundled compose +# service: docker compose --profile postgres up -d). DB_PATH=./data/standup.db - -# Bring your own database: set a PostgreSQL connection string and the -# embedded SQLite is skipped entirely. Works with managed Postgres -# (RDS, Cloud SQL, Neon, Supabase, …) or the bundled compose service -# (docker compose --profile postgres up -d). -# DATABASE_URL=postgres://asyncup:password@postgres:5432/asyncup DATABASE_URL= +# POSTGRES_PASSWORD= # only for the bundled compose Postgres -# Password for the optional bundled Postgres compose service -# POSTGRES_PASSWORD= - -# Chat adapter: "google" for production, "fake" for local demo (logs to console) -ADAPTER=google - -# Your GCP project NUMBER (not ID) — used to verify that incoming webhook -# requests really come from Google Chat. Leave empty to skip verification -# (local development only — never in production). -GOOGLE_CHAT_AUDIENCE= +# Secret for the web dashboard at /dashboard?token=. +# The dashboard is where all app configuration happens — set this! +DASHBOARD_TOKEN= -# Path to the service account key JSON (with Chat API access). -# Standard Google auth env var; not needed for ADAPTER=fake. -GOOGLE_APPLICATION_CREDENTIALS=./service-account.json +# Encrypts secrets stored in the database (AES-256-GCM). +# Generate once: openssl rand -hex 32 +SECRET_KEY= -# Default IANA timezone for new standups -DEFAULT_TIMEZONE=Asia/Kolkata +# "google" for production, "fake" for a local console demo +ADAPTER=google -# Tenant identifier (single-tenant self-hosted installs can leave this) +# Tenant identifier — single-tenant self-hosted installs leave this alone TENANT_ID=default - -# Optional shared secret for POST /tick — used by external cron (e.g. Cloud -# Scheduler) on scale-to-zero deployments. Callers must send -# "Authorization: Bearer ". Empty = endpoint is unauthenticated. -TICK_TOKEN= - -# Optional shared secret for GET /export (CSV download of standup answers). -# The endpoint stays disabled until this is set. -EXPORT_TOKEN= - -# Optional shared secret for the web dashboard (config + history) at -# /dashboard?token=. Disabled until set. -DASHBOARD_TOKEN= - -# Auto-mark participants as away when their Google Calendar has an -# "Out of office" event. Requires domain-wide delegation for the service -# account (scope: calendar.events.readonly) — see the docs. -GOOGLE_CALENDAR_OOO=false - -# Optional bring-your-own-key LLM for AI summaries (enable per standup with -# the "ai on" command). Provider: "anthropic" or "openai". -# LLM_MODEL defaults to claude-opus-4-7 for anthropic; required for openai. -LLM_PROVIDER= -LLM_API_KEY= -LLM_MODEL= diff --git a/.gitignore b/.gitignore index 4a8e9dd..e2654d6 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ service-account.json docs/.vitepress/dist/ docs/.vitepress/cache/ coverage/ +.claude/ diff --git a/README.md b/README.md index 876eed0..20508a1 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ Prereq: a one-time Google Chat app configuration (~15 min) — see **[docs/guide/google-chat-setup.md](docs/guide/google-chat-setup.md)**. ```bash -cp .env.example .env # fill in GOOGLE_CHAT_AUDIENCE, mount your service account key +cp .env.example .env # set DASHBOARD_TOKEN + SECRET_KEY (openssl rand -hex 32) docker compose up -d # pulls ghcr.io/asyncup-dev/asyncup (amd64 + arm64) ``` diff --git a/SECURITY.md b/SECURITY.md index de76f46..782e4c7 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -13,8 +13,9 @@ reproduction steps and the deployment mode (Docker, bare Node, proxy setup). ## Scope notes for self-hosters - `POST /chat/events` should only be reachable via HTTPS, and - `GOOGLE_CHAT_AUDIENCE` must be set in production — it cryptographically + the GCP project number must be set in dashboard settings — it cryptographically verifies that requests come from Google Chat. -- Set `TICK_TOKEN` if your `/tick` endpoint is internet-reachable. +- Generate a tick token (dashboard → Settings) if `/tick` is internet-reachable. +- Keep `SECRET_KEY` out of database backups — it decrypts stored credentials. - The SQLite database contains your team's standup answers — treat backups accordingly. diff --git a/docker-compose.yml b/docker-compose.yml index 1d5f362..ec35965 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,9 +16,6 @@ services: DATABASE_URL: ${DATABASE_URL:-} volumes: - standup-data:/data - # Mount your GCP service account key and set - # GOOGLE_APPLICATION_CREDENTIALS=/app/service-account.json in .env: - # - ./service-account.json:/app/service-account.json:ro # Optional same-machine PostgreSQL: # docker compose --profile postgres up -d diff --git a/docs/guide/ai.md b/docs/guide/ai.md index 7f8d2fa..0286101 100644 --- a/docs/guide/ai.md +++ b/docs/guide/ai.md @@ -3,18 +3,16 @@ AsyncUp can post an **AI TL;DR** under each day's thread and an **AI week in review** with the weekly digest. This is strictly opt-in, twice: -1. The self-hoster configures an LLM key on the server (below). Without it, +1. The self-hoster adds an LLM key in the dashboard (below). Without it, nothing ever leaves your infrastructure. 2. A standup admin enables it per standup with `ai on`. -## Server configuration +## Configuration -```bash -# .env -LLM_PROVIDER=anthropic # or "openai" -LLM_API_KEY=sk-ant-... -# LLM_MODEL=claude-opus-4-7 # default for anthropic; required for openai -``` +Dashboard → **Settings → AI summaries**: pick the provider (Anthropic or +OpenAI), paste your API key (stored encrypted), optionally set the model +(Anthropic defaults to `claude-opus-4-7`; OpenAI requires an explicit model). +Saving applies immediately. The integration uses plain HTTPS calls (no SDK dependency) and only ever sends the standup submissions of standups that have `ai on`. Failures are logged and @@ -31,8 +29,8 @@ never block the run from closing. ## Cost & model notes A daily summary for a 10-person team is roughly 1–2k input tokens — a few -cents per day even on the most capable models. Set `LLM_MODEL` to a smaller -model (e.g. `claude-haiku-4-5`) if you want it near-free. +cents per day even on the most capable models. Set a smaller model +(e.g. `claude-haiku-4-5`) in Settings if you want it near-free. ## Privacy considerations diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 6c4ec61..1bf37d4 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1,41 +1,58 @@ # Configuration -Everything is configured via environment variables (see `.env.example`). +AsyncUp is configured in two layers: + +1. **Bootstrap** — a handful of environment variables (where's the database, + what port, the dashboard token). Set once, rarely touched. +2. **Everything else** — managed in the **[web dashboard](./dashboard) + Settings page** and stored in your database, with secrets encrypted + (AES-256-GCM via `SECRET_KEY`). Changes apply immediately, no restart. + +## Bootstrap environment variables | Variable | Default | Purpose | | --- | --- | --- | -| `PORT` | `8080` | Webhook port | +| `PORT` | `8080` | Webhook + dashboard port | | `DB_PATH` | `./data/standup.db` | SQLite database file (default storage) | -| `DATABASE_URL` | *(empty)* | Bring-your-own PostgreSQL connection string — when set, SQLite is skipped (see [Deployment](./deployment#database-embedded-or-bring-your-own)) | +| `DATABASE_URL` | *(empty)* | Bring-your-own PostgreSQL — when set, SQLite is skipped (see [Deployment](./deployment#database-embedded-or-bring-your-own)) | +| `DASHBOARD_TOKEN` | *(empty)* | Secret for `/dashboard` — **required** to configure the app. Disabled while empty | +| `SECRET_KEY` | — | Encrypts stored secrets. Generate with `openssl rand -hex 32`. Required (except `ADAPTER=fake`) | | `ADAPTER` | `google` | `google` for production, `fake` for a console demo | -| `GOOGLE_CHAT_AUDIENCE` | *(empty)* | Your GCP project **number**. Verifies incoming requests are signed by Google Chat. Empty skips verification — local development only | -| `GOOGLE_APPLICATION_CREDENTIALS` | — | Path to the service account key JSON | -| `DEFAULT_TIMEZONE` | `UTC` | Timezone assigned to newly created standups | | `TENANT_ID` | `default` | Tenant identifier — leave as is for self-hosted installs | -| `TICK_TOKEN` | *(empty)* | Shared secret for `POST /tick` (see [Deployment](./deployment#scale-to-zero-cloud-run)) | -| `EXPORT_TOKEN` | *(empty)* | Shared secret for `GET /export`. **Endpoint disabled while empty** | -| `DASHBOARD_TOKEN` | *(empty)* | Shared secret for the [web dashboard](./dashboard). **Disabled while empty** | -| `GOOGLE_CALENDAR_OOO` | `false` | Auto-mark participants away when their Google Calendar has an *Out of office* event (needs [domain-wide delegation](./google-chat-setup#calendar-ooo)) | -| `LLM_PROVIDER` | *(empty)* | `anthropic` or `openai` — enables [AI summaries](./ai) | -| `LLM_API_KEY` | — | Your LLM provider API key | -| `LLM_MODEL` | `claude-opus-4-7` (anthropic) | Model override; required for openai | + +## Dashboard settings (stored in the database) + +Open `https:///dashboard?token=` → **Settings**: + +| Setting | What it does | +| --- | --- | +| GCP project number | Verifies incoming webhooks are signed by Google Chat | +| Service-account key (JSON) | Paste the downloaded key file — used for Chat API calls and Calendar OOO. Empty = [Application Default Credentials](https://cloud.google.com/docs/authentication/application-default-credentials) (e.g. Cloud Run service identity) | +| AI provider / API key / model | Bring-your-own-key [AI summaries](./ai) | +| Default timezone | Assigned to newly created standups | +| Calendar OOO sync | Auto-mark people away on out-of-office days | +| Scheduler tick token | Authorizes `POST /tick` for external cron | +| CSV export token | Enables `GET /export` (off until generated) | + +Secrets are write-only: the UI shows *that* they're configured (and e.g. the +service account's email), never the material itself. ## Endpoints | Endpoint | Purpose | | --- | --- | | `POST /chat/events` | Google Chat webhook — point the Chat app here | -| `POST /tick` | Manually advance the scheduler (for external cron). Requires `Authorization: Bearer $TICK_TOKEN` when set | -| `GET /export?standupId=N&days=30` | CSV download of submissions (long format: one row per answer). Requires `Authorization: Bearer $EXPORT_TOKEN`; disabled when unset | -| `GET /dashboard` | [Web dashboard](./dashboard) — config + history. Requires `DASHBOARD_TOKEN`; disabled when unset | -| `GET /healthz` | Liveness check | +| `POST /tick` | Manually advance the scheduler (for external cron). Requires `Authorization: Bearer ` when one is set | +| `GET /export?standupId=N&days=30` | CSV download (long format). Requires the export token; disabled until one is generated | +| `GET /dashboard` | [Web dashboard](./dashboard) — settings, config, history | +| `GET /healthz` | Liveness check (pings the database) | ## Data -All state — standups, participants, admins, runs, submissions, blockers, and -the DM-space cache — lives either in a single SQLite file (`DB_PATH`, the -default) or in your own PostgreSQL (`DATABASE_URL`). Back up the file or use -your database's backup story; the process can restart at any time without -losing or double-sending prompts (graceful shutdown on SIGTERM included). -Schema migrations run automatically on startup in both modes, so upgrading -AsyncUp is just deploying the new image. +All state — standups, participants, admins, runs, submissions, blockers, app +settings — lives either in a single SQLite file (`DB_PATH`, the default) or in +your own PostgreSQL (`DATABASE_URL`). Back up the file or use your database's +backup story; stored secrets are encrypted, so backups are safe to ship +off-box as long as `SECRET_KEY` stays out of them. Schema migrations run +automatically on startup in both modes, so upgrading AsyncUp is just +deploying the new image. Graceful shutdown on SIGTERM included. diff --git a/docs/guide/dashboard.md b/docs/guide/dashboard.md index da3c31e..89fb22e 100644 --- a/docs/guide/dashboard.md +++ b/docs/guide/dashboard.md @@ -21,6 +21,13 @@ doesn't need the query parameter. ## What's there +- **First-run checklist** — a setup meter (connect Google Chat, create a + standup, add your team, optional AI) that disappears once you're rolling. +- **Settings** — *all app configuration lives here*: Google Chat connection + (project number + paste-in service-account key), AI provider and key, + default timezone, Calendar OOO sync, and the machine tokens for `/tick` + and `/export` (generate/clear; shown exactly once). Secrets are stored + encrypted and never echoed back. - **Standup list** — every standup with schedule and today's progress. - **Standup detail** — edit name, times, timezone, days, reminder, questions, and toggles (mood / anonymous mood / digest / AI / escalation threshold); diff --git a/docs/guide/deployment.md b/docs/guide/deployment.md index 4fe1d0b..0335194 100644 --- a/docs/guide/deployment.md +++ b/docs/guide/deployment.md @@ -48,9 +48,9 @@ one-liner (`docker build -t asyncup .`) if you prefer auditing what you run. ## Docker Compose (simplest) ```bash -cp .env.example .env # set GOOGLE_CHAT_AUDIENCE, credentials path -# uncomment the service-account.json mount in docker-compose.yml +cp .env.example .env # set DASHBOARD_TOKEN + SECRET_KEY docker compose up -d # pulls the GHCR image by default +# then finish setup in https:///dashboard?token= ``` Put it behind any HTTPS reverse proxy (Caddy, nginx, Traefik) and point the @@ -71,8 +71,8 @@ scale on the free tier: 2. The in-process scheduler only runs while an instance is alive, so drive it externally: create a **Cloud Scheduler** job (free tier: 3 jobs) that hits `POST /tick` every minute with header - `Authorization: Bearer `. -3. Set `TICK_TOKEN` in the service env. + `Authorization: Bearer `. +3. Generate the tick token in dashboard → Settings → Access tokens. Webhook events (dialog opens, submissions, commands) spin the instance up on demand; `/tick` wakes it for prompts, reminders, and deadlines. Ticks are @@ -90,7 +90,7 @@ Run it under systemd or any process manager. ## Production checklist -- [ ] `GOOGLE_CHAT_AUDIENCE` set (request verification on) +- [ ] GCP project number set in dashboard settings (request verification on) - [ ] HTTPS in front of `/chat/events` -- [ ] `TICK_TOKEN` set if `/tick` is internet-reachable +- [ ] Tick token generated if `/tick` is internet-reachable; `SECRET_KEY` backed up - [ ] `DB_PATH` on persistent storage, backed up diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index b00563f..1a920ab 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -23,7 +23,7 @@ the names of mandatory participants who didn't fill it in. ```bash git clone https://github.com/asyncup-dev/asyncup cd asyncup -cp .env.example .env # set GOOGLE_CHAT_AUDIENCE + service account key +cp .env.example .env # set DASHBOARD_TOKEN + SECRET_KEY docker compose up -d ``` diff --git a/docs/guide/google-chat-setup.md b/docs/guide/google-chat-setup.md index 88b8607..898f555 100644 --- a/docs/guide/google-chat-setup.md +++ b/docs/guide/google-chat-setup.md @@ -6,32 +6,39 @@ Workspace admin (for domain-wide install) and have a Google Cloud project. ## 1. Create a GCP project and enable the Chat API 1. Go to [console.cloud.google.com](https://console.cloud.google.com) and create a project (e.g. `asyncup`). -2. Note the **project number** (Dashboard → Project info) — this is your `GOOGLE_CHAT_AUDIENCE`. +2. Note the **project number** (Dashboard → Project info) — you'll paste it into AsyncUp's settings. 3. Enable the API: **APIs & Services → Library → Google Chat API → Enable**. ## 2. Create a service account 1. **IAM & Admin → Service Accounts → Create service account** (e.g. `asyncup`). No project roles are needed — Chat API access comes from the app configuration. -2. Open the account → **Keys → Add key → JSON**. Download the file as - `service-account.json` next to `docker-compose.yml` (it is gitignored). +2. Open the account → **Keys → Add key → JSON** and download the key file. + You'll paste its contents into the dashboard in step 3 — no file mounting. -## 3. Deploy the bot and get an HTTPS URL - -Google Chat must reach your webhook over **public HTTPS**: +## 3. Deploy the bot and connect it ```bash cp .env.example .env -# set GOOGLE_CHAT_AUDIENCE= -# set GOOGLE_APPLICATION_CREDENTIALS=/app/service-account.json -# uncomment the service-account.json volume mount in docker-compose.yml +# set DASHBOARD_TOKEN (any long random string) +# set SECRET_KEY (openssl rand -hex 32) docker compose up -d ``` -Put it behind your reverse proxy (Caddy/nginx/Traefik) or, for a quick test, -a tunnel like `cloudflared tunnel --url http://localhost:8080`. +Expose it over **public HTTPS** behind your reverse proxy +(Caddy/nginx/Traefik) or, for a quick test, a tunnel like +`cloudflared tunnel --url http://localhost:8080`. Your event URL is `https:///chat/events`. +Then open `https:///dashboard?token=` → +**Settings → Google Chat** and paste: + +- the **project number** from step 1 +- the **service-account key JSON** from step 2 (stored encrypted) + +Changes apply immediately — no restart. (On Cloud Run you can skip the key +and use the service's own identity via Application Default Credentials.) + ## 4. Configure the Chat app **APIs & Services → Google Chat API → Configuration** tab: @@ -81,7 +88,7 @@ event, the service account needs **domain-wide delegation**: - Client ID: the number from step 1 - Scope: `https://www.googleapis.com/auth/calendar.events.readonly` 3. Enable the **Google Calendar API** in your GCP project. -4. Set `GOOGLE_CALENDAR_OOO=true` in your `.env` and restart. +4. Dashboard → **Settings → Workspace** → tick *Google Calendar OOO sync*. AsyncUp learns each person's email the first time they interact with the bot, then checks their primary calendar for OOO events when a run opens. People who @@ -90,6 +97,6 @@ are OOO are listed as 🏖️ away — never as missing. ## Troubleshooting - **"No DM space with users/…"** in logs → that user doesn't have the app installed; see step 5. -- **401 on events** → `GOOGLE_CHAT_AUDIENCE` must be the project *number*, not the project ID. +- **401 on events** → the value in Settings → Google Chat must be the project *number*, not the project ID. - **No prompts arriving** → check `docker compose logs`; the scheduler logs every run open/close. Verify the standup `status`, days, and timezone. - **Replies not threading** → the bot posts with `threadKey`, which threads correctly even if the parent message failed; check the space's history settings. diff --git a/src/adapters/gchat/adapter.ts b/src/adapters/gchat/adapter.ts index 5e17b9a..3fc5cfd 100644 --- a/src/adapters/gchat/adapter.ts +++ b/src/adapters/gchat/adapter.ts @@ -1,5 +1,6 @@ import { auth as chatAuth, chat, type chat_v1 } from '@googleapis/chat'; import type { ChatAdapter } from '../../core/adapter.js'; +import type { SettingsService } from '../../core/settings.js'; import type { Repo } from '../../db/repo.js'; import type { Blocker, Run, RunSummary, Standup, Submission } from '../../core/types.js'; import { @@ -12,21 +13,37 @@ import { } from './cards.js'; export class GoogleChatAdapter implements ChatAdapter { - private client: chat_v1.Chat; + private client: chat_v1.Chat | null = null; - constructor(private repo: Repo) { - const auth = new chatAuth.GoogleAuth({ scopes: ['https://www.googleapis.com/auth/chat.bot'] }); + constructor( + private repo: Repo, + private settings: SettingsService, + ) { + settings.onChange(() => { + this.client = null; + }); + } + + /** Auth comes from the pasted service-account JSON, falling back to ADC. */ + private async getClient(): Promise { + if (this.client) return this.client; + const { serviceAccountJson } = await this.settings.get(); + const scopes = ['https://www.googleapis.com/auth/chat.bot']; + const auth = serviceAccountJson + ? new chatAuth.GoogleAuth({ credentials: JSON.parse(serviceAccountJson), scopes }) + : new chatAuth.GoogleAuth({ scopes }); this.client = chat({ version: 'v1', auth }); + return this.client; } async sendStandupPrompt(userName: string, standup: Standup, run: Run): Promise { const dm = await this.ensureDmSpace(userName); - await this.client.spaces.messages.create({ parent: dm, requestBody: promptMessage(standup, run) }); + await (await this.getClient()).spaces.messages.create({ parent: dm, requestBody: promptMessage(standup, run) }); } async sendReminder(userName: string, standup: Standup, run: Run): Promise { const dm = await this.ensureDmSpace(userName); - await this.client.spaces.messages.create({ parent: dm, requestBody: reminderMessage(standup, run) }); + await (await this.getClient()).spaces.messages.create({ parent: dm, requestBody: reminderMessage(standup, run) }); } async postThreadParent(standup: Standup, run: Run): Promise { @@ -42,7 +59,7 @@ export class GoogleChatAdapter implements ChatAdapter { } async updateSubmission(standup: Standup, submission: Submission): Promise { - await this.client.spaces.messages.update({ + await (await this.getClient()).spaces.messages.update({ name: submission.messageName!, updateMask: 'cardsV2', requestBody: submissionMessage(submission, standup.moodAnonymous), @@ -58,17 +75,17 @@ export class GoogleChatAdapter implements ChatAdapter { await this.postInThread(spaceName, threadKey, { text }); return; } - await this.client.spaces.messages.create({ parent: spaceName, requestBody: { text } }); + await (await this.getClient()).spaces.messages.create({ parent: spaceName, requestBody: { text } }); } async sendDm(userName: string, text: string): Promise { const dm = await this.ensureDmSpace(userName); - await this.client.spaces.messages.create({ parent: dm, requestBody: { text } }); + await (await this.getClient()).spaces.messages.create({ parent: dm, requestBody: { text } }); } async sendBlockerCard(userName: string, standup: Standup, blocker: Blocker, note: string): Promise { const dm = await this.ensureDmSpace(userName); - await this.client.spaces.messages.create({ parent: dm, requestBody: blockerCard(standup, blocker, note) }); + await (await this.getClient()).spaces.messages.create({ parent: dm, requestBody: blockerCard(standup, blocker, note) }); } private async postInThread( @@ -76,7 +93,7 @@ export class GoogleChatAdapter implements ChatAdapter { threadKey: string, body: chat_v1.Schema$Message, ): Promise { - const res = await this.client.spaces.messages.create({ + const res = await (await this.getClient()).spaces.messages.create({ parent: spaceName, messageReplyOption: 'REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD', requestBody: { ...body, thread: { threadKey } }, @@ -93,7 +110,7 @@ export class GoogleChatAdapter implements ChatAdapter { const cached = await this.repo.getDmSpace(userName); if (cached) return cached; try { - const res = await this.client.spaces.findDirectMessage({ name: userName }); + const res = await (await this.getClient()).spaces.findDirectMessage({ name: userName }); const spaceName = res.data.name!; await this.repo.setDmSpace(userName, spaceName); return spaceName; diff --git a/src/config.ts b/src/config.ts index 78d84ba..b402214 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,25 +1,19 @@ -import type { LlmConfig } from './ai/llm.js'; - +/** + * Bootstrap-only configuration. Everything else (Google Chat credentials, + * AI keys, integrations, access tokens, default timezone) lives in the + * database and is edited from the dashboard — see src/core/settings.ts. + */ export interface Config { port: number; dbPath: string; /** PostgreSQL connection string; empty = embedded SQLite at DB_PATH. */ databaseUrl: string; adapter: 'google' | 'fake'; - /** GCP project number used to verify incoming Chat requests. Empty = skip (dev only). */ - chatAudience: string; - defaultTimezone: string; tenantId: string; - /** Shared secret for POST /tick (external cron on scale-to-zero deploys). Empty = no auth required. */ - tickToken: string; - /** Shared secret for GET /export. Empty = export endpoint disabled. */ - exportToken: string; /** Shared secret for the web dashboard. Empty = dashboard disabled. */ dashboardToken: string; - /** Check participants' Google Calendar for OOO events (needs domain-wide delegation). */ - calendarOoo: boolean; - /** Bring-your-own-key LLM for AI summaries. Null = AI features off. */ - llm: LlmConfig | null; + /** Encrypts secrets at rest (AES-256-GCM). Required unless ADAPTER=fake. */ + secretKey: string; } export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { @@ -27,30 +21,17 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { if (adapter !== 'google' && adapter !== 'fake') { throw new Error(`ADAPTER must be "google" or "fake", got "${adapter}"`); } + const secretKey = env.SECRET_KEY ?? ''; + if (!secretKey && adapter === 'google') { + throw new Error('SECRET_KEY is required — generate one with `openssl rand -hex 32`'); + } return { port: Number(env.PORT ?? 8080), dbPath: env.DB_PATH ?? './data/standup.db', databaseUrl: env.DATABASE_URL ?? '', adapter, - chatAudience: env.GOOGLE_CHAT_AUDIENCE ?? '', - defaultTimezone: env.DEFAULT_TIMEZONE ?? 'UTC', tenantId: env.TENANT_ID ?? 'default', - tickToken: env.TICK_TOKEN ?? '', - exportToken: env.EXPORT_TOKEN ?? '', dashboardToken: env.DASHBOARD_TOKEN ?? '', - calendarOoo: env.GOOGLE_CALENDAR_OOO === 'true', - llm: loadLlmConfig(env), + secretKey: secretKey || 'dev-only-ephemeral-secret', }; } - -function loadLlmConfig(env: NodeJS.ProcessEnv): LlmConfig | null { - const provider = env.LLM_PROVIDER; - if (!provider) return null; - if (provider !== 'anthropic' && provider !== 'openai') { - throw new Error(`LLM_PROVIDER must be "anthropic" or "openai", got "${provider}"`); - } - if (!env.LLM_API_KEY) throw new Error('LLM_PROVIDER is set but LLM_API_KEY is missing'); - const model = env.LLM_MODEL ?? (provider === 'anthropic' ? 'claude-opus-4-7' : ''); - if (!model) throw new Error('LLM_MODEL is required when LLM_PROVIDER=openai'); - return { provider, apiKey: env.LLM_API_KEY, model }; -} diff --git a/src/core/commands.ts b/src/core/commands.ts index 3ebca79..37b25f2 100644 --- a/src/core/commands.ts +++ b/src/core/commands.ts @@ -1,5 +1,6 @@ import { DateTime, IANAZone } from 'luxon'; import type { BlockerService } from './blocker-service.js'; +import type { SettingsService } from './settings.js'; import type { Repo } from '../db/repo.js'; import { trendsText } from './insights.js'; import { @@ -51,7 +52,7 @@ const OPEN_COMMANDS = new Set(['help', 'status', 'trends', 'blockers', 'blocker' export class CommandHandler { constructor( private repo: Repo, - private defaultTimezone: string, + private settings: SettingsService, private now: () => DateTime = () => DateTime.utc(), private blockerService: BlockerService | null = null, ) {} @@ -163,7 +164,7 @@ export class CommandHandler { tenantId: ctx.tenantId, spaceName: ctx.spaceName, name: name || 'Daily Standup', - timezone: this.defaultTimezone, + timezone: (await this.settings.get()).defaultTimezone, }); if (ctx.sender.userName) { await this.repo.addAdmin(standup.id, ctx.sender.userName, ctx.sender.displayName); diff --git a/src/core/crypto.ts b/src/core/crypto.ts new file mode 100644 index 0000000..3bf2d80 --- /dev/null +++ b/src/core/crypto.ts @@ -0,0 +1,34 @@ +import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'; + +/** + * AES-256-GCM for settings marked secret. The key is derived from the + * SECRET_KEY env var, so database dumps never contain usable credentials. + * Wire format: base64(iv).base64(tag).base64(ciphertext) + */ +export class SecretBox { + private key: Buffer; + + constructor(secretKey: string) { + if (!secretKey) throw new Error('SECRET_KEY is required (e.g. `openssl rand -hex 32`)'); + this.key = createHash('sha256').update(secretKey).digest(); + } + + encrypt(plaintext: string): string { + const iv = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', this.key, iv); + const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + return `${iv.toString('base64')}.${cipher.getAuthTag().toString('base64')}.${ciphertext.toString('base64')}`; + } + + decrypt(payload: string): string { + const [iv, tag, ciphertext] = payload.split('.'); + if (!iv || !tag || !ciphertext) throw new Error('malformed secret payload'); + const decipher = createDecipheriv('aes-256-gcm', this.key, Buffer.from(iv, 'base64')); + decipher.setAuthTag(Buffer.from(tag, 'base64')); + return Buffer.concat([decipher.update(Buffer.from(ciphertext, 'base64')), decipher.final()]).toString('utf8'); + } +} + +export function generateToken(): string { + return randomBytes(24).toString('base64url'); +} diff --git a/src/core/scheduler.ts b/src/core/scheduler.ts index a2c5558..53b14e5 100644 --- a/src/core/scheduler.ts +++ b/src/core/scheduler.ts @@ -21,6 +21,16 @@ function timeOn(date: string, time: string, zone: string): DateTime { return DateTime.fromISO(`${date}T${time}`, { zone }); } +/** + * Lazily resolved integrations — settings can change at runtime via the + * dashboard, so the scheduler asks for fresh instances instead of holding + * boot-time ones. + */ +export interface SchedulerProviders { + summarizer?: () => Promise; + ooo?: () => Promise; +} + /** * Drives the standup lifecycle off a periodic tick (call every ~minute). * All state lives in the DB (prompted_at / reminded_at / run status), so @@ -33,8 +43,7 @@ export class Scheduler { private service: StandupService, private now: () => DateTime = () => DateTime.utc(), private log: (msg: string) => void = (msg) => console.log(`[scheduler] ${msg}`), - private ai: AiSummarizer | null = null, - private ooo: OooChecker | null = null, + private providers: SchedulerProviders = {}, ) {} start(intervalMs = 60_000): NodeJS.Timeout { @@ -124,13 +133,14 @@ export class Scheduler { /** Marks participants with a calendar OOO event today as away for this run only. */ private async applyCalendarOoo(standup: Standup, run: Run): Promise { - if (!this.ooo) return; + const ooo = await this.providers.ooo?.(); + if (!ooo) return; for (const rp of await this.repo.listRunParticipants(run.id)) { if (rp.onVacation) continue; const email = await this.repo.getUserEmail(rp.userName); if (!email) continue; try { - if (await this.ooo.isOoo(email, run.date, standup.timezone)) { + if (await ooo.isOoo(email, run.date, standup.timezone)) { await this.repo.markRunVacation(run.id, rp.userName); this.log(`calendar OOO: ${rp.displayName} is away for run ${run.id}`); } @@ -163,11 +173,12 @@ export class Scheduler { this.log(`blocker nudges failed for run ${run.id}: ${err}`); } - if (standup.aiEnabled && this.ai) { + const ai = standup.aiEnabled ? ((await this.providers.summarizer?.()) ?? null) : null; + if (ai) { try { const submissions = await this.repo.listSubmissions(run.id); if (submissions.length > 0) { - const text = await this.ai.dailySummary(standup, run, submissions); + const text = await ai.dailySummary(standup, run, submissions); await this.adapter.postText(standup.spaceName, `🤖 *AI summary*\n${text}`, run.threadKey); } } catch (err) { @@ -181,10 +192,10 @@ export class Scheduler { try { const digest = await buildWeeklyDigest(this.repo, standup, run.date); let text = digestText(digest); - if (standup.aiEnabled && this.ai) { + if (ai) { const submissions = await this.repo.listSubmissionsBetween(standup.id, digest.weekStart, digest.weekEnd); if (submissions.length > 0) { - text += `\n\n🤖 *AI week in review*\n${await this.ai.weeklySummary(standup, digest, submissions)}`; + text += `\n\n🤖 *AI week in review*\n${await ai.weeklySummary(standup, digest, submissions)}`; } } await this.adapter.postText(standup.spaceName, text, `digest-${standup.id}-${digest.weekStart}`); diff --git a/src/core/settings.ts b/src/core/settings.ts new file mode 100644 index 0000000..c515a2a --- /dev/null +++ b/src/core/settings.ts @@ -0,0 +1,94 @@ +import { DateTime } from 'luxon'; +import { SecretBox } from './crypto.js'; +import type { Repo } from '../db/repo.js'; + +/** + * Runtime app configuration, stored in the database and edited from the + * dashboard. Secrets are AES-256-GCM encrypted at rest via SECRET_KEY. + * Only bootstrap values (port, database location, dashboard token, secret + * key) remain environment variables. + */ +export interface AppSettings { + /** GCP project number used to verify incoming Chat webhooks. */ + chatAudience: string; + /** Service-account key JSON (pasted in the UI). Empty = use ADC. */ + serviceAccountJson: string; + defaultTimezone: string; + calendarOoo: boolean; + llmProvider: '' | 'anthropic' | 'openai'; + llmApiKey: string; + llmModel: string; + tickToken: string; + exportToken: string; +} + +export const SETTING_DEFAULTS: AppSettings = { + chatAudience: '', + serviceAccountJson: '', + defaultTimezone: 'UTC', + calendarOoo: false, + llmProvider: '', + llmApiKey: '', + llmModel: '', + tickToken: '', + exportToken: '', +}; + +const SECRET_KEYS: (keyof AppSettings)[] = ['serviceAccountJson', 'llmApiKey', 'tickToken', 'exportToken']; + +export class SettingsService { + private box: SecretBox; + private cache: AppSettings | null = null; + private listeners: (() => void)[] = []; + + constructor( + private repo: Repo, + secretKey: string, + private now: () => DateTime = () => DateTime.utc(), + ) { + this.box = new SecretBox(secretKey); + } + + /** Re-create provider clients etc. when settings change. */ + onChange(listener: () => void): void { + this.listeners.push(listener); + } + + async get(): Promise { + if (this.cache) return this.cache; + const settings: AppSettings = { ...SETTING_DEFAULTS }; + for (const row of await this.repo.getSettingRows()) { + if (!(row.key in settings)) continue; + let value = row.value; + if (row.encrypted) { + try { + value = this.box.decrypt(row.value); + } catch { + // SECRET_KEY changed — treat the secret as unset rather than crash. + console.error(`[settings] cannot decrypt "${row.key}" — was SECRET_KEY rotated? Re-enter it in the dashboard.`); + continue; + } + } + const key = row.key as keyof AppSettings; + (settings as any)[key] = typeof SETTING_DEFAULTS[key] === 'boolean' ? value === 'true' : value; + } + this.cache = settings; + return settings; + } + + async update(partial: Partial): Promise { + const at = this.now().toISO()!; + for (const [key, raw] of Object.entries(partial)) { + if (raw === undefined || !(key in SETTING_DEFAULTS)) continue; + const value = typeof raw === 'boolean' ? String(raw) : raw; + const secret = SECRET_KEYS.includes(key as keyof AppSettings); + if (value === '') { + await this.repo.deleteSetting(key); + } else { + await this.repo.setSetting(key, secret ? this.box.encrypt(value) : value, secret, at); + } + } + this.cache = null; + for (const listener of this.listeners) listener(); + } +} diff --git a/src/dashboard/dashboard.ts b/src/dashboard/dashboard.ts index 24ec5b1..b1d05fd 100644 --- a/src/dashboard/dashboard.ts +++ b/src/dashboard/dashboard.ts @@ -1,6 +1,8 @@ import express, { type Express, type Request, type Response } from 'express'; import { DateTime, IANAZone } from 'luxon'; +import { generateToken } from '../core/crypto.js'; import { moodEmoji, rangeStats } from '../core/insights.js'; +import type { AppSettings, SettingsService } from '../core/settings.js'; import { MOOD_EMOJI, standupQuestions, @@ -12,6 +14,7 @@ import type { Repo } from '../db/repo.js'; export interface DashboardDeps { repo: Repo; + settings: SettingsService; /** Empty string disables the dashboard entirely. */ token: string; now?: () => DateTime; @@ -22,7 +25,7 @@ const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/; export function registerDashboard(app: Express, deps: DashboardDeps): void { if (!deps.token) return; - const { repo, token } = deps; + const { repo, settings, token } = deps; const now = deps.now ?? (() => DateTime.utc()); app.use('/dashboard', express.urlencoded({ extended: false })); @@ -40,59 +43,149 @@ export function registerDashboard(app: Express, deps: DashboardDeps): void { .map((c) => c.trim()) .find((c) => c.startsWith(`${COOKIE}=`)); if (cookie && decodeURIComponent(cookie.slice(COOKIE.length + 1)) === token) return true; - res.status(401).send(layout('Unauthorized', `

Open /dashboard?token=… with your DASHBOARD_TOKEN.

`)); + res.status(401).send(layout('Unauthorized', 'home', `

Open /dashboard?token=… with your DASHBOARD_TOKEN.

`)); return false; }; + // ---------- home: checklist + standups ---------- + app.get('/dashboard', async (req, res) => { if (!authed(req, res)) return; const standups = await repo.listActiveStandups(); - const rowParts: string[] = []; - for (const s of standups) { - const today = now().setZone(s.timezone).toISODate()!; - const run = await repo.getRun(s.id, today); - const todayCell = run - ? `${(await repo.listSubmissions(run.id)).length} submitted (${run.status})` - : '—'; - rowParts.push(` - #${s.id} ${esc(s.name)} - ${esc(s.spaceName)} - ${esc(s.promptTime)} → ${esc(s.deadlineTime)} ${esc(s.timezone)} - ${(await repo.listParticipants(s.id)).length} + const s = await settings.get(); + + const steps = [ + { done: !!(s.chatAudience && s.serviceAccountJson), label: 'Connect Google Chat', hint: 'Project number + service-account key in Settings', href: '/dashboard/settings' }, + { done: standups.length > 0, label: 'Create a standup', hint: 'Mention the bot in a space: @AsyncUp setup', href: null }, + { done: false, label: 'Add your team', hint: '@AsyncUp add @Alice @Bob in the space', href: null }, + { done: !!s.llmProvider, label: 'Optional: AI summaries', hint: 'Bring your own key in Settings', href: '/dashboard/settings' }, + ]; + // "Add your team" is done when any standup has participants. + for (const st of standups) { + if ((await repo.listParticipants(st.id)).length > 0) { + steps[2]!.done = true; + break; + } + } + const doneCount = steps.filter((x) => x.done).length; + const checklist = + doneCount === steps.length + ? '' + : `
+
First-run setup
+

Good morning. Let's get the standups flowing.

+ +
    + ${steps + .map( + (x) => `
  1. + ${x.done ? '✓' : ''} +
    ${x.label}${x.hint}
    + ${x.href && !x.done ? `Open` : ''} +
  2. `, + ) + .join('')} +
+
`; + + const rows: string[] = []; + for (const st of standups) { + const today = now().setZone(st.timezone).toISODate()!; + const run = await repo.getRun(st.id, today); + const todayCell = run ? `${(await repo.listSubmissions(run.id)).length} submitted (${run.status})` : '—'; + rows.push(` + #${st.id} ${esc(st.name)} + ${esc(st.spaceName)} + ${esc(st.promptTime)} → ${esc(st.deadlineTime)} ${esc(st.timezone)} + ${(await repo.listParticipants(st.id)).length} ${todayCell} `); } - const rows = rowParts.join(''); res.send( layout( 'AsyncUp dashboard', - `

Standups

- ${standups.length === 0 ? '

No standups yet — create one from Google Chat with setup.

' : ''} - ${rows}
StandupSpaceSchedulePeopleToday
`, + 'home', + `${checklist} +
+
Teams
+

Standups

+ ${standups.length === 0 ? '

None yet — create one from Google Chat with @AsyncUp setup.

' : ''} + ${standups.length ? `${rows.join('')}
StandupSpaceSchedulePeopleToday
` : ''} +
`, + ), + ); + }); + + // ---------- settings ---------- + + app.get('/dashboard/settings', async (req, res) => { + if (!authed(req, res)) return; + res.send( + layout( + 'Settings — AsyncUp', + 'settings', + await settingsPage(await settings.get(), req.query.saved === '1', null, null), ), ); }); + app.post('/dashboard/settings', async (req, res) => { + if (!authed(req, res)) return; + const body = req.body ?? {}; + + if (typeof body.action === 'string') { + const [verb, which] = body.action.split('-'); + const field = which === 'tick' ? 'tickToken' : which === 'export' ? 'exportToken' : null; + if (field && verb === 'generate') { + const fresh = generateToken(); + await settings.update({ [field]: fresh }); + res.send( + layout('Settings — AsyncUp', 'settings', await settingsPage(await settings.get(), false, null, { field, value: fresh })), + ); + return; + } + if (field && verb === 'clear') { + await settings.update({ [field]: '' }); + res.redirect('/dashboard/settings?saved=1'); + return; + } + res.status(400).send(layout('Settings — AsyncUp', 'settings', await settingsPage(await settings.get(), false, 'Unknown action.', null))); + return; + } + + const error = await applySettings(settings, body); + if (error) { + res.status(400).send(layout('Settings — AsyncUp', 'settings', await settingsPage(await settings.get(), false, error, null))); + return; + } + res.redirect('/dashboard/settings?saved=1'); + }); + + // ---------- standup detail + config ---------- + app.get('/dashboard/standup/:id', async (req, res) => { if (!authed(req, res)) return; const standup = await repo.getStandupById(Number(req.params.id)); if (!standup) { - res.status(404).send(layout('Not found', '

Unknown standup.

')); + res.status(404).send(layout('Not found', 'home', '

Unknown standup.

')); return; } - res.send(layout(`${standup.name} — AsyncUp`, await standupPage(repo, standup, now(), req.query.saved === '1', null))); + res.send(layout(`${standup.name} — AsyncUp`, 'home', await standupPage(repo, standup, now(), req.query.saved === '1', null))); }); app.post('/dashboard/standup/:id', async (req, res) => { if (!authed(req, res)) return; const standup = await repo.getStandupById(Number(req.params.id)); if (!standup) { - res.status(404).send(layout('Not found', '

Unknown standup.

')); + res.status(404).send(layout('Not found', 'home', '

Unknown standup.

')); return; } const error = await applyConfig(repo, standup, req.body); if (error) { - res.status(400).send(layout(`${standup.name} — AsyncUp`, await standupPage(repo, (await repo.getStandupById(standup.id))!, now(), false, error))); + res.status(400).send(layout(`${standup.name} — AsyncUp`, 'home', await standupPage(repo, (await repo.getStandupById(standup.id))!, now(), false, error))); return; } res.redirect(`/dashboard/standup/${standup.id}?saved=1`); @@ -103,12 +196,12 @@ export function registerDashboard(app: Express, deps: DashboardDeps): void { const standup = await repo.getStandupById(Number(req.params.id)); const run = standup ? await repo.getRun(standup.id, String(req.params.date)) : null; if (!standup || !run) { - res.status(404).send(layout('Not found', '

Unknown run.

')); + res.status(404).send(layout('Not found', 'home', '

Unknown run.

')); return; } const submissions = (await repo.listSubmissions(run.id)) .map( - (s) => `
+ (s) => `

${s.mood && !standup.moodAnonymous ? MOOD_EMOJI[s.mood] : '📝'} ${esc(s.displayName)} ${s.late ? 'late' : ''}${s.editedAt ? 'edited' : ''}

${s.answers.map((a) => `

${esc(a.question)}
${esc(a.answer)}

`).join('')} @@ -123,15 +216,173 @@ export function registerDashboard(app: Express, deps: DashboardDeps): void { res.send( layout( `${run.date} — ${standup.name}`, - `

← ${esc(standup.name)}

+ 'home', + `

← ${esc(standup.name)}

${run.date} (${run.status})

${missing.length ? `

❌ Missing: ${missing.join(', ')}

` : ''} - ${submissions || '

No submissions.

'}`, + ${submissions || '

No submissions.

'}`, ), ); }); } +// ---------- settings rendering ---------- + +async function applySettings(settings: SettingsService, body: any): Promise { + const section = String(body.section ?? ''); + + if (section === 'chat') { + const chatAudience = String(body.chatAudience ?? '').trim(); + if (chatAudience && !/^\d+$/.test(chatAudience)) { + return 'The project number is numeric — find it on the GCP dashboard (not the project ID).'; + } + const json = String(body.serviceAccountJson ?? '').trim(); + if (json) { + try { + const parsed = JSON.parse(json); + if (!parsed.client_email || !parsed.private_key) { + return 'That JSON is missing client_email / private_key — paste the full service-account key file.'; + } + } catch { + return 'The service-account key must be valid JSON — paste the whole downloaded file.'; + } + } + await settings.update({ chatAudience, ...(json ? { serviceAccountJson: json } : {}) }); + if (body.clear_serviceAccountJson === 'on') await settings.update({ serviceAccountJson: '' }); + return null; + } + + if (section === 'ai') { + const llmProvider = String(body.llmProvider ?? ''); + if (!['', 'anthropic', 'openai'].includes(llmProvider)) return 'Unknown AI provider.'; + const llmModel = String(body.llmModel ?? '').trim(); + if (llmProvider === 'openai' && !llmModel && !body.llmApiKey) { + // model checked properly below once we know a key exists + } + const key = String(body.llmApiKey ?? '').trim(); + if (llmProvider === 'openai' && !llmModel) return 'OpenAI needs an explicit model name.'; + await settings.update({ + llmProvider: llmProvider as AppSettings['llmProvider'], + llmModel, + ...(key ? { llmApiKey: key } : {}), + }); + if (body.clear_llmApiKey === 'on') await settings.update({ llmApiKey: '' }); + return null; + } + + if (section === 'workspace') { + const tz = String(body.defaultTimezone ?? '').trim(); + if (!IANAZone.isValidZone(tz)) return `Invalid IANA timezone: ${tz || '(empty)'} — e.g. Asia/Kolkata.`; + await settings.update({ defaultTimezone: tz, calendarOoo: body.calendarOoo === 'on' }); + return null; + } + + return 'Unknown settings section.'; +} + +function secretStatus(value: string, describe?: (v: string) => string): string { + if (!value) return 'Not set'; + const detail = describe ? describe(value) : `ends in ${esc(value.slice(-4))}`; + return `Configured ${detail}`; +} + +async function settingsPage( + s: AppSettings, + saved: boolean, + error: string | null, + revealed: { field: string; value: string } | null, +): Promise { + const saJsonStatus = secretStatus(s.serviceAccountJson, (v) => { + try { + return `key for ${esc(JSON.parse(v).client_email ?? 'unknown')}`; + } catch { + return 'stored'; + } + }); + + const tokenRow = (field: 'tickToken' | 'exportToken', title: string, hint: string) => { + const value = s[field]; + const which = field === 'tickToken' ? 'tick' : 'export'; + const reveal = + revealed?.field === field + ? `
New token (copy now — it won't be shown again):${esc(revealed.value)}
` + : ''; + return `
+
${title}${hint}
${secretStatus(value)}
${reveal}
+
+
+ ${value ? `
` : ''} +
+
`; + }; + + return ` +
Configuration
+

Settings

+

Stored in your database; secrets are encrypted with your SECRET_KEY. Changes apply immediately — no restart.

+ ${saved ? '
✓ Saved
' : ''} + ${error ? `
⚠ ${esc(error)}
` : ''} + +
+ +
01 · Google Chat
+

Workspace connection

+ + + +
+ +
+ +
02 · AI summaries
+

Bring your own key

+ + + + Then enable per standup with @AsyncUp ai on. + +
+ +
+ +
03 · Workspace
+

Defaults & integrations

+ + + +
+ +
+
04 · Access tokens
+

Machine endpoints

+ ${tokenRow('tickToken', 'Scheduler tick token', 'Authorizes POST /tick for external cron (scale-to-zero deploys).')} + ${tokenRow('exportToken', 'CSV export token', 'Enables GET /export. Endpoint stays off until a token exists.')} +
`; +} + +// ---------- standup pages (unchanged behavior, restyled) ---------- + async function applyConfig(repo: Repo, standup: Standup, body: any): Promise { const name = String(body.name ?? '').trim(); if (!name) return 'Name is required.'; @@ -187,7 +438,7 @@ async function standupPage(repo: Repo, s: Standup, now: DateTime, saved: boolean .join(''); const admins = (await repo.listAdmins(s.id)).map((a) => esc(a.displayName)).join(', ') || 'none (open config)'; - const runParts: string[] = []; + const runRows: string[] = []; for (const run of await repo.listRecentRuns(s.id, 14)) { const roster = await repo.listRunParticipants(run.id); const submitted = new Set((await repo.listSubmissions(run.id)).map((x) => x.userName)); @@ -195,75 +446,84 @@ async function standupPage(repo: Repo, s: Standup, now: DateTime, saved: boolean const missing = roster.filter( (p) => p.mandatory && !submitted.has(p.userName) && !p.skippedAt && !p.onVacation, ); - runParts.push(` + runRows.push(` ${run.date} ${run.status} ${submitted.size}/${roster.length - away.length} ${missing.map((p) => esc(p.displayName)).join(', ') || '—'} `); } - const runs = runParts.join(''); const local = now.setZone(s.timezone); - const trendParts: string[] = []; + const trendRows: string[] = []; for (const i of [3, 2, 1, 0]) { const start = local.minus({ weeks: i }).startOf('week'); const end = local.minus({ weeks: i }).endOf('week'); const stats = await rangeStats(repo, s.id, start.toISODate()!, end.toISODate()!); if (stats.runCount === 0) { - trendParts.push(`${start.toFormat('dd LLL')}no runs`); + trendRows.push(`${start.toFormat('dd LLL')}no runs`); continue; } const pct = stats.expected === 0 ? 100 : Math.round((stats.submitted / stats.expected) * 100); const mood = stats.moodCount ? Math.round((stats.moodSum / stats.moodCount) * 10) / 10 : null; - trendParts.push(`${start.toFormat('dd LLL')}–${end.toFormat('dd LLL')}${pct}%${ + trendRows.push(`${start.toFormat('dd LLL')}–${end.toFormat('dd LLL')}${pct}%${ mood !== null ? `${moodEmoji(mood)} ${mood}/5` : '—' }`); } - const trendRows = trendParts.join(''); const blockers = (await repo.listOpenBlockers(s.id)) .map((b) => `
  • ⚠️ ${esc(b.displayName)}: ${esc(b.text)} (since ${b.openedDate}${b.escalatedAt ? ', escalated' : ''})
  • `) .join(''); const check = (v: boolean) => (v ? 'checked' : ''); - return `

    ← All standups

    + return `

    ← All standups

    #${s.id} ${esc(s.name)}

    - ${saved ? '

    ✅ Saved.

    ' : ''} - ${error ? `

    ⚠️ ${esc(error)}

    ` : ''} + ${saved ? '
    ✓ Saved
    ' : ''} + ${error ? `
    ⚠ ${esc(error)}
    ` : ''}
    -
    -

    Configuration

    + +
    Configuration
    - + - - - - - -

    Participants, admins and the escalation contact are managed from Google Chat + + + + + +

    Participants, admins and the escalation contact are managed from Google Chat (add, admin, escalate @user …) since they need Chat identities.

    -

    People

    -
      ${participants || '
    • none yet
    • '}
    -

    Admins: ${admins}

    -

    Open blockers

    -
      ${blockers || '
    • ✅ none
    • '}
    -

    Trends

    - ${trendRows}
    WeekParticipationMood
    +
    +
    People
    +
      ${participants || '
    • none yet
    • '}
    +

    Admins: ${admins}

    +
    +
    +
    Open blockers
    +
      ${blockers || '
    • ✅ none
    • '}
    +
    +
    +
    Trends
    + ${trendRows.join('')}
    WeekParticipationMood
    +
    -

    History (last 14 runs)

    - ${runs || ''}
    DateStatusSubmittedMissing
    no runs yet
    `; +
    +
    History
    +

    Last 14 runs

    + ${runRows.join('') || ''}
    DateStatusSubmittedMissing
    no runs yet
    +
    `; } +// ---------- chrome ---------- + function esc(value: string): string { return value .replace(/&/g, '&') @@ -273,33 +533,121 @@ function esc(value: string): string { } const LOGO_SVG = - ''; + ''; -function layout(title: string, body: string): string { - return ` +function layout(title: string, active: 'home' | 'settings', body: string): string { + return ` ${esc(title)} -
    ${LOGO_SVG}AsyncUp
    -${body}`; +
    + ${LOGO_SVG} + AsyncUp + +
    +
    ${body}
    +`; } diff --git a/src/db/repo.ts b/src/db/repo.ts index f4af36d..41645d5 100644 --- a/src/db/repo.ts +++ b/src/db/repo.ts @@ -190,6 +190,15 @@ CREATE TABLE blocker_updates ( ); ALTER TABLE blockers ADD COLUMN resolved_by TEXT; CREATE INDEX idx_blockers_unresolved ON blockers(standup_id) WHERE resolved_date IS NULL; +`, + // 5 — app settings move from env vars into the database + ` +CREATE TABLE settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + encrypted INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL +); `, ]; @@ -319,6 +328,15 @@ CREATE TABLE blocker_updates ( ); ALTER TABLE blockers ADD COLUMN resolved_by TEXT; CREATE INDEX idx_blockers_unresolved ON blockers(standup_id) WHERE resolved_date IS NULL; +`, + // 5 — app settings move from env vars into the database + ` +CREATE TABLE settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + encrypted INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL +); `, ]; @@ -1049,6 +1067,25 @@ export class Repo { return row?.email ?? null; } + // --- settings (key/value, optionally encrypted) --- + + async getSettingRows(): Promise<{ key: string; value: string; encrypted: boolean }[]> { + const rows = await this.db.all('SELECT key, value, encrypted FROM settings'); + return rows.map((r: any) => ({ key: r.key, value: r.value, encrypted: !!r.encrypted })); + } + + async setSetting(key: string, value: string, encrypted: boolean, at: string): Promise { + await this.db.run( + `INSERT INTO settings (key, value, encrypted, updated_at) VALUES (?, ?, ?, ?) + ON CONFLICT (key) DO UPDATE SET value = excluded.value, encrypted = excluded.encrypted, updated_at = excluded.updated_at`, + [key, value, encrypted ? 1 : 0, at], + ); + } + + async deleteSetting(key: string): Promise { + await this.db.run('DELETE FROM settings WHERE key = ?', [key]); + } + // --- DM space cache (used by the Google Chat adapter) --- async getDmSpace(userName: string): Promise { diff --git a/src/index.ts b/src/index.ts index db83459..d9783c6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,14 +4,14 @@ import { loadConfig } from './config.js'; import { Repo } from './db/repo.js'; import { FakeAdapter } from './adapters/fake/adapter.js'; import { GoogleChatAdapter } from './adapters/gchat/adapter.js'; -import { ChatRequestVerifier } from './adapters/gchat/auth.js'; import { EventRouter } from './adapters/gchat/events.js'; import { createLlm } from './ai/llm.js'; import { AiSummarizer } from './ai/summarizer.js'; import { GoogleCalendarOoo } from './integrations/google-calendar.js'; import { BlockerService } from './core/blocker-service.js'; import { CommandHandler } from './core/commands.js'; -import { Scheduler } from './core/scheduler.js'; +import { Scheduler, type SchedulerProviders } from './core/scheduler.js'; +import { SettingsService } from './core/settings.js'; import { StandupService } from './core/standup-service.js'; import { createServer } from './server.js'; @@ -27,57 +27,50 @@ if (config.databaseUrl) { console.log(`[db] using embedded SQLite at ${config.dbPath}`); } +const settings = new SettingsService(repo, config.secretKey); + const adapter = config.adapter === 'google' - ? new GoogleChatAdapter(repo) + ? new GoogleChatAdapter(repo, settings) : new FakeAdapter((msg) => console.log(`[fake-adapter] ${msg}`)); const service = new StandupService(repo, adapter); const blockerService = new BlockerService(repo, adapter); -const commands = new CommandHandler(repo, config.defaultTimezone, undefined, blockerService); +const commands = new CommandHandler(repo, settings, undefined, blockerService); const router = new EventRouter(commands, service, blockerService, repo, config.tenantId); -let verifier: ChatRequestVerifier | null = null; -if (config.chatAudience) { - verifier = new ChatRequestVerifier(config.chatAudience); -} else { - console.warn( - '[server] GOOGLE_CHAT_AUDIENCE is not set — incoming requests are NOT verified. ' + - 'Set it to your GCP project number before exposing this to the internet.', - ); -} - -let summarizer: AiSummarizer | null = null; -if (config.llm) { - summarizer = new AiSummarizer(createLlm(config.llm)); - console.log(`[ai] summaries available via ${config.llm.provider} (${config.llm.model})`); -} - -let oooChecker: GoogleCalendarOoo | null = null; -if (config.calendarOoo) { - const keyPath = process.env.GOOGLE_APPLICATION_CREDENTIALS; - if (!keyPath) { - console.error('[ooo] GOOGLE_CALENDAR_OOO=true requires GOOGLE_APPLICATION_CREDENTIALS'); - } else { - oooChecker = new GoogleCalendarOoo(keyPath); - console.log('[ooo] Google Calendar OOO sync enabled'); - } -} +// Integrations are resolved from settings per use, so dashboard changes +// apply immediately — no restart. +const providers: SchedulerProviders = { + summarizer: async () => { + const s = await settings.get(); + if (!s.llmProvider || !s.llmApiKey) return null; + const model = s.llmModel || (s.llmProvider === 'anthropic' ? 'claude-opus-4-7' : ''); + if (!model) return null; + return new AiSummarizer(createLlm({ provider: s.llmProvider, apiKey: s.llmApiKey, model })); + }, + ooo: async () => { + const s = await settings.get(); + if (!s.calendarOoo || !s.serviceAccountJson) return null; + return new GoogleCalendarOoo(s.serviceAccountJson); + }, +}; -const scheduler = new Scheduler(repo, adapter, service, undefined, undefined, summarizer, oooChecker); +const scheduler = new Scheduler(repo, adapter, service, undefined, undefined, providers); const timer = scheduler.start(); scheduler.tick().catch((err) => console.error('[scheduler] initial tick failed:', err)); const app = createServer({ router, - verifier, scheduler, repo, - tickToken: config.tickToken, - exportToken: config.exportToken, + settings, dashboardToken: config.dashboardToken, + skipVerification: config.adapter === 'fake', }); if (config.dashboardToken) console.log('[dashboard] enabled at /dashboard'); +else console.warn('[dashboard] DASHBOARD_TOKEN is not set — the dashboard (and all app settings) are unavailable.'); + const server = app.listen(config.port, () => { console.log(`asyncup listening on :${config.port} (adapter: ${config.adapter}, db: ${config.databaseUrl ? 'postgres' : config.dbPath})`); }); diff --git a/src/integrations/google-calendar.ts b/src/integrations/google-calendar.ts index 6b5c503..fafcc52 100644 --- a/src/integrations/google-calendar.ts +++ b/src/integrations/google-calendar.ts @@ -1,4 +1,3 @@ -import { readFileSync } from 'node:fs'; import { JWT } from 'google-auth-library'; import { DateTime } from 'luxon'; import type { OooChecker } from '../core/ooo.js'; @@ -7,15 +6,15 @@ const SCOPE = 'https://www.googleapis.com/auth/calendar.events.readonly'; /** * Looks for "Out of office" events in the user's primary Google Calendar. - * Requires domain-wide delegation for the service account with the - * calendar.events.readonly scope (see docs/guide/google-chat-setup.md). + * Requires a service-account key (pasted in dashboard settings) with + * domain-wide delegation for the calendar.events.readonly scope. */ export class GoogleCalendarOoo implements OooChecker { private clientEmail: string; private privateKey: string; - constructor(keyPath: string) { - const creds = JSON.parse(readFileSync(keyPath, 'utf8')); + constructor(credentialsJson: string) { + const creds = JSON.parse(credentialsJson); this.clientEmail = creds.client_email; this.privateKey = creds.private_key; } diff --git a/src/server.ts b/src/server.ts index ff0e58f..fbe42fd 100644 --- a/src/server.ts +++ b/src/server.ts @@ -2,7 +2,8 @@ import express, { type Express, type Request } from 'express'; import { rateLimit } from 'express-rate-limit'; import { DateTime } from 'luxon'; import type { EventRouter } from './adapters/gchat/events.js'; -import type { ChatRequestVerifier } from './adapters/gchat/auth.js'; +import { ChatRequestVerifier } from './adapters/gchat/auth.js'; +import type { SettingsService } from './core/settings.js'; import type { Scheduler } from './core/scheduler.js'; import type { Repo } from './db/repo.js'; import { buildCsv } from './core/export.js'; @@ -10,14 +11,13 @@ import { registerDashboard } from './dashboard/dashboard.js'; export interface ServerDeps { router: EventRouter; - verifier: ChatRequestVerifier | null; scheduler: Scheduler; repo: Repo; - tickToken: string; - /** Empty string disables the /export endpoint. */ - exportToken: string; + settings: SettingsService; /** Empty string disables the /dashboard pages. */ dashboardToken: string; + /** Skip Chat webhook verification (fake adapter / local development). */ + skipVerification?: boolean; now?: () => DateTime; } @@ -26,8 +26,29 @@ function bearerToken(req: Request): string | undefined { } export function createServer(deps: ServerDeps): Express { - const { router, verifier, scheduler, repo, tickToken, exportToken } = deps; + const { router, scheduler, repo, settings } = deps; const now = deps.now ?? (() => DateTime.utc()); + + // The audience lives in DB settings and can change at runtime. + let verifierCache: { audience: string; verifier: ChatRequestVerifier } | null = null; + let warnedUnverified = false; + const getVerifier = async (): Promise => { + if (deps.skipVerification) return null; + const { chatAudience } = await settings.get(); + if (!chatAudience) { + if (!warnedUnverified) { + warnedUnverified = true; + console.warn( + '[server] Chat webhook verification is OFF — set the GCP project number in dashboard settings.', + ); + } + return null; + } + if (verifierCache?.audience !== chatAudience) { + verifierCache = { audience: chatAudience, verifier: new ChatRequestVerifier(chatAudience) }; + } + return verifierCache.verifier; + }; const app = express(); // First reverse-proxy hop is trusted so rate limiting sees real client IPs. app.set('trust proxy', 1); @@ -37,7 +58,7 @@ export function createServer(deps: ServerDeps): Express { const authLimiter = rateLimit({ windowMs: 60_000, limit: 60, standardHeaders: 'draft-8', legacyHeaders: false }); app.use(['/dashboard', '/export', '/tick'], authLimiter); - registerDashboard(app, { repo, token: deps.dashboardToken, now: deps.now }); + registerDashboard(app, { repo, settings, token: deps.dashboardToken, now: deps.now }); app.get('/healthz', async (_req, res) => { try { @@ -49,6 +70,7 @@ export function createServer(deps: ServerDeps): Express { }); app.post('/chat/events', async (req, res) => { + const verifier = await getVerifier(); if (verifier && !(await verifier.verify(req.header('authorization')))) { res.status(401).json({ error: 'unauthorized' }); return; @@ -64,6 +86,7 @@ export function createServer(deps: ServerDeps): Express { // For scale-to-zero deployments (Cloud Run + Cloud Scheduler, etc.) where // the in-process interval doesn't run while the instance is suspended. app.post('/tick', async (req, res) => { + const { tickToken } = await settings.get(); if (tickToken && bearerToken(req) !== tickToken) { res.status(401).json({ error: 'unauthorized' }); return; @@ -75,8 +98,9 @@ export function createServer(deps: ServerDeps): Express { // CSV export — disabled unless EXPORT_TOKEN is configured (the data is // your team's standup answers; never expose it unauthenticated). app.get('/export', async (req, res) => { + const { exportToken } = await settings.get(); if (!exportToken) { - res.status(404).json({ error: 'export disabled — set EXPORT_TOKEN to enable' }); + res.status(404).json({ error: 'export disabled — generate an export token in dashboard settings' }); return; } if (bearerToken(req) !== exportToken) { diff --git a/tests/dashboard.test.ts b/tests/dashboard.test.ts index 23c2f89..6e330a5 100644 --- a/tests/dashboard.test.ts +++ b/tests/dashboard.test.ts @@ -11,12 +11,11 @@ async function startServer(dashboardToken = 'dash-secret') { const router = new EventRouter(stack.commands, stack.service, stack.blockers, stack.repo, TENANT); const app = createServer({ router, - verifier: null, scheduler: stack.scheduler, repo: stack.repo, - tickToken: '', - exportToken: '', + settings: stack.settings, dashboardToken, + skipVerification: true, now: stack.clock.now, }); const server = app.listen(0); @@ -77,6 +76,72 @@ describe('dashboard', () => { expect(runPage).toContain('What will you do today?'); }); + it('serves the settings page, saves sections, and never echoes secrets', async () => { + const { url, settings, get } = await startServer(); + + const page = await (await get('/dashboard/settings')).text(); + expect(page).toContain('Google Chat'); + expect(page).toContain('AI summaries'); + expect(page).toContain('Access tokens'); + + const post = (body: Record) => + fetch(`${url}/dashboard/settings`, { + method: 'POST', + headers: { + cookie: 'asyncup_dash=dash-secret', + 'content-type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams(body).toString(), + redirect: 'manual', + }); + + expect((await post({ section: 'chat', chatAudience: 'not-a-number' })).status).toBe(400); + + const ok = await post({ + section: 'chat', + chatAudience: '987654', + serviceAccountJson: JSON.stringify({ client_email: 'bot@p.iam.gserviceaccount.com', private_key: 'k' }), + }); + expect(ok.status).toBe(302); + const saved = await settings.get(); + expect(saved.chatAudience).toBe('987654'); + expect(saved.serviceAccountJson).toContain('client_email'); + + // the page shows status, never the key material + const after = await (await get('/dashboard/settings')).text(); + expect(after).toContain('bot@p.iam.gserviceaccount.com'); + expect(after).not.toContain('private_key'); + + // saving AI section with empty key keeps configured values intact + expect((await post({ section: 'ai', llmProvider: 'anthropic', llmModel: '' })).status).toBe(302); + expect((await settings.get()).llmProvider).toBe('anthropic'); + }); + + it('generates tokens shown once and enforces them on /tick', async () => { + const { url, settings } = await startServer(); + const res = await fetch(`${url}/dashboard/settings`, { + method: 'POST', + headers: { + cookie: 'asyncup_dash=dash-secret', + 'content-type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ action: 'generate-tick' }).toString(), + }); + expect(res.status).toBe(200); + const html = await res.text(); + const token = (await settings.get()).tickToken; + expect(token).not.toBe(''); + expect(html).toContain(token); // revealed exactly once on this response + + const fresh = await (await fetch(`${url}/dashboard/settings`, { headers: { cookie: 'asyncup_dash=dash-secret' } })).text(); + expect(fresh).not.toContain(token); + + expect((await fetch(`${url}/tick`, { method: 'POST' })).status).toBe(401); + expect( + (await fetch(`${url}/tick`, { method: 'POST', headers: { authorization: `Bearer ${token}` } })).status, + ).toBe(200); + }); + it('updates configuration via the form and validates input', async () => { const { repo, url } = await startServer(); const standup = await seedStandup(repo); diff --git a/tests/helpers.ts b/tests/helpers.ts index 907dccc..1101c91 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -3,7 +3,9 @@ import { FakeAdapter } from '../src/adapters/fake/adapter.js'; import { AiSummarizer } from '../src/ai/summarizer.js'; import { BlockerService } from '../src/core/blocker-service.js'; import { CommandHandler } from '../src/core/commands.js'; +import type { OooChecker } from '../src/core/ooo.js'; import { Scheduler } from '../src/core/scheduler.js'; +import { SettingsService } from '../src/core/settings.js'; import { StandupService } from '../src/core/standup-service.js'; import { DEFAULT_QUESTIONS, type SubmissionInput } from '../src/core/types.js'; import { Repo } from '../src/db/repo.js'; @@ -13,7 +15,7 @@ export const TENANT = 'default'; let schemaCounter = 0; -export async function makeStack(opts: { summarizer?: AiSummarizer | null } = {}) { +export async function makeStack(opts: { summarizer?: AiSummarizer | null; ooo?: OooChecker | null } = {}) { let repo: Repo; if (process.env.TEST_DATABASE_URL) { const schema = `t_${process.pid}_${Date.now()}_${schemaCounter++}`; @@ -32,12 +34,17 @@ export async function makeStack(opts: { summarizer?: AiSummarizer | null } = {}) }, }; + const settings = new SettingsService(repo, 'test-secret-key', clock.now); + await settings.update({ defaultTimezone: TZ }); const service = new StandupService(repo, adapter, clock.now); const blockers = new BlockerService(repo, adapter, clock.now); - const scheduler = new Scheduler(repo, adapter, service, clock.now, () => {}, opts.summarizer ?? null); - const commands = new CommandHandler(repo, TZ, clock.now, blockers); + const scheduler = new Scheduler(repo, adapter, service, clock.now, () => {}, { + summarizer: async () => opts.summarizer ?? null, + ooo: async () => opts.ooo ?? null, + }); + const commands = new CommandHandler(repo, settings, clock.now, blockers); - return { repo, adapter, service, blockers, scheduler, commands, clock }; + return { repo, adapter, service, blockers, settings, scheduler, commands, clock }; } export async function seedStandup(repo: Repo, opts: { deadlineTime?: string; spaceName?: string } = {}) { diff --git a/tests/round3.test.ts b/tests/round3.test.ts index 9318fbb..8d76b57 100644 --- a/tests/round3.test.ts +++ b/tests/round3.test.ts @@ -27,16 +27,8 @@ describe('Calendar OOO sync', () => { it('marks participants with an OOO event as away for the run', async () => { const checker = makeChecker(['bob@org.com']); - const stack = await makeStack(); - const scheduler = new (await import('../src/core/scheduler.js')).Scheduler( - stack.repo, - stack.adapter, - stack.service, - stack.clock.now, - () => {}, - null, - checker, - ); + const stack = await makeStack({ ooo: checker }); + const scheduler = stack.scheduler; const standup = await seedStandup(stack.repo); await stack.repo.setUserEmail('users/alice', 'alice@org.com'); await stack.repo.setUserEmail('users/bob', 'bob@org.com'); @@ -66,21 +58,13 @@ describe('Calendar OOO sync', () => { }); it('treats checker failures as not-OOO', async () => { - const stack = await makeStack(); const failing: OooChecker = { async isOoo() { throw new Error('DWD not configured'); }, }; - const scheduler = new (await import('../src/core/scheduler.js')).Scheduler( - stack.repo, - stack.adapter, - stack.service, - stack.clock.now, - () => {}, - null, - failing, - ); + const stack = await makeStack({ ooo: failing }); + const scheduler = stack.scheduler; await seedStandup(stack.repo); await stack.repo.setUserEmail('users/alice', 'alice@org.com'); stack.clock.set('2026-06-10T09:30'); diff --git a/tests/server.test.ts b/tests/server.test.ts index 0b2c053..1ecd9cf 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -8,15 +8,16 @@ let close: (() => void) | null = null; async function startServer(opts: { tickToken?: string; exportToken?: string; dashboardToken?: string } = {}) { const stack = await makeStack(); + if (opts.tickToken) await stack.settings.update({ tickToken: opts.tickToken }); + if (opts.exportToken) await stack.settings.update({ exportToken: opts.exportToken }); const router = new EventRouter(stack.commands, stack.service, stack.blockers, stack.repo, TENANT); const app = createServer({ router, - verifier: null, scheduler: stack.scheduler, repo: stack.repo, - tickToken: opts.tickToken ?? '', - exportToken: opts.exportToken ?? '', + settings: stack.settings, dashboardToken: opts.dashboardToken ?? '', + skipVerification: true, now: stack.clock.now, }); const server = app.listen(0); diff --git a/tests/settings.test.ts b/tests/settings.test.ts new file mode 100644 index 0000000..97304c1 --- /dev/null +++ b/tests/settings.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; +import { SecretBox, generateToken } from '../src/core/crypto.js'; +import { makeStack } from './helpers.js'; + +describe('SecretBox', () => { + it('round-trips and rejects tampering', () => { + const box = new SecretBox('some-secret-key'); + const payload = box.encrypt('sk-ant-very-secret'); + expect(payload).not.toContain('very-secret'); + expect(box.decrypt(payload)).toBe('sk-ant-very-secret'); + + const other = new SecretBox('different-key'); + expect(() => other.decrypt(payload)).toThrow(); + expect(() => box.decrypt(payload.slice(0, -4) + 'AAAA')).toThrow(); + }); + + it('generates url-safe tokens', () => { + const token = generateToken(); + expect(token.length).toBeGreaterThanOrEqual(30); + expect(token).toMatch(/^[A-Za-z0-9_-]+$/); + expect(generateToken()).not.toBe(token); + }); +}); + +describe('SettingsService', () => { + it('returns defaults when nothing is stored', async () => { + const { settings } = await makeStack(); + const s = await settings.get(); + expect(s.chatAudience).toBe(''); + expect(s.calendarOoo).toBe(false); + expect(s.defaultTimezone).toBe('Asia/Kolkata'); // set by the test harness + }); + + it('persists values, encrypting secrets at rest', async () => { + const { settings, repo } = await makeStack(); + await settings.update({ chatAudience: '12345', llmApiKey: 'sk-secret', calendarOoo: true }); + + const s = await settings.get(); + expect(s.chatAudience).toBe('12345'); + expect(s.llmApiKey).toBe('sk-secret'); + expect(s.calendarOoo).toBe(true); + + const rows = await repo.getSettingRows(); + const audience = rows.find((r) => r.key === 'chatAudience')!; + expect(audience.encrypted).toBe(false); + expect(audience.value).toBe('12345'); + const key = rows.find((r) => r.key === 'llmApiKey')!; + expect(key.encrypted).toBe(true); + expect(key.value).not.toContain('sk-secret'); + }); + + it('clears values with empty string and notifies listeners', async () => { + const { settings } = await makeStack(); + let notified = 0; + settings.onChange(() => notified++); + + await settings.update({ tickToken: 'abc' }); + expect((await settings.get()).tickToken).toBe('abc'); + await settings.update({ tickToken: '' }); + expect((await settings.get()).tickToken).toBe(''); + expect(notified).toBe(2); + }); +});