/** * @license * Copyright 2026 Steven Roussey * SPDX-License-Identifier: Apache-2.0 */ import type { CredentialPutOptions, ICredentialStore } from "@workglow/util"; import type { IKvStorage } from "../kv/IKvStorage"; import type { SecretVault } from "./SecretVault"; /** Persisted metadata row (NO secret value). */ export interface CredentialMetadataRow { readonly userId: string; readonly projectId: string; readonly key: string; readonly label: string | undefined; readonly provider: string | undefined; readonly createdAt: string; readonly updatedAt: string; readonly expiresAt: string | undefined; /** * True while a new-entry put() is mid-flight (metadata written but vault * write not yet acknowledged), or sticky-true if vault rollback failed and * the row is an orphan marker. Readers (get/has/listMetadata/keys) MUST * treat pending rows as absent. Absent or `false` means committed. */ readonly pending?: boolean; /** * ISO timestamp written if the new-entry rollback path itself failed. The * vault may still contain bytes for this id; operators should reconcile. */ readonly orphanedAt?: string; /** * Diagnostic discriminator written alongside `orphanedAt`. Identifies the * failure path that produced the orphan marker so operators can pick a * remediation strategy without re-running the original write: * - "vault-write-failed": new-entry put, vault.setSecret threw and * metadata.delete also failed; vault may still hold bytes. * - "metadata-commit-failed": new-entry put, vault.setSecret succeeded * but the commit metadata.put (clearing `pending`) failed; vault holds * bytes that no committed row points at. * - "vault-delete-failed": delete(), metadata.delete succeeded but * vault.deleteSecret failed; metadata row reinstated as pending so * readers see absence, vault holds orphan bytes. */ readonly orphanReason?: "vault-write-failed" | "metadata-commit-failed" | "vault-delete-failed"; } /** Discriminator for the `orphanReason` field on {@link CredentialMetadataRow}. */ export type OrphanReason = NonNullable; /** Metadata shape exposed to the API list route (no value). */ export interface CredentialMetadataInfo { readonly key: string; readonly label: string | undefined; readonly provider: string | undefined; readonly createdAt: string; readonly updatedAt: string; readonly expiresAt: string | undefined; } export interface ServerCredentialStoreOptions { readonly vault: SecretVault; readonly metadata: IKvStorage; readonly userId: string; readonly projectId: string; } /** * Project-scoped credential store. Secret bytes live in a {@link SecretVault}; * metadata lives in an {@link IKvStorage}. Decryption happens only here, in * the process that owns the vault — never in the renderer. */ export declare class ServerCredentialStore implements ICredentialStore { /** * Strict grammar for any segment that goes into the vault id * (`${userId}/${projectId}/${key}`). Restricting to URL-safe-ish characters * and capping length at 128 chars closes a key-injection class: without it, * a key like `"../../other-user/other-project/leaked"` could be smuggled * through, escape the project scope, and collide with another user's * vault id. The bounds are also chosen so the joined vault id stays well * under common KV/file-system key-length limits. */ private static readonly SAFE_SEGMENT; private readonly vault; private readonly metadata; private readonly userId; private readonly projectId; private readonly prefix; constructor(opts: ServerCredentialStoreOptions); /** * Build the prefixed vault id for `key`. Re-validates `key` against the * SAFE_SEGMENT grammar on every call: `put` is the only public path that * routes through here (it must throw loudly so a programmer error cannot * silently persist a colliding vault id). Reader methods (`get`/`has`/ * `delete`) short-circuit via {@link isSafeKey} instead so they keep the * `ICredentialStore` contract of returning `undefined`/`false` for invalid * keys, preserving substitutability with the other stores. */ private vaultId; /** * Non-throwing key predicate used by readers (`get`/`has`/`delete`). The * `ICredentialStore` contract documents these methods as returning * `undefined`/`false` for absent or invalid keys; throwing on every invalid * lookup breaks substitutability with the other credential stores * (`InMemoryCredentialStore`, `EncryptedKvCredentialStore`). `put` keeps * routing through {@link vaultId} so a malicious key cannot persist a * colliding vault id. */ private isSafeKey; /** * Inverse of {@link vaultId} for `deleteAll`: strips the user/project * prefix to recover the original key. Only called on ids already * confirmed in-scope by {@link scopedIds}. */ private unscopedKey; private isExpired; private isPending; get(key: string): Promise; put(key: string, value: string, options?: CredentialPutOptions): Promise; /** * Internal delete by already-resolved vault id, shared by {@link delete} * (via {@link deleteById}) and {@link deleteAll} (via {@link forceDeleteById}). * Centralises the metadata-first ordering and the orphan-marker rewrite so * the two callers cannot drift out of sync. * * `op` is interpolated into the error message ("delete" vs "deleteAll") * so operators can tell which public method observed the failure. * * Ordering rationale: * 1. Delete metadata FIRST. If that throws, the vault is untouched * and the row stays visible — readers continue to get the secret, * same as before the call. * 2. If the vault delete then fails we reinstate a pending+orphaned * metadata row so readers see absence (correct) while a sticky * marker remains for operator reconciliation. If the marker write * itself fails, the row is fully unrecoverable from the store's * perspective — surface that clearly in the error message so an * operator knows whether they have a metadata trail to follow. * * Pending-row policy lives in the callers: {@link deleteById} short-circuits * to avoid the put/delete race, while {@link forceDeleteById} is the wipe * path that must clear sticky orphan markers and in-flight pending rows. */ private commitDelete; /** * Internal delete used by {@link delete}. Short-circuits on pending rows: * a pending row is either an in-flight new-entry put() or a sticky orphan * marker, and in both cases the vault state is something only the owning * operation should mutate. Returning false here is the close for both the * orphan-overwrite hazard and the put/delete race. * * {@link deleteAll} deliberately does NOT route through this path — it uses * {@link forceDeleteById} so a scope-wide wipe actually clears those rows. */ private deleteById; delete(key: string): Promise; has(key: string): Promise; keys(): Promise; /** * Vault ids of every row in this scope, ignoring expiry and pending state. * Filters on key prefix first (cheap string check), then re-asserts the * userId/projectId fields as defence-in-depth in case of corrupt rows. */ private scopedIds; /** * Variant of {@link deleteById} that does NOT short-circuit on pending rows. * `deleteAll` is an operator-level "wipe scope" operation whose whole job is * to clear sticky orphan markers (rows where the original `delete` failed * its vault hop and reinstated a `pending: true` marker) as well as * still-in-flight pending new-entry rows. Routing `deleteAll` through * {@link deleteById} would silently skip both — there would be no public API * that could clear an orphan, defeating the purpose of writing the marker in * the first place. The metadata-first ordering and orphan-marker rewrite on * vault-delete failure are preserved via the shared {@link commitDelete}. */ private forceDeleteById; deleteAll(): Promise; /** Metadata for every non-expired, committed credential in this project scope. */ listMetadata(): Promise; } //# sourceMappingURL=ServerCredentialStore.d.ts.map