Files
pieced-portal/src/lib/packages.ts
admin a6ed74b1be
All checks were successful
Build and Push / build (push) Successful in 1m45s
Phase8: Auto bill credit card
2026-05-28 23:27:32 +02:00

371 lines
13 KiB
TypeScript

/**
* Portal-side package catalog. Hardcoded mirror of the operator-side
* catalog ConfigMap (deploy/helm/pieced-operator/templates/catalog-cm.yaml).
*
* The two have to stay in sync:
* - `id` here must match the catalog key in the ConfigMap.
* - `secrets[].key` here must match the catalog's env_vars[].secret_key
* so that POST /api/tenants/:name/secrets writes to the same OpenBao
* path the operator's ExternalSecret reads from.
* - `requiresSecrets` is true when the catalog declares any env_var
* that is a secret (vault_path_suffix set, no default value).
*
* Category model (Phase A rework):
* - core — platform-behaviour toggles (heartbeat, cron,
* active-memory, voice). Mostly no secrets. core-voice is
* fully wired (Phase B): toggling installs the STT / TTS /
* Talk surface via the operator's config_patch, routed
* through LiteLLM (pieced-stt, pieced-tts-inbound,
* pieced-tts-talk).
* - channel — messaging integration.
* - skill — ClawHub skill install.
*
* Custom provisioning (Threema):
* The `threema` channel sets `requiresSecrets: false` because its
* credentials are platform-issued, not customer-entered. Enabling
* threema goes through a dedicated endpoint
* (/api/tenants/:name/threema) that mints token + HMAC secret from
* the central pieced-threema-gateway relay and writes them to OpenBao
* at secret/data/tenants/<name>/threema-relay before the package is
* added to spec.packages. Disabling reverses both steps. The
* `customProvisioning` flag here tells the package-card UI to use
* that endpoint instead of the standard /secrets+PATCH dance.
*/
export interface PackageSecretField {
key: string;
labelKey: string;
placeholderKey: string;
}
export type PackageCategory = "core" | "channel" | "skill";
export interface PackageDef {
id: string;
name: string;
descriptionKey: string;
requiresSecrets: boolean;
secrets?: PackageSecretField[];
instructionsKey?: string;
disclaimerKey?: string;
category: PackageCategory;
/**
* When true, enabling/disabling this package goes through
* /api/tenants/:name/<id> (POST/DELETE) instead of the generic
* /secrets+PATCH flow. The handler at that path does platform-side
* provisioning (mint credentials, register with sibling services, etc.)
* that the customer is not aware of.
*/
customProvisioning?: boolean;
/**
* When true, customer-initiated enable requests are routed through
* an admin approval queue (skill_activation_requests) instead of
* being applied immediately. Platform-side manual work (hardware
* provisioning, third-party account setup, DNS, etc.) happens
* between request and approval, so we keep the tenant out of the
* spec until that work is done and the operator would otherwise
* fail to reconcile.
*
* Platform admins bypass the gate (direct PATCH from /admin still
* applies immediately). Disable is always direct — there's no
* gate on turning a skill off.
*
* Orthogonal to `requiresSecrets` and `customProvisioning`. A skill
* can have all three: customer provides credentials, the secrets
* are stored, the activation request lands in the admin queue,
* admin does the manual work, then approves.
*/
requiresManualSetup?: boolean;
/**
* Phase 9b: when true, the wizard visually highlights this package
* as recommended (a badge + accent border) without pre-selecting
* it. Used for the Threema channel — we want customers to choose
* Threema as their messaging surface when possible, but the choice
* stays opt-in.
*/
recommended?: boolean;
/**
* Phase 9b: when true, the onboarding wizard collects the
* customer's own user id for this channel (e.g. their Telegram
* numeric id, their Threema ID) at request time. The collected
* id is forwarded with the tenant request, stored on the row,
* and applied on admin approval:
* - spec.channelUsers[<channel>] gets the id seeded so the
* operator's first reconcile already has it
* - for Threema specifically, the approve handler additionally
* calls the relay's createRoute() so inbound messages from
* that id reach the new tenant
* Customers can add more ids later via the channel-users page.
* Help copy and label come from channelUsers.<id>IdHelp.
*/
collectsChannelUserId?: boolean;
}
export const PACKAGE_CATALOG: PackageDef[] = [
// -------------------------------------------------------------------------
// CORE
// -------------------------------------------------------------------------
{
id: "core-heartbeat",
name: "Heartbeat (Proactive Checks)",
descriptionKey: "packages.coreHeartbeat.description",
requiresSecrets: false,
category: "core",
},
{
id: "core-cron",
name: "Scheduled Tasks (Cron)",
descriptionKey: "packages.coreCron.description",
requiresSecrets: false,
category: "core",
},
{
id: "core-active-memory",
name: "Active Memory",
descriptionKey: "packages.coreActiveMemory.description",
requiresSecrets: false,
category: "core",
},
{
id: "core-voice",
name: "Voice Interaction",
descriptionKey: "packages.coreVoice.description",
requiresSecrets: false,
category: "core",
},
// -------------------------------------------------------------------------
// CHANNELS
// -------------------------------------------------------------------------
{
id: "telegram",
name: "Telegram",
descriptionKey: "packages.telegram.description",
requiresSecrets: true,
secrets: [
{
key: "bot-token",
labelKey: "packages.telegram.botTokenLabel",
placeholderKey: "packages.telegram.botTokenPlaceholder",
},
],
instructionsKey: "packages.telegram.instructions",
disclaimerKey: "packages.telegram.disclaimer",
category: "channel",
collectsChannelUserId: true,
},
{
id: "discord",
name: "Discord",
descriptionKey: "packages.discord.description",
requiresSecrets: true,
secrets: [
{
key: "bot-token",
labelKey: "packages.discord.botTokenLabel",
placeholderKey: "packages.discord.botTokenPlaceholder",
},
// app-id was missing from the portal catalog historically while the
// operator catalog declared DISCORD_APP_ID as a required env var.
// Tenants who enabled Discord ended up with the env var blank
// because the secrets POST never wrote an `app-id` key to OpenBao
// and the operator's ExternalSecret couldn't populate it. Added
// here as part of the Phase A rework to close the alignment gap;
// not strictly secret (the application ID is visible in the bot's
// profile URL) but stored alongside the bot token for convenience.
{
key: "app-id",
labelKey: "packages.discord.appIdLabel",
placeholderKey: "packages.discord.appIdPlaceholder",
},
],
instructionsKey: "packages.discord.instructions",
disclaimerKey: "packages.discord.disclaimer",
category: "channel",
collectsChannelUserId: true,
},
{
id: "threema",
name: "Threema",
descriptionKey: "packages.threema.description",
// No customer-entered secrets. The token + hmac secret are minted
// server-side by the relay's /admin/tokens endpoint when the
// package is enabled, and stored in OpenBao by the portal. The
// `customProvisioning` flag steers the PackageCard UI through the
// dedicated /api/tenants/:name/threema endpoint instead.
requiresSecrets: false,
customProvisioning: true,
instructionsKey: "packages.threema.instructions",
disclaimerKey: "packages.threema.disclaimer",
category: "channel",
recommended: true,
collectsChannelUserId: true,
},
// -------------------------------------------------------------------------
// SKILLS
// -------------------------------------------------------------------------
{
id: "git-cli",
name: "Git CLI",
descriptionKey: "packages.gitCli.description",
requiresSecrets: false,
category: "skill",
},
{
id: "github",
name: "GitHub (gh CLI)",
descriptionKey: "packages.github.description",
requiresSecrets: true,
secrets: [
{
key: "token",
labelKey: "packages.github.tokenLabel",
placeholderKey: "packages.github.tokenPlaceholder",
},
],
instructionsKey: "packages.github.instructions",
category: "skill",
},
{
id: "gitea",
name: "Gitea",
descriptionKey: "packages.gitea.description",
requiresSecrets: true,
secrets: [
{
key: "token",
labelKey: "packages.gitea.tokenLabel",
placeholderKey: "packages.gitea.tokenPlaceholder",
},
],
instructionsKey: "packages.gitea.instructions",
category: "skill",
},
{
id: "whisper-self-hosted",
name: "Whisper (Self-Hosted Transcription)",
descriptionKey: "packages.whisperSelfHosted.description",
requiresSecrets: false,
category: "skill",
},
{
id: "searxng-local-search",
name: "Web Search (SearXNG)",
descriptionKey: "packages.searxngLocalSearch.description",
requiresSecrets: false,
category: "skill",
},
{
id: "gog",
name: "Google Workspace (Gog)",
descriptionKey: "packages.gog.description",
requiresSecrets: true,
secrets: [
{
key: "client-id",
labelKey: "packages.gog.clientIdLabel",
placeholderKey: "packages.gog.clientIdPlaceholder",
},
{
key: "client-secret",
labelKey: "packages.gog.clientSecretLabel",
placeholderKey: "packages.gog.clientSecretPlaceholder",
},
{
key: "refresh-token",
labelKey: "packages.gog.refreshTokenLabel",
placeholderKey: "packages.gog.refreshTokenPlaceholder",
},
],
instructionsKey: "packages.gog.instructions",
disclaimerKey: "packages.gog.disclaimer",
category: "skill",
},
{
id: "mail",
name: "Email (IMAP / SMTP)",
descriptionKey: "packages.mail.description",
requiresSecrets: true,
secrets: [
{
key: "imap-host",
labelKey: "packages.mail.imapHostLabel",
placeholderKey: "packages.mail.imapHostPlaceholder",
},
{
key: "imap-user",
labelKey: "packages.mail.imapUserLabel",
placeholderKey: "packages.mail.imapUserPlaceholder",
},
{
key: "imap-pass",
labelKey: "packages.mail.imapPassLabel",
placeholderKey: "packages.mail.imapPassPlaceholder",
},
{
key: "smtp-host",
labelKey: "packages.mail.smtpHostLabel",
placeholderKey: "packages.mail.smtpHostPlaceholder",
},
{
key: "smtp-user",
labelKey: "packages.mail.smtpUserLabel",
placeholderKey: "packages.mail.smtpUserPlaceholder",
},
{
key: "smtp-pass",
labelKey: "packages.mail.smtpPassLabel",
placeholderKey: "packages.mail.smtpPassPlaceholder",
},
],
instructionsKey: "packages.mail.instructions",
disclaimerKey: "packages.mail.disclaimer",
category: "skill",
},
];
export function getPackageDef(id: string): PackageDef | undefined {
return PACKAGE_CATALOG.find((p) => p.id === id);
}
/**
* IDs of channel-category packages. Derived from the catalog so it
* cannot drift from the source of truth (previously hardcoded as
* `["telegram", "discord", "email"]` in tenants/[name]/page.tsx —
* removed as part of the Phase A package-model rework).
*
* Consumers: tenant detail page (filter spec.packages to channel set
* before rendering the channel-users panel).
*/
export const CHANNEL_PACKAGE_IDS: string[] = PACKAGE_CATALOG
.filter((p) => p.category === "channel")
.map((p) => p.id);
/**
* Default packages selected when the wizard opens a fresh onboarding
* request. The three CORE behaviours that make the assistant feel
* "smart out of the box":
* - heartbeat: proactive checks (otherwise the assistant is purely
* reactive).
* - cron: scheduled tasks (daily briefings, reminders).
* - active-memory: long-term recall of stable preferences and habits.
*
* Each adds some token cost — active-memory the most (one extra
* sub-agent turn per inbound message) — so customers can untoggle any
* of them before submitting.
*
* core-voice is intentionally NOT a default. It is fully wired (Phase B)
* and customers can enable it from the wizard, but it incurs separate
* audio spend on every inbound voice note (Whisper STT) and every
* outbound reply (kani-tts / kokoro-fastapi via LiteLLM). Opt-in keeps
* cost predictable for tenants who don't intend to use voice channels.
*
* Phase 9b revision: nothing is pre-enabled. New tenants start with a
* blank slate — the customer opts into exactly what they want. The
* Threema channel is flagged `recommended` (see PACKAGE_CATALOG) so
* the wizard highlights it, since we want customers to use Threema as
* their channel when possible — but it's still opt-in, not auto-on.
*/
export const DEFAULT_PACKAGE_IDS: string[] = [];