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
7 changes: 6 additions & 1 deletion astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,12 @@ export default defineConfig({
// Inline the (small, ~6KB) global stylesheet into each page's <head> instead
// of emitting a render-blocking <link>. Pages are SSR'd per request, so there
// is no shared-CSS-cache benefit to give up, and FCP/LCP improve.
build: { inlineStylesheets: "always" },
// inlineStylesheets: small global CSS goes inline (no render-blocking <link>).
// format "file": prerendered pages emit as `about.html` (served at /about with
// no trailing slash) rather than `about/index.html` (which 307-redirects
// /about -> /about/). This keeps the prerendered URLs identical to the
// existing canonical/sitemap/nav scheme, so no redirect hop is introduced.
build: { inlineStylesheets: "always", format: "file" },
vite: {
plugins: [tailwindcss()],
resolve: {
Expand Down
12 changes: 1 addition & 11 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,7 @@
"jose": "^6.2.3",
"tailwindcss": "^4.2.1",
"tw-animate-css": "^1.3.4",
"web-vitals": "^5.2.0",
"zod": "^3.24.2"
"web-vitals": "^5.2.0"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20260527.1",
Expand Down
65 changes: 41 additions & 24 deletions src/components/BriefingForm.astro
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,6 @@ const errorClass = "mt-2 text-xs text-accent font-bold";
</form>

<script>
import { z } from "zod";
import { ENGAGEMENT_KEYS } from "@/lib/engagements";

// Prod sitekey is locked to decipher.ms; on localhost it errors 110200. Use
Expand All @@ -120,18 +119,37 @@ const errorClass = "mt-2 text-xs text-accent font-bold";
}
}

const schema = z.object({
name: z.string().trim().min(1, "Required").max(120),
email: z.string().trim().email("Invalid email").max(255),
role: z.string().trim().max(120).optional().or(z.literal("")),
engagementType: z.enum(ENGAGEMENT_KEYS, {
errorMap: () => ({ message: "Pick an engagement type" }),
}),
topic: z.string().trim().min(1, "Required").max(200),
details: z.string().trim().min(10, "Add a bit more detail").max(4000),
});
// Lightweight client-side validation mirroring the server checks in
// server/briefing.ts (the authority). Hand-rolled so zod stays out of the
// client bundle — this only provides instant inline feedback.
type FieldName = "name" | "email" | "role" | "engagementType" | "topic" | "details";
type FormValues = Record<FieldName, string>;

type FieldName = keyof z.infer<typeof schema>;
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

const validate = (v: FormValues): Partial<Record<FieldName, string>> => {
const e: Partial<Record<FieldName, string>> = {};
// Validate the trimmed values — that's what gets submitted, and what the
// server checks — so leading/trailing whitespace never blocks a valid input.
const name = v.name.trim();
if (!name) e.name = "Required";
else if (name.length > 120) e.name = "Keep this under 120 characters";
const email = v.email.trim();
if (!email) e.email = "Required";
else if (email.length > 255) e.email = "Keep this under 255 characters";
else if (!EMAIL_RE.test(email)) e.email = "Invalid email";
if (v.role.trim().length > 120) e.role = "Keep this under 120 characters";
if (!(ENGAGEMENT_KEYS as readonly string[]).includes(v.engagementType))
e.engagementType = "Pick an engagement type";
const topic = v.topic.trim();
if (!topic) e.topic = "Required";
else if (topic.length > 200) e.topic = "Keep this under 200 characters";
const details = v.details.trim();
if (!details) e.details = "Required";
else if (details.length < 10) e.details = "Add a bit more detail";
else if (details.length > 4000) e.details = "Keep this under 4000 characters";
return e;
};
Comment on lines +130 to +152

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right — the old zod schema was .trim().max() (validated the trimmed value) and we submit trimmed, so untrimmed length checks were a behavior change. Fixed in c6ed36e: validate() now checks trimmed values for all required/length rules (and reuses the trimmed string for the email regex). Verified: ' Joe Smith ' now passes. 🤖 Claude Code


const form = document.getElementById("briefing-form") as HTMLFormElement | null;
if (form) {
Expand Down Expand Up @@ -212,7 +230,7 @@ const errorClass = "mt-2 text-xs text-accent font-bold";

form.addEventListener("submit", async (e) => {
e.preventDefault();
const values = {
const values: FormValues = {
name: (form.elements.namedItem("name") as HTMLInputElement).value,
email: (form.elements.namedItem("email") as HTMLInputElement).value,
role: (form.elements.namedItem("role") as HTMLInputElement).value,
Expand All @@ -221,12 +239,11 @@ const errorClass = "mt-2 text-xs text-accent font-bold";
details: (form.elements.namedItem("details") as HTMLTextAreaElement).value,
};

const parsed = schema.safeParse(values);
if (!parsed.success) {
const errors = validate(values);
const failed = Object.keys(errors) as FieldName[];
if (failed.length > 0) {
clearFieldErrors();
for (const issue of parsed.error.issues) {
setFieldError(issue.path[0] as FieldName, issue.message);
}
for (const field of failed) setFieldError(field, errors[field]!);
return;
}
clearFieldErrors();
Expand All @@ -246,12 +263,12 @@ const errorClass = "mt-2 text-xs text-accent font-bold";
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
name: parsed.data.name,
email: parsed.data.email,
role: parsed.data.role || null,
engagementType: parsed.data.engagementType,
topic: parsed.data.topic,
details: parsed.data.details,
name: values.name.trim(),
email: values.email.trim(),
role: values.role.trim() || null,
engagementType: values.engagementType,
topic: values.topic.trim(),
details: values.details.trim(),
turnstileToken: token,
}),
});
Expand Down
13 changes: 0 additions & 13 deletions src/layouts/Layout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,6 @@ const websiteLd = {
name: "decipher.ms",
url: "https://decipher.ms/",
};

// Per-request App Insights client config, derived at render time from the
// connection-string secret + request hostname (see middleware / Telemetry).
const telemetryConfig = Astro.locals.telemetry?.clientConfig() ?? null;
---

<!doctype html>
Expand Down Expand Up @@ -91,15 +87,6 @@ const telemetryConfig = Astro.locals.telemetry?.clientConfig() ?? null;
<script type="application/ld+json" set:html={JSON.stringify(organizationLd)} />
<script type="application/ld+json" set:html={JSON.stringify(websiteLd)} />

{
telemetryConfig && (
<script
is:inline
set:html={`window.__telemetryConfig=${JSON.stringify(telemetryConfig)}`}
/>
)
}

<slot name="head" />

{/* Self-hosted, served from /_astro — no cross-origin render-blocking
Expand Down
1 change: 0 additions & 1 deletion src/lib/server/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,4 @@ export interface Env {
OIDC_KID: string;
OIDC_PRIVATE_KEY: string;
TURNSTILE_SECRET_KEY: string;
APPLICATIONINSIGHTS_CONNECTION_STRING: string;
}
61 changes: 8 additions & 53 deletions src/lib/server/telemetry.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ExecutionContext } from "@cloudflare/workers-types";
import type { Env } from "./env";
import { AI_IKEY, AI_INGESTION_ENDPOINT, resolveEnvironment } from "../telemetry-config";

interface Envelope {
name: string;
Expand All @@ -9,39 +9,8 @@ interface Envelope {
data: { baseType: string; baseData: Record<string, unknown> };
}

interface ParsedConnString {
iKey: string;
ingestionEndpoint: string;
}

const SDK_VERSION = "decipher-ms:1.0";

function resolveEnvironment(hostname: string): string {
if (hostname === "decipher.ms" || hostname === "www.decipher.ms") return "production";
const previewMatch = hostname.match(/^([a-z0-9-]+)-decipher-ms\.[^.]+\.workers\.dev$/i);
if (previewMatch) return `preview:${previewMatch[1]}`;
if (hostname.endsWith(".workers.dev")) return "preview";
return "development";
}

function parseConnString(s: unknown): ParsedConnString {
if (typeof s !== "string" || s.length === 0) {
throw new Error("APPLICATIONINSIGHTS_CONNECTION_STRING is empty or not a string");
}
const map: Record<string, string> = {};
for (const part of s.split(";")) {
const eq = part.indexOf("=");
if (eq < 0) continue;
map[part.slice(0, eq).trim().toLowerCase()] = part.slice(eq + 1).trim();
}
const iKey = map["instrumentationkey"];
const ingestionEndpoint = (map["ingestionendpoint"] || "").replace(/\/$/, "");
if (!iKey || !ingestionEndpoint) {
throw new Error("APPLICATIONINSIGHTS_CONNECTION_STRING missing iKey or IngestionEndpoint");
}
return { iKey, ingestionEndpoint };
}

function randomHex(bytes: number): string {
const arr = new Uint8Array(bytes);
crypto.getRandomValues(arr);
Expand All @@ -63,7 +32,6 @@ function formatDuration(ms: number): string {

export class Telemetry {
private envelopes: Envelope[] = [];
private readonly conn: ParsedConnString | null;
private readonly requestProperties: Record<string, string> = {};
readonly operationId = randomHex(16);

Expand All @@ -83,35 +51,22 @@ export class Telemetry {
private readonly cloudRole: string;

constructor(
env: Env,
private readonly ctx: ExecutionContext,
hostname: string,
) {
this.environment = resolveEnvironment(hostname);
this.cloudRole = `decipher-ms-${this.environment}`;
try {
this.conn = parseConnString(env.APPLICATIONINSIGHTS_CONNECTION_STRING);
} catch (err) {
console.error("Telemetry disabled:", err);
this.conn = null;
}
}

clientConfig(): { iKey: string; ingestionEndpoint: string; environment: string } | null {
if (!this.conn) return null;
return {
iKey: this.conn.iKey,
ingestionEndpoint: this.conn.ingestionEndpoint,
environment: this.environment,
};
}

newSpanId(): string {
return randomHex(8);
}

private push(baseType: string, baseData: Record<string, unknown>, parentId?: string): void {
if (!this.conn) return;
// Don't emit from local dev (no real hostname): keeps localhost preview from
// POSTing to the shared App Insights ingestion endpoint, matching the prior
// "disabled without a connection string" behavior.
if (this.environment === "development") return;
const tags: Record<string, string> = {
"ai.operation.id": this.operationId,
"ai.cloud.role": this.cloudRole,
Expand All @@ -121,7 +76,7 @@ export class Telemetry {
this.envelopes.push({
name: `Microsoft.ApplicationInsights.${baseType.replace(/Data$/, "")}`,
time: new Date().toISOString(),
iKey: this.conn.iKey,
iKey: AI_IKEY,
tags,
data: { baseType, baseData: { ver: 2, ...baseData } },
});
Expand Down Expand Up @@ -207,10 +162,10 @@ export class Telemetry {
}

async flushNow(): Promise<void> {
if (!this.conn || this.envelopes.length === 0) return;
if (this.envelopes.length === 0) return;
const payload = this.envelopes.map((e) => JSON.stringify(e)).join("\n");
this.envelopes = [];
const url = `${this.conn.ingestionEndpoint}/v2.1/track`;
const url = `${AI_INGESTION_ENDPOINT}/v2.1/track`;
try {
Comment on lines 164 to 169

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — same fix server-side in c6ed36e: push() short-circuits when this.environment === "development", so no envelopes accumulate and flushNow() never POSTs from local preview. Deployed prod/preview hostnames are unaffected. 🤖 Claude Code

const res = await fetch(url, {
method: "POST",
Expand Down
23 changes: 23 additions & 0 deletions src/lib/telemetry-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// App Insights client identifiers, shared by the browser RUM client
// (telemetry.ts) and the server request tracer (server/telemetry.ts).
//
// These are NOT secret: the instrumentation key and ingestion endpoint are
// shipped to every visitor in the client telemetry payload (visible in
// view-source), so a "connection string secret" only ever protected public
// values. Keeping them in source lets prerendered (static) pages configure
// telemetry with zero per-request server work — no SSR round-trip just to
// learn an iKey that is already public.
//
// The `environment` dimension is resolved from the request/location hostname
// at runtime (below), so a single build serves production and preview Workers
// while still tagging telemetry correctly.
export const AI_IKEY = "b122de9d-24eb-4bdc-adc7-4d0a68490740";
export const AI_INGESTION_ENDPOINT = "https://westus2-2.in.applicationinsights.azure.com";

export function resolveEnvironment(hostname: string): string {
if (hostname === "decipher.ms" || hostname === "www.decipher.ms") return "production";
const previewMatch = hostname.match(/^([a-z0-9-]+)-decipher-ms\.[^.]+\.workers\.dev$/i);
if (previewMatch) return `preview:${previewMatch[1]}`;
if (hostname.endsWith(".workers.dev")) return "preview";
return "development";
}
21 changes: 13 additions & 8 deletions src/lib/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type LCPMetricWithAttribution,
type TTFBMetricWithAttribution,
} from "web-vitals/attribution";
import { AI_IKEY, AI_INGESTION_ENDPOINT, resolveEnvironment } from "./telemetry-config";

type VitalMetric =
| CLSMetricWithAttribution
Expand All @@ -26,12 +27,6 @@ interface TelemetryConfig {
environment: string;
}

declare global {
interface Window {
__telemetryConfig?: TelemetryConfig;
}
}

interface Envelope {
name: string;
time: string;
Expand Down Expand Up @@ -255,8 +250,18 @@ let initialized = false;
export function initTelemetry(): void {
if (initialized) return;
initialized = true;
config = window.__telemetryConfig ?? null;
if (!config) return;
// Identifiers are public constants; the environment is derived client-side so
// prerendered static pages need no per-request server-injected config.
const environment = resolveEnvironment(location.hostname);
// Stay silent on localhost: historically dev had no injected config and so
// never emitted; sending here would pollute the shared App Insights resource
// (and fire surprising cross-origin beacons during local work).
if (environment === "development") return;
config = {
Comment on lines 250 to +260

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — confirmed during preview testing (a beacon actually fired from localhost). Fixed in c6ed36e: initTelemetry() returns early when resolveEnvironment(location.hostname) === "development", restoring the prior 'no injected config => no emit' behavior. Preview/prod still send, tagged by environment. 🤖 Claude Code

iKey: AI_IKEY,
ingestionEndpoint: AI_INGESTION_ENDPOINT,
environment,
};

onCLS(trackVital);
onINP(trackVital);
Expand Down
11 changes: 6 additions & 5 deletions src/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { defineMiddleware } from "astro:middleware";
import { env } from "cloudflare:workers";
import { Telemetry } from "@/lib/server/telemetry";

// Server-side request telemetry, ported from the old worker entrypoint. Runs
// for every on-demand route (static assets are served by the platform and
// never reach here). The Telemetry instance is stashed on locals so endpoints
// and the Layout can attach spans / read the client config for the same span.
// for every on-demand route (prerendered pages and static assets are served by
// the platform and never reach here). The Telemetry instance is stashed on
// locals so endpoints can attach spans/exceptions to the same request span; the
// per-request client config injection is gone — the browser RUM client now
// self-configures from public constants (see lib/telemetry.ts).
export const onRequest = defineMiddleware(async (context, next) => {
const { request, url, locals } = context;
const ctx = locals.cfContext;
Expand All @@ -14,7 +15,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
return next();
}

const tel = new Telemetry(env, ctx, url.hostname);
const tel = new Telemetry(ctx, url.hostname);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated the comment in c6ed36elocals.telemetry is now only used for server-side request/exception spans; the per-request client config injection is gone (the browser RUM client self-configures from public constants). 🤖 Claude Code

const requestId = tel.newSpanId();
locals.telemetry = tel;
locals.requestId = requestId;
Expand Down
1 change: 1 addition & 0 deletions src/pages/404.astro
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
---
export const prerender = true; // static content — CDN-served HTML, no per-request render
import Layout from "@/layouts/Layout.astro";
---

Expand Down
Loading
Loading