Compare commits

..

3 Commits

Author SHA1 Message Date
octoturge 7fc76f0ad1 Ignore bun.lock alongside bun.lockb 2026-08-28 18:28:27 +00:00
octoturge 0fa4bfaf45 Simplify ts-core auth.ts; fix bun-types resolution in monorepo workspace
auth.ts: dropped the legacy HS256-shared-secret verification path, keeping
only the JWKS path Supabase recommends now — one code path instead of two.

Also fixes a real bug: @types/bun's ambient types didn't resolve inside a
bun workspace (its internal 'bun-types' reference can't hoist into a nested
package's node_modules the same way it does in a flat install). Depending on
bun-types directly instead of @types/bun fixes it — verified with a clean
'bun run typecheck'.
2026-08-28 18:28:06 +00:00
octoturge ea28211380 Simplify rust-core: single-format gcode header parser, fewer error variants
parse_header now only handles '; key = value' lines instead of three
different slicer comment formats at once. Error enum trimmed from 4
variants to 3 (Io, Parse, Other).

Verified with cargo test (3/3 passing).
2026-08-28 18:24:51 +00:00
6 changed files with 43 additions and 180 deletions
+1
View File
@@ -3,6 +3,7 @@ node_modules/
dist/ dist/
*.tsbuildinfo *.tsbuildinfo
bun.lockb bun.lockb
bun.lock
.bun/ .bun/
# Rust # Rust
+4 -5
View File
@@ -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
View File
@@ -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));
} }
} }
+1 -1
View File
@@ -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"
} }
+8 -46
View File
@@ -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 */
supabaseUrl: string;
/** /**
* Legacy HS256 project JWT secret. When provided, verification uses this * Verifies a Supabase-issued access token against the project's public
* shared secret instead of fetching the project's JWKS. Prefer leaving * JWKS (fetched once and cached). `supabaseUrl` is your project URL, e.g.
* this unset for projects on Supabase's newer asymmetric (ES256/RS256) * `https://xyzcompany.supabase.co`.
* 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");
} }
+1
View File
@@ -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,