Compare commits
4 Commits
e8e0900e7b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
791b5135f4
|
|||
|
7fc76f0ad1
|
|||
|
0fa4bfaf45
|
|||
|
ea28211380
|
@@ -3,6 +3,7 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
bun.lockb
|
bun.lockb
|
||||||
|
bun.lock
|
||||||
.bun/
|
.bun/
|
||||||
|
|
||||||
# Rust
|
# Rust
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Shared, dependency-light building blocks used across the Continuum cloud and edg
|
|||||||
|
|
||||||
## Packages
|
## 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/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.
|
- **`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.
|
Each package is versioned and published independently; see their own READMEs (inline doc comments) for usage.
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
|
/// One error type for everything in this crate. `Parse` covers G-code
|
||||||
|
/// parsing problems; `Other` is an escape hatch for anything else via `?`.
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum Error {
|
pub enum Error {
|
||||||
#[error("io error: {0}")]
|
#[error("io error: {0}")]
|
||||||
Io(#[from] std::io::Error),
|
Io(#[from] std::io::Error),
|
||||||
|
|
||||||
#[error("invalid g-code checksum on line: {line}")]
|
#[error("{0}")]
|
||||||
GcodeChecksum { line: String },
|
Parse(String),
|
||||||
|
|
||||||
#[error("malformed g-code header: {reason}")]
|
|
||||||
GcodeHeader { reason: String },
|
|
||||||
|
|
||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
Other(#[from] anyhow::Error),
|
Other(#[from] anyhow::Error),
|
||||||
|
|||||||
+23
-123
@@ -1,9 +1,9 @@
|
|||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
/// XORs every byte in `data` together, per the standard Marlin/RepRap
|
/// XORs every byte together - the standard Marlin/RepRap G-code checksum.
|
||||||
/// G-code checksum scheme (checksum covers everything before the `*`,
|
/// Covers everything before the `*`, including a leading `N<n> ` line number
|
||||||
/// including any leading `N<n> ` line number).
|
/// if present.
|
||||||
pub fn compute_checksum(data: &[u8]) -> u8 {
|
pub fn compute_checksum(data: &[u8]) -> u8 {
|
||||||
data.iter().fold(0u8, |acc, &b| acc ^ b)
|
data.iter().fold(0u8, |acc, &b| acc ^ b)
|
||||||
}
|
}
|
||||||
@@ -20,14 +20,11 @@ pub fn split_checksum(line: &str) -> (&str, Option<u8>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Verifies a checksummed G-code line (e.g. `N12 G1 X10 Y20*43`).
|
/// 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<bool> {
|
pub fn verify_line_checksum(line: &str) -> Result<bool> {
|
||||||
let (content, checksum) = split_checksum(line);
|
let (content, checksum) = split_checksum(line);
|
||||||
match checksum {
|
match checksum {
|
||||||
Some(expected) => Ok(compute_checksum(content.as_bytes()) == expected),
|
Some(expected) => Ok(compute_checksum(content.as_bytes()) == expected),
|
||||||
None => Err(Error::GcodeChecksum {
|
None => Err(Error::Parse(format!("line has no checksum: {line}"))),
|
||||||
line: line.to_string(),
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,110 +33,28 @@ pub fn append_checksum(line: &str) -> String {
|
|||||||
format!("{line}*{}", compute_checksum(line.as_bytes()))
|
format!("{line}*{}", compute_checksum(line.as_bytes()))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
/// Every `; key = value` comment line found in a G-code file's header,
|
||||||
pub struct GcodeHeader {
|
/// keyed by `key`. Slicers (PrusaSlicer, BambuStudio, ...) write their
|
||||||
pub slicer: Option<String>,
|
/// settings this way at the top of the file.
|
||||||
pub filament_type: Option<String>,
|
///
|
||||||
pub nozzle_diameter: Option<String>,
|
/// This only handles that one format. Real slicer output has more variety
|
||||||
pub estimated_print_time_secs: Option<u64>,
|
/// (Cura's `;KEY:value` style, free-form "generated by ..." comments) -
|
||||||
/// Every `; key = value` comment field found, keyed by `key`.
|
/// add support for those once this simple version makes sense.
|
||||||
pub raw_fields: BTreeMap<String, String>,
|
pub fn parse_header(gcode: &str) -> BTreeMap<String, String> {
|
||||||
}
|
let mut fields = BTreeMap::new();
|
||||||
|
|
||||||
/// Parses slicer metadata out of a G-code file's `;`-prefixed comment lines.
|
for line in gcode.lines() {
|
||||||
/// Handles the PrusaSlicer/SuperSlicer/BambuStudio `; key = value` config
|
let Some(comment) = line.trim().strip_prefix(';') else { continue };
|
||||||
/// block as well as Cura's `;KEY:value` header and both slicers' free-form
|
let Some((key, value)) = comment.split_once('=') else { continue };
|
||||||
/// `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 key = key.trim().to_string();
|
||||||
let value = value.trim().to_string();
|
let value = value.trim().to_string();
|
||||||
if !key.is_empty() && !value.is_empty() {
|
if !key.is_empty() && !value.is_empty() {
|
||||||
match key.as_str() {
|
fields.insert(key, value);
|
||||||
"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::<u64>().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<u64> {
|
|
||||||
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)
|
fields
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -161,25 +76,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn header_parses_prusaslicer_style() {
|
fn header_parses_key_value_comments() {
|
||||||
let gcode = "\
|
let gcode = "; filament_type = PLA\n; nozzle_diameter = 0.4\nG28\n";
|
||||||
; generated by PrusaSlicer 2.7.0+win64 on 2026-01-01 at 12:00:00
|
let fields = parse_header(gcode);
|
||||||
; filament_type = PLA
|
assert_eq!(fields.get("filament_type").map(String::as_str), Some("PLA"));
|
||||||
; nozzle_diameter = 0.4
|
assert_eq!(fields.get("nozzle_diameter").map(String::as_str), Some("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));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.1.14",
|
"bun-types": "^1.1.14",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^5.6.3",
|
||||||
"elysia": "^1.1.26"
|
"elysia": "^1.1.26"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,6 @@ export interface SupabaseClaims extends JWTPayload {
|
|||||||
sub: string;
|
sub: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
role?: string;
|
role?: string;
|
||||||
aud: string | string[];
|
|
||||||
app_metadata?: Record<string, unknown>;
|
|
||||||
user_metadata?: Record<string, unknown>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class JwtVerificationError extends Error {
|
export class JwtVerificationError extends Error {
|
||||||
@@ -16,63 +13,28 @@ export class JwtVerificationError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VerifierOptions {
|
/**
|
||||||
/** Supabase project URL, e.g. https://xyzcompany.supabase.co */
|
* Verifies a Supabase-issued access token against the project's public
|
||||||
supabaseUrl: string;
|
* JWKS (fetched once and cached). `supabaseUrl` is your project URL, e.g.
|
||||||
/**
|
* `https://xyzcompany.supabase.co`.
|
||||||
* 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;
|
export function createSupabaseJwtVerifier(supabaseUrl: string) {
|
||||||
audience?: string;
|
const jwks = createRemoteJWKSet(new URL("/auth/v1/.well-known/jwks.json", supabaseUrl));
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
return {
|
||||||
async verify(token: string): Promise<SupabaseClaims> {
|
async verify(token: string): Promise<SupabaseClaims> {
|
||||||
try {
|
try {
|
||||||
const { payload } = await jwtVerify(token, jwks, { audience });
|
const { payload } = await jwtVerify(token, jwks, { audience: "authenticated" });
|
||||||
return payload as SupabaseClaims;
|
return payload as SupabaseClaims;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new JwtVerificationError("failed to verify Supabase JWT (JWKS)", err);
|
throw new JwtVerificationError("failed to verify Supabase JWT", err);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function extractBearerToken(authorizationHeader: string | null | undefined): string {
|
export function extractBearerToken(authorizationHeader: string | null | undefined): string {
|
||||||
if (!authorizationHeader) {
|
const [scheme, token] = (authorizationHeader ?? "").split(" ");
|
||||||
throw new JwtVerificationError("missing Authorization header");
|
|
||||||
}
|
|
||||||
const [scheme, token] = authorizationHeader.split(" ");
|
|
||||||
if (scheme !== "Bearer" || !token) {
|
if (scheme !== "Bearer" || !token) {
|
||||||
throw new JwtVerificationError("Authorization header is not a Bearer token");
|
throw new JwtVerificationError("Authorization header is not a Bearer token");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "Bundler",
|
"moduleResolution": "Bundler",
|
||||||
"lib": ["ES2022"],
|
"lib": ["ES2022"],
|
||||||
|
"types": ["bun-types"],
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"declarationMap": true,
|
"declarationMap": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user