Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ready-horses-punch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@scrawn/core": patch
---
Comment on lines +1 to +3

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Adding httpUrl as a required constructor argument is a breaking change: any existing caller that does not pass httpUrl will now receive a ScrawnConfigError at runtime. Under semantic versioning this requires at least a minor bump; if the library is already at a stable major version it may warrant a major bump. A patch release communicates bug fixes or non-breaking additions, which does not describe this change.

Suggested change
---
"@scrawn/core": patch
---
---
"@scrawn/core": minor
---

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


feat: pass in httpurl and webhook public key to constructor
2 changes: 2 additions & 0 deletions examples/scrawn/biller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import { TAGS, EXPRESSIONS } from "./pricerefs.ts";
export const biller = scrawn({
apiKey: process.env.SCRAWN_KEY as string,
baseURL: process.env.SCRAWN_BASE_URL as string,
httpUrl: process.env.SCRAWN_HTTP_URL as string,
secure: process.env.SCRAWN_BASE_URL?.startsWith("https") ?? false,
tags: TAGS,
expressions: EXPRESSIONS,
webhookPublicKey: process.env.SCRAWN_WEBHOOK_PUBLIC_KEY,
});
8 changes: 2 additions & 6 deletions packages/scrawn/src/core/auth/apiKeyAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,11 @@ const API_KEY_REGEX = /^scrn_(dash|live|test)_[a-zA-Z0-9]{32}$/;
/**
* Type guard to validate API key format
*/
function isValidApiKey(key: string): key is ApiKeyFormat {
export function isValidApiKey(key: string): key is ApiKeyFormat {
return API_KEY_REGEX.test(key);
}

/**
* Validates and returns a properly typed API key
* @throws Error if the API key format is invalid
*/
function validateApiKey(key: string): ApiKeyFormat {
export function validateApiKey(key: string): ApiKeyFormat {
if (!isValidApiKey(key)) {
log.error(`Invalid API key format: "${key}".`);
throw new ScrawnValidationError(
Expand Down
43 changes: 30 additions & 13 deletions packages/scrawn/src/core/scrawn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ const log = new ScrawnLogger("Scrawn");
* const biller = scrawn({
* apiKey: process.env.SCRAWN_KEY,
* baseURL: 'http://localhost:8069',
* httpUrl: 'http://localhost:8070',
* tags: ["PREMIUM_CALL", "EXTRA_FEE"] as const,
* });
*
Expand Down Expand Up @@ -205,23 +206,27 @@ export class Scrawn<
*
* @param config - Configuration object
* @param config.apiKey - Your Scrawn API key for authentication
* @param config.baseURL - Base URL for the Scrawn API (e.g., 'https://api.scrawn.dev')
* @param config.baseURL - Base URL for the Scrawn gRPC API (e.g., 'http://localhost:8069')
* @param config.httpUrl - HTTP URL for the Scrawn HTTP API (e.g., 'http://localhost:8070')
*
* @example
* ```typescript
* const scrawn = new Scrawn({
* apiKey: 'sk_test_...',
* baseURL: 'https://api.scrawn.dev'
* baseURL: 'http://localhost:8069',
* httpUrl: 'http://localhost:8070',
* });
* await scrawn.init();
* ```
*/
constructor(config: {
apiKey: AllCredentials["apiKey"];
baseURL: string;
httpUrl: string;
secure?: boolean;
credentials?: import("@grpc/grpc-js").ChannelCredentials;
retryCount?: number;
webhookPublicKey?: string;
}) {
try {
// Validate configuration
Expand All @@ -243,9 +248,21 @@ export class Scrawn<
);
}

if (!config.httpUrl || typeof config.httpUrl !== "string") {
throw new ScrawnConfigError(
"httpUrl is required and must be a string",
{
details: { provided: typeof config.httpUrl },
}
);
}

this.apiKey = config.apiKey;
this.retryCount = config.retryCount ?? 2;
this.httpUrl = this.buildHttpUrl(config.baseURL);
this.httpUrl = config.httpUrl;
if (config.webhookPublicKey) {
this.cachedPublicKey = config.webhookPublicKey;
}
Comment on lines +263 to +265

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 No validation of the supplied webhookPublicKey

The key is cached as-is without any format check. If a caller pastes a malformed or empty-after-trim key, verifyWebhook will use it silently and every webhook verification will fail — with no indication at construction time that the supplied key is wrong. A basic format guard (e.g., minimum length, expected PEM prefix) would surface the misconfiguration immediately.

this.grpcClient = new GrpcClient(this.parseURLToTarget(config.baseURL), {
secure: config.secure ?? true,
credentials: config.credentials,
Expand All @@ -268,16 +285,6 @@ 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.
*
Expand Down Expand Up @@ -1307,6 +1314,7 @@ export class Scrawn<
export interface ScrawnInitConfig {
apiKey: string;
baseURL: string;
httpUrl: string;
secure?: boolean;
credentials?: import("@grpc/grpc-js").ChannelCredentials;
tags?: readonly string[];
Expand All @@ -1317,6 +1325,12 @@ export interface ScrawnInitConfig {
* Each event also gets a manual `.retry()` context in the onError callback.
*/
retryCount?: number;
/**
* Optional webhook public key to skip fetching it from the backend.
* The dashboard displays this key — paste it here to avoid an extra HTTP
* call on every cold start of biller.webhook().
*/
webhookPublicKey?: string;
}

/**
Expand All @@ -1333,6 +1347,7 @@ export interface ScrawnInitConfig {
* const biller = scrawn({
* apiKey: process.env.SCRAWN_KEY,
* baseURL: process.env.SCRAWN_BASE_URL,
* httpUrl: process.env.SCRAWN_HTTP_URL,
* tags: ["PREMIUM_CALL", "EXTRA_FEE"] as const,
* expressions: ["MY_EXPR"] as const,
* });
Expand Down Expand Up @@ -1363,8 +1378,10 @@ export function scrawn(
return new Scrawn({
apiKey: config.apiKey as AllCredentials["apiKey"],
baseURL: config.baseURL,
httpUrl: config.httpUrl,
secure: config.secure,
credentials: config.credentials,
retryCount: config.retryCount,
webhookPublicKey: config.webhookPublicKey,
});
}
5 changes: 5 additions & 0 deletions packages/scrawn/tests/unit/scrawn/middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ describe("middlewareEventConsumer", () => {
const biller = scrawn({
apiKey: validKey,
baseURL: "https://api.example",
httpUrl: "https://api.example",
});
attachMockClient(biller);
const middleware = biller.middlewareEventConsumer({
Expand All @@ -73,6 +74,7 @@ describe("middlewareEventConsumer", () => {
const biller = scrawn({
apiKey: validKey,
baseURL: "https://api.example",
httpUrl: "https://api.example",
});
attachMockClient(biller);
const middleware = biller.middlewareEventConsumer({
Expand All @@ -92,6 +94,7 @@ describe("middlewareEventConsumer", () => {
const biller = scrawn({
apiKey: validKey,
baseURL: "https://api.example",
httpUrl: "https://api.example",
});
attachMockClient(biller);
const middleware = biller.middlewareEventConsumer({
Expand All @@ -110,6 +113,7 @@ describe("middlewareEventConsumer", () => {
const biller = scrawn({
apiKey: validKey,
baseURL: "https://api.example",
httpUrl: "https://api.example",
retryCount: 0,
});
attachMockClient(biller);
Expand All @@ -135,6 +139,7 @@ describe("middlewareEventConsumer", () => {
const biller = scrawn({
apiKey: validKey,
baseURL: "https://api.example",
httpUrl: "https://api.example",
});
attachMockClient(biller);
const onError = vi.fn();
Expand Down
11 changes: 11 additions & 0 deletions packages/scrawn/tests/unit/scrawn/scrawn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ describe("Scrawn", () => {
const biller = scrawn({
apiKey: validKey,
baseURL: "https://api.example",
httpUrl: "https://api.example",
httpUrl: "https://api.example",
httpUrl: "https://api.example",
});
Comment on lines 59 to 65

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Duplicate object keys cause TypeScript compilation errors

The same httpUrl key appears two or three times inside a single object literal in several test cases (lines 62-64, 83-84, 101-102, 122-123, 136-137). TypeScript's strict mode treats duplicate object literal keys as a compile-time error (An object literal cannot have multiple properties with the same name). Even in lenient environments the last value silently wins, which is confusing and masks intent. These duplicate entries should be collapsed to a single httpUrl property in each object.

This pattern repeats across at least five test cases in this file.

attachMockClient(biller);

Expand All @@ -77,6 +80,8 @@ describe("Scrawn", () => {
const biller = scrawn({
apiKey: validKey,
baseURL: "https://api.example",
httpUrl: "https://api.example",
httpUrl: "https://api.example",
});
attachMockClient(biller);

Expand All @@ -93,6 +98,8 @@ describe("Scrawn", () => {
const biller = scrawn({
apiKey: validKey,
baseURL: "https://api.example",
httpUrl: "https://api.example",
httpUrl: "https://api.example",
});
attachMockClient(biller);
const link = await biller.collectPayment("user_1");
Expand All @@ -112,6 +119,8 @@ describe("Scrawn", () => {
const biller = scrawn({
apiKey: validKey,
baseURL: "https://api.example",
httpUrl: "https://api.example",
httpUrl: "https://api.example",
});
attachMockClient(biller);

Expand All @@ -124,6 +133,8 @@ describe("Scrawn", () => {
const biller = scrawn({
apiKey: validKey,
baseURL: "https://api.example",
httpUrl: "https://api.example",
httpUrl: "https://api.example",
retryCount: 0,
});
const onError = vi.fn();
Expand Down
Loading