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.
Files changed (32) hide show
  1. package/core-assets/.claude-plugin/plugin.json +2 -2
  2. package/core-assets/PHILOSOPHY.md +18 -5
  3. package/core-assets/data/catalog.v3.json +29 -109
  4. package/core-assets/data/certification.json +1 -1
  5. package/core-assets/data/model.json +28 -52
  6. package/core-assets/hooks/_syntax-worker.cjs +1 -1
  7. package/core-assets/hooks/_void-hook.mjs +8 -6
  8. package/core-assets/hooks/tdd-guard.md +45 -88
  9. package/core-assets/hooks/viewport-screenshot-gate.sh +3 -3
  10. package/core-assets/packs/pack-monorepo/skills/void-dependency-direction/SKILL.md +27 -146
  11. package/core-assets/packs/pack-monorepo/skills/void-service-package/SKILL.md +5 -8
  12. package/core-assets/packs/pack-server/skills/void-webhook-handler-pattern/.source +4 -2
  13. package/core-assets/packs/pack-server/skills/void-webhook-handler-pattern/SKILL.md +32 -132
  14. package/core-assets/skills/void-accessibility/SKILL.md +2 -2
  15. package/core-assets/skills/void-async-safety/SKILL.md +37 -24
  16. package/core-assets/skills/void-autopilot/SKILL.md +17 -15
  17. package/core-assets/skills/void-autopilot/references/publication.md +82 -0
  18. package/core-assets/skills/void-code-review/SKILL.md +1 -1
  19. package/core-assets/skills/void-commit-discipline/SKILL.md +5 -11
  20. package/core-assets/skills/void-frontend-design/SKILL.md +2 -2
  21. package/core-assets/skills/void-hexagonal-architecture/SKILL.md +5 -5
  22. package/core-assets/skills/void-implement/SKILL.md +11 -5
  23. package/core-assets/skills/void-observability/SKILL.md +4 -4
  24. package/core-assets/skills/void-orchestrate/SKILL.md +57 -224
  25. package/core-assets/skills/void-testing/SKILL.md +4 -4
  26. package/core-assets/skills/void-verify/SKILL.md +1 -1
  27. package/core-assets/skills/void-verify/references/ci-admission.mjs +1 -1
  28. package/core-assets/skills/void-verify/references/ci-feedback.md +33 -4
  29. package/core-assets/templates/github/void-dependency-audit.yml +18 -2
  30. package/dist/main.js +2941 -2449
  31. package/package.json +2 -2
  32. package/core-assets/hooks/block-protected-push.sh +0 -81
@@ -1,160 +1,41 @@
1
1
  ---
2
2
  name: void-dependency-direction
3
- description: Enforce the @repo/* import direction in a Turborepo workspace. Concrete violations + fixes. Composes with core:hexagonal-architecture and the boundary-direction-check hook.
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
- Use when adding an `import` statement that crosses a package boundary, or when fixing a `boundary-direction-check` hook violation. This skill is the **operational guide** for the rules core `void-hexagonal-architecture` and `boundary-direction-check` enforce mechanically.
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
- ## The rule
12
+ ## Before adding an import
11
13
 
12
- ```
13
- @repo/core → nothing internal
14
- @repo/auth → @repo/core
15
- @repo/db → @repo/core
16
- @repo/ui → @repo/core
17
- @repo/<feat> → @repo/core (and only @repo/core)
18
- apps/<app> → any @repo/*
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
- **No `@repo/*` package imports another `@repo/*` except `@repo/core`.** All composition happens at the app level. If `@repo/billing` needs the user, it defines a port; the consuming app wires `@repo/auth`'s adapter into that port.
23
+ ## Mechanical scope
22
24
 
23
- This sounds restrictive. It is. It is what keeps the monorepo from collapsing into a hairball.
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
- ## Why core is the only allowed dependency
26
-
27
- `@repo/core` ships **primitives** with no internal deps:
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` — doctrine on ports + adapters direction (core).
158
- - `boundary-direction-check` hook (core) — mechanical gate.
159
- - `void-package-extraction` — most boundary problems come from premature 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 (enforced by `boundary-direction-check`)
55
+ ## Allowed imports
56
56
 
57
- | This package | May import |
58
- |---|---|
59
- | `@repo/<name>` | `@repo/core` (logger, env, errors, Result/Option/pipe). Nothing else `@repo/*`. |
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
- - No external source audited — native distillation of canonical per-source webhook patterns.
4
- - Per-source patterns referenced (not vendored): Stripe, Resend, GitHub signature/idempotency/retry semantics.
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). Self-contained — no harness wrappers required.
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
- ## The 5 non-negotiable layers
18
+ ## Handler contract
19
19
 
20
- ```
21
- 1. Signature verification — verify the event came from the source
22
- 2. Idempotency — same event delivered twice = one effect
23
- 3. Zod validation — parsed shape matches what handler expects
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
- Skipping ANY of these is a Sev-2 waiting. The pattern below shows the 5 layers explicit — no wrapper assumed.
29
-
30
- ## Canonical handler (explicit, self-contained)
31
-
32
- ```ts
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
- The 5 layers are explicit. No wrapper is required. If your project ships its own `withWebhookSafety` helper that bundles these 5, that's a convenience — but the pattern is the substance, and it's the same.
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: Svix retries automatically; you only see the first failure if your endpoint returns 5xx
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 NOT auto-retry. If you return 5xx, the event is lost. Make sure dead-letter is robust.
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
- ## Inbox table
62
+ ## Durable inbox
132
63
 
133
- The idempotency check needs persistent storage:
134
-
135
- ```sql
136
- CREATE TABLE webhook_inbox (
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
- When the handler decides the error is permanent (validation failure, downstream API said 4xx):
152
-
153
- ```ts
154
- catch (err) {
155
- if (isPermanentFailure(err)) {
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
- | Code | Means | Sender behavior |
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
- NEVER return 200 to a malformed event (you'd accept invalid data). 4xx with a logged DLQ entry is the right pattern.
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** — if the handler takes > 10s, the sender retries. Enqueue background jobs for slow work (see `void-background-job-pattern`).
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`) fails PRs lacking both screenshots.
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`) — fails PRs lacking both mobile and desktop screenshots. ≤ 60 LOC.
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 signature** (HMAC + timestamp window)
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 '@repo/async';
34
+ import { withWebhookSafety } from '@voidcorp/pack-nextjs/async';
35
35
 
36
- export const POST = withWebhookSafety({
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 enforces the order. Business handler stays pure.
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
- Reject events older than the configured window (default 5 minutes). Stripe and most webhook sources include a timestamp; the wrapper checks it. Composes with `void-security-guidance` (replay attack protection).
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
- - No replay window (forever-replayable).
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` is a port (composes with `void-hexagonal-architecture`):
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
- - **Redis adapter** — high volume, low latency
75
- - **Postgres adapter** — default for low-volume webhooks (uses `INSERT ... ON CONFLICT DO NOTHING` for atomic claim)
76
- - **In-memory adapter** — tests only (composes with `void-testing` nullable infrastructure)
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
- The store is the consumer dedup substrate. Process restart does not lose claims.
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
- ### Default TTL: 7 days
90
+ ### Deduplication retention
81
91
 
82
- Long enough for most provider retry windows. Override per handler.
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