voidmachine 6.0.0 → 7.0.1
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 +2 -2
- package/core-assets/PHILOSOPHY.md +18 -5
- package/core-assets/data/catalog.v3.json +29 -109
- package/core-assets/data/certification.json +1 -1
- package/core-assets/data/model.json +28 -52
- package/core-assets/hooks/_syntax-worker.cjs +1 -1
- package/core-assets/hooks/_void-hook.mjs +8 -6
- 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 +17 -15
- package/core-assets/skills/void-autopilot/references/publication.md +82 -0
- package/core-assets/skills/void-code-review/SKILL.md +1 -1
- package/core-assets/skills/void-commit-discipline/SKILL.md +5 -11
- package/core-assets/skills/void-frontend-design/SKILL.md +2 -2
- package/core-assets/skills/void-hexagonal-architecture/SKILL.md +5 -5
- package/core-assets/skills/void-implement/SKILL.md +11 -5
- package/core-assets/skills/void-observability/SKILL.md +4 -4
- package/core-assets/skills/void-orchestrate/SKILL.md +57 -224
- package/core-assets/skills/void-testing/SKILL.md +4 -4
- package/core-assets/skills/void-verify/SKILL.md +1 -1
- package/core-assets/skills/void-verify/references/ci-admission.mjs +1 -1
- package/core-assets/skills/void-verify/references/ci-feedback.md +33 -4
- package/core-assets/templates/github/void-dependency-audit.yml +18 -2
- package/dist/main.js +2941 -2449
- package/package.json +2 -2
- package/core-assets/hooks/block-protected-push.sh +0 -81
|
@@ -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
|
|
|
@@ -23,7 +23,7 @@ Exactly-once delivery requires two-phase commits across systems — extreme effo
|
|
|
23
23
|
|
|
24
24
|
Every webhook handler does FOUR things, IN THIS ORDER:
|
|
25
25
|
|
|
26
|
-
1. **Verify
|
|
26
|
+
1. **Verify authenticity and replay policy** using the provider contract (authenticated timestamp when available)
|
|
27
27
|
2. **Check idempotency key** (claim atomically; if already processed, return success without re-processing)
|
|
28
28
|
3. **Handle the event** (business logic)
|
|
29
29
|
4. **Mark idempotency key as completed** (or release on failure for retry)
|
|
@@ -31,55 +31,68 @@ Every webhook handler does FOUR things, IN THIS ORDER:
|
|
|
31
31
|
The `pack-nextjs` provides a `withWebhookSafety()` wrapper:
|
|
32
32
|
|
|
33
33
|
```typescript
|
|
34
|
-
import { withWebhookSafety } from '@
|
|
34
|
+
import { withWebhookSafety } from '@voidcorp/pack-nextjs/async';
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
const handleWebhook = withWebhookSafety({
|
|
37
37
|
verify: (req) => verifyStripeSignature(req, env.STRIPE_WEBHOOK_SECRET),
|
|
38
38
|
dedupKey: (event) => event.id, // Stripe event ID
|
|
39
|
-
replayWindowMs: 5 * 60 * 1000, // 5 minutes
|
|
40
39
|
store: stripeIdempotencyStore, // injected
|
|
41
40
|
handler: async (event) => handleStripeEvent(deps, event),
|
|
42
41
|
});
|
|
42
|
+
|
|
43
|
+
export async function POST(req: Request): Promise<Response> {
|
|
44
|
+
const outcome = await handleWebhook(req);
|
|
45
|
+
return Response.json(outcome.body, { status: outcome.status });
|
|
46
|
+
}
|
|
43
47
|
```
|
|
44
48
|
|
|
45
|
-
The wrapper
|
|
49
|
+
The wrapper owns verify → claim → handle → mark and returns a typed outcome; the HTTP
|
|
50
|
+
adapter maps its status and body. Verification and handling return `Result`, not an
|
|
51
|
+
untyped event or success by absence of an exception. Signature verification belongs to
|
|
52
|
+
the provider SDK adapter passed as `verify`; it runs before any claim or business effect.
|
|
46
53
|
|
|
47
54
|
### Replay window enforcement
|
|
48
55
|
|
|
49
|
-
|
|
56
|
+
The verifier must use the provider SDK's authenticated delivery metadata and freshness policy.
|
|
57
|
+
For Stripe, check the signed delivery timestamp through its SDK; do not reject an old
|
|
58
|
+
business event merely because its creation time is old. Retries are new signed deliveries.
|
|
59
|
+
The wrapper has no `replayWindowMs` option and supplies no universal five-minute policy.
|
|
60
|
+
A verifier failure is rejected before deduplication or business handling. Providers without
|
|
61
|
+
a signed timestamp require their documented replay/deduplication policy, not a fabricated
|
|
62
|
+
timestamp check. See the provider reference in this skill's `.source`.
|
|
50
63
|
|
|
51
64
|
### Banned
|
|
52
65
|
|
|
53
66
|
- State mutation BEFORE signature verification.
|
|
54
67
|
- Hand-rolled signature check (use the wrapper / the provider's official lib).
|
|
55
68
|
- In-memory `Set` for dedup (lost on process restart).
|
|
56
|
-
-
|
|
69
|
+
- Ignoring the provider's authenticated freshness policy when one exists, or omitting durable event deduplication.
|
|
57
70
|
|
|
58
71
|
---
|
|
59
72
|
|
|
60
73
|
## Idempotency keys — port + store
|
|
61
74
|
|
|
62
|
-
`IdempotencyStore`
|
|
63
|
-
|
|
64
|
-
```typescript
|
|
65
|
-
interface IdempotencyStore {
|
|
66
|
-
tryClaim(key: string, ttl: Duration): Promise<Result<'claimed' | 'already-processed', StoreError>>;
|
|
67
|
-
markCompleted(key: string): Promise<Result<void, StoreError>>;
|
|
68
|
-
release(key: string): Promise<Result<void, StoreError>>;
|
|
69
|
-
}
|
|
70
|
-
```
|
|
71
|
-
|
|
72
|
-
Adapters (provided by packs):
|
|
75
|
+
The exported `IdempotencyStore` in `@voidcorp/pack-nextjs/async` owns this contract:
|
|
73
76
|
|
|
74
|
-
-
|
|
75
|
-
|
|
76
|
-
-
|
|
77
|
+
- `tryClaim` atomically claims a new key. `already-processed` means **completed**, never
|
|
78
|
+
merely received or in progress. A pending claim returns a retryable store error.
|
|
79
|
+
- A failed handler releases its claim. A failed completion write returns non-success and
|
|
80
|
+
releases for retry. Release failure never turns the operation into success.
|
|
81
|
+
- The consumer provides a durable adapter; the harness does not ship a Redis/Postgres store.
|
|
82
|
+
Tests may use an in-memory implementation of the same contract.
|
|
83
|
+
- A claim cannot make an external effect atomic with its completion record. Use a shared
|
|
84
|
+
transaction/outbox where possible, or make the business effect idempotent by the stable
|
|
85
|
+
event key. After an uncertain completion, a retry may execute the handler again.
|
|
77
86
|
|
|
78
|
-
|
|
87
|
+
Do not implement deduplication as "inbox row exists → acknowledge": an unfinished row
|
|
88
|
+
must remain retryable. This is the single safety contract used by the webhook handler skill.
|
|
79
89
|
|
|
80
|
-
###
|
|
90
|
+
### Deduplication retention
|
|
81
91
|
|
|
82
|
-
|
|
92
|
+
Choose retention from the provider's automatic and manual redelivery windows and the
|
|
93
|
+
business invariant. The wrapper passes `dedupTtlMs` (default seven days) to the
|
|
94
|
+
store; the durable adapter must enforce that retention. Override it when the
|
|
95
|
+
provider contract or business invariant requires a longer window.
|
|
83
96
|
|
|
84
97
|
---
|
|
85
98
|
|