Skip to content

Commit 6a1e599

Browse files
gidson5Calebuxclaude
authored
feat: biometric auth hardening and subscription webhook event catalog (#654)
- Harden biometric auth with exponential backoff lockout (3/10/30 min tiers) (#552) - Add DeviceAttestationService with root/jailbreak detection (#552) - Add device-bound key tracking, PIN hash storage, enrollment change detection (#552) - Add BiometricPolicy type and authenticateHardened() with lockout enforcement (#552) - Add useDeviceIntegrity hook with 24h cached attestation (#552) - Add EventCatalogRegistry with 35 typed events across 6 categories (#570) - Add EventSchemaValidator with field type and required checks (#570) - Add EventReplayWorker with per-subscription ordering and idempotency (#570) - Expand WebhookEventType to 35+ events covering full lifecycle (#570) - Support wildcard event filtering (subscription.*, payment.*) (#570) - Add event versioning with Deprecation/Sunset headers (#570) Co-authored-by: calebux <petersoncaleb275@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent a1f0795 commit 6a1e599

7 files changed

Lines changed: 682 additions & 4 deletions

File tree

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
/**
2+
* EventCatalogRegistry — Comprehensive webhook event catalog (30+ events)
3+
* covering the full subscription lifecycle with typed payloads, versioning,
4+
* and deprecation support.
5+
*/
6+
7+
export interface EventDefinition {
8+
type: string;
9+
version: number;
10+
description: string;
11+
category: EventCategory;
12+
deprecated?: boolean;
13+
deprecatedAt?: string;
14+
sunsetAt?: string;
15+
replacedBy?: string;
16+
payloadSchema: Record<string, SchemaField>;
17+
}
18+
19+
export interface SchemaField {
20+
type: 'string' | 'number' | 'boolean' | 'object' | 'array';
21+
required: boolean;
22+
description: string;
23+
example?: unknown;
24+
}
25+
26+
export type EventCategory =
27+
| 'subscription'
28+
| 'payment'
29+
| 'invoice'
30+
| 'trial'
31+
| 'usage'
32+
| 'plan';
33+
34+
const basePayloadSchema: Record<string, SchemaField> = {
35+
id: { type: 'string', required: true, description: 'Unique event ID', example: 'evt_abc123' },
36+
type: { type: 'string', required: true, description: 'Event type', example: 'subscription.created' },
37+
version: { type: 'number', required: true, description: 'Schema version', example: 1 },
38+
occurredAt: { type: 'number', required: true, description: 'Unix timestamp (ms)', example: 1719100000000 },
39+
idempotencyKey: { type: 'string', required: true, description: 'Idempotency key for deduplication' },
40+
merchantId: { type: 'string', required: true, description: 'Merchant identifier' },
41+
};
42+
43+
const subscriptionDataSchema: Record<string, SchemaField> = {
44+
subscriptionId: { type: 'string', required: true, description: 'Subscription ID' },
45+
planId: { type: 'string', required: true, description: 'Plan ID' },
46+
subscriberId: { type: 'string', required: true, description: 'Subscriber address/ID' },
47+
status: { type: 'string', required: true, description: 'Current status' },
48+
previousStatus: { type: 'string', required: false, description: 'Previous status (for transitions)' },
49+
};
50+
51+
function defineEvent(
52+
type: string,
53+
description: string,
54+
category: EventCategory,
55+
extraFields: Record<string, SchemaField> = {},
56+
opts: Partial<Pick<EventDefinition, 'deprecated' | 'deprecatedAt' | 'sunsetAt' | 'replacedBy'>> = {},
57+
): EventDefinition {
58+
return {
59+
type,
60+
version: 1,
61+
description,
62+
category,
63+
...opts,
64+
payloadSchema: { ...basePayloadSchema, ...subscriptionDataSchema, ...extraFields },
65+
};
66+
}
67+
68+
const amountField: SchemaField = { type: 'number', required: true, description: 'Amount in smallest unit' };
69+
const currencyField: SchemaField = { type: 'string', required: true, description: 'Token/currency symbol' };
70+
const reasonField: SchemaField = { type: 'string', required: false, description: 'Reason for the action' };
71+
72+
export const EVENT_CATALOG: EventDefinition[] = [
73+
// ── Subscription events ──────────────────────────────────────────────────
74+
defineEvent('subscription.created', 'New subscription created', 'subscription'),
75+
defineEvent('subscription.updated', 'Subscription details updated', 'subscription'),
76+
defineEvent('subscription.cancelled', 'Subscription cancelled', 'subscription', { reason: reasonField, cancelledAt: { type: 'number', required: true, description: 'Cancellation timestamp' } }),
77+
defineEvent('subscription.paused', 'Subscription paused', 'subscription', { pausedAt: { type: 'number', required: true, description: 'Pause timestamp' } }),
78+
defineEvent('subscription.resumed', 'Subscription resumed from pause', 'subscription', { resumedAt: { type: 'number', required: true, description: 'Resume timestamp' } }),
79+
defineEvent('subscription.expired', 'Subscription reached end date', 'subscription'),
80+
defineEvent('subscription.renewed', 'Subscription auto-renewed', 'subscription', { amount: amountField, currency: currencyField }),
81+
defineEvent('subscription.upgraded', 'Plan upgrade completed', 'subscription', { oldPlanId: { type: 'string', required: true, description: 'Previous plan' }, newPlanId: { type: 'string', required: true, description: 'New plan' } }),
82+
defineEvent('subscription.downgraded', 'Plan downgrade completed', 'subscription', { oldPlanId: { type: 'string', required: true, description: 'Previous plan' }, newPlanId: { type: 'string', required: true, description: 'New plan' } }),
83+
defineEvent('subscription.transfer_requested', 'Ownership transfer requested', 'subscription'),
84+
defineEvent('subscription.transfer_completed', 'Ownership transfer completed', 'subscription'),
85+
defineEvent('subscription.grace_period_started', 'Grace period after failed payment', 'subscription'),
86+
defineEvent('subscription.grace_period_ended', 'Grace period expired', 'subscription'),
87+
88+
// ── Payment events ───────────────────────────────────────────────────────
89+
defineEvent('payment.succeeded', 'Payment processed successfully', 'payment', { amount: amountField, currency: currencyField, transactionHash: { type: 'string', required: false, description: 'On-chain tx hash' } }),
90+
defineEvent('payment.failed', 'Payment attempt failed', 'payment', { amount: amountField, currency: currencyField, errorCode: { type: 'string', required: false, description: 'Error code' } }),
91+
defineEvent('payment.refunded', 'Payment refunded', 'payment', { amount: amountField, currency: currencyField, refundReason: reasonField }),
92+
defineEvent('payment.disputed', 'Payment disputed by subscriber', 'payment', { amount: amountField, currency: currencyField }),
93+
defineEvent('payment.chargeback', 'Chargeback initiated', 'payment', { amount: amountField, currency: currencyField }),
94+
defineEvent('payment.method_updated', 'Payment method changed', 'payment'),
95+
defineEvent('payment.retry_scheduled', 'Failed payment retry scheduled', 'payment', { retryAt: { type: 'number', required: true, description: 'Retry timestamp' }, attemptNumber: { type: 'number', required: true, description: 'Attempt count' } }),
96+
97+
// ── Invoice events ───────────────────────────────────────────────────────
98+
defineEvent('invoice.created', 'Invoice generated', 'invoice', { invoiceId: { type: 'string', required: true, description: 'Invoice ID' }, amount: amountField }),
99+
defineEvent('invoice.finalized', 'Invoice finalized and ready for payment', 'invoice', { invoiceId: { type: 'string', required: true, description: 'Invoice ID' } }),
100+
defineEvent('invoice.paid', 'Invoice paid', 'invoice', { invoiceId: { type: 'string', required: true, description: 'Invoice ID' }, amount: amountField }),
101+
defineEvent('invoice.voided', 'Invoice voided', 'invoice', { invoiceId: { type: 'string', required: true, description: 'Invoice ID' }, reason: reasonField }),
102+
defineEvent('invoice.overdue', 'Invoice past due', 'invoice', { invoiceId: { type: 'string', required: true, description: 'Invoice ID' }, daysOverdue: { type: 'number', required: true, description: 'Days overdue' } }),
103+
104+
// ── Trial events ─────────────────────────────────────────────────────────
105+
defineEvent('trial.started', 'Trial period started', 'trial', { trialEndsAt: { type: 'number', required: true, description: 'Trial end timestamp' } }),
106+
defineEvent('trial.ending_soon', 'Trial ending within 3 days', 'trial', { trialEndsAt: { type: 'number', required: true, description: 'Trial end timestamp' }, daysRemaining: { type: 'number', required: true, description: 'Days left' } }),
107+
defineEvent('trial.ended', 'Trial period ended', 'trial', { converted: { type: 'boolean', required: true, description: 'Whether trial converted to paid' } }),
108+
defineEvent('trial.converted', 'Trial converted to paid subscription', 'trial'),
109+
110+
// ── Usage events ─────────────────────────────────────────────────────────
111+
defineEvent('usage.threshold_reached', 'Usage threshold reached', 'usage', { metric: { type: 'string', required: true, description: 'Usage metric name' }, currentUsage: { type: 'number', required: true, description: 'Current value' }, threshold: { type: 'number', required: true, description: 'Threshold value' } }),
112+
defineEvent('usage.limit_exceeded', 'Usage limit exceeded', 'usage', { metric: { type: 'string', required: true, description: 'Usage metric name' }, currentUsage: { type: 'number', required: true, description: 'Current value' }, limit: { type: 'number', required: true, description: 'Limit value' } }),
113+
defineEvent('usage.recorded', 'Usage data point recorded', 'usage', { metric: { type: 'string', required: true, description: 'Usage metric name' }, value: { type: 'number', required: true, description: 'Recorded value' } }),
114+
115+
// ── Plan events ──────────────────────────────────────────────────────────
116+
defineEvent('plan.created', 'New plan created', 'plan', { planName: { type: 'string', required: true, description: 'Plan name' }, price: amountField }),
117+
defineEvent('plan.updated', 'Plan details updated', 'plan'),
118+
defineEvent('plan.archived', 'Plan archived (no new subscriptions)', 'plan'),
119+
defineEvent('plan.price_changed', 'Plan price changed', 'plan', { oldPrice: amountField, newPrice: { type: 'number', required: true, description: 'New price' } }),
120+
];
121+
122+
export class EventCatalogRegistry {
123+
private events: Map<string, EventDefinition>;
124+
125+
constructor() {
126+
this.events = new Map();
127+
for (const event of EVENT_CATALOG) {
128+
this.events.set(event.type, event);
129+
}
130+
}
131+
132+
getEvent(type: string): EventDefinition | undefined {
133+
return this.events.get(type);
134+
}
135+
136+
getAllEvents(): EventDefinition[] {
137+
return Array.from(this.events.values());
138+
}
139+
140+
getByCategory(category: EventCategory): EventDefinition[] {
141+
return this.getAllEvents().filter(e => e.category === category);
142+
}
143+
144+
getActiveEvents(): EventDefinition[] {
145+
return this.getAllEvents().filter(e => !e.deprecated);
146+
}
147+
148+
matchesWildcard(pattern: string, eventType: string): boolean {
149+
if (pattern === '*') return true;
150+
if (pattern.endsWith('.*')) {
151+
return eventType.startsWith(pattern.slice(0, -1));
152+
}
153+
return pattern === eventType;
154+
}
155+
156+
filterByPatterns(patterns: string[]): EventDefinition[] {
157+
return this.getAllEvents().filter(e =>
158+
patterns.some(p => this.matchesWildcard(p, e.type))
159+
);
160+
}
161+
162+
getDeprecationHeaders(type: string): Record<string, string> {
163+
const event = this.getEvent(type);
164+
if (!event?.deprecated) return {};
165+
const headers: Record<string, string> = {};
166+
if (event.deprecatedAt) headers['Deprecation'] = event.deprecatedAt;
167+
if (event.sunsetAt) headers['Sunset'] = event.sunsetAt;
168+
if (event.replacedBy) headers['Link'] = `<${event.replacedBy}>; rel="successor-version"`;
169+
return headers;
170+
}
171+
}
172+
173+
export const eventCatalog = new EventCatalogRegistry();
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/**
2+
* Event replay worker — replays webhook events from history
3+
* with idempotency key checking and ordering guarantees.
4+
*/
5+
6+
export interface ReplayRequest {
7+
eventIds: string[];
8+
webhookId: string;
9+
targetUrl: string;
10+
secretKey: string;
11+
}
12+
13+
export interface ReplayResult {
14+
eventId: string;
15+
status: 'replayed' | 'skipped' | 'failed';
16+
error?: string;
17+
responseCode?: number;
18+
}
19+
20+
export interface StoredEvent {
21+
id: string;
22+
type: string;
23+
subscriptionId: string;
24+
payload: Record<string, unknown>;
25+
occurredAt: number;
26+
idempotencyKey: string;
27+
}
28+
29+
export class EventReplayWorker {
30+
private eventStore: Map<string, StoredEvent> = new Map();
31+
private deliveredIdempotencyKeys: Set<string> = new Set();
32+
33+
storeEvent(event: StoredEvent): void {
34+
this.eventStore.set(event.id, event);
35+
}
36+
37+
markDelivered(idempotencyKey: string): void {
38+
this.deliveredIdempotencyKeys.set.add(idempotencyKey);
39+
}
40+
41+
async replay(request: ReplayRequest): Promise<ReplayResult[]> {
42+
const results: ReplayResult[] = [];
43+
44+
// Sort events by occurredAt for ordering guarantee per subscription
45+
const events = request.eventIds
46+
.map(id => this.eventStore.get(id))
47+
.filter((e): e is StoredEvent => e !== undefined)
48+
.sort((a, b) => a.occurredAt - b.occurredAt);
49+
50+
// Group by subscription for per-subscription ordering
51+
const bySubscription = new Map<string, StoredEvent[]>();
52+
for (const event of events) {
53+
const group = bySubscription.get(event.subscriptionId) ?? [];
54+
group.push(event);
55+
bySubscription.set(event.subscriptionId, group);
56+
}
57+
58+
for (const [, subEvents] of bySubscription) {
59+
for (const event of subEvents) {
60+
if (this.deliveredIdempotencyKeys.has(event.idempotencyKey)) {
61+
results.push({ eventId: event.id, status: 'skipped' });
62+
continue;
63+
}
64+
65+
try {
66+
const response = await this.deliverEvent(
67+
request.targetUrl,
68+
event.payload,
69+
request.secretKey,
70+
event.idempotencyKey
71+
);
72+
73+
if (response.ok) {
74+
this.deliveredIdempotencyKeys.add(event.idempotencyKey);
75+
results.push({ eventId: event.id, status: 'replayed', responseCode: response.status });
76+
} else {
77+
results.push({ eventId: event.id, status: 'failed', responseCode: response.status });
78+
}
79+
} catch (err) {
80+
results.push({
81+
eventId: event.id,
82+
status: 'failed',
83+
error: err instanceof Error ? err.message : 'Unknown error',
84+
});
85+
}
86+
}
87+
}
88+
89+
return results;
90+
}
91+
92+
private async deliverEvent(
93+
url: string,
94+
payload: Record<string, unknown>,
95+
secretKey: string,
96+
idempotencyKey: string
97+
): Promise<{ ok: boolean; status: number }> {
98+
const body = JSON.stringify(payload);
99+
100+
const res = await fetch(url, {
101+
method: 'POST',
102+
headers: {
103+
'Content-Type': 'application/json',
104+
'X-Webhook-Signature': this.computeSignature(body, secretKey),
105+
'X-Idempotency-Key': idempotencyKey,
106+
'X-Replay': 'true',
107+
},
108+
body,
109+
signal: AbortSignal.timeout(10000),
110+
});
111+
112+
return { ok: res.ok, status: res.status };
113+
}
114+
115+
private computeSignature(body: string, secret: string): string {
116+
// In production, use HMAC-SHA256
117+
// Placeholder: simple hash for structure
118+
let hash = 0;
119+
const input = secret + body;
120+
for (let i = 0; i < input.length; i++) {
121+
hash = ((hash << 5) - hash + input.charCodeAt(i)) | 0;
122+
}
123+
return `sha256=${Math.abs(hash).toString(16)}`;
124+
}
125+
126+
getEventHistory(webhookId: string, limit = 50): StoredEvent[] {
127+
return Array.from(this.eventStore.values())
128+
.sort((a, b) => b.occurredAt - a.occurredAt)
129+
.slice(0, limit);
130+
}
131+
}
132+
133+
export const eventReplayWorker = new EventReplayWorker();
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/**
2+
* EventSchemaValidator — Validates webhook event payloads against
3+
* the JSON Schema definitions in the event catalog.
4+
*/
5+
6+
import { eventCatalog, type SchemaField } from './eventCatalog';
7+
8+
export interface ValidationResult {
9+
valid: boolean;
10+
errors: string[];
11+
}
12+
13+
export class EventSchemaValidator {
14+
validate(eventType: string, payload: Record<string, unknown>): ValidationResult {
15+
const definition = eventCatalog.getEvent(eventType);
16+
if (!definition) {
17+
return { valid: false, errors: [`Unknown event type: ${eventType}`] };
18+
}
19+
20+
const errors: string[] = [];
21+
const schema = definition.payloadSchema;
22+
23+
for (const [field, spec] of Object.entries(schema)) {
24+
const value = payload[field];
25+
26+
if (spec.required && (value === undefined || value === null)) {
27+
errors.push(`Missing required field: ${field}`);
28+
continue;
29+
}
30+
31+
if (value !== undefined && value !== null) {
32+
if (!this.checkType(value, spec)) {
33+
errors.push(`Field "${field}" expected type "${spec.type}", got "${typeof value}"`);
34+
}
35+
}
36+
}
37+
38+
return { valid: errors.length === 0, errors };
39+
}
40+
41+
private checkType(value: unknown, spec: SchemaField): boolean {
42+
switch (spec.type) {
43+
case 'string':
44+
return typeof value === 'string';
45+
case 'number':
46+
return typeof value === 'number' && !isNaN(value);
47+
case 'boolean':
48+
return typeof value === 'boolean';
49+
case 'object':
50+
return typeof value === 'object' && value !== null && !Array.isArray(value);
51+
case 'array':
52+
return Array.isArray(value);
53+
default:
54+
return true;
55+
}
56+
}
57+
58+
generateExample(eventType: string): Record<string, unknown> | null {
59+
const definition = eventCatalog.getEvent(eventType);
60+
if (!definition) return null;
61+
62+
const example: Record<string, unknown> = {};
63+
for (const [field, spec] of Object.entries(definition.payloadSchema)) {
64+
if (spec.example !== undefined) {
65+
example[field] = spec.example;
66+
} else {
67+
example[field] = this.defaultForType(spec.type);
68+
}
69+
}
70+
return example;
71+
}
72+
73+
private defaultForType(type: string): unknown {
74+
switch (type) {
75+
case 'string': return '';
76+
case 'number': return 0;
77+
case 'boolean': return false;
78+
case 'object': return {};
79+
case 'array': return [];
80+
default: return null;
81+
}
82+
}
83+
}
84+
85+
export const eventSchemaValidator = new EventSchemaValidator();

0 commit comments

Comments
 (0)