Initial boilerplate scaffold for continuum-common
Adds ts-core (logger, Supabase JWT verification, Elysia error transformers) and rust-core (thiserror error type, tracing init, G-code checksum/header parsing) packages. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@continuum/ts-core",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"module": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"jose": "^5.9.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"elysia": ">=1.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"elysia": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.1.14",
|
||||
"typescript": "^5.6.3",
|
||||
"elysia": "^1.1.26"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";
|
||||
|
||||
export interface SupabaseClaims extends JWTPayload {
|
||||
sub: string;
|
||||
email?: string;
|
||||
role?: string;
|
||||
aud: string | string[];
|
||||
app_metadata?: Record<string, unknown>;
|
||||
user_metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class JwtVerificationError extends Error {
|
||||
constructor(message: string, readonly cause?: unknown) {
|
||||
super(message);
|
||||
this.name = "JwtVerificationError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface VerifierOptions {
|
||||
/** Supabase project URL, e.g. https://xyzcompany.supabase.co */
|
||||
supabaseUrl: string;
|
||||
/**
|
||||
* Legacy HS256 project JWT secret. When provided, verification uses this
|
||||
* shared secret instead of fetching the project's JWKS. Prefer leaving
|
||||
* this unset for projects on Supabase's newer asymmetric (ES256/RS256)
|
||||
* signing keys.
|
||||
*/
|
||||
jwtSecret?: string;
|
||||
audience?: string;
|
||||
}
|
||||
|
||||
export interface SupabaseJwtVerifier {
|
||||
verify(token: string): Promise<SupabaseClaims>;
|
||||
}
|
||||
|
||||
export function createSupabaseJwtVerifier(options: VerifierOptions): SupabaseJwtVerifier {
|
||||
const audience = options.audience ?? "authenticated";
|
||||
|
||||
if (options.jwtSecret) {
|
||||
const key = new TextEncoder().encode(options.jwtSecret);
|
||||
return {
|
||||
async verify(token: string): Promise<SupabaseClaims> {
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, key, {
|
||||
algorithms: ["HS256"],
|
||||
audience,
|
||||
});
|
||||
return payload as SupabaseClaims;
|
||||
} catch (err) {
|
||||
throw new JwtVerificationError("failed to verify Supabase JWT (HS256)", err);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const jwksUrl = new URL("/auth/v1/.well-known/jwks.json", options.supabaseUrl);
|
||||
const jwks = createRemoteJWKSet(jwksUrl);
|
||||
|
||||
return {
|
||||
async verify(token: string): Promise<SupabaseClaims> {
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, jwks, { audience });
|
||||
return payload as SupabaseClaims;
|
||||
} catch (err) {
|
||||
throw new JwtVerificationError("failed to verify Supabase JWT (JWKS)", err);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function extractBearerToken(authorizationHeader: string | null | undefined): string {
|
||||
if (!authorizationHeader) {
|
||||
throw new JwtVerificationError("missing Authorization header");
|
||||
}
|
||||
const [scheme, token] = authorizationHeader.split(" ");
|
||||
if (scheme !== "Bearer" || !token) {
|
||||
throw new JwtVerificationError("Authorization header is not a Bearer token");
|
||||
}
|
||||
return token;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
export abstract class HttpError extends Error {
|
||||
abstract readonly status: number;
|
||||
abstract readonly code: string;
|
||||
|
||||
constructor(message: string, readonly details?: unknown) {
|
||||
super(message);
|
||||
this.name = new.target.name;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
error: {
|
||||
code: this.code,
|
||||
message: this.message,
|
||||
details: this.details,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class BadRequestError extends HttpError {
|
||||
readonly status = 400;
|
||||
readonly code = "BAD_REQUEST";
|
||||
}
|
||||
|
||||
export class UnauthorizedError extends HttpError {
|
||||
readonly status = 401;
|
||||
readonly code = "UNAUTHORIZED";
|
||||
}
|
||||
|
||||
export class ForbiddenError extends HttpError {
|
||||
readonly status = 403;
|
||||
readonly code = "FORBIDDEN";
|
||||
}
|
||||
|
||||
export class NotFoundError extends HttpError {
|
||||
readonly status = 404;
|
||||
readonly code = "NOT_FOUND";
|
||||
}
|
||||
|
||||
export class ConflictError extends HttpError {
|
||||
readonly status = 409;
|
||||
readonly code = "CONFLICT";
|
||||
}
|
||||
|
||||
export class InternalError extends HttpError {
|
||||
readonly status = 500;
|
||||
readonly code = "INTERNAL_ERROR";
|
||||
}
|
||||
|
||||
interface ElysiaErrorContext {
|
||||
code: string | number;
|
||||
error: unknown;
|
||||
set: { status?: number | string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop-in handler for Elysia's `.onError()`. Maps our HttpError hierarchy,
|
||||
* Elysia's built-in error codes, and anything unrecognized to a consistent
|
||||
* `{ error: { code, message, details? } }` JSON body.
|
||||
*/
|
||||
export function toErrorResponse({ code, error, set }: ElysiaErrorContext) {
|
||||
if (error instanceof HttpError) {
|
||||
set.status = error.status;
|
||||
return error.toJSON();
|
||||
}
|
||||
|
||||
if (code === "VALIDATION") {
|
||||
set.status = 422;
|
||||
return {
|
||||
error: {
|
||||
code: "VALIDATION",
|
||||
message: error instanceof Error ? error.message : "validation failed",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (code === "NOT_FOUND") {
|
||||
set.status = 404;
|
||||
return { error: { code: "NOT_FOUND", message: "not found" } };
|
||||
}
|
||||
|
||||
set.status = 500;
|
||||
return {
|
||||
error: {
|
||||
code: "INTERNAL_ERROR",
|
||||
message: error instanceof Error ? error.message : "internal server error",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./logger";
|
||||
export * from "./auth";
|
||||
export * from "./errors";
|
||||
@@ -0,0 +1,65 @@
|
||||
export type LogLevel = "debug" | "info" | "warn" | "error";
|
||||
|
||||
const LEVEL_WEIGHT: Record<LogLevel, number> = {
|
||||
debug: 10,
|
||||
info: 20,
|
||||
warn: 30,
|
||||
error: 40,
|
||||
};
|
||||
|
||||
export interface LoggerOptions {
|
||||
name: string;
|
||||
level?: LogLevel;
|
||||
base?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface Logger {
|
||||
debug(msg: string, fields?: Record<string, unknown>): void;
|
||||
info(msg: string, fields?: Record<string, unknown>): void;
|
||||
warn(msg: string, fields?: Record<string, unknown>): void;
|
||||
error(msg: string, fields?: Record<string, unknown>): void;
|
||||
child(fields: Record<string, unknown>): Logger;
|
||||
}
|
||||
|
||||
function write(
|
||||
level: LogLevel,
|
||||
minLevel: LogLevel,
|
||||
name: string,
|
||||
base: Record<string, unknown>,
|
||||
msg: string,
|
||||
fields?: Record<string, unknown>,
|
||||
): void {
|
||||
if (LEVEL_WEIGHT[level] < LEVEL_WEIGHT[minLevel]) return;
|
||||
|
||||
const record = {
|
||||
time: new Date().toISOString(),
|
||||
level,
|
||||
name,
|
||||
msg,
|
||||
...base,
|
||||
...fields,
|
||||
};
|
||||
|
||||
const line = JSON.stringify(record);
|
||||
if (level === "error") {
|
||||
console.error(line);
|
||||
} else if (level === "warn") {
|
||||
console.warn(line);
|
||||
} else {
|
||||
console.log(line);
|
||||
}
|
||||
}
|
||||
|
||||
export function createLogger(options: LoggerOptions): Logger {
|
||||
const level = options.level ?? (process.env.LOG_LEVEL as LogLevel | undefined) ?? "info";
|
||||
const base = options.base ?? {};
|
||||
|
||||
return {
|
||||
debug: (msg, fields) => write("debug", level, options.name, base, msg, fields),
|
||||
info: (msg, fields) => write("info", level, options.name, base, msg, fields),
|
||||
warn: (msg, fields) => write("warn", level, options.name, base, msg, fields),
|
||||
error: (msg, fields) => write("error", level, options.name, base, msg, fields),
|
||||
child: (fields) =>
|
||||
createLogger({ name: options.name, level, base: { ...base, ...fields } }),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022"],
|
||||
"strict": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"verbatimModuleSyntax": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user