diff --git a/examples/ai-token-stream-expr-usage.ts b/examples/ai-token-stream-expr-usage.ts index 6316c55..a636f47 100644 --- a/examples/ai-token-stream-expr-usage.ts +++ b/examples/ai-token-stream-expr-usage.ts @@ -11,7 +11,7 @@ config({ path: ".env.local" }); async function* tokenUsageFromAIStream(): AsyncGenerator< AITokenUsagePayload<"PREMIUM_CALL" | "EXTRA_FEE"> > { - const userId = "c0971bcb-b901-4c3e-a191-c9a97871c39f"; + const userId = "c0971bcb-b901-4c3e-a191-c9a97871c36f"; yield { userId, diff --git a/examples/create-checkout.ts b/examples/create-checkout.ts deleted file mode 100644 index f0c9dd5..0000000 --- a/examples/create-checkout.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { biller } from "./scrawn/biller.ts"; - -const userId = "c0971bcb-b901-4c3e-a191-c9a97871c39f"; - -const url = await biller.collectPayment(userId); - -console.log(`\nCheckout URL for ${userId}:`); -console.log(url); -console.log(); diff --git a/examples/webhook-server.ts b/examples/webhook-server.ts deleted file mode 100644 index 2c67662..0000000 --- a/examples/webhook-server.ts +++ /dev/null @@ -1,49 +0,0 @@ -import express from "express"; -import { biller } from "./scrawn/biller.ts"; -import { toWebRequest } from "@scrawn/core"; - -const app = express(); - -app.post( - "/webhooks/scrawn", - express.raw({ type: "application/json" }), - async (req, res) => { - try { - // Convert Express req to Web API Request & verify in one call - const event = await biller.webhook(toWebRequest(req, req.body)); - - console.log(`\n=== ${event.resource}.${event.action} ===`); - console.log("ID:", event.id); - console.log("Data:", JSON.stringify(event.data, null, 2)); - console.log("========================\n"); - - // Handle based on event type — fully typed with intellisense - switch (event.action) { - case "succeeded": - // event.data.amount → number - // event.data.currency → "usd" - // event.data.mode → "test" | "production" - break; - case "failed": - // event.data.mode → "test" | "production" - break; - } - - res.status(200).json({ received: true }); - } catch (error) { - console.error("Webhook verification failed:", error); - res.status(401).json({ error: "Invalid signature" }); - } - } -); - -app.listen(3000, () => { - console.log("Webhook receiver listening on http://localhost:3000"); - console.log("Register this URL with Scrawn:"); - console.log( - ` curl -X POST http://localhost:8070/api/v1/internals/webhook-endpoint \\` - ); - console.log(` -H "Authorization: Bearer " \\`); - console.log(` -H "Content-Type: application/json" \\`); - console.log(` -d '{"url": "http://localhost:3000/webhooks/scrawn"}'`); -}); diff --git a/packages/scrawn/src/core/auth/apiKeyAuth.ts b/packages/scrawn/src/core/auth/apiKeyAuth.ts index aa69b66..f766d24 100644 --- a/packages/scrawn/src/core/auth/apiKeyAuth.ts +++ b/packages/scrawn/src/core/auth/apiKeyAuth.ts @@ -16,7 +16,7 @@ const API_KEY_REGEX = /^scrn_(dash|live|test)_[a-zA-Z0-9]{32}$/; /** * Type guard to validate API key format */ -export function isValidApiKey(key: string): key is ApiKeyFormat { +function isValidApiKey(key: string): key is ApiKeyFormat { return API_KEY_REGEX.test(key); } @@ -24,7 +24,7 @@ export function isValidApiKey(key: string): key is ApiKeyFormat { * Validates and returns a properly typed API key * @throws Error if the API key format is invalid */ -export function validateApiKey(key: string): ApiKeyFormat { +function validateApiKey(key: string): ApiKeyFormat { if (!isValidApiKey(key)) { log.error(`Invalid API key format: "${key}".`); throw new ScrawnValidationError( diff --git a/packages/scrawn/src/core/scrawn.ts b/packages/scrawn/src/core/scrawn.ts index 62f0a1a..f79011f 100644 --- a/packages/scrawn/src/core/scrawn.ts +++ b/packages/scrawn/src/core/scrawn.ts @@ -135,6 +135,9 @@ export class Scrawn< return this.apiKey; } + /** Base URL for the HTTP API (derived from baseURL config) */ + private httpUrl: string; + private sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -223,6 +226,7 @@ export class Scrawn< this.apiKey = config.apiKey; this.retryCount = config.retryCount ?? 2; + this.httpUrl = this.buildHttpUrl(config.baseURL); this.grpcClient = new GrpcClient(this.parseURLToTarget(config.baseURL), { secure: config.secure ?? true, credentials: config.credentials, @@ -245,6 +249,16 @@ export class Scrawn< : `${baseURL}:${ScrawnConfig.grpc.defaultPort}`; } + private buildHttpUrl(baseURL: string): string { + if (baseURL.includes("://")) { + const url = new URL(baseURL); + return `http://${url.hostname}:8070`; + } + + const host = baseURL.includes(":") ? baseURL.split(":")[0] : baseURL; + return `http://${host}:8070`; + } + /** * Create a type-safe tag reference. * @@ -1289,7 +1303,7 @@ export class Scrawn< async webhook(request: Request): Promise { if (!this.cachedPublicKey) { const response = await fetch( - "http://localhost:8070/api/v1/internals/webhook-endpoint/public-key", + `${this.httpUrl}/api/v1/internals/webhook-endpoint/public-key`, { headers: { Authorization: `Bearer ${this.apiKey}` } } ); if (!response.ok) diff --git a/packages/scrawn/src/core/webhook/index.ts b/packages/scrawn/src/core/webhook/index.ts index cb39a06..5cf71c8 100644 --- a/packages/scrawn/src/core/webhook/index.ts +++ b/packages/scrawn/src/core/webhook/index.ts @@ -1,4 +1,4 @@ -import { createVerify } from "node:crypto"; +import { createPublicKey, verify } from "node:crypto"; import { parseEventType } from "./types.js"; import type { WebhookEvent } from "./types.js"; @@ -50,10 +50,13 @@ function verifyEd25519( publicKeyPem: string ): boolean { try { - const verifier = createVerify("ed25519"); - verifier.update(payload); - verifier.end(); - return verifier.verify(publicKeyPem, signatureBase64, "base64"); + const publicKey = createPublicKey(publicKeyPem); + return verify( + null, + Buffer.from(payload), + publicKey, + Buffer.from(signatureBase64, "base64") + ); } catch { return false; } @@ -111,7 +114,11 @@ export async function verifyWebhook( 300 ); - let parsed: { type: string; data: Record }; + let parsed: { + type: string; + data: Record; + raw_data?: Record; + }; try { parsed = JSON.parse(rawBody); } catch { @@ -131,6 +138,10 @@ export async function verifyWebhook( ); } + const eventData = parsed.raw_data + ? { ...parsed.data, raw_data: parsed.raw_data } + : parsed.data; + switch (parsed.type) { case "payment.succeeded": return { @@ -138,7 +149,7 @@ export async function verifyWebhook( timestamp, resource: "payment" as const, action: "succeeded" as const, - data: parsed.data, + data: eventData, } as unknown as WebhookEvent; case "payment.failed": return { @@ -146,7 +157,7 @@ export async function verifyWebhook( timestamp, resource: "payment" as const, action: "failed" as const, - data: parsed.data, + data: eventData, } as unknown as WebhookEvent; default: throw new WebhookVerificationError(`Unknown event type: ${parsed.type}`); diff --git a/packages/scrawn/src/core/webhook/types.ts b/packages/scrawn/src/core/webhook/types.ts index 1a3add1..14ce679 100644 --- a/packages/scrawn/src/core/webhook/types.ts +++ b/packages/scrawn/src/core/webhook/types.ts @@ -1,15 +1,93 @@ +export interface BillingAddress { + country: string; + city?: string | null; + state?: string | null; + street?: string | null; + zipcode?: string | null; +} + +export interface CustomerLimitedDetails { + customer_id: string; + email: string; + name: string; + phone_number?: string | null; + metadata?: { [key: string]: string }; +} + +export type IntentStatus = + | "succeeded" + | "failed" + | "cancelled" + | "processing" + | "requires_customer_action" + | "requires_merchant_action" + | "requires_payment_method" + | "requires_confirmation" + | "requires_capture" + | "partially_captured" + | "partially_captured_and_capturable"; + +export interface DodoPaymentData { + billing: BillingAddress; + brand_id: string; + business_id: string; + created_at: string; + currency: string; + customer: CustomerLimitedDetails; + digital_products_delivered: boolean; + metadata: { [key: string]: string }; + payment_id: string; + total_amount: number; + status?: IntentStatus | null; + subscription_id?: string | null; + tax?: number | null; + updated_at?: string | null; + invoice_id?: string | null; + invoice_url?: string | null; + payment_method?: string | null; + payment_method_type?: string | null; + discount_id?: string | null; + discount_ids?: string[] | null; + product_cart?: Array<{ + product_id: string; + quantity: number; + }>; + refund_status?: string | null; +} + +export interface DodoPaymentSucceededEvent { + business_id: string; + data: DodoPaymentData; + timestamp: string; + type: "payment.succeeded"; +} + +export interface DodoPaymentFailedEvent { + business_id: string; + data: Record; + timestamp: string; + type: "payment.failed"; +} + export interface PaymentSucceededData { paymentId: string; checkoutSessionId: string; + userId: string; amount: number; currency: string; mode: "test" | "production"; + billed_upto: string; + createdAt: string; + raw_data: DodoPaymentSucceededEvent; } export interface PaymentFailedData { paymentId: string; checkoutSessionId: string; + userId: string; mode: "test" | "production"; + createdAt: string; + raw_data: DodoPaymentFailedEvent; } export type WebhookEvent = @@ -28,19 +106,6 @@ export type WebhookEvent = data: PaymentFailedData; }; -export type WebhookEventMap = { - "payment.succeeded": { - resource: "payment"; - action: "succeeded"; - data: PaymentSucceededData; - }; - "payment.failed": { - resource: "payment"; - action: "failed"; - data: PaymentFailedData; - }; -}; - export function parseEventType( type: string ): { resource: string; action: string } | null { diff --git a/packages/scrawn/src/core/webhook/verify.ts b/packages/scrawn/src/core/webhook/verify.ts deleted file mode 100644 index a09e462..0000000 --- a/packages/scrawn/src/core/webhook/verify.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { createVerify } from "node:crypto"; - -export class WebhookVerificationError extends Error { - constructor(message: string) { - super(message); - this.name = "WebhookVerificationError"; - } -} - -export interface WebhookHeaders { - webhookId: string; - webhookTimestamp: string; - webhookSignature: string; -} - -export function extractWebhookHeaders( - headers: Headers | Record -): WebhookHeaders { - const get = (key: string): string | undefined => { - if (headers instanceof Headers) { - const val = headers.get(key); - return val ?? undefined; - } - const val = (headers as Record)[key]; - if (Array.isArray(val)) return val[0]; - return val; - }; - - const webhookId = get("webhook-id"); - const webhookTimestamp = get("webhook-timestamp"); - const webhookSignature = get("webhook-signature"); - - if (!webhookId) { - throw new WebhookVerificationError("Missing webhook-id header"); - } - if (!webhookTimestamp) { - throw new WebhookVerificationError("Missing webhook-timestamp header"); - } - if (!webhookSignature) { - throw new WebhookVerificationError("Missing webhook-signature header"); - } - - return { webhookId, webhookTimestamp, webhookSignature }; -} - -function publicKeyPrefixedToPem(prefixedKey: string): string { - const base64Key = prefixedKey.replace("whpk_", ""); - return `-----BEGIN PUBLIC KEY-----\n${base64Key}\n-----END PUBLIC KEY-----`; -} - -function verifyEd25519( - payload: string, - signatureBase64: string, - publicKeyPem: string -): boolean { - try { - const verifier = createVerify("ed25519"); - verifier.update(payload); - verifier.end(); - return verifier.verify(publicKeyPem, signatureBase64, "base64"); - } catch { - return false; - } -} - -export interface VerificationResult { - id: string; - timestamp: string; - rawBody: string; -} - -export function verifyWebhookSignature( - rawBody: string, - headers: WebhookHeaders, - publicKeyPrefixed: string, - toleranceSeconds: number = 300 -): VerificationResult { - const { webhookId, webhookTimestamp, webhookSignature } = headers; - - if (webhookSignature.startsWith("v1a,")) { - const signature = webhookSignature.slice("v1a,".length); - const signedPayload = `${webhookId}.${webhookTimestamp}.${rawBody}`; - const publicKeyPem = publicKeyPrefixedToPem(publicKeyPrefixed); - - const isValid = verifyEd25519(signedPayload, signature, publicKeyPem); - - if (!isValid) { - throw new WebhookVerificationError("Invalid webhook signature"); - } - } else { - throw new WebhookVerificationError( - `Unsupported signature version: expected v1a, got ${ - webhookSignature.split(",")[0] - }` - ); - } - - const now = Math.floor(Date.now() / 1000); - const timestamp = parseInt(webhookTimestamp, 10); - - if (Number.isNaN(timestamp)) { - throw new WebhookVerificationError( - "Invalid webhook-timestamp: not a number" - ); - } - - if (Math.abs(now - timestamp) > toleranceSeconds) { - throw new WebhookVerificationError( - `Webhook timestamp is outside tolerance (${Math.abs( - now - timestamp - )}s > ${toleranceSeconds}s)` - ); - } - - return { - id: webhookId, - timestamp: new Date(timestamp * 1000).toISOString(), - rawBody, - }; -}