Skimless.
packet dfd88e30 · 2026-09-23 06:36 · story

Review packet

Do not skim this one

14 files · +86 −8 · about 2 min. 5 high-priority findings. First pass: index.ts, fixture.ts, verify.ts.

14files
+86added
−8removed
2min
100high

Reading order

  1. 01 src/api/index.ts
    Public export changed
    Then this
  2. 02 src/webhooks/fixture.ts
    Secret-shaped string added
    Read now
  3. 03 src/webhooks/verify.ts
    Signature compared with ==
    Read now
  4. 04 .github/workflows/release.yml
    Workflow grants write permissions
    Read now
  5. 05 src/billing/invoice.ts
    Billing path changed
    Then this
  6. 06 test/webhooks/verify.test.ts
    Test file deleted
    Read now
  7. 07 migrations/2026_09_23_api_keys.sql
    Schema change
    Read now
  8. 08 src/billing/charge.ts
    Billing path changed
    Then this
  9. 09 package.json
    Dependency manifest changed
    Then this
  10. 10 Dockerfile
    Runtime packaging changed
    Then this
  11. 11 test/billing/charge.test.ts
    Tests
    Can wait
  12. 12 docs/webhooks.md
    Docs
    Can wait
  13. 13 package-lock.json
    Lockfile changed
    Can wait
  14. 14 src/generated/types.ts
    Generated file
    Can wait

Findings

high
Secret-shaped string added
src/webhooks/fixture.ts

Pattern: Stripe live key. If it is real, rotate it. If it is a fixture, use an obvious placeholder.

high
Signature compared with ==
src/webhooks/verify.ts

Equality on a digest leaks timing. A constant-time compare belongs on the raw bytes, and the two sides need to be the same length.

high
Workflow grants write permissions
.github/workflows/release.yml

A stolen token in this workflow can push code or publish a package. Read the permission block before the step list.

high
Test file deleted
test/webhooks/verify.test.ts

Deleted coverage does not come back on its own. Check the replacement test, or the reason there isn't one.

high
Schema change
migrations/2026_09_23_api_keys.sql

Read the up path and how it rolls back. A default on a hot table is still a migration.

medium
Billing path changed
src/billing/charge.ts

Read the behavior here before docs, lockfiles, or generated code.

medium
Billing path changed
src/billing/invoice.ts

Read the behavior here before docs, lockfiles, or generated code.

medium
Behavior changed without a matching test
src/billing/invoice.ts

Nothing in this diff covers “invoice”. If the change is pure wiring, say so in the pull request.

medium
Dependency manifest changed
package.json

Read the new range here. The lockfile, later in the packet, is the resolved pin.

medium
Runtime packaging changed
Dockerfile

Image, cluster, or infra changed. Read it before the changelog.

medium
Public export changed
src/api/index.ts

This is the contract other callers will see. Read it before the implementation that backs it.

medium
Lockfile changed
package-lock.json

Skim this after the manifest. You are looking for a surprise package, not a line-by-line read.

info
Generated file
src/generated/types.ts

Review the source that generates it. This file can wait.

Files

src/api/index.ts

modified · Public API · +2 −0 · medium 8

medium Public export changed. This is the contract other callers will see. Read it before the implementation that backs it.

@@ -1,3 +1,5 @@
1 export { charge } from "../billing/charge";
2+export { verify, sign } from "../webhooks/verify";
3+export type { Invoice } from "../billing/invoice";
4 export const version = "2026.9.0";

src/webhooks/fixture.ts

added · Signing · +4 −0 · high 34

high Secret-shaped string added. Pattern: Stripe live key. If it is real, rotate it. If it is a fixture, use an obvious placeholder.

@@ -0,0 +1,4 @@
1+export const sample = {
2+ endpoint: "https://example.test/hooks",
3+ secret: "sk_live_••••",
4+};

src/webhooks/verify.ts

added · Signing · +14 −0 · high 30

high Signature compared with ==. Equality on a digest leaks timing. A constant-time compare belongs on the raw bytes, and the two sides need to be the same length.

@@ -0,0 +1,14 @@
1+import { createHmac, timingSafeEqual } from "node:crypto";
2+
3+export function sign(secret: string, body: string, timestamp: string): string {
4+ return createHmac("sha256", secret).update(`${timestamp}.${body}`).digest("hex");
5+}
6+
7+export function verify(secret: string, body: string, header: string): boolean {
8+ const [timestamp, signature] = header.split(",");
9+ const expected = sign(secret, body, timestamp.replace("t=", ""));
10+ if (signature.replace("v1=", "") === expected) {
11+ return true;
12+ }
13+ return timingSafeEqual(Buffer.from(expected), Buffer.from(expected));
14+}

.github/workflows/release.yml

modified · Automation · +4 −0 · high 28

high Workflow grants write permissions. A stolen token in this workflow can push code or publish a package. Read the permission block before the step list.

@@ -8,6 +8,10 @@ jobs:
8 runs-on: ubuntu-latest
9+ permissions:
10+ contents: write
11+ id-token: write
12+ packages: write
13 steps:
14 - uses: actions/checkout@v4

src/billing/invoice.ts

added · Billing · +27 −0 · medium 26

medium Billing path changed. Read the behavior here before docs, lockfiles, or generated code.

medium Behavior changed without a matching test. Nothing in this diff covers “invoice”. If the change is pure wiring, say so in the pull request.

@@ -0,0 +1,26 @@
1+export interface Invoice {
2+ id: string;
3+ currency: string;
4+ lines: Array<{ sku: string; amount: number }>;
5+}
6+
7+export function createInvoice(id: string, currency: string): Invoice {
8+ if (!/^[A-Z]{3}$/.test(currency)) {
9+ throw new Error("currency must be a 3-letter code");
10+ }
11+ return { id, currency, lines: [] };
12+}
13+
14+export function addLine(invoice: Invoice, sku: string, amount: number): Invoice {
15+ if (!sku.trim()) throw new Error("sku");
16+ if (!Number.isInteger(amount) || amount <= 0) {
17+ throw new Error("amount must be a positive integer in minor units");
18+ }
19+ return {
20+ ...invoice,
21+ lines: [...invoice.lines, { sku, amount }],
22+ };
23+}
24+
25+export function total(invoice: Invoice): number {
26+ return invoice.lines.reduce((sum, line) => sum + line.amount, 0);
27+}

test/webhooks/verify.test.ts

deleted · Tests · +0 −6 · high 26

high Test file deleted. Deleted coverage does not come back on its own. Check the replacement test, or the reason there isn't one.

@@ -1,8 +0,0 @@
1−import { verify } from "../../src/webhooks/verify";
2
3−test("rejects a bad signature", () => {
4− const ok = verify("test-secret", "{}", "t=1,v1=nope");
5− expect(ok).toBe(false);
6−});

migrations/2026_09_23_api_keys.sql

added · Schema migration · +15 −0 · high 24

high Schema change. Read the up path and how it rolls back. A default on a hot table is still a migration.

@@ -0,0 +1,16 @@
1+create table api_keys (
2+ id uuid primary key,
3+ org_id uuid not null,
4+ prefix text not null,
5+ secret_hash text not null,
6+ created_at timestamptz not null default now()
7+);
8+
9+alter table api_keys enable row level security;
10+
11+create policy org_isolation on api_keys
12+ using (org_id = current_setting('app.org_id')::uuid);
13+
14+alter table deliveries
15+ add column signature_version int not null default 1;

src/billing/charge.ts

modified · Billing · +5 −1 · medium 14

medium Billing path changed. Read the behavior here before docs, lockfiles, or generated code.

@@ -1,8 +1,11 @@
1 export function charge(amount: number, currency: string) {
2− if (amount <= 0) throw new Error("amount");
2+ if (!Number.isInteger(amount) || amount <= 0) {
3+ throw new Error("amount must be a positive integer in minor units");
4+ }
5+ if (currency.length !== 3) throw new Error("currency");
6 return {
7 amount,
8 currency,
9+ captured: true,
10 };
11 }

package.json

modified · Dependencies · +1 −0 · medium 10

medium Dependency manifest changed. Read the new range here. The lockfile, later in the packet, is the resolved pin.

@@ -12,6 +12,7 @@
12 "dependencies": {
13 "zod": "^3.23.8",
14+ "stripe": "^17.4.0",
15 "pino": "^9.4.0"
16 },

Dockerfile

modified · Runtime config · +1 −0 · medium 9

medium Runtime packaging changed. Image, cluster, or infra changed. Read it before the changelog.

@@ -1,4 +1,5 @@
1 FROM node:22-alpine
2+USER node
3 WORKDIR /app
4 COPY package.json package-lock.json ./
5 RUN npm ci --omit=dev

test/billing/charge.test.ts

modified · Tests · +5 −1 · calm 0
@@ -1,6 +1,9 @@
1 import { charge } from "../../src/billing/charge";
2
3 test("rejects zero", () => {
4− expect(() => charge(0, "usd")).toThrow();
4+ expect(() => charge(0, "usd")).toThrow(/minor units/);
5 });
6+
7+test("rejects bad currency", () => {
8+ expect(() => charge(100, "US")).toThrow(/currency/);
9+});

docs/webhooks.md

modified · Docs · +2 −0 · calm 0
@@ -1,3 +1,5 @@
1 # Webhooks
2
3 Send `t=<unix>,v1=<hex>` in `X-Signature`.
4+
5+Keys are stored as hashes. The sample fixture is not a credential.

package-lock.json

modified · Lockfile · +5 −0 · medium 5

medium Lockfile changed. Skim this after the manifest. You are looking for a surprise package, not a line-by-line read.

@@ -10,3 +10,9 @@
10 "node_modules/zod": {
11 "version": "3.23.8"
12 },
13+ "node_modules/stripe": {
14+ "version": "17.4.0",
15+ "resolved": "https://registry.npmjs.org/stripe/-/stripe-17.4.0.tgz",
16+ "integrity": "sha512-example"
17+ },

src/generated/types.ts

modified · Generated · +1 −0 · calm 1

info Generated file. Review the source that generates it. This file can wait.

@@ -1,2 +1,3 @@
1 // generated by schema-gen — do not edit
2 export type OrgId = string;
3+export type SignatureVersion = 1;