voidmachine 5.0.8 → 7.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core-assets/.claude-plugin/plugin.json +13 -2
- package/core-assets/PHILOSOPHY.md +29 -6
- package/core-assets/data/catalog.v3.json +52 -132
- package/core-assets/data/certification.json +1 -1
- package/core-assets/data/model.json +51 -75
- package/core-assets/hooks/_syntax-worker.cjs +1 -1
- package/core-assets/hooks/_void-hook.mjs +730 -515
- package/core-assets/hooks/outcome-meter.sh +2 -1
- package/core-assets/hooks/tdd-guard.md +45 -88
- package/core-assets/hooks/viewport-screenshot-gate.sh +3 -3
- package/core-assets/packs/pack-monorepo/skills/void-dependency-direction/SKILL.md +27 -146
- package/core-assets/packs/pack-monorepo/skills/void-service-package/SKILL.md +5 -8
- package/core-assets/packs/pack-server/skills/void-webhook-handler-pattern/.source +4 -2
- package/core-assets/packs/pack-server/skills/void-webhook-handler-pattern/SKILL.md +32 -132
- package/core-assets/skills/void-accessibility/SKILL.md +2 -2
- package/core-assets/skills/void-async-safety/SKILL.md +37 -24
- package/core-assets/skills/void-autopilot/SKILL.md +7 -6
- package/core-assets/skills/void-brainstorm/SKILL.md +40 -61
- package/core-assets/skills/void-code-review/SKILL.md +24 -11
- package/core-assets/skills/void-commit-discipline/SKILL.md +13 -20
- package/core-assets/skills/void-debug/SKILL.md +9 -10
- package/core-assets/skills/void-domain-driven-design/SKILL.md +1 -1
- package/core-assets/skills/void-frontend-design/SKILL.md +3 -3
- package/core-assets/skills/void-hexagonal-architecture/SKILL.md +5 -5
- package/core-assets/skills/void-implement/SKILL.md +67 -26
- package/core-assets/skills/void-llm-cost-discipline/SKILL.md +3 -0
- package/core-assets/skills/void-observability/SKILL.md +4 -4
- package/core-assets/skills/void-orchestrate/SKILL.md +91 -221
- package/core-assets/skills/void-plan/SKILL.md +12 -17
- package/core-assets/skills/void-qa/SKILL.md +15 -14
- package/core-assets/skills/void-tdd/SKILL.md +33 -84
- package/core-assets/skills/void-testing/SKILL.md +5 -5
- package/core-assets/skills/void-ticket/SKILL.md +2 -0
- package/core-assets/skills/void-typescript-strict/SKILL.md +5 -5
- package/core-assets/skills/void-verify/SKILL.md +57 -159
- package/dist/main.js +1959 -1633
- package/package.json +3 -3
- package/core-assets/hooks/block-protected-push.sh +0 -81
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
#
|
|
2
|
+
# PostToolUse outcomes and repeated-read advice share this non-blocking process.
|
|
3
|
+
# PostToolUseFailure resets advice only; Stop retains its canonical event.
|
|
3
4
|
set -uo pipefail
|
|
4
5
|
|
|
5
6
|
ROOT="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
|
|
@@ -1,90 +1,47 @@
|
|
|
1
1
|
# `tdd-guard` hook
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
## Pure-deletion bypass
|
|
50
|
-
|
|
51
|
-
If the staged diff for the file contains only deletions (no `+` non-marker lines), the hook allows. Pure deletion does not require a new failing test.
|
|
52
|
-
|
|
53
|
-
## Override
|
|
54
|
-
|
|
55
|
-
- Per-file: add `// tdd-mode: exploratory` (or `strict` / `souple`) at the top of the file.
|
|
56
|
-
- Per-repo: set `.void/config.json` `modes.tdd` to `strict` / `souple` / `exploratory`.
|
|
57
|
-
- Per-session: set env `VOIDCORP_MODE_TDD=souple` (not yet wired — open question).
|
|
58
|
-
|
|
59
|
-
## Inputs (env vars set by the runner)
|
|
60
|
-
|
|
61
|
-
- `VOIDCORP_HOOK_FILE` — absolute path of the file
|
|
62
|
-
- `VOIDCORP_HOOK_TOOL` — `Edit` / `Write`
|
|
63
|
-
- `VOIDCORP_HOOK_PHASE` — `pre` / `post` (this hook only runs on `pre`)
|
|
64
|
-
- `VOIDCORP_HOOK_DIFF` — optional, the staged diff content
|
|
65
|
-
- `VOIDCORP_CONFIG` — optional, defaults to `.void/config.json`
|
|
66
|
-
|
|
67
|
-
## Exit codes
|
|
68
|
-
|
|
69
|
-
- `0` — allow
|
|
70
|
-
- `1` — block (message on stderr)
|
|
71
|
-
- `2` — warn (allow, message on stderr)
|
|
72
|
-
|
|
73
|
-
## Test fixtures (for `test/tdd-guard/`)
|
|
74
|
-
|
|
75
|
-
The skill test suite must cover at least:
|
|
76
|
-
|
|
77
|
-
- `strict` mode + production edit without sibling test → block
|
|
78
|
-
- `strict` mode + production edit WITH sibling test in same staged set → allow
|
|
79
|
-
- `souple` mode + production edit without sibling test → warn
|
|
80
|
-
- `exploratory` mode + any edit → allow
|
|
81
|
-
- Each of the 10 bypasses → allow
|
|
82
|
-
- Pure deletion → allow
|
|
83
|
-
- Override marker `// tdd-mode: exploratory` in file → allow
|
|
84
|
-
|
|
85
|
-
## Open questions
|
|
86
|
-
|
|
87
|
-
- Per-session env override (`VOIDCORP_MODE_TDD`) — wire it up in the hook or rely on `.void/config.json` only? Lean wire (faster iteration during work).
|
|
88
|
-
- Glob matching for `paths.business` — current `case` statement is shell glob, fine for simple patterns but not full minimatch. Defer until a consumer needs `**` patterns more complex than `apps/*/src/**`.
|
|
89
|
-
- Auto-detection of the test runner (vitest vs jest) for the sibling-test naming convention. Currently assumes `.test.ts(x)`. Make configurable in Phase D.
|
|
90
|
-
- Performance — `git diff --cached --name-only` runs every edit. Cache for the duration of a session? Defer; measure first.
|
|
3
|
+
The native PreToolUse manifests invoke the shared Node `tdd-order` rule for
|
|
4
|
+
supported structured writes. `tdd-guard.sh` is its compatibility adapter.
|
|
5
|
+
`packages/hook-runner/src/rules/tdd-order.ts` owns the structural rule and
|
|
6
|
+
`enforcement/runner.ts` resolves the proposed file and evidence.
|
|
7
|
+
|
|
8
|
+
This floor checks that a governed production file has an existing sibling test,
|
|
9
|
+
or a valid first-line `// tdd-cover: e2e <project-relative spec>` declaration pointing
|
|
10
|
+
to an existing test inside the project. It does **not** run that test, require a
|
|
11
|
+
staged test modification, assess behavior coverage, or prove an observed RED.
|
|
12
|
+
`void-tdd` and `void-debug` own the observed RED-before-fix/GREEN evidence.
|
|
13
|
+
|
|
14
|
+
## Configuration and scope
|
|
15
|
+
|
|
16
|
+
The rule reads `.void/config.json`: `modes.tdd`, `paths.business` and `paths.spikes`.
|
|
17
|
+
The default business glob is `apps/*/src/**`; the default spike glob is
|
|
18
|
+
`apps/*/scripts/spike-*`. The first five existing lines may declare
|
|
19
|
+
`// tdd-mode: strict`, `souple` or `exploratory`.
|
|
20
|
+
|
|
21
|
+
- `auto` and `strict` refuse a governed write with no test evidence.
|
|
22
|
+
- `souple` allows it with a warning.
|
|
23
|
+
- `exploratory` allows it.
|
|
24
|
+
|
|
25
|
+
Only the supported JS/TS production paths inside business globs are governed.
|
|
26
|
+
The owning rule excludes test/declaration files, docs, fixtures, seeds, migrations,
|
|
27
|
+
codemods, generated paths and configured spikes. This is a path floor, not a
|
|
28
|
+
semantic classifier of all configuration or generated code.
|
|
29
|
+
|
|
30
|
+
A complete file deletion is exempt. A deletion-only edit to a surviving file is
|
|
31
|
+
still checked. A module containing only re-exports is exempt only when both the
|
|
32
|
+
original and complete proposed source meet that condition. Adding behavior removes
|
|
33
|
+
the exemption. Unresolvable evidence on a governed file is refused, not guessed.
|
|
34
|
+
|
|
35
|
+
## Runtime results
|
|
36
|
+
|
|
37
|
+
The rule returns a verdict. Runtime adapters translate a refusal into Claude's
|
|
38
|
+
blocking exit 2 or Codex's native deny response; warning and allow are successful
|
|
39
|
+
responses. There are no `VOIDCORP_HOOK_FILE`, staged-diff or per-session TDD env
|
|
40
|
+
inputs to this native contract. Configured does not mean invoked: the runtime must
|
|
41
|
+
load and trust its hooks. Shell writes do not provide this test-content guarantee.
|
|
42
|
+
|
|
43
|
+
## Proof
|
|
44
|
+
|
|
45
|
+
The rule and enforcement tests cover sibling/declaration existence, modes,
|
|
46
|
+
re-export detection, path escapes, deletion-only edits and unsupported evidence.
|
|
47
|
+
Behavioral RED/GREEN for the changed application remains a separate required proof.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# viewport-screenshot-gate —
|
|
2
|
+
# viewport-screenshot-gate — optional PR-text advisory (not installed in native manifests)
|
|
3
3
|
#
|
|
4
|
-
#
|
|
4
|
+
# Reports if PR text for UI changes lacks mobile and desktop capture references
|
|
5
5
|
# in the PR body. Composed with the `accessibility` and
|
|
6
6
|
# `frontend-design` skills (mobile-first dual-quality invariant).
|
|
7
7
|
#
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
# "no-ui-change: <reason>" in the body for non-UI PRs that happen to
|
|
14
14
|
# touch tsx files.
|
|
15
15
|
#
|
|
16
|
-
# Exit
|
|
16
|
+
# No image validation. Missing PR/base is skipped. Exit 0 no finding, 2 advisory finding.
|
|
17
17
|
|
|
18
18
|
set -euo pipefail
|
|
19
19
|
|
|
@@ -1,160 +1,41 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: void-dependency-direction
|
|
3
|
-
description:
|
|
3
|
+
description: Declare cross-package dependencies explicitly and preserve domain-owned ports. Use when crossing a workspace package boundary or fixing boundary-direction-check.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# dependency-direction
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
The package's `package.json` owns its permitted package dependencies. The project's
|
|
9
|
+
architecture owns which dependencies make sense. A universal star topology through
|
|
10
|
+
`@repo/core` is not imposed by the harness.
|
|
9
11
|
|
|
10
|
-
##
|
|
12
|
+
## Before adding an import
|
|
11
13
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
14
|
+
1. Read the consuming package's manifest and project architecture.
|
|
15
|
+
2. Keep domain code independent of concrete infrastructure; compose adapters through
|
|
16
|
+
domain-owned ports, per `void-hexagonal-architecture`.
|
|
17
|
+
3. Declare an accepted dependency in the appropriate manifest section. Types create
|
|
18
|
+
dependencies too. Import the package's public API, not a sibling's private files.
|
|
19
|
+
4. If the dependency would violate an accepted project boundary, reshape the port
|
|
20
|
+
and compose at the application edge. A structural exception follows `void-decide`.
|
|
21
|
+
5. Run the affected package's typecheck and behavior tests.
|
|
20
22
|
|
|
21
|
-
|
|
23
|
+
## Mechanical scope
|
|
22
24
|
|
|
23
|
-
|
|
25
|
+
The shared Node `boundary-direction-check` rule rejects detected scoped imports
|
|
26
|
+
that the nearest readable package manifest does not declare. It accepts self-imports
|
|
27
|
+
and dependencies, devDependencies, peerDependencies and optionalDependencies.
|
|
28
|
+
It skips tests/generated files and allows when no manifest can be read. Its lexical
|
|
29
|
+
matcher is a guardrail, not a complete import graph or internal-layer validator.
|
|
30
|
+
An `allow-boundary:` comment is a narrow escape recognized by the existing rule;
|
|
31
|
+
it does not approve an architectural exception or make a missing dependency buildable.
|
|
24
32
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
- `logger` (pino)
|
|
30
|
-
- `env` (Zod-validated env)
|
|
31
|
-
- `errors` (typed error classes)
|
|
32
|
-
- `Result`, `Option`, `pipe` (functional utilities)
|
|
33
|
-
|
|
34
|
-
These are leaf utilities. Everything else is composition.
|
|
35
|
-
|
|
36
|
-
## Common violation: `@repo/billing` wants the user
|
|
37
|
-
|
|
38
|
-
**Wrong**:
|
|
39
|
-
```ts
|
|
40
|
-
// packages/billing/src/billing.service.ts
|
|
41
|
-
import { db } from '@repo/db'; // ✗
|
|
42
|
-
import { getCurrentUser } from '@repo/auth'; // ✗
|
|
43
|
-
|
|
44
|
-
export async function cancelSubscription() {
|
|
45
|
-
const user = await getCurrentUser();
|
|
46
|
-
await db.update(subscriptions)...
|
|
47
|
-
}
|
|
48
|
-
```
|
|
49
|
-
|
|
50
|
-
**Right** — define the port in `@repo/billing`, wire the adapter in the app:
|
|
51
|
-
|
|
52
|
-
```ts
|
|
53
|
-
// packages/billing/src/billing.types.ts (port)
|
|
54
|
-
export interface BillingPorts {
|
|
55
|
-
readonly userRepo: { findById(id: string): Promise<User | null> };
|
|
56
|
-
readonly subscriptionRepo: { update(id: string, p: Partial<Sub>): Promise<void> };
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// packages/billing/src/billing.service.ts (pure)
|
|
60
|
-
export function makeBillingService(ports: BillingPorts) {
|
|
61
|
-
return {
|
|
62
|
-
async cancelSubscription(userId: string) {
|
|
63
|
-
const user = await ports.userRepo.findById(userId);
|
|
64
|
-
// ... domain logic only
|
|
65
|
-
},
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// apps/web/src/adapters/billing.ts (composition)
|
|
70
|
-
import { makeBillingService } from '@repo/billing';
|
|
71
|
-
import { db } from '@repo/db';
|
|
72
|
-
import { userRepo, subRepo } from '@/repositories';
|
|
73
|
-
export const billing = makeBillingService({ userRepo, subscriptionRepo: subRepo });
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
The app is the integration point. Packages are pure capabilities.
|
|
77
|
-
|
|
78
|
-
## Common violation: types from another package
|
|
79
|
-
|
|
80
|
-
**Wrong**:
|
|
81
|
-
```ts
|
|
82
|
-
// packages/billing/src/billing.types.ts
|
|
83
|
-
import type { User } from '@repo/auth'; // ✗ types still create coupling
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
**Right** — declare your own User type narrowed to what billing needs:
|
|
87
|
-
|
|
88
|
-
```ts
|
|
89
|
-
// packages/billing/src/billing.types.ts
|
|
90
|
-
export interface BillingUser {
|
|
91
|
-
readonly id: string;
|
|
92
|
-
readonly email: string;
|
|
93
|
-
readonly orgId: string;
|
|
94
|
-
}
|
|
95
|
-
```
|
|
96
|
-
|
|
97
|
-
If `@repo/auth`'s `User` and `@repo/billing`'s `BillingUser` should be the same shape, factor the shape into `@repo/core/types`. But almost always, each package wants a narrower projection — that's good.
|
|
98
|
-
|
|
99
|
-
## Common violation: `@repo/ui` calls a service
|
|
100
|
-
|
|
101
|
-
**Wrong**:
|
|
102
|
-
```tsx
|
|
103
|
-
// packages/ui/src/UserAvatar.tsx
|
|
104
|
-
import { getCurrentUser } from '@repo/auth'; // ✗
|
|
105
|
-
export function UserAvatar() {
|
|
106
|
-
const user = use(getCurrentUser()); // ✗ side effect in UI
|
|
107
|
-
return <Avatar src={user.avatarUrl} />;
|
|
108
|
-
}
|
|
109
|
-
```
|
|
110
|
-
|
|
111
|
-
**Right**:
|
|
112
|
-
```tsx
|
|
113
|
-
// packages/ui/src/Avatar.tsx (dumb prop-driven)
|
|
114
|
-
export function Avatar({ src, fallback }: { src: string; fallback: string }) {
|
|
115
|
-
return <img src={src} alt={fallback} />;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// apps/web/src/components/CurrentUserAvatar.tsx (binding, NOT in @repo/ui)
|
|
119
|
-
import { Avatar } from '@repo/ui';
|
|
120
|
-
import { useCurrentUser } from '@/hooks';
|
|
121
|
-
export function CurrentUserAvatar() {
|
|
122
|
-
const user = useCurrentUser();
|
|
123
|
-
return <Avatar src={user.avatarUrl} fallback={user.name} />;
|
|
124
|
-
}
|
|
125
|
-
```
|
|
126
|
-
|
|
127
|
-
`@repo/ui` exports primitives with no I/O. Bindings to actual data live in apps.
|
|
128
|
-
|
|
129
|
-
## The `apps/<app>` → `apps/<other-app>` rule
|
|
130
|
-
|
|
131
|
-
**Never** import from another app:
|
|
132
|
-
|
|
133
|
-
```ts
|
|
134
|
-
// apps/web/src/foo.ts
|
|
135
|
-
import { whatever } from '../../mobile/src/...'; // ✗ — even via @repo/* alias if you set it up
|
|
136
|
-
```
|
|
137
|
-
|
|
138
|
-
If two apps need the same code, that code goes in a `@repo/*` package. There is no "shared/" directory between apps.
|
|
139
|
-
|
|
140
|
-
## When you genuinely need cross-package access
|
|
141
|
-
|
|
142
|
-
You don't. Re-read the section on ports. If after that you still think you do, write an ADR (`void-decide`) documenting the exception with the reversal cost. 90% of "I need this" turns out to be "I forgot to define the port".
|
|
143
|
-
|
|
144
|
-
## Mechanical enforcement
|
|
145
|
-
|
|
146
|
-
The `boundary-direction-check` hook (core) blocks Edit/Write that introduces a forbidden import. Override by tagging the import line `// allow-boundary: <reason>` — but think hard first, because each exception is a small future grind.
|
|
147
|
-
|
|
148
|
-
## Workflow
|
|
149
|
-
|
|
150
|
-
1. **Before adding the import**, ask: does this cross a `@repo/*` boundary?
|
|
151
|
-
2. **If yes**, ask: is the destination `@repo/core`? OK. Anything else? Stop.
|
|
152
|
-
3. **Define a port** in your package, wire the adapter in the app.
|
|
153
|
-
4. **If the hook complains**, do not tag-and-move-on. Refactor.
|
|
33
|
+
A `tsconfig` path alias changes resolution, not permission. It cannot prove a
|
|
34
|
+
package boundary. Project-specific cycle or layer checks must be explicitly wired
|
|
35
|
+
and verified before claiming deterministic enforcement.
|
|
154
36
|
|
|
155
37
|
## Composition
|
|
156
38
|
|
|
157
|
-
- `void-hexagonal-architecture`
|
|
158
|
-
- `
|
|
159
|
-
- `void-package-extraction`
|
|
160
|
-
- `void-service-package` — the 5+5 layout includes a `<name>.types.ts` precisely for owning your own types.
|
|
39
|
+
- `void-hexagonal-architecture` owns ports and dependency direction inside a domain.
|
|
40
|
+
- `void-service-package` applies the project package layout.
|
|
41
|
+
- `void-package-extraction` decides whether a shared package is warranted.
|
|
@@ -52,15 +52,12 @@ service ─→ repository (port) service ─→ helper service ─→ types
|
|
|
52
52
|
|
|
53
53
|
If your package needs `@repo/db` directly: stop. Move the DB-touching code into an adapter in the consuming app. The package owns the **port**, not the adapter.
|
|
54
54
|
|
|
55
|
-
## Allowed imports
|
|
55
|
+
## Allowed imports
|
|
56
56
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
If you find yourself needing to import another `@repo/*`, you are blurring layers. Two valid fixes:
|
|
62
|
-
- Move the shared concept into `@repo/core`
|
|
63
|
-
- Reshape the boundary so the consumer composes the two packages explicitly
|
|
57
|
+
Follow `void-dependency-direction`: the package manifest declares dependencies;
|
|
58
|
+
the project's architecture decides whether they belong. This service convention
|
|
59
|
+
prefers domain-owned ports and app composition. It does not claim the shared hook
|
|
60
|
+
forbids every dependency except `@repo/core`.
|
|
64
61
|
|
|
65
62
|
## `package.json` template
|
|
66
63
|
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
Sources for `void-webhook-handler-pattern` skill (see `docs/plans/skill-audits/void-webhook-handler-pattern.md` for the full audit):
|
|
2
2
|
|
|
3
|
-
-
|
|
4
|
-
-
|
|
3
|
+
- Official provider contracts checked for the 2026-10-30 coherence correction:
|
|
4
|
+
- Stripe: https://docs.stripe.com/webhooks#preventing-replay-attacks
|
|
5
|
+
- GitHub: https://docs.github.com/en/webhooks/using-webhooks/handling-failed-webhook-deliveries
|
|
6
|
+
- Provider code is not vendored; verifier and durable store remain consumer-owned.
|
|
5
7
|
- Doctrine referenced: `void-async-safety` (stack-agnostic retry/idempotency/dead-letter).
|
|
6
8
|
|
|
7
9
|
Strategy: distill. Per-source handler patterns distilled into the 5 non-negotiable layers; no upstream skill was copied.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: void-webhook-handler-pattern
|
|
3
|
-
description: Build a webhook handler with signature verification, idempotency, and dead-letter routing. Per-source patterns (Stripe, Resend, GitHub).
|
|
3
|
+
description: Build a webhook handler with signature verification, idempotency, and dead-letter routing. Per-source patterns (Stripe, Resend, GitHub). Uses the shared async-safety contract.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# webhook-handler-pattern
|
|
@@ -15,91 +15,22 @@ apps/<app>/src/app/api/webhooks/<source>/route.ts
|
|
|
15
15
|
|
|
16
16
|
One folder per source. Path stable (external systems POST to a fixed URL — never rename without coordinating).
|
|
17
17
|
|
|
18
|
-
##
|
|
18
|
+
## Handler contract
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
4. Service call — the business work (in apps/<app>/src/services/)
|
|
25
|
-
5. Acknowledgment — return 2xx on success; specific codes on failure
|
|
26
|
-
```
|
|
20
|
+
Compose `void-async-safety` for the canonical executable `withWebhookSafety` example
|
|
21
|
+
and its `IdempotencyStore` contract. Keep signature/freshness verification in the provider
|
|
22
|
+
SDK adapter, validate its decoded data at that boundary, and map the typed outcome to HTTP.
|
|
23
|
+
Do not copy a second verify/claim/handle/mark implementation into this skill.
|
|
27
24
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
// apps/web/src/app/api/webhooks/stripe/route.ts
|
|
34
|
-
import { NextRequest, NextResponse } from 'next/server';
|
|
35
|
-
import Stripe from 'stripe';
|
|
36
|
-
import { z } from 'zod';
|
|
37
|
-
import { db } from '@/adapters/db';
|
|
38
|
-
import { env, logger } from '@repo/core';
|
|
39
|
-
import * as Sentry from '@sentry/nextjs';
|
|
40
|
-
import { handleStripeEvent } from '@/services/billing/stripe';
|
|
41
|
-
|
|
42
|
-
const stripe = new Stripe(env.STRIPE_SECRET_KEY);
|
|
43
|
-
|
|
44
|
-
// 3. Zod schema for the parsed Stripe event (defense in depth — Stripe's
|
|
45
|
-
// own parser is trusted, but we re-shape to our service contract).
|
|
46
|
-
const EventSchema = z.object({
|
|
47
|
-
id: z.string(),
|
|
48
|
-
type: z.string(),
|
|
49
|
-
data: z.object({ object: z.unknown() }),
|
|
50
|
-
livemode: z.boolean(),
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
export async function POST(req: NextRequest) {
|
|
54
|
-
const sig = req.headers.get('stripe-signature');
|
|
55
|
-
const body = await req.text();
|
|
56
|
-
const idempotencyKey = `stripe:${req.headers.get('stripe-signature')?.slice(0, 16) ?? ''}`;
|
|
57
|
-
|
|
58
|
-
let stripeEvent: Stripe.Event;
|
|
59
|
-
try {
|
|
60
|
-
// 1. Signature verification (Stripe-specific)
|
|
61
|
-
if (!sig) {
|
|
62
|
-
return NextResponse.json({ error: 'missing signature' }, { status: 401 });
|
|
63
|
-
}
|
|
64
|
-
stripeEvent = stripe.webhooks.constructEvent(body, sig, env.STRIPE_WEBHOOK_SECRET);
|
|
65
|
-
} catch (err) {
|
|
66
|
-
logger.warn({ event: 'webhook.signature_invalid', source: 'stripe' });
|
|
67
|
-
return NextResponse.json({ error: 'invalid signature' }, { status: 401 });
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// 2. Idempotency — Stripe's event.id is globally unique
|
|
71
|
-
const inboxKey = `stripe:${stripeEvent.id}`;
|
|
72
|
-
const inserted = await db
|
|
73
|
-
.insert(webhookInbox)
|
|
74
|
-
.values({ key: inboxKey, source: 'stripe', eventType: stripeEvent.type, payload: stripeEvent })
|
|
75
|
-
.onConflictDoNothing()
|
|
76
|
-
.returning();
|
|
77
|
-
|
|
78
|
-
if (inserted.length === 0) {
|
|
79
|
-
// Already seen — return 2xx to stop Stripe retrying
|
|
80
|
-
logger.info({ event: 'webhook.duplicate', source: 'stripe', eventId: stripeEvent.id });
|
|
81
|
-
return NextResponse.json({ ok: true, duplicate: true });
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// 3. Re-validate with our schema
|
|
85
|
-
const event = EventSchema.parse(stripeEvent);
|
|
86
|
-
|
|
87
|
-
// 4. Service call — pure dispatch
|
|
88
|
-
logger.info({ event: 'webhook.received', source: 'stripe', type: event.type, eventId: event.id });
|
|
89
|
-
try {
|
|
90
|
-
await handleStripeEvent(event);
|
|
91
|
-
await db.update(webhookInbox).set({ committedAt: new Date() }).where(eq(webhookInbox.key, inboxKey));
|
|
92
|
-
// 5. Ack
|
|
93
|
-
return NextResponse.json({ ok: true });
|
|
94
|
-
} catch (err) {
|
|
95
|
-
Sentry.captureException(err, { tags: { webhook: 'stripe', eventType: event.type } });
|
|
96
|
-
// Retryable: return 5xx so Stripe retries with backoff
|
|
97
|
-
return NextResponse.json({ ok: false }, { status: 500 });
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
```
|
|
25
|
+
The handler's business effects and deduplication must share a transaction/outbox or use
|
|
26
|
+
the same stable idempotency key. A received or pending inbox row is **not** proof of a
|
|
27
|
+
completed event. Failed processing and failed completion persistence remain non-success
|
|
28
|
+
and retryable; only completed events may be acknowledged as duplicates. An in-flight
|
|
29
|
+
concurrent delivery must not report that unfinished processing as success.
|
|
101
30
|
|
|
102
|
-
|
|
31
|
+
A project may provide its own wrapper with this contract. The optional Next.js pack
|
|
32
|
+
exports the tested implementation; it does not supply the project's verifier or durable
|
|
33
|
+
store. No universal event-age limit is invented by the wrapper.
|
|
103
34
|
|
|
104
35
|
## Per-source notes
|
|
105
36
|
|
|
@@ -114,13 +45,13 @@ The 5 layers are explicit. No wrapper is required. If your project ships its own
|
|
|
114
45
|
|
|
115
46
|
- Signature: `svix-id`, `svix-timestamp`, `svix-signature` headers → `new Webhook(secret).verify(body, headers)` from `svix` package
|
|
116
47
|
- Idempotency: `svix-id` header
|
|
117
|
-
- Retry:
|
|
48
|
+
- Retry: use the provider's current retry schedule and delivery diagnostics; do not infer completion from receipt.
|
|
118
49
|
|
|
119
50
|
### GitHub
|
|
120
51
|
|
|
121
52
|
- Signature: `x-hub-signature-256` header (HMAC SHA-256 of the body with your secret)
|
|
122
53
|
- Idempotency: `x-github-delivery` header (UUID)
|
|
123
|
-
- Retry: GitHub does
|
|
54
|
+
- Retry: GitHub does not automatically redeliver failed deliveries; arrange manual or automated redelivery from its delivery records. A failure response is not durable acceptance.
|
|
124
55
|
|
|
125
56
|
### Custom (internal services calling each other)
|
|
126
57
|
|
|
@@ -128,64 +59,33 @@ The 5 layers are explicit. No wrapper is required. If your project ships its own
|
|
|
128
59
|
- Idempotency: caller-generated UUID v7 in `x-idempotency-key`
|
|
129
60
|
- Retry: caller's responsibility; receiver acknowledges with 2xx + the key it already saw
|
|
130
61
|
|
|
131
|
-
##
|
|
62
|
+
## Durable inbox
|
|
132
63
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
key text PRIMARY KEY, -- e.g., "stripe:evt_xxx"
|
|
138
|
-
source text NOT NULL,
|
|
139
|
-
event_type text NOT NULL,
|
|
140
|
-
received_at timestamptz NOT NULL DEFAULT now(),
|
|
141
|
-
committed_at timestamptz,
|
|
142
|
-
payload jsonb NOT NULL
|
|
143
|
-
);
|
|
144
|
-
CREATE INDEX idx_webhook_inbox_received ON webhook_inbox (received_at);
|
|
145
|
-
```
|
|
146
|
-
|
|
147
|
-
Migration follows the safe-migration pattern (see `void-drizzle-migration-safe` skill).
|
|
64
|
+
Use the application's existing durable store following `void-async-safety`. Its state
|
|
65
|
+
must distinguish pending claims from completed events and release/expire failed claims
|
|
66
|
+
for retry. A unique key alone cannot express completion. Apply schema changes through
|
|
67
|
+
`void-drizzle-migration-safe`; this skill supplies no independently evolving SQL schema.
|
|
148
68
|
|
|
149
69
|
## Dead-letter routing
|
|
150
70
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
await db.insert(webhookDeadLetter).values({
|
|
157
|
-
key: inboxKey,
|
|
158
|
-
source: 'stripe',
|
|
159
|
-
error: err.message,
|
|
160
|
-
payload: event,
|
|
161
|
-
receivedAt: new Date(),
|
|
162
|
-
});
|
|
163
|
-
return NextResponse.json({ ok: false, retry: false }, { status: 400 });
|
|
164
|
-
// 4xx tells Stripe "permanent — stop retrying"
|
|
165
|
-
}
|
|
166
|
-
throw err; // retryable — let the 5xx propagate
|
|
167
|
-
}
|
|
168
|
-
```
|
|
169
|
-
|
|
170
|
-
Dead letter = a queue/table the team reviews. UI: `Settings → Webhook DLQ`. Never silently drop.
|
|
171
|
-
|
|
172
|
-
## Error response codes
|
|
71
|
+
A permanent failure may be recorded in a durable dead-letter destination for an operator
|
|
72
|
+
before applying the provider's acknowledgment policy. Never interpret an HTTP status as
|
|
73
|
+
a universal instruction to stop delivery: Stripe retries non-2xx responses, including 4xx;
|
|
74
|
+
GitHub does not automatically redeliver failures. Keep provider-specific retries explicit.
|
|
75
|
+
Never acknowledge a malformed event as successfully processed or silently drop a failure.
|
|
173
76
|
|
|
174
|
-
|
|
175
|
-
|---|---|---|
|
|
176
|
-
| 200/202 | Accepted | Stops retrying |
|
|
177
|
-
| 401 | Invalid signature | Stops retrying (Stripe, GitHub will flag the endpoint) |
|
|
178
|
-
| 4xx (other) | Permanent failure (bad event shape) | Stops retrying — log this for investigation |
|
|
179
|
-
| 5xx | Transient failure | Retries with backoff (Stripe/Svix); GitHub doesn't retry |
|
|
77
|
+
## Error responses
|
|
180
78
|
|
|
181
|
-
|
|
79
|
+
The canonical wrapper rejects verifier failures with 400 and returns 500 for handler or
|
|
80
|
+
store failures. The HTTP adapter preserves those outcomes. A 200 means completed work or
|
|
81
|
+
a previously completed duplicate, never an in-progress claim. If a queue/outbox is used,
|
|
82
|
+
its separate acceptance contract must mean durable responsibility for eventual processing.
|
|
182
83
|
|
|
183
84
|
## Anti-patterns
|
|
184
85
|
|
|
185
86
|
- ✗ **No signature verification** — anyone with the URL can forge events
|
|
186
87
|
- ✗ **Idempotency key = `Date.now()`** — re-delivery NOT detected; double-charges
|
|
187
|
-
- ✗ **Calling services synchronously for slow work** —
|
|
188
|
-
- ✗ **Returning 5xx to "make sender retry" without enqueueing** — sender's backoff is unpredictable; use your own queue
|
|
88
|
+
- ✗ **Calling services synchronously for slow work** — work exceeding the provider's response deadline is a failed delivery; retry policy varies by provider. Enqueue background jobs for slow work (see `void-background-job-pattern`).
|
|
189
89
|
- ✗ **One handler for all sources** (`/api/webhooks/route.ts` switching on `source`) — different signatures, different idempotency keys, different retry semantics. One folder per source.
|
|
190
90
|
- ✗ **`==` for HMAC comparison** — use `crypto.timingSafeEqual`. Constant-time compare prevents timing attacks.
|
|
191
91
|
|
|
@@ -198,7 +198,7 @@ Every UI is designed mobile-first AND must reach first-class quality on both vie
|
|
|
198
198
|
- No mobile-only nor desktop-only features without an explicit decision recorded through the `void-decide` skill.
|
|
199
199
|
- Both viewports screenshot-reviewed before merge.
|
|
200
200
|
|
|
201
|
-
The companion `viewport-screenshot-gate` hook (shared with `void-frontend-design`)
|
|
201
|
+
The companion `viewport-screenshot-gate` hook (shared with `void-frontend-design`) is an optional PR-text heuristic; it does not validate images or enforce a native/CI gate. Review the actual mobile and desktop captures before merge.
|
|
202
202
|
|
|
203
203
|
---
|
|
204
204
|
|
|
@@ -229,7 +229,7 @@ The design system handles this by default in `pack-nextjs`. Specific animations
|
|
|
229
229
|
## Companion hooks
|
|
230
230
|
|
|
231
231
|
- `axe-precommit` (pre-commit on UI changes) — axe-core static analysis, blocks on AA violations. ≤ 80 LOC.
|
|
232
|
-
- `viewport-screenshot-gate` (pre-PR on UI changes, shared with `void-frontend-design`) —
|
|
232
|
+
- `viewport-screenshot-gate` (pre-PR on UI changes, shared with `void-frontend-design`) — optional PR-text heuristic, not a wired native/CI gate or image validation. Review actual captures.
|
|
233
233
|
|
|
234
234
|
---
|
|
235
235
|
|