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
7 changes: 7 additions & 0 deletions .fallowrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"$schema": "node_modules/fallow/schema.json",
"ignorePatterns": [
"src/gen/**/*_connect.ts",
"src/gen/**/file_*.ts"
]
}
158 changes: 58 additions & 100 deletions bun.lock

Large diffs are not rendered by default.

23 changes: 8 additions & 15 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"type": "module",
"private": true,
"scripts": {
"gen": "cd proto && bunx buf generate",
"gen": "cd proto && bunx buf generate && bunx grpc_tools_node_protoc --js_out=import_style=commonjs,binary:../src/gen auth/v1/auth.proto event/v1/event.proto payment/v1/payment.proto",
"dev:backend": "bun --watch src/server.ts",
"test": "vitest",
"test:ui": "vitest --ui",
Expand All @@ -15,41 +15,34 @@
"proto:push": "cd proto && git add . && git commit -s -m \"$1\" && git push && cd .."
},
"devDependencies": {
"@bufbuild/protoc-gen-es": "^2.11.0",
"@types/bun": "latest",
"@types/expr-eval": "^1.1.2",
"@types/luxon": "^3.7.1",
"@vitest/ui": "^4.0.3",
"@bufbuild/buf": "^1.66.1",
"dotenv": "^17.2.3",
"drizzle-kit": "^0.31.6",
"fallow": "^2.64.0",
"grpc-tools": "^1.13.1",
"ts-protoc-gen": "^0.15.0",
"tsx": "^4.20.6",
"skills": "^1.3.1",
"vitest": "^4.0.3"
},
"peerDependencies": {
"typescript": "^5"
},
"dependencies": {
"@bufbuild/protobuf": "^2.12.0",
"@connectrpc/connect": "^2.1.1",
"@connectrpc/connect-fastify": "^2.1.1",
"@connectrpc/connect-node": "^2.1.1",
"@connectrpc/protoc-gen-connect-es": "^1.7.0",
"@connectrpc/validate": "^0.2.0",
"@typescript/native-preview": "^7.0.0-dev.20260502.1",

"bullmq": "^5.75.2",
"dodopayments": "^2.30.0",
"dotenv": "^17.2.3",
"drizzle-orm": "^0.44.7",
"expr-eval": "^2.0.2",
"fastify": "^5.8.5",
"fastify-raw-body": "^5.0.0",
"install": "^0.13.0",
"google-protobuf": "^4.0.2",
"ioredis": "^5.10.1",
"luxon": "^3.7.2",
"pino": "^10.1.0",
"pino-pretty": "^13.1.2",
"postgres": "^3.4.7",
"standardwebhooks": "^1.0.0",
"zod": "^4.1.12"
}
}
2 changes: 1 addition & 1 deletion proto
Submodule proto updated 1 files
+4 −2 buf.gen.yaml
21 changes: 0 additions & 21 deletions src/config/identifiers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,25 +35,4 @@ const ID_CONFIGS = {
*/
export const USER_ID_CONFIG = ID_CONFIGS.uuid;

/**
* Type-safe UserId type inferred from configuration.
* This type is used throughout the codebase for user identifiers.
*/
export type UserId = z.infer<typeof USER_ID_CONFIG.validator>;

/**
* Parse and validate a user ID from external input (e.g., gRPC string).
* Throws if validation fails.
*/
export function parseUserId(input: unknown): UserId {
return USER_ID_CONFIG.validator.parse(input);
}

/**
* Safely parse a user ID, returning undefined if validation fails.
* Use this when you want to handle validation errors gracefully.
*/
export function safeParseUserId(input: unknown): UserId | undefined {
const result = USER_ID_CONFIG.validator.safeParse(input);
return result.success ? result.data : undefined;
}
4 changes: 1 addition & 3 deletions src/context/auth.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1 @@
import { createContextKey } from "@connectrpc/connect";

export const apiKeyContextKey = createContextKey<string | null>(null);
export const apiKeyContextKey = Symbol.for("apiKeyContextKey");
21 changes: 9 additions & 12 deletions src/context/requestContext.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
import { createContextKey } from "@connectrpc/connect";
import { randomUUID } from "node:crypto";
import type { WideEvent } from "../errors/logger";
import { DateTime } from "luxon";

/**
* Context key for accessing the WideEventBuilder during request processing.
*/
export const wideEventContextKey = createContextKey<WideEventBuilder | null>(
null
);
export const wideEventContextKey = Symbol.for("wideEventContextKey");

/**
* Generate a unique request ID using UUID v4.
Expand Down Expand Up @@ -41,12 +39,12 @@ export class WideEventBuilder {
private startTime: number;

constructor(requestId: string, method: string, url: string) {
this.startTime = Date.now();
this.startTime = DateTime.utc().toMillis();
this.event = {
requestId,
method,
path: extractPath(url),
timestamp: new Date().toISOString(),
timestamp: DateTime.utc().toISO(),
env: process.env.NODE_ENV || "development",
};
}
Expand Down Expand Up @@ -77,9 +75,9 @@ export class WideEventBuilder {
return this;
}

/**
* Set payment/pricing context.
*/
/**
* Set payment/pricing context.
*/
setPaymentContext(data: {
creditAmount?: number;
debitAmount?: number;
Expand All @@ -92,8 +90,7 @@ export class WideEventBuilder {
this.event.debitAmount = data.debitAmount;
if (data.priceAmount !== undefined)
this.event.priceAmount = data.priceAmount;
if (data.sessionId !== undefined)
this.event.sessionId = data.sessionId;
if (data.sessionId !== undefined) this.event.sessionId = data.sessionId;
return this;
}

Expand Down Expand Up @@ -151,7 +148,7 @@ export class WideEventBuilder {
* Build the final wide event with duration calculation.
*/
build(): WideEvent {
const durationMs = Date.now() - this.startTime;
const durationMs = DateTime.utc().toMillis() - this.startTime;

return {
...this.event,
Expand Down
28 changes: 14 additions & 14 deletions src/errors/apikey.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Code, ConnectError } from "@connectrpc/connect";
import { status as Status } from "@grpc/grpc-js";

export enum APIKeyErrorType {
enum APIKeyErrorType {
INVALID_EXPIRATION = "INVALID_EXPIRATION",
INVALID_NAME = "INVALID_NAME",
CREATION_FAILED = "CREATION_FAILED",
Expand All @@ -14,20 +14,20 @@ export interface APIKeyErrorContext {
type: APIKeyErrorType;
message: string;
originalError?: Error;
code: Code;
code: number;
}

export class APIKeyError extends ConnectError {
export class APIKeyError extends Error {
readonly type: APIKeyErrorType;
readonly originalError?: Error;
readonly code: Status;

constructor(context: APIKeyErrorContext) {
super(context.message, context.code);
super(context.message);
this.name = "APIKeyError";
this.type = context.type;
this.originalError = context.originalError;

Object.setPrototypeOf(this, APIKeyError.prototype);
this.code = context.code;
}

static invalidExpiration(
Expand All @@ -39,7 +39,7 @@ export class APIKeyError extends ConnectError {
message: details
? `Invalid expiration: ${details}`
: "Invalid expiration time",
code: Code.InvalidArgument,
code: Status.INVALID_ARGUMENT,
originalError,
});
}
Expand All @@ -48,7 +48,7 @@ export class APIKeyError extends ConnectError {
return new APIKeyError({
type: APIKeyErrorType.INVALID_NAME,
message: details ? `Invalid name: ${details}` : "Invalid API key name",
code: Code.InvalidArgument,
code: Status.INVALID_ARGUMENT,
originalError,
});
}
Expand All @@ -59,7 +59,7 @@ export class APIKeyError extends ConnectError {
message: details
? `Failed to create API key: ${details}`
: "Failed to create API key",
code: Code.Internal,
code: Status.INTERNAL,
originalError,
});
}
Expand All @@ -70,7 +70,7 @@ export class APIKeyError extends ConnectError {
message: apiKeyId
? `API key not found: ${apiKeyId}`
: "API key not found",
code: Code.NotFound,
code: Status.NOT_FOUND,
originalError,
});
}
Expand All @@ -84,7 +84,7 @@ export class APIKeyError extends ConnectError {
message: details
? `Failed to revoke API key: ${details}`
: "Failed to revoke API key",
code: Code.Internal,
code: Status.INTERNAL,
originalError,
});
}
Expand All @@ -93,7 +93,7 @@ export class APIKeyError extends ConnectError {
return new APIKeyError({
type: APIKeyErrorType.VALIDATION_FAILED,
message: `API key validation failed: ${details}`,
code: Code.InvalidArgument,
code: Status.INVALID_ARGUMENT,
originalError,
});
}
Expand All @@ -103,7 +103,7 @@ export class APIKeyError extends ConnectError {
return new APIKeyError({
type: APIKeyErrorType.UNKNOWN,
message: `Unexpected API key error: ${details}`,
code: Code.Internal,
code: Status.INTERNAL,
originalError,
});
}
Expand Down
32 changes: 14 additions & 18 deletions src/errors/auth.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Code, ConnectError } from "@connectrpc/connect";
import { status as Status } from "@grpc/grpc-js";

export enum AuthErrorType {
enum AuthErrorType {
MISSING_HEADER = "MISSING_HEADER",
INVALID_HEADER_FORMAT = "INVALID_HEADER_FORMAT",
INVALID_API_KEY = "INVALID_API_KEY",
Expand All @@ -14,71 +14,67 @@ export interface AuthErrorContext {
type: AuthErrorType;
message: string;
originalError?: Error;
code: Code;
code: Status;
}

export class AuthError extends ConnectError {
export class AuthError extends Error {
readonly type: AuthErrorType;
readonly originalError?: Error;
readonly code: Status;

constructor(context: AuthErrorContext) {
super(context.message, context.code);
super(context.message);
this.name = "AuthError";
this.type = context.type;
this.originalError = context.originalError;

// Fix prototype chain for instanceof checks
// Must set AuthError.prototype after ConnectError constructor
if (Object.getPrototypeOf(this) !== AuthError.prototype) {
Object.setPrototypeOf(this, AuthError.prototype);
}
this.code = context.code;
}

static missingHeader(): AuthError {
return new AuthError({
type: AuthErrorType.MISSING_HEADER,
message: "Missing Authorization header",
code: Code.Unauthenticated,
code: Status.UNAUTHENTICATED,
});
}

static invalidHeaderFormat(): AuthError {
return new AuthError({
type: AuthErrorType.INVALID_HEADER_FORMAT,
message: 'Authorization header must be in format "Bearer <api_key>"',
code: Code.Unauthenticated,
code: Status.UNAUTHENTICATED,
});
}

static invalidAPIKey(details?: string): AuthError {
return new AuthError({
type: AuthErrorType.INVALID_API_KEY,
message: details ? `Invalid API key: ${details}` : "Invalid API key",
code: Code.Unauthenticated,
code: Status.UNAUTHENTICATED,
});
}

static expiredAPIKey(): AuthError {
return new AuthError({
type: AuthErrorType.EXPIRED_API_KEY,
message: "API key has expired",
code: Code.Unauthenticated,
code: Status.UNAUTHENTICATED,
});
}

static revokedAPIKey(): AuthError {
return new AuthError({
type: AuthErrorType.REVOKED_API_KEY,
message: "API key has been revoked",
code: Code.Unauthenticated,
code: Status.UNAUTHENTICATED,
});
}

static databaseError(originalError?: Error): AuthError {
return new AuthError({
type: AuthErrorType.DATABASE_ERROR,
message: "Failed to verify API key",
code: Code.Internal,
code: Status.INTERNAL,
originalError,
});
}
Expand All @@ -88,7 +84,7 @@ export class AuthError extends ConnectError {
return new AuthError({
type: AuthErrorType.UNKNOWN,
message: `Unexpected authentication error: ${details}`,
code: Code.Internal,
code: Status.INTERNAL,
originalError,
});
}
Expand Down
Loading
Loading