Big News: Socket raises $60M Series C at a $1B valuation to secure software supply chains for AI-driven development.Announcement
Sign In

@openparachute/scribe

Package Overview
Dependencies
Maintainers
1
Versions
16
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@openparachute/scribe - npm Package Compare versions

Comparing version
0.4.4-rc.4
to
0.4.4-rc.5
+1
-1
package.json
{
"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",

@@ -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.

@@ -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