@openparachute/scribe
Advanced tools
+1
-1
| { | ||
| "name": "@openparachute/scribe", | ||
| "version": "0.4.4-rc.4", | ||
| "version": "0.4.4-rc.5", | ||
| "description": "Audio transcription + LLM cleanup. Whisper-compatible API for Parachute.", | ||
@@ -5,0 +5,0 @@ "module": "src/index.ts", |
+122
-1
@@ -34,3 +34,5 @@ /** | ||
| * stored value. Sending a non-empty string replaces it. Explicit | ||
| * clearing uses `POST /admin/clear-credential/<kind>/<name>` instead. | ||
| * clearing uses `POST /admin/clear-credential/<kind>/<name>` (see | ||
| * `clearProviderCredential` below) — the only way to remove a stored | ||
| * writeOnly value short of hand-editing `config.json`. | ||
| * | ||
@@ -443,2 +445,121 @@ * Pre-0.4.4 wire fields stay supported. `cleanupDefault` (old name) is | ||
| /** | ||
| * Provider kinds for `POST /admin/clear-credential/<kind>/<name>`. The kind | ||
| * routes the lookup to the right per-provider map; the name is the registry | ||
| * key inside it (e.g. `cleanup/anthropic` → `cleanupProviders.anthropic`). | ||
| */ | ||
| export const CREDENTIAL_KINDS = ["transcribe", "cleanup"] as const; | ||
| export type CredentialKind = (typeof CREDENTIAL_KINDS)[number]; | ||
| /** | ||
| * Currently the only clearable writeOnly field is `apiKey`. Surfacing this | ||
| * as a tuple so future fields (e.g. claude-code `setupToken` once that flow | ||
| * lands) can be added without reshaping callers — the response always | ||
| * echoes `field` so the SPA knows exactly what was removed. | ||
| */ | ||
| export const CLEARABLE_FIELDS = ["apiKey"] as const; | ||
| export type ClearableField = (typeof CLEARABLE_FIELDS)[number]; | ||
| export type ClearCredentialResult = { | ||
| /** True when an existing value was actually removed; false on no-op. */ | ||
| cleared: boolean; | ||
| /** The post-clear config, ready to atomically persist. */ | ||
| config: ScribeConfig; | ||
| }; | ||
| /** | ||
| * Validate kind/name against the allowed enums + the live provider registry. | ||
| * Returns `null` on success or an error tuple for the route to translate | ||
| * into a 400 response. | ||
| */ | ||
| export function validateClearCredentialTarget( | ||
| kind: string, | ||
| name: string, | ||
| ): { ok: true; kind: CredentialKind; name: string } | { ok: false; error: string; message: string } { | ||
| if (!(CREDENTIAL_KINDS as readonly string[]).includes(kind)) { | ||
| return { | ||
| ok: false, | ||
| error: "invalid_kind", | ||
| message: `kind must be one of: ${CREDENTIAL_KINDS.join(", ")} (got "${kind}")`, | ||
| }; | ||
| } | ||
| const validNames = kind === "transcribe" ? VALID_TRANSCRIBE_PROVIDER_NAMES : VALID_CLEANUP_PROVIDER_NAMES; | ||
| if (!validNames.has(name)) { | ||
| return { | ||
| ok: false, | ||
| error: "unknown_provider", | ||
| message: `unknown ${kind} provider "${name}" — known: ${Array.from(validNames).sort().join(", ")}`, | ||
| }; | ||
| } | ||
| return { ok: true, kind: kind as CredentialKind, name }; | ||
| } | ||
| /** | ||
| * Remove the `apiKey` field from `<kind>Providers.<name>`. Returns `cleared: | ||
| * false` when there was no stored value to begin with — the operator's intent | ||
| * ("ensure this credential is cleared") is satisfied either way, so the | ||
| * endpoint returns 200 idempotently and only differs in the `cleared` flag. | ||
| * | ||
| * Pairs with `mergeIntoFileShape` (PUT preserves writeOnly fields when | ||
| * omitted; this is the only way to remove them). | ||
| */ | ||
| export function clearProviderCredential( | ||
| existing: ScribeConfig, | ||
| kind: CredentialKind, | ||
| name: string, | ||
| ): ClearCredentialResult { | ||
| const mapKey = kind === "transcribe" ? "transcribeProviders" : "cleanupProviders"; | ||
| const next: ScribeConfig = { | ||
| ...existing, | ||
| // Shallow-clone the per-provider map so we don't mutate the caller's | ||
| // object — the in-process scribeConfig is the same reference the | ||
| // running handler reads per-request. | ||
| transcribeProviders: existing.transcribeProviders ? { ...existing.transcribeProviders } : undefined, | ||
| cleanupProviders: existing.cleanupProviders ? { ...existing.cleanupProviders } : undefined, | ||
| }; | ||
| const providerMap = next[mapKey]; | ||
| const block = providerMap?.[name]; | ||
| if (!block || block.apiKey === undefined || block.apiKey === "") { | ||
| // No stored credential — drop the empty/undef apiKey shell if it's there | ||
| // so the on-disk shape stays tidy, but report `cleared: false` for the | ||
| // wire response. Don't synthesize a provider entry that wasn't already | ||
| // there. | ||
| if (block && "apiKey" in block) { | ||
| const { apiKey: _drop, ...rest } = block; | ||
| if (Object.keys(rest).length === 0 && providerMap) { | ||
| delete providerMap[name]; | ||
| } else if (providerMap) { | ||
| providerMap[name] = rest; | ||
| } | ||
| } | ||
| return { cleared: false, config: trimEmptyProviderMaps(next) }; | ||
| } | ||
| // Real clear — strip apiKey, keep model/url. Drop the provider entry | ||
| // entirely if it was apiKey-only so the on-disk file doesn't accumulate | ||
| // empty `{}` blocks per provider that was once configured. | ||
| const { apiKey: _stripped, ...rest } = block; | ||
| if (Object.keys(rest).length === 0 && providerMap) { | ||
| delete providerMap[name]; | ||
| } else if (providerMap) { | ||
| providerMap[name] = rest; | ||
| } | ||
| return { cleared: true, config: trimEmptyProviderMaps(next) }; | ||
| } | ||
| /** | ||
| * Drop empty per-provider maps so the on-disk file stays tidy when the last | ||
| * configured provider in a kind gets its apiKey cleared. Mirrors the | ||
| * housekeeping in `mergeIntoFileShape`. | ||
| */ | ||
| function trimEmptyProviderMaps(cfg: ScribeConfig): ScribeConfig { | ||
| const out = { ...cfg }; | ||
| if (out.transcribeProviders && Object.keys(out.transcribeProviders).length === 0) { | ||
| delete out.transcribeProviders; | ||
| } | ||
| if (out.cleanupProviders && Object.keys(out.cleanupProviders).length === 0) { | ||
| delete out.cleanupProviders; | ||
| } | ||
| return out; | ||
| } | ||
| /** | ||
| * Read the existing on-disk config (if any). Returns `{}` when the file is | ||
@@ -445,0 +566,0 @@ * missing or empty. Throws when the file is present but malformed. |
+108
-2
@@ -28,2 +28,3 @@ import { cleaners, getProvider, transcribers, type Cleaner } from "./providers.ts"; | ||
| buildPublicResolvedConfig, | ||
| clearProviderCredential, | ||
| detectRestartRequired, | ||
@@ -33,2 +34,3 @@ mergeIntoFileShape, | ||
| toFileShape, | ||
| validateClearCredentialTarget, | ||
| validateConfig, | ||
@@ -165,7 +167,10 @@ writeConfigFileAtomic, | ||
| // Admin actions live under /admin/* — refresh-claude-token-status is the | ||
| // only one shipped in 0.4.4-rc.1; clear-credential follows in Phase 2. | ||
| // Admin actions live under /admin/* — refresh-claude-token-status and | ||
| // clear-credential (Phase 2 polish from scribe#47). | ||
| if (internalPath === "/admin/refresh-claude-token-status" && req.method === "POST") { | ||
| return handleRefreshSetupTokenStatus(deps); | ||
| } | ||
| if (internalPath.startsWith("/admin/clear-credential/") && req.method === "POST") { | ||
| return handleClearCredential(internalPath, deps); | ||
| } | ||
| if (internalPath.startsWith("/admin/") && req.method !== "GET") { | ||
@@ -249,2 +254,103 @@ return Response.json({ error: "Not found" }, { status: 404 }); | ||
| /** | ||
| * `POST /admin/clear-credential/<kind>/<name>` — remove the stored | ||
| * writeOnly `apiKey` for a provider. Pairs with PUT /.parachute/config's | ||
| * omit-to-keep semantics: PUT preserves apiKey when omitted, so this | ||
| * endpoint is the only way to actually erase a stored credential without | ||
| * hand-editing `config.json`. | ||
| * | ||
| * - 200 `{ok: true, cleared: {kind, name, field}, hadStoredValue: bool}` | ||
| * on success. Idempotent: clearing a provider with no stored apiKey still | ||
| * returns 200, with `hadStoredValue: false` to distinguish the no-op. | ||
| * - 400 `{error: "invalid_kind"|"unknown_provider", message}` for bad path | ||
| * segments (kind not in enum, name not in the provider registry). | ||
| * - 401/403 inherited from the standard auth gate (scribe:admin scope). | ||
| * | ||
| * Phase 2 polish from scribe#47 + #48 reviews. Today the only clearable | ||
| * `field` is `apiKey`; the response carries `field` explicitly so future | ||
| * additions (e.g. claude-code `setupToken`) can extend without reshaping | ||
| * the wire contract. | ||
| */ | ||
| async function handleClearCredential( | ||
| internalPath: string, | ||
| deps: ServerDeps, | ||
| ): Promise<Response> { | ||
| // Path is `/admin/clear-credential/<kind>/<name>` — anything past the | ||
| // two segments is a bad request (no field-targeting yet; apiKey is the | ||
| // only clearable field for now). | ||
| const suffix = internalPath.slice("/admin/clear-credential/".length); | ||
| const parts = suffix.split("/").filter((s) => s.length > 0); | ||
| if (parts.length !== 2) { | ||
| return Response.json( | ||
| { | ||
| error: "invalid_path", | ||
| message: | ||
| "expected /admin/clear-credential/<kind>/<name> — e.g. /admin/clear-credential/cleanup/anthropic", | ||
| }, | ||
| { status: 400 }, | ||
| ); | ||
| } | ||
| const [kindRaw, nameRaw] = parts; | ||
| // Decode in case the SPA URL-encoded a provider name with special chars. | ||
| let kind: string; | ||
| let name: string; | ||
| try { | ||
| kind = decodeURIComponent(kindRaw!); | ||
| name = decodeURIComponent(nameRaw!); | ||
| } catch { | ||
| return Response.json( | ||
| { error: "invalid_path", message: "malformed URL encoding in path" }, | ||
| { status: 400 }, | ||
| ); | ||
| } | ||
| const validation = validateClearCredentialTarget(kind, name); | ||
| if (!validation.ok) { | ||
| return Response.json( | ||
| { error: validation.error, message: validation.message }, | ||
| { status: 400 }, | ||
| ); | ||
| } | ||
| const path = deps.configPath ?? resolveDefaultConfigPath(); | ||
| let existing: ScribeConfig; | ||
| try { | ||
| existing = readExistingConfig(path); | ||
| } catch (err) { | ||
| const message = err instanceof Error ? err.message : String(err); | ||
| console.error(`[scribe] clear-credential config read failed (${path}): ${message}`); | ||
| return Response.json( | ||
| { error: "read_failed", message: `failed to read ${path}: ${message}` }, | ||
| { status: 500 }, | ||
| ); | ||
| } | ||
| const { cleared, config: nextConfig } = clearProviderCredential( | ||
| existing, | ||
| validation.kind, | ||
| validation.name, | ||
| ); | ||
| try { | ||
| writeConfigFileAtomic(path, nextConfig); | ||
| } catch (err) { | ||
| const message = err instanceof Error ? err.message : String(err); | ||
| console.error(`[scribe] clear-credential config write failed (${path}): ${message}`); | ||
| return Response.json( | ||
| { error: "write_failed", message: `failed to write ${path}: ${message}` }, | ||
| { status: 500 }, | ||
| ); | ||
| } | ||
| // Sync the in-process scribeConfig so the next transcribe/cleanup request | ||
| // doesn't keep using the just-cleared apiKey from memory. | ||
| deps.scribeConfig.transcribeProviders = nextConfig.transcribeProviders; | ||
| deps.scribeConfig.cleanupProviders = nextConfig.cleanupProviders; | ||
| return Response.json({ | ||
| ok: true, | ||
| cleared: { kind: validation.kind, name: validation.name, field: "apiKey" }, | ||
| hadStoredValue: cleared, | ||
| }); | ||
| } | ||
| /** | ||
| * PUT /.parachute/config — validate, atomically persist, mutate the | ||
@@ -251,0 +357,0 @@ * in-process scribeConfig so dynamically-read fields take effect without |
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
244186
3.76%4993
4.5%