From e8e0900e7bf861b720b77c1a7b710aad4173ddc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iwo=20Strzebo=C5=84ski?= Date: Fri, 28 Aug 2026 16:11:35 +0000 Subject: [PATCH] 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 --- .env.example | 6 + .gitignore | 20 +++ README.md | 10 ++ package.json | 15 ++ packages/rust-core/Cargo.toml | 13 ++ packages/rust-core/src/error.rs | 18 +++ packages/rust-core/src/gcode.rs | 185 +++++++++++++++++++++++++ packages/rust-core/src/lib.rs | 6 + packages/rust-core/src/tracing_init.rs | 14 ++ packages/ts-core/package.json | 31 +++++ packages/ts-core/src/auth.ts | 80 +++++++++++ packages/ts-core/src/errors.ts | 90 ++++++++++++ packages/ts-core/src/index.ts | 3 + packages/ts-core/src/logger.ts | 65 +++++++++ packages/ts-core/tsconfig.json | 18 +++ 15 files changed, 574 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 package.json create mode 100644 packages/rust-core/Cargo.toml create mode 100644 packages/rust-core/src/error.rs create mode 100644 packages/rust-core/src/gcode.rs create mode 100644 packages/rust-core/src/lib.rs create mode 100644 packages/rust-core/src/tracing_init.rs create mode 100644 packages/ts-core/package.json create mode 100644 packages/ts-core/src/auth.ts create mode 100644 packages/ts-core/src/errors.ts create mode 100644 packages/ts-core/src/index.ts create mode 100644 packages/ts-core/src/logger.ts create mode 100644 packages/ts-core/tsconfig.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5f7ebb9 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# continuum-common is a library repo (ts-core / rust-core) and has no runtime +# of its own. These vars are only needed when running this package's tests, +# which exercise the Supabase JWT verification helper against real settings. + +SUPABASE_URL=https://your-project.supabase.co +SUPABASE_JWT_SECRET=your-supabase-jwt-secret diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6f2a230 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# Node / Bun +node_modules/ +dist/ +*.tsbuildinfo +bun.lockb +.bun/ + +# Rust +target/ +Cargo.lock + +# Env +.env +.env.local + +# Editors / OS +.DS_Store +.vscode/ +.idea/ +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..53d3de9 --- /dev/null +++ b/README.md @@ -0,0 +1,10 @@ +# continuum-common + +Shared, dependency-light building blocks used across the Continuum cloud and edge services. + +## Packages + +- **`packages/ts-core`** — TypeScript package consumed by `continuum-backend` (and other Bun/Node services). Provides a structured logger, a Supabase JWT verification helper, and ElysiaJS-compatible HTTP error transformers. +- **`packages/rust-core`** — Rust crate consumed by `continuum-proxy` and `continuum-ai-worker`. Provides a shared `thiserror`-based error type, `tracing` initialization, and G-code checksum/header parsing helpers. + +Each package is versioned and published independently; see their own READMEs (inline doc comments) for usage. diff --git a/package.json b/package.json new file mode 100644 index 0000000..5312d5f --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "continuum-common", + "private": true, + "version": "0.1.0", + "workspaces": [ + "packages/ts-core" + ], + "scripts": { + "build": "bun run --filter '*' build", + "typecheck": "bun run --filter '*' typecheck" + }, + "engines": { + "bun": ">=1.1.0" + } +} diff --git a/packages/rust-core/Cargo.toml b/packages/rust-core/Cargo.toml new file mode 100644 index 0000000..7e6cd22 --- /dev/null +++ b/packages/rust-core/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "continuum-core" +version = "0.1.0" +edition = "2021" +description = "Shared error handling, tracing setup, and G-code helpers for Continuum Rust services" + +[dependencies] +thiserror = "1.0" +anyhow = "1.0" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } + +[dev-dependencies] diff --git a/packages/rust-core/src/error.rs b/packages/rust-core/src/error.rs new file mode 100644 index 0000000..f6ac4ad --- /dev/null +++ b/packages/rust-core/src/error.rs @@ -0,0 +1,18 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum Error { + #[error("io error: {0}")] + Io(#[from] std::io::Error), + + #[error("invalid g-code checksum on line: {line}")] + GcodeChecksum { line: String }, + + #[error("malformed g-code header: {reason}")] + GcodeHeader { reason: String }, + + #[error("{0}")] + Other(#[from] anyhow::Error), +} + +pub type Result = std::result::Result; diff --git a/packages/rust-core/src/gcode.rs b/packages/rust-core/src/gcode.rs new file mode 100644 index 0000000..b5a6ea3 --- /dev/null +++ b/packages/rust-core/src/gcode.rs @@ -0,0 +1,185 @@ +use crate::error::{Error, Result}; +use std::collections::BTreeMap; + +/// XORs every byte in `data` together, per the standard Marlin/RepRap +/// G-code checksum scheme (checksum covers everything before the `*`, +/// including any leading `N ` line number). +pub fn compute_checksum(data: &[u8]) -> u8 { + data.iter().fold(0u8, |acc, &b| acc ^ b) +} + +/// Splits a line into its content and trailing `*NN` checksum, if present. +pub fn split_checksum(line: &str) -> (&str, Option) { + match line.rsplit_once('*') { + Some((content, checksum_str)) => match checksum_str.trim().parse::() { + Ok(value) => (content, Some(value)), + Err(_) => (line, None), + }, + None => (line, None), + } +} + +/// Verifies a checksummed G-code line (e.g. `N12 G1 X10 Y20*43`). +/// Returns `Err` if the line carries no `*NN` suffix to verify against. +pub fn verify_line_checksum(line: &str) -> Result { + let (content, checksum) = split_checksum(line); + match checksum { + Some(expected) => Ok(compute_checksum(content.as_bytes()) == expected), + None => Err(Error::GcodeChecksum { + line: line.to_string(), + }), + } +} + +/// Appends a computed `*NN` checksum to a line that doesn't have one yet. +pub fn append_checksum(line: &str) -> String { + format!("{line}*{}", compute_checksum(line.as_bytes())) +} + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct GcodeHeader { + pub slicer: Option, + pub filament_type: Option, + pub nozzle_diameter: Option, + pub estimated_print_time_secs: Option, + /// Every `; key = value` comment field found, keyed by `key`. + pub raw_fields: BTreeMap, +} + +/// Parses slicer metadata out of a G-code file's `;`-prefixed comment lines. +/// Handles the PrusaSlicer/SuperSlicer/BambuStudio `; key = value` config +/// block as well as Cura's `;KEY:value` header and both slicers' free-form +/// `generated by` / estimated-time comments. +pub fn parse_header(gcode: &str) -> GcodeHeader { + let mut header = GcodeHeader::default(); + + for raw_line in gcode.lines() { + let line = raw_line.trim(); + let comment = match line.strip_prefix(';') { + Some(rest) => rest.trim(), + None => continue, + }; + if comment.is_empty() { + continue; + } + + if header.slicer.is_none() { + if let Some(rest) = comment + .strip_prefix("generated by ") + .or_else(|| comment.strip_prefix("Generated by ")) + { + let name = rest.split(" on ").next().unwrap_or(rest).trim(); + header.slicer = Some(name.to_string()); + continue; + } + if let Some(rest) = comment + .strip_prefix("Generated with ") + .or_else(|| comment.strip_prefix("generated with ")) + { + header.slicer = Some(rest.trim().to_string()); + continue; + } + } + + if let Some((key, value)) = comment.split_once('=') { + let key = key.trim().to_string(); + let value = value.trim().to_string(); + if !key.is_empty() && !value.is_empty() { + match key.as_str() { + "filament_type" => header.filament_type = Some(value.clone()), + "nozzle_diameter" => header.nozzle_diameter = Some(value.clone()), + _ => {} + } + if key.starts_with("estimated printing time") { + header.estimated_print_time_secs = parse_duration(&value); + } + header.raw_fields.insert(key, value); + } + continue; + } + + if let Some((key, value)) = comment.split_once(':') { + let key = key.trim(); + let value = value.trim(); + if key.eq_ignore_ascii_case("TIME") { + header.estimated_print_time_secs = + value.parse::().ok().or_else(|| parse_duration(value)); + } + } + } + + header +} + +/// Parses slicer duration strings like `1h 23m 45s` or `45m 2s` into seconds. +fn parse_duration(s: &str) -> Option { + let mut total: u64 = 0; + let mut digits = String::new(); + let mut matched_any = false; + + for c in s.chars() { + if c.is_ascii_digit() { + digits.push(c); + } else if matches!(c, 'd' | 'h' | 'm' | 's') { + if digits.is_empty() { + continue; + } + let n: u64 = digits.parse().ok()?; + digits.clear(); + matched_any = true; + total += match c { + 'd' => n * 86_400, + 'h' => n * 3_600, + 'm' => n * 60, + 's' => n, + _ => 0, + }; + } else { + digits.clear(); + } + } + + matched_any.then_some(total) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn checksum_roundtrip() { + let line = "N12 G1 X10.0 Y20.0 F1500.0"; + let with_checksum = append_checksum(line); + assert!(verify_line_checksum(&with_checksum).unwrap()); + } + + #[test] + fn checksum_detects_corruption() { + let good = append_checksum("N1 G28"); + let corrupted = good.replace("G28", "G29"); + assert!(!verify_line_checksum(&corrupted).unwrap()); + } + + #[test] + fn header_parses_prusaslicer_style() { + let gcode = "\ +; generated by PrusaSlicer 2.7.0+win64 on 2026-01-01 at 12:00:00 +; filament_type = PLA +; nozzle_diameter = 0.4 +; estimated printing time (normal mode) = 1h 23m 45s +G28 +"; + let header = parse_header(gcode); + assert_eq!(header.slicer.as_deref(), Some("PrusaSlicer 2.7.0+win64")); + assert_eq!(header.filament_type.as_deref(), Some("PLA")); + assert_eq!(header.estimated_print_time_secs, Some(5025)); + } + + #[test] + fn header_parses_cura_style() { + let gcode = ";Generated with Cura_SteamEngine 5.6.0\n;TIME:3725\nG28\n"; + let header = parse_header(gcode); + assert_eq!(header.slicer.as_deref(), Some("Cura_SteamEngine 5.6.0")); + assert_eq!(header.estimated_print_time_secs, Some(3725)); + } +} diff --git a/packages/rust-core/src/lib.rs b/packages/rust-core/src/lib.rs new file mode 100644 index 0000000..6d4364b --- /dev/null +++ b/packages/rust-core/src/lib.rs @@ -0,0 +1,6 @@ +pub mod error; +pub mod gcode; +pub mod tracing_init; + +pub use error::{Error, Result}; +pub use tracing_init::init_tracing; diff --git a/packages/rust-core/src/tracing_init.rs b/packages/rust-core/src/tracing_init.rs new file mode 100644 index 0000000..67c9147 --- /dev/null +++ b/packages/rust-core/src/tracing_init.rs @@ -0,0 +1,14 @@ +use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; + +/// Initializes a JSON-formatted `tracing` subscriber driven by `RUST_LOG` +/// (defaulting to `info`). Call once at process startup. +pub fn init_tracing(service_name: &str) { + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); + + tracing_subscriber::registry() + .with(filter) + .with(fmt::layer().json().with_target(true)) + .init(); + + tracing::info!(service = service_name, "tracing initialized"); +} diff --git a/packages/ts-core/package.json b/packages/ts-core/package.json new file mode 100644 index 0000000..4032643 --- /dev/null +++ b/packages/ts-core/package.json @@ -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" + } +} diff --git a/packages/ts-core/src/auth.ts b/packages/ts-core/src/auth.ts new file mode 100644 index 0000000..0ed0434 --- /dev/null +++ b/packages/ts-core/src/auth.ts @@ -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; + user_metadata?: Record; +} + +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; +} + +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 { + 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 { + 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; +} diff --git a/packages/ts-core/src/errors.ts b/packages/ts-core/src/errors.ts new file mode 100644 index 0000000..a8275dc --- /dev/null +++ b/packages/ts-core/src/errors.ts @@ -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", + }, + }; +} diff --git a/packages/ts-core/src/index.ts b/packages/ts-core/src/index.ts new file mode 100644 index 0000000..7cd3270 --- /dev/null +++ b/packages/ts-core/src/index.ts @@ -0,0 +1,3 @@ +export * from "./logger"; +export * from "./auth"; +export * from "./errors"; diff --git a/packages/ts-core/src/logger.ts b/packages/ts-core/src/logger.ts new file mode 100644 index 0000000..f40748d --- /dev/null +++ b/packages/ts-core/src/logger.ts @@ -0,0 +1,65 @@ +export type LogLevel = "debug" | "info" | "warn" | "error"; + +const LEVEL_WEIGHT: Record = { + debug: 10, + info: 20, + warn: 30, + error: 40, +}; + +export interface LoggerOptions { + name: string; + level?: LogLevel; + base?: Record; +} + +export interface Logger { + debug(msg: string, fields?: Record): void; + info(msg: string, fields?: Record): void; + warn(msg: string, fields?: Record): void; + error(msg: string, fields?: Record): void; + child(fields: Record): Logger; +} + +function write( + level: LogLevel, + minLevel: LogLevel, + name: string, + base: Record, + msg: string, + fields?: Record, +): 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 } }), + }; +} diff --git a/packages/ts-core/tsconfig.json b/packages/ts-core/tsconfig.json new file mode 100644 index 0000000..7c00750 --- /dev/null +++ b/packages/ts-core/tsconfig.json @@ -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"] +}