diff --git a/src/hooks.server.ts b/src/hooks.server.ts
index 463f651..0233675 100644
--- a/src/hooks.server.ts
+++ b/src/hooks.server.ts
@@ -3,6 +3,9 @@ import { validateSession, setSessionCookie, deleteSessionCookie } from '$lib/ser
import { env } from '$env/dynamic/private';
import { error } from '@sveltejs/kit';
+// Route IDs are prefixed with their filesystem route group, e.g. "/(app)/devices/[id]".
+const PROTECTED_ROUTE_PREFIX = '/(app)';
+
export const handleError: HandleServerError = async ({ error: err }) => {
const message = err instanceof Error ? err.message : 'Unknown error';
@@ -64,5 +67,19 @@ export const handle: Handle = async ({ event, resolve }) => {
event.locals.session = null;
}
+ // Auth gate for every request under the (app) route group — not just page
+ // renders. SvelteKit runs form `actions` and `+server.ts` handlers without
+ // first invoking +layout.server.ts `load`, so the layout's redirect-based
+ // guard never ran for POST/PUT/PATCH/DELETE requests; a crafted request
+ // with no session could reach any mutating action directly. GET/HEAD page
+ // requests still fall through to the layout guard for its redirect-to-login
+ // UX; everything else is hard-rejected here.
+ if (event.route.id?.startsWith(PROTECTED_ROUTE_PREFIX) && !event.locals.user) {
+ const method = event.request.method;
+ if (method !== 'GET' && method !== 'HEAD') {
+ error(401, 'Unauthorized');
+ }
+ }
+
return resolve(event);
};
diff --git a/src/lib/components/parts/PartLookup.svelte b/src/lib/components/parts/PartLookup.svelte
new file mode 100644
index 0000000..b07e354
--- /dev/null
+++ b/src/lib/components/parts/PartLookup.svelte
@@ -0,0 +1,82 @@
+
+
+
+
+
+
+
+
+ {#if message}
+
+ {message}
+
+ {/if}
+
diff --git a/src/lib/server/digikeyLookup.ts b/src/lib/server/digikeyLookup.ts
new file mode 100644
index 0000000..7711851
--- /dev/null
+++ b/src/lib/server/digikeyLookup.ts
@@ -0,0 +1,78 @@
+import { env } from '$env/dynamic/private';
+import type { PartLookupResult } from './partsLookup.js';
+
+let cachedToken: { value: string; expiresAt: number } | null = null;
+
+async function getToken(clientId: string, clientSecret: string): Promise {
+ if (cachedToken && cachedToken.expiresAt > Date.now() + 30_000) {
+ return cachedToken.value;
+ }
+ try {
+ const res = await fetch('https://api.digikey.com/v1/oauth2/token', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({
+ client_id: clientId,
+ client_secret: clientSecret,
+ grant_type: 'client_credentials'
+ }),
+ signal: AbortSignal.timeout(8000)
+ });
+ if (!res.ok) return null;
+ const body = await res.json();
+ if (!body?.access_token) return null;
+ cachedToken = {
+ value: body.access_token,
+ expiresAt: Date.now() + (Number(body.expires_in) || 600) * 1000
+ };
+ return cachedToken.value;
+ } catch {
+ return null;
+ }
+}
+
+export async function lookupDigikeyPart(pn: string): Promise {
+ const clientId = env.DIGIKEY_CLIENT_ID;
+ const clientSecret = env.DIGIKEY_CLIENT_SECRET;
+ if (!clientId || !clientSecret) return null;
+
+ const token = await getToken(clientId, clientSecret);
+ if (!token) return null;
+
+ try {
+ const res = await fetch(
+ `https://api.digikey.com/products/v4/search/${encodeURIComponent(pn)}/productdetails`,
+ {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ 'X-DIGIKEY-Client-Id': clientId,
+ Accept: 'application/json'
+ },
+ signal: AbortSignal.timeout(8000)
+ }
+ );
+ if (!res.ok) return null;
+
+ const body = await res.json();
+ const product = body?.Product ?? body;
+ if (!product) return null;
+
+ // Best-effort mapping — UNVERIFIED against a live Digikey response; recheck once real credentials exist.
+ return {
+ mpn: product.ManufacturerProductNumber ?? pn,
+ name: product.Manufacturer?.Name ?? null,
+ category: null,
+ value: null,
+ voltage: null,
+ tolerance: null,
+ power: null,
+ mounting: null,
+ package: null,
+ notes: `Auto-filled from Digikey ${pn}`,
+ sourceUrl: product.ProductUrl ?? null,
+ provider: 'digikey'
+ };
+ } catch {
+ return null;
+ }
+}
diff --git a/src/lib/server/lcscLookup.ts b/src/lib/server/lcscLookup.ts
new file mode 100644
index 0000000..6be3866
--- /dev/null
+++ b/src/lib/server/lcscLookup.ts
@@ -0,0 +1,79 @@
+import type { PartLookupResult } from './partsLookup.js';
+
+const USER_AGENT =
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
+
+const CATEGORY_RULES: [RegExp, string][] = [
+ [/capacitor/, 'Capacitor'],
+ [/resistor/, 'Resistor'],
+ [/inductor|ferrite|choke/, 'Inductor'],
+ [/light emitting diode|\bled\b/, 'LED'],
+ [/diode/, 'Diode'],
+ [/transistor|mosfet|igbt/, 'Transistor'],
+ [/crystal|oscillator|resonator/, 'Crystal / Oscillator'],
+ [/fuse/, 'Fuse'],
+ [/switch|button|tactile/, 'Button / Switch'],
+ [/connector|header|terminal/, 'Connector'],
+ [/circuit|controller|amplifier|microcontroller|regulator|interface|logic|memory|driver|ic\b/, 'IC / Chip']
+];
+
+function mapCategory(tags: unknown): string {
+ const joined = Array.isArray(tags) ? tags.join(' ').toLowerCase() : '';
+ for (const [re, cat] of CATEGORY_RULES) {
+ if (re.test(joined)) return cat;
+ }
+ return 'Other';
+}
+
+function mapPackage(pkg: string | null | undefined): string | null {
+ if (!pkg) return null;
+ // Strip leading letters from codes like "R0603" / "C0402" → "0603" / "0402"; keep irregular ones as-is.
+ const m = pkg.match(/^[A-Za-z]+(\d{3,5})$/);
+ return m ? m[1] : pkg;
+}
+
+export async function lookupLcscPart(code: string): Promise {
+ // Validate before building the URL — this is the only untrusted input on the request path (prevents SSRF/path injection).
+ if (!/^C\d+$/i.test(code)) return null;
+
+ try {
+ const res = await fetch(`https://easyeda.com/api/products/${code}/components`, {
+ headers: { 'User-Agent': USER_AGENT, Accept: 'application/json' },
+ signal: AbortSignal.timeout(8000)
+ });
+ if (!res.ok) return null;
+
+ const contentType = res.headers.get('content-type') ?? '';
+ if (!contentType.includes('application/json')) return null;
+
+ const body = await res.json();
+ if (!body?.success || !body.result) return null;
+
+ const result = body.result;
+ const cPara = result?.dataStr?.head?.c_para ?? {};
+ const description: string = typeof result.description === 'string' ? result.description : '';
+
+ const toleranceMatch = description.match(/±\s*([\d.]+\s*%)/);
+ const voltageMatches = description.match(/(\d+(?:\.\d+)?\s?[mkMGT]?V)\b/g);
+ const powerMatch = description.match(/(\d+(?:\.\d+)?\s?[mkM]?W)\b/);
+
+ const value = typeof cPara.Value === 'string' ? cPara.Value.trim() : '';
+
+ return {
+ mpn: cPara['Manufacturer Part'] || result.title || code,
+ name: cPara.Manufacturer ? String(cPara.Manufacturer) : null,
+ category: mapCategory(result.tags),
+ value: value || null,
+ tolerance: toleranceMatch ? toleranceMatch[1].replace(/\s+/g, '') : null,
+ voltage: voltageMatches ? voltageMatches[voltageMatches.length - 1] : null,
+ power: powerMatch ? powerMatch[1] : null,
+ mounting: result.SMT === true ? 'SMT' : result.SMT === false ? 'THT' : null,
+ package: mapPackage(cPara.package),
+ notes: `Auto-filled from LCSC ${code}`,
+ sourceUrl: result.lcsc?.url ?? null,
+ provider: 'lcsc'
+ };
+ } catch {
+ return null;
+ }
+}
diff --git a/src/lib/server/partsLookup.ts b/src/lib/server/partsLookup.ts
new file mode 100644
index 0000000..324e43e
--- /dev/null
+++ b/src/lib/server/partsLookup.ts
@@ -0,0 +1,27 @@
+import { lookupLcscPart } from './lcscLookup.js';
+import { lookupDigikeyPart } from './digikeyLookup.js';
+
+export interface PartLookupResult {
+ name: string | null;
+ category: string | null;
+ mpn: string | null;
+ value: string | null;
+ voltage: string | null;
+ tolerance: string | null;
+ power: string | null;
+ mounting: 'THT' | 'SMT' | null;
+ package: string | null;
+ notes: string | null;
+ sourceUrl: string | null;
+ provider: 'lcsc' | 'digikey';
+}
+
+export async function lookupPartNumber(raw: string): Promise {
+ const trimmed = raw.trim();
+ if (!trimmed) return null;
+
+ if (/^C\d+$/i.test(trimmed)) {
+ return lookupLcscPart(trimmed);
+ }
+ return lookupDigikeyPart(trimmed);
+}
diff --git a/src/routes/(app)/parts/[id]/edit/+page.svelte b/src/routes/(app)/parts/[id]/edit/+page.svelte
index 1cbe6a8..216a0c3 100644
--- a/src/routes/(app)/parts/[id]/edit/+page.svelte
+++ b/src/routes/(app)/parts/[id]/edit/+page.svelte
@@ -2,18 +2,40 @@
import { enhance } from '$app/forms';
import { PART_CATEGORIES } from '$lib/constants.js';
import LocationPicker from '$lib/components/ui/LocationPicker.svelte';
+ import PartLookup from '$lib/components/parts/PartLookup.svelte';
+ import type { PartLookupResult } from '$lib/server/partsLookup.js';
let { data, form } = $props();
+ const val = (field: string) => (form?.values as any)?.[field] ?? (data.part as any)[field] ?? '';
+
let category = $state(String(form?.values?.category ?? data.part.category ?? ''));
+ let name = $state(String(val('name')));
+ let mpn = $state(String(val('mpn')));
+ let value = $state(String(val('value')));
+ let voltage = $state(String(val('voltage')));
+ let tolerance = $state(String(val('tolerance')));
+ let power = $state(String(val('power')));
+ let mounting = $state(String(val('mounting')));
+ let pkg = $state(String(val('package')));
+
+ function onFound(r: PartLookupResult) {
+ if (r.name != null) name = r.name;
+ if (r.mpn != null) mpn = r.mpn;
+ if (r.category != null) category = r.category;
+ if (r.value != null) value = r.value;
+ if (r.voltage != null) voltage = r.voltage;
+ if (r.tolerance != null) tolerance = r.tolerance;
+ if (r.power != null) power = r.power;
+ if (r.mounting != null) mounting = r.mounting;
+ if (r.package != null) pkg = r.package;
+ }
const showCapacitance = $derived(category === 'Capacitor');
const showResistance = $derived(category === 'Resistor');
const showVoltage = $derived(['Capacitor', 'Diode', 'LED', 'Transistor', 'IC / Chip', 'Fuse'].includes(category));
const showPower = $derived(['Resistor', 'Transistor', 'Diode', 'Inductor'].includes(category));
const showTolerance = $derived(['Resistor', 'Capacitor', 'Inductor', 'Crystal / Oscillator'].includes(category));
-
- const val = (field: string) => (form?.values as any)?.[field] ?? (data.part as any)[field] ?? '';
@@ -27,6 +49,10 @@
{form.error}
{/if}
+
+