diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bdd1b10..d666c3fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,18 @@ Contributors: add user-facing changes under **[Unreleased]** in your PR to `deve --- +## [1.22.13] - 2026-10-23 + +### Added + +- **Check-in key rotation** — the API accepts an optional `CHECKIN_ENCRYPTION_KEY_PREVIOUS` (decrypt only), so `CHECKIN_ENCRYPTION_KEY` can be rotated without losing stored check-in tokens. A new `run-checkin-reencrypt` job re-encrypts tokens with the current key (dry run by default, `--apply` to write, counts only in output). Runbook in `docs/CHECK-INS.md` ([#863](https://github.com/Telemetry-Tracker/telemetry-tracker/pull/863)) + +### Changed + +- **Docs** — Node SDK docs align the ingest URL, add ESM-only guidance and an Express error handler example ([#862](https://github.com/Telemetry-Tracker/telemetry-tracker/pull/862)); leftover `tacko.io` sender references replaced ([#861](https://github.com/Telemetry-Tracker/telemetry-tracker/pull/861)) + +--- + ## [1.22.12] - 2026-10-20 ### Fixed diff --git a/apps/api/.env.example b/apps/api/.env.example index 80c12242..e593b93c 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -1,6 +1,8 @@ DATABASE_URL=postgresql://postgres:postgres@localhost:5432/telemetry # Check-in monitoring: 32 random bytes as hex; retain securely with DB backups. # CHECKIN_ENCRYPTION_KEY= +# Optional, during key rotation only (decrypt-only, comma-separated old keys): +# CHECKIN_ENCRYPTION_KEY_PREVIOUS= PORT=3001 HOST=0.0.0.0 @@ -36,7 +38,7 @@ NODE_ENV=development # Optional email (invites + password reset + notifications + contact) # RESEND_API_KEY=re_... -# TELEMETRY_EMAIL_FROM=Telemetry Tracker +# TELEMETRY_EMAIL_FROM=Telemetry Tracker # CONTACT_INBOX_EMAIL=info@telemetry-tracker.com # Reply-To for outgoing mail. Hosted cloud defaults to info@telemetry-tracker.com; self-hosted defaults to none. # TELEMETRY_EMAIL_REPLY_TO= diff --git a/apps/api/package.json b/apps/api/package.json index 069ab4f0..a795c922 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -14,6 +14,7 @@ "alert-rules-evaluator": "tsx src/jobs/run-alert-rules-evaluator.ts", "builtin-alert-rules-backfill": "tsx src/jobs/run-builtin-alert-rules-backfill.ts", "pii-scrub-backfill": "tsx src/jobs/run-pii-scrub-backfill.ts", + "checkin-reencrypt": "tsx src/jobs/run-checkin-reencrypt.ts", "brief-worker": "tsx src/jobs/run-brief-worker.ts", "alert-webhook-worker": "tsx src/jobs/run-alert-webhook-worker.ts", "seed:dev-api-key": "tsx scripts/seed-dev-api-key.ts" diff --git a/apps/api/src/generated/api-version.ts b/apps/api/src/generated/api-version.ts index 7d7b6f3d..9f34f9fd 100644 --- a/apps/api/src/generated/api-version.ts +++ b/apps/api/src/generated/api-version.ts @@ -1,2 +1,2 @@ /** Generated at build time from CHANGELOG (CHANGELOG.md). Do not edit manually. */ -export const API_VERSION = "1.22.12"; +export const API_VERSION = "1.22.13"; diff --git a/apps/api/src/jobs/checkin-reencrypt.ts b/apps/api/src/jobs/checkin-reencrypt.ts new file mode 100644 index 00000000..bd12b19d --- /dev/null +++ b/apps/api/src/jobs/checkin-reencrypt.ts @@ -0,0 +1,66 @@ +/** + * Re-encrypt CheckInMonitor.token_encrypted with the current CHECKIN_ENCRYPTION_KEY. + * Idempotent: rows already on the current key are skipped. Logs counts only. + */ +import type { PrismaClient } from "@prisma/client"; +import { checkInTokenHash, decryptCheckInSecret, encryptCheckInToken, isEncryptedWithCurrentKey } from "../lib/checkin-secrets.js"; + +export type CheckInReencryptResult = { + dryRun: boolean; scanned: number; alreadyCurrent: number; reencrypted: number; failed: number; +}; + +type Db = Pick; + +export function parseCheckInReencryptArgs(argv: string[]): { apply: boolean; batchSize: number; help: boolean; error?: string } { + let apply = false, batchSize = 200, help = false; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--apply") apply = true; + else if (a === "--dry-run") apply = false; + else if (a === "--help" || a === "-h") help = true; + else if (a === "--batch-size") { + const n = Number(argv[++i]); + if (!Number.isInteger(n) || n < 1 || n > 2000) return { apply, batchSize, help, error: "--batch-size must be 1..2000" }; + batchSize = n; + } else if (a !== "--") return { apply, batchSize, help, error: `Unknown argument: ${a}` }; + } + return { apply, batchSize, help }; +} + +export async function runCheckInReencrypt(db: Db, opts: { apply?: boolean; batchSize?: number } = {}): Promise { + const apply = opts.apply === true; + const batchSize = opts.batchSize ?? 200; + const result: CheckInReencryptResult = { dryRun: !apply, scanned: 0, alreadyCurrent: 0, reencrypted: 0, failed: 0 }; + let cursor: string | undefined; + for (;;) { + const rows = await db.checkInMonitor.findMany({ + select: { id: true, token_hash: true, token_encrypted: true }, + orderBy: { id: "asc" }, take: batchSize, + ...(cursor ? { skip: 1, cursor: { id: cursor } } : {}), + }); + if (rows.length === 0) break; + for (const row of rows) { + result.scanned++; + if (isEncryptedWithCurrentKey(row.token_encrypted)) { + try { decryptCheckInSecret(row.token_encrypted); result.alreadyCurrent++; } catch { result.failed++; } + continue; + } + let token: string; + try { token = decryptCheckInSecret(row.token_encrypted); } catch { result.failed++; continue; } + if (checkInTokenHash(token) !== row.token_hash) { result.failed++; continue; } + if (apply) { + // Compare-and-set so a concurrent token rotation is never overwritten. + const updated = await db.checkInMonitor.updateMany({ + where: { id: row.id, token_encrypted: row.token_encrypted }, + data: { token_encrypted: encryptCheckInToken(token) }, + }); + if (updated.count === 1) result.reencrypted++; + } else { + result.reencrypted++; + } + } + cursor = rows[rows.length - 1].id; + if (rows.length < batchSize) break; + } + return result; +} diff --git a/apps/api/src/jobs/run-checkin-reencrypt.ts b/apps/api/src/jobs/run-checkin-reencrypt.ts new file mode 100644 index 00000000..30233d68 --- /dev/null +++ b/apps/api/src/jobs/run-checkin-reencrypt.ts @@ -0,0 +1,30 @@ +/** + * CLI: re-encrypt check-in tokens with the current CHECKIN_ENCRYPTION_KEY. + * + * node dist/jobs/run-checkin-reencrypt.js # dry run (default) + * node dist/jobs/run-checkin-reencrypt.js --apply # write + * + * Requires DATABASE_URL, CHECKIN_ENCRYPTION_KEY and (during rotation) CHECKIN_ENCRYPTION_KEY_PREVIOUS. + * Prints counts only; never prints tokens or keys. + */ +import { prisma } from "../lib/db.js"; +import { parseCheckInReencryptArgs, runCheckInReencrypt } from "./checkin-reencrypt.js"; + +const parsed = parseCheckInReencryptArgs(process.argv.slice(2)); +if (parsed.help) { + console.log("Usage: node dist/jobs/run-checkin-reencrypt.js [--apply] [--batch-size ]\nDry run by default. Idempotent. See docs/CHECK-INS.md (key rotation)."); + process.exit(0); +} +if (parsed.error) { console.error(parsed.error); process.exit(1); } + +runCheckInReencrypt(prisma, { apply: parsed.apply, batchSize: parsed.batchSize }) + .then(async (result) => { + console.log(JSON.stringify({ ok: result.failed === 0, ...result, at: new Date().toISOString() })); + await prisma.$disconnect(); + if (result.failed > 0) process.exit(1); + }) + .catch(async (e: unknown) => { + console.error(`[checkin-reencrypt] failed: ${e instanceof Error ? e.message : "unknown error"}`); + await prisma.$disconnect().catch(() => {}); + process.exit(1); + }); diff --git a/apps/api/src/lib/checkin-key-rotation.test.ts b/apps/api/src/lib/checkin-key-rotation.test.ts new file mode 100644 index 00000000..2bfa2069 --- /dev/null +++ b/apps/api/src/lib/checkin-key-rotation.test.ts @@ -0,0 +1,99 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createCipheriv, randomBytes } from "node:crypto"; +import { checkInTokenHash, createCheckInSecret, decryptCheckInSecret, isEncryptedWithCurrentKey } from "./checkin-secrets.js"; +import { parseCheckInReencryptArgs, runCheckInReencrypt } from "../jobs/checkin-reencrypt.js"; + +const OLD = "ab".repeat(32), NEW = "cd".repeat(32), OTHER = "ef".repeat(32); +afterEach(() => vi.unstubAllEnvs()); + +/** Legacy (pre-rotation) format: bare base64(iv|tag|ct). */ +function legacyEncrypt(keyHex: string, token: string): string { + const iv = randomBytes(12); + const c = createCipheriv("aes-256-gcm", Buffer.from(keyHex, "hex"), iv); + const ct = Buffer.concat([c.update(token, "utf8"), c.final()]); + return Buffer.concat([iv, c.getAuthTag(), ct]).toString("base64"); +} +function setKeys(current: string, previous?: string) { + vi.stubEnv("CHECKIN_ENCRYPTION_KEY", current); + vi.stubEnv("CHECKIN_ENCRYPTION_KEY_PREVIOUS", previous ?? ""); +} +function fakeDb(rows: { id: string; token_hash: string; token_encrypted: string }[]) { + return { + checkInMonitor: { + findMany: vi.fn(async (args: { take: number; cursor?: { id: string } }) => { + const sorted = [...rows].sort((a, b) => a.id.localeCompare(b.id)); + const start = args.cursor ? sorted.findIndex((r) => r.id === args.cursor!.id) + 1 : 0; + return sorted.slice(start, start + args.take).map((r) => ({ ...r })); + }), + updateMany: vi.fn(async (args: { where: { id: string; token_encrypted: string }; data: { token_encrypted: string } }) => { + const r = rows.find((x) => x.id === args.where.id && x.token_encrypted === args.where.token_encrypted); + if (!r) return { count: 0 }; + r.token_encrypted = args.data.token_encrypted; return { count: 1 }; + }), + }, + }; +} + +describe("check-in encryption key rotation", () => { + it("decrypts legacy and versioned old-key data after rotation", () => { + setKeys(OLD); + const versioned = createCheckInSecret().token_encrypted; + const legacy = legacyEncrypt(OLD, "ttc_legacy"); + setKeys(NEW, `${OTHER}, ${OLD}`); + expect(decryptCheckInSecret(legacy)).toBe("ttc_legacy"); + expect(decryptCheckInSecret(versioned)).toMatch(/^ttc_/); + expect(isEncryptedWithCurrentKey(versioned)).toBe(false); + }); + it("encrypts new writes with the current key only", () => { + setKeys(NEW, OLD); + const enc = createCheckInSecret().token_encrypted; + expect(enc.startsWith("v1.")).toBe(true); + expect(enc).not.toContain(NEW); + expect(isEncryptedWithCurrentKey(enc)).toBe(true); + setKeys(NEW); + expect(decryptCheckInSecret(enc)).toMatch(/^ttc_/); + setKeys(OLD); + expect(() => decryptCheckInSecret(enc)).toThrow("could not be decrypted"); + }); + it("fails safely without the previous key and rejects malformed previous keys", () => { + const legacy = legacyEncrypt(OLD, "ttc_x"); + setKeys(NEW); + expect(() => decryptCheckInSecret(legacy)).toThrow("could not be decrypted"); + setKeys(NEW, "not-hex"); + expect(() => decryptCheckInSecret(legacy)).toThrow("CHECKIN_ENCRYPTION_KEY_PREVIOUS"); + setKeys("", OLD); + expect(() => createCheckInSecret()).toThrow("CHECKIN_ENCRYPTION_KEY"); + }); + it("re-encrypt is dry-run by default, migrates with --apply and is idempotent", async () => { + const rows = Array.from({ length: 5 }, (_, i) => { + const token = `ttc_${i}`; + return { id: `m${i}`, token_hash: checkInTokenHash(token), token_encrypted: legacyEncrypt(OLD, token) }; + }); + setKeys(NEW, OLD); + const db = fakeDb(rows); + const log = vi.spyOn(console, "log"); + const dry = await runCheckInReencrypt(db as never, { batchSize: 2 }); + expect(dry).toEqual({ dryRun: true, scanned: 5, alreadyCurrent: 0, reencrypted: 5, failed: 0 }); + expect(db.checkInMonitor.updateMany).not.toHaveBeenCalled(); + const applied = await runCheckInReencrypt(db as never, { apply: true, batchSize: 2 }); + expect(applied).toMatchObject({ dryRun: false, scanned: 5, reencrypted: 5, failed: 0 }); + const again = await runCheckInReencrypt(db as never, { apply: true, batchSize: 2 }); + expect(again).toMatchObject({ scanned: 5, alreadyCurrent: 5, reencrypted: 0, failed: 0 }); + setKeys(NEW); + rows.forEach((r, i) => expect(decryptCheckInSecret(r.token_encrypted)).toBe(`ttc_${i}`)); + expect(log).not.toHaveBeenCalled(); + }); + it("re-encrypt counts undecryptable rows as failed and leaves them unchanged", async () => { + const row = { id: "m1", token_hash: checkInTokenHash("ttc_a"), token_encrypted: legacyEncrypt(OLD, "ttc_a") }; + setKeys(NEW); + const before = row.token_encrypted; + const r = await runCheckInReencrypt(fakeDb([row]) as never, { apply: true }); + expect(r).toMatchObject({ failed: 1, reencrypted: 0 }); + expect(row.token_encrypted).toBe(before); + }); + it("parses CLI args with dry-run default", () => { + expect(parseCheckInReencryptArgs([])).toMatchObject({ apply: false, batchSize: 200 }); + expect(parseCheckInReencryptArgs(["--apply", "--batch-size", "50"])).toMatchObject({ apply: true, batchSize: 50 }); + expect(parseCheckInReencryptArgs(["--nope"]).error).toBeTruthy(); + }); +}); diff --git a/apps/api/src/lib/checkin-secrets.ts b/apps/api/src/lib/checkin-secrets.ts index 91e8c2b6..2395c1c4 100644 --- a/apps/api/src/lib/checkin-secrets.ts +++ b/apps/api/src/lib/checkin-secrets.ts @@ -3,27 +3,83 @@ import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node: export function checkInTokenHash(token: string): string { return createHash("sha256").update(token).digest("hex"); } -function encryptionKey(): Buffer { - const raw = process.env.CHECKIN_ENCRYPTION_KEY ?? ""; - if (!/^[a-f0-9]{64}$/i.test(raw)) throw new Error("CHECKIN_ENCRYPTION_KEY must be 32 bytes in hex"); - return Buffer.from(raw, "hex"); + +/** + * Encryption keys for check-in tokens. + * - CHECKIN_ENCRYPTION_KEY: current key (32 bytes hex), used for all new encryption. + * - CHECKIN_ENCRYPTION_KEY_PREVIOUS: optional comma-separated old keys, decryption only. + * Ciphertext format: `v1..` where kid is a short SHA-256 fingerprint + * of the key (not the key). Legacy rows are bare base64 and are tried against every key; + * AES-256-GCM authentication rejects a wrong key. + */ +type CheckInKey = { id: string; key: Buffer }; +const KEY_RE = /^[a-f0-9]{64}$/i; +const PREFIX = "v1."; + +function toKey(raw: string): CheckInKey { + const key = Buffer.from(raw, "hex"); + return { id: createHash("sha256").update(key).digest("hex").slice(0, 16), key }; } -export function createCheckInSecret() { - const token = `ttc_${randomBytes(32).toString("hex")}`; +function currentKey(): CheckInKey { + const raw = (process.env.CHECKIN_ENCRYPTION_KEY ?? "").trim(); + if (!KEY_RE.test(raw)) throw new Error("CHECKIN_ENCRYPTION_KEY must be 32 bytes in hex"); + return toKey(raw); +} +function previousKeys(): CheckInKey[] { + const raw = process.env.CHECKIN_ENCRYPTION_KEY_PREVIOUS ?? ""; + const parts = raw.split(",").map((s) => s.trim()).filter(Boolean); + for (const p of parts) { + if (!KEY_RE.test(p)) throw new Error("CHECKIN_ENCRYPTION_KEY_PREVIOUS must be comma-separated 32-byte hex keys"); + } + return parts.map(toKey); +} +/** Current key first, then previous keys (deduplicated). */ +function decryptionKeys(): CheckInKey[] { + const keys = [currentKey(), ...previousKeys()]; + return keys.filter((k, i) => keys.findIndex((o) => o.id === k.id) === i); +} + +function encryptWith(k: CheckInKey, plaintext: string): string { const iv = randomBytes(12); - const cipher = createCipheriv("aes-256-gcm", encryptionKey(), iv); - const ciphertext = Buffer.concat([cipher.update(token, "utf8"), cipher.final()]); - return { - token_hash: checkInTokenHash(token), - token_encrypted: Buffer.concat([iv, cipher.getAuthTag(), ciphertext]).toString("base64"), - }; + const cipher = createCipheriv("aes-256-gcm", k.key, iv); + const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + return `${PREFIX}${k.id}.${Buffer.concat([iv, cipher.getAuthTag(), ciphertext]).toString("base64")}`; } -export function decryptCheckInSecret(encrypted: string): string { - const bytes = Buffer.from(encrypted, "base64"); - const decipher = createDecipheriv("aes-256-gcm", encryptionKey(), bytes.subarray(0, 12)); +function decryptWith(k: CheckInKey, payload: string): string { + const bytes = Buffer.from(payload, "base64"); + const decipher = createDecipheriv("aes-256-gcm", k.key, bytes.subarray(0, 12)); decipher.setAuthTag(bytes.subarray(12, 28)); return Buffer.concat([decipher.update(bytes.subarray(28)), decipher.final()]).toString("utf8"); } +function parse(encrypted: string): { kid: string | null; payload: string } { + if (encrypted.startsWith(PREFIX)) { + const rest = encrypted.slice(PREFIX.length); + const dot = rest.indexOf("."); + if (dot > 0) return { kid: rest.slice(0, dot), payload: rest.slice(dot + 1) }; + } + return { kid: null, payload: encrypted }; +} + +export function encryptCheckInToken(token: string): string { + return encryptWith(currentKey(), token); +} +export function createCheckInSecret() { + const token = `ttc_${randomBytes(32).toString("hex")}`; + return { token_hash: checkInTokenHash(token), token_encrypted: encryptCheckInToken(token) }; +} +export function decryptCheckInSecret(encrypted: string): string { + const { kid, payload } = parse(encrypted); + const keys = decryptionKeys(); + const ordered = kid ? [...keys.filter((k) => k.id === kid), ...keys.filter((k) => k.id !== kid)] : keys; + for (const k of ordered) { + try { return decryptWith(k, payload); } catch { /* wrong key or tampered: try next */ } + } + throw new Error("Check-in secret could not be decrypted with the configured keys"); +} +/** True when the value is already encrypted with the current key (versioned format). */ +export function isEncryptedWithCurrentKey(encrypted: string): boolean { + return parse(encrypted).kid === currentKey().id; +} export function redactCheckInSecrets(text: string): string { return text.replace(/ttc_[a-zA-Z0-9_%.-]+/g, "[redacted]") .replace(/(\/checkin\/)[^\s/?#"<>]+/gi, "$1[redacted]"); diff --git a/apps/api/src/routes/contact.ts b/apps/api/src/routes/contact.ts index 4d7fe505..b457eb6a 100644 --- a/apps/api/src/routes/contact.ts +++ b/apps/api/src/routes/contact.ts @@ -80,7 +80,7 @@ function contactDeliveryError(result: { status?: number; error?: string }): stri resendMessage.includes("resend.dev") ) { return ( - `Email delivery is blocked by Resend: verify tacko.io (or your sending domain) in the Resend dashboard ` + + `Email delivery is blocked by Resend: verify telemetry-tracker.com (or your sending domain) in the Resend dashboard ` + `and set TELEMETRY_EMAIL_FROM to an address on that domain (e.g. Telemetry ). ` + `Until then, email ${CONTACT_INBOX} directly.` ); diff --git a/apps/dashboard/app/docs/node/page.tsx b/apps/dashboard/app/docs/node/page.tsx index 37f69d9c..c8ae7f5e 100644 --- a/apps/dashboard/app/docs/node/page.tsx +++ b/apps/dashboard/app/docs/node/page.tsx @@ -3,6 +3,8 @@ import Link from "next/link"; import { CodeBlock } from "@/app/components/docs/CodeBlock"; import { DocsAlsoSee } from "@/app/components/docs/DocsAlsoSee"; import { DocsArticle } from "@/app/components/docs/DocsArticle"; +import { HOSTED_API_URL } from "@/lib/hosted-cloud"; +import { nodeCommonJs, nodeExpressErrorHandler, nodeSetup } from "@/lib/sdk-setup-snippets"; export const metadata: Metadata = { title: "Node.js", @@ -35,19 +37,23 @@ npm install @telemetry-tracker/node`}

Call init() as early as possible (e.g. at the top of your entry file):

- +

+ ingestUrl is required: the SDK has no built-in default. The fallback above is + the hosted API ({HOSTED_API_URL}); set TELEMETRY_INGEST_URL to + your own API when self-hosting. +

-trackEvent("server_started", { version: "1.0.0" }); -trackError(new Error("DB connection failed"), { db: "primary" });`} - /> +

ES modules only

+

+ @telemetry-tracker/node is published as an ES module only: its package.json sets{" "} + {`"type": "module"`} and exports only an import condition. Use{" "} + import in .mjs files or packages with{" "} + {`"type": "module"`}. {`require("@telemetry-tracker/node")`} is not + supported. From CommonJS code, load it with a dynamic import() and await{" "} + flush() inside an async function: +

+

Short-lived scripts and jobs

@@ -110,6 +116,14 @@ app.use((req, res, next) => { .

+

Express error handler

+

+ Errors that Express catches in a route never reach the global handlers. Report them from an + error-handling middleware (four arguments, registered after your routes) with{" "} + trackError(), then pass the error on: +

+ + Minimal setup

Call init at the top of your entry file. Keep the API key in an environment variable:

+

+ ingestUrl is required: the SDK has no built-in default. The fallback above is + the hosted API ({HOSTED_API_URL}); point TELEMETRY_INGEST_URL at + your own API when self-hosting. The package is ES modules only; see the{" "} + Node.js docs for use from CommonJS. +

Send a test error

@@ -127,6 +134,13 @@ export default function NodeJsErrorTrackingPage() {

+

Express error handler

+

+ Errors Express catches in a route do not reach the global handlers. Report them from an + error-handling middleware registered after your routes: +

+ +

What you will see

Server errors group like browser issues: fingerprint, stack, and the context object you diff --git a/apps/dashboard/lib/sdk-setup-snippets.ts b/apps/dashboard/lib/sdk-setup-snippets.ts index d60f3e4d..fe37ccb5 100644 --- a/apps/dashboard/lib/sdk-setup-snippets.ts +++ b/apps/dashboard/lib/sdk-setup-snippets.ts @@ -172,6 +172,36 @@ app.use((req, res, next) => { }); });`; +export const nodeExpressErrorHandler = `import express from "express"; +import { init, middleware, trackError } from "@telemetry-tracker/node"; + +init({ + ingestUrl: process.env.TELEMETRY_INGEST_URL || "${HOSTED_API_URL}", + app: "my-api", + apiKey: process.env.TELEMETRY_API_KEY, +}); + +const app = express(); +app.use(middleware()); + +// ... your routes ... + +// Error-handling middleware: four arguments, registered after all routes. +app.use((err, req, res, next) => { + trackError(err, { method: req.method, url: req.originalUrl }); + next(err); // keep Express's default error response (or send your own) +});`; + +export const nodeCommonJs = `// CommonJS file (.cjs, or "type" is not "module"): load the ESM package with import() +async function main() { + const { init, trackEvent, flush } = await import("@telemetry-tracker/node"); + init({ ingestUrl: process.env.TELEMETRY_INGEST_URL || "${HOSTED_API_URL}", app: "my-script", apiKey: process.env.TELEMETRY_API_KEY }); + trackEvent("script_finished"); + await flush(); +} + +main();`; + export const nodeTestError = `import { trackError } from "@telemetry-tracker/node"; trackError(new Error("Test error from Node.js"), { route: "/health" });`; diff --git a/docs/BILLING.md b/docs/BILLING.md index 7ca875bb..77c23ee9 100644 --- a/docs/BILLING.md +++ b/docs/BILLING.md @@ -51,10 +51,10 @@ Use this checklist for **telemetry-tracker.com** on Railway (API service only #### 2. Verify sending domain -The hosted cloud sends from **tacko.io** (operator domain). `telemetry-tracker.com` is the app URL, not the mail-from domain. +The hosted cloud sends from **telemetry-tracker.com** (`info@telemetry-tracker.com`), verified in Resend (region `eu-west-1`). -1. **Domains** → **Add domain** → `tacko.io`. -2. Add the DNS records Resend shows (typically **SPF**, **DKIM**, and optionally **DMARC**) at your DNS host for `tacko.io`. +1. **Domains** → **Add domain** → `telemetry-tracker.com` (region `eu-west-1`). +2. Add the DNS records Resend shows (typically **SPF**, **DKIM**, and optionally **DMARC**) at your DNS host for `telemetry-tracker.com`. 3. Wait until Resend shows the domain as **Verified**. Recommended sender: @@ -135,7 +135,7 @@ Test with Stripe CLI or Dashboard **Send test webhook** after deploy. ## Production checklist -- [ ] Resend domain verified (`tacko.io` or your sending domain) — see [Resend production setup](./BILLING.md#production-setup-hosted-cloud) +- [ ] Resend domain verified (`telemetry-tracker.com` or your sending domain) — see [Resend production setup](./BILLING.md#production-setup-hosted-cloud) - [ ] `RESEND_API_KEY` and `TELEMETRY_EMAIL_FROM` set on Railway API; `/health` shows `"email":"configured"` - [ ] Test invite, password reset, and contact form delivery - [ ] Stripe webhook URL reachable from the internet diff --git a/docs/CHECK-INS.md b/docs/CHECK-INS.md index 9b110a33..8ad2a75d 100644 --- a/docs/CHECK-INS.md +++ b/docs/CHECK-INS.md @@ -165,6 +165,26 @@ viewable URL; SHA-256 of the 256-bit random token provides indexed authenticatio Losing/changing this encryption key makes existing URLs unreadable in the dashboard; rotate affected monitor URLs afterwards. Do not reuse an ingest key or a public key. +### Rotating CHECKIN_ENCRYPTION_KEY without data loss + +Pings authenticate by SHA-256 hash, so they keep working throughout. The key only +protects the copyable URL shown in the dashboard. New encryption always uses +`CHECKIN_ENCRYPTION_KEY`; `CHECKIN_ENCRYPTION_KEY_PREVIOUS` (comma-separated, optional) +is used only to decrypt older values. Never paste key values into tickets or logs. + +1. Deploy a release that contains key-rotation support (this change). +2. Generate a new key (`openssl rand -hex 32`). On the API service **and on any job + service that shares API code**, set `CHECKIN_ENCRYPTION_KEY_PREVIOUS` to the old key and + `CHECKIN_ENCRYPTION_KEY` to the new key. Deploy. +3. Dry run (default, counts only): `node dist/jobs/run-checkin-reencrypt.js` +4. Apply: `node dist/jobs/run-checkin-reencrypt.js --apply` (batched, idempotent, safe to re-run). +5. Verify: re-run the dry run; expect `reencrypted: 0`, `failed: 0`, `alreadyCurrent` equal to + `scanned`. Open a monitor in the dashboard and confirm its URL is shown. +6. Remove `CHECKIN_ENCRYPTION_KEY_PREVIOUS` and deploy. Then retire the old key. + +If the script reports `failed > 0`, keep `CHECKIN_ENCRYPTION_KEY_PREVIOUS` set and investigate; +those rows cannot be decrypted with any configured key (rotate those monitor URLs instead). + Set dashboard **CHECKIN_PUBLIC_BASE_URL** to the publicly reachable API origin if `API_URL` is an internal address. Otherwise `API_URL` is used for copied URLs.