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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ lerna-debug.log*
node_modules
dist
dist-ssr
# Astro generated types / cache
.astro/
*.local
.env
.env.*
Expand Down
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
node_modules
dist
package-lock.json
.astro
4 changes: 3 additions & 1 deletion .prettierrc
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,7 @@
"printWidth": 100,
"semi": true,
"singleQuote": false,
"trailingComma": "all"
"trailingComma": "all",
"plugins": ["prettier-plugin-astro"],
"overrides": [{ "files": "*.astro", "options": { "parser": "astro" } }]
}
59 changes: 40 additions & 19 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,34 +6,54 @@ Briefing submissions are stored in Cloudflare **D1**. Two databases, both on
account `vezza.dev` / `d1db42c1ac42b3aee886f219b8f56e16`, binding `DB` (see
`wrangler.jsonc`):

| Database | id | Used by |
| --- | --- | --- |
| `decipher-ms-db` | `7fa5214b-5743-43cb-86e1-b679428f1d17` | production only |
| Database | id | Used by |
| --------------------- | -------------------------------------- | ----------------------------- |
| `decipher-ms-db` | `7fa5214b-5743-43cb-86e1-b679428f1d17` | production only |
| `decipher-ms-preview` | `e13ad566-bc05-49f8-890e-1d5820189be7` | all preview branches (shared) |

Production has its **own** database — branch builds never read, write, or
migrate it. Every non-prod branch shares the single `decipher-ms-preview` DB.

### Build output (Astro SSR on the Cloudflare adapter)

`npm run build` runs the Astro Cloudflare adapter, which emits:

- `dist/client/` — static assets (served by Workers Static Assets, bypassing the worker)
- `dist/server/` — the SSR worker (`entry.mjs`) **plus a generated `wrangler.json`**

The generated `dist/server/wrangler.json` is the deploy config: its `main`
(`entry.mjs`) and `assets.directory` (`../client`) are relative to `dist/server/`,
and it inherits the D1 binding + `vars` from the root `wrangler.jsonc`. Deploys
target **that** file, not the root config. The root `wrangler.jsonc` has no
`main` (the adapter injects one at build time) and is used only as the source of
truth for bindings/vars and for `d1` commands.

### Migrations ship with the deploy

Migrations live in `migrations/` and are tracked by `wrangler`. The Cloudflare
**Workers Builds** deploy commands (dashboard → Worker → Settings → Builds, not
in the repo) handle them per environment:

| Build | Deploy command | Database it migrates |
| --- | --- | --- |
| Production (main) | `npx wrangler d1 migrations apply decipher-ms-db --remote && npx wrangler deploy` | `decipher-ms-db` |
| Non-production (branches) | `bash scripts/preview-deploy.sh` | `decipher-ms-preview` |
| Build | Deploy command | Database it migrates |
| ------------------------- | -------------------------------------------------------------------------------------------------------------- | --------------------- |
| Production (main) | `npx wrangler d1 migrations apply decipher-ms-db --remote && npx wrangler deploy -c dist/server/wrangler.json` | `decipher-ms-db` |
| Non-production (branches) | `bash scripts/preview-deploy.sh` | `decipher-ms-preview` |

> **Dashboard update required (one-time):** the production deploy command must
> now pass `-c dist/server/wrangler.json` to `wrangler deploy` (it previously
> ran a bare `npx wrangler deploy`). The build step must run `npm run build`
> first so `dist/server/` exists. `d1 migrations apply` still reads the root
> `wrangler.jsonc` and needs no `-c`.

Production order matters: migrate **before** deploy, so new code never runs
against an old schema. `migrations apply` is idempotent — a clean no-op when
nothing is pending — so it's safe on every build.

`scripts/preview-deploy.sh` derives `wrangler.preview.jsonc` (gitignored) from
`wrangler.jsonc` by swapping only the D1 name + id to the preview database, then
migrates *that* and runs `versions upload`. `wrangler.jsonc` stays the single
source of truth; the worker name is unchanged, so the branch preview URL
(`<branch>-decipher-ms.*.workers.dev`) is unaffected.
`scripts/preview-deploy.sh` migrates the preview DB using a root-derived
`wrangler.preview.jsonc` (gitignored; only the D1 name + id swapped), then swaps
the same D1 name + id inside the generated `dist/server/wrangler.json` and runs
`versions upload` against it. The worker name is unchanged, so the branch
preview URL (`<branch>-decipher-ms.*.workers.dev`) is unaffected.

Auth: the migrate step needs D1 write. The Workers Builds managed token usually
has it; if a build fails with `7403`, set `CLOUDFLARE_API_TOKEN` (D1-edit
Expand Down Expand Up @@ -63,13 +83,13 @@ but App Insights is the source of truth).

### Where things live

| Thing | Where |
| --- | --- |
| App Insights resource | `ai-decipherms-wu2-1` in `rg-decipherms-wu2-1`, sub `240ae4d5-0160-4b5d-b078-e8e3074cecc2` |
| Connection string | `APPLICATIONINSIGHTS_CONNECTION_STRING` Worker secret. Worker parses it (`worker/telemetry.ts:parseConnString`) and injects `{iKey, ingestionEndpoint, environment}` into HTML responses as `window.__telemetryConfig` for the client to consume. Set per environment via `npx wrangler secret put APPLICATIONINSIGHTS_CONNECTION_STRING`. |
| App ID for Logs API | `00575dd1-2f5b-47ad-845d-a706d47fbfe4` |
| Dashboard | [Azure portal dashboard](https://portal.azure.com/#@vezza.dev/dashboard/arm/subscriptions/240ae4d5-0160-4b5d-b078-e8e3074cecc2/resourcegroups/rg-decipherms-wu2-1/providers/microsoft.portal/dashboards/00575dd1-2f5b-47ad-845d-a706d47fbfe4-dashboard) — mirrors Cloudflare RUM tiles (page views, visits, web vitals p75 + rating split + top offending elements, browsers, OS, countries, exceptions). 7d window by default. |
| Cloudflare RUM (parallel) | [siteTag c1ccb0d6…](https://dash.cloudflare.com/d1db42c1ac42b3aee886f219b8f56e16/web-analytics/overview?siteTag~in=c1ccb0d6146e49e1a9d2bbf4d4bbccfa&excludeBots=Yes) |
| Thing | Where |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| App Insights resource | `ai-decipherms-wu2-1` in `rg-decipherms-wu2-1`, sub `240ae4d5-0160-4b5d-b078-e8e3074cecc2` |
| Connection string | `APPLICATIONINSIGHTS_CONNECTION_STRING` Worker secret. The telemetry middleware parses it (`src/lib/server/telemetry.ts:parseConnString`) and the Layout renders `{iKey, ingestionEndpoint, environment}` into the page head as `window.__telemetryConfig` for the client to consume. Set per environment via `npx wrangler secret put APPLICATIONINSIGHTS_CONNECTION_STRING`. |
| App ID for Logs API | `00575dd1-2f5b-47ad-845d-a706d47fbfe4` |
| Dashboard | [Azure portal dashboard](https://portal.azure.com/#@vezza.dev/dashboard/arm/subscriptions/240ae4d5-0160-4b5d-b078-e8e3074cecc2/resourcegroups/rg-decipherms-wu2-1/providers/microsoft.portal/dashboards/00575dd1-2f5b-47ad-845d-a706d47fbfe4-dashboard) — mirrors Cloudflare RUM tiles (page views, visits, web vitals p75 + rating split + top offending elements, browsers, OS, countries, exceptions). 7d window by default. |
| Cloudflare RUM (parallel) | [siteTag c1ccb0d6…](https://dash.cloudflare.com/d1db42c1ac42b3aee886f219b8f56e16/web-analytics/overview?siteTag~in=c1ccb0d6146e49e1a9d2bbf4d4bbccfa&excludeBots=Yes) |

### Tables

Expand Down Expand Up @@ -106,6 +126,7 @@ az monitor app-insights query --app $APP --analytics-query '<kql>' -o json
### Environment tag

Every envelope has `customDimensions.environment` set to one of:

- `production` — `decipher.ms` / `www.decipher.ms`
- `preview:<branch>` — `<branch>-decipher-ms.*.workers.dev`
- `preview` — any other `*.workers.dev`
Expand Down
27 changes: 20 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,27 +1,40 @@
# decipher.ms

Source for [decipher.ms](https://decipher.ms) — an independent Microsoft advisory operated by Vezza LLC. A marketing site plus a Cloudflare Worker that handles briefing-form intake, anonymous telemetry, and transactional email.
Source for [decipher.ms](https://decipher.ms) — an independent Microsoft advisory operated by Vezza LLC. A marketing site plus server routes that handle briefing-form intake, anonymous telemetry, and transactional email — all running on Cloudflare Workers via Astro's SSR adapter.

## Stack

- **Frontend** — Vite, React 19 + TypeScript, React Router, Tailwind CSS v4, `react-helmet-async`
- **Backend** — Cloudflare Worker, D1 (briefing storage), Microsoft Graph (outbound email), Cloudflare Turnstile (bot protection)
- **Frontend** — Astro (SSR) + TypeScript, Tailwind CSS v4
- **Backend** — Astro server routes on the Cloudflare Workers adapter, D1 (briefing storage), Microsoft Graph (outbound email), Cloudflare Turnstile (bot protection)
- **Telemetry** — Azure Application Insights, ingested directly via raw envelope POSTs (no SDK). See [`AGENTS.md`](AGENTS.md).

## Development

```bash
npm install
npm run dev # Vite dev server
npm run build # Production build
npm run dev # Astro dev server (Cloudflare bindings via the adapter)
npm run build # SSR build to ./dist/{client,server}
npm run preview # Preview the built SSR worker (via the Cloudflare adapter)
npm run lint
```

The worker reads its configuration from [`wrangler.jsonc`](wrangler.jsonc) and the secrets declared in [`worker/env.ts`](worker/env.ts) (`GRAPH_*`, `OIDC_*`, `TURNSTILE_SECRET_KEY`, `APPLICATIONINSIGHTS_CONNECTION_STRING`). Set them per environment with `npx wrangler secret put <NAME>`.
`npm run build` emits an SSR worker to `./dist/server` and static assets to
`./dist/client`. Server logic lives in `src/`:

- `src/pages/api/briefing.ts` — briefing intake (`POST /api/briefing`)
- `src/pages/.well-known/*` — OIDC discovery + JWKS
- `src/middleware.ts` — per-request App Insights telemetry
- `src/lib/server/*` — D1, Microsoft Graph, JWT/OIDC, Turnstile, telemetry

Bindings are read via `import { env } from "cloudflare:workers"`; the execution
context is `Astro.locals.cfContext`. Secrets (`OIDC_PRIVATE_KEY`,
`TURNSTILE_SECRET_KEY`, `APPLICATIONINSIGHTS_CONNECTION_STRING`) and `vars`
(`GRAPH_*`, `OIDC_*`) come from [`wrangler.jsonc`](wrangler.jsonc); set secrets
per environment with `npx wrangler secret put <NAME>`.

## Deployment

Cloudflare Pages / Workers Builds. Pushes to `main` deploy production; branch pushes get a preview at `<branch>-decipher-ms.*.workers.dev`.
Cloudflare Workers Builds. Pushes to `main` deploy production; branch pushes get a preview at `<branch>-decipher-ms.*.workers.dev`. Deploy targets the adapter-generated `dist/server/wrangler.json` — see [`AGENTS.md`](AGENTS.md) for the exact build/deploy commands.

## License

Expand Down
28 changes: 28 additions & 0 deletions astro.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { defineConfig, sessionDrivers } from "astro/config";
import cloudflare from "@astrojs/cloudflare";
import tailwindcss from "@tailwindcss/vite";

// SSR on Cloudflare Workers. Pages render on demand so the telemetry
// middleware can run per request (and inject the per-request App Insights
// client config); API + OIDC logic lives in src/pages routes. Static files in
// public/ and built assets are served by the platform, bypassing the worker.
export default defineConfig({
site: "https://decipher.ms",
output: "server",
// This app doesn't use Astro sessions; opt out of the adapter's default
// Cloudflare-KV session driver so no (id-less) SESSION KV binding is emitted
// into the generated deploy config.
session: { driver: sessionDrivers.memory() },
adapter: cloudflare({
// Plain <img> with build-emitted assets — no runtime image service / IMAGES binding.
imageService: "passthrough",
}),
vite: {
plugins: [tailwindcss()],
resolve: {
alias: {
"@": new URL("./src", import.meta.url).pathname,
},
},
},
});
19 changes: 7 additions & 12 deletions eslint.config.js
Original file line number Diff line number Diff line change
@@ -1,28 +1,23 @@
import js from "@eslint/js";
import eslintPluginPrettier from "eslint-plugin-prettier/recommended";
import eslintConfigPrettier from "eslint-config-prettier";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
import eslintPluginAstro from "eslint-plugin-astro";

export default tseslint.config(
{ ignores: ["dist"] },
{ ignores: ["dist", ".astro"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2020,
ecmaVersion: 2022,
globals: globals.browser,
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
"@typescript-eslint/no-unused-vars": "off",
},
},
eslintPluginPrettier,
...eslintPluginAstro.configs.recommended,
// Code quality via ESLint; formatting is owned by Prettier (npm run format).
eslintConfigPrettier,
);
74 changes: 0 additions & 74 deletions index.html

This file was deleted.

Loading