importpkg

package
v0.2.2-dev Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Apr 30, 2026 License: Apache-2.0 Imports: 21 Imported by: 0

Documentation

Overview

Package importpkg implements the Go local-server HTTP surface for the bulk-import flow. It is a thin orchestration shell: every vault-touching operation (parse / verify / batch_import / query) is delegated to the Rust aikey CLI via stdin-JSON IPC (see aikey-cli/src/commands_internal). Go never reads or writes vault.db and never performs AES-GCM; only Argon2id key derivation runs on the Go side because the derived `vault_key_hex` must outlive the single unlock HTTP request and be reused by subsequent actions within the session.

Package name note: Go keyword `import` cannot be used for a package name, hence `importpkg` (documented decision in roadmap20260320/技术实现/阶段3-增强版KEY管理/批量导入-实施计划.md §Stage 4).

Index

Constants

View Source
const (
	// Protocol + spawn layer
	ErrCliNotFound       = "I_CLI_NOT_FOUND"       // aikey binary missing in PATH and ~/.aikey/bin
	ErrCliSpawnFailed    = "I_CLI_SPAWN_FAILED"    // os/exec returned an error before cli ran
	ErrCliTimeout        = "I_CLI_TIMEOUT"         // ctx deadline reached while waiting for cli stdout
	ErrCliMalformedReply = "I_CLI_MALFORMED_REPLY" // stdout JSON unparseable or missing required fields

	// Vault session layer
	ErrVaultLocked       = "I_VAULT_LOCKED"        // request targets an unlock-required route while session is absent
	ErrVaultUnlockFailed = "I_VAULT_UNLOCK_FAILED" // password did not produce a verifying key (wraps cli I_VAULT_KEY_INVALID)
	ErrVaultNoSession    = "I_VAULT_NO_SESSION"    // session id cookie missing or expired

	// Request-level
	ErrBadRequest = "I_BAD_REQUEST" // malformed JSON body or missing required field

	// User Vault CRUD layer (Web page /user/vault — 2026-04-23 decision set)
	ErrOAuthAddViaCLI       = "I_OAUTH_ADD_VIA_CLI"      // POST /vault/entry called with target=oauth (OAuth add flow lives in CLI)
	ErrUnknownTarget        = "I_UNKNOWN_TARGET"         // target is not personal|oauth|team, or team is not yet implemented
	ErrAliasSuffixExhausted = "I_ALIAS_SUFFIX_EXHAUSTED" // auto -2/-3 retry ran 20× and still conflicted (extreme edge case)
	ErrUnlockRateLimited    = "I_UNLOCK_RATE_LIMITED"    // too many unlock attempts from one source; online brute-force defense

)

I_* error codes surfaced to the Web UI. Mirror the set emitted by the Rust cli where applicable (aikey-cli/src/error_codes.rs); additional codes here cover Go-side orchestration failures (cli not found / spawn timeout / session missing) that have no cli analogue.

Variables

This section is empty.

Functions

This section is empty.

Types

type CliBridge

type CliBridge struct {
	// BinaryPath is the resolved aikey executable. Empty => look up lazily.
	BinaryPath string
	// Timeout applied to each invocation unless the caller's ctx deadline is sooner.
	Timeout time.Duration
	Logger  *slog.Logger
}

CliBridge spawns `aikey _internal <subcommand>` with a stdin envelope and parses the single-line stdout ResultEnvelope. One spawn per call — the cli is stateless and re-exec cost is acceptable (measured ~30ms on macOS, per Stage 0.3 subprocess latency baseline).

func NewCliBridge

func NewCliBridge(logger *slog.Logger) *CliBridge

NewCliBridge builds a bridge with a default 15s timeout. The binary is resolved lazily on first call so local-server can boot even if the cli isn't installed yet (the page still renders; only the action endpoints fail).

func (*CliBridge) Invoke

func (b *CliBridge) Invoke(
	ctx context.Context,
	subcommand string,
	action string,
	vaultKeyHex string,
	requestID string,
	payload any,
) (*resultEnvelope, error)

Invoke spawns one `aikey _internal <subcommand>` and returns the parsed ResultEnvelope. A non-nil error is returned when the envelope's Status is not "ok", or when spawn / parse fails before the cli produced a valid reply; the caller is expected to surface ErrorCode / ErrorMessage to the browser via writeCliError.

subcommand is the top-level `_internal` subcommand name ("parse", "vault-op", "query", "update-alias"). action is the sub-action inside vault-op (e.g. "verify", "metadata", "batch_import"); for subcommands that don't use the action field (parse), pass "".

type Config

type Config struct {
	// SessionTTL is the idle timeout for an unlocked vault.
	// v4.1 Stage 13: 默认 15 分钟(之前 10 分钟),让用户在一次导入流程里(粘贴 → 解析 →
	// 编辑 → 逐个 OAuth 登录跳转)不会中途被强制 re-unlock。超过 15 分钟未操作仍自动锁,
	// 保持闲置时的安全边界。
	SessionTTL time.Duration
	// VKCacheTTL is the per-entry TTL for the virtual-key list cache.
	VKCacheTTL time.Duration
	// CliTimeout bounds every `aikey _internal` subprocess invocation.
	CliTimeout time.Duration
}

Config knobs the caller can tune at boot.

type Handlers

type Handlers struct {
	Vault     *VaultHandlers
	Import    *ImportHandlers
	VaultCRUD *VaultCRUDHandlers
}

Handlers is the top-level bundle mounted on /api/user/{import,vault}/*. Keeping the two sub-groups together simplifies wiring: the user package's Handlers struct only needs one Import *importpkg.Handlers field, and the router in internal/api/router.go only needs one Register call.

func NewHandlers

func NewHandlers(cfg *Config, logger *slog.Logger) *Handlers

NewHandlers constructs the Handlers bundle. A nil cfg triggers defaults.

func (*Handlers) Register

func (h *Handlers) Register(mux *http.ServeMux, authMW func(http.Handler) http.Handler)

Register attaches the HTTP routes to the given mux under the standard /api/user/ prefix. authMW should be the caller's JWT auth middleware (same one used for /accounts/me); we apply it to everything except /vault/status which the Web UI probes before login.

Route ownership:

POST   /api/user/vault/unlock        -> VaultHandlers.UnlockHandler
POST   /api/user/vault/lock          -> VaultHandlers.LockHandler
GET    /api/user/vault/status        -> VaultHandlers.StatusHandler (unauthed probe)
GET    /api/user/vault/list          -> VaultCRUDHandlers.ListHandler        (requires unlock)
PATCH  /api/user/vault/entry/alias   -> VaultCRUDHandlers.AliasPatchHandler  (requires unlock)
POST   /api/user/vault/entry         -> VaultCRUDHandlers.EntryAddHandler    (requires unlock)
DELETE /api/user/vault/entry         -> VaultCRUDHandlers.EntryDeleteHandler (requires unlock)
POST   /api/user/vault/use           -> VaultCRUDHandlers.UseHandler         (requires unlock)

The former POST /api/user/vault/reveal endpoint (plaintext secret read) was removed 2026-04-24 security review round 2; see vault_crud.go.

POST   /api/user/import/parse        -> ImportHandlers.ParseHandler
POST   /api/user/import/confirm      -> ImportHandlers.ConfirmHandler        (requires unlock)
GET    /api/user/import/rules        -> ImportHandlers.RulesHandler

type ImportHandlers

type ImportHandlers struct {
	Bridge  *CliBridge
	VKCache *VKCache
}

ImportHandlers bundles the /api/user/import/* endpoints. Each handler is a thin envelope around one cli subcommand; the cli owns the business logic (parse engine, vault writes, audit log) and this layer only marshals JSON.

func (*ImportHandlers) ConfirmHandler

func (h *ImportHandlers) ConfirmHandler(w http.ResponseWriter, r *http.Request)

ConfirmHandler: POST /api/user/import/confirm Body: forwarded as the `payload` of cli `_internal vault-op` action="batch_import". Requires unlocked session (see router wiring via RequireUnlock).

func (*ImportHandlers) ParseHandler

func (h *ImportHandlers) ParseHandler(w http.ResponseWriter, r *http.Request)

ParseHandler: POST /api/user/import/parse Body: forwarded as the `payload` of cli `_internal parse` envelope. No vault unlock required — parse runs on plaintext input only.

func (*ImportHandlers) RulesHandler

func (h *ImportHandlers) RulesHandler(w http.ResponseWriter, r *http.Request)

RulesHandler: GET /api/user/import/rules

Delegates to `aikey _internal rules` so the YAML stays the single source of truth (the cli reads aikey-cli/data/provider_fingerprint.yaml).

Returned JSON shape:

{
  "layer_versions":    {"rules":"...", "crf":"...", "fingerprint":"..."},
  "sample_providers":  [...],
  "family_base_urls":  {"anthropic":"https://api.anthropic.com", ...},
  "family_login_urls": {"anthropic":"https://claude.ai/login", ...}
}

The Web Import page harvests hosts from BOTH `family_base_urls` and `family_login_urls` to drive Use-Official auto-fill rules:

  • empty / official-host-matched base_url → auto-fill official URL
  • protocol multi-select transitions to length=1 with empty base_url → auto-fill that protocol's official URL

`family_login_urls` covers browser-facing pages (e.g. `aistudio.google.com/app/apikey`, `dashscope.console.aliyun.com/apiKey`) users frequently paste — without it the host-match rule would silently miss those.

FALLBACK: if the cli is missing or fails (binary not deployed yet, upgrade in flight, panic on startup), we serve a hardcoded snapshot so the Web UI keeps working with stale-but-valid data. The fallback is the last-known-good copy of the YAML maps; it WILL drift if the YAML is updated without redeploying the service binary, but a stale fallback is strictly better than a 5xx that breaks the import page entirely.

type InvokeError

type InvokeError struct {
	Code string
	Msg  string
}

InvokeError carries both an I_* code and a human message so handlers can map to the correct HTTP status. Wrapping via fmt.Errorf with %s prefix worked for display but hid the code from writeErr's status table, which made every spawn-level failure surface as 500 regardless of its actual cause (the bug this type fixes — 2026-04-22 self-review).

func (*InvokeError) Error

func (e *InvokeError) Error() string

type SessionStore

type SessionStore struct {
	// contains filtered or unexported fields
}

SessionStore is an in-memory session map keyed by session_id. Sessions survive process lifetime only; on restart users re-unlock. This is acceptable for Personal + Trial editions (local single-user) and intentional for Production (users unlock per session, no shared state).

func NewSessionStore

func NewSessionStore(ttl time.Duration) *SessionStore

NewSessionStore returns a store with the given idle TTL. Use 10 minutes for Personal, shorter for high-security deployments.

type VKCache

type VKCache struct {
	// contains filtered or unexported fields
}

VKCache is a tiny in-memory TTL cache for the user's virtual-key list. The Web UI queries /api/user/virtual-keys while composing the import manifest to look up aliases + provider bindings; those queries repeat within one editing session, so a short cache avoids hitting the delivery handler on every keystroke.

Stage 4 ships the skeleton; actual wiring to the virtual-key delivery endpoint is Stage 5 work (together with the React interactions). The Get / Set methods are ready-to-call so Stage 5 can just bolt in a fetcher.

func NewVKCache

func NewVKCache(ttl time.Duration) *VKCache

NewVKCache returns a cache with the given per-entry TTL. Defaults recommended: 5 * time.Minute per UX v2 "sliding TTL on read".

func (*VKCache) Get

func (c *VKCache) Get(key string) ([]byte, bool)

Get returns the cached value and a cache-hit flag. Expired entries are evicted lazily on read.

func (*VKCache) Invalidate

func (c *VKCache) Invalidate(key string)

Invalidate drops a key (called after batch_import succeeds so the next VK list fetch reflects the newly imported aliases).

func (*VKCache) Set

func (c *VKCache) Set(key string, value []byte)

Set stores a value under key with the cache's configured TTL.

type VaultCRUDHandlers

type VaultCRUDHandlers struct {
	Store  *SessionStore
	Bridge *CliBridge
}

VaultCRUDHandlers bundles the five User-Vault-page endpoints. Depends on SessionStore + CliBridge already built by NewHandlers.

func NewVaultCRUDHandlers

func NewVaultCRUDHandlers(store *SessionStore, bridge *CliBridge) *VaultCRUDHandlers

NewVaultCRUDHandlers wires a VaultCRUDHandlers with shared deps.

func (*VaultCRUDHandlers) AliasPatchHandler

func (h *VaultCRUDHandlers) AliasPatchHandler(w http.ResponseWriter, r *http.Request)

AliasPatchHandler: PATCH /api/user/vault/entry/alias.

func (*VaultCRUDHandlers) EntryAddHandler

func (h *VaultCRUDHandlers) EntryAddHandler(w http.ResponseWriter, r *http.Request)

EntryAddHandler: POST /api/user/vault/entry.

func (*VaultCRUDHandlers) EntryDeleteHandler

func (h *VaultCRUDHandlers) EntryDeleteHandler(w http.ResponseWriter, r *http.Request)

EntryDeleteHandler: DELETE /api/user/vault/entry.

func (*VaultCRUDHandlers) ListHandler

func (h *VaultCRUDHandlers) ListHandler(w http.ResponseWriter, r *http.Request)

ListHandler: GET /api/user/vault/list.

Dispatches to one of two cli paths based on whether the caller has an unlocked vault session:

  • Unlocked (valid session cookie in Store) → spawns `query list_personal_with_masked` + `query list_oauth` in parallel and merges the two into a single `records[]` array. Personal rows carry secret_prefix / secret_suffix / secret_len so the UI can render `sk-ant-api03-•••••-afef3`.

  • Locked (no cookie / expired session) → spawns the single `query list_metadata_locked` action, which reads ONLY plaintext columns from vault.db (no AES-GCM decryption, no password_hash check). Personal rows carry secret_prefix / secret_suffix / secret_len = null; the UI renders a pure-asterisk secret pill.

Why this handler does its own session lookup instead of running under RequireUnlock middleware: we intentionally SHOULD serve this endpoint when locked, so the middleware would be wrong for it. See also `list_metadata_locked` in aikey-cli/src/commands_internal/query.rs for the security reasoning (2026-04-23 user decision A).

func (*VaultCRUDHandlers) UseHandler

func (h *VaultCRUDHandlers) UseHandler(w http.ResponseWriter, r *http.Request)

UseHandler: POST /api/user/vault/use.

Switches the default-profile provider binding(s) for the given key. Multi-provider semantics match `aikey use` non-interactive mode: a personal key with `supported_providers: ["anthropic","openai"]` promotes this single key across BOTH providers in one call. OAuth accounts always target exactly one provider (the OAuth issuer).

Unlock required — the underlying vault-op verifies the vault_key against password_hash. The routing binding table isn't encrypted, but unlock is still required because the operation also refreshes `~/.aikey/active.env` which integrates provider-scoped sentinel tokens that we don't want to regenerate without a session.

type VaultHandlers

type VaultHandlers struct {
	Store  *SessionStore
	Bridge *CliBridge
	// contains filtered or unexported fields
}

VaultHandlers bundles the /api/user/vault/{unlock,lock,status} endpoints.

func (*VaultHandlers) LockHandler

func (h *VaultHandlers) LockHandler(w http.ResponseWriter, r *http.Request)

LockHandler: POST /api/user/vault/lock (explicit lock; session is dropped)

func (*VaultHandlers) RequireUnlock

func (h *VaultHandlers) RequireUnlock(next http.HandlerFunc) http.HandlerFunc

RequireUnlock wraps a handler so it runs only when the request carries a valid session cookie. The handler can retrieve the hex via vaultKeyFrom(ctx).

func (*VaultHandlers) StatusHandler

func (h *VaultHandlers) StatusHandler(w http.ResponseWriter, r *http.Request)

StatusHandler: GET /api/user/vault/status (unauthenticated probe; used by the Web UI to render the locked vs unlocked banner).

func (*VaultHandlers) UnlockHandler

func (h *VaultHandlers) UnlockHandler(w http.ResponseWriter, r *http.Request)

UnlockHandler: POST /api/user/vault/unlock Body: {"password": "..."} 1) call cli `vault-op metadata` to fetch salt + KDF params (no secret revealed) 2) derive Argon2id(password, salt) locally -> 32-byte key -> hex 3) call cli `vault-op verify` with the hex; on "ok", mint session 4) zero password bytes asap

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL