From df178bb5b5b9a12033c66946da9c9258357ec6c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 00:58:14 +0000 Subject: [PATCH 01/20] Add outreach-engine build plan. Provider-neutral outreach orchestration spec: durable action queue with claimed/executing split and uncertain-outcome reconciliation, versioned playbooks, approvals bound to content hashes, deterministic importer, reply correlation, CLI/HTTP/MCP/Slack/Papr surfaces, milestones M0-M10, and a parallel non-blocking Papr Work upstream track. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MbDW8kfMcxUuZ3WBtaDKBv --- outreach-engine/BUILD_PLAN.md | 1036 +++++++++++++++++++++++++++++++++ 1 file changed, 1036 insertions(+) create mode 100644 outreach-engine/BUILD_PLAN.md diff --git a/outreach-engine/BUILD_PLAN.md b/outreach-engine/BUILD_PLAN.md new file mode 100644 index 0000000..a4ba526 --- /dev/null +++ b/outreach-engine/BUILD_PLAN.md @@ -0,0 +1,1036 @@ +# Outreach Engine — Build Plan + +- Status: approved-to-build spec, nothing implemented yet +- Folder: `outreach-engine/` (MIT, SplitInTech open-internal-tools) +- Package scope: `@splitin/outreach-*` +- Runtime: TypeScript on Node ≥ 22.13 (first release with unflagged `node:sqlite`; Papr Work runs Node 24) +- Supersedes: `PAPRWORK_GTM_OUTBOUND_AND_OUTREACH_AUTOMATION_PLAN.md` for architecture. That plan still supplies product requirements; §17 lists exactly what was kept and what was dropped. +- Inputs: the Papr GTM technical audit and upstream review (2026-09-11), plus a re-verification against `Papr-ai/paprwork@faf6de5` (v2.6.18, 2026-09-27). + +This document is the single source for building the whole thing. A contributor or agent should be able to pick any milestone in §14 and build it without reading anything else. + +--- + +## 1. What we are building + +We are building a provider-neutral outreach orchestration engine. It imports contacts, turns versioned playbooks into per-contact scheduled actions, sends each action at most once through capability-checked provider adapters, stops the sequence on a reply, bounce, opt-out or pause, and records every transition in an append-only audit log. + +It ships as small npm packages plus three thin control surfaces: a CLI, an HTTP API with webhook ingress, and an MCP server. It also ships a Papr Work app that runs the engine through Papr's existing jobs and database extension surface. + +The engine is the product. Papr, Slack and ChatGPT are clients of it. + +### Why it lives here and not in Papr's core + +Papr Work (`Papr-ai/paprwork`, AGPL-3.0) has no durable side-effect execution model. Its scheduler guarantees that a job run starts, and runs interrupted by a restart are reconciled as failed (`JobsService.ts:3627`). The project is effectively maintained by two people, and outside PRs are rare. + +Building the engine here, under MIT, has three advantages. It unblocks SplitIn immediately. The code can be relicensed into AGPL later if the maintainers ask for it in core. And it can be distributed as a Papr app on the extension surface Papr already promotes (apps + jobs + registry databases, published through the Papr Cloud catalog) without needing their approval. The upstream track in §15 runs in parallel and never blocks this plan. + +### Non-goals + +These are permanent unless a later ADR changes them: + +- Automated LinkedIn (or any social network) connection requests, DMs, scraping, or unattended browser actions. Social steps create **manual tasks** that a human completes on the native site. +- Stealth, anti-detection, or CAPTCHA/challenge/2FA handling of any kind. +- Using a provider for a purpose its terms exclude. For example, Zoho Mail's usage policy excludes automated and marketing mail, so its adapter only declares `manual_correspondence`. +- Claims of exactly-once delivery. We guarantee **at-most-once-without-confirmation**: a send whose outcome is unknown is never repeated until the provider confirms it did not happen. +- An LLM anywhere in the send path. LLMs may draft, summarize and suggest classifications. Deterministic code decides and executes. +- SplitIn personas, lists, copy, accounts or rules in this public folder (see §16). + +--- + +## 2. Architecture + +```text + ┌─────────────┐ ┌──────────────┐ ┌────────────┐ ┌──────────────┐ + surfaces │ outreach CLI│ │ HTTP API + │ │ MCP server │ │ Papr app + │ + │ │ │ webhooks │ │ stdio/HTTP │ │ Slack (HQ) │ + └──────┬──────┘ └──────┬───────┘ └─────┬──────┘ └──────┬───────┘ + └───────────────┬┴────────────────┴────────────────┘ + ▼ + core ┌───────────────────────────────────────────────────────────────┐ + │ Application services (auth context → policy → domain → store) │ + │ import · campaign · approve · pause · status · manual tasks │ + ├───────────────────────────────────────────────────────────────┤ + │ Workers (all idempotent, --once or --loop) │ + │ materializer · executor · lease sweeper · reconciler · │ + │ event processor │ + └───────────────┬───────────────────────────────┬───────────────┘ + ▼ ▼ + storage ┌───────────────────────────┐ ┌───────────────────────────────┐ + │ SQLite (WAL) via Store │ │ Provider registry │ + │ port; one file per install│ │ capability-checked adapters │ + └───────────────────────────┘ └───────────────┬───────────────┘ + ▼ + providers email adapter · slack notifier · manual-task provider · fakes +``` + +Five rules hold everywhere: + +1. **The store is the only source of truth.** Worker processes are disposable. Killing any process at any instruction must leave the system in a state the workers can recover from. +2. **Every external effect originates from a `scheduled_actions` row.** Nothing calls a provider directly: not surfaces, not agents, not Slack buttons. +3. **Identity and workspace come from the authenticated context**, never from tool arguments or request payloads. +4. **Surfaces are thin.** Business rules live in application services only. A rule implemented twice counts as a bug. +5. **Fail closed.** Missing capability, unhealthy account, engaged kill switch, expired approval, or an unrenderable template all result in no send and a recorded reason. + +--- + +## 3. Repository layout + +```text +outreach-engine/ + BUILD_PLAN.md this file + README.md quick start (M0) + package.json npm workspaces, private + tsconfig.base.json + vitest.workspace.ts + eslint.config.js + packages/ + outreach-contracts/ types, zod schemas, state tables, error taxonomy, capability model + outreach-fakes/ fake email/notify/manual providers + provider conformance suite + outreach-store-sqlite/ migrations, repositories, driver port (node:sqlite, better-sqlite3) + outreach-core/ application services, policy, calendar, workers + outreach-import/ HTML/CSV/XLSX/JSON staging importer + outreach-notify-slack/ Slack incoming-webhook / chat.postMessage notifier + outreach-provider-email-*/ reference email adapter (after decision D1) + outreach-server/ HTTP API, webhook ingress, one-click unsubscribe endpoint + outreach-mcp/ MCP server (stdio local, Streamable HTTP remote) + outreach-cli/ `outreach` binary + apps/ + papr/ Papr Work app: UI, job definitions, skill markdown + examples/ + playbooks/ neutral example playbooks + fixtures/ synthetic leads on example.com / example.org + docs/ + adr/ 0001-architecture.md, 0002-..., one decision per file + threat-model.md + runbook.md + provider-authoring.md + state-machines.md +``` + +Conventions copied from `verification-adapter-sdk/`, the closest sibling (contract + engine + adapters + server): + +| Area | Convention | +|---|---| +| Build | `tsup` → ESM + CJS + `.d.ts` | +| Tests | Vitest; `fast-check` for property tests | +| Formatting | Strict TS, no `any`, ≤ 400 lines per file (Papr's 500-line rule, with headroom) | +| Dependencies | Enforced by `scripts/check-package-boundaries.mjs`. `contracts` depends on nothing internal; `core` depends on `contracts` plus the store port only; adapters depend on `contracts` only. | +| Publishing | OIDC via the existing `scripts/oidc-npm-publish.mjs` and a new `.github/workflows/outreach-engine-publish.yml`. Tag: `outreach-engine-v*`. | +| CI | `.github/workflows/outreach-engine.yml`: typecheck, lint, test, boundaries, secret scan. Fakes only, no live providers. | + +### Dependencies (and why) + +| Need | Choice | Reason | +|---|---|---| +| Schemas | `zod` | Papr and the MCP SDK already use zod; one schema language serves validation, MCP tool schemas and HTTP. | +| SQLite | `node:sqlite` default, `better-sqlite3` adapter | `node:sqlite` needs no native build and is what `slack-agent-hq` uses. `better-sqlite3` is what Papr/Electron already loads. | +| Time zones / business days | `luxon` | Correct IANA/DST handling; the calendar logic stays our own pure code on top. | +| CSV | `csv-parse` | Streaming, strict quoting, no evaluation. | +| XLSX | `exceljs` | Maintained on npm; reads cached values and never evaluates formulas. | +| HTML | `parse5` | Spec-compliant and inert: no script execution, no network, no DOM runtime. | +| HTTP | `hono` + `@hono/node-server` | Tiny, typed; gives raw-body access for signature verification. | +| MCP | `@modelcontextprotocol/sdk` | Official SDK, supports stdio and Streamable HTTP. | +| IDs | Built-in ULID (about 20 lines, `crypto.getRandomValues`) | Sortable IDs without a dependency. | + +Anything not in this table needs an ADR. + +--- + +## 4. Domain model + +All tables are `STRICT`. Every row except `audit_events` carries `workspace_id`. In standalone mode the workspace is `default`. In Papr mode it is the Papr workspace id, never a new tenancy concept. Timestamps are epoch milliseconds in `INTEGER` columns. IDs are ULIDs stored as `TEXT`. + +### 4.1 Identity and contacts + +```sql +CREATE TABLE workspaces (id TEXT PRIMARY KEY, name TEXT NOT NULL, created_at INTEGER NOT NULL) STRICT; + +CREATE TABLE principals ( + id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL REFERENCES workspaces(id), + external_ref TEXT NOT NULL, -- "slack:T123:U456", "papr:user:…", "cli:local" + display_name TEXT NOT NULL, + roles TEXT NOT NULL, -- JSON array: viewer|operator|approver|admin + UNIQUE (workspace_id, external_ref) +) STRICT; + +CREATE TABLE organizations ( + id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, + name TEXT NOT NULL, domain_norm TEXT, + UNIQUE (workspace_id, domain_norm) +) STRICT; + +CREATE TABLE contacts ( + id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, + organization_id TEXT REFERENCES organizations(id), + full_name TEXT NOT NULL, first_name TEXT, title TEXT, + timezone TEXT, locale TEXT, + attributes TEXT NOT NULL DEFAULT '{}', -- JSON, playbook-specific fields + merged_into_id TEXT REFERENCES contacts(id), + created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL +) STRICT; + +CREATE TABLE contact_points ( + id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, + contact_id TEXT NOT NULL REFERENCES contacts(id), + kind TEXT NOT NULL CHECK (kind IN ('email','social_profile','phone','other')), + value_norm TEXT NOT NULL, value_raw TEXT NOT NULL, + source TEXT NOT NULL, -- import batch id or "manual" + consent_basis TEXT, -- consent|legitimate_interest|existing_relationship|unknown + consent_evidence TEXT, consent_at INTEGER, + jurisdiction TEXT, -- ISO 3166 code or 'unknown' + permitted_channels TEXT NOT NULL DEFAULT '[]', + UNIQUE (workspace_id, kind, value_norm) +) STRICT; +``` + +### 4.2 Import + +```sql +CREATE TABLE mapping_profiles ( + id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, + name TEXT NOT NULL, version INTEGER NOT NULL, spec TEXT NOT NULL, + UNIQUE (workspace_id, name, version) +) STRICT; + +CREATE TABLE import_batches ( + id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, + source_name TEXT NOT NULL, source_sha256 TEXT NOT NULL, format TEXT NOT NULL, + mapping_profile_id TEXT NOT NULL REFERENCES mapping_profiles(id), + status TEXT NOT NULL CHECK (status IN ('previewed','committed','abandoned')), + preview_hash TEXT NOT NULL, idempotency_key TEXT NOT NULL, + counts TEXT NOT NULL, created_by TEXT NOT NULL, + created_at INTEGER NOT NULL, committed_at INTEGER, + UNIQUE (workspace_id, idempotency_key) +) STRICT; + +CREATE TABLE import_rows ( + id TEXT PRIMARY KEY, batch_id TEXT NOT NULL REFERENCES import_batches(id), + locator TEXT NOT NULL, -- "csv:row=42" | "xlsx:Sheet1!A42" | "html:table[0]/tr[42]" + raw TEXT NOT NULL, normalized TEXT, + outcome TEXT NOT NULL CHECK (outcome IN ('create','update','merge','reject','ambiguous')), + errors TEXT NOT NULL DEFAULT '[]', contact_id TEXT +) STRICT; +``` + +### 4.3 Definitions (immutable once referenced) + +```sql +CREATE TABLE templates ( + id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, + name TEXT NOT NULL, version INTEGER NOT NULL, + channel TEXT NOT NULL, subject TEXT, body_text TEXT NOT NULL, body_html TEXT, + required_tokens TEXT NOT NULL, + UNIQUE (workspace_id, name, version) +) STRICT; + +CREATE TABLE sequence_versions ( + id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, + name TEXT NOT NULL, version INTEGER NOT NULL, + spec TEXT NOT NULL, spec_hash TEXT NOT NULL, -- compiled playbook, §7 + UNIQUE (workspace_id, name, version) +) STRICT; + +CREATE TABLE provider_accounts ( + id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, + provider TEXT NOT NULL, external_account_id TEXT NOT NULL, + sender_identity TEXT NOT NULL, -- JSON: from name/address, postal address, reply-to + purposes TEXT NOT NULL, -- JSON: subset of ProviderPurpose (§5.1), operator-attested + capabilities TEXT NOT NULL, -- JSON CapabilitySnapshot, refreshed by discover() + secret_ref TEXT NOT NULL, -- "env:NAME" | "keychain:NAME"; never the secret + health TEXT NOT NULL CHECK (health IN ('ok','degraded','unhealthy','reauth_required')), + health_checked_at INTEGER, + UNIQUE (workspace_id, provider, external_account_id) +) STRICT; + +CREATE TABLE campaigns ( + id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, name TEXT NOT NULL, + purpose TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('draft','active','paused','completed','archived')), + active_version_id TEXT, paused_reason TEXT +) STRICT; + +CREATE TABLE campaign_versions ( + id TEXT PRIMARY KEY, campaign_id TEXT NOT NULL REFERENCES campaigns(id), + version INTEGER NOT NULL, + sequence_version_id TEXT NOT NULL REFERENCES sequence_versions(id), + provider_account_id TEXT NOT NULL REFERENCES provider_accounts(id), + policy TEXT NOT NULL, policy_hash TEXT NOT NULL, + audience_hash TEXT NOT NULL, + activated_at INTEGER, activated_by TEXT, + UNIQUE (campaign_id, version) +) STRICT; + +CREATE TABLE audience_members ( + id TEXT PRIMARY KEY, campaign_version_id TEXT NOT NULL REFERENCES campaign_versions(id), + contact_id TEXT NOT NULL, contact_point_id TEXT NOT NULL, + eligibility TEXT NOT NULL, -- JSON policy decision evidence at snapshot time + UNIQUE (campaign_version_id, contact_id) +) STRICT; +``` + +### 4.4 Runtime + +```sql +CREATE TABLE enrollments ( + id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, + campaign_id TEXT NOT NULL, campaign_version_id TEXT NOT NULL, contact_id TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN + ('active','paused','replied','opted_out','bounced','completed','stopped','error')), + stop_reason TEXT, current_step_id TEXT, + row_version INTEGER NOT NULL DEFAULT 0, -- optimistic concurrency + enrolled_at INTEGER NOT NULL, updated_at INTEGER NOT NULL +) STRICT; +CREATE UNIQUE INDEX ux_enrollment_live ON enrollments (workspace_id, campaign_id, contact_id) + WHERE status IN ('active','paused'); + +CREATE TABLE scheduled_actions ( + id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, + enrollment_id TEXT, campaign_id TEXT, step_id TEXT, + kind TEXT NOT NULL CHECK (kind IN ('email.send','email.reply','notify.publish','manual.task')), + provider_account_id TEXT, + state TEXT NOT NULL CHECK (state IN ('planned','awaiting_approval','scheduled','claimed', + 'executing','succeeded','retryable','uncertain','reconciling','failed','cancelled','review')), + due_at INTEGER NOT NULL, not_after INTEGER, + payload TEXT NOT NULL, -- fully rendered, frozen content (§6.3) + content_hash TEXT NOT NULL, + idempotency_key TEXT NOT NULL, -- stable across attempts + rfc_message_id TEXT, -- generated by us for email kinds + approval_id TEXT, + lease_owner TEXT, lease_expires_at INTEGER, + attempt_count INTEGER NOT NULL DEFAULT 0, max_attempts INTEGER NOT NULL DEFAULT 5, + last_error_class TEXT, state_reason TEXT, + created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, + UNIQUE (workspace_id, idempotency_key) +) STRICT; +CREATE INDEX ix_actions_due ON scheduled_actions (state, due_at); +CREATE INDEX ix_actions_enrollment ON scheduled_actions (enrollment_id, state); + +CREATE TABLE action_attempts ( + id TEXT PRIMARY KEY, action_id TEXT NOT NULL REFERENCES scheduled_actions(id), + attempt_no INTEGER NOT NULL, + outcome TEXT NOT NULL CHECK (outcome IN + ('pending','succeeded','rejected_retryable','rejected_permanent','uncertain')), + error_class TEXT, error_detail TEXT, -- redacted + receipt TEXT, started_at INTEGER NOT NULL, finished_at INTEGER, + UNIQUE (action_id, attempt_no) +) STRICT; + +CREATE TABLE messages ( + id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, + direction TEXT NOT NULL CHECK (direction IN ('outbound','inbound')), + provider_account_id TEXT NOT NULL, + provider_message_id TEXT NOT NULL, provider_thread_id TEXT, + rfc_message_id TEXT, in_reply_to TEXT, references_ids TEXT NOT NULL DEFAULT '[]', + from_addr TEXT NOT NULL, to_addrs TEXT NOT NULL, subject TEXT, + at INTEGER NOT NULL, action_id TEXT, enrollment_id TEXT, + UNIQUE (provider_account_id, provider_message_id) +) STRICT; +CREATE INDEX ix_messages_rfc ON messages (rfc_message_id); +CREATE INDEX ix_messages_thread ON messages (provider_account_id, provider_thread_id); + +CREATE TABLE provider_events ( + id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, provider_account_id TEXT NOT NULL, + provider_event_id TEXT NOT NULL, kind TEXT NOT NULL, + payload TEXT NOT NULL, payload_digest TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('pending','processed','ignored','failed','review')), + received_at INTEGER NOT NULL, processed_at INTEGER, + UNIQUE (provider_account_id, provider_event_id) +) STRICT; + +CREATE TABLE provider_cursors ( + provider_account_id TEXT PRIMARY KEY, cursor TEXT, overlap_ms INTEGER NOT NULL, updated_at INTEGER NOT NULL +) STRICT; + +CREATE TABLE manual_tasks ( + id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, action_id TEXT NOT NULL UNIQUE, + channel TEXT NOT NULL, target_url TEXT, draft_text TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('open','done','skipped','expired')), + confirmed_by TEXT, confirmed_at INTEGER, note TEXT +) STRICT; +``` + +### 4.5 Safety and audit + +```sql +CREATE TABLE approvals ( + id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, + scope TEXT NOT NULL CHECK (scope IN ('action','batch','campaign_version')), + subject_id TEXT NOT NULL, operation_hash TEXT NOT NULL, + preview TEXT NOT NULL, + requested_by TEXT NOT NULL, decided_by TEXT, + decision TEXT NOT NULL CHECK (decision IN ('pending','approved','rejected','revoked','expired')), + created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, + decided_at INTEGER, consumed_count INTEGER NOT NULL DEFAULT 0 +) STRICT; + +CREATE TABLE suppressions ( + id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, + scope TEXT NOT NULL CHECK (scope IN ('global','channel','provider_account','domain')), + channel TEXT NOT NULL DEFAULT '*', value_norm TEXT NOT NULL, + reason TEXT NOT NULL CHECK (reason IN + ('opt_out','hard_bounce','complaint','manual','do_not_contact','legal')), + source TEXT NOT NULL, effective_at INTEGER NOT NULL, + UNIQUE (workspace_id, scope, channel, value_norm) +) STRICT; + +CREATE TABLE kill_switches ( + workspace_id TEXT NOT NULL, -- '*' for global + scope TEXT NOT NULL CHECK (scope IN ('global','workspace','provider_account','campaign')), + target_id TEXT NOT NULL, + engaged INTEGER NOT NULL, reason TEXT, engaged_by TEXT, engaged_at INTEGER, + PRIMARY KEY (workspace_id, scope, target_id) +) STRICT; + +CREATE TABLE rate_buckets ( + workspace_id TEXT NOT NULL, scope_key TEXT NOT NULL, -- "account::day" | "domain:acme.com:day" | "recipient::gap" + window_start INTEGER NOT NULL, used INTEGER NOT NULL, limit_value INTEGER NOT NULL, + PRIMARY KEY (workspace_id, scope_key, window_start) +) STRICT; + +CREATE TABLE audit_events ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + workspace_id TEXT NOT NULL, at INTEGER NOT NULL, + actor_kind TEXT NOT NULL, -- principal|worker|provider|system + actor_id TEXT NOT NULL, source TEXT NOT NULL, trace_id TEXT NOT NULL, + resource_kind TEXT NOT NULL, resource_id TEXT NOT NULL, + action TEXT NOT NULL, detail TEXT NOT NULL, + prev_hash TEXT NOT NULL, hash TEXT NOT NULL -- sha256(prev_hash || canonical(row)) +) STRICT; +CREATE TRIGGER audit_no_update BEFORE UPDATE ON audit_events BEGIN SELECT RAISE(ABORT,'append-only'); END; +CREATE TRIGGER audit_no_delete BEFORE DELETE ON audit_events BEGIN SELECT RAISE(ABORT,'append-only'); END; +``` + +A hash chain costs almost nothing, and `outreach audit verify` can prove the log was not edited. Retention pruning uses a separate, audited `archive` path that exports a signed segment first; it never deletes in place. + +--- + +## 5. Contracts (`@splitin/outreach-contracts`) + +### 5.1 Capabilities and purpose + +```ts +export type ProviderPurpose = + | 'manual_correspondence' // a human-initiated one-to-one message + | 'transactional' + | 'automated_outreach' // scheduled one-to-one B2B sequences + | 'marketing' + | 'bulk'; + +export interface CapabilitySnapshot { + provider: string; + purposes: ProviderPurpose[]; // what the provider's terms permit, declared by the adapter + send: boolean; + replyInThread: boolean; + customHeaders: boolean; // can we set Message-ID / List-Unsubscribe? + externalIdempotency: boolean; // provider dedupes on our key + inboundWebhook: boolean; + mailboxPolling: boolean; + reconcileBySentSearch: boolean; + maxRecipientsPerMessage: number; + discoveredAt: number; +} +``` + +A campaign may activate only if `campaign.purpose ∈ adapter.purposes ∩ account.purposes`. The adapter declares what the provider's terms allow; the operator attests what their contract allows. Both must agree. + +### 5.2 Provider ports + +```ts +export interface ProviderContext { workspaceId: string; account: ProviderAccountRef; secrets: SecretResolver; traceId: string; signal: AbortSignal; } + +export interface AccountPort { + discover(ctx: ProviderContext): Promise; + health(ctx: ProviderContext): Promise<{ status: 'ok' | 'degraded' | 'unhealthy' | 'reauth_required'; detail?: string }>; +} + +export interface EmailSender { + send(ctx: ProviderContext, a: ApprovedEmail): Promise; + reconcile(ctx: ProviderContext, a: UncertainEmail): Promise; +} + +export interface MailboxReader { + readChanges(ctx: ProviderContext, cursor: string | null): Promise<{ events: InboundMailEvent[]; nextCursor: string }>; +} + +export interface WebhookVerifier { + verify(rawBody: Uint8Array, headers: Headers, secret: string, now: number): Promise; +} + +export interface NotificationPublisher { publish(ctx: ProviderContext, n: Notification): Promise; } + +export type SendResult = + | { kind: 'accepted'; receipt: ProviderReceipt } + | { kind: 'rejected'; errorClass: ErrorClass; retryAfterMs?: number; detail: string } + | { kind: 'unknown'; detail: string }; // timeout/reset after bytes left the process + +export type ReconcileResult = + | { kind: 'found'; receipt: ProviderReceipt } + | { kind: 'absent' } // provider affirms it was not sent + | { kind: 'still_unknown'; detail: string }; + +export interface ProviderReceipt { + providerMessageId: string; providerThreadId?: string; rfcMessageId?: string; + acceptedAt: number; raw?: Record; // ids only, never bodies or tokens +} +``` + +### 5.3 Error taxonomy + +Adapters map every failure into one of these classes. The engine decides what happens based on the class alone; no provider-specific logic lives in the core. + +| `ErrorClass` | Engine behaviour | +|---|---| +| `auth_expired` | Refresh once under a per-account lock. If confirmed, retry; if not, set account `reauth_required` and hold its actions. | +| `auth_revoked` / `forbidden` | Permanent failure. Account `unhealthy`. Pause the campaign and notify. | +| `rate_limited` | `retryable`, honoring `retryAfterMs`; tighten the account bucket. | +| `invalid_recipient` / `hard_bounce` | Permanent failure. Add a suppression, set the enrollment to `bounced`. | +| `content_rejected` | Permanent failure. The action goes to `review`. | +| `policy_blocked` / `complaint` | Engage the account kill switch. Operator review is required before resuming. | +| `transient` (5xx, reset before send) | `retryable` with full-jitter backoff, capped at 15 minutes. | +| `unsupported` | Permanent failure. Never fall back to another adapter or channel. | + +### 5.4 Manual tasks + +```ts +export interface ManualTaskProvider { + prepare(ctx: ProviderContext, input: { channel: string; targetUrl?: string; draft: string }): Promise<{ taskId: string }>; +} +// Only a principal can complete a task: recordManualOutcome(taskId, 'done' | 'skipped', note). +// No code path marks a manual task done on its own. +``` + +### 5.5 Conformance kit + +`@splitin/outreach-fakes/conformance` exports `runEmailSenderConformance(factory)`. Every adapter must pass it in CI against a recorded or sandbox double. It checks: + +- ID stability: the receipt has the same `providerMessageId` whenever `reconcile` finds a message. +- Errors map into `ErrorClass` with no raw provider strings leaking. +- `unknown` is returned for post-send timeouts. +- `reconcile` never returns `absent` unless the provider can affirm it. +- Honoring `retryAfterMs`. +- No secrets in thrown errors or receipts. + +--- + +## 6. Execution core — the heart + +### 6.1 Action state machine + +```text +planned ──(needs approval)──▶ awaiting_approval ──approved──▶ scheduled + │ └──rejected/expired──▶ cancelled + └──(no approval needed)──────────────────────────────────▶ scheduled +scheduled ──claim──▶ claimed ──preflight ok──▶ executing +claimed ──preflight defers (window/budget)──▶ scheduled (new due_at) +claimed ──preflight blocks (paused/suppressed/replied/kill)──▶ cancelled +claimed ──lease expired──▶ scheduled (no attempt started: safe) +executing ──accepted──▶ succeeded +executing ──rejected retryable──▶ retryable ──backoff──▶ scheduled +executing ──rejected permanent──▶ failed +executing ──unknown──▶ uncertain +executing ──lease expired──▶ uncertain (attempt may have run: never assume) +uncertain ──▶ reconciling ──found──▶ succeeded + ──absent──▶ scheduled (if attempts remain) | failed + ──still_unknown ×N──▶ review +any non-terminal except executing ──cancel (reply/opt-out/pause/kill)──▶ cancelled +``` + +Terminal states are `succeeded`, `failed`, `cancelled` and `review` (review exits only by human decision). Transitions live in one table in `contracts` (`ACTION_TRANSITIONS`). The store rejects any transition not in the table. A property test generates random event sequences and asserts that no path reaches a second provider call without an intervening `absent` or `rejected`. + +### 6.2 Why `claimed` and `executing` are separate + +This split is what fixes the original plan's deduplication bug. `claimed` means a worker holds the lease but has not committed to acting; a crash here is harmless, and the action simply returns to `scheduled`. `executing` is written, together with an `action_attempts(pending)` row, in a committed transaction **before** the provider call. A crash after that point can only lead to `uncertain`, never to a blind resend. + +### 6.3 Content is frozen before approval + +The materializer renders the template with the contact's attributes when it creates the action. It stores the complete payload (from, to, subject, text, html, headers, attachment digests), computes `content_hash = sha256(canonical(payload))`, and generates `rfc_message_id = `. Approval binds that hash. Editing a template creates a new template version; existing actions keep their frozen payload unless explicitly re-materialized, which invalidates their approvals. A missing required token means the action is never created, the enrollment goes to `error`, and a reason is recorded. + +### 6.4 Executor tick + +```text +claim(N): + BEGIN IMMEDIATE + SELECT id FROM scheduled_actions + WHERE state='scheduled' AND due_at<=:now ORDER BY due_at LIMIT :N + UPDATE … SET state='claimed', lease_owner=:worker, lease_expires_at=:now+lease + COMMIT + +for each claimed action: + PREFLIGHT (BEGIN IMMEDIATE … COMMIT) + assert lease_owner = me AND lease not expired + kill switches: global, workspace, provider_account, campaign → cancel/hold + campaign.status = active; enrollment.status = active → cancel + suppression match (global, channel, account, domain) on recipient → cancel + enrollment stopped + not_after passed → cancel(expired) + approval (if required): approved, unexpired, hash = content_hash → else awaiting_approval + account.health = ok; capability permits kind + campaign purpose → else hold (state_reason) + send window (recipient tz, business calendar) → reschedule to next slot + reserve rate buckets: account/day, domain/day, campaign/day, recipient gap + → exhausted: reschedule to reset + INSERT action_attempts(pending); state='executing'; attempt_count+=1; audit + CALL provider with idempotency_key, rfc_message_id, AbortSignal(timeout) + RESULT (BEGIN IMMEDIATE … COMMIT) + accepted → attempt succeeded, message(outbound), action succeeded, advance enrollment (§7.3) + retryable → release rate reservation, state retryable → scheduled(due_at=backoff) + permanent → state failed; apply class effects (§5.3) + unknown → attempt uncertain, state uncertain (keep rate reservation) + every branch → audit + notify.publish action if the policy asks for it +``` + +"Hold" means the action stays `scheduled` with `due_at` pushed forward and a `state_reason`. Held actions show in the UI as blocked, with a reason, not as failures. + +### 6.5 Other workers + +| Worker | Cadence | Job | +|---|---|---| +| Lease sweeper | Every executor tick | `claimed` with expired lease → `scheduled`; `executing` with expired lease → `uncertain`. | +| Reconciler | Every 5 min | `uncertain` → `reconciling` → adapter `reconcile()` searching by `rfc_message_id`, then idempotency key, then recipient + time window. Applies found/absent/still_unknown; after 3 × `still_unknown` → `review`. | +| Materializer | Every tick | Due step transitions and activation fan-out create actions (§7.3). | +| Event processor | Every tick | `provider_events(pending)` → classify → correlate → apply (§8). | +| Health checker | Every 15 min | `AccountPort.health()`; updates `provider_accounts.health`. | + +Each worker is a pure function of `(store, providers, clock)` and runs through `outreach worker --once|--loop`. `--once` exits after one pass of all due work. That lets a Papr job, a cron entry or a systemd timer host it with no daemon. `--loop` is for the standalone service. + +### 6.6 Concurrency on SQLite + +- WAL mode and `busy_timeout = 5000`. +- Every write transaction is `BEGIN IMMEDIATE`, so SQLite serializes writers. +- Claims batch at most `N = 25`, keeping transactions in single-digit milliseconds. +- Provider calls happen **outside** transactions. + +Multiple worker processes on one file are safe. The test suite proves this by running 4 child processes against a single file with 10,000 actions and asserting zero double attempts. + +For hosted or multi-node use, a Postgres store later implements the same port (`FOR UPDATE SKIP LOCKED` claims). It is not in scope until needed. + +--- + +## 7. Playbooks, sequences and calendar + +### 7.1 Playbook format + +```yaml +apiVersion: outreach.splitin.net/v1alpha1 +kind: Playbook +metadata: { name: b2b-introduction } +spec: + purpose: automated_outreach + audience: { importBatch: latest, require: [email], eligibility: [consent_or_legitimate_interest] } + policy: + approval: first_batch_then_campaign # none | every_action | first_batch_then_campaign + firstBatchSize: 20 + stopOn: [human_reply, opt_out, hard_bounce, complaint] + window: { timezone: recipient, fallback: America/New_York, days: [Mon,Tue,Wed,Thu], start: "09:30", end: "16:30" } + limits: { accountPerDay: 40, domainPerDay: 3, recipientMinGap: P3D } + steps: + - { id: intro, type: email.send, template: intro@1, approval: inherit } + - { id: wait1, type: wait, duration: P3D, calendar: business } + - { id: followup, type: email.reply, template: followup@1, when: no_reply } + - { id: social, type: manual.task, channel: linkedin, template: social_note@1, when: no_reply } + - { id: wait2, type: wait, duration: P4D, calendar: business } + - { id: close, type: email.reply, template: close@1, when: no_reply } +``` + +### 7.2 Compilation rules + +The playbook compiles into a `sequence_version` and a `campaign_version`. Compilation rejects: + +- unknown step types; +- templates missing a token the audience cannot supply; +- `email.reply` with no prior `email.send`; +- a purpose not permitted by the account and adapter; +- a missing sender postal address when the policy requires one; +- `when` conditions other than `always | no_reply`; +- a social step of any type other than `manual.task`. + +The output is deterministic, so identical input produces an identical `spec_hash`. + +### 7.3 Enrollment progression + +Actions are created one step at a time. The next action is created **in the same transaction** that marks the previous one `succeeded` (or, for waits, from `succeeded_at + duration` on the business calendar). A reply arriving during a wait therefore has only one pending action to cancel, and the `when: no_reply` check is evaluated at materialization time and again at preflight. + +### 7.4 Calendar + +`nextSlot(instant, window, holidays) → instant` is a pure function using luxon. It is property-tested across every IANA zone the fixtures use, both DST transitions, windows crossing midnight, and holiday runs. Business-day durations skip non-window days. + +--- + +## 8. Inbound: replies, bounces, opt-outs + +### 8.1 Ingestion + +Two ingestion paths write into the same inbox: + +- **Webhooks:** `POST /v1/webhooks/:provider/:accountId`. The raw body is verified by the adapter's `WebhookVerifier` (HMAC, with a 5-minute timestamp window) before parsing. Verified events are inserted into `provider_events` with `UNIQUE(provider_account_id, provider_event_id)`, which makes retries no-ops. The endpoint returns 200 before any processing happens. +- **Polling:** `MailboxReader.readChanges(cursor)` with an overlap window. Overlap duplicates collapse on the same unique key. + +Both paths run when available: webhooks for latency, polling to fill gaps. + +### 8.2 Classification (deterministic first) + +| Signal | Class | +|---|---| +| `multipart/report; report-type=delivery-status`, status 5.x.x | `hard_bounce` | +| DSN with 4.x.x | `soft_bounce` (no stop; count it and stop after 3) | +| Provider feedback-loop / complaint event | `complaint` | +| `Auto-Submitted: auto-replied`, `X-Autoreply`, `Precedence: auto_reply`, OOO subject patterns | `auto_reply` (no stop; optional delay) | +| One-click unsubscribe hit, or a reply matching opt-out phrases | `opt_out` | +| Anything else correlated to an enrollment | `human_reply` | +| Uncorrelated, or a rule conflict | `review` | + +An LLM may attach a suggested class and summary to `review` items. It never applies a class on its own. + +### 8.3 Correlation order + +1. `provider_thread_id` matches an outbound message on the same account. +2. `In-Reply-To` or any `References` id equals a stored outbound `rfc_message_id`. +3. Fallback: the sender equals an enrolled contact point and the message arrived within 30 days of our last outbound to them. This match is marked `weak`; a weak human reply still stops the enrollment (the safe direction) but also goes to review. + +### 8.4 Stop is atomic + +In one `BEGIN IMMEDIATE` transaction: + +- set the enrollment status (`replied`, `opted_out`, `bounced`); +- cancel every action for that enrollment in `planned | awaiting_approval | scheduled | retryable | claimed`; +- insert a suppression when the class requires one; +- write an audit event; +- enqueue a `notify.publish` action. + +An action already in `executing` cannot be recalled. That race window is bounded by one provider call, and it is the only one. It is documented in the runbook and measured in tests. + +### 8.5 Unsubscribe + +When `customHeaders` is available, email payloads include `List-Unsubscribe: ` and `List-Unsubscribe-Post: List-Unsubscribe=One-Click` (RFC 8058). The body footer carries the same link. The token is an HMAC of `(workspace, contact_point, campaign)`, so no database lookup is needed to verify it. `POST /u/:token` writes a suppression immediately and returns 200. This requires a public URL (decision D3). Without one, the policy must fall back to "reply STOP" handling and the playbook declares it. + +--- + +## 9. Policy, approvals, authorization + +### 9.1 Roles + +| Role | Can | +|---|---| +| `viewer` | Read status, previews, audit. | +| `operator` | Import, create drafts, pause, complete manual tasks. | +| `approver` | Approve or reject; resume after a kill switch. | +| `admin` | Manage accounts, kill switches, roles. | + +A workspace can require separation of duties: the approver cannot be the principal who requested the approval. Bulk approvals (more than 50 actions) require the `approver` role, with no `operator` override. + +### 9.2 Approval hashes + +- **Action scope:** `content_hash`. +- **Batch scope:** `sha256(sorted(action.content_hash))`. Approving a batch approves exactly those actions and nothing added later. +- **Campaign-version scope:** `sha256(policy_hash, sequence spec_hash, audience_hash, all template versions)`. Approving covers every action materialized from that version, but only while every one of those hashes still matches. + +Approvals expire (72 hours by default). Revocation takes effect at the next preflight. + +### 9.3 Two-phase mutations (every surface) + +```ts +prepare(op) → { operationId, preview, operationHash, requiresApproval, expiresAt, warnings } +commit({ operationId, operationHash, idempotencyKey }) → { status, affected, auditId } +``` + +`commit` re-derives the preview server-side and rejects on hash mismatch. Slack buttons, MCP tools and the Papr UI all use this flow; none of them has its own shortcut. + +### 9.4 Kill switches + +Engaging a kill switch takes one transaction: it flips the row, marks every affected `awaiting_approval` action as blocked with a reason, and audits the change. The executor checks kill switches during preflight, so actions already `claimed` stop at the next preflight. Resuming requires the `approver` role and a written reason. + +--- + +## 10. Importer (`@splitin/outreach-import`) + +Pipeline: `detect → parse (inert) → stage → map → normalize/validate → resolve duplicates → preview → commit`. + +**Detection.** Formats are detected by magic bytes and extension. Limits: 25 MB and 100k rows by default. Encoding is sniffed (BOM, then UTF-8 validation, then Windows-1252 fallback), and a sniffed encoding produces a warning in the preview. + +**Parsing is inert.** +- `parse5` for HTML, with `` and repeated-card extraction by a mapping-profile selector (a small CSS subset implemented over the parse5 tree; no browser). +- `exceljs` for XLSX, reading cached values only and ignoring formulas and external links. +- `csv-parse` in strict mode. +- JSON/JSONL natively. + +**Mapping profiles** are versioned JSON: `{ columns|selectors → canonical fields, transforms: trim|lower|split_name|url_canonical, required: [...] }`. + +**Normalization rules:** +- Emails: lowercase the domain; the local part is preserved except for a case-fold on known case-insensitive providers. +- Profile URLs: canonical host, strip query and tracking parameters. +- Names: whitespace collapse only; no guessing. + +**Duplicates.** Exact `contact_points` value matches produce `update`. The same name at the same organization domain with a different email produces `ambiguous`, which is never auto-merged. + +**Preview.** The preview lists counts per outcome, up to 20 sample rows each, and all errors. `preview_hash = sha256(batch source_sha256, profile version, normalized rows)`. + +**Commit.** A commit requires the matching `preview_hash` and an idempotency key. It creates contacts and contact points. It **never** creates enrollments. + +**Export.** Any CSV export escapes cells starting with `= + - @ \t \r` (formula injection). + +--- + +## 11. Surfaces + +### 11.1 CLI (`outreach`) + +```text +outreach init --db ./outreach.db +outreach migrate +outreach import preview --profile → prints preview + hash +outreach import commit --hash +outreach playbook compile → validation report +outreach campaign create --playbook --account +outreach campaign activate → prepare/commit, may require approval +outreach approvals list | approve --hash | reject +outreach pause|resume campaign|account|workspace +outreach kill engage|release --reason "…" +outreach tasks list | done | skip +outreach worker --once | --loop [--only executor,events,reconciler] +outreach status [campaign ] +outreach review list | resolve --as sent|not_sent|ignore +outreach audit verify +``` + +The CLI principal is `cli:` with admin rights on the local database. Remote surfaces never get this implicit trust. + +### 11.2 HTTP API (`@splitin/outreach-server`) + +`/v1` exposes resources that map one-to-one onto application services (`/imports`, `/campaigns`, `/operations/:id/commit`, `/approvals`, `/tasks`, `/status`, `/review`), plus `/v1/webhooks/*` and `/u/:token`. + +- Auth: bearer tokens hashed in the database (an `api_tokens` table added in M7), each bound to a principal and scopes. +- The server listens on loopback by default. Binding a non-loopback address requires `--public` and TLS termination in front of it. +- Every response carries a `trace_id`. + +### 11.3 MCP (`@splitin/outreach-mcp`) + +The MCP server comes in two stages. + +**Stage 1 — local stdio**, for Claude Code, Cursor and the `vscode-agent-router` peers. It uses the operator's identity, with no network exposure and no OAuth. It is cheap and useful immediately. + +**Stage 2 — remote Streamable HTTP**, for ChatGPT. It requires public HTTPS and OAuth 2.1 as a protected resource. It reuses `outreach-server` auth, with tokens audience-bound to the MCP resource. + +Tools are a fixed, deny-by-default list: + +| Tool | Class | +|---|---| +| `outreach_status`, `outreach_campaign_get`, `outreach_approvals_list`, `outreach_review_list` | Read | +| `outreach_import_preview`, `outreach_campaign_prepare`, `outreach_sequence_preview` | Draft (no effect) | +| `outreach_commit` | Mutation. Needs `operationId` + `operationHash`, and the host must confirm. | +| `outreach_pause` | Mutation, safe direction, still two-phase. | +| `outreach_task_done` | Mutation, human-confirmed manual outcome. | + +There is no tool that sends immediately, no tool that edits kill switches, and nothing touching the filesystem, shell or secrets. Tool descriptions state the exact effect. Everything returned from contacts or replies is marked as untrusted data in the tool result. + +### 11.4 Slack + +- **Notifications** (`@splitin/outreach-notify-slack`, M8) are `notify.publish` actions delivered through the same executor. They use an incoming webhook for a fixed channel or `chat.postMessage` when the destination varies. They get the same retry and uncertainty semantics as email, since a duplicated Slack ping is cheap but still counted. +- **Control:** a new app in `slack-agent-hq/apps/outreach`, as a separate PR per `CONTRIBUTING.md`. It uses the HQ's existing Slack plumbing plus signed-request verification (raw body, `v0` HMAC, 5-minute window) and acknowledges within 3 seconds, then processes asynchronously. + - Commands: `/outreach status|preview|pause`. + - Buttons carry only an opaque `operationId`. The click handler calls `commit` with the Slack user mapped to a `principals` row (`slack::`); unmapped users are refused. + +### 11.5 Papr Work app (`apps/papr`) + +Papr is a host, not a dependency. The engine's packages never import Papr code. + +| Papr primitive | Use | +|---|---| +| Registry database (`create_database`) | Holds the engine's SQLite file. Jobs declare it in `writeDbIds`. | +| `node` jobs | `outreach worker --once` every 1 min; `--only reconciler` every 5 min; `--only health` every 15 min. | +| Custom keys | Provider secrets injected as env vars. `secret_ref = env:NAME`. | +| Mini-app | Operator UI (below), calling the engine through its HTTP API bound to loopback, or directly through the app backend. | +| Skill markdown | `outreach.md`: teaches the agent to draft, preview and prepare, and never to commit without the user. | +| Browser | Manual tasks may open `target_url` in Papr Chrome. Opening is the only allowed browser action. | +| Workspace | `workspace_id` = the Papr workspace id. | + +UI intent: the Today view is a timeline of everything scheduled to go out in the next 24 hours. Each item shows the recipient, the frozen preview, why it is waiting (window, budget, approval), and one control that stops it. Beside it sit the approval queue, the review queue (uncertain sends, ambiguous replies, weak matches), manual tasks, imports and health. It follows Papr's mini-app design system; no parallel design language. + +**M7.0 spike (required before building):** confirm on Papr v2.6.18 how a job receives its registry database path (`jobDbProxyEnv.ts`, `jobSdkEnv.ts`), whether `node` jobs can run an npm binary, how custom keys arrive in the environment, and how local-only placement is declared. The findings go in ADR 0005. + +Papr jobs stop when the desktop sleeps. That is acceptable for a pilot. Production uses the standalone worker (`--loop` under systemd or launchd) with Papr as the UI only; both point at the same engine API. + +--- + +## 12. Security + +**Threat model (`docs/threat-model.md`).** Assets: +- provider tokens; +- contact PII; +- the ability to send as the operator; +- the audit log. + +Entry points: +- imports (hostile files); +- webhooks (forgery, replay); +- HTTP/MCP (auth bypass, IDOR, prompt injection through contact or reply text); +- Slack (forged interactions); +- the local database file. + +Controls: + +- **Secrets:** only `secret_ref` values are stored, resolved at call time from env or the OS keychain, never logged. A redaction filter runs on every log line and error detail, and a test asserts that known token shapes never appear in logs, receipts or audit. +- **Egress:** adapters may only call their declared base URLs (checked by an allowlisted fetch wrapper; no user-controlled URLs reach fetch). +- **Webhooks:** raw-body verification before parsing, timestamp window, event-id dedupe, and a 1 MB size cap. +- **Transport:** HTTP binds loopback by default. Tokens are hashed at rest, scoped, and expire. Every mutation is two-phase and audited. +- **Isolation:** every repository function takes `workspaceId` from context and includes it in its `WHERE` clause. A test fixture with two workspaces asserts zero cross-reads on every service method. +- **Untrusted text:** contact attributes and inbound bodies are data. They go into templates only through escaping, and into MCP results only inside marked untrusted fields. +- **PII minimization:** inbound bodies are not stored by default (headers, ids and classification only). A configurable retention period prunes contact data for opted-out contacts, keeping only the suppression hash. +- **Live-send gate:** until decision D1 and legal sign-off are recorded, the executor refuses any recipient not in `OUTREACH_LIVE_ALLOWLIST` (exact addresses or domains). This is enforced in preflight, not in the UI. + +--- + +## 13. Testing + +| Suite | What it must prove | +|---|---| +| Contracts | Transition table is closed; property test: no path to a second provider call without `absent`/`rejected`. | +| Store | Migrations up on an empty and a seeded database; constraint violations fire (live enrollment uniqueness, idempotency, event dedupe); audit triggers abort; hash-chain verify detects tampering. | +| Failure injection | Fake provider modes: `accept`, `reject_retryable`, `reject_permanent`, `unknown_after_accept`, `unknown_before_accept`, `rate_limited(retryAfter)`, plus crash hooks at every step boundary (after claim, after preflight commit, mid-call, before result commit). Assert final states and exact provider-call counts. | +| Concurrency | 4 processes × 10k actions on one file: zero duplicate attempts. Reply arriving during a claim: no send. Kill switch during a claim: no send. | +| Calendar | Property tests across time zones, DST and holidays. | +| Policy | Suppression precedence, purpose gating, approval hash invalidation on template edit, separation of duties, expiry, revocation. | +| Import | Hostile HTML (scripts, huge attributes, deep nesting), CSV formula cells, malformed quoting, 100k-row CSV under memory budget, XLSX with formulas/external links, mixed encodings, ambiguous duplicates, preview/commit hash mismatch. | +| Inbound | DSN parsing, OOO headers, thread/`References` correlation, weak matches → review, webhook forgery/replay/oversize rejected, poll overlap dedupe. | +| Surfaces | CLI golden output; HTTP auth/scope/IDOR; MCP tool list is exactly the allowlist, commit needs a hash, cross-workspace denied; Slack signature/replay/unmapped-user refusal. | +| End to end | Fake provider: import → compile → activate → approve first batch → sends → inbound reply → atomic stop → Slack notice → audit verify. Runs in CI in under 30 s. | +| Live (opt-in) | `OUTREACH_LIVE=1`, sandbox account, allowlisted internal recipients only; never in default CI. | + +Performance budgets, checked in CI on the end-to-end fixture: +- `worker --once` cold start < 400 ms; +- RSS < 90 MB; +- claim + preflight + result overhead < 5 ms per action (excluding the provider call); +- 100k-row CSV preview < 10 s and < 250 MB. + +--- + +## 14. Milestones + +Each milestone is one PR inside `outreach-engine/`, is independently green, and leaves the system usable. Sizes: S = a few days, M = about a week, L = about two weeks, for one engineer working with agents. + +| # | Milestone | Size | Contents | Acceptance | +|---|---|---|---|---| +| M0 | Scaffold + ADRs | S | Workspace, tsconfig, lint, vitest, boundaries script, CI workflow, README, ADR 0001 (architecture), 0002 (at-most-once-without-confirmation), 0003 (no social automation) | CI green on an empty package set; ADRs merged. | +| M1 | Contracts + fakes | M | §5 types and zod schemas, `ACTION_TRANSITIONS`, error taxonomy, capability/purpose model, fake email/notify/manual providers with failure modes, conformance kit | Fakes pass their own conformance; transition property test green. | +| M2 | SQLite store | M | Driver port with `node:sqlite` + `better-sqlite3` adapters, migrations §4, repositories, audit chain, `audit verify` | Store suite green on both drivers. | +| M3 | Execution core | L | Claim, preflight, execute, result, lease sweeper, reconciler, review queue, rate buckets, kill switches, `worker --once/--loop` | Full failure-injection and concurrency suites green. **This is the milestone that must not be rushed.** | +| M4 | Campaign domain | L | Playbook compiler, templates + freezing, calendar, audience snapshot, activation, enrollment progression, approvals (three scopes), suppression, two-phase `prepare/commit`, roles | Policy + calendar suites; fake E2E up to "sends happen". | +| M5 | Importer | M | §10 in full, mapping profiles, CLI import commands | Import suite + performance budget. | +| M6 | Inbound | M | Provider event inbox, poll cursor, classification, correlation, atomic stop, unsubscribe token + `/u/:token` | Inbound suite; full fake E2E including reply stop. | +| M7 | Surfaces | M | CLI complete, HTTP server + tokens, M7.0 Papr spike → `apps/papr` jobs + skill + minimal UI | CLI/HTTP suites; Papr app runs the fake E2E on a desktop install. | +| M8 | Real providers | M | Reference email adapter (per D1) + conformance against sandbox; Slack notifier; the Slack control app lands separately in `slack-agent-hq` | Adapter passes conformance; live opt-in run to the allowlist. | +| M9 | MCP | M | Stage 1 stdio; Stage 2 remote once D3/D4 are settled | MCP suite; handshake verified in Claude Code, and in ChatGPT for Stage 2. | +| M10 | Release | S | Publish workflow, versioned docs, runbook, threat model, hub README row, `splitin.net/tech-stack` entry | Signed tag publishes `@splitin/outreach-*` through OIDC. | + +Critical path: M0 → M1 → M2 → M3 → M4 → M6 → M8. M5 can run in parallel after M2. M7 can start after M4. M9 comes after M7. + +**Pilot readiness** means M0–M8 are done, decisions D1–D3 are recorded, legal sign-off exists for the target jurisdictions and message class, and the live-send allowlist is lifted by an admin through an audited action. + +--- + +## 15. Upstream track (Papr Work) — parallel, non-blocking + +| Step | What | When | +|---|---|---| +| U1 | Privately disclose to the maintainers (email or GitHub private security advisory; not a public issue): gateway default bind `0.0.0.0` (`src/gateway/index.ts:198`); plaintext `cookies.json` session storage (`PlatformSessionService.ts:697,1363`); empty allowlist returns every tool (`ToolRegistry.ts:80-83`). Offer patches. | Now | +| U2 | Open a short issue (text in Appendix A). One question: outreach primitives in core, or an app on the extension surface? | After M3 is demoable | +| U3 | Tiny docs/CI PR: `CONTRIBUTING.md` targets a nonexistent `develop` branch; CI never runs `npm run check`, so the 500-line rule is unenforced. | Any time | +| U4 | If the maintainers want it in core: port `contracts` + the execution core as small PRs under `src/gateway/services/outreach/`, relicensed into AGPL (our MIT code allows this). Otherwise publish `apps/papr` to the Papr Cloud catalog. | After their answer | + +Papr's own LinkedIn automation (the social-media-auth skill, `papr_platform_browser.py`) is their decision. Our proposal simply excludes social automation from scope; we don't frame it as a policy lecture. + +--- + +## 16. SplitIn private layer (never in this repo) + +The private repository `splitintech/splitin-outreach-config` holds: + +- mapping profiles for SplitIn's HTML/CSV lead lists; +- personas and eligibility rules; +- playbooks and templates (copy, signatures, postal address); +- account wiring (`secret_ref` names only); +- Slack channel and principal mappings; +- jurisdiction and legal-basis decisions; +- suppression seeds; +- reporting definitions. + +It consumes the published `@splitin/outreach-*` packages through their CLI, API and playbook format only; no fork. Secrets live in the host's env or keychain, never in either repo. + +--- + +## 17. Traceability to the original plan + +| Original plan item | Disposition | +|---|---| +| Import → validate → enroll → execute → detect replies → stop → report | Kept (§6–§10) | +| Leads / sequences / steps / templates / enrollments / events / suppressions / mail accounts / runtime settings | Replaced by §4 (adds contact points, versions, actions/attempts, messages, provider events, approvals, audit, tenancy) | +| `MailConnector` single interface | Split into ports (§5.2) | +| Tick + insert-before-send dedupe | Replaced by the claimed/executing split and reconciliation (§6) | +| Subject/from reply search | Replaced by thread/RFC correlation (§8.3) | +| Zoho + SMTP as v1 adapters | One reference adapter chosen by D1; Zoho Mail limited to `manual_correspondence` | +| LinkedIn connect/DM workers, warm-up caps | Dropped; `manual.task` only | +| Slack incoming webhook digest | Kept as the notifier; control moves to a signed Slack app | +| Optional MCP client | Dropped for now; an MCP **server** is what ChatGPT needs (§11.3) | +| Mini-app screens, first-run wizard, "no send-10k button", capacity shown before enroll | Kept (§11.5) | +| Kill switch, caps, quiet hours, circuit breakers, approve first N | Kept and made transactional (§6.4, §9) | +| Dry-run CI with fakes, opt-in live E2E | Kept (§13) | +| Runbook, metrics | Kept → `docs/runbook.md`; metrics in §18 | +| Upstream PR-A…F | Replaced by §14 here and §15 upstream | + +--- + +## 18. Operations and metrics + +`outreach status` and the UI expose: + +- queue depth by state; +- oldest due age (lag); +- claim expiries; +- attempt outcomes (succeeded / retryable / uncertain); +- review-queue size and age; +- reply latency (inbound received → enrollment stopped); +- suppression hits at preflight; +- bounce and complaint rates per account (breaker: 5% hard bounces over the last 100 sends, or any complaint, engages the account kill switch); +- approval age; +- webhook verification failures. + +The runbook covers: + +- expired auth; +- rate-limit blocks; +- an uncertain-send backlog; +- webhook outage (polling covers it); +- a bounce spike; +- a suspected duplicate (use `audit verify` and the attempts timeline); +- a reply-stop miss; +- restoring from backup (SQLite `VACUUM INTO` snapshots, taken daily by the worker). + +--- + +## 19. Open decisions + +| ID | Decision | Owner | Blocks | +|---|---|---|---| +| D1 | Email provider whose terms permit `automated_outreach` for SplitIn's audience and volume. Criteria: API + OAuth or scoped keys, thread/Message-ID exposure, custom headers, inbound webhook or polling, sent-search for reconciliation, EU/US data residency as needed. | SplitIn business + legal | M8 live sends | +| D2 | Production host: standalone worker (recommended) vs Papr jobs only | Engineering | M7 docs, pilot | +| D3 | Public URL for webhooks and one-click unsubscribe (e.g. a small VM or Cloudflare Tunnel) | Engineering | M6 unsubscribe, M8 webhooks, M9 stage 2 | +| D4 | OAuth provider for remote MCP / HTTP tokens (self-issued vs an existing IdP) | Engineering | M9 stage 2 | +| D5 | Jurisdictions and message class for the first campaign; legal sign-off | Legal | Pilot | + +--- + +## Appendix A — Upstream issue draft (U2) + +> **Proposal: reliable outreach sequences as a Papr app (or core primitives, if you prefer)** +> +> We are building an MIT-licensed outreach engine (`splitintech/open-internal-tools/outreach-engine`): versioned sequences, a durable action queue with leases and explicit "uncertain" states (no blind resends after timeouts), reply/bounce/opt-out stop, human approvals bound to exact content, and fake-provider tests. It runs today via Papr jobs (`worker --once`) and a registry database, with a mini-app UI. +> +> One question before we go further: would you want the durable action/outbox primitives in Papr's core (we would port them in small PRs, relicensed AGPL, bound to your existing workspace model), or do you prefer this stays an app on the extension surface and is published to the Community catalog? +> +> Social-network automation is out of scope for this proposal. Happy to demo. + +## Appendix B — Example fixtures + +Fixtures use only `example.com`, `example.org`, `example.net` and generated names. `examples/fixtures/leads-100.csv` uses the header below. `examples/fixtures/leads-hostile.html` and `leads-formulas.xlsx` exist for the security suites. + +```csv +email,profile_url,full_name,first_name,org_name,org_domain,title,timezone,attr.segment +``` From 8122da829d801cb262bca2505f3b155eebb1f5e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 01:12:23 +0000 Subject: [PATCH 02/20] Add outreach-engine M0 scaffold. npm workspace with strict TypeScript, ESLint, Vitest and tsup; CI workflow on Node 22 and 24; scripts enforcing package boundaries, a secret scan and a 400-line file limit; README; ADRs 0001-0003; and a seed @splitin/outreach-contracts package so the pipeline builds and tests real code. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MbDW8kfMcxUuZ3WBtaDKBv --- .github/workflows/outreach-engine.yml | 43 + outreach-engine/.gitignore | 13 + outreach-engine/.npmrc | 1 + outreach-engine/BUILD_PLAN.md | 4 +- outreach-engine/LICENSE | 21 + outreach-engine/README.md | 63 + outreach-engine/docs/adr/0001-architecture.md | 42 + .../0002-at-most-once-without-confirmation.md | 44 + .../docs/adr/0003-no-social-automation.md | 33 + outreach-engine/eslint.config.js | 21 + outreach-engine/package-lock.json | 3451 +++++++++++++++++ outreach-engine/package.json | 36 + .../packages/outreach-contracts/package.json | 32 + .../outreach-contracts/src/index.test.ts | 14 + .../packages/outreach-contracts/src/index.ts | 12 + .../packages/outreach-contracts/tsconfig.json | 5 + .../outreach-contracts/tsup.config.ts | 10 + outreach-engine/scripts/check-max-lines.mjs | 54 + .../scripts/check-package-boundaries.mjs | 118 + outreach-engine/scripts/scan-secrets.mjs | 58 + outreach-engine/tsconfig.base.json | 24 + outreach-engine/vitest.config.ts | 8 + 22 files changed, 4105 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/outreach-engine.yml create mode 100644 outreach-engine/.gitignore create mode 100644 outreach-engine/.npmrc create mode 100644 outreach-engine/LICENSE create mode 100644 outreach-engine/README.md create mode 100644 outreach-engine/docs/adr/0001-architecture.md create mode 100644 outreach-engine/docs/adr/0002-at-most-once-without-confirmation.md create mode 100644 outreach-engine/docs/adr/0003-no-social-automation.md create mode 100644 outreach-engine/eslint.config.js create mode 100644 outreach-engine/package-lock.json create mode 100644 outreach-engine/package.json create mode 100644 outreach-engine/packages/outreach-contracts/package.json create mode 100644 outreach-engine/packages/outreach-contracts/src/index.test.ts create mode 100644 outreach-engine/packages/outreach-contracts/src/index.ts create mode 100644 outreach-engine/packages/outreach-contracts/tsconfig.json create mode 100644 outreach-engine/packages/outreach-contracts/tsup.config.ts create mode 100644 outreach-engine/scripts/check-max-lines.mjs create mode 100644 outreach-engine/scripts/check-package-boundaries.mjs create mode 100644 outreach-engine/scripts/scan-secrets.mjs create mode 100644 outreach-engine/tsconfig.base.json create mode 100644 outreach-engine/vitest.config.ts diff --git a/.github/workflows/outreach-engine.yml b/.github/workflows/outreach-engine.yml new file mode 100644 index 0000000..4ccf708 --- /dev/null +++ b/.github/workflows/outreach-engine.yml @@ -0,0 +1,43 @@ +name: outreach-engine + +on: + push: + paths: + - "outreach-engine/**" + - ".github/workflows/outreach-engine.yml" + pull_request: + paths: + - "outreach-engine/**" + - ".github/workflows/outreach-engine.yml" + workflow_dispatch: + +permissions: + contents: read + +defaults: + run: + working-directory: outreach-engine + +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + node-version: [22, 24] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + cache-dependency-path: outreach-engine/package-lock.json + - run: npm ci + - run: npm run build + - run: npm run lint + - run: npm run typecheck + - run: npm test + - run: npm run boundaries + - run: npm run secrets + - run: npm run loc diff --git a/outreach-engine/.gitignore b/outreach-engine/.gitignore new file mode 100644 index 0000000..05980c8 --- /dev/null +++ b/outreach-engine/.gitignore @@ -0,0 +1,13 @@ +node_modules/ +dist/ +coverage/ +.DS_Store +*.log +*.db +*.db-wal +*.db-shm +.env +.env.* +!.env.example +*.tgz +tmp/ diff --git a/outreach-engine/.npmrc b/outreach-engine/.npmrc new file mode 100644 index 0000000..268c392 --- /dev/null +++ b/outreach-engine/.npmrc @@ -0,0 +1 @@ +provenance=true diff --git a/outreach-engine/BUILD_PLAN.md b/outreach-engine/BUILD_PLAN.md index a4ba526..c163bc3 100644 --- a/outreach-engine/BUILD_PLAN.md +++ b/outreach-engine/BUILD_PLAN.md @@ -82,7 +82,7 @@ outreach-engine/ README.md quick start (M0) package.json npm workspaces, private tsconfig.base.json - vitest.workspace.ts + vitest.config.ts eslint.config.js packages/ outreach-contracts/ types, zod schemas, state tables, error taxonomy, capability model @@ -908,7 +908,7 @@ Each milestone is one PR inside `outreach-engine/`, is independently green, and | # | Milestone | Size | Contents | Acceptance | |---|---|---|---|---| -| M0 | Scaffold + ADRs | S | Workspace, tsconfig, lint, vitest, boundaries script, CI workflow, README, ADR 0001 (architecture), 0002 (at-most-once-without-confirmation), 0003 (no social automation) | CI green on an empty package set; ADRs merged. | +| M0 | Scaffold + ADRs | S | Workspace, tsconfig, lint, vitest, boundaries/secret/line-limit scripts, CI workflow, README, ADR 0001 (architecture), 0002 (at-most-once-without-confirmation), 0003 (no social automation), and a seed `outreach-contracts` package so the pipeline builds and tests real code | CI green; each check proven to fail on a planted violation; ADRs merged. | | M1 | Contracts + fakes | M | §5 types and zod schemas, `ACTION_TRANSITIONS`, error taxonomy, capability/purpose model, fake email/notify/manual providers with failure modes, conformance kit | Fakes pass their own conformance; transition property test green. | | M2 | SQLite store | M | Driver port with `node:sqlite` + `better-sqlite3` adapters, migrations §4, repositories, audit chain, `audit verify` | Store suite green on both drivers. | | M3 | Execution core | L | Claim, preflight, execute, result, lease sweeper, reconciler, review queue, rate buckets, kill switches, `worker --once/--loop` | Full failure-injection and concurrency suites green. **This is the milestone that must not be rushed.** | diff --git a/outreach-engine/LICENSE b/outreach-engine/LICENSE new file mode 100644 index 0000000..f701f8c --- /dev/null +++ b/outreach-engine/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 SplitInTech + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/outreach-engine/README.md b/outreach-engine/README.md new file mode 100644 index 0000000..fe05b4c --- /dev/null +++ b/outreach-engine/README.md @@ -0,0 +1,63 @@ +

+ SplitIn logo +

+ +# Outreach Engine + +MIT-licensed, provider-neutral outreach orchestration. It imports contacts, turns +versioned playbooks into per-contact scheduled actions, executes each action at most +once through capability-checked provider adapters, stops on reply, bounce, opt-out or +pause, and records every transition in an append-only audit log. + +The engine is the product. The CLI, HTTP API, MCP server, Slack and the Papr Work app +are thin clients of it. + +> **Status: M0 (scaffold).** No outreach logic exists yet. The full specification is +> [BUILD_PLAN.md](BUILD_PLAN.md); milestones are in §14. + +## Guarantees it is being built to + +- **At most once without confirmation.** A send whose outcome is unknown is never + repeated until the provider confirms it did not happen ([ADR 0002](docs/adr/0002-at-most-once-without-confirmation.md)). +- **Fail closed.** Unhealthy account, engaged kill switch, expired approval, suppressed + recipient or unrenderable template all mean no send, with a recorded reason. +- **No social-network automation.** Social steps are manual tasks a human completes on + the native site ([ADR 0003](docs/adr/0003-no-social-automation.md)). +- **No LLM in the send path.** Models may draft and suggest; deterministic code decides. + +## Packages + +| Package | Milestone | What it is | +| --- | --- | --- | +| `@splitin/outreach-contracts` | M0 seed, M1 | Types, schemas, action transition table, error taxonomy, provider ports | +| `@splitin/outreach-fakes` | M1 | Fake providers with failure modes + provider conformance kit | +| `@splitin/outreach-store-sqlite` | M2 | Migrations, repositories, audit hash chain | +| `@splitin/outreach-core` | M3–M4, M6 | Execution core, campaign domain, policy, inbound processing | +| `@splitin/outreach-import` | M5 | Inert HTML/CSV/XLSX/JSON importer | +| `@splitin/outreach-server`, `-cli`, `-mcp` | M7, M9 | Surfaces | +| `@splitin/outreach-notify-slack`, `-provider-email-*` | M8 | Adapters | + +## Develop + +Requires Node ≥ 22.13. + +```zsh +cd open-internal-tools/outreach-engine +npm install +npm run check # lint, typecheck, test, package boundaries, secret scan, line limit +npm run build +``` + +`npm run boundaries` enforces the dependency direction in BUILD_PLAN.md §3: the core, +importer and adapters depend on `@splitin/outreach-contracts` only, and storage, HTTP, +MCP, Slack and file-parsing libraries are confined to the packages that own them. A new +package fails the check until it is given a rule. + +## Decisions + +Architecture decisions live in [docs/adr/](docs/adr/). Change a decision by adding a new +ADR that supersedes the old one, not by editing it. + +## License + +MIT. See [LICENSE](LICENSE). diff --git a/outreach-engine/docs/adr/0001-architecture.md b/outreach-engine/docs/adr/0001-architecture.md new file mode 100644 index 0000000..4f08549 --- /dev/null +++ b/outreach-engine/docs/adr/0001-architecture.md @@ -0,0 +1,42 @@ +# ADR 0001 — The engine is the product; surfaces are thin clients + +- Status: Accepted +- Date: 2026-09-28 + +## Context + +The original Papr Work GTM plan put the whole outreach domain inside one Papr mini-app +bundle, with workers as Papr jobs ticking over a shared SQLite database. Papr's jobs +guarantee that a run starts, not that an external effect inside it happens once; +interrupted runs are reconciled as failed. Papr has no MCP server, no Slack integration +and no email provider. The project is maintained by two people and rarely merges outside +PRs, so building inside Papr's core would make our roadmap depend on their review queue. + +We also need the same behaviour from several places: a CLI, an HTTP API with webhook +ingress, an MCP server for Claude Code, Cursor and ChatGPT, Slack commands and approvals, +and the Papr Work UI. + +## Decision + +Build a standalone, provider-neutral engine in `outreach-engine/` as MIT packages: + +- Application services hold every business rule. The CLI, HTTP API, MCP server, Slack + app and Papr app call those services and implement no rules of their own. +- The store is the only source of truth. Every external effect originates from a + `scheduled_actions` row; nothing calls a provider directly. +- Workers are idempotent functions of `(store, providers, clock)` exposed as + `outreach worker --once | --loop`, so a Papr job, cron, systemd or launchd can host + them without a daemon of ours. +- Identity and workspace come from the authenticated context, never from tool arguments + or payloads. In Papr mode the workspace is Papr's workspace id. +- Papr is a host, not a dependency. No package imports Papr code. +- Dependency direction is enforced in CI by `scripts/check-package-boundaries.mjs`. + +## Consequences + +- SplitIn is not blocked on upstream. MIT code can be relicensed into Papr's AGPL core + later if its maintainers ask for it. +- Papr jobs stop while the desktop sleeps, so production runs the standalone worker and + uses Papr as a UI; the pilot may run inside Papr. +- Every surface needs its own auth mapping onto engine principals. This is intended: it + keeps trust decisions explicit per surface. diff --git a/outreach-engine/docs/adr/0002-at-most-once-without-confirmation.md b/outreach-engine/docs/adr/0002-at-most-once-without-confirmation.md new file mode 100644 index 0000000..7ff040a --- /dev/null +++ b/outreach-engine/docs/adr/0002-at-most-once-without-confirmation.md @@ -0,0 +1,44 @@ +# ADR 0002 — At most once without confirmation + +- Status: Accepted +- Date: 2026-09-28 + +## Context + +The original plan deduplicated sends by inserting an event key "before any external side +effect" and skipping on conflict. That loses sends when a process dies between the +insert and the provider call, and duplicates them when the call succeeds but the process +dies before recording the receipt, or when a timeout hides an accepted send. Most email +and notification providers cannot guarantee exactly-once delivery, and many do not +honour client idempotency keys. + +## Decision + +The engine guarantees that an action is executed **at most once unless the provider +confirms it was not executed**. + +- An action moves `scheduled → claimed` under a lease. A crash while `claimed` returns it + to `scheduled`; no attempt was started, so this is safe. +- Before the provider call, one committed transaction re-checks every precondition and + moves the action to `executing` with an `action_attempts(pending)` row. +- A provider result of accepted, rejected-retryable or rejected-permanent is recorded in + a second transaction. +- A timeout or connection loss after the request may have left the process, or an + expired lease while `executing`, moves the action to `uncertain`. It is never retried + automatically. +- The reconciler asks the provider (by our generated `Message-ID`, then idempotency key, + then recipient and time window). `found` means succeeded; `absent` means it may be + rescheduled; repeated `still_unknown` sends it to a human review queue. +- Adapters must return `absent` only when the provider can affirm it. The conformance kit + enforces this. +- Transitions live in one table in `@splitin/outreach-contracts`; the store rejects any + transition not in it. A property test asserts that no path reaches a second provider + call without an intervening `absent` or `rejected`. + +## Consequences + +- We never claim exactly-once delivery. +- Some legitimate sends wait in review when a provider cannot be queried. That is the + price of never double-contacting a person, and it is visible in the UI. +- The only race we cannot close is a reply that arrives while a send is already in + flight; it is bounded by one provider call and documented in the runbook. diff --git a/outreach-engine/docs/adr/0003-no-social-automation.md b/outreach-engine/docs/adr/0003-no-social-automation.md new file mode 100644 index 0000000..638bc9d --- /dev/null +++ b/outreach-engine/docs/adr/0003-no-social-automation.md @@ -0,0 +1,33 @@ +# ADR 0003 — No social-network automation; social steps are manual tasks + +- Status: Accepted +- Date: 2026-09-28 + +## Context + +The original plan included scheduled LinkedIn connection-request and DM workers driving +a logged-in browser. LinkedIn's User Agreement prohibits unauthorized automated access, +contact additions and messaging. Its Invitations and Messages APIs are limited to +approved partners, and the Messages API requires a member's contemporaneous action for +each send. Browser feasibility is not permission, and the failure mode is losing the +operator's account. + +## Decision + +- Playbooks may include social steps only as `manual.task`. The compiler rejects any + other step type on a social channel. +- A manual task stores the target profile URL and a rendered, editable draft. A host may + open the URL for the human; opening is the only browser action allowed. +- Only an authenticated principal can mark a manual task `done` or `skipped`. No code + path completes one automatically. +- The engine contains no stealth, anti-detection, CAPTCHA, challenge or 2FA handling, + and no scraping. +- An official social API adapter may be added later only behind a verified entitlement, + as a new ADR. It may never fall back to browser automation. + +## Consequences + +- Social touches cost human time. Reminders, drafts and a task queue keep that cost low. +- The engine is safe to publish and run against real accounts. +- Hosts such as Papr Work may ship their own social automation; this project neither + depends on nor extends it. diff --git a/outreach-engine/eslint.config.js b/outreach-engine/eslint.config.js new file mode 100644 index 0000000..c13ce37 --- /dev/null +++ b/outreach-engine/eslint.config.js @@ -0,0 +1,21 @@ +import eslint from '@eslint/js'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { ignores: ['**/dist/**', '**/node_modules/**', 'coverage/**'] }, + eslint.configs.recommended, + ...tseslint.configs.recommended, + { + files: ['scripts/**/*.mjs'], + languageOptions: { + globals: { process: 'readonly', console: 'readonly', URL: 'readonly' }, + }, + }, + { + files: ['**/*.ts'], + rules: { + '@typescript-eslint/no-explicit-any': 'error', + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], + }, + }, +); diff --git a/outreach-engine/package-lock.json b/outreach-engine/package-lock.json new file mode 100644 index 0000000..44cab6a --- /dev/null +++ b/outreach-engine/package-lock.json @@ -0,0 +1,3451 @@ +{ + "name": "outreach-engine", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "outreach-engine", + "version": "0.0.0", + "license": "MIT", + "workspaces": [ + "packages/*", + "apps/*" + ], + "devDependencies": { + "@eslint/js": "^9.39.5", + "@types/node": "^22.20.4", + "eslint": "^9.39.5", + "tsup": "^8.5.1", + "typescript": "^5.9.3", + "typescript-eslint": "^8.70.1", + "vitest": "^3.2.7" + }, + "engines": { + "node": ">=22.13" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.7.tgz", + "integrity": "sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.2", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.5.tgz", + "integrity": "sha512-J25QJU+B78T4FhhBsNpLJyVWOi31mwtpcMwywHmOKH65Q9IWGA81gPj+dnwlhU8wktVriYE+tFAaQgrnJRzAZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.5.tgz", + "integrity": "sha512-LDopB3zuZM5Ux9TT2luNEBJW/tYbGU2g1d+VpKk6I+gSKDb+/7sYE6M225gRQt4RbMX6MSwMsVR/phdjVUgRLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.5.tgz", + "integrity": "sha512-wlJEERGfeuHeBavCL2qVnNacOK43NDoZM4sjkeRPymd04OAE9T1zBqDJgmZ+CIsPTYKwdzpUC8vmOw84dwY4Tg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.5.tgz", + "integrity": "sha512-4nJJGg5jbo2wwPP4JP+LfEBA3bvP8rU9CLuhp7jWvq9sxEyhjQFTFdrqi+/dHEin/pd8jpT0vcehIpnZtmEdcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.5.tgz", + "integrity": "sha512-DrZbyCDF1hneuO6jRbvZ2D7+PIBM6yIwYnJpg2vIk58T+wuFpiaGZrfUr59lDWw45bg+IrpTGLPiNi/Fk4w3Cg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.5.tgz", + "integrity": "sha512-gqfUVMJMB3mehqywxp6hTBFfgtMQykZY19+cfiaYP0toIJLb/1DZRJHVkQQGP13W4TAwfZDWeg1qBcheTRioXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.5.tgz", + "integrity": "sha512-CFmhpvAwzSaWMlN3VN7UtmoTihlZNzoP0juQib5TQRnYUyDV8dXeWOp29sobWAT6gXl/hQgAClLlEiYozQG3OQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.5.tgz", + "integrity": "sha512-Uc9H8eXCOayV6JLTH5bXKMId6qbhNHa818/BgYjm4jrlq3vZquC9cqyvHBw17xy5Mnj5f+I3gFK5JcEf3hSqrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.5.tgz", + "integrity": "sha512-VcPr/szv/1BFw112Kt//fxulXt/JPqzzidU84iW68L2DdjnOO8QFUv2zTSYBEPHD6movBD4z+bbr5y60GYM7Jw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.5.tgz", + "integrity": "sha512-BnxtJ5/91BrIHYIkGrmjz/lbMhqEHt1dPFqIxIFR+jPn0xVc/oUSCtIT089zfp5ufwGDlYz2UC+Fe1SRBpYFbQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.5.tgz", + "integrity": "sha512-LrYcHZwF+fAMNKHYTOQ5osWM4AZF7YF6D+XtsjDyEvljtt11twc+zHVXBLNEjxVSUnKYsOhvVz4Z213eW02COQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.5.tgz", + "integrity": "sha512-nj7QKQePAAUpCpJHtg0pR0W/b92A9NO17JS3BAQmHDn/yhmkir2p8llrKY9TOhleKIaSzy1JhxS3T9FVld6coA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.5.tgz", + "integrity": "sha512-5ylkX6dWMeBKge9nTU+Rxfb+ZfaCIJ9lRqIFaK0eAMcWp7OJbYnLveLgXmm0VrvuLKb8qIK+mHyH0qu88RM+iA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.5.tgz", + "integrity": "sha512-oHK4ZHYFDKjZviK34I+NwgfbGxgI7ztrNxj2hPTSSNFgeq1a/lEd7dHV2fdGAuTH4Iym3RHJg+vAbWaWG4B7Zg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.5.tgz", + "integrity": "sha512-UcetmHZ6XOXuUByiKZyQmb55ZPr0LABr3Ec/HB9wKZn6CEAFWZkE+hsJErJ9hbPBC7nI0dKuELx7CoV6IM7TMg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.5.tgz", + "integrity": "sha512-C5CmDPQBtvjVo8cgQsBs+w6WB0JLkiixhgi6hVLV11hERWdn/p0XcPU2OUcZzac9BPOFq7SbaHFa8r3SWEysCQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.5.tgz", + "integrity": "sha512-lHVQHJFKsuuxLMi3MQO9XVL8Tje3JR82CzB+QDKC5NWBcsIWuwsn9uIM5e3lBhI+fF1/s63qnyYqsg65+8rV/w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.5.tgz", + "integrity": "sha512-3W9bTFcQNJn71cSJVM9RKIiZOy8DO/XLDii8Uv/Pm6WKqDRj7JV3ZfuXIEfyuy5LXpIzAbB/1M4Ukp9GKNa7nA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.5.tgz", + "integrity": "sha512-VDC7rRJlee/scpki96GZ27Omf6yU87s1YXwVTpjE5841faVlDYYT565rgfmoR1U0sqL7z5ivQSDjcsF6VRXyBA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.5.tgz", + "integrity": "sha512-z86Ok2p4pTdv5xqCKZsTooO7yBEiaJR/HzU3Wx8RmWsPoLppnMKROhJusQob8B3IE1ghC343kUW9rC2r+Wf3ig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.5.tgz", + "integrity": "sha512-IzQmj+xXwQFGhMAMKMQVXkMwMZN3TqkJgAE0nSsqvVwWWciP4AIPMmWRqOQ2GfX7TUDZr+xqGFcBS36CRPGw0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.5.tgz", + "integrity": "sha512-F6qpTaPc9bwBH85kjy0/BLmLSW1uv7AoOXCoRIkg2arlgCYlWYcAbiMkvZuAcaWk9TpCRG//okznLAqLGshkMw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.5.tgz", + "integrity": "sha512-igoDsTFhhwECBeGbUuLeIk7t8Y1apa+cs6mDWpx2EZ0ch7oEQgzHbFUXN9euoHekCAQzXdXApAGkV6jznS7tWw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.5.tgz", + "integrity": "sha512-U3teMeMbXFmaM5D+OTJpsOXd+wV/qftIeYF9kBKL4v73641qyJmoXFtA28DQLsnmlyayEsTe72xpLHrArq6vHw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.5.tgz", + "integrity": "sha512-ypfC34F3RKXvCXBglGqGMsUSMKlgwd1HX9AOAlx9RoZZ6GaI42YHVeKpzg3JG+wpBUJYTG+NNZhqbDWL8tBZkw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@splitin/outreach-contracts": { + "resolved": "packages/outreach-contracts", + "link": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.4.tgz", + "integrity": "sha512-zJRE40jpHtKqE/C4fgHrAKQLJuSpzEnP9ff9Y7YtoR3Wd2pwqzlekDeEuUQXjRd+QCYnVnNwuJYmhdk9XV8gvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.1.tgz", + "integrity": "sha512-nDNrUQ/4ruSNYbu749TRY7cfrzPtoLHEXSNBI8aaNY32LlZCajixqRf3FqcKC4p5Cam4VOHYx/t+i5+nKXvrqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.70.1", + "@typescript-eslint/type-utils": "8.70.1", + "@typescript-eslint/utils": "8.70.1", + "@typescript-eslint/visitor-keys": "8.70.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.70.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.10", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.10.tgz", + "integrity": "sha512-HpbUakT7xp5miBUywCHf36ZEuAJNklBJDDsGpUIjMzOSmM8ELSfA9Sa/QDPeNeqeoN31u+UTCkL4klCOVvRm4Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.70.1.tgz", + "integrity": "sha512-nO974WLllwhSFWQXnMLj6nDGa8f0khKEz1JzpPJ1u7Vm/4X1X6ZHajpoknU4bb41vJyMB0HHVyS2GqdhWfIXZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.70.1", + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/typescript-estree": "8.70.1", + "@typescript-eslint/visitor-keys": "8.70.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.70.1.tgz", + "integrity": "sha512-62xOgboPfwc3/IgPSX/W6oQR3ZbF04194FPGUGH8HL8iLFHbt/456/8Ph1wLNUgVF+s94FlHoipBsz+v7+LMnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.70.1", + "@typescript-eslint/types": "^8.70.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.70.1.tgz", + "integrity": "sha512-Pa0EeSeAusQc1WbjQMac+YfenewYTBu0KjgYvkUKwhXaHUKbFog23Dm/rp0DX/6tyYOQ3Xl1a+3EcFNZynGHCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/visitor-keys": "8.70.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.1.tgz", + "integrity": "sha512-jumze1fPI+sDOaM2TWGQdn39PDxTr7TZGeuyLkAbNyx2vtMT3uRnVKChN0hfht5V2TugphJzF6bYXvBcE09qqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.70.1.tgz", + "integrity": "sha512-7zKTnyvaVWqzLZHPFQtX1hVHqgkMC+WebPWakNCSyrQVbIP1AM0L0TlBZtACldIRb6PptI8Odk+jyZ5kP3B1VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/typescript-estree": "8.70.1", + "@typescript-eslint/utils": "8.70.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.70.1.tgz", + "integrity": "sha512-Dm1ypdhhrGCTyyehxElhgJ6kgk8MVCv5qXdoOVqPr1uqk42jX8KjrZqhROvdShczA8qrDoYiOWn1ykWlx2k81Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.1.tgz", + "integrity": "sha512-TU8PwyGN0PQJUcE96mw8eCQ44SmxGdQlJmlWakHaHQ15eIuuvye5yNtmh/i6oS88jzXVQB71xdNkbkB/fMwL0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.70.1", + "@typescript-eslint/tsconfig-utils": "8.70.1", + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/visitor-keys": "8.70.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.70.1.tgz", + "integrity": "sha512-Esgul8MsnKnRLdYU2Eb2cRV9bS5HJYtKj1ByJnOzzG2M58DGdSUQ1jUuILxipqcpB2h9WLrbD5GijIWUjX/Tqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.70.1", + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/typescript-estree": "8.70.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.1.tgz", + "integrity": "sha512-Vwj9lUIW5Xq3wQ9w6gv3R86g1hMK8f2zNOdGTAgeXUMMXFK78G9ruCjjqutHMNJc0+CH7LYRnHeUB9IT8wFmcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/bundle-require": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", + "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "load-tsconfig": "^0.2.3" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "peerDependencies": { + "esbuild": ">=0.18" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fix-dts-default-cjs-exports": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", + "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.17", + "mlly": "^1.7.4", + "rollup": "^4.34.8" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/load-tsconfig": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", + "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.63.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.5.tgz", + "integrity": "sha512-KRWwmNLlPw5M7HcdYfm15oBv9n9LPtjzpzCIxS/phwqvPyxHSoKX6Y2YU3pxSPfy0CLquVgsx/j/hBi6OvH1Nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.5", + "@rollup/rollup-android-arm64": "4.63.5", + "@rollup/rollup-darwin-arm64": "4.63.5", + "@rollup/rollup-darwin-x64": "4.63.5", + "@rollup/rollup-freebsd-arm64": "4.63.5", + "@rollup/rollup-freebsd-x64": "4.63.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.5", + "@rollup/rollup-linux-arm-musleabihf": "4.63.5", + "@rollup/rollup-linux-arm64-gnu": "4.63.5", + "@rollup/rollup-linux-arm64-musl": "4.63.5", + "@rollup/rollup-linux-loong64-gnu": "4.63.5", + "@rollup/rollup-linux-loong64-musl": "4.63.5", + "@rollup/rollup-linux-ppc64-gnu": "4.63.5", + "@rollup/rollup-linux-ppc64-musl": "4.63.5", + "@rollup/rollup-linux-riscv64-gnu": "4.63.5", + "@rollup/rollup-linux-riscv64-musl": "4.63.5", + "@rollup/rollup-linux-s390x-gnu": "4.63.5", + "@rollup/rollup-linux-x64-gnu": "4.63.5", + "@rollup/rollup-linux-x64-musl": "4.63.5", + "@rollup/rollup-openbsd-x64": "4.63.5", + "@rollup/rollup-openharmony-arm64": "4.63.5", + "@rollup/rollup-win32-arm64-msvc": "4.63.5", + "@rollup/rollup-win32-ia32-msvc": "4.63.5", + "@rollup/rollup-win32-x64-gnu": "4.63.5", + "@rollup/rollup-win32-x64-msvc": "4.63.5", + "fsevents": "~2.3.2" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz", + "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tsup": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", + "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-require": "^5.1.0", + "cac": "^6.7.14", + "chokidar": "^4.0.3", + "consola": "^3.4.0", + "debug": "^4.4.0", + "esbuild": "^0.27.0", + "fix-dts-default-cjs-exports": "^1.0.0", + "joycon": "^3.1.1", + "picocolors": "^1.1.1", + "postcss-load-config": "^6.0.1", + "resolve-from": "^5.0.0", + "rollup": "^4.34.8", + "source-map": "^0.7.6", + "sucrase": "^3.35.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.11", + "tree-kill": "^1.2.2" + }, + "bin": { + "tsup": "dist/cli-default.js", + "tsup-node": "dist/cli-node.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@microsoft/api-extractor": "^7.36.0", + "@swc/core": "^1", + "postcss": "^8.4.12", + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "postcss": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/tsup/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.70.1.tgz", + "integrity": "sha512-AcWG7KDjZ2THNXsgwttMaGmzVi0VFRlFYfqFHYQRbDpF3owuYbuiL8c7UUrd2k8s3PoSfIQrWfrGXfcElrWLYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.70.1", + "@typescript-eslint/parser": "8.70.1", + "@typescript-eslint/typescript-estree": "8.70.1", + "@typescript-eslint/utils": "8.70.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/outreach-contracts": { + "name": "@splitin/outreach-contracts", + "version": "0.0.0", + "license": "MIT", + "engines": { + "node": ">=22.13" + } + } + } +} diff --git a/outreach-engine/package.json b/outreach-engine/package.json new file mode 100644 index 0000000..fad46d4 --- /dev/null +++ b/outreach-engine/package.json @@ -0,0 +1,36 @@ +{ + "name": "outreach-engine", + "private": true, + "version": "0.0.0", + "description": "MIT-licensed, provider-neutral outreach orchestration engine workspace.", + "license": "MIT", + "author": "SplitInTech", + "type": "module", + "engines": { + "node": ">=22.13" + }, + "packageManager": "npm@10.9.2", + "workspaces": [ + "packages/*", + "apps/*" + ], + "scripts": { + "build": "npm run build --workspaces --if-present", + "typecheck": "npm run typecheck --workspaces --if-present", + "test": "vitest run", + "lint": "eslint .", + "boundaries": "node scripts/check-package-boundaries.mjs", + "secrets": "node scripts/scan-secrets.mjs", + "loc": "node scripts/check-max-lines.mjs", + "check": "npm run lint && npm run typecheck && npm test && npm run boundaries && npm run secrets && npm run loc" + }, + "devDependencies": { + "@eslint/js": "^9.39.5", + "@types/node": "^22.20.4", + "eslint": "^9.39.5", + "tsup": "^8.5.1", + "typescript": "^5.9.3", + "typescript-eslint": "^8.70.1", + "vitest": "^3.2.7" + } +} diff --git a/outreach-engine/packages/outreach-contracts/package.json b/outreach-engine/packages/outreach-contracts/package.json new file mode 100644 index 0000000..45511ac --- /dev/null +++ b/outreach-engine/packages/outreach-contracts/package.json @@ -0,0 +1,32 @@ +{ + "name": "@splitin/outreach-contracts", + "version": "0.0.0", + "description": "Provider-neutral outreach contracts: types, schemas, action state table, error taxonomy.", + "license": "MIT", + "author": "SplitInTech", + "homepage": "https://github.com/splitintech/open-internal-tools/tree/main/outreach-engine#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/splitintech/open-internal-tools.git", + "directory": "outreach-engine/packages/outreach-contracts" + }, + "type": "module", + "sideEffects": false, + "engines": { "node": ">=22.13" }, + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "files": ["dist", "README.md", "package.json"], + "publishConfig": { "access": "public" }, + "scripts": { + "build": "tsup", + "typecheck": "tsc --noEmit -p tsconfig.json" + } +} diff --git a/outreach-engine/packages/outreach-contracts/src/index.test.ts b/outreach-engine/packages/outreach-contracts/src/index.test.ts new file mode 100644 index 0000000..2c2b6c7 --- /dev/null +++ b/outreach-engine/packages/outreach-contracts/src/index.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; +import { OUTREACH_API_VERSION, isSupportedApiVersion } from './index'; + +describe('isSupportedApiVersion', () => { + it('accepts the current API version', () => { + expect(isSupportedApiVersion(OUTREACH_API_VERSION)).toBe(true); + }); + + it('rejects other versions and non-strings', () => { + expect(isSupportedApiVersion('outreach.splitin.net/v1')).toBe(false); + expect(isSupportedApiVersion(undefined)).toBe(false); + expect(isSupportedApiVersion(1)).toBe(false); + }); +}); diff --git a/outreach-engine/packages/outreach-contracts/src/index.ts b/outreach-engine/packages/outreach-contracts/src/index.ts new file mode 100644 index 0000000..7b3c416 --- /dev/null +++ b/outreach-engine/packages/outreach-contracts/src/index.ts @@ -0,0 +1,12 @@ +/** + * Playbook and API version this contracts package describes. + * M1 (BUILD_PLAN.md §14) adds the provider ports, capability model, error taxonomy + * and the action transition table to this package. + */ +export const OUTREACH_API_VERSION = 'outreach.splitin.net/v1alpha1' as const; + +export type OutreachApiVersion = typeof OUTREACH_API_VERSION; + +export function isSupportedApiVersion(value: unknown): value is OutreachApiVersion { + return value === OUTREACH_API_VERSION; +} diff --git a/outreach-engine/packages/outreach-contracts/tsconfig.json b/outreach-engine/packages/outreach-contracts/tsconfig.json new file mode 100644 index 0000000..6fcd57f --- /dev/null +++ b/outreach-engine/packages/outreach-contracts/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "dist" }, + "include": ["src"] +} diff --git a/outreach-engine/packages/outreach-contracts/tsup.config.ts b/outreach-engine/packages/outreach-contracts/tsup.config.ts new file mode 100644 index 0000000..1343a99 --- /dev/null +++ b/outreach-engine/packages/outreach-contracts/tsup.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + dts: true, + sourcemap: true, + clean: true, + target: 'node22', +}); diff --git a/outreach-engine/scripts/check-max-lines.mjs b/outreach-engine/scripts/check-max-lines.mjs new file mode 100644 index 0000000..053365a --- /dev/null +++ b/outreach-engine/scripts/check-max-lines.mjs @@ -0,0 +1,54 @@ +#!/usr/bin/env node +// BUILD_PLAN.md §3: at most 400 significant lines per TypeScript file +// (Papr Work's 500-line rule with headroom). Blank and comment-only lines do not count. +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const MAX = 400; +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const skipDirs = new Set(['node_modules', 'dist', 'coverage']); + +function walk(dir, acc = []) { + if (!existsSync(dir)) return acc; + for (const name of readdirSync(dir)) { + if (skipDirs.has(name)) continue; + const full = join(dir, name); + if (statSync(full).isDirectory()) walk(full, acc); + else if (/\.(ts|tsx)$/.test(name) && !name.endsWith('.d.ts')) acc.push(full); + } + return acc; +} + +function significantLines(source) { + let count = 0; + let inBlock = false; + for (const raw of source.split('\n')) { + const line = raw.trim(); + if (inBlock) { + if (line.includes('*/')) inBlock = false; + continue; + } + if (line === '' || line.startsWith('//')) continue; + if (line.startsWith('/*')) { + if (!line.includes('*/')) inBlock = true; + continue; + } + count += 1; + } + return count; +} + +const offenders = []; +for (const group of ['packages', 'apps']) { + for (const file of walk(join(root, group))) { + const lines = significantLines(readFileSync(file, 'utf8')); + if (lines > MAX) offenders.push(`${relative(root, file)}: ${lines} lines (max ${MAX})`); + } +} + +if (offenders.length) { + process.stderr.write(`Files over the line limit:\n${offenders.join('\n')}\n`); + process.exit(1); +} +process.stdout.write(`Line limit ok (max ${MAX}).\n`); diff --git a/outreach-engine/scripts/check-package-boundaries.mjs b/outreach-engine/scripts/check-package-boundaries.mjs new file mode 100644 index 0000000..357b3d8 --- /dev/null +++ b/outreach-engine/scripts/check-package-boundaries.mjs @@ -0,0 +1,118 @@ +#!/usr/bin/env node +// Enforces the dependency direction in BUILD_PLAN.md §3: +// contracts ← everything; core, import and adapters depend on contracts only; +// only the surfaces (server, mcp, cli, apps) may compose packages freely. +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { dirname, join, relative, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const INTERNAL = /^@splitin\/outreach-[a-z0-9-]+$/; +const ANY = '*'; + +// Internal packages each package may depend on. A package missing from this table fails the check, +// so every new package must declare its place in the graph. +const internalRules = [ + [/^@splitin\/outreach-contracts$/, []], + [/^@splitin\/outreach-fakes$/, ['@splitin/outreach-contracts']], + [/^@splitin\/outreach-store-sqlite$/, ['@splitin/outreach-contracts']], + [/^@splitin\/outreach-core$/, ['@splitin/outreach-contracts']], + [/^@splitin\/outreach-import$/, ['@splitin/outreach-contracts']], + [/^@splitin\/outreach-notify-[a-z0-9-]+$/, ['@splitin/outreach-contracts']], + [/^@splitin\/outreach-provider-[a-z0-9-]+$/, ['@splitin/outreach-contracts']], + [/^@splitin\/outreach-(server|mcp|cli)$/, ANY], + [/^@splitin\/outreach-app-[a-z0-9-]+$/, ANY], +]; + +// External modules that only specific packages may import. +const externalOwners = [ + [/^(node:sqlite|better-sqlite3)$/, /^@splitin\/outreach-store-sqlite$/], + [/^(hono|@hono\/.+)$/, /^@splitin\/outreach-(server|mcp)$/], + [/^@modelcontextprotocol\/.+$/, /^@splitin\/outreach-mcp$/], + [/^@slack\/.+$/, /^@splitin\/outreach-notify-slack$/], + [/^(csv-parse|exceljs|parse5)(\/.*)?$/, /^@splitin\/outreach-import$/], +]; + +const IMPORT_RE = /(?:import|export)\s[^'"]*?from\s*['"]([^'"]+)['"]|import\(\s*['"]([^'"]+)['"]\s*\)|require\(\s*['"]([^'"]+)['"]\s*\)/g; + +function packageDirs() { + const dirs = []; + for (const group of ['packages', 'apps']) { + const base = join(root, group); + if (!existsSync(base)) continue; + for (const name of readdirSync(base)) { + const dir = join(base, name); + if (existsSync(join(dir, 'package.json'))) dirs.push(dir); + } + } + return dirs; +} + +function sourceFiles(dir) { + if (!existsSync(dir)) return []; + const out = []; + for (const name of readdirSync(dir)) { + if (name === 'node_modules' || name === 'dist') continue; + const path = join(dir, name); + if (statSync(path).isDirectory()) out.push(...sourceFiles(path)); + else if (/\.(ts|tsx|mts|cts|js|mjs)$/.test(name)) out.push(path); + } + return out; +} + +function allowedInternal(name) { + const rule = internalRules.find(([pattern]) => pattern.test(name)); + return rule ? rule[1] : null; +} + +const violations = []; +const dirs = packageDirs(); + +for (const dir of dirs) { + const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')); + const name = pkg.name; + const rel = relative(root, dir); + const allowed = allowedInternal(name); + if (allowed === null) { + violations.push(`${rel}: package "${name}" has no boundary rule; add it to scripts/check-package-boundaries.mjs`); + continue; + } + const permits = (dep) => allowed === ANY || allowed.includes(dep); + + const declared = { ...pkg.dependencies, ...pkg.peerDependencies, ...pkg.optionalDependencies }; + for (const dep of Object.keys(declared)) { + if (INTERNAL.test(dep) && !permits(dep)) violations.push(`${rel}/package.json: ${name} may not depend on ${dep}`); + } + + for (const file of sourceFiles(join(dir, 'src'))) { + const fileRel = relative(root, file); + const source = readFileSync(file, 'utf8'); + for (const match of source.matchAll(IMPORT_RE)) { + const spec = match[1] ?? match[2] ?? match[3]; + if (!spec) continue; + if (spec.startsWith('.')) { + const target = resolve(dirname(file), spec); + if (target !== dir && !target.startsWith(dir + sep)) { + violations.push(`${fileRel}: relative import "${spec}" escapes its package; import the package by name`); + } + continue; + } + const bare = spec.startsWith('@') ? spec.split('/').slice(0, 2).join('/') : spec; + if (INTERNAL.test(bare)) { + if (bare !== name && !permits(bare)) violations.push(`${fileRel}: ${name} may not import ${bare}`); + continue; + } + for (const [modulePattern, ownerPattern] of externalOwners) { + if (modulePattern.test(spec) && !ownerPattern.test(name)) { + violations.push(`${fileRel}: "${spec}" may only be imported by packages matching ${ownerPattern}`); + } + } + } + } +} + +if (violations.length) { + process.stderr.write(`Package boundary violations:\n${violations.join('\n')}\n`); + process.exit(1); +} +process.stdout.write(`Package boundaries ok (${dirs.length} package${dirs.length === 1 ? '' : 's'}).\n`); diff --git a/outreach-engine/scripts/scan-secrets.mjs b/outreach-engine/scripts/scan-secrets.mjs new file mode 100644 index 0000000..e942dd4 --- /dev/null +++ b/outreach-engine/scripts/scan-secrets.mjs @@ -0,0 +1,58 @@ +#!/usr/bin/env node +// Fails if credential-shaped strings appear in source, examples or docs. +// Fixtures must use obviously fake values (see fixtureHint). +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const scanRoots = ['packages', 'apps', 'examples', 'scripts', 'docs'].map((dir) => join(root, dir)); +const skipDirs = new Set(['node_modules', 'dist', 'coverage', '.git']); +const self = fileURLToPath(import.meta.url); + +const patterns = [ + { name: 'slack_token', re: /\bxox[abposr]-[A-Za-z0-9-]{10,}/g }, + { name: 'slack_webhook', re: /hooks\.slack\.com\/services\/T[A-Z0-9]+\/B[A-Z0-9]+\/[A-Za-z0-9]{16,}/g }, + { name: 'zoho_token', re: /\b1000\.[a-f0-9]{32}\.[a-f0-9]{32}\b/g }, + { name: 'github_token', re: /\b(?:ghp|gho|ghs|ghu|github_pat)_[A-Za-z0-9_]{20,}/g }, + { name: 'anthropic_key', re: /\bsk-ant-[A-Za-z0-9_-]{20,}/g }, + { name: 'openai_key', re: /\bsk-(?:proj-)?[A-Za-z0-9]{32,}/g }, + { name: 'aws_access_key', re: /\bAKIA[0-9A-Z]{16}\b/g }, + { name: 'google_api_key', re: /\bAIza[0-9A-Za-z_-]{35}\b/g }, + { name: 'private_key', re: new RegExp(`BEGIN (?:RSA |EC |OPENSSH )?${['PRIVATE', 'KEY'].join(' ')}`, 'g') }, +]; + +const fixtureHint = /example|fake|dummy|placeholder|fixture|redacted|xxxx/i; + +function walk(dir, acc = []) { + if (!existsSync(dir)) return acc; + for (const name of readdirSync(dir)) { + if (skipDirs.has(name)) continue; + const full = join(dir, name); + const stat = statSync(full); + if (stat.isDirectory()) walk(full, acc); + else if (stat.size <= 2_000_000) acc.push(full); + } + return acc; +} + +const failures = []; +for (const scanRoot of scanRoots) { + for (const file of walk(scanRoot)) { + if (file === self) continue; + const source = readFileSync(file, 'utf8'); + if (source.includes('\u0000')) continue; + for (const { name, re } of patterns) { + for (const match of source.match(re) ?? []) { + if (fixtureHint.test(match)) continue; + failures.push(`${relative(root, file)}: ${name} ${match.slice(0, 12)}…`); + } + } + } +} + +if (failures.length) { + process.stderr.write(`Possible secrets found:\n${failures.join('\n')}\n`); + process.exit(1); +} +process.stdout.write('Secret scan ok.\n'); diff --git a/outreach-engine/tsconfig.base.json b/outreach-engine/tsconfig.base.json new file mode 100644 index 0000000..e15e2b8 --- /dev/null +++ b/outreach-engine/tsconfig.base.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "resolveJsonModule": true, + "types": ["node"] + } +} diff --git a/outreach-engine/vitest.config.ts b/outreach-engine/vitest.config.ts new file mode 100644 index 0000000..b934145 --- /dev/null +++ b/outreach-engine/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['packages/*/src/**/*.test.ts', 'apps/*/src/**/*.test.ts'], + environment: 'node', + }, +}); From cd93141a94ea18dac637785a38b8aa309558be97 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 01:23:19 +0000 Subject: [PATCH 03/20] outreach-engine M1: contracts, fake providers and conformance kit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements milestone M1 of outreach-engine/BUILD_PLAN.md (§5, §14). @splitin/outreach-contracts (no runtime dependencies) - actions.ts: the action state machine from §6.1 as a single closed transition table (ACTION_TRANSITIONS), terminal and cancellable state sets, and assertTransition() throwing a typed InvalidTransitionError. The table encodes ADR 0002: `executing` is reachable only from `claimed`, and neither `executing` nor `uncertain` can be cancelled or rescheduled directly; uncertain actions leave only through reconciliation or human review. - errors.ts: the provider error taxonomy (11 classes) and one ErrorDisposition per class: retry never / backoff / after re-auth, terminal state (failed or review), and side effects (suppress recipient, bounce enrollment, mark account unhealthy or needing re-auth, engage the account kill switch, pause the campaign). The core will act on the class alone, so no provider logic lives in the engine. - capabilities.ts: ProviderPurpose (manual_correspondence, transactional, automated_outreach, marketing, bulk), CapabilitySnapshot, and purposePermitted(), which requires BOTH the adapter's terms and the operator-attested account purposes to allow a campaign's purpose. - providers.ts: capability-sized provider ports instead of the original plan's monolithic MailConnector: AccountPort, EmailSender (send + reconcile), MailboxReader (cursor-based), WebhookVerifier (raw body, verified before parsing), NotificationPublisher, ManualTaskProvider, and ProviderAdapter bundling them. SendResult distinguishes accepted, rejected (with ErrorClass and retryAfterMs) and unknown; ReconcileResult is found, absent (only when the provider can affirm it) or still_unknown. InboundMailEvent carries provider/RFC ids, references, DSN details and a bounded snippet for deterministic classification. - sql.ts: a synchronous SQLite-dialect SqlDatabase port. Transactions are synchronous by design, so a transaction can never span an await and provider calls always happen outside them (§6.6). The store package implements it; core and importer write SQL against it. The plan's package layout is updated to record this split. - crypto.ts: canonical JSON, sha256/HMAC helpers, constant-time hex comparison, and a dependency-free monotonic ULID. @splitin/outreach-fakes - FakeEmailProvider: deterministic in-memory email provider with a scriptable outcome queue (accept, reject with any ErrorClass and retryAfterMs, unknown_after_accept, unknown_before_accept, throw_after_accept). It records a delivery only when the effect really happened, which later lets the engine's tests assert "at most one delivery per action" directly. It also checks the secret resolved through ctx.secrets, supports optional provider-side idempotency, threads replies by In-Reply-To, simulates replies, DSN bounces and complaints, pages a mailbox by cursor, and signs and verifies HMAC webhooks with a 5-minute timestamp window, 1 MB cap and tamper detection. The reconcile mode can be switched to still_unknown to exercise the review path. - FakeNotifier: stands in for Slack with the same scriptable outcomes. - Conformance kit (EMAIL_SENDER_CONFORMANCE, runEmailSenderConformance): framework-agnostic cases every email adapter must pass: stable ids, no credential leakage in results, reconcile finds accepted sends with the same provider id, reconcile never "finds" an unsent message, timeouts after/before accept reconcile correctly, rate limits carry retryAfterMs, rejections use the taxonomy. Cases that need a forced outcome are skipped for real sandbox accounts. staticSecrets() helper. Tooling - vitest resolves @splitin/outreach-* to package sources, so tests never depend on a prior build. - The boundary check lets *.test.ts files import workspace packages declared in devDependencies, and reserves @splitin/outreach-e2e for the end-to-end suite. Tests: 31 passing (transition-table closure and invariants, taxonomy coverage, crypto vectors, ULID ordering/uniqueness, full conformance suite against the fake, delivery bookkeeping, webhook verification, mailbox paging, notifier). lint, typecheck, boundaries, secret scan and line limit all pass. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MbDW8kfMcxUuZ3WBtaDKBv --- outreach-engine/BUILD_PLAN.md | 6 +- outreach-engine/package-lock.json | 15 + .../outreach-contracts/src/actions.test.ts | 51 +++ .../outreach-contracts/src/actions.ts | 78 +++++ .../src/capabilities.test.ts | 10 + .../outreach-contracts/src/capabilities.ts | 41 +++ .../outreach-contracts/src/crypto.test.ts | 36 +++ .../packages/outreach-contracts/src/crypto.ts | 71 +++++ .../outreach-contracts/src/errors.test.ts | 26 ++ .../packages/outreach-contracts/src/errors.ts | 64 ++++ .../packages/outreach-contracts/src/index.ts | 19 +- .../outreach-contracts/src/providers.ts | 185 +++++++++++ .../packages/outreach-contracts/src/sql.ts | 26 ++ .../src/{index.test.ts => version.test.ts} | 2 +- .../outreach-contracts/src/version.ts | 11 + .../packages/outreach-contracts/tsconfig.json | 2 +- .../packages/outreach-fakes/package.json | 43 +++ .../outreach-fakes/src/conformance.ts | 183 +++++++++++ .../packages/outreach-fakes/src/fake-email.ts | 293 ++++++++++++++++++ .../outreach-fakes/src/fake-notify.ts | 72 +++++ .../packages/outreach-fakes/src/fakes.test.ts | 152 +++++++++ .../packages/outreach-fakes/src/index.ts | 3 + .../packages/outreach-fakes/tsconfig.json | 5 + .../packages/outreach-fakes/tsup.config.ts | 10 + .../scripts/check-package-boundaries.mjs | 6 +- outreach-engine/vitest.config.ts | 8 + 26 files changed, 1400 insertions(+), 18 deletions(-) create mode 100644 outreach-engine/packages/outreach-contracts/src/actions.test.ts create mode 100644 outreach-engine/packages/outreach-contracts/src/actions.ts create mode 100644 outreach-engine/packages/outreach-contracts/src/capabilities.test.ts create mode 100644 outreach-engine/packages/outreach-contracts/src/capabilities.ts create mode 100644 outreach-engine/packages/outreach-contracts/src/crypto.test.ts create mode 100644 outreach-engine/packages/outreach-contracts/src/crypto.ts create mode 100644 outreach-engine/packages/outreach-contracts/src/errors.test.ts create mode 100644 outreach-engine/packages/outreach-contracts/src/errors.ts create mode 100644 outreach-engine/packages/outreach-contracts/src/providers.ts create mode 100644 outreach-engine/packages/outreach-contracts/src/sql.ts rename outreach-engine/packages/outreach-contracts/src/{index.test.ts => version.test.ts} (86%) create mode 100644 outreach-engine/packages/outreach-contracts/src/version.ts create mode 100644 outreach-engine/packages/outreach-fakes/package.json create mode 100644 outreach-engine/packages/outreach-fakes/src/conformance.ts create mode 100644 outreach-engine/packages/outreach-fakes/src/fake-email.ts create mode 100644 outreach-engine/packages/outreach-fakes/src/fake-notify.ts create mode 100644 outreach-engine/packages/outreach-fakes/src/fakes.test.ts create mode 100644 outreach-engine/packages/outreach-fakes/src/index.ts create mode 100644 outreach-engine/packages/outreach-fakes/tsconfig.json create mode 100644 outreach-engine/packages/outreach-fakes/tsup.config.ts diff --git a/outreach-engine/BUILD_PLAN.md b/outreach-engine/BUILD_PLAN.md index c163bc3..3a74ab1 100644 --- a/outreach-engine/BUILD_PLAN.md +++ b/outreach-engine/BUILD_PLAN.md @@ -85,10 +85,10 @@ outreach-engine/ vitest.config.ts eslint.config.js packages/ - outreach-contracts/ types, zod schemas, state tables, error taxonomy, capability model + outreach-contracts/ types, state tables, error taxonomy, capability model, SqlDatabase port, audit chain outreach-fakes/ fake email/notify/manual providers + provider conformance suite - outreach-store-sqlite/ migrations, repositories, driver port (node:sqlite, better-sqlite3) - outreach-core/ application services, policy, calendar, workers + outreach-store-sqlite/ migrations + drivers implementing the SqlDatabase port (node:sqlite, better-sqlite3) + outreach-core/ repositories (SQL against the port), services, policy, calendar, workers outreach-import/ HTML/CSV/XLSX/JSON staging importer outreach-notify-slack/ Slack incoming-webhook / chat.postMessage notifier outreach-provider-email-*/ reference email adapter (after decision D1) diff --git a/outreach-engine/package-lock.json b/outreach-engine/package-lock.json index 44cab6a..d3aaee8 100644 --- a/outreach-engine/package-lock.json +++ b/outreach-engine/package-lock.json @@ -1087,6 +1087,10 @@ "resolved": "packages/outreach-contracts", "link": true }, + "node_modules/@splitin/outreach-fakes": { + "resolved": "packages/outreach-fakes", + "link": true + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -3446,6 +3450,17 @@ "engines": { "node": ">=22.13" } + }, + "packages/outreach-fakes": { + "name": "@splitin/outreach-fakes", + "version": "0.0.0", + "license": "MIT", + "dependencies": { + "@splitin/outreach-contracts": "0.0.0" + }, + "engines": { + "node": ">=22.13" + } } } } diff --git a/outreach-engine/packages/outreach-contracts/src/actions.test.ts b/outreach-engine/packages/outreach-contracts/src/actions.test.ts new file mode 100644 index 0000000..5f7b674 --- /dev/null +++ b/outreach-engine/packages/outreach-contracts/src/actions.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { + ACTION_STATES, + ACTION_TRANSITIONS, + CANCELLABLE_ACTION_STATES, + InvalidTransitionError, + TERMINAL_ACTION_STATES, + assertTransition, + canTransition, +} from './actions'; + +describe('action transition table', () => { + it('is closed: every target is a known state and every state has an entry', () => { + expect(Object.keys(ACTION_TRANSITIONS).sort()).toEqual([...ACTION_STATES].sort()); + for (const targets of Object.values(ACTION_TRANSITIONS)) { + for (const target of targets) expect(ACTION_STATES).toContain(target); + } + }); + + it('has no exits from terminal states', () => { + for (const state of TERMINAL_ACTION_STATES) expect(ACTION_TRANSITIONS[state]).toEqual([]); + }); + + it('lets every cancellable state be cancelled', () => { + for (const state of CANCELLABLE_ACTION_STATES) expect(canTransition(state, 'cancelled')).toBe(true); + }); + + it('never lets executing or uncertain actions be cancelled or rescheduled directly', () => { + for (const state of ['executing', 'uncertain'] as const) { + expect(canTransition(state, 'cancelled')).toBe(false); + expect(canTransition(state, 'scheduled')).toBe(false); + expect(canTransition(state, 'claimed')).toBe(false); + } + }); + + it('only reaches executing from claimed', () => { + const sources = ACTION_STATES.filter((state) => canTransition(state, 'executing')); + expect(sources).toEqual(['claimed']); + }); + + it('only reaches scheduled from uncertain via reconciliation or review', () => { + const intoScheduled = ACTION_STATES.filter((state) => canTransition(state, 'scheduled')); + expect(intoScheduled).not.toContain('uncertain'); + expect(intoScheduled).not.toContain('executing'); + }); + + it('throws a typed error on invalid transitions', () => { + expect(() => assertTransition('succeeded', 'scheduled')).toThrow(InvalidTransitionError); + expect(() => assertTransition('scheduled', 'claimed')).not.toThrow(); + }); +}); diff --git a/outreach-engine/packages/outreach-contracts/src/actions.ts b/outreach-engine/packages/outreach-contracts/src/actions.ts new file mode 100644 index 0000000..c6a9101 --- /dev/null +++ b/outreach-engine/packages/outreach-contracts/src/actions.ts @@ -0,0 +1,78 @@ +/** + * The action state machine (BUILD_PLAN.md §6.1, ADR 0002). + * + * `claimed` means a worker holds the lease but has not committed to acting: a crash is harmless. + * `executing` is committed together with a pending attempt BEFORE the provider call, so a crash + * after that point can only lead to `uncertain`, never to a blind resend. + */ +export const ACTION_STATES = [ + 'planned', + 'awaiting_approval', + 'scheduled', + 'claimed', + 'executing', + 'succeeded', + 'retryable', + 'uncertain', + 'reconciling', + 'failed', + 'cancelled', + 'review', +] as const; + +export type ActionState = (typeof ACTION_STATES)[number]; + +export const ACTION_KINDS = ['email.send', 'email.reply', 'notify.publish', 'manual.task'] as const; + +export type ActionKind = (typeof ACTION_KINDS)[number]; + +export const ACTION_TRANSITIONS: Readonly> = { + planned: ['awaiting_approval', 'scheduled', 'cancelled'], + awaiting_approval: ['scheduled', 'cancelled'], + scheduled: ['claimed', 'awaiting_approval', 'cancelled'], + // Preflight either proceeds, defers, blocks, or parks the action for a human. + claimed: ['executing', 'scheduled', 'awaiting_approval', 'cancelled', 'review'], + executing: ['succeeded', 'retryable', 'failed', 'uncertain', 'review'], + retryable: ['scheduled', 'failed', 'cancelled'], + // An uncertain action is never cancelled or retried directly: only reconciliation or a human decides. + uncertain: ['reconciling', 'review'], + reconciling: ['succeeded', 'scheduled', 'failed', 'uncertain', 'review'], + succeeded: [], + failed: [], + cancelled: [], + // A human resolves review items: sent, not sent (reschedule or drop), or failed. + review: ['succeeded', 'scheduled', 'failed', 'cancelled'], +}; + +export const TERMINAL_ACTION_STATES: readonly ActionState[] = ['succeeded', 'failed', 'cancelled']; + +/** States a stop (reply, opt-out, bounce, pause-to-stop) may cancel. `executing` and `uncertain` cannot be recalled. */ +export const CANCELLABLE_ACTION_STATES: readonly ActionState[] = [ + 'planned', + 'awaiting_approval', + 'scheduled', + 'claimed', + 'retryable', +]; + +export class InvalidTransitionError extends Error { + constructor( + readonly from: ActionState, + readonly to: ActionState, + ) { + super(`Invalid action transition ${from} -> ${to}`); + this.name = 'InvalidTransitionError'; + } +} + +export function isActionState(value: unknown): value is ActionState { + return typeof value === 'string' && (ACTION_STATES as readonly string[]).includes(value); +} + +export function canTransition(from: ActionState, to: ActionState): boolean { + return ACTION_TRANSITIONS[from].includes(to); +} + +export function assertTransition(from: ActionState, to: ActionState): void { + if (!canTransition(from, to)) throw new InvalidTransitionError(from, to); +} diff --git a/outreach-engine/packages/outreach-contracts/src/capabilities.test.ts b/outreach-engine/packages/outreach-contracts/src/capabilities.test.ts new file mode 100644 index 0000000..56c928a --- /dev/null +++ b/outreach-engine/packages/outreach-contracts/src/capabilities.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest'; +import { purposePermitted } from './capabilities'; + +describe('purposePermitted', () => { + it('requires both the adapter and the account to permit the purpose', () => { + expect(purposePermitted('automated_outreach', ['automated_outreach'], ['automated_outreach'])).toBe(true); + expect(purposePermitted('automated_outreach', ['manual_correspondence'], ['automated_outreach'])).toBe(false); + expect(purposePermitted('automated_outreach', ['automated_outreach'], ['manual_correspondence'])).toBe(false); + }); +}); diff --git a/outreach-engine/packages/outreach-contracts/src/capabilities.ts b/outreach-engine/packages/outreach-contracts/src/capabilities.ts new file mode 100644 index 0000000..57af101 --- /dev/null +++ b/outreach-engine/packages/outreach-contracts/src/capabilities.ts @@ -0,0 +1,41 @@ +/** + * Provider purposes and capabilities (BUILD_PLAN.md §5.1). + * A campaign may run only if its purpose is permitted by BOTH the adapter (the provider's terms) + * and the account (what the operator attests their contract allows). + */ +export const PROVIDER_PURPOSES = [ + 'manual_correspondence', + 'transactional', + 'automated_outreach', + 'marketing', + 'bulk', +] as const; + +export type ProviderPurpose = (typeof PROVIDER_PURPOSES)[number]; + +export interface CapabilitySnapshot { + readonly provider: string; + readonly send: boolean; + readonly replyInThread: boolean; + /** Whether we can set Message-ID and List-Unsubscribe headers. */ + readonly customHeaders: boolean; + /** Whether the provider deduplicates on our idempotency key. */ + readonly externalIdempotency: boolean; + readonly inboundWebhook: boolean; + readonly mailboxPolling: boolean; + readonly reconcileBySentSearch: boolean; + readonly maxRecipientsPerMessage: number; + readonly discoveredAt: number; +} + +export function isProviderPurpose(value: unknown): value is ProviderPurpose { + return typeof value === 'string' && (PROVIDER_PURPOSES as readonly string[]).includes(value); +} + +export function purposePermitted( + purpose: ProviderPurpose, + adapterPurposes: readonly ProviderPurpose[], + accountPurposes: readonly ProviderPurpose[], +): boolean { + return adapterPurposes.includes(purpose) && accountPurposes.includes(purpose); +} diff --git a/outreach-engine/packages/outreach-contracts/src/crypto.test.ts b/outreach-engine/packages/outreach-contracts/src/crypto.test.ts new file mode 100644 index 0000000..ec0f5f2 --- /dev/null +++ b/outreach-engine/packages/outreach-contracts/src/crypto.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { canonicalize, digestCanonical, hmacSha256Hex, safeEqualHex, sha256Hex, ulid } from './crypto'; + +describe('canonicalize', () => { + it('is independent of key order and drops undefined', () => { + expect(canonicalize({ b: 1, a: { d: [2, { z: 1, y: 2 }], c: undefined } })).toBe( + canonicalize({ a: { d: [2, { y: 2, z: 1 }] }, b: 1 }), + ); + expect(digestCanonical({ a: 1, b: 2 })).toBe(digestCanonical({ b: 2, a: 1 })); + }); +}); + +describe('hashing', () => { + it('produces known sha256 and hmac values', () => { + expect(sha256Hex('abc')).toBe('ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad'); + expect(hmacSha256Hex('key', 'The quick brown fox jumps over the lazy dog')).toBe( + 'f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8', + ); + }); + + it('compares hex digests safely', () => { + expect(safeEqualHex('abcd', 'abcd')).toBe(true); + expect(safeEqualHex('abcd', 'abce')).toBe(false); + expect(safeEqualHex('abcd', 'abc')).toBe(false); + expect(safeEqualHex('zz', 'zz')).toBe(false); + }); +}); + +describe('ulid', () => { + it('is 26 Crockford base32 characters and sorts by creation order', () => { + const ids = Array.from({ length: 500 }, (_, i) => ulid(1_700_000_000_000 + Math.floor(i / 50))); + for (const id of ids) expect(id).toMatch(/^[0-9A-HJKMNP-TV-Z]{26}$/); + expect([...ids].sort()).toEqual(ids); + expect(new Set(ids).size).toBe(ids.length); + }); +}); diff --git a/outreach-engine/packages/outreach-contracts/src/crypto.ts b/outreach-engine/packages/outreach-contracts/src/crypto.ts new file mode 100644 index 0000000..026c1b2 --- /dev/null +++ b/outreach-engine/packages/outreach-contracts/src/crypto.ts @@ -0,0 +1,71 @@ +import { createHash, createHmac, getRandomValues, timingSafeEqual } from 'node:crypto'; + +/** Canonical JSON: recursively sorted keys, `undefined` dropped, no insignificant whitespace. */ +export function canonicalize(value: unknown): string { + return JSON.stringify(sortValue(value)); +} + +function sortValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sortValue); + if (value && typeof value === 'object' && !(value instanceof Uint8Array)) { + const record = value as Record; + const out: Record = {}; + for (const key of Object.keys(record).sort()) { + if (record[key] !== undefined) out[key] = sortValue(record[key]); + } + return out; + } + return value; +} + +export function sha256Hex(payload: string | Uint8Array): string { + return createHash('sha256').update(payload).digest('hex'); +} + +export function digestCanonical(value: unknown): string { + return sha256Hex(canonicalize(value)); +} + +export function hmacSha256Hex(secret: string, payload: string | Uint8Array): string { + return createHmac('sha256', secret).update(payload).digest('hex'); +} + +/** Constant-time comparison of two hex digests. */ +export function safeEqualHex(a: string, b: string): boolean { + if (a.length !== b.length || !/^[0-9a-f]*$/i.test(a) || !/^[0-9a-f]*$/i.test(b)) return false; + return timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex')); +} + +const ULID_ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; +let lastTime = -1; +let lastRandom: number[] = []; + +/** Monotonic ULID: sortable by creation time, unique within a process even in the same millisecond. */ +export function ulid(now: number = Date.now()): string { + if (now === lastTime) { + lastRandom = incrementBase32(lastRandom); + } else { + lastTime = now; + lastRandom = Array.from(getRandomValues(new Uint8Array(16)), (byte) => byte % 32); + } + let time = ''; + let remaining = now; + for (let i = 0; i < 10; i += 1) { + time = ULID_ALPHABET[remaining % 32] + time; + remaining = Math.floor(remaining / 32); + } + return time + lastRandom.map((digit) => ULID_ALPHABET[digit]).join(''); +} + +function incrementBase32(digits: number[]): number[] { + const next = [...digits]; + for (let i = next.length - 1; i >= 0; i -= 1) { + const digit = next[i] ?? 0; + if (digit < 31) { + next[i] = digit + 1; + return next; + } + next[i] = 0; + } + throw new Error('ULID random component overflowed within one millisecond'); +} diff --git a/outreach-engine/packages/outreach-contracts/src/errors.test.ts b/outreach-engine/packages/outreach-contracts/src/errors.test.ts new file mode 100644 index 0000000..dda7708 --- /dev/null +++ b/outreach-engine/packages/outreach-contracts/src/errors.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { ERROR_CLASSES, ERROR_DISPOSITIONS, isErrorClass } from './errors'; + +describe('error taxonomy', () => { + it('has a disposition for every class', () => { + expect(Object.keys(ERROR_DISPOSITIONS).sort()).toEqual([...ERROR_CLASSES].sort()); + }); + + it('never retries classes that mean the recipient or account is bad', () => { + for (const cls of ['invalid_recipient', 'hard_bounce', 'auth_revoked', 'forbidden', 'complaint', 'unsupported'] as const) { + expect(ERROR_DISPOSITIONS[cls].retry).toBe('never'); + } + }); + + it('gives non-retried classes a terminal state and retried classes none', () => { + for (const cls of ERROR_CLASSES) { + const disposition = ERROR_DISPOSITIONS[cls]; + expect(disposition.retry === 'never').toBe(disposition.terminal !== null); + } + }); + + it('recognises classes', () => { + expect(isErrorClass('rate_limited')).toBe(true); + expect(isErrorClass('HTTP 500')).toBe(false); + }); +}); diff --git a/outreach-engine/packages/outreach-contracts/src/errors.ts b/outreach-engine/packages/outreach-contracts/src/errors.ts new file mode 100644 index 0000000..bcb5e29 --- /dev/null +++ b/outreach-engine/packages/outreach-contracts/src/errors.ts @@ -0,0 +1,64 @@ +/** + * Provider error taxonomy (BUILD_PLAN.md §5.3). Adapters map every failure into one class; + * the engine decides behaviour from the class alone, so no provider logic lives in the core. + */ +export const ERROR_CLASSES = [ + 'auth_expired', + 'auth_revoked', + 'forbidden', + 'rate_limited', + 'invalid_recipient', + 'hard_bounce', + 'content_rejected', + 'policy_blocked', + 'complaint', + 'transient', + 'unsupported', +] as const; + +export type ErrorClass = (typeof ERROR_CLASSES)[number]; + +export type ErrorEffect = + | 'suppress_recipient' + | 'bounce_enrollment' + | 'account_unhealthy' + | 'account_reauth' + | 'engage_account_kill' + | 'pause_campaign'; + +export interface ErrorDisposition { + /** `backoff`: retry after jittered delay; `after_reauth`: retry once the account is healthy again. */ + readonly retry: 'never' | 'backoff' | 'after_reauth'; + /** Final action state when not retried. */ + readonly terminal: 'failed' | 'review' | null; + readonly effects: readonly ErrorEffect[]; +} + +const permanentAccount: ErrorDisposition = { + retry: 'never', + terminal: 'failed', + effects: ['account_unhealthy', 'pause_campaign'], +}; +const badRecipient: ErrorDisposition = { + retry: 'never', + terminal: 'failed', + effects: ['suppress_recipient', 'bounce_enrollment'], +}; + +export const ERROR_DISPOSITIONS: Readonly> = { + auth_expired: { retry: 'after_reauth', terminal: null, effects: ['account_reauth'] }, + auth_revoked: permanentAccount, + forbidden: permanentAccount, + rate_limited: { retry: 'backoff', terminal: null, effects: [] }, + invalid_recipient: badRecipient, + hard_bounce: badRecipient, + content_rejected: { retry: 'never', terminal: 'review', effects: [] }, + policy_blocked: { retry: 'never', terminal: 'failed', effects: ['engage_account_kill'] }, + complaint: { retry: 'never', terminal: 'failed', effects: ['engage_account_kill', 'suppress_recipient'] }, + transient: { retry: 'backoff', terminal: null, effects: [] }, + unsupported: { retry: 'never', terminal: 'failed', effects: [] }, +}; + +export function isErrorClass(value: unknown): value is ErrorClass { + return typeof value === 'string' && (ERROR_CLASSES as readonly string[]).includes(value); +} diff --git a/outreach-engine/packages/outreach-contracts/src/index.ts b/outreach-engine/packages/outreach-contracts/src/index.ts index 7b3c416..423a761 100644 --- a/outreach-engine/packages/outreach-contracts/src/index.ts +++ b/outreach-engine/packages/outreach-contracts/src/index.ts @@ -1,12 +1,7 @@ -/** - * Playbook and API version this contracts package describes. - * M1 (BUILD_PLAN.md §14) adds the provider ports, capability model, error taxonomy - * and the action transition table to this package. - */ -export const OUTREACH_API_VERSION = 'outreach.splitin.net/v1alpha1' as const; - -export type OutreachApiVersion = typeof OUTREACH_API_VERSION; - -export function isSupportedApiVersion(value: unknown): value is OutreachApiVersion { - return value === OUTREACH_API_VERSION; -} +export * from './version'; +export * from './actions'; +export * from './errors'; +export * from './capabilities'; +export * from './providers'; +export * from './crypto'; +export * from './sql'; diff --git a/outreach-engine/packages/outreach-contracts/src/providers.ts b/outreach-engine/packages/outreach-contracts/src/providers.ts new file mode 100644 index 0000000..7442fc9 --- /dev/null +++ b/outreach-engine/packages/outreach-contracts/src/providers.ts @@ -0,0 +1,185 @@ +/** + * Provider ports (BUILD_PLAN.md §5.2). Small capabilities instead of one all-purpose connector. + * Adapters implement only what their provider supports; the engine never falls back to another + * adapter or channel when a capability is missing. + */ +import type { CapabilitySnapshot, ProviderPurpose } from './capabilities'; +import type { ErrorClass } from './errors'; + +export interface SenderIdentity { + readonly name: string; + readonly address: string; + readonly organization?: string; + readonly postalAddress?: string; + readonly replyTo?: string; +} + +export interface ProviderAccountRef { + readonly id: string; + readonly provider: string; + readonly externalAccountId: string; + readonly sender: SenderIdentity; +} + +/** Resolves a secret reference ("env:NAME", "keychain:NAME") at call time. Values are never stored. */ +export interface SecretResolver { + get(ref: string): Promise; +} + +export interface ProviderContext { + readonly workspaceId: string; + readonly account: ProviderAccountRef; + readonly secretRef: string; + readonly secrets: SecretResolver; + readonly traceId: string; + readonly signal: AbortSignal; + readonly now: () => number; +} + +export type AccountHealth = 'ok' | 'degraded' | 'unhealthy' | 'reauth_required'; + +export interface AccountPort { + discover(ctx: ProviderContext): Promise; + health(ctx: ProviderContext): Promise<{ status: AccountHealth; detail?: string }>; +} + +export interface EmailAddress { + readonly address: string; + readonly name?: string; +} + +export interface EmailContent { + readonly from: EmailAddress; + readonly to: readonly EmailAddress[]; + readonly replyTo?: EmailAddress; + readonly subject: string; + readonly text: string; + readonly html?: string; + readonly headers: Readonly>; + readonly inReplyTo?: string; + readonly references?: readonly string[]; + readonly providerThreadId?: string; +} + +export interface ApprovedEmail extends EmailContent { + readonly actionId: string; + readonly idempotencyKey: string; + /** Generated by the engine; used for reply correlation and reconciliation. */ + readonly rfcMessageId: string; + readonly contentHash: string; +} + +export interface UncertainEmail { + readonly actionId: string; + readonly idempotencyKey: string; + readonly rfcMessageId: string; + readonly to: readonly string[]; + readonly attemptedAt: number; +} + +export interface ProviderReceipt { + readonly providerMessageId: string; + readonly providerThreadId?: string; + readonly rfcMessageId?: string; + readonly acceptedAt: number; + /** Identifiers only. Never bodies, tokens or credentials. */ + readonly raw?: Readonly>; +} + +export type SendResult = + | { readonly kind: 'accepted'; readonly receipt: ProviderReceipt } + | { + readonly kind: 'rejected'; + readonly errorClass: ErrorClass; + readonly retryAfterMs?: number; + readonly detail: string; + } + /** The request may have reached the provider (timeout, reset after send). Never retried blindly. */ + | { readonly kind: 'unknown'; readonly detail: string }; + +export type ReconcileResult = + | { readonly kind: 'found'; readonly receipt: ProviderReceipt } + /** Only when the provider can affirm the message was not sent. */ + | { readonly kind: 'absent' } + | { readonly kind: 'still_unknown'; readonly detail: string }; + +export interface EmailSender { + send(ctx: ProviderContext, email: ApprovedEmail): Promise; + reconcile(ctx: ProviderContext, email: UncertainEmail): Promise; +} + +export type InboundKind = 'message' | 'bounce' | 'complaint' | 'delivery'; + +export interface InboundMailEvent { + /** Stable provider event id, used for deduplication across webhook retries and poll overlap. */ + readonly eventId: string; + readonly kind: InboundKind; + readonly providerMessageId: string; + readonly providerThreadId?: string; + readonly rfcMessageId?: string; + readonly inReplyTo?: string; + readonly references: readonly string[]; + readonly from: string; + readonly to: readonly string[]; + readonly subject?: string; + readonly receivedAt: number; + readonly headers: Readonly>; + readonly contentType?: string; + /** Delivery status notification details for bounces. */ + readonly dsn?: { readonly status: string; readonly recipient?: string; readonly originalMessageId?: string }; + /** At most 500 characters, used only for deterministic classification. */ + readonly snippet?: string; +} + +export interface MailboxReader { + readChanges( + ctx: ProviderContext, + cursor: string | null, + ): Promise<{ events: readonly InboundMailEvent[]; nextCursor: string }>; +} + +export interface WebhookVerifier { + /** Verify over the raw body BEFORE parsing. Returns 'reject' on bad signature, stale timestamp or oversize body. */ + verify( + rawBody: Uint8Array, + headers: Readonly>, + secret: string, + now: number, + ): Promise; +} + +export interface Notification { + readonly title: string; + readonly lines: readonly string[]; + readonly severity: 'info' | 'warning' | 'error'; + readonly link?: string; +} + +export interface NotificationPublisher { + publish(ctx: ProviderContext, notification: Notification & { idempotencyKey: string }): Promise; +} + +export interface ManualTaskInput { + readonly taskId: string; + readonly channel: string; + readonly targetUrl?: string; + readonly draft: string; +} + +/** Hosts may announce manual tasks (e.g. open a URL for the human). Completing a task is always a human action. */ +export interface ManualTaskProvider { + prepare(ctx: ProviderContext, input: ManualTaskInput): Promise; +} + +/** Everything one provider integration offers. Missing ports mean "unsupported", never "fall back". */ +export interface ProviderAdapter { + readonly name: string; + /** Purposes the provider's terms permit. */ + readonly purposes: readonly ProviderPurpose[]; + readonly account: AccountPort; + readonly email?: EmailSender; + readonly mailbox?: MailboxReader; + readonly webhook?: WebhookVerifier; + readonly notify?: NotificationPublisher; + readonly manual?: ManualTaskProvider; +} diff --git a/outreach-engine/packages/outreach-contracts/src/sql.ts b/outreach-engine/packages/outreach-contracts/src/sql.ts new file mode 100644 index 0000000..fcc178e --- /dev/null +++ b/outreach-engine/packages/outreach-contracts/src/sql.ts @@ -0,0 +1,26 @@ +/** + * Synchronous SQLite-dialect database port. `@splitin/outreach-store-sqlite` provides drivers and + * migrations; the core and importer write their queries against this port. + * + * Transactions are synchronous on purpose: a transaction can never span an `await`, so provider + * calls always happen outside them (BUILD_PLAN.md §6.6). + */ +export type SqlValue = string | number | bigint | null | Uint8Array; + +export interface SqlRunResult { + readonly changes: number; +} + +export interface SqlStatement { + run(...params: SqlValue[]): SqlRunResult; + get>(...params: SqlValue[]): T | undefined; + all>(...params: SqlValue[]): T[]; +} + +export interface SqlDatabase { + exec(sql: string): void; + prepare(sql: string): SqlStatement; + /** Outermost call runs `BEGIN IMMEDIATE`; nested calls use savepoints. `fn` must be synchronous. */ + transaction(fn: () => T): T; + close(): void; +} diff --git a/outreach-engine/packages/outreach-contracts/src/index.test.ts b/outreach-engine/packages/outreach-contracts/src/version.test.ts similarity index 86% rename from outreach-engine/packages/outreach-contracts/src/index.test.ts rename to outreach-engine/packages/outreach-contracts/src/version.test.ts index 2c2b6c7..a6121f9 100644 --- a/outreach-engine/packages/outreach-contracts/src/index.test.ts +++ b/outreach-engine/packages/outreach-contracts/src/version.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { OUTREACH_API_VERSION, isSupportedApiVersion } from './index'; +import { OUTREACH_API_VERSION, isSupportedApiVersion } from './version'; describe('isSupportedApiVersion', () => { it('accepts the current API version', () => { diff --git a/outreach-engine/packages/outreach-contracts/src/version.ts b/outreach-engine/packages/outreach-contracts/src/version.ts new file mode 100644 index 0000000..2a1f8ed --- /dev/null +++ b/outreach-engine/packages/outreach-contracts/src/version.ts @@ -0,0 +1,11 @@ +/** + * Playbook and API version this contracts package describes. + * Every playbook and API payload carries this value in `apiVersion`. + */ +export const OUTREACH_API_VERSION = 'outreach.splitin.net/v1alpha1' as const; + +export type OutreachApiVersion = typeof OUTREACH_API_VERSION; + +export function isSupportedApiVersion(value: unknown): value is OutreachApiVersion { + return value === OUTREACH_API_VERSION; +} diff --git a/outreach-engine/packages/outreach-contracts/tsconfig.json b/outreach-engine/packages/outreach-contracts/tsconfig.json index 6fcd57f..585a92d 100644 --- a/outreach-engine/packages/outreach-contracts/tsconfig.json +++ b/outreach-engine/packages/outreach-contracts/tsconfig.json @@ -1,5 +1,5 @@ { "extends": "../../tsconfig.base.json", - "compilerOptions": { "rootDir": "src", "outDir": "dist" }, + "compilerOptions": { "outDir": "dist" }, "include": ["src"] } diff --git a/outreach-engine/packages/outreach-fakes/package.json b/outreach-engine/packages/outreach-fakes/package.json new file mode 100644 index 0000000..4d247ac --- /dev/null +++ b/outreach-engine/packages/outreach-fakes/package.json @@ -0,0 +1,43 @@ +{ + "name": "@splitin/outreach-fakes", + "version": "0.0.0", + "description": "Deterministic fake providers with scriptable failure modes, plus the provider conformance kit.", + "license": "MIT", + "author": "SplitInTech", + "homepage": "https://github.com/splitintech/open-internal-tools/tree/main/outreach-engine#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/splitintech/open-internal-tools.git", + "directory": "outreach-engine/packages/outreach-fakes" + }, + "type": "module", + "sideEffects": false, + "engines": { + "node": ">=22.13" + }, + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "files": [ + "dist", + "README.md", + "package.json" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsup", + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@splitin/outreach-contracts": "0.0.0" + } +} diff --git a/outreach-engine/packages/outreach-fakes/src/conformance.ts b/outreach-engine/packages/outreach-fakes/src/conformance.ts new file mode 100644 index 0000000..facb922 --- /dev/null +++ b/outreach-engine/packages/outreach-fakes/src/conformance.ts @@ -0,0 +1,183 @@ +/** + * Email adapter conformance kit (BUILD_PLAN.md §5.5). Framework-agnostic: each case throws on failure, + * so any test runner can execute it. Cases that need a forced outcome are skipped when the harness + * cannot force one (for example a real sandbox account). + */ +import { + ERROR_CLASSES, + ulid, + type ApprovedEmail, + type EmailSender, + type ProviderContext, + type SecretResolver, +} from '@splitin/outreach-contracts'; +import type { FakeSendMode } from './fake-email'; + +export interface EmailConformanceHarness { + readonly sender: EmailSender; + readonly ctx: ProviderContext; + /** The secret value behind `ctx.secretRef`; it must never appear in results. */ + readonly secretValue: string; + /** Recipient the harness may send to (a sandbox or allowlisted address). */ + readonly recipient: string; + /** Forces the next send outcome. Absent for real providers. */ + readonly force?: (mode: FakeSendMode) => void; +} + +export interface ConformanceCase { + readonly name: string; + readonly needsForce: boolean; + run(harness: EmailConformanceHarness): Promise; +} + +export class ConformanceFailure extends Error { + constructor(caseName: string, message: string) { + super(`[${caseName}] ${message}`); + this.name = 'ConformanceFailure'; + } +} + +function makeEmail(harness: EmailConformanceHarness): ApprovedEmail { + const id = ulid(); + return { + actionId: `conf-${id}`, + idempotencyKey: `conf:${id}`, + rfcMessageId: `<${id}.conformance@example.com>`, + contentHash: 'conformance', + from: { address: harness.ctx.account.sender.address, name: harness.ctx.account.sender.name }, + to: [{ address: harness.recipient }], + subject: `Conformance ${id}`, + text: 'Conformance test message.', + headers: {}, + }; +} + +function uncertainFrom(email: ApprovedEmail, at: number) { + return { + actionId: email.actionId, + idempotencyKey: email.idempotencyKey, + rfcMessageId: email.rfcMessageId, + to: email.to.map((to) => to.address), + attemptedAt: at, + }; +} + +function assert(condition: unknown, caseName: string, message: string): asserts condition { + if (!condition) throw new ConformanceFailure(caseName, message); +} + +function assertNoSecret(value: unknown, harness: EmailConformanceHarness, caseName: string): void { + const serialized = JSON.stringify(value) ?? ''; + assert(!serialized.includes(harness.secretValue), caseName, 'result leaks the credential'); +} + +export const EMAIL_SENDER_CONFORMANCE: readonly ConformanceCase[] = [ + { + name: 'accepted send returns stable provider ids and no secrets', + needsForce: false, + async run(h) { + const result = await h.sender.send(h.ctx, makeEmail(h)); + assert(result.kind === 'accepted', this.name, `expected accepted, got ${result.kind}`); + assert(result.receipt.providerMessageId.length > 0, this.name, 'empty providerMessageId'); + assert(Number.isFinite(result.receipt.acceptedAt), this.name, 'acceptedAt is not a timestamp'); + assertNoSecret(result, h, this.name); + }, + }, + { + name: 'reconcile finds an accepted send with the same provider message id', + needsForce: false, + async run(h) { + const email = makeEmail(h); + const sent = await h.sender.send(h.ctx, email); + assert(sent.kind === 'accepted', this.name, `expected accepted, got ${sent.kind}`); + const found = await h.sender.reconcile(h.ctx, uncertainFrom(email, h.ctx.now())); + assert(found.kind !== 'absent', this.name, 'reconcile claimed an accepted message is absent'); + if (found.kind === 'found') { + assert( + found.receipt.providerMessageId === sent.receipt.providerMessageId, + this.name, + 'reconcile returned a different providerMessageId', + ); + } + }, + }, + { + name: 'reconcile never finds a message that was never sent', + needsForce: false, + async run(h) { + const result = await h.sender.reconcile(h.ctx, uncertainFrom(makeEmail(h), h.ctx.now())); + assert(result.kind !== 'found', this.name, 'reconcile found a message that was never sent'); + }, + }, + { + name: 'a timeout after accept reports unknown and reconciles to found', + needsForce: true, + async run(h) { + h.force?.({ kind: 'unknown_after_accept' }); + const email = makeEmail(h); + const result = await h.sender.send(h.ctx, email); + assert(result.kind === 'unknown', this.name, `expected unknown, got ${result.kind}`); + const found = await h.sender.reconcile(h.ctx, uncertainFrom(email, h.ctx.now())); + assert(found.kind !== 'absent', this.name, 'reconcile reported absent for a delivered message'); + }, + }, + { + name: 'a timeout before accept never reconciles to found', + needsForce: true, + async run(h) { + h.force?.({ kind: 'unknown_before_accept' }); + const email = makeEmail(h); + const result = await h.sender.send(h.ctx, email); + assert(result.kind === 'unknown', this.name, `expected unknown, got ${result.kind}`); + const reconciled = await h.sender.reconcile(h.ctx, uncertainFrom(email, h.ctx.now())); + assert(reconciled.kind !== 'found', this.name, 'reconcile found a message that was never delivered'); + }, + }, + { + name: 'rate limits are classified and carry retryAfterMs', + needsForce: true, + async run(h) { + h.force?.({ kind: 'reject', errorClass: 'rate_limited', retryAfterMs: 30_000 }); + const result = await h.sender.send(h.ctx, makeEmail(h)); + assert(result.kind === 'rejected', this.name, `expected rejected, got ${result.kind}`); + assert(result.errorClass === 'rate_limited', this.name, `expected rate_limited, got ${result.errorClass}`); + assert(result.retryAfterMs === 30_000, this.name, 'retryAfterMs not propagated'); + }, + }, + { + name: 'permanent rejections use the error taxonomy and leak no secrets', + needsForce: true, + async run(h) { + h.force?.({ kind: 'reject', errorClass: 'invalid_recipient' }); + const result = await h.sender.send(h.ctx, makeEmail(h)); + assert(result.kind === 'rejected', this.name, `expected rejected, got ${result.kind}`); + assert((ERROR_CLASSES as readonly string[]).includes(result.errorClass), this.name, 'unknown error class'); + assertNoSecret(result, h, this.name); + }, + }, +]; + +/** Runs every applicable case; returns the names of skipped cases. Throws on the first failure. */ +export async function runEmailSenderConformance(makeHarness: () => EmailConformanceHarness): Promise { + const skipped: string[] = []; + for (const testCase of EMAIL_SENDER_CONFORMANCE) { + const harness = makeHarness(); + if (testCase.needsForce && !harness.force) { + skipped.push(testCase.name); + continue; + } + await testCase.run(harness); + } + return skipped; +} + +/** Secret resolver backed by a fixed map; unknown references throw. */ +export function staticSecrets(values: Readonly>): SecretResolver { + return { + async get(ref) { + const value = values[ref]; + if (value === undefined) throw new Error(`Unknown secret reference ${ref}`); + return value; + }, + }; +} diff --git a/outreach-engine/packages/outreach-fakes/src/fake-email.ts b/outreach-engine/packages/outreach-fakes/src/fake-email.ts new file mode 100644 index 0000000..b7c33fc --- /dev/null +++ b/outreach-engine/packages/outreach-fakes/src/fake-email.ts @@ -0,0 +1,293 @@ +import { + hmacSha256Hex, + safeEqualHex, + type AccountHealth, + type ApprovedEmail, + type CapabilitySnapshot, + type ErrorClass, + type InboundMailEvent, + type ProviderAdapter, + type ProviderContext, + type ProviderPurpose, + type ProviderReceipt, + type ReconcileResult, + type SendResult, + type UncertainEmail, +} from '@splitin/outreach-contracts'; + +/** What the next `send` call does. The fake records a delivery only when the effect really "happened". */ +export type FakeSendMode = + | { kind: 'accept' } + | { kind: 'reject'; errorClass: ErrorClass; retryAfterMs?: number } + /** Delivered, but the caller never learns it (timeout after the provider accepted). */ + | { kind: 'unknown_after_accept' } + /** Not delivered, and the caller cannot tell (timeout before the provider saw it). */ + | { kind: 'unknown_before_accept' } + /** Delivered, then the adapter throws (bug or crash inside the adapter). */ + | { kind: 'throw_after_accept' }; + +export interface FakeDelivery { + readonly providerMessageId: string; + readonly providerThreadId: string; + readonly rfcMessageId: string; + readonly idempotencyKey: string; + readonly actionId: string; + readonly from: string; + readonly to: readonly string[]; + readonly subject: string; + readonly headers: Readonly>; + readonly at: number; +} + +export interface FakeEmailOptions { + readonly name?: string; + readonly purposes?: readonly ProviderPurpose[]; + /** The secret value the fake expects from `ctx.secrets.get(ctx.secretRef)`. */ + readonly secret?: string; + readonly externalIdempotency?: boolean; + /** `authoritative`: reconcile answers found/absent. `still_unknown`: reconcile can never decide. */ + readonly reconcile?: 'authoritative' | 'still_unknown'; + readonly webhookSecret?: string; +} + +export const FAKE_EMAIL_SECRET = 'fake-email-secret-value'; +export const FAKE_WEBHOOK_SECRET = 'fake-webhook-secret-value'; +const WEBHOOK_TOLERANCE_MS = 5 * 60_000; + +/** Deterministic in-memory email provider for tests and dry runs. */ +export class FakeEmailProvider { + readonly name: string; + readonly deliveries: FakeDelivery[] = []; + readonly inbound: InboundMailEvent[] = []; + sendCalls = 0; + reconcileCalls = 0; + healthStatus: AccountHealth = 'ok'; + reconcileMode: 'authoritative' | 'still_unknown'; + private readonly queue: FakeSendMode[] = []; + private defaultMode: FakeSendMode = { kind: 'accept' }; + private readonly purposes: readonly ProviderPurpose[]; + private readonly secret: string; + private readonly webhookSecret: string; + private readonly externalIdempotency: boolean; + private counter = 0; + + constructor(options: FakeEmailOptions = {}) { + this.name = options.name ?? 'fake-email'; + this.purposes = options.purposes ?? ['manual_correspondence', 'transactional', 'automated_outreach']; + this.secret = options.secret ?? FAKE_EMAIL_SECRET; + this.webhookSecret = options.webhookSecret ?? FAKE_WEBHOOK_SECRET; + this.externalIdempotency = options.externalIdempotency ?? false; + this.reconcileMode = options.reconcile ?? 'authoritative'; + } + + /** Queue modes for the next `send` calls, in order. */ + script(...modes: FakeSendMode[]): this { + this.queue.push(...modes); + return this; + } + + setDefault(mode: FakeSendMode): this { + this.defaultMode = mode; + return this; + } + + deliveriesFor(rfcMessageId: string): FakeDelivery[] { + return this.deliveries.filter((delivery) => delivery.rfcMessageId === rfcMessageId); + } + + adapter(): ProviderAdapter { + return { + name: this.name, + purposes: this.purposes, + account: { + discover: async (ctx) => this.capabilities(ctx.now()), + health: async () => ({ status: this.healthStatus }), + }, + email: { + send: (ctx, email) => this.send(ctx, email), + reconcile: (ctx, email) => this.reconcile(ctx, email), + }, + mailbox: { + readChanges: async (_ctx, cursor) => { + const start = cursor ? Number.parseInt(cursor, 10) : 0; + return { events: this.inbound.slice(start), nextCursor: String(this.inbound.length) }; + }, + }, + webhook: { + verify: async (rawBody, headers, secret, now) => this.verifyWebhook(rawBody, headers, secret, now), + }, + }; + } + + capabilities(now: number): CapabilitySnapshot { + return { + provider: this.name, + send: true, + replyInThread: true, + customHeaders: true, + externalIdempotency: this.externalIdempotency, + inboundWebhook: true, + mailboxPolling: true, + reconcileBySentSearch: this.reconcileMode === 'authoritative', + maxRecipientsPerMessage: 1, + discoveredAt: now, + }; + } + + private async send(ctx: ProviderContext, email: ApprovedEmail): Promise { + this.sendCalls += 1; + if (ctx.signal.aborted) return { kind: 'unknown', detail: 'aborted before send' }; + if ((await ctx.secrets.get(ctx.secretRef)) !== this.secret) { + return { kind: 'rejected', errorClass: 'auth_revoked', detail: 'credential rejected' }; + } + const mode = this.queue.shift() ?? this.defaultMode; + if (this.externalIdempotency) { + const existing = this.deliveries.find((delivery) => delivery.idempotencyKey === email.idempotencyKey); + if (existing && mode.kind === 'accept') return { kind: 'accepted', receipt: toReceipt(existing) }; + } + switch (mode.kind) { + case 'accept': + return { kind: 'accepted', receipt: toReceipt(this.deliver(ctx, email)) }; + case 'reject': + return { + kind: 'rejected', + errorClass: mode.errorClass, + detail: `fake rejection: ${mode.errorClass}`, + ...(mode.retryAfterMs === undefined ? {} : { retryAfterMs: mode.retryAfterMs }), + }; + case 'unknown_after_accept': + this.deliver(ctx, email); + return { kind: 'unknown', detail: 'fake timeout after accept' }; + case 'unknown_before_accept': + return { kind: 'unknown', detail: 'fake timeout before accept' }; + case 'throw_after_accept': + this.deliver(ctx, email); + throw new Error('fake adapter crashed after accept'); + } + } + + private async reconcile(_ctx: ProviderContext, email: UncertainEmail): Promise { + this.reconcileCalls += 1; + if (this.reconcileMode === 'still_unknown') return { kind: 'still_unknown', detail: 'fake cannot search' }; + const match = this.deliveries.find( + (delivery) => delivery.rfcMessageId === email.rfcMessageId || delivery.idempotencyKey === email.idempotencyKey, + ); + return match ? { kind: 'found', receipt: toReceipt(match) } : { kind: 'absent' }; + } + + private deliver(ctx: ProviderContext, email: ApprovedEmail): FakeDelivery { + this.counter += 1; + const parent = email.inReplyTo ? this.deliveries.find((d) => d.rfcMessageId === email.inReplyTo) : undefined; + const delivery: FakeDelivery = { + providerMessageId: `fake-msg-${this.counter}`, + providerThreadId: email.providerThreadId ?? parent?.providerThreadId ?? `fake-thread-${this.counter}`, + rfcMessageId: email.rfcMessageId, + idempotencyKey: email.idempotencyKey, + actionId: email.actionId, + from: email.from.address, + to: email.to.map((to) => to.address), + subject: email.subject, + headers: email.headers, + at: ctx.now(), + }; + this.deliveries.push(delivery); + return delivery; + } + + /** Simulate the recipient replying in thread. */ + reply( + to: FakeDelivery, + options: { from?: string; snippet?: string; subject?: string; headers?: Record; at?: number } = {}, + ): InboundMailEvent { + return this.pushInbound({ + kind: 'message', + providerThreadId: to.providerThreadId, + inReplyTo: to.rfcMessageId, + references: [to.rfcMessageId], + from: options.from ?? to.to[0] ?? 'unknown@example.com', + to: [to.from], + subject: options.subject ?? `Re: ${to.subject}`, + headers: options.headers ?? {}, + snippet: options.snippet ?? 'Thanks, happy to talk next week.', + at: options.at, + }); + } + + /** Simulate a delivery status notification for a previous delivery. */ + bounce(to: FakeDelivery, status: string, at?: number): InboundMailEvent { + return this.pushInbound({ + kind: 'bounce', + references: [to.rfcMessageId], + from: 'mailer-daemon@example.net', + to: [to.from], + subject: 'Delivery Status Notification (Failure)', + headers: {}, + contentType: 'multipart/report; report-type=delivery-status', + dsn: { status, recipient: to.to[0], originalMessageId: to.rfcMessageId }, + at, + }); + } + + complaint(to: FakeDelivery, at?: number): InboundMailEvent { + return this.pushInbound({ + kind: 'complaint', + references: [to.rfcMessageId], + from: to.to[0] ?? 'unknown@example.com', + to: [to.from], + headers: {}, + at, + }); + } + + /** Any inbound message, e.g. an unrelated email or an out-of-office. */ + pushInbound(input: Omit & { at?: number | undefined }): InboundMailEvent { + this.counter += 1; + const { at, ...rest } = input; + const event: InboundMailEvent = { + ...rest, + eventId: `fake-evt-${this.counter}`, + providerMessageId: `fake-in-${this.counter}`, + rfcMessageId: rest.rfcMessageId ?? ``, + receivedAt: at ?? Date.now(), + }; + this.inbound.push(event); + return event; + } + + /** Build a signed webhook request carrying the given events. */ + signWebhook(events: readonly InboundMailEvent[], timestamp: number): { rawBody: Uint8Array; headers: Record } { + const body = JSON.stringify({ events }); + return { + rawBody: new TextEncoder().encode(body), + headers: { + 'x-fake-timestamp': String(timestamp), + 'x-fake-signature': hmacSha256Hex(this.webhookSecret, `${timestamp}.${body}`), + }, + }; + } + + private async verifyWebhook( + rawBody: Uint8Array, + headers: Readonly>, + secret: string, + now: number, + ): Promise { + if (rawBody.byteLength > 1_000_000) return 'reject'; + const timestamp = Number(headers['x-fake-timestamp']); + const signature = headers['x-fake-signature'] ?? ''; + if (!Number.isFinite(timestamp) || Math.abs(now - timestamp) > WEBHOOK_TOLERANCE_MS) return 'reject'; + const body = new TextDecoder().decode(rawBody); + if (!safeEqualHex(hmacSha256Hex(secret, `${timestamp}.${body}`), signature)) return 'reject'; + const parsed = JSON.parse(body) as { events?: InboundMailEvent[] }; + return parsed.events ?? []; + } +} + +function toReceipt(delivery: FakeDelivery): ProviderReceipt { + return { + providerMessageId: delivery.providerMessageId, + providerThreadId: delivery.providerThreadId, + rfcMessageId: delivery.rfcMessageId, + acceptedAt: delivery.at, + }; +} diff --git a/outreach-engine/packages/outreach-fakes/src/fake-notify.ts b/outreach-engine/packages/outreach-fakes/src/fake-notify.ts new file mode 100644 index 0000000..cba8c4f --- /dev/null +++ b/outreach-engine/packages/outreach-fakes/src/fake-notify.ts @@ -0,0 +1,72 @@ +import type { AccountHealth, Notification, ProviderAdapter, SendResult } from '@splitin/outreach-contracts'; +import type { FakeSendMode } from './fake-email'; + +export interface FakePublished extends Notification { + readonly idempotencyKey: string; + readonly at: number; +} + +/** In-memory notification channel (stands in for Slack in tests). */ +export class FakeNotifier { + readonly name: string; + readonly published: FakePublished[] = []; + healthStatus: AccountHealth = 'ok'; + private readonly queue: FakeSendMode[] = []; + + constructor(name = 'fake-notify') { + this.name = name; + } + + script(...modes: FakeSendMode[]): this { + this.queue.push(...modes); + return this; + } + + adapter(): ProviderAdapter { + return { + name: this.name, + purposes: ['transactional'], + account: { + discover: async (ctx) => ({ + provider: this.name, + send: true, + replyInThread: false, + customHeaders: false, + externalIdempotency: false, + inboundWebhook: false, + mailboxPolling: false, + reconcileBySentSearch: false, + maxRecipientsPerMessage: 1, + discoveredAt: ctx.now(), + }), + health: async () => ({ status: this.healthStatus }), + }, + notify: { + publish: async (ctx, notification): Promise => { + const mode = this.queue.shift() ?? { kind: 'accept' }; + const record = (): FakePublished => { + const published = { ...notification, at: ctx.now() }; + this.published.push(published); + return published; + }; + switch (mode.kind) { + case 'accept': + record(); + return { + kind: 'accepted', + receipt: { providerMessageId: `fake-note-${this.published.length}`, acceptedAt: ctx.now() }, + }; + case 'reject': + return { kind: 'rejected', errorClass: mode.errorClass, detail: 'fake notify rejection' }; + case 'unknown_after_accept': + case 'throw_after_accept': + record(); + return { kind: 'unknown', detail: 'fake notify timeout' }; + case 'unknown_before_accept': + return { kind: 'unknown', detail: 'fake notify timeout' }; + } + }, + }, + }; + } +} diff --git a/outreach-engine/packages/outreach-fakes/src/fakes.test.ts b/outreach-engine/packages/outreach-fakes/src/fakes.test.ts new file mode 100644 index 0000000..d4b1fd5 --- /dev/null +++ b/outreach-engine/packages/outreach-fakes/src/fakes.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from 'vitest'; +import type { ProviderContext } from '@splitin/outreach-contracts'; +import { + EMAIL_SENDER_CONFORMANCE, + FAKE_EMAIL_SECRET, + FAKE_WEBHOOK_SECRET, + FakeEmailProvider, + FakeNotifier, + runEmailSenderConformance, + staticSecrets, +} from './index'; + +function ctxFor(): ProviderContext { + return { + workspaceId: 'default', + account: { + id: 'acct-1', + provider: 'fake-email', + externalAccountId: 'ext-1', + sender: { name: 'Sender', address: 'sender@example.com' }, + }, + secretRef: 'env:FAKE', + secrets: staticSecrets({ 'env:FAKE': FAKE_EMAIL_SECRET }), + traceId: 'trace', + signal: new AbortController().signal, + now: () => 1_700_000_000_000, + }; +} + +function harness(fake = new FakeEmailProvider()) { + const adapter = fake.adapter(); + if (!adapter.email) throw new Error('fake has no email port'); + return { + sender: adapter.email, + ctx: ctxFor(), + secretValue: FAKE_EMAIL_SECRET, + recipient: 'lead@example.org', + force: (mode: Parameters[0]) => void fake.script(mode), + }; +} + +describe('FakeEmailProvider conformance', () => { + for (const testCase of EMAIL_SENDER_CONFORMANCE) { + it(testCase.name, async () => { + await testCase.run(harness()); + }); + } + + it('skips forced cases when the harness cannot force outcomes', async () => { + const { force: _force, ...unforced } = harness(); + const skipped = await runEmailSenderConformance(() => unforced); + expect(skipped.length).toBe(EMAIL_SENDER_CONFORMANCE.filter((c) => c.needsForce).length); + }); +}); + +describe('FakeEmailProvider behaviour', () => { + it('records deliveries only when the effect happened', async () => { + const fake = new FakeEmailProvider().script( + { kind: 'unknown_before_accept' }, + { kind: 'unknown_after_accept' }, + { kind: 'reject', errorClass: 'transient' }, + ); + const h = harness(fake); + const email = (n: number) => ({ + actionId: `a${n}`, + idempotencyKey: `k${n}`, + rfcMessageId: ``, + contentHash: 'h', + from: { address: 'sender@example.com' }, + to: [{ address: 'lead@example.org' }], + subject: 's', + text: 't', + headers: {}, + }); + await h.sender.send(h.ctx, email(1)); + await h.sender.send(h.ctx, email(2)); + await h.sender.send(h.ctx, email(3)); + expect(fake.deliveries.map((d) => d.rfcMessageId)).toEqual(['']); + expect(fake.sendCalls).toBe(3); + }); + + it('rejects a wrong credential as auth_revoked', async () => { + const fake = new FakeEmailProvider({ secret: 'other-fake-secret' }); + const h = harness(fake); + const result = await h.sender.send(h.ctx, { + actionId: 'a', + idempotencyKey: 'k', + rfcMessageId: '', + contentHash: 'h', + from: { address: 'sender@example.com' }, + to: [{ address: 'lead@example.org' }], + subject: 's', + text: 't', + headers: {}, + }); + expect(result).toMatchObject({ kind: 'rejected', errorClass: 'auth_revoked' }); + }); + + it('threads replies and signs webhooks that verify only when untampered', async () => { + const fake = new FakeEmailProvider(); + const h = harness(fake); + const sent = await h.sender.send(h.ctx, { + actionId: 'a', + idempotencyKey: 'k', + rfcMessageId: '', + contentHash: 'h', + from: { address: 'sender@example.com' }, + to: [{ address: 'lead@example.org' }], + subject: 'Hello', + text: 't', + headers: {}, + }); + expect(sent.kind).toBe('accepted'); + const delivery = fake.deliveries[0]; + if (!delivery) throw new Error('no delivery'); + const reply = fake.reply(delivery, { at: 1_700_000_100_000 }); + expect(reply).toMatchObject({ inReplyTo: '', providerThreadId: delivery.providerThreadId }); + + const verifier = fake.adapter().webhook; + if (!verifier) throw new Error('no webhook port'); + const now = 1_700_000_200_000; + const signed = fake.signWebhook([reply], now); + expect(await verifier.verify(signed.rawBody, signed.headers, FAKE_WEBHOOK_SECRET, now)).toEqual([reply]); + const tampered = new TextEncoder().encode(new TextDecoder().decode(signed.rawBody).replace('lead', 'evil')); + expect(await verifier.verify(tampered, signed.headers, FAKE_WEBHOOK_SECRET, now)).toBe('reject'); + expect(await verifier.verify(signed.rawBody, signed.headers, FAKE_WEBHOOK_SECRET, now + 6 * 60_000)).toBe('reject'); + }); + + it('pages the mailbox by cursor', async () => { + const fake = new FakeEmailProvider(); + const mailbox = fake.adapter().mailbox; + if (!mailbox) throw new Error('no mailbox port'); + fake.pushInbound({ kind: 'message', references: [], from: 'a@example.org', to: [], headers: {} }); + const first = await mailbox.readChanges(ctxFor(), null); + fake.pushInbound({ kind: 'message', references: [], from: 'b@example.org', to: [], headers: {} }); + const second = await mailbox.readChanges(ctxFor(), first.nextCursor); + expect(first.events.map((e) => e.from)).toEqual(['a@example.org']); + expect(second.events.map((e) => e.from)).toEqual(['b@example.org']); + }); +}); + +describe('FakeNotifier', () => { + it('records accepted notifications and not rejected ones', async () => { + const notifier = new FakeNotifier().script({ kind: 'reject', errorClass: 'transient' }); + const port = notifier.adapter().notify; + if (!port) throw new Error('no notify port'); + const note = { title: 't', lines: ['l'], severity: 'info' as const, idempotencyKey: 'n1' }; + expect((await port.publish(ctxFor(), note)).kind).toBe('rejected'); + expect((await port.publish(ctxFor(), note)).kind).toBe('accepted'); + expect(notifier.published).toHaveLength(1); + }); +}); diff --git a/outreach-engine/packages/outreach-fakes/src/index.ts b/outreach-engine/packages/outreach-fakes/src/index.ts new file mode 100644 index 0000000..ae64b00 --- /dev/null +++ b/outreach-engine/packages/outreach-fakes/src/index.ts @@ -0,0 +1,3 @@ +export * from './fake-email'; +export * from './fake-notify'; +export * from './conformance'; diff --git a/outreach-engine/packages/outreach-fakes/tsconfig.json b/outreach-engine/packages/outreach-fakes/tsconfig.json new file mode 100644 index 0000000..585a92d --- /dev/null +++ b/outreach-engine/packages/outreach-fakes/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist" }, + "include": ["src"] +} diff --git a/outreach-engine/packages/outreach-fakes/tsup.config.ts b/outreach-engine/packages/outreach-fakes/tsup.config.ts new file mode 100644 index 0000000..1343a99 --- /dev/null +++ b/outreach-engine/packages/outreach-fakes/tsup.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + dts: true, + sourcemap: true, + clean: true, + target: 'node22', +}); diff --git a/outreach-engine/scripts/check-package-boundaries.mjs b/outreach-engine/scripts/check-package-boundaries.mjs index 357b3d8..424dc02 100644 --- a/outreach-engine/scripts/check-package-boundaries.mjs +++ b/outreach-engine/scripts/check-package-boundaries.mjs @@ -22,6 +22,7 @@ const internalRules = [ [/^@splitin\/outreach-provider-[a-z0-9-]+$/, ['@splitin/outreach-contracts']], [/^@splitin\/outreach-(server|mcp|cli)$/, ANY], [/^@splitin\/outreach-app-[a-z0-9-]+$/, ANY], + [/^@splitin\/outreach-e2e$/, ANY], ]; // External modules that only specific packages may import. @@ -84,8 +85,11 @@ for (const dir of dirs) { if (INTERNAL.test(dep) && !permits(dep)) violations.push(`${rel}/package.json: ${name} may not depend on ${dep}`); } + const devDeps = new Set(Object.keys(pkg.devDependencies ?? {})); for (const file of sourceFiles(join(dir, 'src'))) { const fileRel = relative(root, file); + // Tests may compose any workspace package declared in devDependencies (fakes, store). + const isTest = /\.test\.[cm]?[jt]sx?$/.test(file); const source = readFileSync(file, 'utf8'); for (const match of source.matchAll(IMPORT_RE)) { const spec = match[1] ?? match[2] ?? match[3]; @@ -99,7 +103,7 @@ for (const dir of dirs) { } const bare = spec.startsWith('@') ? spec.split('/').slice(0, 2).join('/') : spec; if (INTERNAL.test(bare)) { - if (bare !== name && !permits(bare)) violations.push(`${fileRel}: ${name} may not import ${bare}`); + if (bare !== name && !permits(bare) && !(isTest && devDeps.has(bare))) violations.push(`${fileRel}: ${name} may not import ${bare}`); continue; } for (const [modulePattern, ownerPattern] of externalOwners) { diff --git a/outreach-engine/vitest.config.ts b/outreach-engine/vitest.config.ts index b934145..cf79e59 100644 --- a/outreach-engine/vitest.config.ts +++ b/outreach-engine/vitest.config.ts @@ -1,8 +1,16 @@ +import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vitest/config'; +const packagesDir = fileURLToPath(new URL('./packages/', import.meta.url)); + export default defineConfig({ + resolve: { + // Tests run against sources, so they never depend on a prior build. + alias: [{ find: /^@splitin\/outreach-([a-z0-9-]+)$/, replacement: `${packagesDir}outreach-$1/src/index.ts` }], + }, test: { include: ['packages/*/src/**/*.test.ts', 'apps/*/src/**/*.test.ts'], environment: 'node', + testTimeout: 20_000, }, }); From 9e0b865130c35fe0d328b25f543f62addf942675 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 01:26:21 +0000 Subject: [PATCH 04/20] outreach-engine M2: SQLite store, migrations and hash-chained audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements milestone M2 of outreach-engine/BUILD_PLAN.md (§4, §6.6, §14). @splitin/outreach-store-sqlite (new) - schema.ts: the full initial schema from plan §4 as migration 0001_init. 25 STRICT tables covering workspaces/principals, contacts and contact points (consent basis, jurisdiction, permitted channels), mapping profiles and import batches/rows, templates, sequence and campaign versions, audience snapshots, enrollments, scheduled actions and attempts, messages, provider events and cursors, manual tasks, approvals, suppressions, kill switches, rate buckets and the audit log. Invariants are constraints, not application convention: * one live enrollment per (workspace, campaign, contact) via a partial unique index over status IN ('active','paused'), which fixes the race the original plan left to app logic; * action idempotency: UNIQUE (workspace_id, idempotency_key); * provider event dedupe: UNIQUE (provider_account_id, provider_event_id); * CHECK constraints on every state/kind/reason enum and foreign keys on. Columns the execution core needs beyond the plan's sketch are explicit: action purpose, recipient_norm, contact_point_id and reconcile_count; per-attempt rate-budget reservations; the confirmed_absent attempt outcome; message recipient_norm for per-recipient gaps; event class/correlation/enrollment; workspace settings; approval reason. - audit_events is append-only: BEFORE UPDATE / BEFORE DELETE triggers abort. - migrate(): ordered migrations, each in its own transaction, recorded with a sha256 of its SQL; an applied migration whose source changed raises MigrationDriftError instead of silently diverging. - driver.ts: wrapNativeDatabase() adapts any synchronous SQLite handle (node:sqlite DatabaseSync or better-sqlite3) to the SqlDatabase port, with statement caching, BEGIN IMMEDIATE outer transactions and savepoints for nesting. Returning a Promise from a transaction callback throws TransactionMisuseError, enforcing "never await inside a transaction". - open.ts: openSqliteDatabase() for the built-in node:sqlite driver (WAL, busy_timeout, foreign_keys, synchronous=NORMAL, auto-migrate), and fromBetterSqlite3() for hosts such as Papr Work that already load better-sqlite3. The better-sqlite3 path is structural, so there is no native dependency here; it is not exercised in CI yet because installing the native module is left to hosts. @splitin/outreach-contracts - audit.ts: appendAudit() and verifyAuditChain(). Each row stores prev_hash and hash = sha256(prev_hash || canonical(row)); writers append inside their own transaction so BEGIN IMMEDIATE serializes the chain. verifyAuditChain() recomputes it and reports the first broken seq. It lives in contracts so the core and importer can both write audit rows without depending on the store package. Build - tsup configs set removeNodeProtocol: false; esbuild otherwise rewrites "node:sqlite" to "sqlite", which does not resolve. The ESM and CJS bundles are smoke-tested against a real database. Tests (11 new, 42 total): migrations idempotent and drift-refusing; live-enrollment uniqueness while finished enrollments repeat; idempotency-key and provider-event dedupe; STRICT typing and CHECK enums; foreign keys; audit triggers abort UPDATE/DELETE; the chain detects an edited row even with the trigger dropped; nested savepoint rollback semantics; async callback refusal; two connections to one file serialize writers (BEGIN IMMEDIATE, SQLITE_BUSY with no timeout). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MbDW8kfMcxUuZ3WBtaDKBv --- outreach-engine/package-lock.json | 15 + .../packages/outreach-contracts/src/audit.ts | 99 +++++++ .../packages/outreach-contracts/src/index.ts | 1 + .../outreach-contracts/tsup.config.ts | 2 + .../packages/outreach-fakes/tsup.config.ts | 2 + .../outreach-store-sqlite/package.json | 43 +++ .../outreach-store-sqlite/src/driver.ts | 72 +++++ .../outreach-store-sqlite/src/index.ts | 4 + .../outreach-store-sqlite/src/migrate.ts | 47 ++++ .../outreach-store-sqlite/src/open.ts | 36 +++ .../outreach-store-sqlite/src/schema.ts | 261 ++++++++++++++++++ .../outreach-store-sqlite/src/store.test.ts | 188 +++++++++++++ .../outreach-store-sqlite/tsconfig.json | 5 + .../outreach-store-sqlite/tsup.config.ts | 12 + 14 files changed, 787 insertions(+) create mode 100644 outreach-engine/packages/outreach-contracts/src/audit.ts create mode 100644 outreach-engine/packages/outreach-store-sqlite/package.json create mode 100644 outreach-engine/packages/outreach-store-sqlite/src/driver.ts create mode 100644 outreach-engine/packages/outreach-store-sqlite/src/index.ts create mode 100644 outreach-engine/packages/outreach-store-sqlite/src/migrate.ts create mode 100644 outreach-engine/packages/outreach-store-sqlite/src/open.ts create mode 100644 outreach-engine/packages/outreach-store-sqlite/src/schema.ts create mode 100644 outreach-engine/packages/outreach-store-sqlite/src/store.test.ts create mode 100644 outreach-engine/packages/outreach-store-sqlite/tsconfig.json create mode 100644 outreach-engine/packages/outreach-store-sqlite/tsup.config.ts diff --git a/outreach-engine/package-lock.json b/outreach-engine/package-lock.json index d3aaee8..1d52360 100644 --- a/outreach-engine/package-lock.json +++ b/outreach-engine/package-lock.json @@ -1091,6 +1091,10 @@ "resolved": "packages/outreach-fakes", "link": true }, + "node_modules/@splitin/outreach-store-sqlite": { + "resolved": "packages/outreach-store-sqlite", + "link": true + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -3461,6 +3465,17 @@ "engines": { "node": ">=22.13" } + }, + "packages/outreach-store-sqlite": { + "name": "@splitin/outreach-store-sqlite", + "version": "0.0.0", + "license": "MIT", + "dependencies": { + "@splitin/outreach-contracts": "0.0.0" + }, + "engines": { + "node": ">=22.13" + } } } } diff --git a/outreach-engine/packages/outreach-contracts/src/audit.ts b/outreach-engine/packages/outreach-contracts/src/audit.ts new file mode 100644 index 0000000..cabff68 --- /dev/null +++ b/outreach-engine/packages/outreach-contracts/src/audit.ts @@ -0,0 +1,99 @@ +/** + * Append-only, hash-chained audit log (BUILD_PLAN.md §4.5). Every writer (core, importer, surfaces) + * appends through this function inside its own transaction, so `BEGIN IMMEDIATE` serializes the chain. + */ +import { canonicalize, sha256Hex } from './crypto'; +import type { SqlDatabase } from './sql'; + +export type AuditActorKind = 'principal' | 'worker' | 'provider' | 'system'; + +export interface AuditInput { + readonly workspaceId: string; + readonly at: number; + readonly actorKind: AuditActorKind; + readonly actorId: string; + readonly source: string; + readonly traceId: string; + readonly resourceKind: string; + readonly resourceId: string; + readonly action: string; + readonly detail?: unknown; +} + +export const AUDIT_GENESIS_HASH = '0'.repeat(64); + +interface AuditRow { + seq: number; + workspace_id: string; + at: number; + actor_kind: string; + actor_id: string; + source: string; + trace_id: string; + resource_kind: string; + resource_id: string; + action: string; + detail: string; + prev_hash: string; + hash: string; +} + +function rowBody(row: Omit): string { + return canonicalize(row); +} + +function chainHash(prevHash: string, body: string): string { + return sha256Hex(`${prevHash}\n${body}`); +} + +export function appendAudit(db: SqlDatabase, input: AuditInput): string { + const previous = db.prepare('SELECT hash FROM audit_events ORDER BY seq DESC LIMIT 1').get<{ hash: string }>(); + const prevHash = previous?.hash ?? AUDIT_GENESIS_HASH; + const body = { + workspace_id: input.workspaceId, + at: input.at, + actor_kind: input.actorKind, + actor_id: input.actorId, + source: input.source, + trace_id: input.traceId, + resource_kind: input.resourceKind, + resource_id: input.resourceId, + action: input.action, + detail: canonicalize(input.detail ?? {}), + }; + const hash = chainHash(prevHash, rowBody(body)); + db.prepare( + `INSERT INTO audit_events (workspace_id, at, actor_kind, actor_id, source, trace_id, resource_kind, + resource_id, action, detail, prev_hash, hash) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, + ).run( + body.workspace_id, + body.at, + body.actor_kind, + body.actor_id, + body.source, + body.trace_id, + body.resource_kind, + body.resource_id, + body.action, + body.detail, + prevHash, + hash, + ); + return hash; +} + +export type AuditVerification = { ok: true; count: number } | { ok: false; count: number; brokenAtSeq: number }; + +/** Recomputes the whole chain. Any edited, deleted or reordered row breaks it. */ +export function verifyAuditChain(db: SqlDatabase): AuditVerification { + const rows = db.prepare('SELECT * FROM audit_events ORDER BY seq ASC').all(); + let prevHash = AUDIT_GENESIS_HASH; + for (const row of rows) { + const { seq, prev_hash, hash, ...rest } = row; + if (prev_hash !== prevHash || chainHash(prevHash, rowBody(rest)) !== hash) { + return { ok: false, count: rows.length, brokenAtSeq: seq }; + } + prevHash = hash; + } + return { ok: true, count: rows.length }; +} diff --git a/outreach-engine/packages/outreach-contracts/src/index.ts b/outreach-engine/packages/outreach-contracts/src/index.ts index 423a761..0a5d48b 100644 --- a/outreach-engine/packages/outreach-contracts/src/index.ts +++ b/outreach-engine/packages/outreach-contracts/src/index.ts @@ -5,3 +5,4 @@ export * from './capabilities'; export * from './providers'; export * from './crypto'; export * from './sql'; +export * from './audit'; diff --git a/outreach-engine/packages/outreach-contracts/tsup.config.ts b/outreach-engine/packages/outreach-contracts/tsup.config.ts index 1343a99..458d1fd 100644 --- a/outreach-engine/packages/outreach-contracts/tsup.config.ts +++ b/outreach-engine/packages/outreach-contracts/tsup.config.ts @@ -7,4 +7,6 @@ export default defineConfig({ sourcemap: true, clean: true, target: 'node22', + // node:sqlite exists only with the protocol prefix. + removeNodeProtocol: false, }); diff --git a/outreach-engine/packages/outreach-fakes/tsup.config.ts b/outreach-engine/packages/outreach-fakes/tsup.config.ts index 1343a99..458d1fd 100644 --- a/outreach-engine/packages/outreach-fakes/tsup.config.ts +++ b/outreach-engine/packages/outreach-fakes/tsup.config.ts @@ -7,4 +7,6 @@ export default defineConfig({ sourcemap: true, clean: true, target: 'node22', + // node:sqlite exists only with the protocol prefix. + removeNodeProtocol: false, }); diff --git a/outreach-engine/packages/outreach-store-sqlite/package.json b/outreach-engine/packages/outreach-store-sqlite/package.json new file mode 100644 index 0000000..c562ed1 --- /dev/null +++ b/outreach-engine/packages/outreach-store-sqlite/package.json @@ -0,0 +1,43 @@ +{ + "name": "@splitin/outreach-store-sqlite", + "version": "0.0.0", + "description": "SQLite schema, migrations and drivers implementing the outreach SqlDatabase port.", + "license": "MIT", + "author": "SplitInTech", + "homepage": "https://github.com/splitintech/open-internal-tools/tree/main/outreach-engine#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/splitintech/open-internal-tools.git", + "directory": "outreach-engine/packages/outreach-store-sqlite" + }, + "type": "module", + "sideEffects": false, + "engines": { + "node": ">=22.13" + }, + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "files": [ + "dist", + "README.md", + "package.json" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsup", + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@splitin/outreach-contracts": "0.0.0" + } +} diff --git a/outreach-engine/packages/outreach-store-sqlite/src/driver.ts b/outreach-engine/packages/outreach-store-sqlite/src/driver.ts new file mode 100644 index 0000000..2b673f2 --- /dev/null +++ b/outreach-engine/packages/outreach-store-sqlite/src/driver.ts @@ -0,0 +1,72 @@ +import type { SqlDatabase, SqlRunResult, SqlStatement, SqlValue } from '@splitin/outreach-contracts'; + +/** Minimal structural shape shared by node:sqlite's DatabaseSync and better-sqlite3's Database. */ +interface NativeStatement { + run(...params: SqlValue[]): { changes: number | bigint }; + get(...params: SqlValue[]): unknown; + all(...params: SqlValue[]): unknown[]; +} + +export interface NativeDatabase { + exec(sql: string): unknown; + prepare(sql: string): NativeStatement; + close(): unknown; +} + +export class TransactionMisuseError extends Error { + constructor(message: string) { + super(message); + this.name = 'TransactionMisuseError'; + } +} + +/** + * Wraps a native synchronous SQLite handle as the SqlDatabase port: statement caching, and + * BEGIN IMMEDIATE transactions with savepoints for nesting. + */ +export function wrapNativeDatabase(native: NativeDatabase): SqlDatabase { + const cache = new Map(); + let depth = 0; + + const prepare = (sql: string): SqlStatement => { + const cached = cache.get(sql); + if (cached) return cached; + const statement = native.prepare(sql); + const wrapped: SqlStatement = { + run: (...params): SqlRunResult => ({ changes: Number(statement.run(...params).changes) }), + get: (...params: SqlValue[]) => statement.get(...params) as T | undefined, + all: (...params: SqlValue[]) => statement.all(...params) as T[], + }; + cache.set(sql, wrapped); + return wrapped; + }; + + return { + exec: (sql) => { + native.exec(sql); + }, + prepare, + transaction(fn: () => T): T { + const savepoint = `sp_${depth}`; + native.exec(depth === 0 ? 'BEGIN IMMEDIATE' : `SAVEPOINT ${savepoint}`); + depth += 1; + try { + const result = fn(); + if (result && typeof (result as { then?: unknown }).then === 'function') { + throw new TransactionMisuseError('transaction callbacks must be synchronous; never await inside one'); + } + depth -= 1; + native.exec(depth === 0 ? 'COMMIT' : `RELEASE ${savepoint}`); + return result; + } catch (error) { + depth -= 1; + native.exec(depth === 0 ? 'ROLLBACK' : `ROLLBACK TO ${savepoint}; RELEASE ${savepoint}`); + throw error; + } + }, + close: () => { + cache.clear(); + native.close(); + }, + }; +} diff --git a/outreach-engine/packages/outreach-store-sqlite/src/index.ts b/outreach-engine/packages/outreach-store-sqlite/src/index.ts new file mode 100644 index 0000000..3e9453d --- /dev/null +++ b/outreach-engine/packages/outreach-store-sqlite/src/index.ts @@ -0,0 +1,4 @@ +export { wrapNativeDatabase, TransactionMisuseError, type NativeDatabase } from './driver'; +export { migrate, MIGRATIONS, MigrationDriftError, type Migration } from './migrate'; +export { openSqliteDatabase, fromBetterSqlite3, type OpenOptions } from './open'; +export { SCHEMA_0001 } from './schema'; diff --git a/outreach-engine/packages/outreach-store-sqlite/src/migrate.ts b/outreach-engine/packages/outreach-store-sqlite/src/migrate.ts new file mode 100644 index 0000000..e80c8c0 --- /dev/null +++ b/outreach-engine/packages/outreach-store-sqlite/src/migrate.ts @@ -0,0 +1,47 @@ +import { sha256Hex, type SqlDatabase } from '@splitin/outreach-contracts'; +import { SCHEMA_0001 } from './schema'; + +export interface Migration { + readonly id: string; + readonly sql: string; +} + +export const MIGRATIONS: readonly Migration[] = [{ id: '0001_init', sql: SCHEMA_0001 }]; + +export class MigrationDriftError extends Error { + constructor(id: string) { + super(`Applied migration ${id} no longer matches its source; never edit an applied migration`); + this.name = 'MigrationDriftError'; + } +} + +/** Applies pending migrations in order, each in its own transaction. Refuses edited migrations. */ +export function migrate(db: SqlDatabase, migrations: readonly Migration[] = MIGRATIONS, now = Date.now()): string[] { + db.exec(`CREATE TABLE IF NOT EXISTS schema_migrations ( + id TEXT PRIMARY KEY, sha256 TEXT NOT NULL, applied_at INTEGER NOT NULL) STRICT`); + const applied = new Map( + db + .prepare('SELECT id, sha256 FROM schema_migrations') + .all<{ id: string; sha256: string }>() + .map((row) => [row.id, row.sha256]), + ); + const ran: string[] = []; + for (const migration of migrations) { + const digest = sha256Hex(migration.sql); + const existing = applied.get(migration.id); + if (existing !== undefined) { + if (existing !== digest) throw new MigrationDriftError(migration.id); + continue; + } + db.transaction(() => { + db.exec(migration.sql); + db.prepare('INSERT INTO schema_migrations (id, sha256, applied_at) VALUES (?,?,?)').run( + migration.id, + digest, + now, + ); + }); + ran.push(migration.id); + } + return ran; +} diff --git a/outreach-engine/packages/outreach-store-sqlite/src/open.ts b/outreach-engine/packages/outreach-store-sqlite/src/open.ts new file mode 100644 index 0000000..d11b774 --- /dev/null +++ b/outreach-engine/packages/outreach-store-sqlite/src/open.ts @@ -0,0 +1,36 @@ +import { DatabaseSync } from 'node:sqlite'; +import type { SqlDatabase } from '@splitin/outreach-contracts'; +import { wrapNativeDatabase, type NativeDatabase } from './driver'; +import { migrate } from './migrate'; + +export interface OpenOptions { + /** Apply pending migrations after opening (default true). */ + readonly migrate?: boolean; + readonly busyTimeoutMs?: number; +} + +function configure(db: SqlDatabase, path: string, busyTimeoutMs: number): void { + if (path !== ':memory:') db.exec('PRAGMA journal_mode = WAL'); + db.exec(`PRAGMA busy_timeout = ${Math.max(0, Math.floor(busyTimeoutMs))}`); + db.exec('PRAGMA foreign_keys = ON'); + db.exec('PRAGMA synchronous = NORMAL'); +} + +/** Opens (and by default migrates) a database with the built-in node:sqlite driver. */ +export function openSqliteDatabase(path: string, options: OpenOptions = {}): SqlDatabase { + const db = wrapNativeDatabase(new DatabaseSync(path) as unknown as NativeDatabase); + configure(db, path, options.busyTimeoutMs ?? 5_000); + if (options.migrate ?? true) migrate(db); + return db; +} + +/** + * Adapts an already-open better-sqlite3 handle (what Papr Work loads in Electron). + * Kept structural so this package does not depend on the native module. + */ +export function fromBetterSqlite3(native: NativeDatabase, path: string, options: OpenOptions = {}): SqlDatabase { + const db = wrapNativeDatabase(native); + configure(db, path, options.busyTimeoutMs ?? 5_000); + if (options.migrate ?? true) migrate(db); + return db; +} diff --git a/outreach-engine/packages/outreach-store-sqlite/src/schema.ts b/outreach-engine/packages/outreach-store-sqlite/src/schema.ts new file mode 100644 index 0000000..4302c06 --- /dev/null +++ b/outreach-engine/packages/outreach-store-sqlite/src/schema.ts @@ -0,0 +1,261 @@ +/** + * Initial schema (BUILD_PLAN.md §4). All tables are STRICT; every owned row carries workspace_id; + * timestamps are epoch milliseconds; ids are ULIDs. Invariants live in constraints, not app code. + */ +export const SCHEMA_0001 = ` +CREATE TABLE workspaces ( + id TEXT PRIMARY KEY, name TEXT NOT NULL, + settings TEXT NOT NULL DEFAULT '{}', + created_at INTEGER NOT NULL +) STRICT; + +CREATE TABLE principals ( + id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL REFERENCES workspaces(id), + external_ref TEXT NOT NULL, display_name TEXT NOT NULL, roles TEXT NOT NULL, + UNIQUE (workspace_id, external_ref) +) STRICT; + +CREATE TABLE organizations ( + id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL REFERENCES workspaces(id), + name TEXT NOT NULL, domain_norm TEXT, + UNIQUE (workspace_id, domain_norm) +) STRICT; + +CREATE TABLE contacts ( + id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL REFERENCES workspaces(id), + organization_id TEXT REFERENCES organizations(id), + full_name TEXT NOT NULL, first_name TEXT, title TEXT, timezone TEXT, locale TEXT, + attributes TEXT NOT NULL DEFAULT '{}', + merged_into_id TEXT REFERENCES contacts(id), + created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL +) STRICT; +CREATE INDEX ix_contacts_name ON contacts (workspace_id, full_name); + +CREATE TABLE contact_points ( + id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL REFERENCES workspaces(id), + contact_id TEXT NOT NULL REFERENCES contacts(id), + kind TEXT NOT NULL CHECK (kind IN ('email','social_profile','phone','other')), + value_norm TEXT NOT NULL, value_raw TEXT NOT NULL, source TEXT NOT NULL, + consent_basis TEXT CHECK (consent_basis IN ('consent','legitimate_interest','existing_relationship','unknown')), + consent_evidence TEXT, consent_at INTEGER, jurisdiction TEXT, + permitted_channels TEXT NOT NULL DEFAULT '[]', + UNIQUE (workspace_id, kind, value_norm) +) STRICT; +CREATE INDEX ix_contact_points_contact ON contact_points (contact_id); + +CREATE TABLE mapping_profiles ( + id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL REFERENCES workspaces(id), + name TEXT NOT NULL, version INTEGER NOT NULL, spec TEXT NOT NULL, + UNIQUE (workspace_id, name, version) +) STRICT; + +CREATE TABLE import_batches ( + id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL REFERENCES workspaces(id), + source_name TEXT NOT NULL, source_sha256 TEXT NOT NULL, format TEXT NOT NULL, + mapping_profile_id TEXT NOT NULL REFERENCES mapping_profiles(id), + status TEXT NOT NULL CHECK (status IN ('previewed','committed','abandoned')), + preview_hash TEXT NOT NULL, idempotency_key TEXT, + counts TEXT NOT NULL, warnings TEXT NOT NULL DEFAULT '[]', created_by TEXT NOT NULL, + created_at INTEGER NOT NULL, committed_at INTEGER, + UNIQUE (workspace_id, idempotency_key) +) STRICT; + +CREATE TABLE import_rows ( + id TEXT PRIMARY KEY, batch_id TEXT NOT NULL REFERENCES import_batches(id), + ordinal INTEGER NOT NULL, locator TEXT NOT NULL, raw TEXT NOT NULL, normalized TEXT, + outcome TEXT NOT NULL CHECK (outcome IN ('create','update','merge','reject','ambiguous')), + errors TEXT NOT NULL DEFAULT '[]', contact_id TEXT, + UNIQUE (batch_id, ordinal) +) STRICT; + +CREATE TABLE templates ( + id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL REFERENCES workspaces(id), + name TEXT NOT NULL, version INTEGER NOT NULL, channel TEXT NOT NULL, + subject TEXT, body_text TEXT NOT NULL, body_html TEXT, required_tokens TEXT NOT NULL, + created_at INTEGER NOT NULL, + UNIQUE (workspace_id, name, version) +) STRICT; + +CREATE TABLE sequence_versions ( + id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL REFERENCES workspaces(id), + name TEXT NOT NULL, version INTEGER NOT NULL, spec TEXT NOT NULL, spec_hash TEXT NOT NULL, + created_at INTEGER NOT NULL, + UNIQUE (workspace_id, name, version), + UNIQUE (workspace_id, spec_hash) +) STRICT; + +CREATE TABLE provider_accounts ( + id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL REFERENCES workspaces(id), + provider TEXT NOT NULL, external_account_id TEXT NOT NULL, + sender_identity TEXT NOT NULL, purposes TEXT NOT NULL, + capabilities TEXT NOT NULL DEFAULT '{}', secret_ref TEXT NOT NULL, + webhook_secret_ref TEXT, + health TEXT NOT NULL DEFAULT 'ok' CHECK (health IN ('ok','degraded','unhealthy','reauth_required')), + health_detail TEXT, health_checked_at INTEGER, + UNIQUE (workspace_id, provider, external_account_id) +) STRICT; + +CREATE TABLE campaigns ( + id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL REFERENCES workspaces(id), + name TEXT NOT NULL, purpose TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('draft','active','paused','completed','archived')), + active_version_id TEXT, paused_reason TEXT, + created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL +) STRICT; + +CREATE TABLE campaign_versions ( + id TEXT PRIMARY KEY, campaign_id TEXT NOT NULL REFERENCES campaigns(id), + version INTEGER NOT NULL, + sequence_version_id TEXT NOT NULL REFERENCES sequence_versions(id), + provider_account_id TEXT NOT NULL REFERENCES provider_accounts(id), + policy TEXT NOT NULL, policy_hash TEXT NOT NULL, audience TEXT NOT NULL, + audience_hash TEXT, version_hash TEXT, approval_id TEXT, + activated_at INTEGER, activated_by TEXT, + UNIQUE (campaign_id, version) +) STRICT; + +CREATE TABLE audience_members ( + id TEXT PRIMARY KEY, campaign_version_id TEXT NOT NULL REFERENCES campaign_versions(id), + contact_id TEXT NOT NULL REFERENCES contacts(id), + contact_point_id TEXT NOT NULL REFERENCES contact_points(id), + eligibility TEXT NOT NULL, + UNIQUE (campaign_version_id, contact_id) +) STRICT; + +CREATE TABLE enrollments ( + id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL REFERENCES workspaces(id), + campaign_id TEXT NOT NULL REFERENCES campaigns(id), + campaign_version_id TEXT NOT NULL REFERENCES campaign_versions(id), + contact_id TEXT NOT NULL REFERENCES contacts(id), + contact_point_id TEXT NOT NULL REFERENCES contact_points(id), + status TEXT NOT NULL CHECK (status IN + ('active','paused','replied','opted_out','bounced','completed','stopped','error')), + stop_reason TEXT, current_step_id TEXT, + row_version INTEGER NOT NULL DEFAULT 0, + enrolled_at INTEGER NOT NULL, updated_at INTEGER NOT NULL +) STRICT; +CREATE UNIQUE INDEX ux_enrollment_live ON enrollments (workspace_id, campaign_id, contact_id) + WHERE status IN ('active','paused'); +CREATE INDEX ix_enrollments_contact ON enrollments (workspace_id, contact_id, status); + +CREATE TABLE scheduled_actions ( + id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL REFERENCES workspaces(id), + enrollment_id TEXT REFERENCES enrollments(id), campaign_id TEXT REFERENCES campaigns(id), + step_id TEXT, contact_point_id TEXT, + kind TEXT NOT NULL CHECK (kind IN ('email.send','email.reply','notify.publish','manual.task')), + purpose TEXT, provider_account_id TEXT REFERENCES provider_accounts(id), recipient_norm TEXT, + state TEXT NOT NULL CHECK (state IN ('planned','awaiting_approval','scheduled','claimed', + 'executing','succeeded','retryable','uncertain','reconciling','failed','cancelled','review')), + due_at INTEGER NOT NULL, not_after INTEGER, + payload TEXT NOT NULL, content_hash TEXT NOT NULL, idempotency_key TEXT NOT NULL, + rfc_message_id TEXT, approval_id TEXT, + lease_owner TEXT, lease_expires_at INTEGER, + attempt_count INTEGER NOT NULL DEFAULT 0, max_attempts INTEGER NOT NULL DEFAULT 5, + reconcile_count INTEGER NOT NULL DEFAULT 0, + last_error_class TEXT, state_reason TEXT, + created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, + UNIQUE (workspace_id, idempotency_key) +) STRICT; +CREATE INDEX ix_actions_due ON scheduled_actions (state, due_at); +CREATE INDEX ix_actions_enrollment ON scheduled_actions (enrollment_id, state); +CREATE INDEX ix_actions_approval ON scheduled_actions (approval_id); + +CREATE TABLE action_attempts ( + id TEXT PRIMARY KEY, action_id TEXT NOT NULL REFERENCES scheduled_actions(id), + attempt_no INTEGER NOT NULL, + outcome TEXT NOT NULL CHECK (outcome IN + ('pending','succeeded','rejected_retryable','rejected_permanent','uncertain','confirmed_absent')), + error_class TEXT, error_detail TEXT, receipt TEXT, + reservations TEXT NOT NULL DEFAULT '[]', + started_at INTEGER NOT NULL, finished_at INTEGER, + UNIQUE (action_id, attempt_no) +) STRICT; + +CREATE TABLE messages ( + id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL REFERENCES workspaces(id), + direction TEXT NOT NULL CHECK (direction IN ('outbound','inbound')), + provider_account_id TEXT NOT NULL REFERENCES provider_accounts(id), + provider_message_id TEXT NOT NULL, provider_thread_id TEXT, + rfc_message_id TEXT, in_reply_to TEXT, references_ids TEXT NOT NULL DEFAULT '[]', + from_addr TEXT NOT NULL, to_addrs TEXT NOT NULL, recipient_norm TEXT, subject TEXT, + at INTEGER NOT NULL, action_id TEXT, enrollment_id TEXT, + UNIQUE (provider_account_id, provider_message_id) +) STRICT; +CREATE INDEX ix_messages_rfc ON messages (rfc_message_id); +CREATE INDEX ix_messages_thread ON messages (provider_account_id, provider_thread_id); +CREATE INDEX ix_messages_recipient ON messages (workspace_id, recipient_norm, at); + +CREATE TABLE provider_events ( + id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL REFERENCES workspaces(id), + provider_account_id TEXT NOT NULL REFERENCES provider_accounts(id), + provider_event_id TEXT NOT NULL, kind TEXT NOT NULL, + payload TEXT NOT NULL, payload_digest TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('pending','processed','ignored','failed','review')), + class TEXT, correlation TEXT, enrollment_id TEXT, detail TEXT, + received_at INTEGER NOT NULL, processed_at INTEGER, + UNIQUE (provider_account_id, provider_event_id) +) STRICT; +CREATE INDEX ix_provider_events_status ON provider_events (status, received_at); + +CREATE TABLE provider_cursors ( + provider_account_id TEXT PRIMARY KEY REFERENCES provider_accounts(id), + cursor TEXT, updated_at INTEGER NOT NULL +) STRICT; + +CREATE TABLE manual_tasks ( + id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL REFERENCES workspaces(id), + action_id TEXT NOT NULL UNIQUE REFERENCES scheduled_actions(id), + enrollment_id TEXT, channel TEXT NOT NULL, target_url TEXT, draft_text TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('open','done','skipped','expired')), + confirmed_by TEXT, confirmed_at INTEGER, note TEXT, created_at INTEGER NOT NULL +) STRICT; + +CREATE TABLE approvals ( + id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL REFERENCES workspaces(id), + scope TEXT NOT NULL CHECK (scope IN ('action','batch','campaign_version')), + subject_id TEXT NOT NULL, operation_hash TEXT NOT NULL, preview TEXT NOT NULL, + requested_by TEXT NOT NULL, decided_by TEXT, + decision TEXT NOT NULL CHECK (decision IN ('pending','approved','rejected','revoked','expired')), + reason TEXT, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, + decided_at INTEGER, consumed_count INTEGER NOT NULL DEFAULT 0 +) STRICT; +CREATE INDEX ix_approvals_pending ON approvals (workspace_id, decision); + +CREATE TABLE suppressions ( + id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL REFERENCES workspaces(id), + scope TEXT NOT NULL CHECK (scope IN ('global','channel','provider_account','domain')), + channel TEXT NOT NULL DEFAULT '*', value_norm TEXT NOT NULL, + reason TEXT NOT NULL CHECK (reason IN + ('opt_out','hard_bounce','complaint','manual','do_not_contact','legal')), + source TEXT NOT NULL, effective_at INTEGER NOT NULL, + UNIQUE (workspace_id, scope, channel, value_norm) +) STRICT; +CREATE INDEX ix_suppressions_value ON suppressions (workspace_id, value_norm); + +CREATE TABLE kill_switches ( + workspace_id TEXT NOT NULL, + scope TEXT NOT NULL CHECK (scope IN ('global','workspace','provider_account','campaign')), + target_id TEXT NOT NULL, engaged INTEGER NOT NULL CHECK (engaged IN (0,1)), + reason TEXT, changed_by TEXT, changed_at INTEGER, + PRIMARY KEY (workspace_id, scope, target_id) +) STRICT; + +CREATE TABLE rate_buckets ( + workspace_id TEXT NOT NULL, scope_key TEXT NOT NULL, window_start INTEGER NOT NULL, + used INTEGER NOT NULL CHECK (used >= 0), limit_value INTEGER NOT NULL, + PRIMARY KEY (workspace_id, scope_key, window_start) +) STRICT; + +CREATE TABLE audit_events ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + workspace_id TEXT NOT NULL, at INTEGER NOT NULL, + actor_kind TEXT NOT NULL CHECK (actor_kind IN ('principal','worker','provider','system')), + actor_id TEXT NOT NULL, source TEXT NOT NULL, trace_id TEXT NOT NULL, + resource_kind TEXT NOT NULL, resource_id TEXT NOT NULL, + action TEXT NOT NULL, detail TEXT NOT NULL, + prev_hash TEXT NOT NULL, hash TEXT NOT NULL +) STRICT; +CREATE INDEX ix_audit_resource ON audit_events (resource_kind, resource_id); +CREATE TRIGGER audit_no_update BEFORE UPDATE ON audit_events BEGIN SELECT RAISE(ABORT, 'audit_events is append-only'); END; +CREATE TRIGGER audit_no_delete BEFORE DELETE ON audit_events BEGIN SELECT RAISE(ABORT, 'audit_events is append-only'); END; +`; diff --git a/outreach-engine/packages/outreach-store-sqlite/src/store.test.ts b/outreach-engine/packages/outreach-store-sqlite/src/store.test.ts new file mode 100644 index 0000000..fbf3168 --- /dev/null +++ b/outreach-engine/packages/outreach-store-sqlite/src/store.test.ts @@ -0,0 +1,188 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { appendAudit, verifyAuditChain, type SqlDatabase } from '@splitin/outreach-contracts'; +import { MIGRATIONS, MigrationDriftError, TransactionMisuseError, migrate, openSqliteDatabase } from './index'; + +const open: SqlDatabase[] = []; +const dirs: string[] = []; +function memory(): SqlDatabase { + const db = openSqliteDatabase(':memory:'); + open.push(db); + return db; +} +afterEach(() => { + for (const db of open.splice(0)) db.close(); + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +function seed(db: SqlDatabase): void { + db.exec(` + INSERT INTO workspaces (id, name, created_at) VALUES ('ws', 'Workspace', 0); + INSERT INTO contacts (id, workspace_id, full_name, created_at, updated_at) VALUES ('c1', 'ws', 'Ada', 0, 0); + INSERT INTO contact_points (id, workspace_id, contact_id, kind, value_norm, value_raw, source) + VALUES ('cp1', 'ws', 'c1', 'email', 'ada@example.org', 'Ada@Example.org', 'manual'); + INSERT INTO provider_accounts (id, workspace_id, provider, external_account_id, sender_identity, purposes, secret_ref) + VALUES ('pa', 'ws', 'fake', 'ext', '{}', '[]', 'env:X'); + INSERT INTO sequence_versions (id, workspace_id, name, version, spec, spec_hash, created_at) + VALUES ('sv', 'ws', 'seq', 1, '{}', 'h', 0); + INSERT INTO campaigns (id, workspace_id, name, purpose, status, created_at, updated_at) + VALUES ('camp', 'ws', 'C', 'automated_outreach', 'active', 0, 0); + INSERT INTO campaign_versions (id, campaign_id, version, sequence_version_id, provider_account_id, policy, policy_hash, audience) + VALUES ('cv', 'camp', 1, 'sv', 'pa', '{}', 'p', '{}'); + `); +} + +function enroll(db: SqlDatabase, id: string, status: string): void { + db.prepare( + `INSERT INTO enrollments (id, workspace_id, campaign_id, campaign_version_id, contact_id, contact_point_id, + status, enrolled_at, updated_at) VALUES (?, 'ws', 'camp', 'cv', 'c1', 'cp1', ?, 0, 0)`, + ).run(id, status); +} + +describe('migrations', () => { + it('apply once and are idempotent', () => { + const db = memory(); + expect(migrate(db)).toEqual([]); + const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all<{ name: string }>(); + expect(tables.map((t) => t.name)).toEqual(expect.arrayContaining(['scheduled_actions', 'audit_events', 'enrollments'])); + }); + + it('refuse an edited migration', () => { + const db = memory(); + const edited = MIGRATIONS.map((m) => ({ ...m, sql: `${m.sql}\n-- edited` })); + expect(() => migrate(db, edited)).toThrow(MigrationDriftError); + }); +}); + +describe('constraints', () => { + it('allow one live enrollment per contact and campaign, but many finished ones', () => { + const db = memory(); + seed(db); + enroll(db, 'e1', 'completed'); + enroll(db, 'e2', 'active'); + expect(() => enroll(db, 'e3', 'paused')).toThrow(/UNIQUE/); + enroll(db, 'e4', 'replied'); + }); + + it('dedupe actions by idempotency key and provider events by provider id', () => { + const db = memory(); + seed(db); + const action = db.prepare( + `INSERT INTO scheduled_actions (id, workspace_id, kind, state, due_at, payload, content_hash, idempotency_key, + created_at, updated_at) VALUES (?, 'ws', 'email.send', 'scheduled', 0, '{}', 'h', 'key-1', 0, 0)`, + ); + action.run('a1'); + expect(() => action.run('a2')).toThrow(/UNIQUE/); + const event = db.prepare( + `INSERT INTO provider_events (id, workspace_id, provider_account_id, provider_event_id, kind, payload, + payload_digest, status, received_at) VALUES (?, 'ws', 'pa', 'evt-1', 'message', '{}', 'd', 'pending', 0)`, + ); + event.run('pe1'); + expect(() => event.run('pe2')).toThrow(/UNIQUE/); + }); + + it('reject unknown states and wrong types (STRICT)', () => { + const db = memory(); + seed(db); + expect(() => + db.exec(`INSERT INTO scheduled_actions (id, workspace_id, kind, state, due_at, payload, content_hash, + idempotency_key, created_at, updated_at) VALUES ('x', 'ws', 'email.send', 'sending', 0, '{}', 'h', 'k', 0, 0)`), + ).toThrow(/CHECK/); + expect(() => + db.exec(`INSERT INTO scheduled_actions (id, workspace_id, kind, state, due_at, payload, content_hash, + idempotency_key, created_at, updated_at) VALUES ('y', 'ws', 'email.send', 'scheduled', 'soon', '{}', 'h', 'k2', 0, 0)`), + ).toThrow(); + }); + + it('enforce foreign keys', () => { + const db = memory(); + expect(() => + db.exec(`INSERT INTO contacts (id, workspace_id, full_name, created_at, updated_at) VALUES ('c', 'missing', 'x', 0, 0)`), + ).toThrow(/FOREIGN KEY/); + }); +}); + +describe('audit chain', () => { + const entry = (i: number) => ({ + workspaceId: 'ws', + at: i, + actorKind: 'system' as const, + actorId: 'test', + source: 'test', + traceId: `t${i}`, + resourceKind: 'thing', + resourceId: `r${i}`, + action: 'touched', + detail: { i }, + }); + + it('is append-only and verifiable', () => { + const db = memory(); + db.transaction(() => { + for (let i = 0; i < 5; i += 1) appendAudit(db, entry(i)); + }); + expect(verifyAuditChain(db)).toEqual({ ok: true, count: 5 }); + expect(() => db.exec("UPDATE audit_events SET action = 'x' WHERE seq = 2")).toThrow(/append-only/); + expect(() => db.exec('DELETE FROM audit_events WHERE seq = 2')).toThrow(/append-only/); + }); + + it('detects tampering even if the triggers are bypassed', () => { + const db = memory(); + db.transaction(() => { + for (let i = 0; i < 3; i += 1) appendAudit(db, entry(i)); + }); + db.exec('DROP TRIGGER audit_no_update'); + db.exec("UPDATE audit_events SET detail = '{\"i\":42}' WHERE seq = 2"); + expect(verifyAuditChain(db)).toEqual({ ok: false, count: 3, brokenAtSeq: 2 }); + }); +}); + +describe('transactions', () => { + it('roll back on error, including nested savepoints', () => { + const db = memory(); + db.exec(`INSERT INTO workspaces (id, name, created_at) VALUES ('ws', 'W', 0)`); + expect(() => + db.transaction(() => { + db.exec(`UPDATE workspaces SET name = 'outer' WHERE id = 'ws'`); + db.transaction(() => db.exec(`UPDATE workspaces SET name = 'inner' WHERE id = 'ws'`)); + throw new Error('boom'); + }), + ).toThrow('boom'); + expect(db.prepare(`SELECT name FROM workspaces`).get<{ name: string }>()?.name).toBe('W'); + + db.transaction(() => { + db.exec(`UPDATE workspaces SET name = 'kept' WHERE id = 'ws'`); + expect(() => + db.transaction(() => { + db.exec(`UPDATE workspaces SET name = 'dropped' WHERE id = 'ws'`); + throw new Error('inner'); + }), + ).toThrow('inner'); + }); + expect(db.prepare(`SELECT name FROM workspaces`).get<{ name: string }>()?.name).toBe('kept'); + }); + + it('refuse async callbacks', () => { + const db = memory(); + expect(() => db.transaction(() => Promise.resolve(1))).toThrow(TransactionMisuseError); + }); + + it('serialize writers across connections to one file', () => { + const dir = mkdtempSync(join(tmpdir(), 'outreach-store-')); + dirs.push(dir); + const path = join(dir, 'outreach.db'); + const a = openSqliteDatabase(path); + const b = openSqliteDatabase(path, { busyTimeoutMs: 0 }); + open.push(a, b); + a.exec(`INSERT INTO workspaces (id, name, created_at) VALUES ('ws', 'W', 0)`); + expect(() => + a.transaction(() => { + a.exec(`UPDATE workspaces SET name = 'a' WHERE id = 'ws'`); + b.transaction(() => b.exec(`UPDATE workspaces SET name = 'b' WHERE id = 'ws'`)); + }), + ).toThrow(/locked|busy/i); + expect(b.prepare(`SELECT name FROM workspaces`).get<{ name: string }>()?.name).toBe('W'); + }); +}); diff --git a/outreach-engine/packages/outreach-store-sqlite/tsconfig.json b/outreach-engine/packages/outreach-store-sqlite/tsconfig.json new file mode 100644 index 0000000..585a92d --- /dev/null +++ b/outreach-engine/packages/outreach-store-sqlite/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist" }, + "include": ["src"] +} diff --git a/outreach-engine/packages/outreach-store-sqlite/tsup.config.ts b/outreach-engine/packages/outreach-store-sqlite/tsup.config.ts new file mode 100644 index 0000000..458d1fd --- /dev/null +++ b/outreach-engine/packages/outreach-store-sqlite/tsup.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + dts: true, + sourcemap: true, + clean: true, + target: 'node22', + // node:sqlite exists only with the protocol prefix. + removeNodeProtocol: false, +}); From fd8a75ec347c3a38606bf8090ca15842f940970d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 01:33:00 +0000 Subject: [PATCH 05/20] outreach-engine M3: durable execution core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements milestone M3 of outreach-engine/BUILD_PLAN.md (§6, ADR 0002), the part of the system that must not be rushed. @splitin/outreach-core (new), src/execution/ - enqueue.ts: enqueueAction(), the only way an external effect is created. Idempotent on (workspace, idempotency_key); freezes the payload and its canonical content hash; generates the RFC Message-ID for email kinds; audits creation. - actions-repo.ts: transitionAction(), the only way an action changes state. It enforces the contracts transition table, compares-and-sets on the expected state (a concurrent change raises StaleActionError instead of being overwritten), and appends a hash-chained audit row for every transition. - preflight.ts: claimActions() takes due actions under a short lease in one BEGIN IMMEDIATE transaction. preflight() is the last gate before any effect: in one transaction it re-validates the lease, runs the checks (kill switches, expiry, pluggable domain checks, account health, adapter capability, purpose permitted by adapter AND account, live-send allowlist gate), reserves rate budget, and commits `executing` together with a pending attempt BEFORE the provider is called. Verdicts are pass / defer / cancel / await_approval / review. - invoke.ts: calls the provider outside any transaction with a timeout. Anything an adapter throws is treated as `unknown`, never as a failure, because we cannot prove the request never left the process. - results.ts: records the outcome in its own transaction. Accepted means succeeded plus the outbound message row (or an open manual task). Rejected follows the error taxonomy: backoff with full jitter or honoured retryAfterMs, re-auth hold, max-attempts to failed, content_rejected to review. It applies account effects (unhealthy, reauth_required, provider-account kill switch) and releases rate budget for confirmed non-sends. Unknown means `uncertain`, never a retry; notifications, which cannot be reconciled, fail instead of retrying. A late definitive result for an action the sweeper already parked resolves it as reconciliation would. - recovery.ts: sweepExpiredLeases() puts expired `claimed` actions back to `scheduled` (no attempt started, so safe), and sends expired `executing`/`reconciling` actions to `uncertain`. reconcileUncertain() asks the adapter: found means succeeded, absent means retry if attempts remain, still_unknown means exponential re-check and, after 3 tries, `review`. - rate.ts: atomic fixed-window budgets (all-or-nothing inside preflight) and a per-recipient minimum gap across campaigns. - kill-switches.ts: global, workspace, provider-account and campaign switches, checked at preflight; engage and release are audited. - review.ts: human resolution of review items: sent (records success with the given provider id), not_sent_retry, or drop. - executor.ts: executeDue() and runExecutionPass() (sweep, reconcile, execute). Crash hooks at afterClaim / afterPreflight / beforeResult let tests kill the "process" at every step boundary. Manual tasks can optionally be announced to a host. - ActionEffects hooks (onSucceeded, onFailed, onCancelled, onErrorEffects) run inside the outcome transaction; the campaign domain (M4) plugs into them. Tests (29 new, 71 total) - Executor: happy path with attempt, message and a valid audit chain; not-due; enqueue idempotency; retryable backoff with budget release; retryAfterMs; max attempts; permanent recipient failure with effects; revoked auth holds the account; policy block engages the account kill switch; content rejection to review; global kill switch defer/release; expiry; allowlist gate plus review retry; purpose gate; fixed-window budget and recipient gap; uncertain notifications never retried; manual tasks. - Failure injection: timeout after accept reconciles to succeeded with no resend; timeout before accept retries only after affirmed absence; adapter crash after accept; undecidable outcomes go to review after 3 checks and are never resent; crashes after claim, after the preflight commit and after provider acceptance each recover with exactly one delivery; late results; a dead reconciler's lease is recovered. - Property test (fast-check, 60 runs): random provider behaviour (accept, transient, rate limit, bad recipient, unknown after/before accept, adapter throw) combined with random crash points over many rounds. Invariants: no action is ever delivered twice; every action ends succeeded/failed/review; succeeded holds exactly when delivered; the audit chain verifies. Mutation-checked: making reconciliation treat "found" as "absent" is caught with a minimal counterexample. - Concurrency: two workers on separate connections to one database file, 300 actions, interleaved passes: no action has two attempts and every message is delivered exactly once. A sweep never steals a live lease. Not yet covered: the plan's 4-process x 10k soak, which needs built artifacts in child processes. It is deferred to the CLI milestone (M7), where a worker binary exists. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MbDW8kfMcxUuZ3WBtaDKBv --- outreach-engine/package-lock.json | 60 +++++ outreach-engine/package.json | 1 + .../packages/outreach-core/package.json | 47 ++++ .../src/execution/actions-repo.ts | 87 +++++++ .../outreach-core/src/execution/enqueue.ts | 87 +++++++ .../src/execution/executor.test.ts | 207 ++++++++++++++++ .../outreach-core/src/execution/executor.ts | 98 ++++++++ .../src/execution/harness.test-util.ts | 125 ++++++++++ .../outreach-core/src/execution/index.ts | 11 + .../src/execution/invariants.test.ts | 103 ++++++++ .../outreach-core/src/execution/invoke.ts | 82 +++++++ .../src/execution/kill-switches.ts | 59 +++++ .../outreach-core/src/execution/preflight.ts | 159 +++++++++++++ .../outreach-core/src/execution/rate.ts | 58 +++++ .../src/execution/recovery.test.ts | 136 +++++++++++ .../outreach-core/src/execution/recovery.ts | 138 +++++++++++ .../outreach-core/src/execution/results.ts | 222 ++++++++++++++++++ .../outreach-core/src/execution/review.ts | 54 +++++ .../outreach-core/src/execution/types.ts | 163 +++++++++++++ .../packages/outreach-core/src/index.ts | 1 + .../packages/outreach-core/tsconfig.json | 5 + .../packages/outreach-core/tsup.config.ts | 12 + .../scripts/check-package-boundaries.mjs | 2 +- 23 files changed, 1916 insertions(+), 1 deletion(-) create mode 100644 outreach-engine/packages/outreach-core/package.json create mode 100644 outreach-engine/packages/outreach-core/src/execution/actions-repo.ts create mode 100644 outreach-engine/packages/outreach-core/src/execution/enqueue.ts create mode 100644 outreach-engine/packages/outreach-core/src/execution/executor.test.ts create mode 100644 outreach-engine/packages/outreach-core/src/execution/executor.ts create mode 100644 outreach-engine/packages/outreach-core/src/execution/harness.test-util.ts create mode 100644 outreach-engine/packages/outreach-core/src/execution/index.ts create mode 100644 outreach-engine/packages/outreach-core/src/execution/invariants.test.ts create mode 100644 outreach-engine/packages/outreach-core/src/execution/invoke.ts create mode 100644 outreach-engine/packages/outreach-core/src/execution/kill-switches.ts create mode 100644 outreach-engine/packages/outreach-core/src/execution/preflight.ts create mode 100644 outreach-engine/packages/outreach-core/src/execution/rate.ts create mode 100644 outreach-engine/packages/outreach-core/src/execution/recovery.test.ts create mode 100644 outreach-engine/packages/outreach-core/src/execution/recovery.ts create mode 100644 outreach-engine/packages/outreach-core/src/execution/results.ts create mode 100644 outreach-engine/packages/outreach-core/src/execution/review.ts create mode 100644 outreach-engine/packages/outreach-core/src/execution/types.ts create mode 100644 outreach-engine/packages/outreach-core/src/index.ts create mode 100644 outreach-engine/packages/outreach-core/tsconfig.json create mode 100644 outreach-engine/packages/outreach-core/tsup.config.ts diff --git a/outreach-engine/package-lock.json b/outreach-engine/package-lock.json index 1d52360..6968d2b 100644 --- a/outreach-engine/package-lock.json +++ b/outreach-engine/package-lock.json @@ -16,6 +16,7 @@ "@eslint/js": "^9.39.5", "@types/node": "^22.20.4", "eslint": "^9.39.5", + "fast-check": "^4.10.2", "tsup": "^8.5.1", "typescript": "^5.9.3", "typescript-eslint": "^8.70.1", @@ -1087,6 +1088,10 @@ "resolved": "packages/outreach-contracts", "link": true }, + "node_modules/@splitin/outreach-core": { + "resolved": "packages/outreach-core", + "link": true + }, "node_modules/@splitin/outreach-fakes": { "resolved": "packages/outreach-fakes", "link": true @@ -2069,6 +2074,29 @@ "node": ">=12.0.0" } }, + "node_modules/fast-check": { + "version": "4.10.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.10.2.tgz", + "integrity": "sha512-iK2f+YrcmoeGqk6fA0ea2bptcu/itMIm4NfEozq6N25+aG6h7s5HZbB/k1aV7b5w5sFLMCbbtRUsTVR+BgC3xw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2759,6 +2787,23 @@ "node": ">=6" } }, + "node_modules/pure-rand": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", + "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -3455,6 +3500,21 @@ "node": ">=22.13" } }, + "packages/outreach-core": { + "name": "@splitin/outreach-core", + "version": "0.0.0", + "license": "MIT", + "dependencies": { + "@splitin/outreach-contracts": "0.0.0" + }, + "devDependencies": { + "@splitin/outreach-fakes": "0.0.0", + "@splitin/outreach-store-sqlite": "0.0.0" + }, + "engines": { + "node": ">=22.13" + } + }, "packages/outreach-fakes": { "name": "@splitin/outreach-fakes", "version": "0.0.0", diff --git a/outreach-engine/package.json b/outreach-engine/package.json index fad46d4..c0b56d2 100644 --- a/outreach-engine/package.json +++ b/outreach-engine/package.json @@ -28,6 +28,7 @@ "@eslint/js": "^9.39.5", "@types/node": "^22.20.4", "eslint": "^9.39.5", + "fast-check": "^4.10.2", "tsup": "^8.5.1", "typescript": "^5.9.3", "typescript-eslint": "^8.70.1", diff --git a/outreach-engine/packages/outreach-core/package.json b/outreach-engine/packages/outreach-core/package.json new file mode 100644 index 0000000..bc127d7 --- /dev/null +++ b/outreach-engine/packages/outreach-core/package.json @@ -0,0 +1,47 @@ +{ + "name": "@splitin/outreach-core", + "version": "0.0.0", + "description": "Outreach engine core: durable action execution, campaign domain, policy and inbound processing.", + "license": "MIT", + "author": "SplitInTech", + "homepage": "https://github.com/splitintech/open-internal-tools/tree/main/outreach-engine#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/splitintech/open-internal-tools.git", + "directory": "outreach-engine/packages/outreach-core" + }, + "type": "module", + "sideEffects": false, + "engines": { + "node": ">=22.13" + }, + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "files": [ + "dist", + "README.md", + "package.json" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsup", + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@splitin/outreach-contracts": "0.0.0" + }, + "devDependencies": { + "@splitin/outreach-fakes": "0.0.0", + "@splitin/outreach-store-sqlite": "0.0.0" + } +} diff --git a/outreach-engine/packages/outreach-core/src/execution/actions-repo.ts b/outreach-engine/packages/outreach-core/src/execution/actions-repo.ts new file mode 100644 index 0000000..15df042 --- /dev/null +++ b/outreach-engine/packages/outreach-core/src/execution/actions-repo.ts @@ -0,0 +1,87 @@ +import { + appendAudit, + assertTransition, + type ActionState, + type AuditActorKind, + type SqlDatabase, + type SqlValue, +} from '@splitin/outreach-contracts'; +import type { AccountRow, ActionRow } from './types'; + +export interface Actor { + readonly kind: AuditActorKind; + readonly id: string; + readonly source: string; + readonly traceId: string; +} + +export function loadAction(db: SqlDatabase, id: string): ActionRow | undefined { + return db.prepare('SELECT * FROM scheduled_actions WHERE id = ?').get(id); +} + +export function loadAccount(db: SqlDatabase, id: string | null): AccountRow | null { + if (!id) return null; + return db.prepare('SELECT * FROM provider_accounts WHERE id = ?').get(id) ?? null; +} + +export class StaleActionError extends Error { + constructor(id: string, expected: ActionState) { + super(`Action ${id} is no longer in state ${expected}`); + this.name = 'StaleActionError'; + } +} + +type ActionPatch = Partial< + Pick< + ActionRow, + | 'due_at' + | 'lease_owner' + | 'lease_expires_at' + | 'attempt_count' + | 'reconcile_count' + | 'last_error_class' + | 'state_reason' + | 'approval_id' + > +>; + +/** + * The only way an action changes state. Enforces the transition table, compares-and-sets on the + * expected state (so a concurrent change is detected, never overwritten), and appends an audit row. + * Must run inside a transaction. + */ +export function transitionAction( + db: SqlDatabase, + action: ActionRow, + to: ActionState, + now: number, + actor: Actor, + patch: ActionPatch = {}, + detail: Record = {}, +): ActionRow { + assertTransition(action.state, to); + const columns = Object.keys(patch) as (keyof ActionPatch)[]; + const sets = ['state = ?', 'updated_at = ?', ...columns.map((column) => `${column} = ?`)]; + const values: SqlValue[] = [to, now, ...columns.map((column) => (patch[column] ?? null) as SqlValue)]; + const result = db + .prepare(`UPDATE scheduled_actions SET ${sets.join(', ')} WHERE id = ? AND state = ?`) + .run(...values, action.id, action.state); + if (result.changes !== 1) throw new StaleActionError(action.id, action.state); + appendAudit(db, { + workspaceId: action.workspace_id, + at: now, + actorKind: actor.kind, + actorId: actor.id, + source: actor.source, + traceId: actor.traceId, + resourceKind: 'action', + resourceId: action.id, + action: `${action.state}->${to}`, + detail: { ...detail, ...(patch.state_reason ? { reason: patch.state_reason } : {}) }, + }); + return { ...action, ...patch, state: to, updated_at: now }; +} + +export function workerActor(workerId: string, traceId: string): Actor { + return { kind: 'worker', id: workerId, source: 'worker', traceId }; +} diff --git a/outreach-engine/packages/outreach-core/src/execution/enqueue.ts b/outreach-engine/packages/outreach-core/src/execution/enqueue.ts new file mode 100644 index 0000000..cacc4d1 --- /dev/null +++ b/outreach-engine/packages/outreach-core/src/execution/enqueue.ts @@ -0,0 +1,87 @@ +import { appendAudit, digestCanonical, ulid, type ActionKind, type SqlDatabase } from '@splitin/outreach-contracts'; +import type { Actor } from './actions-repo'; +import type { ActionRow } from './types'; + +export interface EnqueueInput { + readonly workspaceId: string; + readonly kind: ActionKind; + readonly payload: unknown; + readonly idempotencyKey: string; + readonly dueAt: number; + readonly providerAccountId?: string | null; + readonly purpose?: string | null; + readonly recipient?: string | null; + readonly enrollmentId?: string | null; + readonly campaignId?: string | null; + readonly stepId?: string | null; + readonly contactPointId?: string | null; + readonly notAfter?: number | null; + readonly maxAttempts?: number; + /** Email kinds: the Message-ID we will set; generated if absent. */ + readonly rfcMessageId?: string | null; + readonly senderDomain?: string; + readonly awaitingApproval?: boolean; + readonly approvalId?: string | null; +} + +export type EnqueueResult = { created: true; action: ActionRow } | { created: false; action: ActionRow }; + +/** + * Creates a durable action. Every external effect starts here. Idempotent on (workspace, idempotency key): + * enqueueing the same logical action twice returns the existing row. Must run inside a transaction. + */ +export function enqueueAction(db: SqlDatabase, input: EnqueueInput, actor: Actor, now: number): EnqueueResult { + const existing = db + .prepare('SELECT * FROM scheduled_actions WHERE workspace_id = ? AND idempotency_key = ?') + .get(input.workspaceId, input.idempotencyKey); + if (existing) return { created: false, action: existing }; + + const id = ulid(now); + const isEmail = input.kind === 'email.send' || input.kind === 'email.reply'; + const rfcMessageId = isEmail ? (input.rfcMessageId ?? `<${id.toLowerCase()}@${input.senderDomain ?? 'outreach.invalid'}>`) : null; + const payload = JSON.stringify(input.payload); + const state = input.awaitingApproval ? 'awaiting_approval' : 'scheduled'; + db.prepare( + `INSERT INTO scheduled_actions (id, workspace_id, enrollment_id, campaign_id, step_id, contact_point_id, kind, + purpose, provider_account_id, recipient_norm, state, due_at, not_after, payload, content_hash, idempotency_key, + rfc_message_id, approval_id, max_attempts, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + ).run( + id, + input.workspaceId, + input.enrollmentId ?? null, + input.campaignId ?? null, + input.stepId ?? null, + input.contactPointId ?? null, + input.kind, + input.purpose ?? null, + input.providerAccountId ?? null, + input.recipient?.toLowerCase() ?? null, + state, + input.dueAt, + input.notAfter ?? null, + payload, + digestCanonical(input.payload), + input.idempotencyKey, + rfcMessageId, + input.approvalId ?? null, + input.maxAttempts ?? 5, + now, + now, + ); + appendAudit(db, { + workspaceId: input.workspaceId, + at: now, + actorKind: actor.kind, + actorId: actor.id, + source: actor.source, + traceId: actor.traceId, + resourceKind: 'action', + resourceId: id, + action: `created:${state}`, + detail: { kind: input.kind, dueAt: input.dueAt, enrollmentId: input.enrollmentId ?? null }, + }); + const action = db.prepare('SELECT * FROM scheduled_actions WHERE id = ?').get(id); + if (!action) throw new Error(`Action ${id} vanished after insert`); + return { created: true, action }; +} diff --git a/outreach-engine/packages/outreach-core/src/execution/executor.test.ts b/outreach-engine/packages/outreach-core/src/execution/executor.test.ts new file mode 100644 index 0000000..bb83bf1 --- /dev/null +++ b/outreach-engine/packages/outreach-core/src/execution/executor.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it, vi } from 'vitest'; +import { verifyAuditChain } from '@splitin/outreach-contracts'; +import { workerActor } from './actions-repo'; +import { enqueueAction } from './enqueue'; +import { executeDue, runExecutionPass } from './executor'; +import { EMAIL_ACCOUNT, NOTIFY_ACCOUNT, WS, attempts, makeEnv } from './harness.test-util'; +import { setKillSwitch } from './kill-switches'; +import { resolveReviewAction } from './review'; + +describe('executor happy path', () => { + it('sends once, records the attempt, the outbound message and a valid audit chain', async () => { + const env = makeEnv(); + const action = env.enqueueEmail('k1'); + const report = await executeDue(env.deps); + expect(report).toMatchObject({ claimed: 1, executed: 1 }); + expect(env.action(action.id)).toMatchObject({ state: 'succeeded', attempt_count: 1, lease_owner: null }); + expect(env.fake.deliveries).toHaveLength(1); + expect(env.fake.deliveries[0]?.rfcMessageId).toBe(action.rfc_message_id); + expect(attempts(env.db, action.id)).toEqual([{ outcome: 'succeeded', error_class: null }]); + const message = env.db.prepare('SELECT * FROM messages WHERE action_id = ?').get<{ direction: string; recipient_norm: string }>(action.id); + expect(message).toMatchObject({ direction: 'outbound', recipient_norm: 'lead@example.org' }); + expect(verifyAuditChain(env.db).ok).toBe(true); + }); + + it('does not touch actions that are not due', async () => { + const env = makeEnv(); + const action = env.enqueueEmail('k1', { dueAt: env.now() + 60_000 }); + expect((await executeDue(env.deps)).claimed).toBe(0); + env.advance(60_000); + await executeDue(env.deps); + expect(env.action(action.id).state).toBe('succeeded'); + }); + + it('is idempotent on enqueue', () => { + const env = makeEnv(); + const a = env.enqueueEmail('same'); + const b = env.enqueueEmail('same', { payload: { different: true } }); + expect(b.id).toBe(a.id); + expect(env.db.prepare('SELECT COUNT(*) AS n FROM scheduled_actions').get<{ n: number }>()?.n).toBe(1); + }); +}); + +describe('rejections', () => { + it('retries retryable errors with backoff and releases the budget', async () => { + const env = makeEnv(); + env.fake.script({ kind: 'reject', errorClass: 'transient' }); + const action = env.enqueueEmail('k1'); + const deps = env.with({ ratePolicy: () => ({ limits: [{ scopeKey: 'account:day', windowMs: 86_400_000, limit: 10 }] }) }); + await executeDue(deps); + const after = env.action(action.id); + expect(after).toMatchObject({ state: 'scheduled', last_error_class: 'transient', attempt_count: 1 }); + expect(after.due_at).toBeGreaterThan(env.now()); + expect(env.db.prepare('SELECT used FROM rate_buckets').get<{ used: number }>()?.used).toBe(0); + env.advance(after.due_at - env.now()); + await executeDue(deps); + expect(env.action(action.id).state).toBe('succeeded'); + expect(env.fake.deliveries).toHaveLength(1); + expect(env.db.prepare('SELECT used FROM rate_buckets').get<{ used: number }>()?.used).toBe(1); + }); + + it('honours retryAfterMs from rate limits', async () => { + const env = makeEnv(); + env.fake.script({ kind: 'reject', errorClass: 'rate_limited', retryAfterMs: 90_000 }); + const action = env.enqueueEmail('k1'); + await executeDue(env.deps); + expect(env.action(action.id).due_at).toBe(env.now() + 90_000); + }); + + it('fails after max attempts', async () => { + const env = makeEnv(); + env.fake.setDefault({ kind: 'reject', errorClass: 'transient' }); + const action = env.enqueueEmail('k1', { maxAttempts: 2 }); + for (let i = 0; i < 3; i += 1) { + await executeDue(env.deps); + env.advance(20 * 60_000); + } + expect(env.action(action.id)).toMatchObject({ state: 'failed', state_reason: 'max_attempts', attempt_count: 2 }); + }); + + it('fails permanently on a bad recipient and reports domain effects', async () => { + const env = makeEnv(); + const onErrorEffects = vi.fn(); + const onFailed = vi.fn(); + env.fake.script({ kind: 'reject', errorClass: 'invalid_recipient' }); + const action = env.enqueueEmail('k1'); + await executeDue(env.with({ effects: { onErrorEffects, onFailed } })); + expect(env.action(action.id)).toMatchObject({ state: 'failed', state_reason: 'invalid_recipient' }); + expect(onErrorEffects).toHaveBeenCalledWith(env.db, expect.objectContaining({ id: action.id }), ['suppress_recipient', 'bounce_enrollment'], env.now()); + expect(onFailed).toHaveBeenCalledOnce(); + }); + + it('marks the account unhealthy on revoked auth and holds further sends', async () => { + const env = makeEnv(); + env.fake.script({ kind: 'reject', errorClass: 'auth_revoked' }); + const first = env.enqueueEmail('k1'); + await executeDue(env.deps); + expect(env.action(first.id).state).toBe('failed'); + const second = env.enqueueEmail('k2'); + await executeDue(env.deps); + expect(env.action(second.id)).toMatchObject({ state: 'scheduled', state_reason: 'account_unhealthy' }); + expect(env.fake.sendCalls).toBe(1); + }); + + it('engages the account kill switch when the provider blocks for policy', async () => { + const env = makeEnv(); + env.fake.script({ kind: 'reject', errorClass: 'policy_blocked' }); + env.enqueueEmail('k1'); + await executeDue(env.deps); + const second = env.enqueueEmail('k2'); + await executeDue(env.deps); + expect(env.action(second.id).state_reason).toBe('kill_switch:provider_account'); + }); + + it('sends content rejections to review', async () => { + const env = makeEnv(); + env.fake.script({ kind: 'reject', errorClass: 'content_rejected' }); + const action = env.enqueueEmail('k1'); + await executeDue(env.deps); + expect(env.action(action.id).state).toBe('review'); + }); +}); + +describe('gates', () => { + it('defers while a kill switch is engaged and sends after release', async () => { + const env = makeEnv(); + const actor = workerActor('admin', 'test'); + env.db.transaction(() => setKillSwitch(env.db, { workspaceId: WS, scope: 'global', targetId: '*', engaged: true, reason: 'incident' }, actor, env.now())); + const action = env.enqueueEmail('k1'); + await executeDue(env.deps); + expect(env.action(action.id)).toMatchObject({ state: 'scheduled', state_reason: 'kill_switch:global' }); + env.db.transaction(() => setKillSwitch(env.db, { workspaceId: WS, scope: 'global', targetId: '*', engaged: false, reason: 'resolved' }, actor, env.now())); + env.advance(60_000); + await executeDue(env.deps); + expect(env.action(action.id).state).toBe('succeeded'); + }); + + it('cancels expired actions', async () => { + const env = makeEnv(); + const action = env.enqueueEmail('k1', { notAfter: env.now() - 1 }); + await executeDue(env.deps); + expect(env.action(action.id)).toMatchObject({ state: 'cancelled', state_reason: 'expired' }); + expect(env.fake.sendCalls).toBe(0); + }); + + it('parks non-allowlisted recipients for review until the gate opens', async () => { + const env = makeEnv(); + const gated = env.with({ sendGate: { mode: 'allowlist', allow: ['@example.com', 'vip@example.org'] } }); + const blocked = env.enqueueEmail('k1'); + const allowed = env.enqueueEmail('k2', { recipient: 'vip@example.org' }); + await executeDue(gated); + expect(env.action(blocked.id)).toMatchObject({ state: 'review', state_reason: 'not_in_live_allowlist' }); + expect(env.action(allowed.id).state).toBe('succeeded'); + resolveReviewAction(env.deps, blocked.id, { kind: 'not_sent_retry' }, workerActor('admin', 'test')); + await executeDue(env.deps); + expect(env.action(blocked.id).state).toBe('succeeded'); + }); + + it('sends purposes the account does not permit to review', async () => { + const env = makeEnv(); + const action = env.enqueueEmail('k1', { purpose: 'marketing' }); + await executeDue(env.deps); + expect(env.action(action.id)).toMatchObject({ state: 'review', state_reason: 'purpose_not_permitted:marketing' }); + }); + + it('enforces fixed-window budgets and the per-recipient gap', async () => { + const env = makeEnv(); + const day = 86_400_000; + const deps = env.with({ + ratePolicy: () => ({ limits: [{ scopeKey: 'account:day', windowMs: day, limit: 2 }], recipientMinGapMs: 0 }), + }); + const ids = ['a', 'b', 'c'].map((k) => env.enqueueEmail(k, { recipient: `${k}@example.org` }).id); + await executeDue(deps); + expect(ids.map((id) => env.action(id).state)).toEqual(['succeeded', 'succeeded', 'scheduled']); + expect(env.action(ids[2] ?? '').due_at).toBe(Math.floor(env.now() / day) * day + day); + + const gapDeps = env.with({ ratePolicy: () => ({ limits: [], recipientMinGapMs: 3 * day }) }); + const repeat = env.enqueueEmail('again', { recipient: 'a@example.org' }); + await executeDue(gapDeps); + expect(env.action(repeat.id)).toMatchObject({ state: 'scheduled', state_reason: 'recipient_min_gap' }); + }); +}); + +describe('other kinds', () => { + it('never retries an uncertain notification', async () => { + const env = makeEnv(); + env.notifier.script({ kind: 'unknown_after_accept' }); + const note = env.db.transaction(() => + enqueueAction(env.db, { workspaceId: WS, kind: 'notify.publish', providerAccountId: NOTIFY_ACCOUNT, idempotencyKey: 'n1', dueAt: env.now(), payload: { title: 'Reply', lines: ['x'], severity: 'info' } }, workerActor('t', 't'), env.now()), + ).action; + await runExecutionPass(env.deps); + await runExecutionPass(env.deps); + expect(env.action(note.id)).toMatchObject({ state: 'failed', state_reason: 'uncertain_not_reconcilable' }); + expect(env.notifier.published).toHaveLength(1); + }); + + it('turns manual steps into open tasks without any provider call', async () => { + const env = makeEnv(); + const task = env.db.transaction(() => + enqueueAction(env.db, { workspaceId: WS, kind: 'manual.task', idempotencyKey: 'm1', dueAt: env.now(), payload: { channel: 'linkedin', targetUrl: 'https://www.linkedin.com/in/example', draft: 'Hi' } }, workerActor('t', 't'), env.now()), + ).action; + await executeDue(env.deps); + expect(env.action(task.id).state).toBe('succeeded'); + expect(env.db.prepare('SELECT status, channel FROM manual_tasks WHERE action_id = ?').get(task.id)).toEqual({ status: 'open', channel: 'linkedin' }); + expect(env.fake.sendCalls).toBe(0); + expect(EMAIL_ACCOUNT).toBeTruthy(); + }); +}); diff --git a/outreach-engine/packages/outreach-core/src/execution/executor.ts b/outreach-engine/packages/outreach-core/src/execution/executor.ts new file mode 100644 index 0000000..e84d0c4 --- /dev/null +++ b/outreach-engine/packages/outreach-core/src/execution/executor.ts @@ -0,0 +1,98 @@ +import { ulid } from '@splitin/outreach-contracts'; +import { loadAccount, workerActor } from './actions-repo'; +import { invokeProvider } from './invoke'; +import { claimActions, preflight } from './preflight'; +import { reconcileUncertain, sweepExpiredLeases, type ReconcileReport, type SweepReport } from './recovery'; +import { recordResult } from './results'; +import { resolveConfig, type ExecutionDeps } from './types'; + +export interface ExecuteReport { + readonly claimed: number; + readonly executed: number; + readonly stopped: number; + readonly lost: number; +} + +/** + * One executor pass: claim due actions, then for each run preflight (commits `executing`), call the + * provider outside any transaction, and record the result. Repeats until nothing is due or `maxActions`. + */ +export async function executeDue(deps: ExecutionDeps, maxActions = 500): Promise { + const config = resolveConfig(deps); + const traceId = ulid(); + const actor = workerActor(deps.workerId, traceId); + let claimedTotal = 0; + let executed = 0; + let stopped = 0; + let lost = 0; + + while (claimedTotal < maxActions) { + const ids = claimActions(deps, actor, Math.min(config.claimBatch, maxActions - claimedTotal)); + if (ids.length === 0) break; + claimedTotal += ids.length; + for (const id of ids) { + deps.hooks?.afterClaim?.(id); + const outcome = preflight(deps, id, actor); + if (outcome.kind === 'lost') { + lost += 1; + continue; + } + if (outcome.kind === 'stopped') { + stopped += 1; + continue; + } + deps.hooks?.afterPreflight?.(id); + const account = loadAccount(deps.db, outcome.action.provider_account_id); + const adapter = account ? (deps.adapters.get(account.provider) ?? null) : null; + const result = await invokeProvider(deps, outcome.action, account, adapter, traceId); + deps.hooks?.beforeResult?.(id); + recordResult(deps, id, outcome.attemptId, result, actor); + if (outcome.action.kind === 'manual.task' && deps.manual) { + await announceManualTask(deps, outcome.action.id); + } + executed += 1; + } + } + return { claimed: claimedTotal, executed, stopped, lost }; +} + +async function announceManualTask(deps: ExecutionDeps, actionId: string): Promise { + const task = deps.db + .prepare('SELECT id, channel, target_url, draft_text, workspace_id FROM manual_tasks WHERE action_id = ?') + .get<{ id: string; channel: string; target_url: string | null; draft_text: string; workspace_id: string }>(actionId); + if (!task || !deps.manual) return; + try { + const ctx = { + workspaceId: task.workspace_id, + account: { id: 'manual', provider: 'manual', externalAccountId: 'manual', sender: { name: '', address: '' } }, + secretRef: '', + secrets: deps.secrets, + traceId: ulid(), + signal: AbortSignal.timeout(resolveConfig(deps).providerTimeoutMs), + now: deps.now, + }; + await deps.manual.prepare(ctx, { + taskId: task.id, + channel: task.channel, + draft: task.draft_text, + ...(task.target_url ? { targetUrl: task.target_url } : {}), + }); + } catch { + // Announcing is best effort; the task already exists and is visible in the queue. + } +} + +export interface ExecutionPassReport { + readonly sweep: SweepReport; + readonly reconcile: ReconcileReport; + readonly execute: ExecuteReport; +} + +/** Sweep dead leases, reconcile uncertain actions, then execute due ones. */ +export async function runExecutionPass(deps: ExecutionDeps): Promise { + const actor = workerActor(deps.workerId, ulid()); + const sweep = sweepExpiredLeases(deps, actor); + const reconcile = await reconcileUncertain(deps, actor); + const execute = await executeDue(deps); + return { sweep, reconcile, execute }; +} diff --git a/outreach-engine/packages/outreach-core/src/execution/harness.test-util.ts b/outreach-engine/packages/outreach-core/src/execution/harness.test-util.ts new file mode 100644 index 0000000..10dc2c6 --- /dev/null +++ b/outreach-engine/packages/outreach-core/src/execution/harness.test-util.ts @@ -0,0 +1,125 @@ +import type { SqlDatabase } from '@splitin/outreach-contracts'; +import { + FAKE_EMAIL_SECRET, + FakeEmailProvider, + FakeNotifier, + staticSecrets, +} from '@splitin/outreach-fakes'; +import { openSqliteDatabase } from '@splitin/outreach-store-sqlite'; +import { workerActor } from './actions-repo'; +import { enqueueAction, type EnqueueInput } from './enqueue'; +import type { ActionRow, ExecutionDeps } from './types'; + +export const T0 = 1_700_000_000_000; +export const WS = 'ws'; +export const EMAIL_ACCOUNT = 'acct-email'; +export const NOTIFY_ACCOUNT = 'acct-notify'; + +export function seedWorkspace(db: SqlDatabase, purposes: string[] = ['automated_outreach']): void { + db.prepare(`INSERT INTO workspaces (id, name, created_at) VALUES (?, 'Test', 0)`).run(WS); + const insertAccount = db.prepare( + `INSERT INTO provider_accounts (id, workspace_id, provider, external_account_id, sender_identity, purposes, secret_ref, + webhook_secret_ref) VALUES (?,?,?,?,?,?,?,?)`, + ); + insertAccount.run( + EMAIL_ACCOUNT, + WS, + 'fake-email', + 'ext-email', + JSON.stringify({ name: 'Sam Sender', address: 'sender@example.com', organization: 'Example Co', postalAddress: '1 Example St' }), + JSON.stringify(purposes), + 'env:FAKE_EMAIL', + 'env:FAKE_WEBHOOK', + ); + insertAccount.run(NOTIFY_ACCOUNT, WS, 'fake-notify', 'ext-notify', JSON.stringify({ name: 'Ops', address: 'ops@example.com' }), '["transactional"]', 'env:FAKE_EMAIL', null); +} + +export interface TestEnv { + readonly db: SqlDatabase; + readonly fake: FakeEmailProvider; + readonly notifier: FakeNotifier; + readonly deps: ExecutionDeps; + now(): number; + advance(ms: number): void; + with(overrides: Partial): ExecutionDeps; + enqueueEmail(key: string, overrides?: Partial): ActionRow; + action(id: string): ActionRow; +} + +export function makeEnv(options: { db?: SqlDatabase; seed?: boolean; fake?: FakeEmailProvider } = {}): TestEnv { + const db = options.db ?? openSqliteDatabase(':memory:'); + if (options.seed ?? true) seedWorkspace(db); + const fake = options.fake ?? new FakeEmailProvider(); + const notifier = new FakeNotifier(); + let now = T0; + const deps: ExecutionDeps = { + db, + adapters: new Map([ + [fake.name, fake.adapter()], + [notifier.name, notifier.adapter()], + ]), + secrets: staticSecrets({ 'env:FAKE_EMAIL': FAKE_EMAIL_SECRET, 'env:FAKE_WEBHOOK': 'fake-webhook-secret-value' }), + now: () => now, + workerId: 'worker-1', + sendGate: { mode: 'open' }, + config: { reconcileDelayMs: 0 }, + random: () => 0.5, + }; + const env: TestEnv = { + db, + fake, + notifier, + deps, + now: () => now, + advance: (ms) => { + now += ms; + }, + with: (overrides) => ({ ...deps, ...overrides }), + enqueueEmail: (key, overrides = {}) => + db.transaction( + () => + enqueueAction( + db, + { + workspaceId: WS, + kind: 'email.send', + providerAccountId: EMAIL_ACCOUNT, + purpose: 'automated_outreach', + recipient: 'lead@example.org', + idempotencyKey: key, + dueAt: now, + senderDomain: 'example.com', + payload: { + from: { address: 'sender@example.com', name: 'Sam Sender' }, + to: [{ address: 'lead@example.org' }], + subject: `Hello ${key}`, + text: 'Hi there', + headers: {}, + }, + ...overrides, + }, + workerActor('test', 'setup'), + now, + ).action, + ), + action: (id) => { + const row = db.prepare('SELECT * FROM scheduled_actions WHERE id = ?').get(id); + if (!row) throw new Error(`no action ${id}`); + return row; + }, + }; + return env; +} + +export class SimulatedCrash extends Error { + constructor(at: string) { + super(`simulated crash at ${at}`); + this.name = 'SimulatedCrash'; + } +} + +export function attempts(db: SqlDatabase, actionId: string): { outcome: string; error_class: string | null }[] { + return db + .prepare('SELECT outcome, error_class FROM action_attempts WHERE action_id = ? ORDER BY attempt_no') + .all<{ outcome: string; error_class: string | null }>(actionId); +} diff --git a/outreach-engine/packages/outreach-core/src/execution/index.ts b/outreach-engine/packages/outreach-core/src/execution/index.ts new file mode 100644 index 0000000..6ab99f5 --- /dev/null +++ b/outreach-engine/packages/outreach-core/src/execution/index.ts @@ -0,0 +1,11 @@ +export * from './types'; +export { loadAction, loadAccount, transitionAction, workerActor, StaleActionError, type Actor } from './actions-repo'; +export { enqueueAction, type EnqueueInput, type EnqueueResult } from './enqueue'; +export { setKillSwitch, engagedKillSwitch, type KillSwitchChange, type KillSwitchScope } from './kill-switches'; +export { reserveBudget, releaseBudget, type ReservationResult } from './rate'; +export { claimActions, preflight, recipientAllowed, type PreflightOutcome } from './preflight'; +export { invokeProvider, providerContext, toApprovedEmail, type EmailPayload, type NotifyPayload, type ManualPayload } from './invoke'; +export { recordResult, completeSuccess, confirmAbsent, backoffMs } from './results'; +export { sweepExpiredLeases, reconcileUncertain, type SweepReport, type ReconcileReport } from './recovery'; +export { executeDue, runExecutionPass, type ExecuteReport, type ExecutionPassReport } from './executor'; +export { resolveReviewAction, ReviewStateError, type ReviewResolution } from './review'; diff --git a/outreach-engine/packages/outreach-core/src/execution/invariants.test.ts b/outreach-engine/packages/outreach-core/src/execution/invariants.test.ts new file mode 100644 index 0000000..735294d --- /dev/null +++ b/outreach-engine/packages/outreach-core/src/execution/invariants.test.ts @@ -0,0 +1,103 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import fc from 'fast-check'; +import { describe, expect, it } from 'vitest'; +import { verifyAuditChain } from '@splitin/outreach-contracts'; +import type { FakeSendMode } from '@splitin/outreach-fakes'; +import { openSqliteDatabase } from '@splitin/outreach-store-sqlite'; +import { sweepExpiredLeases } from './recovery'; +import { workerActor } from './actions-repo'; +import { executeDue, runExecutionPass } from './executor'; +import { SimulatedCrash, makeEnv, seedWorkspace } from './harness.test-util'; + +const modeArb: fc.Arbitrary = fc.oneof( + fc.constant({ kind: 'accept' }), + fc.constant({ kind: 'reject', errorClass: 'transient' }), + fc.constant({ kind: 'reject', errorClass: 'rate_limited', retryAfterMs: 5_000 }), + fc.constant({ kind: 'reject', errorClass: 'invalid_recipient' }), + fc.constant({ kind: 'unknown_after_accept' }), + fc.constant({ kind: 'unknown_before_accept' }), + fc.constant({ kind: 'throw_after_accept' }), +); +const crashArb = fc.constantFrom<'none' | 'afterClaim' | 'afterPreflight' | 'beforeResult'>('none', 'none', 'afterClaim', 'afterPreflight', 'beforeResult'); + +describe('at-most-once invariant (ADR 0002)', () => { + it('never delivers any action twice, whatever the provider does and wherever the worker dies', async () => { + await fc.assert( + fc.asyncProperty( + fc.integer({ min: 1, max: 6 }), + fc.array(modeArb, { minLength: 1, maxLength: 30 }), + fc.array(crashArb, { minLength: 1, maxLength: 12 }), + async (actionCount, modes, crashes) => { + const env = makeEnv(); + env.fake.script(...modes); + const ids = Array.from({ length: actionCount }, (_, i) => env.enqueueEmail(`k${i}`, { maxAttempts: 4 }).id); + for (let round = 0; round < 25; round += 1) { + const crash = crashes[round % crashes.length] ?? 'none'; + const deps = crash === 'none' ? env.deps : env.with({ hooks: { [crash]: () => { throw new SimulatedCrash(crash); } } }); + try { + await runExecutionPass(deps); + } catch (error) { + if (!(error instanceof SimulatedCrash)) throw error; + } + env.advance(11 * 60_000); + } + // Let the system settle without crashes. + for (let round = 0; round < 10; round += 1) { + await runExecutionPass(env.deps); + env.advance(20 * 60_000); + } + for (const id of ids) { + const action = env.action(id); + const delivered = env.fake.deliveriesFor(action.rfc_message_id ?? '').length; + expect(delivered).toBeLessThanOrEqual(1); + expect(['succeeded', 'failed', 'review']).toContain(action.state); + // With an authoritative reconciler, "succeeded" is exactly "delivered". + expect(action.state === 'succeeded').toBe(delivered === 1); + } + expect(verifyAuditChain(env.db).ok).toBe(true); + env.db.close(); + }, + ), + { numRuns: 60 }, + ); + }); +}); + +describe('concurrent workers on one database file', () => { + it('two workers with separate connections never attempt the same action twice', async () => { + const dir = mkdtempSync(join(tmpdir(), 'outreach-core-')); + try { + const path = join(dir, 'outreach.db'); + const first = makeEnv({ db: openSqliteDatabase(path) }); + const secondDb = openSqliteDatabase(path); + const second = makeEnv({ db: secondDb, seed: false, fake: first.fake }); + const ids = Array.from({ length: 300 }, (_, i) => first.enqueueEmail(`k${i}`, { recipient: `r${i}@example.org` }).id); + const workerTwo = second.with({ workerId: 'worker-2', now: first.now }); + await Promise.all([executeDue(first.deps), executeDue(workerTwo), executeDue(first.deps), executeDue(workerTwo)]); + const perAction = first.db + .prepare('SELECT action_id, COUNT(*) AS n FROM action_attempts GROUP BY action_id HAVING n > 1') + .all(); + expect(perAction).toEqual([]); + expect(ids.every((id) => first.action(id).state === 'succeeded')).toBe(true); + expect(first.fake.deliveries).toHaveLength(300); + expect(new Set(first.fake.deliveries.map((d) => d.rfcMessageId)).size).toBe(300); + first.db.close(); + secondDb.close(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('a sweep never steals a live lease', async () => { + const env = makeEnv(); + env.enqueueEmail('k1'); + const actor = workerActor('w', 't'); + await expect( + executeDue(env.with({ hooks: { afterPreflight: () => { throw new SimulatedCrash('x'); } } })), + ).rejects.toThrow(SimulatedCrash); + expect(sweepExpiredLeases(env.deps, actor)).toEqual({ released: 0, madeUncertain: 0 }); + expect(seedWorkspace).toBeTypeOf('function'); + }); +}); diff --git a/outreach-engine/packages/outreach-core/src/execution/invoke.ts b/outreach-engine/packages/outreach-core/src/execution/invoke.ts new file mode 100644 index 0000000..eea25b1 --- /dev/null +++ b/outreach-engine/packages/outreach-core/src/execution/invoke.ts @@ -0,0 +1,82 @@ +import type { + ApprovedEmail, + EmailContent, + Notification, + ProviderAdapter, + ProviderContext, + SenderIdentity, + SendResult, +} from '@splitin/outreach-contracts'; +import type { AccountRow, ActionRow, ExecutionDeps } from './types'; +import { resolveConfig } from './types'; + +export type EmailPayload = EmailContent; +export type NotifyPayload = Notification; +export interface ManualPayload { + readonly channel: string; + readonly targetUrl?: string; + readonly draft: string; +} + +export function providerContext( + deps: ExecutionDeps, + account: AccountRow, + traceId: string, + signal: AbortSignal, +): ProviderContext { + return { + workspaceId: account.workspace_id, + account: { + id: account.id, + provider: account.provider, + externalAccountId: account.external_account_id, + sender: JSON.parse(account.sender_identity) as SenderIdentity, + }, + secretRef: account.secret_ref, + secrets: deps.secrets, + traceId, + signal, + now: deps.now, + }; +} + +export function toApprovedEmail(action: ActionRow): ApprovedEmail { + const payload = JSON.parse(action.payload) as EmailPayload; + return { + ...payload, + actionId: action.id, + idempotencyKey: action.idempotency_key, + rfcMessageId: action.rfc_message_id ?? `<${action.id}@outreach.invalid>`, + contentHash: action.content_hash, + }; +} + +/** + * Performs the external effect for an `executing` action. Anything thrown by an adapter is treated as + * `unknown`: we cannot prove the request never left the process, so it must be reconciled, not retried. + */ +export async function invokeProvider( + deps: ExecutionDeps, + action: ActionRow, + account: AccountRow | null, + adapter: ProviderAdapter | null, + traceId: string, +): Promise { + if (action.kind === 'manual.task') { + return { kind: 'accepted', receipt: { providerMessageId: `manual:${action.id}`, acceptedAt: deps.now() } }; + } + if (!account || !adapter) return { kind: 'rejected', errorClass: 'unsupported', detail: 'no adapter' }; + const signal = AbortSignal.timeout(resolveConfig(deps).providerTimeoutMs); + const ctx = providerContext(deps, account, traceId, signal); + try { + if (action.kind === 'notify.publish') { + if (!adapter.notify) return { kind: 'rejected', errorClass: 'unsupported', detail: 'no notify port' }; + const payload = JSON.parse(action.payload) as NotifyPayload; + return await adapter.notify.publish(ctx, { ...payload, idempotencyKey: action.idempotency_key }); + } + if (!adapter.email) return { kind: 'rejected', errorClass: 'unsupported', detail: 'no email port' }; + return await adapter.email.send(ctx, toApprovedEmail(action)); + } catch (error) { + return { kind: 'unknown', detail: `adapter threw: ${(error as Error).message}`.slice(0, 300) }; + } +} diff --git a/outreach-engine/packages/outreach-core/src/execution/kill-switches.ts b/outreach-engine/packages/outreach-core/src/execution/kill-switches.ts new file mode 100644 index 0000000..fe659a2 --- /dev/null +++ b/outreach-engine/packages/outreach-core/src/execution/kill-switches.ts @@ -0,0 +1,59 @@ +import { appendAudit, type SqlDatabase } from '@splitin/outreach-contracts'; +import type { Actor } from './actions-repo'; + +export type KillSwitchScope = 'global' | 'workspace' | 'provider_account' | 'campaign'; + +export interface KillSwitchChange { + readonly workspaceId: string; + readonly scope: KillSwitchScope; + /** '*' for global and workspace scopes. */ + readonly targetId: string; + readonly engaged: boolean; + readonly reason: string; +} + +/** Engage or release a kill switch. Must run inside a transaction. The global switch uses workspace '*'. */ +export function setKillSwitch(db: SqlDatabase, change: KillSwitchChange, actor: Actor, now: number): void { + const workspaceId = change.scope === 'global' ? '*' : change.workspaceId; + const targetId = change.scope === 'global' || change.scope === 'workspace' ? '*' : change.targetId; + db.prepare( + `INSERT INTO kill_switches (workspace_id, scope, target_id, engaged, reason, changed_by, changed_at) + VALUES (?,?,?,?,?,?,?) + ON CONFLICT (workspace_id, scope, target_id) + DO UPDATE SET engaged = excluded.engaged, reason = excluded.reason, + changed_by = excluded.changed_by, changed_at = excluded.changed_at`, + ).run(workspaceId, change.scope, targetId, change.engaged ? 1 : 0, change.reason, actor.id, now); + appendAudit(db, { + workspaceId: change.workspaceId, + at: now, + actorKind: actor.kind, + actorId: actor.id, + source: actor.source, + traceId: actor.traceId, + resourceKind: 'kill_switch', + resourceId: `${change.scope}:${targetId}`, + action: change.engaged ? 'engaged' : 'released', + detail: { reason: change.reason }, + }); +} + +/** Returns the first engaged switch that covers this action, or null. */ +export function engagedKillSwitch( + db: SqlDatabase, + workspaceId: string, + providerAccountId: string | null, + campaignId: string | null, +): string | null { + const row = db + .prepare( + `SELECT scope FROM kill_switches WHERE engaged = 1 AND ( + (scope = 'global') OR + (scope = 'workspace' AND workspace_id = ?) OR + (scope = 'provider_account' AND workspace_id = ? AND target_id = ?) OR + (scope = 'campaign' AND workspace_id = ? AND target_id = ?) + ) ORDER BY CASE scope WHEN 'global' THEN 0 WHEN 'workspace' THEN 1 WHEN 'provider_account' THEN 2 ELSE 3 END + LIMIT 1`, + ) + .get<{ scope: string }>(workspaceId, workspaceId, providerAccountId ?? '', workspaceId, campaignId ?? ''); + return row?.scope ?? null; +} diff --git a/outreach-engine/packages/outreach-core/src/execution/preflight.ts b/outreach-engine/packages/outreach-core/src/execution/preflight.ts new file mode 100644 index 0000000..f6de507 --- /dev/null +++ b/outreach-engine/packages/outreach-core/src/execution/preflight.ts @@ -0,0 +1,159 @@ +import { purposePermitted, ulid, type ProviderPurpose, type SqlDatabase } from '@splitin/outreach-contracts'; +import { loadAccount, loadAction, transitionAction, type Actor } from './actions-repo'; +import { engagedKillSwitch } from './kill-switches'; +import { reserveBudget } from './rate'; +import { + resolveConfig, + type ActionRow, + type ExecutionDeps, + type PreflightCheck, + type PreflightInput, + type PreflightVerdict, + type Reservation, + type SendGate, +} from './types'; + +export type PreflightOutcome = + | { kind: 'go'; action: ActionRow; attemptId: string; attemptNo: number; reservations: Reservation[] } + | { kind: 'stopped'; verdict: PreflightVerdict['kind'] } + /** The lease was lost or the action changed underneath us; another path owns it now. */ + | { kind: 'lost' }; + +/** Atomically claims up to `limit` due actions for this worker. */ +export function claimActions(deps: ExecutionDeps, actor: Actor, limit: number): string[] { + const config = resolveConfig(deps); + const now = deps.now(); + return deps.db.transaction(() => { + const due = deps.db + .prepare(`SELECT * FROM scheduled_actions WHERE state = 'scheduled' AND due_at <= ? ORDER BY due_at, id LIMIT ?`) + .all(now, limit); + return due.map( + (action) => + transitionAction(deps.db, action, 'claimed', now, actor, { + lease_owner: deps.workerId, + lease_expires_at: now + config.claimLeaseMs, + }).id, + ); + }); +} + +function isEmail(action: ActionRow): boolean { + return action.kind === 'email.send' || action.kind === 'email.reply'; +} + +export function recipientAllowed(gate: SendGate, recipient: string | null): boolean { + if (gate.mode === 'open') return true; + if (!recipient) return false; + const domain = recipient.split('@')[1] ?? ''; + return gate.allow.some((entry) => { + const rule = entry.trim().toLowerCase(); + if (rule.includes('@') && !rule.startsWith('@')) return rule === recipient; + return rule.replace(/^@/, '') === domain; + }); +} + +function builtInChecks(deps: ExecutionDeps): { before: PreflightCheck[]; after: PreflightCheck[] } { + const config = resolveConfig(deps); + const killSwitch: PreflightCheck = ({ db, action, now }) => { + const scope = engagedKillSwitch(db, action.workspace_id, action.provider_account_id, action.campaign_id); + return scope ? { kind: 'defer', until: now + config.killSwitchDeferMs, reason: `kill_switch:${scope}` } : { kind: 'pass' }; + }; + const expiry: PreflightCheck = ({ action, now }) => + action.not_after !== null && now > action.not_after ? { kind: 'cancel', reason: 'expired' } : { kind: 'pass' }; + const account: PreflightCheck = ({ action, account: acct, adapter, now }) => { + if (action.kind === 'manual.task') return { kind: 'pass' }; + if (!acct) return { kind: 'review', reason: 'provider_account_missing' }; + if (!adapter) return { kind: 'review', reason: `adapter_not_registered:${acct.provider}` }; + if (acct.health !== 'ok' && acct.health !== 'degraded') { + return { kind: 'defer', until: now + config.unhealthyAccountDeferMs, reason: `account_${acct.health}` }; + } + if (isEmail(action)) { + if (!adapter.email) return { kind: 'review', reason: 'unsupported:email' }; + const purpose = (action.purpose ?? 'automated_outreach') as ProviderPurpose; + const accountPurposes = JSON.parse(acct.purposes) as ProviderPurpose[]; + if (!purposePermitted(purpose, adapter.purposes, accountPurposes)) { + return { kind: 'review', reason: `purpose_not_permitted:${purpose}` }; + } + } + if (action.kind === 'notify.publish' && !adapter.notify) return { kind: 'review', reason: 'unsupported:notify' }; + return { kind: 'pass' }; + }; + const gate: PreflightCheck = ({ action }) => + isEmail(action) && !recipientAllowed(deps.sendGate, action.recipient_norm) + ? { kind: 'review', reason: 'not_in_live_allowlist' } + : { kind: 'pass' }; + return { before: [killSwitch, expiry], after: [account, gate] }; +} + +function applyVerdict(deps: ExecutionDeps, input: PreflightInput, verdict: PreflightVerdict, actor: Actor): void { + const { db, action, now } = input; + const release = { lease_owner: null, lease_expires_at: null, state_reason: 'reason' in verdict ? verdict.reason : null }; + switch (verdict.kind) { + case 'pass': + return; + case 'defer': + transitionAction(db, action, 'scheduled', now, actor, { ...release, due_at: verdict.until }); + return; + case 'cancel': { + const cancelled = transitionAction(db, action, 'cancelled', now, actor, release); + deps.effects?.onCancelled?.(db, cancelled, verdict.reason, now); + return; + } + case 'await_approval': + transitionAction(db, action, 'awaiting_approval', now, actor, release); + return; + case 'review': + transitionAction(db, action, 'review', now, actor, release); + return; + } +} + +/** + * The last gate before an external effect. In one transaction: re-validate the lease, run every check, + * reserve rate budget, then commit `executing` plus a pending attempt BEFORE the provider is called. + */ +export function preflight(deps: ExecutionDeps, actionId: string, actor: Actor): PreflightOutcome { + const config = resolveConfig(deps); + const db: SqlDatabase = deps.db; + return db.transaction((): PreflightOutcome => { + const now = deps.now(); + const action = loadAction(db, actionId); + if (!action || action.state !== 'claimed' || action.lease_owner !== deps.workerId) return { kind: 'lost' }; + if ((action.lease_expires_at ?? 0) < now) return { kind: 'lost' }; + + const account = loadAccount(db, action.provider_account_id); + const adapter = account ? (deps.adapters.get(account.provider) ?? null) : null; + const input: PreflightInput = { db, action, account, adapter, now }; + const { before, after } = builtInChecks(deps); + for (const check of [...before, ...(deps.checks ?? []), ...after]) { + const verdict = check(input); + if (verdict.kind !== 'pass') { + applyVerdict(deps, input, verdict, actor); + return { kind: 'stopped', verdict: verdict.kind }; + } + } + + let reservations: Reservation[] = []; + if (action.kind !== 'manual.task' && deps.ratePolicy) { + const budget = reserveBudget(db, action.workspace_id, action.recipient_norm, deps.ratePolicy(db, action), now); + if (!budget.ok) { + applyVerdict(deps, input, { kind: 'defer', until: budget.retryAt, reason: budget.reason }, actor); + return { kind: 'stopped', verdict: 'defer' }; + } + reservations = budget.reservations; + } + + const attemptNo = action.attempt_count + 1; + const attemptId = ulid(now); + db.prepare( + `INSERT INTO action_attempts (id, action_id, attempt_no, outcome, reservations, started_at) + VALUES (?,?,?,'pending',?,?)`, + ).run(attemptId, action.id, attemptNo, JSON.stringify(reservations), now); + const executing = transitionAction(db, action, 'executing', now, actor, { + attempt_count: attemptNo, + lease_expires_at: now + config.executeLeaseMs, + state_reason: null, + }); + return { kind: 'go', action: executing, attemptId, attemptNo, reservations }; + }); +} diff --git a/outreach-engine/packages/outreach-core/src/execution/rate.ts b/outreach-engine/packages/outreach-core/src/execution/rate.ts new file mode 100644 index 0000000..c768548 --- /dev/null +++ b/outreach-engine/packages/outreach-core/src/execution/rate.ts @@ -0,0 +1,58 @@ +import type { SqlDatabase } from '@splitin/outreach-contracts'; +import type { RatePolicy, Reservation } from './types'; + +export type ReservationResult = + | { ok: true; reservations: Reservation[] } + | { ok: false; retryAt: number; reason: string }; + +/** + * Atomically reserves one unit in every fixed window of the policy and enforces the per-recipient gap. + * All-or-nothing: must run inside the preflight transaction, which rolls back partial reservations. + */ +export function reserveBudget( + db: SqlDatabase, + workspaceId: string, + recipient: string | null, + policy: RatePolicy, + now: number, +): ReservationResult { + if (recipient && policy.recipientMinGapMs && policy.recipientMinGapMs > 0) { + const last = db + .prepare( + `SELECT MAX(at) AS at FROM messages + WHERE workspace_id = ? AND direction = 'outbound' AND recipient_norm = ?`, + ) + .get<{ at: number | null }>(workspaceId, recipient); + if (last?.at != null && now - last.at < policy.recipientMinGapMs) { + return { ok: false, retryAt: last.at + policy.recipientMinGapMs, reason: 'recipient_min_gap' }; + } + } + + const reservations: Reservation[] = []; + for (const limit of policy.limits) { + const windowStart = Math.floor(now / limit.windowMs) * limit.windowMs; + const row = db + .prepare('SELECT used FROM rate_buckets WHERE workspace_id = ? AND scope_key = ? AND window_start = ?') + .get<{ used: number }>(workspaceId, limit.scopeKey, windowStart); + if ((row?.used ?? 0) >= limit.limit) { + return { ok: false, retryAt: windowStart + limit.windowMs, reason: `rate_limit:${limit.scopeKey}` }; + } + db.prepare( + `INSERT INTO rate_buckets (workspace_id, scope_key, window_start, used, limit_value) VALUES (?,?,?,1,?) + ON CONFLICT (workspace_id, scope_key, window_start) + DO UPDATE SET used = used + 1, limit_value = excluded.limit_value`, + ).run(workspaceId, limit.scopeKey, windowStart, limit.limit); + reservations.push({ scopeKey: limit.scopeKey, windowStart }); + } + return { ok: true, reservations }; +} + +/** Gives back reservations for an attempt the provider confirmed did not happen. */ +export function releaseBudget(db: SqlDatabase, workspaceId: string, reservations: readonly Reservation[]): void { + for (const reservation of reservations) { + db.prepare( + `UPDATE rate_buckets SET used = MAX(used - 1, 0) + WHERE workspace_id = ? AND scope_key = ? AND window_start = ?`, + ).run(workspaceId, reservation.scopeKey, reservation.windowStart); + } +} diff --git a/outreach-engine/packages/outreach-core/src/execution/recovery.test.ts b/outreach-engine/packages/outreach-core/src/execution/recovery.test.ts new file mode 100644 index 0000000..252ef21 --- /dev/null +++ b/outreach-engine/packages/outreach-core/src/execution/recovery.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest'; +import { verifyAuditChain } from '@splitin/outreach-contracts'; +import { workerActor } from './actions-repo'; +import { executeDue, runExecutionPass } from './executor'; +import { SimulatedCrash, attempts, makeEnv } from './harness.test-util'; +import { claimActions, preflight } from './preflight'; +import { reconcileUncertain, sweepExpiredLeases } from './recovery'; +import { recordResult } from './results'; +import { resolveReviewAction } from './review'; + +const LEASE = 10 * 60_000 + 1; + +describe('uncertain provider outcomes', () => { + it('reconciles a timeout-after-accept to succeeded without resending', async () => { + const env = makeEnv(); + env.fake.script({ kind: 'unknown_after_accept' }); + const action = env.enqueueEmail('k1'); + await executeDue(env.deps); + expect(env.action(action.id)).toMatchObject({ state: 'uncertain', state_reason: 'provider_outcome_unknown' }); + await runExecutionPass(env.deps); + expect(env.action(action.id).state).toBe('succeeded'); + expect(env.fake.deliveries).toHaveLength(1); + expect(env.fake.sendCalls).toBe(1); + expect(attempts(env.db, action.id).map((a) => a.outcome)).toEqual(['succeeded']); + }); + + it('retries only after the provider affirms the message is absent', async () => { + const env = makeEnv(); + env.fake.script({ kind: 'unknown_before_accept' }); + const action = env.enqueueEmail('k1'); + await executeDue(env.deps); + await runExecutionPass(env.deps); + expect(env.action(action.id).state).toBe('succeeded'); + expect(env.fake.deliveries).toHaveLength(1); + expect(attempts(env.db, action.id).map((a) => a.outcome)).toEqual(['confirmed_absent', 'succeeded']); + }); + + it('treats an adapter crash after accept as unknown and reconciles it', async () => { + const env = makeEnv(); + env.fake.script({ kind: 'throw_after_accept' }); + const action = env.enqueueEmail('k1'); + await runExecutionPass(env.deps); + await runExecutionPass(env.deps); + expect(env.action(action.id).state).toBe('succeeded'); + expect(env.fake.deliveries).toHaveLength(1); + }); + + it('sends undecidable actions to review and never resends them', async () => { + const env = makeEnv(); + env.fake.reconcileMode = 'still_unknown'; + env.fake.script({ kind: 'unknown_before_accept' }); + const action = env.enqueueEmail('k1'); + for (let i = 0; i < 6; i += 1) { + await runExecutionPass(env.deps); + env.advance(10 * 60_000); + } + expect(env.action(action.id)).toMatchObject({ state: 'review', reconcile_count: 3 }); + expect(env.fake.sendCalls).toBe(1); + resolveReviewAction(env.deps, action.id, { kind: 'drop', reason: 'operator_checked' }, workerActor('admin', 't')); + expect(env.action(action.id).state).toBe('cancelled'); + }); +}); + +describe('crash recovery at every step boundary', () => { + const crashAt = (point: 'afterClaim' | 'afterPreflight' | 'beforeResult') => ({ + [point]: () => { + throw new SimulatedCrash(point); + }, + }); + + it('after claim: the lease expires and the action is simply scheduled again', async () => { + const env = makeEnv(); + const action = env.enqueueEmail('k1'); + await expect(executeDue(env.with({ hooks: crashAt('afterClaim') }))).rejects.toThrow(SimulatedCrash); + expect(env.action(action.id).state).toBe('claimed'); + env.advance(LEASE); + expect(sweepExpiredLeases(env.deps, workerActor('w', 't'))).toEqual({ released: 1, madeUncertain: 0 }); + await executeDue(env.deps); + expect(env.action(action.id).state).toBe('succeeded'); + expect(env.fake.deliveries).toHaveLength(1); + }); + + it('after preflight commit: never blindly resent; reconciled as absent, then sent once', async () => { + const env = makeEnv(); + const action = env.enqueueEmail('k1'); + await expect(executeDue(env.with({ hooks: crashAt('afterPreflight') }))).rejects.toThrow(SimulatedCrash); + expect(env.action(action.id).state).toBe('executing'); + env.advance(LEASE); + expect(sweepExpiredLeases(env.deps, workerActor('w', 't'))).toEqual({ released: 0, madeUncertain: 1 }); + await runExecutionPass(env.deps); + expect(env.action(action.id).state).toBe('succeeded'); + expect(env.fake.deliveries).toHaveLength(1); + }); + + it('after the provider accepted but before the result was stored: reconciled as found, no resend', async () => { + const env = makeEnv(); + const action = env.enqueueEmail('k1'); + await expect(executeDue(env.with({ hooks: crashAt('beforeResult') }))).rejects.toThrow(SimulatedCrash); + expect(env.fake.deliveries).toHaveLength(1); + env.advance(LEASE); + await runExecutionPass(env.deps); + expect(env.action(action.id).state).toBe('succeeded'); + expect(env.fake.deliveries).toHaveLength(1); + expect(env.fake.sendCalls).toBe(1); + expect(verifyAuditChain(env.db).ok).toBe(true); + }); + + it('a late definitive result resolves an action the sweeper already parked as uncertain', async () => { + const env = makeEnv(); + const action = env.enqueueEmail('k1'); + const actor = workerActor(env.deps.workerId, 't'); + claimActions(env.deps, actor, 1); + const go = preflight(env.deps, action.id, actor); + if (go.kind !== 'go') throw new Error('expected go'); + env.advance(LEASE); + sweepExpiredLeases(env.deps, actor); + expect(env.action(action.id).state).toBe('uncertain'); + recordResult(env.deps, action.id, go.attemptId, { kind: 'accepted', receipt: { providerMessageId: 'late-1', acceptedAt: env.now() } }, actor); + expect(env.action(action.id).state).toBe('succeeded'); + }); + + it('a reconciler that dies mid-flight hands the action back as uncertain', async () => { + const env = makeEnv(); + env.fake.script({ kind: 'unknown_before_accept' }); + const action = env.enqueueEmail('k1'); + await executeDue(env.deps); + const actor = workerActor(env.deps.workerId, 't'); + env.db.transaction(() => + env.db.prepare(`UPDATE scheduled_actions SET state = 'reconciling', lease_owner = 'dead', lease_expires_at = ? WHERE id = ?`).run(env.now() - 1, action.id), + ); + sweepExpiredLeases(env.deps, actor); + expect(env.action(action.id).state).toBe('uncertain'); + await reconcileUncertain(env.deps, actor); + expect(env.action(action.id).state).toBe('scheduled'); + }); +}); diff --git a/outreach-engine/packages/outreach-core/src/execution/recovery.ts b/outreach-engine/packages/outreach-core/src/execution/recovery.ts new file mode 100644 index 0000000..f6499c2 --- /dev/null +++ b/outreach-engine/packages/outreach-core/src/execution/recovery.ts @@ -0,0 +1,138 @@ +import { ulid, type SqlDatabase } from '@splitin/outreach-contracts'; +import { loadAccount, transitionAction, type Actor } from './actions-repo'; +import { providerContext } from './invoke'; +import { completeSuccess, confirmAbsent } from './results'; +import { resolveConfig, type ActionRow, type ExecutionDeps } from './types'; + +interface AttemptRow { + id: string; + outcome: string; + reservations: string; + started_at: number; +} + +function latestAttempt(db: SqlDatabase, actionId: string): AttemptRow | undefined { + return db + .prepare('SELECT id, outcome, reservations, started_at FROM action_attempts WHERE action_id = ? ORDER BY attempt_no DESC LIMIT 1') + .get(actionId); +} + +export interface SweepReport { + readonly released: number; + readonly madeUncertain: number; +} + +/** + * Recovers from dead workers. A `claimed` action with an expired lease never started an attempt, so it is + * safe to schedule again. An `executing` or `reconciling` one may have reached the provider: it becomes + * `uncertain` and is never retried without reconciliation. + */ +export function sweepExpiredLeases(deps: ExecutionDeps, actor: Actor): SweepReport { + const config = resolveConfig(deps); + return deps.db.transaction(() => { + const now = deps.now(); + const expired = deps.db + .prepare( + `SELECT * FROM scheduled_actions WHERE state IN ('claimed','executing','reconciling') + AND lease_expires_at IS NOT NULL AND lease_expires_at < ?`, + ) + .all(now); + let released = 0; + let madeUncertain = 0; + for (const action of expired) { + const patch = { lease_owner: null, lease_expires_at: null }; + if (action.state === 'claimed') { + transitionAction(deps.db, action, 'scheduled', now, actor, { ...patch, state_reason: 'lease_expired' }); + released += 1; + continue; + } + if (action.state === 'executing') { + const attempt = latestAttempt(deps.db, action.id); + if (attempt?.outcome === 'pending') { + deps.db.prepare(`UPDATE action_attempts SET outcome = 'uncertain', finished_at = ?, error_detail = ? WHERE id = ?`) + .run(now, 'worker lease expired during execution', attempt.id); + } + } + transitionAction(deps.db, action, 'uncertain', now, actor, { + ...patch, + due_at: now + config.reconcileDelayMs, + state_reason: 'lease_expired_during_execution', + }); + madeUncertain += 1; + } + return { released, madeUncertain }; + }); +} + +export interface ReconcileReport { + readonly found: number; + readonly absent: number; + readonly stillUnknown: number; + readonly toReview: number; +} + +/** Resolves `uncertain` actions by asking the provider. Only an affirmed `absent` allows another attempt. */ +export async function reconcileUncertain(deps: ExecutionDeps, actor: Actor, limit = 25): Promise { + const config = resolveConfig(deps); + const report = { found: 0, absent: 0, stillUnknown: 0, toReview: 0 }; + const claimed = deps.db.transaction(() => { + const now = deps.now(); + const due = deps.db + .prepare(`SELECT * FROM scheduled_actions WHERE state = 'uncertain' AND due_at <= ? ORDER BY due_at LIMIT ?`) + .all(now, limit); + return due.map((action) => + transitionAction(deps.db, action, 'reconciling', now, actor, { + lease_owner: deps.workerId, + lease_expires_at: now + config.executeLeaseMs, + }), + ); + }); + + for (const action of claimed) { + const account = loadAccount(deps.db, action.provider_account_id); + const adapter = account ? deps.adapters.get(account.provider) : undefined; + const attempt = latestAttempt(deps.db, action.id); + let result: Awaited['email']>['reconcile']>> = { + kind: 'still_unknown', + detail: 'no reconciler for this action', + }; + if (account && adapter?.email && attempt && (action.kind === 'email.send' || action.kind === 'email.reply')) { + try { + const signal = AbortSignal.timeout(config.providerTimeoutMs); + result = await adapter.email.reconcile(providerContext(deps, account, ulid(), signal), { + actionId: action.id, + idempotencyKey: action.idempotency_key, + rfcMessageId: action.rfc_message_id ?? '', + to: action.recipient_norm ? [action.recipient_norm] : [], + attemptedAt: attempt.started_at, + }); + } catch (error) { + result = { kind: 'still_unknown', detail: `reconcile threw: ${(error as Error).message}` }; + } + } + + deps.db.transaction(() => { + const now = deps.now(); + const current = deps.db.prepare('SELECT * FROM scheduled_actions WHERE id = ?').get(action.id); + if (!current || current.state !== 'reconciling' || current.lease_owner !== deps.workerId || !attempt) return; + if (result.kind === 'found') { + report.found += 1; + return completeSuccess(deps, current, attempt.id, result.receipt, now, actor); + } + if (result.kind === 'absent') { + report.absent += 1; + return confirmAbsent(deps, current, attempt, now, actor); + } + const count = current.reconcile_count + 1; + const patch = { lease_owner: null, lease_expires_at: null, reconcile_count: count, state_reason: result.detail.slice(0, 200) }; + if (count >= config.maxReconcileAttempts) { + report.toReview += 1; + transitionAction(deps.db, current, 'review', now, actor, patch); + return; + } + report.stillUnknown += 1; + transitionAction(deps.db, current, 'uncertain', now, actor, { ...patch, due_at: now + config.reconcileDelayMs * 2 ** count }); + }); + } + return report; +} diff --git a/outreach-engine/packages/outreach-core/src/execution/results.ts b/outreach-engine/packages/outreach-core/src/execution/results.ts new file mode 100644 index 0000000..16553f9 --- /dev/null +++ b/outreach-engine/packages/outreach-core/src/execution/results.ts @@ -0,0 +1,222 @@ +import { + ERROR_DISPOSITIONS, + ulid, + type ErrorClass, + type ErrorEffect, + type ProviderReceipt, + type SendResult, + type SqlDatabase, +} from '@splitin/outreach-contracts'; +import { loadAction, transitionAction, type Actor } from './actions-repo'; +import type { EmailPayload, ManualPayload } from './invoke'; +import { setKillSwitch } from './kill-switches'; +import { releaseBudget } from './rate'; +import { resolveConfig, type ActionRow, type ExecutionDeps, type Reservation } from './types'; + +interface AttemptRow { + id: string; + outcome: string; + reservations: string; +} + +const CLEAR_LEASE = { lease_owner: null, lease_expires_at: null } as const; + +function finishAttempt( + db: SqlDatabase, + attemptId: string, + outcome: string, + now: number, + extra: { receipt?: ProviderReceipt | null; errorClass?: string | null; detail?: string | null } = {}, +): void { + db.prepare( + `UPDATE action_attempts SET outcome = ?, finished_at = ?, receipt = ?, error_class = ?, error_detail = ? + WHERE id = ?`, + ).run( + outcome, + now, + extra.receipt ? JSON.stringify(extra.receipt) : null, + extra.errorClass ?? null, + extra.detail ? extra.detail.slice(0, 300) : null, + attemptId, + ); +} + +export function backoffMs(deps: ExecutionDeps, attemptNo: number, retryAfterMs?: number): number { + const config = resolveConfig(deps); + if (retryAfterMs && retryAfterMs > 0) return Math.min(retryAfterMs, config.backoffMaxMs); + const ceiling = Math.min(config.backoffMaxMs, config.backoffBaseMs * 2 ** Math.max(0, attemptNo - 1)); + return Math.max(1_000, Math.floor((deps.random ?? Math.random)() * ceiling)); +} + +function recordOutbound(db: SqlDatabase, action: ActionRow, receipt: ProviderReceipt, now: number): void { + if (action.kind !== 'email.send' && action.kind !== 'email.reply') return; + if (!action.provider_account_id) return; + const payload = JSON.parse(action.payload) as EmailPayload; + db.prepare( + `INSERT INTO messages (id, workspace_id, direction, provider_account_id, provider_message_id, provider_thread_id, + rfc_message_id, in_reply_to, references_ids, from_addr, to_addrs, recipient_norm, subject, at, action_id, + enrollment_id) + VALUES (?, ?, 'outbound', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (provider_account_id, provider_message_id) DO NOTHING`, + ).run( + ulid(now), + action.workspace_id, + action.provider_account_id, + receipt.providerMessageId, + receipt.providerThreadId ?? null, + receipt.rfcMessageId ?? action.rfc_message_id, + payload.inReplyTo ?? null, + JSON.stringify(payload.references ?? []), + payload.from.address, + JSON.stringify(payload.to.map((to) => to.address)), + action.recipient_norm, + payload.subject, + receipt.acceptedAt, + action.id, + action.enrollment_id, + ); +} + +function recordManualTask(db: SqlDatabase, action: ActionRow, now: number): void { + if (action.kind !== 'manual.task') return; + const payload = JSON.parse(action.payload) as ManualPayload; + db.prepare( + `INSERT INTO manual_tasks (id, workspace_id, action_id, enrollment_id, channel, target_url, draft_text, status, + created_at) VALUES (?,?,?,?,?,?,?,'open',?) ON CONFLICT (action_id) DO NOTHING`, + ).run(ulid(now), action.workspace_id, action.id, action.enrollment_id, payload.channel, payload.targetUrl ?? null, + payload.draft, now); +} + +/** Records a confirmed effect. `action` must be `executing` or `reconciling`. */ +export function completeSuccess( + deps: ExecutionDeps, + action: ActionRow, + attemptId: string, + receipt: ProviderReceipt, + now: number, + actor: Actor, +): void { + finishAttempt(deps.db, attemptId, 'succeeded', now, { receipt }); + const done = transitionAction(deps.db, action, 'succeeded', now, actor, { ...CLEAR_LEASE, state_reason: null }); + recordOutbound(deps.db, done, receipt, now); + recordManualTask(deps.db, done, now); + deps.effects?.onSucceeded?.(deps.db, done, action.kind === 'manual.task' ? null : receipt, now); +} + +/** The provider affirmed nothing was sent: give the budget back and retry if attempts remain. */ +export function confirmAbsent( + deps: ExecutionDeps, + action: ActionRow, + attempt: AttemptRow, + now: number, + actor: Actor, +): void { + finishAttempt(deps.db, attempt.id, 'confirmed_absent', now); + releaseBudget(deps.db, action.workspace_id, JSON.parse(attempt.reservations) as Reservation[]); + if (action.attempt_count >= action.max_attempts) { + const failed = transitionAction(deps.db, action, 'failed', now, actor, { ...CLEAR_LEASE, state_reason: 'max_attempts' }); + deps.effects?.onFailed?.(deps.db, failed, null, now); + return; + } + transitionAction(deps.db, action, 'scheduled', now, actor, { ...CLEAR_LEASE, due_at: now, state_reason: 'confirmed_absent' }); +} + +function applyAccountEffects(deps: ExecutionDeps, action: ActionRow, effects: readonly ErrorEffect[], now: number, actor: Actor): void { + const accountId = action.provider_account_id; + if (!accountId) return; + if (effects.includes('account_unhealthy') || effects.includes('account_reauth')) { + const health = effects.includes('account_unhealthy') ? 'unhealthy' : 'reauth_required'; + deps.db + .prepare('UPDATE provider_accounts SET health = ?, health_detail = ?, health_checked_at = ? WHERE id = ?') + .run(health, `set by action ${action.id}`, now, accountId); + } + if (effects.includes('engage_account_kill')) { + setKillSwitch( + deps.db, + { workspaceId: action.workspace_id, scope: 'provider_account', targetId: accountId, engaged: true, reason: `provider:${action.last_error_class ?? 'error'}` }, + actor, + now, + ); + } +} + +function applyRejection( + deps: ExecutionDeps, + action: ActionRow, + attempt: AttemptRow, + result: Extract, + now: number, + actor: Actor, +): void { + const errorClass: ErrorClass = result.errorClass; + const disposition = ERROR_DISPOSITIONS[errorClass]; + const retryable = disposition.retry !== 'never'; + finishAttempt(deps.db, attempt.id, retryable ? 'rejected_retryable' : 'rejected_permanent', now, { + errorClass, + detail: result.detail, + }); + const tagged = { ...action, last_error_class: errorClass }; + applyAccountEffects(deps, tagged, disposition.effects, now, actor); + if (disposition.effects.length) deps.effects?.onErrorEffects?.(deps.db, tagged, disposition.effects, now); + const patch = { ...CLEAR_LEASE, last_error_class: errorClass }; + + if (retryable) { + releaseBudget(deps.db, action.workspace_id, JSON.parse(attempt.reservations) as Reservation[]); + if (action.attempt_count >= action.max_attempts) { + const failed = transitionAction(deps.db, action, 'failed', now, actor, { ...patch, state_reason: 'max_attempts' }); + deps.effects?.onFailed?.(deps.db, failed, errorClass, now); + return; + } + const delay = + disposition.retry === 'after_reauth' + ? resolveConfig(deps).unhealthyAccountDeferMs + : backoffMs(deps, action.attempt_count, result.retryAfterMs); + const retrying = transitionAction(deps.db, action, 'retryable', now, actor, { ...patch, state_reason: errorClass }); + transitionAction(deps.db, retrying, 'scheduled', now, actor, { due_at: now + delay }); + return; + } + const terminal = disposition.terminal ?? 'failed'; + const finished = transitionAction(deps.db, action, terminal, now, actor, { ...patch, state_reason: errorClass }); + if (terminal === 'failed') deps.effects?.onFailed?.(deps.db, finished, errorClass, now); +} + +function loadAttempt(db: SqlDatabase, attemptId: string): AttemptRow | undefined { + return db.prepare('SELECT id, outcome, reservations FROM action_attempts WHERE id = ?').get(attemptId); +} + +/** Records a provider result. Runs in its own transaction after the call returned. */ +export function recordResult(deps: ExecutionDeps, actionId: string, attemptId: string, result: SendResult, actor: Actor): void { + const config = resolveConfig(deps); + deps.db.transaction(() => { + const now = deps.now(); + const action = loadAction(deps.db, actionId); + const attempt = loadAttempt(deps.db, attemptId); + if (!action || !attempt) return; + + if (action.state === 'executing' && attempt.outcome === 'pending') { + if (result.kind === 'accepted') return completeSuccess(deps, action, attempt.id, result.receipt, now, actor); + if (result.kind === 'rejected') return applyRejection(deps, action, attempt, result, now, actor); + finishAttempt(deps.db, attempt.id, 'uncertain', now, { detail: result.detail }); + if (action.kind === 'notify.publish') { + // Notifications cannot be reconciled; per ADR 0002 they are never retried either. + const failed = transitionAction(deps.db, action, 'failed', now, actor, { ...CLEAR_LEASE, state_reason: 'uncertain_not_reconcilable' }); + deps.effects?.onFailed?.(deps.db, failed, null, now); + return; + } + transitionAction(deps.db, action, 'uncertain', now, actor, { + ...CLEAR_LEASE, + due_at: now + config.reconcileDelayMs, + state_reason: 'provider_outcome_unknown', + }); + return; + } + + // The lease expired mid-call and the sweeper parked the action as uncertain. A definitive + // answer from this very attempt resolves it exactly like reconciliation would. + if (action.state === 'uncertain' && attempt.outcome === 'uncertain' && result.kind !== 'unknown') { + const reconciling = transitionAction(deps.db, action, 'reconciling', now, actor, { state_reason: 'late_result' }); + if (result.kind === 'accepted') return completeSuccess(deps, reconciling, attempt.id, result.receipt, now, actor); + return confirmAbsent(deps, reconciling, attempt, now, actor); + } + }); +} diff --git a/outreach-engine/packages/outreach-core/src/execution/review.ts b/outreach-engine/packages/outreach-core/src/execution/review.ts new file mode 100644 index 0000000..2b951e1 --- /dev/null +++ b/outreach-engine/packages/outreach-core/src/execution/review.ts @@ -0,0 +1,54 @@ +import { loadAction, transitionAction, type Actor } from './actions-repo'; +import { completeSuccess } from './results'; +import type { ExecutionDeps } from './types'; + +export type ReviewResolution = + /** A human verified the message went out (e.g. found it in the sent folder). */ + | { kind: 'sent'; providerMessageId: string; providerThreadId?: string } + /** A human verified it did not go out; send it now. */ + | { kind: 'not_sent_retry' } + /** Do not send. */ + | { kind: 'drop'; reason: string }; + +export class ReviewStateError extends Error { + constructor(actionId: string) { + super(`Action ${actionId} is not awaiting review`); + this.name = 'ReviewStateError'; + } +} + +/** Human resolution of a `review` action. Authorization is the caller's job (services layer). */ +export function resolveReviewAction(deps: ExecutionDeps, actionId: string, resolution: ReviewResolution, actor: Actor): void { + deps.db.transaction(() => { + const now = deps.now(); + const action = loadAction(deps.db, actionId); + if (!action || action.state !== 'review') throw new ReviewStateError(actionId); + if (resolution.kind === 'sent') { + const attempt = deps.db + .prepare('SELECT id FROM action_attempts WHERE action_id = ? ORDER BY attempt_no DESC LIMIT 1') + .get<{ id: string }>(actionId); + const attemptId = attempt?.id; + if (!attemptId) throw new ReviewStateError(actionId); + completeSuccess( + deps, + action, + attemptId, + { + providerMessageId: resolution.providerMessageId, + acceptedAt: now, + ...(resolution.providerThreadId ? { providerThreadId: resolution.providerThreadId } : {}), + ...(action.rfc_message_id ? { rfcMessageId: action.rfc_message_id } : {}), + }, + now, + actor, + ); + return; + } + if (resolution.kind === 'not_sent_retry') { + transitionAction(deps.db, action, 'scheduled', now, actor, { due_at: now, state_reason: 'review:not_sent' }); + return; + } + const cancelled = transitionAction(deps.db, action, 'cancelled', now, actor, { state_reason: `review:${resolution.reason}` }); + deps.effects?.onCancelled?.(deps.db, cancelled, resolution.reason, now); + }); +} diff --git a/outreach-engine/packages/outreach-core/src/execution/types.ts b/outreach-engine/packages/outreach-core/src/execution/types.ts new file mode 100644 index 0000000..f030da6 --- /dev/null +++ b/outreach-engine/packages/outreach-core/src/execution/types.ts @@ -0,0 +1,163 @@ +import type { + ActionKind, + ActionState, + ErrorClass, + ErrorEffect, + ManualTaskProvider, + ProviderAdapter, + ProviderReceipt, + SecretResolver, + SqlDatabase, +} from '@splitin/outreach-contracts'; + +export interface ActionRow { + id: string; + workspace_id: string; + enrollment_id: string | null; + campaign_id: string | null; + step_id: string | null; + contact_point_id: string | null; + kind: ActionKind; + purpose: string | null; + provider_account_id: string | null; + recipient_norm: string | null; + state: ActionState; + due_at: number; + not_after: number | null; + payload: string; + content_hash: string; + idempotency_key: string; + rfc_message_id: string | null; + approval_id: string | null; + lease_owner: string | null; + lease_expires_at: number | null; + attempt_count: number; + max_attempts: number; + reconcile_count: number; + last_error_class: string | null; + state_reason: string | null; + created_at: number; + updated_at: number; +} + +export interface AccountRow { + id: string; + workspace_id: string; + provider: string; + external_account_id: string; + sender_identity: string; + purposes: string; + capabilities: string; + secret_ref: string; + webhook_secret_ref: string | null; + health: 'ok' | 'degraded' | 'unhealthy' | 'reauth_required'; +} + +/** What a preflight check decides. Checks run in one transaction right before the provider call. */ +export type PreflightVerdict = + | { kind: 'pass' } + /** Not now: back to `scheduled` with a later due time (window, budget, paused campaign, unhealthy account). */ + | { kind: 'defer'; until: number; reason: string } + /** Never: the action must not be sent (reply, opt-out, suppression, expiry). */ + | { kind: 'cancel'; reason: string } + | { kind: 'await_approval'; reason: string } + /** A human must look (unsupported capability, purpose not permitted, live-send gate). */ + | { kind: 'review'; reason: string }; + +export interface PreflightInput { + readonly db: SqlDatabase; + readonly action: ActionRow; + readonly account: AccountRow | null; + readonly adapter: ProviderAdapter | null; + readonly now: number; +} + +export type PreflightCheck = (input: PreflightInput) => PreflightVerdict; + +export interface RateLimit { + /** e.g. "account::day", "domain:example.org:day", "campaign::day". */ + readonly scopeKey: string; + readonly windowMs: number; + readonly limit: number; +} + +export interface RatePolicy { + readonly limits: readonly RateLimit[]; + /** Minimum time between two outbound messages to the same recipient, across campaigns. */ + readonly recipientMinGapMs?: number; +} + +export type RatePolicyResolver = (db: SqlDatabase, action: ActionRow) => RatePolicy; + +export interface Reservation { + readonly scopeKey: string; + readonly windowStart: number; +} + +/** + * Domain reactions, run inside the transaction that records an outcome. The campaign domain (M4) + * uses them to advance or stop enrollments, suppress recipients and pause campaigns. + */ +export interface ActionEffects { + onSucceeded?(db: SqlDatabase, action: ActionRow, receipt: ProviderReceipt | null, now: number): void; + onFailed?(db: SqlDatabase, action: ActionRow, errorClass: ErrorClass | null, now: number): void; + onCancelled?(db: SqlDatabase, action: ActionRow, reason: string, now: number): void; + onErrorEffects?(db: SqlDatabase, action: ActionRow, effects: readonly ErrorEffect[], now: number): void; +} + +/** Live-send gate (BUILD_PLAN.md §12): until opened by an admin, only allowlisted recipients receive email. */ +export type SendGate = { readonly mode: 'open' } | { readonly mode: 'allowlist'; readonly allow: readonly string[] }; + +/** Test hooks that simulate a process dying at a step boundary. */ +export interface CrashHooks { + afterClaim?(actionId: string): void; + afterPreflight?(actionId: string): void; + beforeResult?(actionId: string): void; +} + +export interface ExecutionConfig { + readonly claimBatch: number; + readonly claimLeaseMs: number; + readonly executeLeaseMs: number; + readonly providerTimeoutMs: number; + readonly backoffBaseMs: number; + readonly backoffMaxMs: number; + readonly reconcileDelayMs: number; + readonly maxReconcileAttempts: number; + readonly unhealthyAccountDeferMs: number; + readonly killSwitchDeferMs: number; +} + +export const DEFAULT_EXECUTION_CONFIG: ExecutionConfig = { + claimBatch: 25, + claimLeaseMs: 60_000, + executeLeaseMs: 10 * 60_000, + providerTimeoutMs: 30_000, + backoffBaseMs: 15_000, + backoffMaxMs: 15 * 60_000, + reconcileDelayMs: 60_000, + maxReconcileAttempts: 3, + unhealthyAccountDeferMs: 5 * 60_000, + killSwitchDeferMs: 60_000, +}; + +export interface ExecutionDeps { + readonly db: SqlDatabase; + readonly adapters: ReadonlyMap; + readonly secrets: SecretResolver; + readonly now: () => number; + readonly workerId: string; + readonly sendGate: SendGate; + readonly config?: Partial; + /** Extra checks (enrollment status, suppression, approval, send window), run after the built-ins. */ + readonly checks?: readonly PreflightCheck[]; + readonly ratePolicy?: RatePolicyResolver; + readonly effects?: ActionEffects; + readonly manual?: ManualTaskProvider; + readonly hooks?: CrashHooks; + readonly random?: () => number; +} + +export function resolveConfig(deps: ExecutionDeps): ExecutionConfig { + return { ...DEFAULT_EXECUTION_CONFIG, ...deps.config }; +} diff --git a/outreach-engine/packages/outreach-core/src/index.ts b/outreach-engine/packages/outreach-core/src/index.ts new file mode 100644 index 0000000..0bf3542 --- /dev/null +++ b/outreach-engine/packages/outreach-core/src/index.ts @@ -0,0 +1 @@ +export * from './execution/index'; diff --git a/outreach-engine/packages/outreach-core/tsconfig.json b/outreach-engine/packages/outreach-core/tsconfig.json new file mode 100644 index 0000000..585a92d --- /dev/null +++ b/outreach-engine/packages/outreach-core/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist" }, + "include": ["src"] +} diff --git a/outreach-engine/packages/outreach-core/tsup.config.ts b/outreach-engine/packages/outreach-core/tsup.config.ts new file mode 100644 index 0000000..458d1fd --- /dev/null +++ b/outreach-engine/packages/outreach-core/tsup.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + dts: true, + sourcemap: true, + clean: true, + target: 'node22', + // node:sqlite exists only with the protocol prefix. + removeNodeProtocol: false, +}); diff --git a/outreach-engine/scripts/check-package-boundaries.mjs b/outreach-engine/scripts/check-package-boundaries.mjs index 424dc02..5d03f2f 100644 --- a/outreach-engine/scripts/check-package-boundaries.mjs +++ b/outreach-engine/scripts/check-package-boundaries.mjs @@ -89,7 +89,7 @@ for (const dir of dirs) { for (const file of sourceFiles(join(dir, 'src'))) { const fileRel = relative(root, file); // Tests may compose any workspace package declared in devDependencies (fakes, store). - const isTest = /\.test\.[cm]?[jt]sx?$/.test(file); + const isTest = /\.test(-util)?\.[cm]?[jt]sx?$/.test(file); const source = readFileSync(file, 'utf8'); for (const match of source.matchAll(IMPORT_RE)) { const spec = match[1] ?? match[2] ?? match[3]; From 0724a468d982e35a8ff56f678ae5521f6f282d06 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 01:42:07 +0000 Subject: [PATCH 06/20] outreach-engine M4: campaign domain, approvals, policy and calendar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements milestone M4 of outreach-engine/BUILD_PLAN.md (§7, §9, §14): turns playbooks into per-contact scheduled actions and plugs the domain rules into the M3 execution core. @splitin/outreach-core, src/domain/ (new) and src/engine.ts - auth.ts: AuthContext (workspace, principal, roles, surface, trace), always derived from an authenticated identity via authenticate(); unknown identities get nothing. Cumulative roles viewer < operator < approver < admin, requireRole(), and audit() for every domain mutation. - calendar.ts: send windows in the recipient's time zone with fallback, allowed weekdays and holidays. nextSlot() and addDuration() use the business calendar (P3D = three sending days) or elapsed time. Luxon handles DST, so slots never land on nonexistent local times. - playbook.ts: zod schema for the Playbook format in plan §7.1 (strict: unknown fields are errors), with policy defaults (approval mode, first-batch size, window, account/domain/campaign daily limits, recipient min gap, postal address, unsubscribe link or reply, expireAfter). Social steps can only be manual.task (ADR 0003). YAML or object input; errors carry field paths. - compile.ts: semantic checks against the database and adapters: unique step ids, a reply only after a send, templates exist on the right channel, send templates have subjects, no trailing waits, the purpose is permitted by both adapter and account, a postal address when required, and unsubscribe links only when a public URL is configured. - templates.ts: immutable versioned templates with a token allowlist (contact, organization, sender, attr.* and unsubscribe_url). Rendering fails closed on any missing value, escapes values in HTML, and strips control characters from untrusted contact data (no header or line injection). - materialize.ts: creates the next step's action, walking waits on the business calendar and snapping to the send window. Content is fully rendered and frozen before approval, with an automatic compliance footer (organization, postal address, unsubscribe link or reply instruction) and RFC 8058 List-Unsubscribe headers when the provider supports custom headers. Replies thread via In-Reply-To, References and the provider thread id. A missing value stops that enrollment with template_missing: instead of sending a broken email. - campaigns.ts: createCampaign (validated, deduplicated sequence versions); prepareActivation snapshots the audience (excludes suppressed, no legal basis, already live in any campaign, duplicate contact) and computes a version hash over sequence, policy, audience, templates and account; commitActivation activates exactly the previewed and approved hash, enrolls the snapshot and materializes first steps. Stale previews are refused. Pause, resume and complete. - approvals.ts: action, batch and campaign-version scopes bound to content hashes. Batch approvals list exactly their actions and content. Decisions require the shown operation hash (two-phase), an optional separation of duties, and expiry; rejection cancels and stops the affected enrollments. Revocation holds sending at preflight until requestBatchApproval() gathers waiting actions into a new batch. - suppressions.ts: global, channel, provider-account and domain scopes; an atomic stopEnrollment() that cancels every cancellable action and expires open tasks; self-verifying HMAC unsubscribe tokens. - wiring.ts: domain preflight checks (enrollment live, campaign live, not suppressed, approval valid for this exact content, inside the send window); a per-campaign rate policy (account/day, domain/day, campaign/day, recipient gap for new threads); and effects (advance on success, error on failure, stop on suppression or expiry, suppress plus bounce on bad recipients, pause the campaign on account failures). - operations.ts: principals, provider account registration (records the adapter capability snapshot; secrets are env:/keychain: references only), manual contacts, suppressions, enrollment pause/resume/stop, manual task listing, and recordManualOutcome() (only a human completes a social task; completion advances the sequence). - engine.ts: createEngine() wires execution and domain; the default send gate is an EMPTY allowlist, so nothing is emailed until an admin opens it. Also bootstrapWorkspace, kill switches as a service (operators may stop, only approvers resume, global needs admin), review listing and resolution, and campaign status. Design notes - A contact can be live in only one campaign at a time (a safety default stricter than the per-campaign unique index). - Reply templates may omit a subject and inherit "Re: ". Tests (28 new, 99 total) - Calendar: inside/early/late/weekend, holidays, DST spring-forward gap and fall-back overlap, zone fallback, a 300-run property test (slot is never earlier, always inside the window, idempotent) across six zones including half-hour and 45-minute offsets, business-day durations. - Playbook: defaults, path-bearing structural errors, no non-manual social steps, no unknown fields, semantic compile issues, role checks. Templates: fail-closed rendering, HTML escaping, header injection stripped, immutable versions, unknown tokens rejected. - Lifecycle: import to completion with audience exclusions, first-batch approval, List-Unsubscribe, a 3-business-day threaded follow-up, a manual social task and completion, audit chain valid; the recipient's window across time zones; stale preview and unapproved activation refused; separation of duties; batch rejection; every_action approvals; revoke then re-batch; mid-sequence suppression; campaign and enrollment pause; hard bounce suppression; missing template value; the default send gate parks sends for review. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MbDW8kfMcxUuZ3WBtaDKBv --- outreach-engine/package-lock.json | 48 +++- .../packages/outreach-core/package.json | 8 +- .../outreach-core/src/domain/approvals.ts | 188 ++++++++++++++ .../packages/outreach-core/src/domain/auth.ts | 106 ++++++++ .../outreach-core/src/domain/calendar.test.ts | 74 ++++++ .../outreach-core/src/domain/calendar.ts | 84 +++++++ .../src/domain/campaigns.test.ts | 216 ++++++++++++++++ .../outreach-core/src/domain/campaigns.ts | 212 ++++++++++++++++ .../outreach-core/src/domain/compile.ts | 59 +++++ .../src/domain/domain.test-util.ts | 121 +++++++++ .../packages/outreach-core/src/domain/env.ts | 68 +++++ .../outreach-core/src/domain/index.ts | 12 + .../outreach-core/src/domain/materialize.ts | 232 ++++++++++++++++++ .../outreach-core/src/domain/operations.ts | 160 ++++++++++++ .../outreach-core/src/domain/playbook.test.ts | 82 +++++++ .../outreach-core/src/domain/playbook.ts | 108 ++++++++ .../outreach-core/src/domain/suppressions.ts | 110 +++++++++ .../outreach-core/src/domain/templates.ts | 118 +++++++++ .../outreach-core/src/domain/wiring.ts | 119 +++++++++ .../packages/outreach-core/src/engine.ts | 119 +++++++++ .../packages/outreach-core/src/index.ts | 2 + 21 files changed, 2242 insertions(+), 4 deletions(-) create mode 100644 outreach-engine/packages/outreach-core/src/domain/approvals.ts create mode 100644 outreach-engine/packages/outreach-core/src/domain/auth.ts create mode 100644 outreach-engine/packages/outreach-core/src/domain/calendar.test.ts create mode 100644 outreach-engine/packages/outreach-core/src/domain/calendar.ts create mode 100644 outreach-engine/packages/outreach-core/src/domain/campaigns.test.ts create mode 100644 outreach-engine/packages/outreach-core/src/domain/campaigns.ts create mode 100644 outreach-engine/packages/outreach-core/src/domain/compile.ts create mode 100644 outreach-engine/packages/outreach-core/src/domain/domain.test-util.ts create mode 100644 outreach-engine/packages/outreach-core/src/domain/env.ts create mode 100644 outreach-engine/packages/outreach-core/src/domain/index.ts create mode 100644 outreach-engine/packages/outreach-core/src/domain/materialize.ts create mode 100644 outreach-engine/packages/outreach-core/src/domain/operations.ts create mode 100644 outreach-engine/packages/outreach-core/src/domain/playbook.test.ts create mode 100644 outreach-engine/packages/outreach-core/src/domain/playbook.ts create mode 100644 outreach-engine/packages/outreach-core/src/domain/suppressions.ts create mode 100644 outreach-engine/packages/outreach-core/src/domain/templates.ts create mode 100644 outreach-engine/packages/outreach-core/src/domain/wiring.ts create mode 100644 outreach-engine/packages/outreach-core/src/engine.ts diff --git a/outreach-engine/package-lock.json b/outreach-engine/package-lock.json index 6968d2b..db04823 100644 --- a/outreach-engine/package-lock.json +++ b/outreach-engine/package-lock.json @@ -1132,6 +1132,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/luxon": { + "version": "3.7.5", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.5.tgz", + "integrity": "sha512-jJ41Q4z6ZVO260MNDdHfW7+7a5iMiX8Mr6ZJHcmgrvhZha6dz5704o/lF2kKl6URjH6ivEL97w9xS/MgpJEphg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.20.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.4.tgz", @@ -2462,6 +2469,15 @@ "dev": true, "license": "MIT" }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -3479,6 +3495,21 @@ "node": ">=0.10.0" } }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -3492,6 +3523,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zod": { + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "packages/outreach-contracts": { "name": "@splitin/outreach-contracts", "version": "0.0.0", @@ -3505,11 +3545,15 @@ "version": "0.0.0", "license": "MIT", "dependencies": { - "@splitin/outreach-contracts": "0.0.0" + "@splitin/outreach-contracts": "0.0.0", + "luxon": "^3.7.2", + "yaml": "^2.9.1", + "zod": "^4.6.5" }, "devDependencies": { "@splitin/outreach-fakes": "0.0.0", - "@splitin/outreach-store-sqlite": "0.0.0" + "@splitin/outreach-store-sqlite": "0.0.0", + "@types/luxon": "^3.7.5" }, "engines": { "node": ">=22.13" diff --git a/outreach-engine/packages/outreach-core/package.json b/outreach-engine/packages/outreach-core/package.json index bc127d7..0f6cf83 100644 --- a/outreach-engine/packages/outreach-core/package.json +++ b/outreach-engine/packages/outreach-core/package.json @@ -38,10 +38,14 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { - "@splitin/outreach-contracts": "0.0.0" + "@splitin/outreach-contracts": "0.0.0", + "luxon": "^3.7.2", + "yaml": "^2.9.1", + "zod": "^4.6.5" }, "devDependencies": { "@splitin/outreach-fakes": "0.0.0", - "@splitin/outreach-store-sqlite": "0.0.0" + "@splitin/outreach-store-sqlite": "0.0.0", + "@types/luxon": "^3.7.5" } } diff --git a/outreach-engine/packages/outreach-core/src/domain/approvals.ts b/outreach-engine/packages/outreach-core/src/domain/approvals.ts new file mode 100644 index 0000000..b450c8d --- /dev/null +++ b/outreach-engine/packages/outreach-core/src/domain/approvals.ts @@ -0,0 +1,188 @@ +import { digestCanonical, ulid, type SqlDatabase } from '@splitin/outreach-contracts'; +import { transitionAction } from '../execution/actions-repo'; +import type { ActionRow, PreflightVerdict } from '../execution/types'; +import { ConflictError, ForbiddenError, NotFoundError, actorOf, audit, requireRole, type AuthContext } from './auth'; +import type { DomainEnv } from './env'; +import { stopEnrollment } from './suppressions'; + +export type ApprovalScope = 'action' | 'batch' | 'campaign_version'; +export type ApprovalDecision = 'pending' | 'approved' | 'rejected' | 'revoked' | 'expired'; + +export interface ApprovalRow { + id: string; + workspace_id: string; + scope: ApprovalScope; + subject_id: string; + operation_hash: string; + preview: string; + requested_by: string; + decided_by: string | null; + decision: ApprovalDecision; + reason: string | null; + created_at: number; + expires_at: number; + decided_at: number | null; + consumed_count: number; +} + +export const APPROVAL_TTL_MS = 72 * 3_600_000; + +export interface BatchPreviewItem { + readonly id: string; + readonly contentHash: string; + readonly recipient: string | null; +} + +export function batchHash(items: readonly BatchPreviewItem[]): string { + return digestCanonical([...items].map((item) => `${item.id}:${item.contentHash}`).sort()); +} + +export function createApproval( + db: SqlDatabase, + input: { workspaceId: string; scope: ApprovalScope; subjectId: string; operationHash: string; preview: unknown; requestedBy: string }, + now: number, +): string { + const id = ulid(now); + db.prepare( + `INSERT INTO approvals (id, workspace_id, scope, subject_id, operation_hash, preview, requested_by, decision, created_at, expires_at) + VALUES (?,?,?,?,?,?,?,'pending',?,?)`, + ).run(id, input.workspaceId, input.scope, input.subjectId, input.operationHash, JSON.stringify(input.preview), input.requestedBy, now, now + APPROVAL_TTL_MS); + return id; +} + +export function loadApproval(db: SqlDatabase, workspaceId: string, id: string): ApprovalRow | undefined { + return db.prepare('SELECT * FROM approvals WHERE workspace_id = ? AND id = ?').get(workspaceId, id); +} + +/** Preflight view of an action's approval: only an unexpired approval of this exact content passes. */ +export function approvalVerdict(db: SqlDatabase, action: ActionRow, now: number): PreflightVerdict { + if (!action.approval_id) return { kind: 'pass' }; + const approval = loadApproval(db, action.workspace_id, action.approval_id); + if (!approval) return { kind: 'await_approval', reason: 'approval_missing' }; + if (approval.decision === 'rejected') return { kind: 'cancel', reason: 'approval_rejected' }; + if (approval.decision !== 'approved') return { kind: 'await_approval', reason: `approval_${approval.decision}` }; + if (approval.scope === 'campaign_version') { + const version = db.prepare('SELECT version_hash FROM campaign_versions WHERE id = ?').get<{ version_hash: string | null }>(approval.subject_id); + return version?.version_hash === approval.operation_hash ? { kind: 'pass' } : { kind: 'await_approval', reason: 'approval_hash_mismatch' }; + } + if (now > approval.expires_at) return { kind: 'await_approval', reason: 'approval_expired' }; + if (approval.scope === 'action') { + return approval.operation_hash === action.content_hash ? { kind: 'pass' } : { kind: 'await_approval', reason: 'approval_hash_mismatch' }; + } + const items = (JSON.parse(approval.preview) as { actions: BatchPreviewItem[] }).actions; + return items.some((item) => item.id === action.id && item.contentHash === action.content_hash) + ? { kind: 'pass' } + : { kind: 'await_approval', reason: 'approval_hash_mismatch' }; +} + +function workspaceSettings(db: SqlDatabase, workspaceId: string): { separationOfDuties?: boolean } { + const row = db.prepare('SELECT settings FROM workspaces WHERE id = ?').get<{ settings: string }>(workspaceId); + return row ? (JSON.parse(row.settings) as { separationOfDuties?: boolean }) : {}; +} + +export interface DecisionInput { + readonly approvalId: string; + readonly decision: 'approved' | 'rejected'; + /** The hash the approver was shown; must still match (BUILD_PLAN.md §9.3). */ + readonly operationHash: string; + readonly reason?: string; +} + +export function decideApproval(env: DomainEnv, ctx: AuthContext, input: DecisionInput): ApprovalRow { + requireRole(ctx, 'approver'); + const { db } = env; + return db.transaction(() => { + const now = env.now(); + const approval = loadApproval(db, ctx.workspaceId, input.approvalId); + if (!approval) throw new NotFoundError(`approval ${input.approvalId}`); + if (approval.decision !== 'pending') throw new ConflictError(`approval is ${approval.decision}`); + if (now > approval.expires_at) { + db.prepare(`UPDATE approvals SET decision = 'expired', decided_at = ? WHERE id = ?`).run(now, approval.id); + throw new ConflictError('approval expired; request a new one'); + } + if (approval.operation_hash !== input.operationHash) throw new ConflictError('operation hash does not match what is pending'); + if (workspaceSettings(db, ctx.workspaceId).separationOfDuties && approval.requested_by === ctx.principalId) { + throw new ForbiddenError('separation of duties: the requester cannot approve'); + } + db.prepare('UPDATE approvals SET decision = ?, decided_by = ?, decided_at = ?, reason = ? WHERE id = ?') + .run(input.decision, ctx.principalId, now, input.reason ?? null, approval.id); + const actor = actorOf(ctx); + const waiting = db + .prepare(`SELECT * FROM scheduled_actions WHERE approval_id = ? AND state = 'awaiting_approval'`) + .all(approval.id); + for (const action of waiting) { + if (input.decision === 'approved') { + transitionAction(db, action, 'scheduled', now, actor, { state_reason: null }); + } else { + transitionAction(db, action, 'cancelled', now, actor, { state_reason: 'approval_rejected' }); + if (action.enrollment_id) stopEnrollment(db, action.enrollment_id, 'stopped', 'approval_rejected', actor, now); + } + } + audit(db, ctx, now, 'approval', approval.id, input.decision, { scope: approval.scope, actions: waiting.length, reason: input.reason ?? null }); + return { ...approval, decision: input.decision, decided_by: ctx.principalId, decided_at: now }; + }); +} + +export function revokeApproval(env: DomainEnv, ctx: AuthContext, approvalId: string, reason: string): void { + requireRole(ctx, 'approver'); + env.db.transaction(() => { + const now = env.now(); + const approval = loadApproval(env.db, ctx.workspaceId, approvalId); + if (!approval) throw new NotFoundError(`approval ${approvalId}`); + if (approval.decision !== 'approved' && approval.decision !== 'pending') throw new ConflictError(`approval is ${approval.decision}`); + env.db.prepare(`UPDATE approvals SET decision = 'revoked', reason = ?, decided_at = ? WHERE id = ?`).run(reason, now, approvalId); + audit(env.db, ctx, now, 'approval', approvalId, 'revoked', { reason }); + }); +} + +/** Gathers a campaign's actions that wait without a live approval into one new batch approval. */ +export function requestBatchApproval(env: DomainEnv, ctx: AuthContext, campaignId: string): { approvalId: string; operationHash: string; count: number } | null { + requireRole(ctx, 'operator'); + return env.db.transaction(() => { + const now = env.now(); + const actions = env.db + .prepare( + `SELECT a.* FROM scheduled_actions a LEFT JOIN approvals p ON p.id = a.approval_id + WHERE a.workspace_id = ? AND a.campaign_id = ? AND a.state = 'awaiting_approval' + AND (p.id IS NULL OR p.decision <> 'pending' OR p.expires_at < ?) + ORDER BY a.due_at LIMIT 500`, + ) + .all(ctx.workspaceId, campaignId, now); + if (!actions.length) return null; + const approvalId = createBatchApproval(env.db, ctx.workspaceId, campaignId, actions, ctx.principalId, now); + audit(env.db, ctx, now, 'approval', approvalId, 'requested', { scope: 'batch', count: actions.length }); + const row = loadApproval(env.db, ctx.workspaceId, approvalId); + return { approvalId, operationHash: row?.operation_hash ?? '', count: actions.length }; + }); +} + +export function createBatchApproval( + db: SqlDatabase, + workspaceId: string, + campaignId: string, + actions: readonly Pick[], + requestedBy: string, + now: number, +): string { + const items: (BatchPreviewItem & { subject?: string })[] = actions.map((action) => ({ + id: action.id, + contentHash: action.content_hash, + recipient: action.recipient_norm, + subject: (JSON.parse(action.payload) as { subject?: string }).subject, + })); + const approvalId = createApproval( + db, + { workspaceId, scope: 'batch', subjectId: campaignId, operationHash: batchHash(items), preview: { actions: items }, requestedBy }, + now, + ); + const update = db.prepare('UPDATE scheduled_actions SET approval_id = ? WHERE id = ?'); + for (const item of items) update.run(approvalId, item.id); + return approvalId; +} + +export function listApprovals(db: SqlDatabase, ctx: AuthContext, decision: ApprovalDecision = 'pending'): ApprovalRow[] { + requireRole(ctx, 'viewer'); + return db + .prepare('SELECT * FROM approvals WHERE workspace_id = ? AND decision = ? ORDER BY created_at') + .all(ctx.workspaceId, decision); +} diff --git a/outreach-engine/packages/outreach-core/src/domain/auth.ts b/outreach-engine/packages/outreach-core/src/domain/auth.ts new file mode 100644 index 0000000..4d051d3 --- /dev/null +++ b/outreach-engine/packages/outreach-core/src/domain/auth.ts @@ -0,0 +1,106 @@ +import { appendAudit, type SqlDatabase } from '@splitin/outreach-contracts'; +import type { Actor } from '../execution/actions-repo'; + +/** Roles are cumulative: each includes everything below it (BUILD_PLAN.md §9.1). */ +export const ROLES = ['viewer', 'operator', 'approver', 'admin'] as const; +export type Role = (typeof ROLES)[number]; + +export type Surface = 'cli' | 'http' | 'mcp' | 'slack' | 'papr' | 'system' | 'test'; + +/** Always derived from authenticated context by a surface, never from tool arguments or payloads. */ +export interface AuthContext { + readonly workspaceId: string; + readonly principalId: string; + readonly roles: readonly Role[]; + readonly source: Surface; + readonly traceId: string; +} + +export class ForbiddenError extends Error { + constructor(message: string) { + super(message); + this.name = 'ForbiddenError'; + } +} + +export class NotFoundError extends Error { + constructor(what: string) { + super(`${what} not found`); + this.name = 'NotFoundError'; + } +} + +export class ConflictError extends Error { + constructor(message: string) { + super(message); + this.name = 'ConflictError'; + } +} + +function rank(role: Role): number { + return ROLES.indexOf(role); +} + +export function hasRole(ctx: AuthContext, required: Role): boolean { + return ctx.roles.some((role) => rank(role) >= rank(required)); +} + +export function requireRole(ctx: AuthContext, required: Role): void { + if (!hasRole(ctx, required)) throw new ForbiddenError(`${required} role required`); +} + +export function actorOf(ctx: AuthContext): Actor { + return { kind: ctx.source === 'system' ? 'system' : 'principal', id: ctx.principalId, source: ctx.source, traceId: ctx.traceId }; +} + +export function audit( + db: SqlDatabase, + ctx: AuthContext, + now: number, + resourceKind: string, + resourceId: string, + action: string, + detail: Record = {}, +): void { + const actor = actorOf(ctx); + appendAudit(db, { + workspaceId: ctx.workspaceId, + at: now, + actorKind: actor.kind, + actorId: actor.id, + source: actor.source, + traceId: actor.traceId, + resourceKind, + resourceId, + action, + detail, + }); +} + +interface PrincipalRow { + id: string; + roles: string; +} + +/** + * Resolves an authenticated external identity (e.g. "slack:T1:U2", "cli:alice") to a principal. + * Unknown identities get no roles and therefore can do nothing. + */ +export function authenticate( + db: SqlDatabase, + workspaceId: string, + externalRef: string, + source: Surface, + traceId: string, +): AuthContext { + const row = db + .prepare('SELECT id, roles FROM principals WHERE workspace_id = ? AND external_ref = ?') + .get(workspaceId, externalRef); + if (!row) throw new ForbiddenError(`unknown principal ${externalRef}`); + const roles = (JSON.parse(row.roles) as string[]).filter((role): role is Role => (ROLES as readonly string[]).includes(role)); + return { workspaceId, principalId: row.id, roles, source, traceId }; +} + +export function systemContext(workspaceId: string, traceId: string): AuthContext { + return { workspaceId, principalId: 'system', roles: ['admin'], source: 'system', traceId }; +} diff --git a/outreach-engine/packages/outreach-core/src/domain/calendar.test.ts b/outreach-engine/packages/outreach-core/src/domain/calendar.test.ts new file mode 100644 index 0000000..99211c9 --- /dev/null +++ b/outreach-engine/packages/outreach-core/src/domain/calendar.test.ts @@ -0,0 +1,74 @@ +import fc from 'fast-check'; +import { DateTime } from 'luxon'; +import { describe, expect, it } from 'vitest'; +import { addDuration, nextSlot, resolveZone, type SendWindow } from './calendar'; + +const WINDOW: SendWindow = { timezone: 'recipient', fallback: 'America/New_York', days: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'], start: '09:30', end: '16:30' }; +const at = (iso: string, zone: string) => DateTime.fromISO(iso, { zone }).toMillis(); +const show = (ms: number, zone: string) => DateTime.fromMillis(ms, { zone }).toFormat("ccc yyyy-MM-dd'T'HH:mm"); + +describe('nextSlot', () => { + it('keeps an instant already inside the window', () => { + const t = at('2025-03-04T10:00', 'America/New_York'); + expect(nextSlot(t, WINDOW, 'America/New_York')).toBe(t); + }); + + it('moves early, late and weekend instants to the next opening', () => { + const zone = 'America/New_York'; + expect(show(nextSlot(at('2025-03-04T07:00', zone), WINDOW, zone), zone)).toBe('Tue 2025-03-04T09:30'); + expect(show(nextSlot(at('2025-03-04T17:00', zone), WINDOW, zone), zone)).toBe('Wed 2025-03-05T09:30'); + expect(show(nextSlot(at('2025-03-07T16:45', zone), WINDOW, zone), zone)).toBe('Mon 2025-03-10T09:30'); + }); + + it('skips holidays', () => { + const zone = 'Europe/Berlin'; + const window = { ...WINDOW, holidays: ['2025-12-25', '2025-12-26'] }; + expect(show(nextSlot(at('2025-12-24T18:00', zone), window, zone), zone)).toBe('Mon 2025-12-29T09:30'); + }); + + it('handles the spring-forward gap and fall-back overlap', () => { + const zone = 'America/New_York'; + const window: SendWindow = { ...WINDOW, days: ['Sun', 'Mon'], start: '02:30', end: '04:00' }; + // 02:30 does not exist on 2025-03-09; luxon moves it forward to 03:30 local. + expect(show(nextSlot(at('2025-03-09T00:00', zone), window, zone), zone)).toBe('Sun 2025-03-09T03:30'); + const fallBack = nextSlot(at('2025-11-02T00:00', zone), { ...window, start: '01:30', end: '03:00' }, zone); + expect(show(fallBack, zone)).toBe('Sun 2025-11-02T01:30'); + }); + + it('resolves the recipient zone with a fallback', () => { + expect(resolveZone(WINDOW, 'Asia/Tokyo')).toBe('Asia/Tokyo'); + expect(resolveZone(WINDOW, 'Mars/Olympus')).toBe('America/New_York'); + expect(resolveZone({ ...WINDOW, timezone: 'Europe/London' }, 'Asia/Tokyo')).toBe('Europe/London'); + }); + + it('always lands inside the window, never earlier, and is idempotent', () => { + const zones = ['America/New_York', 'Europe/Berlin', 'Asia/Kolkata', 'Australia/Lord_Howe', 'Pacific/Chatham', 'America/Sao_Paulo']; + fc.assert( + fc.property(fc.integer({ min: 1_700_000_000_000, max: 1_800_000_000_000 }), fc.constantFrom(...zones), (from, zone) => { + const slot = nextSlot(from, WINDOW, zone); + const local = DateTime.fromMillis(slot, { zone }); + const minutes = local.hour * 60 + local.minute; + expect(slot).toBeGreaterThanOrEqual(from); + expect(local.weekday).toBeLessThanOrEqual(5); + expect(minutes).toBeGreaterThanOrEqual(9 * 60 + 30); + expect(minutes).toBeLessThan(16 * 60 + 30); + expect(nextSlot(slot, WINDOW, zone)).toBe(slot); + }), + { numRuns: 300 }, + ); + }); +}); + +describe('addDuration', () => { + it('counts business days only on the business calendar', () => { + const zone = 'America/New_York'; + const friday = at('2025-03-07T10:00', zone); + expect(show(addDuration(friday, 'P3D', 'business', WINDOW, zone), zone)).toBe('Wed 2025-03-12T10:00'); + expect(show(addDuration(friday, 'P3D', 'calendar', WINDOW, zone), zone)).toBe('Mon 2025-03-10T10:00'); + expect(show(addDuration(friday, 'PT4H', 'business', WINDOW, zone), zone)).toBe('Fri 2025-03-07T14:00'); + }); + + it('rejects invalid durations', () => { + expect(() => addDuration(0, 'three days', 'business', WINDOW, 'UTC')).toThrow(/Invalid ISO duration/); + }); +}); diff --git a/outreach-engine/packages/outreach-core/src/domain/calendar.ts b/outreach-engine/packages/outreach-core/src/domain/calendar.ts new file mode 100644 index 0000000..d498de2 --- /dev/null +++ b/outreach-engine/packages/outreach-core/src/domain/calendar.ts @@ -0,0 +1,84 @@ +import { DateTime, Duration, IANAZone } from 'luxon'; + +export const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] as const; +export type Weekday = (typeof WEEKDAYS)[number]; + +export interface SendWindow { + /** 'recipient' uses the contact's time zone, falling back to `fallback`. */ + readonly timezone: string; + readonly fallback: string; + readonly days: readonly Weekday[]; + /** "HH:mm", local to the resolved zone. start < end (windows do not cross midnight). */ + readonly start: string; + readonly end: string; + /** ISO dates (yyyy-MM-dd) in the resolved zone on which nothing is sent. */ + readonly holidays?: readonly string[]; +} + +export function isValidZone(zone: string | null | undefined): zone is string { + return !!zone && IANAZone.isValidZone(zone); +} + +export function resolveZone(window: SendWindow, contactZone: string | null | undefined): string { + if (window.timezone === 'recipient') return isValidZone(contactZone) ? contactZone : window.fallback; + return window.timezone; +} + +function minutesOf(hhmm: string): number { + const [h, m] = hhmm.split(':').map(Number); + return (h ?? 0) * 60 + (m ?? 0); +} + +function allowedDay(dt: DateTime, window: SendWindow): boolean { + const day = WEEKDAYS[dt.weekday - 1]; + return !!day && window.days.includes(day) && !(window.holidays ?? []).includes(dt.toISODate() ?? ''); +} + +function atMinutes(day: DateTime, minutes: number): DateTime { + // Luxon moves times that fall into a DST gap forward, so this never lands on a nonexistent instant. + return day.startOf('day').plus({ minutes }); +} + +/** The earliest instant >= `from` inside the window, in the resolved zone. */ +export function nextSlot(from: number, window: SendWindow, zone: string): number { + const start = minutesOf(window.start); + const end = minutesOf(window.end); + let dt = DateTime.fromMillis(from, { zone }); + for (let i = 0; i < 400; i += 1) { + if (allowedDay(dt, window)) { + const open = atMinutes(dt, start); + const close = atMinutes(dt, end); + if (dt < open) return open.toMillis(); + if (dt < close) return dt.toMillis(); + } + dt = dt.plus({ days: 1 }).startOf('day'); + } + throw new Error('Send window never opens (no allowed days within 400 days)'); +} + +/** + * Adds an ISO-8601 duration. With the business calendar, whole days count only allowed window days + * (P3D = three sending days later, same local time); hours and minutes are added as elapsed time. + */ +export function addDuration(from: number, iso: string, calendar: 'business' | 'calendar', window: SendWindow, zone: string): number { + const duration = Duration.fromISO(iso); + if (!duration.isValid) throw new Error(`Invalid ISO duration ${iso}`); + const days = Math.floor(duration.as('days')); + const rest = duration.minus({ days }).as('milliseconds'); + let dt = DateTime.fromMillis(from, { zone }); + if (calendar === 'calendar') return dt.plus({ days }).toMillis() + rest; + let counted = 0; + while (counted < days) { + dt = dt.plus({ days: 1 }); + if (allowedDay(dt, window)) counted += 1; + } + return dt.toMillis() + rest; +} + +export function isValidDuration(iso: string): boolean { + return /^P/.test(iso) && Duration.fromISO(iso).isValid; +} + +export function durationMs(iso: string): number { + return Duration.fromISO(iso).as('milliseconds'); +} diff --git a/outreach-engine/packages/outreach-core/src/domain/campaigns.test.ts b/outreach-engine/packages/outreach-core/src/domain/campaigns.test.ts new file mode 100644 index 0000000..96a57ab --- /dev/null +++ b/outreach-engine/packages/outreach-core/src/domain/campaigns.test.ts @@ -0,0 +1,216 @@ +import { DateTime } from 'luxon'; +import { describe, expect, it } from 'vitest'; +import { verifyAuditChain } from '@splitin/outreach-contracts'; +import { campaignStatus, listReview } from '../engine'; +import { ConflictError, ForbiddenError } from './auth'; +import { decideApproval, listApprovals, requestBatchApproval, revokeApproval } from './approvals'; +import { commitActivation, createCampaign, prepareActivation, setCampaignStatus } from './campaigns'; +import { PLAYBOOK, makeDomainEnv, type DomainTestEnv } from './domain.test-util'; +import { listManualTasks, recordManualOutcome, setEnrollmentState, suppress } from './operations'; + +const DAY = 86_400_000; + +async function activate(env: DomainTestEnv, playbook = PLAYBOOK) { + const { campaignId } = createCampaign(env.engine, env.operator, { name: 'Intro', playbook, providerAccountId: env.accountId }); + const preview = prepareActivation(env.engine, env.operator, campaignId); + if (preview.approvalId) decideApproval(env.engine, env.approver, { approvalId: preview.approvalId, decision: 'approved', operationHash: preview.operationHash }); + const result = commitActivation(env.engine, env.operator, { campaignId, operationHash: preview.operationHash }); + return { campaignId, preview, ...result }; +} + +function approveBatch(env: DomainTestEnv, approvalId: string | null) { + if (!approvalId) throw new Error('expected a batch approval'); + const approval = listApprovals(env.engine.db, env.approver).find((row) => row.id === approvalId); + if (!approval) throw new Error('batch approval not pending'); + decideApproval(env.engine, env.approver, { approvalId, decision: 'approved', operationHash: approval.operation_hash }); +} + +function enrollmentStatuses(env: DomainTestEnv, campaignId: string) { + return campaignStatus(env.engine, env.viewer, campaignId).enrollments; +} + +describe('campaign lifecycle', () => { + it('runs import-to-completion: approvals, window, threaded follow-up, manual task, completion', async () => { + const env = await makeDomainEnv(); + env.contact('ada@example.org'); + env.contact('grace@example.net', { fullName: 'Grace Hopper', firstName: 'Grace' }); + env.contact('noconsent@example.org', { consentBasis: 'unknown' }); + env.contact('blocked@example.org'); + suppress(env.engine, env.operator, { scope: 'global', value: 'blocked@example.org', reason: 'do_not_contact' }); + + const { campaignId, preview, enrolled, batchApprovalId } = await activate(env); + expect(preview.audienceCount).toBe(2); + expect(preview.excluded).toMatchObject({ suppressed: 1, noConsent: 1 }); + expect(enrolled).toBe(2); + + // The first batch waits for its own approval. + await env.drain(); + expect(env.fake.deliveries).toHaveLength(0); + approveBatch(env, batchApprovalId); + await env.drain(); + expect(env.fake.deliveries).toHaveLength(2); + const intro = env.fake.deliveries.find((d) => d.to[0] === 'ada@example.org'); + expect(intro?.subject).toBe('Quick question, Ada'); + expect(intro?.headers['List-Unsubscribe']).toMatch(/^$/); + + // Three business days later (Tue -> Fri), the threaded follow-up goes out. + env.advance(2 * DAY); + await env.drain(); + expect(env.fake.deliveries).toHaveLength(2); + env.advance(DAY); + await env.drain(); + const followups = env.fake.deliveries.filter((d) => d.subject === 'Re: Quick question, Ada'); + expect(followups).toHaveLength(1); + expect(followups[0]?.providerThreadId).toBe(intro?.providerThreadId); + + // The social touch is a human task, then the closing email follows once it is done. + const tasks = listManualTasks(env.engine, env.operator); + expect(tasks).toHaveLength(2); + expect(tasks[0]?.draft_text).toMatch(/^Hi (Ada|Grace), sent you an email about Analytical Engines\.$/); + for (const task of tasks) recordManualOutcome(env.engine, env.operator, task.id, 'done', 'sent from my own account'); + await env.drain(); + expect(env.fake.deliveries.filter((d) => d.subject.startsWith('Re:'))).toHaveLength(4); + expect(enrollmentStatuses(env, campaignId)).toEqual({ completed: 2 }); + expect(verifyAuditChain(env.engine.db).ok).toBe(true); + }); + + it('sends only inside the recipient window', async () => { + const saturday = DateTime.fromISO('2025-03-08T12:00', { zone: 'America/New_York' }).toMillis(); + const env = await makeDomainEnv({ start: saturday }); + env.contact('tokyo@example.org', { timezone: 'Asia/Tokyo' }); + approveBatch(env, (await activate(env)).batchApprovalId); + await env.drain(); + expect(env.fake.deliveries).toHaveLength(0); + env.setNow(DateTime.fromISO('2025-03-10T09:05', { zone: 'Asia/Tokyo' }).toMillis()); + await env.drain(); + expect(env.fake.deliveries).toHaveLength(1); + }); + + it('refuses stale previews and unapproved activation', async () => { + const env = await makeDomainEnv(); + env.contact('ada@example.org'); + const { campaignId } = createCampaign(env.engine, env.operator, { name: 'x', playbook: PLAYBOOK, providerAccountId: env.accountId }); + const first = prepareActivation(env.engine, env.operator, campaignId); + expect(() => commitActivation(env.engine, env.operator, { campaignId, operationHash: first.operationHash })).toThrow(ForbiddenError); + env.contact('late@example.org'); + const second = prepareActivation(env.engine, env.operator, campaignId); + expect(second.operationHash).not.toBe(first.operationHash); + expect(() => decideApproval(env.engine, env.approver, { approvalId: second.approvalId ?? '', decision: 'approved', operationHash: first.operationHash })).toThrow(ConflictError); + expect(() => decideApproval(env.engine, env.operator, { approvalId: second.approvalId ?? '', decision: 'approved', operationHash: second.operationHash })).toThrow(ForbiddenError); + }); + + it('enforces separation of duties when the workspace asks for it', async () => { + const env = await makeDomainEnv(); + env.engine.db.prepare(`UPDATE workspaces SET settings = '{"separationOfDuties":true}'`).run(); + env.contact('ada@example.org'); + const { campaignId } = createCampaign(env.engine, env.admin, { name: 'x', playbook: PLAYBOOK, providerAccountId: env.accountId }); + const preview = prepareActivation(env.engine, env.admin, campaignId); + expect(() => decideApproval(env.engine, env.admin, { approvalId: preview.approvalId ?? '', decision: 'approved', operationHash: preview.operationHash })).toThrow(/separation of duties/); + decideApproval(env.engine, env.approver, { approvalId: preview.approvalId ?? '', decision: 'approved', operationHash: preview.operationHash }); + }); + + it('rejecting the first batch cancels it and stops those enrollments', async () => { + const env = await makeDomainEnv(); + env.contact('ada@example.org'); + const { campaignId, batchApprovalId } = await activate(env); + const approval = listApprovals(env.engine.db, env.approver).find((row) => row.id === batchApprovalId); + decideApproval(env.engine, env.approver, { approvalId: batchApprovalId ?? '', decision: 'rejected', operationHash: approval?.operation_hash ?? '', reason: 'copy too long' }); + await env.drain(); + expect(env.fake.deliveries).toHaveLength(0); + expect(enrollmentStatuses(env, campaignId)).toEqual({ stopped: 1 }); + }); + + it('requires per-action approval under every_action', async () => { + const env = await makeDomainEnv(); + env.contact('ada@example.org'); + await activate(env, PLAYBOOK.replace('approval: first_batch_then_campaign', 'approval: every_action')); + await env.drain(); + expect(env.fake.deliveries).toHaveLength(0); + const pending = listApprovals(env.engine.db, env.approver).filter((row) => row.scope === 'action'); + expect(pending).toHaveLength(1); + decideApproval(env.engine, env.approver, { approvalId: pending[0]?.id ?? '', decision: 'approved', operationHash: pending[0]?.operation_hash ?? '' }); + await env.drain(); + expect(env.fake.deliveries).toHaveLength(1); + }); + + it('revoking the campaign approval holds later steps until a new batch is approved', async () => { + const env = await makeDomainEnv(); + env.contact('ada@example.org'); + const { campaignId, preview, batchApprovalId } = await activate(env); + approveBatch(env, batchApprovalId); + await env.drain(); + revokeApproval(env.engine, env.approver, preview.approvalId ?? '', 'legal review'); + env.advance(3 * DAY); + await env.drain(); + expect(env.fake.deliveries).toHaveLength(1); + const request = requestBatchApproval(env.engine, env.operator, campaignId); + expect(request?.count).toBe(1); + decideApproval(env.engine, env.approver, { approvalId: request?.approvalId ?? '', decision: 'approved', operationHash: request?.operationHash ?? '' }); + await env.drain(); + expect(env.fake.deliveries).toHaveLength(2); + }); +}); + +describe('stops and holds', () => { + it('a suppression added mid-sequence cancels pending steps and stops the enrollment', async () => { + const env = await makeDomainEnv(); + env.contact('ada@example.org'); + const { campaignId, batchApprovalId } = await activate(env); + approveBatch(env, batchApprovalId); + await env.drain(); + suppress(env.engine, env.operator, { scope: 'domain', value: 'example.org', reason: 'do_not_contact' }); + env.advance(3 * DAY); + await env.drain(); + expect(env.fake.deliveries).toHaveLength(1); + expect(enrollmentStatuses(env, campaignId)).toEqual({ stopped: 1 }); + }); + + it('pausing a campaign or enrollment defers sends until resumed', async () => { + const env = await makeDomainEnv(); + const contactId = env.contact('ada@example.org'); + const { campaignId, batchApprovalId } = await activate(env); + approveBatch(env, batchApprovalId); + setCampaignStatus(env.engine, env.operator, campaignId, 'paused', 'holiday'); + await env.drain(); + expect(env.fake.deliveries).toHaveLength(0); + setCampaignStatus(env.engine, env.operator, campaignId, 'active', 'back'); + env.advance(16 * 60_000); + await env.drain(); + expect(env.fake.deliveries).toHaveLength(1); + const enrollment = env.engine.db.prepare('SELECT id FROM enrollments WHERE contact_id = ?').get<{ id: string }>(contactId); + setEnrollmentState(env.engine, env.operator, enrollment?.id ?? '', 'pause', 'asked to wait'); + env.advance(3 * DAY); + await env.drain(); + expect(env.fake.deliveries).toHaveLength(1); + }); + + it('a hard bounce suppresses the address and marks the enrollment bounced', async () => { + const env = await makeDomainEnv(); + env.contact('gone@example.org'); + env.fake.script({ kind: 'reject', errorClass: 'hard_bounce' }); + const { campaignId, batchApprovalId } = await activate(env); + approveBatch(env, batchApprovalId); + await env.drain(); + expect(enrollmentStatuses(env, campaignId)).toEqual({ bounced: 1 }); + const suppression = env.engine.db.prepare('SELECT scope, reason FROM suppressions WHERE value_norm = ?').get('gone@example.org'); + expect(suppression).toEqual({ scope: 'channel', reason: 'hard_bounce' }); + }); + + it('a contact missing a required template value is stopped with a reason, never sent a broken email', async () => { + const env = await makeDomainEnv(); + env.contact('nofirst@example.org', { firstName: '' }); + const { campaignId } = await activate(env); + expect(enrollmentStatuses(env, campaignId)).toEqual({ error: 1 }); + const reason = env.engine.db.prepare('SELECT stop_reason FROM enrollments').get<{ stop_reason: string }>(); + expect(reason?.stop_reason).toBe('template_missing:first_name'); + }); + + it('the default send gate parks real sends for review', async () => { + const env = await makeDomainEnv({ sendGate: { mode: 'allowlist', allow: [] } }); + env.contact('ada@example.org'); + approveBatch(env, (await activate(env)).batchApprovalId); + await env.drain(); + expect(env.fake.deliveries).toHaveLength(0); + expect(listReview(env.engine, env.viewer).map((row) => row.state_reason)).toEqual(['not_in_live_allowlist']); + }); +}); diff --git a/outreach-engine/packages/outreach-core/src/domain/campaigns.ts b/outreach-engine/packages/outreach-core/src/domain/campaigns.ts new file mode 100644 index 0000000..2711f43 --- /dev/null +++ b/outreach-engine/packages/outreach-core/src/domain/campaigns.ts @@ -0,0 +1,212 @@ +import { digestCanonical, ulid } from '@splitin/outreach-contracts'; +import { ConflictError, ForbiddenError, NotFoundError, actorOf, audit, requireRole, type AuthContext } from './auth'; +import { createApproval, createBatchApproval, loadApproval } from './approvals'; +import { compileIssues } from './compile'; +import { loadCampaign, type CampaignVersionRow, type DomainEnv } from './env'; +import { loadStepContext, materializeNext } from './materialize'; +import { PlaybookError, parsePlaybook, type Audience, type Policy, type Step } from './playbook'; +import { isSuppressed } from './suppressions'; + +export interface CreateCampaignInput { + readonly name: string; + readonly playbook: string | unknown; + readonly providerAccountId: string; +} + +export function createCampaign(env: DomainEnv, ctx: AuthContext, input: CreateCampaignInput): { campaignId: string; versionId: string } { + requireRole(ctx, 'operator'); + const playbook = parsePlaybook(input.playbook); + const issues = compileIssues(env, ctx.workspaceId, playbook, input.providerAccountId); + if (issues.length) throw new PlaybookError(issues); + const { db } = env; + return db.transaction(() => { + const now = env.now(); + const spec = { steps: playbook.spec.steps }; + const specHash = digestCanonical(spec); + let sequence = db.prepare('SELECT id FROM sequence_versions WHERE workspace_id = ? AND spec_hash = ?').get<{ id: string }>(ctx.workspaceId, specHash); + if (!sequence) { + const latest = db.prepare('SELECT MAX(version) AS v FROM sequence_versions WHERE workspace_id = ? AND name = ?') + .get<{ v: number | null }>(ctx.workspaceId, playbook.metadata.name); + sequence = { id: ulid(now) }; + db.prepare('INSERT INTO sequence_versions (id, workspace_id, name, version, spec, spec_hash, created_at) VALUES (?,?,?,?,?,?,?)') + .run(sequence.id, ctx.workspaceId, playbook.metadata.name, (latest?.v ?? 0) + 1, JSON.stringify(spec), specHash, now); + } + const campaignId = ulid(now); + const versionId = ulid(now); + db.prepare(`INSERT INTO campaigns (id, workspace_id, name, purpose, status, created_at, updated_at) VALUES (?,?,?,?,'draft',?,?)`) + .run(campaignId, ctx.workspaceId, input.name, playbook.spec.purpose, now, now); + db.prepare( + `INSERT INTO campaign_versions (id, campaign_id, version, sequence_version_id, provider_account_id, policy, policy_hash, audience) + VALUES (?,?,1,?,?,?,?,?)`, + ).run(versionId, campaignId, sequence.id, input.providerAccountId, JSON.stringify(playbook.spec.policy), digestCanonical(playbook.spec.policy), JSON.stringify(playbook.spec.audience)); + audit(db, ctx, now, 'campaign', campaignId, 'created', { name: input.name, playbook: playbook.metadata.name, specHash }); + return { campaignId, versionId }; + }); +} + +interface MemberCandidate { + contact_id: string; + contact_point_id: string; + email: string; + consent_basis: string | null; +} + +function draftVersion(env: DomainEnv, campaignId: string): CampaignVersionRow { + const version = env.db + .prepare('SELECT * FROM campaign_versions WHERE campaign_id = ? AND activated_at IS NULL ORDER BY version DESC LIMIT 1') + .get(campaignId); + if (!version) throw new ConflictError('campaign has no draft version to activate'); + return version; +} + +function candidates(env: DomainEnv, workspaceId: string, audience: Audience): MemberCandidate[] { + const base = `SELECT c.id AS contact_id, cp.id AS contact_point_id, cp.value_norm AS email, cp.consent_basis + FROM contacts c JOIN contact_points cp ON cp.contact_id = c.id AND cp.kind = 'email' + WHERE c.workspace_id = ? AND c.merged_into_id IS NULL`; + if (audience.source === 'all') return env.db.prepare(`${base} ORDER BY c.id`).all(workspaceId); + if ('importBatch' in audience.source) { + return env.db + .prepare(`${base} AND c.id IN (SELECT contact_id FROM import_rows WHERE batch_id = ? AND contact_id IS NOT NULL) ORDER BY c.id`) + .all(workspaceId, audience.source.importBatch); + } + const wanted = new Set(audience.source.contactIds); + return env.db.prepare(`${base} ORDER BY c.id`).all(workspaceId).filter((row) => wanted.has(row.contact_id)); +} + +export interface ActivationPreview { + readonly operationId: string; + readonly operationHash: string; + readonly requiresApproval: boolean; + readonly approvalId: string | null; + readonly audienceCount: number; + readonly excluded: { suppressed: number; noConsent: number; alreadyEnrolled: number; duplicateContact: number }; + readonly sampleRecipients: readonly string[]; + readonly steps: readonly Pick[]; +} + +/** Snapshots the audience, computes the exact version hash, and requests approval if the policy needs it. */ +export function prepareActivation(env: DomainEnv, ctx: AuthContext, campaignId: string): ActivationPreview { + requireRole(ctx, 'operator'); + const { db } = env; + return db.transaction(() => { + const now = env.now(); + const campaign = loadCampaign(db, ctx.workspaceId, campaignId); + if (!campaign) throw new NotFoundError(`campaign ${campaignId}`); + if (campaign.status !== 'draft') throw new ConflictError(`campaign is ${campaign.status}`); + const version = draftVersion(env, campaignId); + const audience = JSON.parse(version.audience) as Audience; + const excluded = { suppressed: 0, noConsent: 0, alreadyEnrolled: 0, duplicateContact: 0 }; + const members: MemberCandidate[] = []; + const seenContacts = new Set(); + for (const row of candidates(env, ctx.workspaceId, audience)) { + if (seenContacts.has(row.contact_id)) { + excluded.duplicateContact += 1; + continue; + } + if (isSuppressed(db, ctx.workspaceId, row.email, version.provider_account_id)) excluded.suppressed += 1; + else if (audience.eligibility.includes('consent_or_legitimate_interest') && !['consent', 'legitimate_interest', 'existing_relationship'].includes(row.consent_basis ?? '')) excluded.noConsent += 1; + else if (db.prepare(`SELECT 1 FROM enrollments WHERE workspace_id = ? AND contact_id = ? AND status IN ('active','paused') LIMIT 1`).get(ctx.workspaceId, row.contact_id)) excluded.alreadyEnrolled += 1; + else { + members.push(row); + seenContacts.add(row.contact_id); + } + } + db.prepare('DELETE FROM audience_members WHERE campaign_version_id = ?').run(version.id); + const insert = db.prepare('INSERT INTO audience_members (id, campaign_version_id, contact_id, contact_point_id, eligibility) VALUES (?,?,?,?,?)'); + for (const member of members) insert.run(ulid(now), version.id, member.contact_id, member.contact_point_id, JSON.stringify({ consent: member.consent_basis })); + const audienceHash = digestCanonical(members.map((member) => member.contact_point_id).sort()); + const sequence = db.prepare('SELECT spec, spec_hash FROM sequence_versions WHERE id = ?').get<{ spec: string; spec_hash: string }>(version.sequence_version_id); + const steps = (JSON.parse(sequence?.spec ?? '{"steps":[]}') as { steps: Step[] }).steps; + const templates = steps.flatMap((step) => ('template' in step ? [step.template] : [])); + const versionHash = digestCanonical({ spec: sequence?.spec_hash, policy: version.policy_hash, audience: audienceHash, templates, account: version.provider_account_id }); + const policy = JSON.parse(version.policy) as Policy; + let approvalId: string | null = null; + if (policy.approval !== 'none') { + db.prepare(`UPDATE approvals SET decision = 'expired', decided_at = ? WHERE scope = 'campaign_version' AND subject_id = ? AND decision = 'pending'`).run(now, version.id); + approvalId = createApproval(db, { + workspaceId: ctx.workspaceId, + scope: 'campaign_version', + subjectId: version.id, + operationHash: versionHash, + preview: { campaign: campaign.name, audienceCount: members.length, excluded, steps: steps.map((s) => ({ id: s.id, type: s.type })), templates }, + requestedBy: ctx.principalId, + }, now); + } + db.prepare('UPDATE campaign_versions SET audience_hash = ?, version_hash = ?, approval_id = ? WHERE id = ?').run(audienceHash, versionHash, approvalId, version.id); + audit(db, ctx, now, 'campaign', campaignId, 'activation_prepared', { versionHash, audience: members.length, excluded }); + return { + operationId: version.id, + operationHash: versionHash, + requiresApproval: approvalId !== null, + approvalId, + audienceCount: members.length, + excluded, + sampleRecipients: members.slice(0, 5).map((member) => member.email), + steps: steps.map((step) => ({ id: step.id, type: step.type })), + }; + }); +} + +/** Activates exactly what was previewed (and approved): enrolls the snapshot and materializes first steps. */ +export function commitActivation(env: DomainEnv, ctx: AuthContext, input: { campaignId: string; operationHash: string }): { enrolled: number; batchApprovalId: string | null } { + requireRole(ctx, 'operator'); + const { db } = env; + return db.transaction(() => { + const now = env.now(); + const campaign = loadCampaign(db, ctx.workspaceId, input.campaignId); + if (!campaign) throw new NotFoundError(`campaign ${input.campaignId}`); + if (campaign.status !== 'draft') throw new ConflictError(`campaign is ${campaign.status}`); + const version = draftVersion(env, input.campaignId); + if (!version.version_hash || version.version_hash !== input.operationHash) throw new ConflictError('preview is stale; prepare the activation again'); + const policy = JSON.parse(version.policy) as Policy; + if (policy.approval !== 'none') { + const approval = version.approval_id ? loadApproval(db, ctx.workspaceId, version.approval_id) : undefined; + if (approval?.decision !== 'approved' || approval.operation_hash !== version.version_hash) throw new ForbiddenError('activation requires an approved campaign-version approval'); + } + db.prepare(`UPDATE campaigns SET status = 'active', active_version_id = ?, updated_at = ? WHERE id = ?`).run(version.id, now, campaign.id); + db.prepare('UPDATE campaign_versions SET activated_at = ?, activated_by = ? WHERE id = ?').run(now, ctx.principalId, version.id); + const actor = actorOf(ctx); + const batch = policy.approval === 'first_batch_then_campaign' ? { ids: [] as string[], size: policy.firstBatchSize } : undefined; + const members = db.prepare('SELECT contact_id, contact_point_id FROM audience_members WHERE campaign_version_id = ? ORDER BY id') + .all<{ contact_id: string; contact_point_id: string }>(version.id); + let enrolled = 0; + for (const member of members) { + const enrollmentId = ulid(now); + const inserted = db.prepare( + `INSERT INTO enrollments (id, workspace_id, campaign_id, campaign_version_id, contact_id, contact_point_id, status, enrolled_at, updated_at) + SELECT ?,?,?,?,?,?,'active',?,? WHERE NOT EXISTS (SELECT 1 FROM enrollments WHERE workspace_id = ? AND contact_id = ? AND status IN ('active','paused'))`, + ).run(enrollmentId, ctx.workspaceId, campaign.id, version.id, member.contact_id, member.contact_point_id, now, now, ctx.workspaceId, member.contact_id); + if (inserted.changes !== 1) continue; + enrolled += 1; + const sc = loadStepContext(db, enrollmentId); + if (sc) materializeNext(env, sc, -1, now, actor, { ...(batch ? { batch } : {}), requestActionApproval: actionApprover(env, ctx.workspaceId, ctx.principalId) }); + } + let batchApprovalId: string | null = null; + if (batch && batch.ids.length) { + const placeholders = batch.ids.map(() => '?').join(','); + const actions = db.prepare(`SELECT id, content_hash, recipient_norm, payload FROM scheduled_actions WHERE id IN (${placeholders})`) + .all<{ id: string; content_hash: string; recipient_norm: string | null; payload: string }>(...batch.ids); + batchApprovalId = createBatchApproval(db, ctx.workspaceId, campaign.id, actions, ctx.principalId, now); + } + audit(db, ctx, now, 'campaign', campaign.id, 'activated', { versionId: version.id, enrolled, batchApprovalId }); + return { enrolled, batchApprovalId }; + }); +} + +export function actionApprover(env: DomainEnv, workspaceId: string, requestedBy: string): (actionId: string, contentHash: string) => string { + return (actionId, contentHash) => + createApproval(env.db, { workspaceId, scope: 'action', subjectId: actionId, operationHash: contentHash, preview: { actionId }, requestedBy }, env.now()); +} + +export function setCampaignStatus(env: DomainEnv, ctx: AuthContext, campaignId: string, status: 'paused' | 'active' | 'completed', reason: string): void { + requireRole(ctx, 'operator'); + env.db.transaction(() => { + const now = env.now(); + const campaign = loadCampaign(env.db, ctx.workspaceId, campaignId); + if (!campaign) throw new NotFoundError(`campaign ${campaignId}`); + const allowed = status === 'paused' ? ['active'] : status === 'active' ? ['paused'] : ['active', 'paused']; + if (!allowed.includes(campaign.status)) throw new ConflictError(`cannot move campaign from ${campaign.status} to ${status}`); + env.db.prepare('UPDATE campaigns SET status = ?, paused_reason = ?, updated_at = ? WHERE id = ?').run(status, status === 'paused' ? reason : null, now, campaignId); + audit(env.db, ctx, now, 'campaign', campaignId, status, { reason }); + }); +} diff --git a/outreach-engine/packages/outreach-core/src/domain/compile.ts b/outreach-engine/packages/outreach-core/src/domain/compile.ts new file mode 100644 index 0000000..de015ff --- /dev/null +++ b/outreach-engine/packages/outreach-core/src/domain/compile.ts @@ -0,0 +1,59 @@ +import { purposePermitted, type ProviderPurpose, type SenderIdentity } from '@splitin/outreach-contracts'; +import type { DomainEnv } from './env'; +import type { Playbook } from './playbook'; +import { findTemplate } from './templates'; + +/** + * Semantic checks that need the database and adapters (BUILD_PLAN.md §7.2). Structural checks already + * ran in parsePlaybook. Returns human-readable issues; an empty list means the playbook can run. + */ +export function compileIssues(env: DomainEnv, workspaceId: string, playbook: Playbook, providerAccountId: string): string[] { + const issues: string[] = []; + const { steps, policy, purpose } = playbook.spec; + + const seen = new Set(); + let sentBefore = false; + steps.forEach((step, index) => { + const at = `steps[${index}] (${step.id})`; + if (seen.has(step.id)) issues.push(`${at}: duplicate step id`); + seen.add(step.id); + if (step.type === 'email.reply' && !sentBefore) issues.push(`${at}: email.reply needs an earlier email.send to reply to`); + if (step.type === 'email.send') sentBefore = true; + if (step.type === 'wait') { + if (index === steps.length - 1) issues.push(`${at}: a trailing wait has no effect`); + return; + } + const template = findTemplate(env.db, workspaceId, step.template); + if (!template) { + issues.push(`${at}: template ${step.template} does not exist`); + return; + } + const expected = step.type === 'manual.task' ? [step.channel, 'manual'] : ['email']; + if (!expected.includes(template.channel)) issues.push(`${at}: template ${step.template} is for channel ${template.channel}`); + if (step.type === 'email.send' && !template.subject) issues.push(`${at}: template ${step.template} has no subject (only reply templates may omit it)`); + const tokens = JSON.parse(template.required_tokens) as string[]; + if (tokens.includes('unsubscribe_url') && !env.unsubscribe) issues.push(`${at}: uses {{unsubscribe_url}} but no unsubscribe URL is configured`); + }); + if (!steps.some((step) => step.type !== 'wait')) issues.push('steps: no actionable step'); + + const account = env.db + .prepare('SELECT provider, purposes, sender_identity FROM provider_accounts WHERE workspace_id = ? AND id = ?') + .get<{ provider: string; purposes: string; sender_identity: string }>(workspaceId, providerAccountId); + const sendsEmail = steps.some((step) => step.type === 'email.send' || step.type === 'email.reply'); + if (!account) { + issues.push(`provider account ${providerAccountId} does not exist in this workspace`); + return issues; + } + if (sendsEmail) { + const adapter = env.adapters.get(account.provider); + if (!adapter) issues.push(`no adapter registered for provider ${account.provider}`); + else if (!adapter.email) issues.push(`provider ${account.provider} cannot send email`); + else if (!purposePermitted(purpose, adapter.purposes, JSON.parse(account.purposes) as ProviderPurpose[])) { + issues.push(`purpose ${purpose} is not permitted by both provider ${account.provider} and the account`); + } + const sender = JSON.parse(account.sender_identity) as SenderIdentity; + if (policy.requirePostalAddress && !sender.postalAddress) issues.push('policy.requirePostalAddress: the sending account has no postal address'); + if (policy.unsubscribe === 'link' && !env.unsubscribe) issues.push('policy.unsubscribe is "link" but no unsubscribe URL is configured (use "reply" or configure one)'); + } + return issues; +} diff --git a/outreach-engine/packages/outreach-core/src/domain/domain.test-util.ts b/outreach-engine/packages/outreach-core/src/domain/domain.test-util.ts new file mode 100644 index 0000000..89f36c8 --- /dev/null +++ b/outreach-engine/packages/outreach-core/src/domain/domain.test-util.ts @@ -0,0 +1,121 @@ +import { FAKE_EMAIL_SECRET, FakeEmailProvider, FakeNotifier, staticSecrets } from '@splitin/outreach-fakes'; +import { openSqliteDatabase } from '@splitin/outreach-store-sqlite'; +import { DateTime } from 'luxon'; +import { bootstrapWorkspace, createEngine, type Engine } from '../engine'; +import type { SendGate } from '../execution/types'; +import { authenticate, type AuthContext } from './auth'; +import { addContact, addPrincipal, registerProviderAccount } from './operations'; +import { createTemplate } from './templates'; + +/** Tuesday 2025-03-04 14:00 UTC = 09:00 in New York: inside a 09:00-17:00 window. */ +export const TUESDAY_9AM_NY = DateTime.fromISO('2025-03-04T09:00:00', { zone: 'America/New_York' }).toMillis(); + +export interface DomainTestEnv { + readonly engine: Engine; + readonly fake: FakeEmailProvider; + readonly notifier: FakeNotifier; + readonly admin: AuthContext; + readonly operator: AuthContext; + readonly approver: AuthContext; + readonly viewer: AuthContext; + readonly accountId: string; + now(): number; + advance(ms: number): void; + setNow(ms: number): void; + /** Runs worker passes until nothing changes (bounded). */ + drain(passes?: number): Promise; + contact(email: string, extra?: Partial[2]>): string; +} + +export const PLAYBOOK = ` +apiVersion: outreach.splitin.net/v1alpha1 +kind: Playbook +metadata: { name: b2b-intro } +spec: + purpose: automated_outreach + policy: + approval: first_batch_then_campaign + firstBatchSize: 2 + unsubscribe: link + window: { timezone: recipient, fallback: America/New_York, days: [Mon, Tue, Wed, Thu, Fri], start: "09:00", end: "17:00" } + limits: { accountPerDay: 50, domainPerDay: 50, recipientMinGap: P1D } + steps: + - { id: intro, type: email.send, template: intro@1 } + - { id: wait1, type: wait, duration: P3D } + - { id: followup, type: email.reply, template: followup@1, when: no_reply } + - { id: social, type: manual.task, channel: linkedin, template: social@1, when: no_reply } + - { id: close, type: email.reply, template: close@1, when: no_reply } +`; + +export async function makeDomainEnv(options: { sendGate?: SendGate; start?: number } = {}): Promise { + const db = openSqliteDatabase(':memory:'); + const fake = new FakeEmailProvider(); + const notifier = new FakeNotifier(); + let now = options.start ?? TUESDAY_9AM_NY; + const engine = createEngine({ + db, + adapters: [fake.adapter(), notifier.adapter()], + secrets: staticSecrets({ 'env:FAKE_EMAIL': FAKE_EMAIL_SECRET, 'env:FAKE_WEBHOOK': 'fake-webhook-secret-value' }), + workerId: 'worker-1', + sendGate: options.sendGate ?? { mode: 'open' }, + unsubscribe: { baseUrl: 'https://outreach.example.com/u/', secret: 'unsubscribe-test-secret-fixture' }, + now: () => now, + execution: { reconcileDelayMs: 0 }, + random: () => 0.5, + }); + bootstrapWorkspace(db, { workspaceId: 'ws', name: 'Test', adminRef: 'test:admin', adminName: 'Admin' }, now); + const admin = authenticate(db, 'ws', 'test:admin', 'test', 't-admin'); + addPrincipal(engine, admin, { externalRef: 'test:operator', displayName: 'Operator', roles: ['operator'] }); + addPrincipal(engine, admin, { externalRef: 'test:approver', displayName: 'Approver', roles: ['approver'] }); + addPrincipal(engine, admin, { externalRef: 'test:viewer', displayName: 'Viewer', roles: ['viewer'] }); + const operator = authenticate(db, 'ws', 'test:operator', 'test', 't-op'); + const approver = authenticate(db, 'ws', 'test:approver', 'test', 't-ap'); + const viewer = authenticate(db, 'ws', 'test:viewer', 'test', 't-view'); + const accountId = await registerProviderAccount(engine, admin, { + provider: 'fake-email', + externalAccountId: 'sender@example.com', + sender: { name: 'Sam Sender', address: 'sender@example.com', organization: 'Example Co', postalAddress: '1 Example Street, Springfield' }, + purposes: ['automated_outreach'], + secretRef: 'env:FAKE_EMAIL', + webhookSecretRef: 'env:FAKE_WEBHOOK', + }); + createTemplate(db, operator, { name: 'intro', channel: 'email', subject: 'Quick question, {{first_name}}', text: 'Hi {{first_name}}, I saw {{org_name}} is growing. Worth a chat?\n{{sender_name}}' }, now); + createTemplate(db, operator, { name: 'followup', channel: 'email', text: 'Following up on my note, {{first_name}}.' }, now); + createTemplate(db, operator, { name: 'social', channel: 'linkedin', text: 'Hi {{first_name}}, sent you an email about {{org_name}}.' }, now); + createTemplate(db, operator, { name: 'close', channel: 'email', text: 'Last note from me, {{first_name}}.' }, now); + + const env: DomainTestEnv = { + engine, + fake, + notifier, + admin, + operator, + approver, + viewer, + accountId, + now: () => now, + advance: (ms) => { + now += ms; + }, + setNow: (ms) => { + now = ms; + }, + drain: async (passes = 5) => { + for (let i = 0; i < passes; i += 1) { + const report = await engine.runOnce(); + if (report.execute.claimed === 0 && report.reconcile.found + report.reconcile.absent === 0) return; + } + }, + contact: (email, extra = {}) => + addContact(engine, operator, { + fullName: 'Ada Lovelace', + firstName: 'Ada', + email, + timezone: 'America/New_York', + organization: { name: 'Analytical Engines', domain: email.split('@')[1] ?? 'example.org' }, + consentBasis: 'legitimate_interest', + ...extra, + }), + }; + return env; +} diff --git a/outreach-engine/packages/outreach-core/src/domain/env.ts b/outreach-engine/packages/outreach-core/src/domain/env.ts new file mode 100644 index 0000000..877279e --- /dev/null +++ b/outreach-engine/packages/outreach-core/src/domain/env.ts @@ -0,0 +1,68 @@ +import type { ProviderAdapter, SqlDatabase } from '@splitin/outreach-contracts'; +import type { ExecutionDeps } from '../execution/types'; + +export interface UnsubscribeConfig { + /** Public base URL, e.g. https://outreach.example.com/u/ (decision D3). */ + readonly baseUrl: string; + /** HMAC secret for tokens; resolved once at startup, never stored in the database. */ + readonly secret: string; +} + +export interface DomainEnv { + readonly db: SqlDatabase; + readonly now: () => number; + readonly adapters: ReadonlyMap; + readonly unsubscribe?: UnsubscribeConfig; + /** Execution wiring, used by services that trigger worker-level operations (review resolution). */ + readonly exec: ExecutionDeps; +} + +export interface CampaignVersionRow { + id: string; + campaign_id: string; + version: number; + sequence_version_id: string; + provider_account_id: string; + policy: string; + policy_hash: string; + audience: string; + audience_hash: string | null; + version_hash: string | null; + approval_id: string | null; + activated_at: number | null; +} + +export interface CampaignRow { + id: string; + workspace_id: string; + name: string; + purpose: string; + status: 'draft' | 'active' | 'paused' | 'completed' | 'archived'; + active_version_id: string | null; + paused_reason: string | null; +} + +export interface EnrollmentRow { + id: string; + workspace_id: string; + campaign_id: string; + campaign_version_id: string; + contact_id: string; + contact_point_id: string; + status: 'active' | 'paused' | 'replied' | 'opted_out' | 'bounced' | 'completed' | 'stopped' | 'error'; + stop_reason: string | null; + current_step_id: string | null; + row_version: number; +} + +export function loadCampaign(db: SqlDatabase, workspaceId: string, id: string): CampaignRow | undefined { + return db.prepare('SELECT * FROM campaigns WHERE workspace_id = ? AND id = ?').get(workspaceId, id); +} + +export function loadVersion(db: SqlDatabase, id: string): CampaignVersionRow | undefined { + return db.prepare('SELECT * FROM campaign_versions WHERE id = ?').get(id); +} + +export function loadEnrollment(db: SqlDatabase, id: string): EnrollmentRow | undefined { + return db.prepare('SELECT * FROM enrollments WHERE id = ?').get(id); +} diff --git a/outreach-engine/packages/outreach-core/src/domain/index.ts b/outreach-engine/packages/outreach-core/src/domain/index.ts new file mode 100644 index 0000000..937354e --- /dev/null +++ b/outreach-engine/packages/outreach-core/src/domain/index.ts @@ -0,0 +1,12 @@ +export * from './auth'; +export * from './calendar'; +export * from './playbook'; +export * from './templates'; +export * from './env'; +export * from './suppressions'; +export * from './approvals'; +export * from './compile'; +export * from './campaigns'; +export * from './materialize'; +export * from './wiring'; +export * from './operations'; diff --git a/outreach-engine/packages/outreach-core/src/domain/materialize.ts b/outreach-engine/packages/outreach-core/src/domain/materialize.ts new file mode 100644 index 0000000..f5462a2 --- /dev/null +++ b/outreach-engine/packages/outreach-core/src/domain/materialize.ts @@ -0,0 +1,232 @@ +import type { CapabilitySnapshot, SenderIdentity, SqlDatabase } from '@splitin/outreach-contracts'; +import { enqueueAction } from '../execution/enqueue'; +import type { Actor } from '../execution/actions-repo'; +import type { EmailPayload, ManualPayload } from '../execution/invoke'; +import { addDuration, nextSlot, resolveZone, durationMs } from './calendar'; +import { loadCampaign, loadEnrollment, loadVersion, type CampaignRow, type CampaignVersionRow, type DomainEnv, type EnrollmentRow } from './env'; +import type { Policy, Step } from './playbook'; +import { createUnsubscribeToken, stopEnrollment } from './suppressions'; +import { TemplateRenderError, escapeHtml, findTemplate, render } from './templates'; + +export interface StepContext { + readonly enrollment: EnrollmentRow; + readonly campaign: CampaignRow; + readonly version: CampaignVersionRow; + readonly steps: readonly Step[]; + readonly policy: Policy; +} + +interface ContactView { + full_name: string; + first_name: string | null; + title: string | null; + timezone: string | null; + attributes: string; + org_name: string | null; + org_domain: string | null; + email: string; + profile_url: string | null; +} + +export function loadStepContext(db: SqlDatabase, enrollmentId: string): StepContext | null { + const enrollment = loadEnrollment(db, enrollmentId); + if (!enrollment) return null; + const campaign = loadCampaign(db, enrollment.workspace_id, enrollment.campaign_id); + const version = loadVersion(db, enrollment.campaign_version_id); + if (!campaign || !version) return null; + const sequence = db.prepare('SELECT spec FROM sequence_versions WHERE id = ?').get<{ spec: string }>(version.sequence_version_id); + if (!sequence) return null; + const spec = JSON.parse(sequence.spec) as { steps: Step[] }; + return { enrollment, campaign, version, steps: spec.steps, policy: JSON.parse(version.policy) as Policy }; +} + +function loadContact(db: SqlDatabase, enrollment: EnrollmentRow): ContactView | undefined { + return db + .prepare( + `SELECT c.full_name, c.first_name, c.title, c.timezone, c.attributes, o.name AS org_name, o.domain_norm AS org_domain, + cp.value_norm AS email, + (SELECT value_norm FROM contact_points s WHERE s.contact_id = c.id AND s.kind = 'social_profile' LIMIT 1) AS profile_url + FROM contacts c JOIN contact_points cp ON cp.id = ? LEFT JOIN organizations o ON o.id = c.organization_id + WHERE c.id = ?`, + ) + .get(enrollment.contact_point_id, enrollment.contact_id); +} + +function senderOf(db: SqlDatabase, accountId: string): { sender: SenderIdentity; caps: Partial } { + const row = db.prepare('SELECT sender_identity, capabilities FROM provider_accounts WHERE id = ?').get<{ sender_identity: string; capabilities: string }>(accountId); + if (!row) throw new Error(`provider account ${accountId} not found`); + return { sender: JSON.parse(row.sender_identity) as SenderIdentity, caps: JSON.parse(row.capabilities) as Partial }; +} + +function tokenValues(env: DomainEnv, sc: StepContext, contact: ContactView, sender: SenderIdentity): Record { + const attributes = JSON.parse(contact.attributes) as Record; + const values: Record = { + first_name: contact.first_name ?? undefined, + full_name: contact.full_name, + title: contact.title ?? undefined, + org_name: contact.org_name ?? undefined, + org_domain: contact.org_domain ?? undefined, + sender_name: sender.name, + sender_email: sender.address, + sender_org: sender.organization ?? undefined, + sender_address: sender.postalAddress ?? undefined, + }; + for (const [key, value] of Object.entries(attributes)) { + if (typeof value === 'string' || typeof value === 'number') values[`attr.${key.toLowerCase()}`] = String(value); + } + if (env.unsubscribe) { + values.unsubscribe_url = `${env.unsubscribe.baseUrl}${createUnsubscribeToken(env.unsubscribe.secret, { + w: sc.enrollment.workspace_id, + cp: sc.enrollment.contact_point_id, + c: sc.campaign.id, + })}`; + } + return values; +} + +function footer(sc: StepContext, sender: SenderIdentity, unsubscribeUrl: string | undefined): string[] { + const lines: string[] = []; + if (sc.policy.requirePostalAddress) lines.push(`${sender.organization ?? sender.name}, ${sender.postalAddress ?? ''}`.trim()); + if (sc.policy.unsubscribe === 'link' && unsubscribeUrl) lines.push(`Unsubscribe: ${unsubscribeUrl}`); + else lines.push('Reply "unsubscribe" and we will not contact you again.'); + return lines; +} + +function buildEmail(env: DomainEnv, sc: StepContext, step: Extract, contact: ContactView): EmailPayload { + const { db } = env; + const template = findTemplate(db, sc.campaign.workspace_id, step.template); + if (!template) throw new TemplateRenderError([`template:${step.template}`]); + const { sender, caps } = senderOf(db, sc.version.provider_account_id); + const values = tokenValues(env, sc, contact, sender); + const unsubscribeUrl = sc.policy.unsubscribe === 'link' ? values.unsubscribe_url : undefined; + const lines = footer(sc, sender, unsubscribeUrl); + const text = `${render(template.body_text, values, 'text')}\n\n--\n${lines.join('\n')}`; + const html = template.body_html + ? `${render(template.body_html, values, 'html')}

${lines.map(escapeHtml).join('
')}

` + : undefined; + const headers: Record = {}; + if (unsubscribeUrl && caps.customHeaders) { + headers['List-Unsubscribe'] = `<${unsubscribeUrl}>`; + headers['List-Unsubscribe-Post'] = 'List-Unsubscribe=One-Click'; + } + let subject = template.subject ? render(template.subject, values, 'text') : ''; + const base = { + from: { address: sender.address, name: sender.name }, + to: [{ address: contact.email, name: contact.full_name }], + ...(sender.replyTo ? { replyTo: { address: sender.replyTo } } : {}), + text, + ...(html ? { html } : {}), + headers, + }; + if (step.type === 'email.send') return { ...base, subject }; + const previous = db + .prepare(`SELECT * FROM messages WHERE enrollment_id = ? AND direction = 'outbound' ORDER BY at DESC LIMIT 1`) + .get<{ rfc_message_id: string | null; references_ids: string; provider_thread_id: string | null; subject: string | null }>(sc.enrollment.id); + if (!previous?.rfc_message_id) throw new TemplateRenderError(['previous_message']); + if (!subject) subject = /^re:/i.test(previous.subject ?? '') ? (previous.subject ?? '') : `Re: ${previous.subject ?? ''}`; + const references = [...(JSON.parse(previous.references_ids) as string[]), previous.rfc_message_id]; + return { + ...base, + subject, + inReplyTo: previous.rfc_message_id, + references, + ...(previous.provider_thread_id ? { providerThreadId: previous.provider_thread_id } : {}), + }; +} + +function renderManual(env: DomainEnv, sc: StepContext, ref: string, contact: ContactView): string { + const template = findTemplate(env.db, sc.campaign.workspace_id, ref); + if (!template) throw new TemplateRenderError([`template:${ref}`]); + return render(template.body_text, tokenValues(env, sc, contact, senderOf(env.db, sc.version.provider_account_id).sender), 'text'); +} + +export interface MaterializeOptions { + /** During activation: collect the first N email actions for one batch approval. */ + readonly batch?: { ids: string[]; size: number }; + /** Creates an action-scope approval for an action (every_action policy, or step approval: always). */ + readonly requestActionApproval: (actionId: string, contentHash: string) => string; +} + +export type MaterializeResult = 'created' | 'completed' | 'error' | 'not_live'; + +/** Creates the next actionable step after `afterIndex`, honouring waits and the send window. */ +export function materializeNext( + env: DomainEnv, + sc: StepContext, + afterIndex: number, + from: number, + actor: Actor, + options: MaterializeOptions, +): MaterializeResult { + const { db } = env; + const now = env.now(); + if (sc.enrollment.status !== 'active') return 'not_live'; + const contact = loadContact(db, sc.enrollment); + if (!contact) return 'error'; + const window = sc.policy.window; + const zone = resolveZone(window, contact.timezone); + let due = from; + for (let index = afterIndex + 1; index < sc.steps.length; index += 1) { + const step = sc.steps[index]; + if (!step) break; + if (step.type === 'wait') { + due = addDuration(due, step.duration, step.calendar, window, zone); + continue; + } + const dueAt = nextSlot(Math.max(due, now), window, zone); + try { + const payload: EmailPayload | ManualPayload = + step.type === 'manual.task' + ? { + channel: step.channel, + draft: renderManual(env, sc, step.template, contact), + ...(contact.profile_url ? { targetUrl: contact.profile_url } : {}), + } + : buildEmail(env, sc, step, contact); + const isEmail = step.type !== 'manual.task'; + const collectForBatch = isEmail && !!options.batch && options.batch.ids.length < options.batch.size; + const perAction = isEmail && (sc.policy.approval === 'every_action' || step.approval === 'always') && !collectForBatch; + const { action } = enqueueAction( + db, + { + workspaceId: sc.enrollment.workspace_id, + kind: step.type, + payload, + idempotencyKey: `seq:${sc.enrollment.id}:${step.id}`, + dueAt, + notAfter: dueAt + durationMs(sc.policy.expireAfter), + providerAccountId: isEmail ? sc.version.provider_account_id : null, + purpose: sc.campaign.purpose, + recipient: isEmail ? contact.email : null, + enrollmentId: sc.enrollment.id, + campaignId: sc.campaign.id, + stepId: step.id, + contactPointId: sc.enrollment.contact_point_id, + senderDomain: senderOf(db, sc.version.provider_account_id).sender.address.split('@')[1] ?? 'outreach.invalid', + awaitingApproval: collectForBatch || perAction, + approvalId: isEmail && !collectForBatch && !perAction && sc.policy.approval !== 'none' ? sc.version.approval_id : null, + }, + actor, + now, + ); + if (collectForBatch) options.batch?.ids.push(action.id); + if (perAction) { + const approvalId = options.requestActionApproval(action.id, action.content_hash); + db.prepare('UPDATE scheduled_actions SET approval_id = ? WHERE id = ?').run(approvalId, action.id); + } + db.prepare('UPDATE enrollments SET current_step_id = ?, row_version = row_version + 1, updated_at = ? WHERE id = ?').run(step.id, now, sc.enrollment.id); + return 'created'; + } catch (error) { + if (!(error instanceof TemplateRenderError)) throw error; + stopEnrollment(db, sc.enrollment.id, 'error', `template_missing:${error.missing.join(',')}`.slice(0, 200), actor, now); + return 'error'; + } + } + db.prepare(`UPDATE enrollments SET status = 'completed', stop_reason = 'sequence_finished', row_version = row_version + 1, updated_at = ? WHERE id = ? AND status = 'active'`).run(now, sc.enrollment.id); + return 'completed'; +} + +export function stepIndex(sc: StepContext, stepId: string | null): number { + return sc.steps.findIndex((step) => step.id === stepId); +} + diff --git a/outreach-engine/packages/outreach-core/src/domain/operations.ts b/outreach-engine/packages/outreach-core/src/domain/operations.ts new file mode 100644 index 0000000..e641c94 --- /dev/null +++ b/outreach-engine/packages/outreach-core/src/domain/operations.ts @@ -0,0 +1,160 @@ +import { ulid, type ProviderPurpose, type SenderIdentity } from '@splitin/outreach-contracts'; +import { providerContext } from '../execution/invoke'; +import type { AccountRow } from '../execution/types'; +import { ConflictError, NotFoundError, actorOf, audit, requireRole, type AuthContext, type Role } from './auth'; +import type { DomainEnv, EnrollmentRow } from './env'; +import { loadStepContext, materializeNext, stepIndex } from './materialize'; +import { actionApprover } from './campaigns'; +import { addSuppression, normalizeEmail, stopEnrollment, type SuppressionInput } from './suppressions'; + +export function addPrincipal(env: DomainEnv, ctx: AuthContext, input: { externalRef: string; displayName: string; roles: Role[] }): string { + requireRole(ctx, 'admin'); + return env.db.transaction(() => { + const now = env.now(); + const id = ulid(now); + env.db.prepare('INSERT INTO principals (id, workspace_id, external_ref, display_name, roles) VALUES (?,?,?,?,?)') + .run(id, ctx.workspaceId, input.externalRef, input.displayName, JSON.stringify(input.roles)); + audit(env.db, ctx, now, 'principal', id, 'created', { externalRef: input.externalRef, roles: input.roles }); + return id; + }); +} + +export interface RegisterAccountInput { + readonly provider: string; + readonly externalAccountId: string; + readonly sender: SenderIdentity; + /** Purposes the operator attests their contract with the provider allows. */ + readonly purposes: ProviderPurpose[]; + readonly secretRef: string; + readonly webhookSecretRef?: string; +} + +/** Registers a sending account and records the adapter's capability snapshot. Secrets are references only. */ +export async function registerProviderAccount(env: DomainEnv, ctx: AuthContext, input: RegisterAccountInput): Promise { + requireRole(ctx, 'admin'); + if (!/^(env|keychain):[A-Za-z0-9_.-]+$/.test(input.secretRef)) throw new Error('secretRef must look like env:NAME or keychain:NAME'); + const adapter = env.adapters.get(input.provider); + if (!adapter) throw new NotFoundError(`adapter ${input.provider}`); + const id = ulid(env.now()); + const row: AccountRow = { + id, + workspace_id: ctx.workspaceId, + provider: input.provider, + external_account_id: input.externalAccountId, + sender_identity: JSON.stringify(input.sender), + purposes: JSON.stringify(input.purposes), + capabilities: '{}', + secret_ref: input.secretRef, + webhook_secret_ref: input.webhookSecretRef ?? null, + health: 'ok', + }; + const capabilities = await adapter.account.discover(providerContext(env.exec, row, ctx.traceId, AbortSignal.timeout(30_000))); + env.db.transaction(() => { + const now = env.now(); + env.db.prepare( + `INSERT INTO provider_accounts (id, workspace_id, provider, external_account_id, sender_identity, purposes, capabilities, + secret_ref, webhook_secret_ref, health, health_checked_at) VALUES (?,?,?,?,?,?,?,?,?, 'ok', ?)`, + ).run(id, ctx.workspaceId, input.provider, input.externalAccountId, row.sender_identity, row.purposes, JSON.stringify(capabilities), input.secretRef, row.webhook_secret_ref, now); + audit(env.db, ctx, now, 'provider_account', id, 'registered', { provider: input.provider, purposes: input.purposes }); + }); + return id; +} + +export interface ContactInput { + readonly fullName: string; + readonly firstName?: string; + readonly title?: string; + readonly email: string; + readonly timezone?: string; + readonly organization?: { name: string; domain?: string }; + readonly consentBasis: 'consent' | 'legitimate_interest' | 'existing_relationship' | 'unknown'; + readonly attributes?: Record; +} + +/** Adds a single contact by hand. Bulk import lives in @splitin/outreach-import. */ +export function addContact(env: DomainEnv, ctx: AuthContext, input: ContactInput): string { + requireRole(ctx, 'operator'); + return env.db.transaction(() => { + const now = env.now(); + const email = normalizeEmail(input.email); + if (env.db.prepare(`SELECT 1 FROM contact_points WHERE workspace_id = ? AND kind = 'email' AND value_norm = ?`).get(ctx.workspaceId, email)) { + throw new ConflictError(`contact with ${email} already exists`); + } + let organizationId: string | null = null; + if (input.organization) { + const domain = input.organization.domain?.toLowerCase() ?? null; + const existing = domain ? env.db.prepare('SELECT id FROM organizations WHERE workspace_id = ? AND domain_norm = ?').get<{ id: string }>(ctx.workspaceId, domain) : undefined; + organizationId = existing?.id ?? ulid(now); + if (!existing) env.db.prepare('INSERT INTO organizations (id, workspace_id, name, domain_norm) VALUES (?,?,?,?)').run(organizationId, ctx.workspaceId, input.organization.name, domain); + } + const contactId = ulid(now); + env.db.prepare( + `INSERT INTO contacts (id, workspace_id, organization_id, full_name, first_name, title, timezone, attributes, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?)`, + ).run(contactId, ctx.workspaceId, organizationId, input.fullName, input.firstName ?? null, input.title ?? null, input.timezone ?? null, JSON.stringify(input.attributes ?? {}), now, now); + env.db.prepare( + `INSERT INTO contact_points (id, workspace_id, contact_id, kind, value_norm, value_raw, source, consent_basis, consent_at, permitted_channels) + VALUES (?,?,?,'email',?,?,'manual',?,?,'["email"]')`, + ).run(ulid(now), ctx.workspaceId, contactId, email, input.email, input.consentBasis, now); + audit(env.db, ctx, now, 'contact', contactId, 'created', { source: 'manual' }); + return contactId; + }); +} + +export function suppress(env: DomainEnv, ctx: AuthContext, input: Omit): void { + requireRole(ctx, 'operator'); + env.db.transaction(() => { + const now = env.now(); + addSuppression(env.db, { ...input, workspaceId: ctx.workspaceId, source: `principal:${ctx.principalId}` }, now); + audit(env.db, ctx, now, 'suppression', normalizeEmail(input.value), 'added', { scope: input.scope, reason: input.reason }); + }); +} + +export function setEnrollmentState(env: DomainEnv, ctx: AuthContext, enrollmentId: string, change: 'pause' | 'resume' | 'stop', reason: string): void { + requireRole(ctx, 'operator'); + env.db.transaction(() => { + const now = env.now(); + const row = env.db.prepare('SELECT * FROM enrollments WHERE workspace_id = ? AND id = ?').get(ctx.workspaceId, enrollmentId); + if (!row) throw new NotFoundError(`enrollment ${enrollmentId}`); + if (change === 'stop') { + if (!stopEnrollment(env.db, enrollmentId, 'stopped', reason, actorOf(ctx), now)) throw new ConflictError(`enrollment is ${row.status}`); + } else { + const [from, to] = change === 'pause' ? ['active', 'paused'] : ['paused', 'active']; + const result = env.db.prepare('UPDATE enrollments SET status = ?, row_version = row_version + 1, updated_at = ? WHERE id = ? AND status = ?').run(to ?? '', now, enrollmentId, from ?? ''); + if (result.changes !== 1) throw new ConflictError(`enrollment is ${row.status}`); + } + audit(env.db, ctx, now, 'enrollment', enrollmentId, change, { reason }); + }); +} + +export interface ManualTaskRow { + id: string; + action_id: string; + enrollment_id: string | null; + channel: string; + target_url: string | null; + draft_text: string; + status: 'open' | 'done' | 'skipped' | 'expired'; +} + +export function listManualTasks(env: DomainEnv, ctx: AuthContext, status: ManualTaskRow['status'] = 'open'): ManualTaskRow[] { + requireRole(ctx, 'viewer'); + return env.db.prepare('SELECT * FROM manual_tasks WHERE workspace_id = ? AND status = ? ORDER BY created_at').all(ctx.workspaceId, status); +} + +/** Only a human can complete a manual task (ADR 0003). Completion advances the enrollment. */ +export function recordManualOutcome(env: DomainEnv, ctx: AuthContext, taskId: string, outcome: 'done' | 'skipped', note?: string): void { + requireRole(ctx, 'operator'); + env.db.transaction(() => { + const now = env.now(); + const task = env.db.prepare('SELECT * FROM manual_tasks WHERE workspace_id = ? AND id = ?').get(ctx.workspaceId, taskId); + if (!task) throw new NotFoundError(`task ${taskId}`); + if (task.status !== 'open') throw new ConflictError(`task is ${task.status}`); + env.db.prepare('UPDATE manual_tasks SET status = ?, confirmed_by = ?, confirmed_at = ?, note = ? WHERE id = ?').run(outcome, ctx.principalId, now, note ?? null, taskId); + audit(env.db, ctx, now, 'manual_task', taskId, outcome, { note: note ?? null }); + if (!task.enrollment_id) return; + const sc = loadStepContext(env.db, task.enrollment_id); + const step = env.db.prepare('SELECT step_id FROM scheduled_actions WHERE id = ?').get<{ step_id: string | null }>(task.action_id); + if (sc && step) materializeNext(env, sc, stepIndex(sc, step.step_id), now, actorOf(ctx), { requestActionApproval: actionApprover(env, ctx.workspaceId, ctx.principalId) }); + }); +} diff --git a/outreach-engine/packages/outreach-core/src/domain/playbook.test.ts b/outreach-engine/packages/outreach-core/src/domain/playbook.test.ts new file mode 100644 index 0000000..b6eee69 --- /dev/null +++ b/outreach-engine/packages/outreach-core/src/domain/playbook.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; +import { ForbiddenError } from './auth'; +import { createCampaign } from './campaigns'; +import { compileIssues } from './compile'; +import { PLAYBOOK, makeDomainEnv } from './domain.test-util'; +import { PlaybookError, parsePlaybook } from './playbook'; +import { TemplateRenderError, createTemplate, render } from './templates'; + +describe('parsePlaybook', () => { + it('parses YAML and fills policy defaults', () => { + const playbook = parsePlaybook(` +apiVersion: outreach.splitin.net/v1alpha1 +kind: Playbook +metadata: { name: minimal } +spec: + purpose: automated_outreach + steps: [{ id: intro, type: email.send, template: intro@1 }] +`); + expect(playbook.spec.policy).toMatchObject({ approval: 'first_batch_then_campaign', firstBatchSize: 20, unsubscribe: 'link', expireAfter: 'P14D' }); + expect(playbook.spec.policy.window).toMatchObject({ timezone: 'recipient', start: '09:30', end: '16:30' }); + expect(playbook.spec.audience).toEqual({ source: 'all', require: ['email'], eligibility: ['consent_or_legitimate_interest'] }); + }); + + it('reports structural errors with paths', () => { + const bad = PLAYBOOK.replace('duration: P3D', 'duration: three days').replace('start: "09:00"', 'start: "9am"'); + const error = (() => { + try { + parsePlaybook(bad); + } catch (e) { + return e as PlaybookError; + } + throw new Error('expected failure'); + })(); + expect(error).toBeInstanceOf(PlaybookError); + expect(error.issues.join('\n')).toMatch(/steps\.1\.duration/); + expect(error.issues.join('\n')).toMatch(/policy\.window\.start/); + }); + + it('rejects social steps that are not manual tasks and unknown fields', () => { + expect(() => parsePlaybook(PLAYBOOK.replace('type: manual.task, channel: linkedin', 'type: linkedin.connect, channel: linkedin'))).toThrow(PlaybookError); + expect(() => parsePlaybook(PLAYBOOK.replace('firstBatchSize: 2', 'firstBatchSize: 2\n stealth: true'))).toThrow(PlaybookError); + }); +}); + +describe('compileIssues', () => { + it('passes the reference playbook', async () => { + const env = await makeDomainEnv(); + expect(compileIssues(env.engine, 'ws', parsePlaybook(PLAYBOOK), env.accountId)).toEqual([]); + }); + + it('finds semantic problems', async () => { + const env = await makeDomainEnv(); + const playbook = parsePlaybook(PLAYBOOK.replace('purpose: automated_outreach', 'purpose: marketing').replace('template: intro@1', 'template: missing@3')); + const replyFirst = parsePlaybook(PLAYBOOK.replace('type: email.send, template: intro@1', 'type: email.reply, template: intro@1')); + const issues = compileIssues(env.engine, 'ws', playbook, env.accountId).join('\n'); + expect(issues).toMatch(/template missing@3 does not exist/); + expect(issues).toMatch(/purpose marketing is not permitted/); + expect(compileIssues(env.engine, 'ws', replyFirst, env.accountId).join('\n')).toMatch(/needs an earlier email.send/); + expect(compileIssues(env.engine, 'ws', parsePlaybook(PLAYBOOK), 'nope')).toEqual(['provider account nope does not exist in this workspace']); + }); + + it('refuses to create campaigns from invalid playbooks or without the operator role', async () => { + const env = await makeDomainEnv(); + expect(() => createCampaign(env.engine, env.operator, { name: 'x', playbook: PLAYBOOK.replace('intro@1', 'intro@9'), providerAccountId: env.accountId })).toThrow(PlaybookError); + expect(() => createCampaign(env.engine, env.viewer, { name: 'x', playbook: PLAYBOOK, providerAccountId: env.accountId })).toThrow(ForbiddenError); + }); +}); + +describe('templates', () => { + it('render fails closed and escapes HTML', () => { + expect(render('Hi {{ first_name }}', { first_name: 'Ada' }, 'html')).toBe('Hi <b>Ada</b>'); + expect(render('Hi {{first_name}}', { first_name: 'Ada\r\nBcc: x@example.com' }, 'text')).toBe('Hi Ada Bcc: x@example.com'); + expect(() => render('Hi {{first_name}} at {{org_name}}', { first_name: ' ' }, 'text')).toThrow(TemplateRenderError); + }); + + it('versions immutably and rejects unknown tokens', async () => { + const env = await makeDomainEnv(); + const v2 = createTemplate(env.engine.db, env.operator, { name: 'intro', channel: 'email', subject: 'Hi', text: 'v2 {{attr.segment}}' }, env.now()); + expect(v2.version).toBe(2); + expect(() => createTemplate(env.engine.db, env.operator, { name: 'bad', channel: 'email', subject: 'x', text: '{{password}}' }, env.now())).toThrow(/Unknown template tokens: password/); + }); +}); diff --git a/outreach-engine/packages/outreach-core/src/domain/playbook.ts b/outreach-engine/packages/outreach-core/src/domain/playbook.ts new file mode 100644 index 0000000..39bbbc0 --- /dev/null +++ b/outreach-engine/packages/outreach-core/src/domain/playbook.ts @@ -0,0 +1,108 @@ +import { OUTREACH_API_VERSION, PROVIDER_PURPOSES } from '@splitin/outreach-contracts'; +import { parse as parseYaml } from 'yaml'; +import { z } from 'zod'; +import { WEEKDAYS, isValidDuration, isValidZone } from './calendar'; + +const hhmm = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, 'expected HH:mm'); +const isoDuration = z.string().refine(isValidDuration, 'expected an ISO-8601 duration such as P3D or PT4H'); +const zone = z.string().refine(isValidZone, 'expected an IANA time zone'); +const templateRef = z.string().regex(/^[a-z0-9][a-z0-9_.-]*@\d+$/, 'expected name@version'); +const stepId = z.string().regex(/^[a-z0-9][a-z0-9_-]{0,63}$/, 'expected a lowercase step id'); +const when = z.enum(['always', 'no_reply']).default('always'); + +const emailStep = { + id: stepId, + template: templateRef, + approval: z.enum(['inherit', 'always']).default('inherit'), + when, +}; + +export const StepSchema = z.discriminatedUnion('type', [ + z.object({ ...emailStep, type: z.literal('email.send') }).strict(), + z.object({ ...emailStep, type: z.literal('email.reply') }).strict(), + z.object({ id: stepId, type: z.literal('wait'), duration: isoDuration, calendar: z.enum(['business', 'calendar']).default('business') }).strict(), + z.object({ id: stepId, type: z.literal('manual.task'), channel: z.string().min(1).max(40), template: templateRef, when }).strict(), +]); + +export const PolicySchema = z + .object({ + approval: z.enum(['none', 'every_action', 'first_batch_then_campaign']).default('first_batch_then_campaign'), + firstBatchSize: z.number().int().min(1).max(500).default(20), + window: z + .object({ + timezone: z.union([z.literal('recipient'), zone]).default('recipient'), + fallback: zone.default('America/New_York'), + days: z.array(z.enum(WEEKDAYS)).min(1).default(['Mon', 'Tue', 'Wed', 'Thu']), + start: hhmm.default('09:30'), + end: hhmm.default('16:30'), + holidays: z.array(z.string().regex(/^\d{4}-\d{2}-\d{2}$/)).default([]), + }) + .strict() + .refine((w) => w.start < w.end, 'window start must be before end') + .default({ timezone: 'recipient', fallback: 'America/New_York', days: ['Mon', 'Tue', 'Wed', 'Thu'], start: '09:30', end: '16:30', holidays: [] }), + limits: z + .object({ + accountPerDay: z.number().int().min(1).max(10_000).default(40), + domainPerDay: z.number().int().min(1).max(1_000).default(3), + campaignPerDay: z.number().int().min(1).max(10_000).optional(), + recipientMinGap: isoDuration.default('P3D'), + }) + .strict() + .default({ accountPerDay: 40, domainPerDay: 3, recipientMinGap: 'P3D' }), + requirePostalAddress: z.boolean().default(true), + unsubscribe: z.enum(['link', 'reply']).default('link'), + expireAfter: isoDuration.default('P14D'), + }) + .strict(); + +export const AudienceSchema = z + .object({ + source: z + .union([ + z.literal('all'), + z.object({ importBatch: z.string().min(1) }).strict(), + z.object({ contactIds: z.array(z.string().min(1)).min(1).max(100_000) }).strict(), + ]) + .default('all'), + require: z.array(z.literal('email')).default(['email']), + eligibility: z.array(z.enum(['consent_or_legitimate_interest', 'any'])).default(['consent_or_legitimate_interest']), + }) + .strict(); + +export const PlaybookSchema = z + .object({ + apiVersion: z.literal(OUTREACH_API_VERSION), + kind: z.literal('Playbook'), + metadata: z.object({ name: z.string().regex(/^[a-z0-9][a-z0-9-]{0,63}$/) }).strict(), + spec: z + .object({ + purpose: z.enum(PROVIDER_PURPOSES), + audience: AudienceSchema.default({ source: 'all', require: ['email'], eligibility: ['consent_or_legitimate_interest'] }), + policy: PolicySchema.default(PolicySchema.parse({})), + steps: z.array(StepSchema).min(1).max(50), + }) + .strict(), + }) + .strict(); + +export type Playbook = z.infer; +export type Step = z.infer; +export type Policy = z.infer; +export type Audience = z.infer; + +export class PlaybookError extends Error { + constructor(readonly issues: readonly string[]) { + super(`Invalid playbook:\n- ${issues.join('\n- ')}`); + this.name = 'PlaybookError'; + } +} + +/** Parses YAML or an object into a validated playbook. Structural checks only; see compile for semantic ones. */ +export function parsePlaybook(input: string | unknown): Playbook { + const raw = typeof input === 'string' ? (parseYaml(input) as unknown) : input; + const result = PlaybookSchema.safeParse(raw); + if (!result.success) { + throw new PlaybookError(result.error.issues.map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`)); + } + return result.data; +} diff --git a/outreach-engine/packages/outreach-core/src/domain/suppressions.ts b/outreach-engine/packages/outreach-core/src/domain/suppressions.ts new file mode 100644 index 0000000..6086133 --- /dev/null +++ b/outreach-engine/packages/outreach-core/src/domain/suppressions.ts @@ -0,0 +1,110 @@ +import { hmacSha256Hex, safeEqualHex, ulid, type SqlDatabase } from '@splitin/outreach-contracts'; +import { CANCELLABLE_ACTION_STATES } from '@splitin/outreach-contracts'; +import { transitionAction, type Actor } from '../execution/actions-repo'; +import type { ActionRow } from '../execution/types'; +import type { EnrollmentRow } from './env'; + +export type SuppressionScope = 'global' | 'channel' | 'provider_account' | 'domain'; +export type SuppressionReason = 'opt_out' | 'hard_bounce' | 'complaint' | 'manual' | 'do_not_contact' | 'legal'; + +export interface SuppressionInput { + readonly workspaceId: string; + readonly scope: SuppressionScope; + /** Channel name for 'channel' scope, provider account id for 'provider_account', '*' otherwise. */ + readonly channel?: string; + readonly value: string; + readonly reason: SuppressionReason; + readonly source: string; +} + +export function normalizeEmail(value: string): string { + return value.trim().replace(/^mailto:/i, '').toLowerCase(); +} + +/** Idempotent: suppressing an already-suppressed value keeps the earliest record. Must run in a transaction. */ +export function addSuppression(db: SqlDatabase, input: SuppressionInput, now: number): void { + db.prepare( + `INSERT INTO suppressions (id, workspace_id, scope, channel, value_norm, reason, source, effective_at) + VALUES (?,?,?,?,?,?,?,?) ON CONFLICT (workspace_id, scope, channel, value_norm) DO NOTHING`, + ).run(ulid(now), input.workspaceId, input.scope, input.channel ?? '*', normalizeEmail(input.value), input.reason, input.source, now); +} + +/** Checks every scope that can cover an email recipient: global, email channel, sending account and domain. */ +export function isSuppressed(db: SqlDatabase, workspaceId: string, recipient: string, providerAccountId: string | null): string | null { + const value = normalizeEmail(recipient); + const domain = value.split('@')[1] ?? ''; + const row = db + .prepare( + `SELECT scope, reason FROM suppressions WHERE workspace_id = ? AND ( + (scope = 'global' AND value_norm = ?) OR + (scope = 'channel' AND channel IN ('email','*') AND value_norm = ?) OR + (scope = 'provider_account' AND channel = ? AND value_norm = ?) OR + (scope = 'domain' AND value_norm = ?) + ) LIMIT 1`, + ) + .get<{ scope: string; reason: string }>(workspaceId, value, value, providerAccountId ?? '', value, domain); + return row ? `${row.scope}:${row.reason}` : null; +} + +type StopStatus = 'replied' | 'opted_out' | 'bounced' | 'completed' | 'stopped' | 'error'; + +/** + * Stops an enrollment atomically: status change plus cancellation of every pending action and open task. + * `executing` and `uncertain` actions cannot be recalled (ADR 0002). Returns false if it was not live. + */ +export function stopEnrollment( + db: SqlDatabase, + enrollmentId: string, + status: StopStatus, + reason: string, + actor: Actor, + now: number, +): boolean { + const changed = db + .prepare( + `UPDATE enrollments SET status = ?, stop_reason = ?, row_version = row_version + 1, updated_at = ? + WHERE id = ? AND status IN ('active','paused')`, + ) + .run(status, reason, now, enrollmentId); + if (changed.changes !== 1) return false; + const placeholders = CANCELLABLE_ACTION_STATES.map(() => '?').join(','); + const pending = db + .prepare(`SELECT * FROM scheduled_actions WHERE enrollment_id = ? AND state IN (${placeholders})`) + .all(enrollmentId, ...CANCELLABLE_ACTION_STATES); + for (const action of pending) { + transitionAction(db, action, 'cancelled', now, actor, { lease_owner: null, lease_expires_at: null, state_reason: `enrollment_${status}` }); + } + db.prepare(`UPDATE manual_tasks SET status = 'expired', note = ? WHERE enrollment_id = ? AND status = 'open'`).run(`enrollment_${status}`, enrollmentId); + return true; +} + +export function liveEnrollmentsForContact(db: SqlDatabase, workspaceId: string, contactId: string): EnrollmentRow[] { + return db + .prepare(`SELECT * FROM enrollments WHERE workspace_id = ? AND contact_id = ? AND status IN ('active','paused')`) + .all(workspaceId, contactId); +} + +export interface UnsubscribeClaims { + readonly w: string; + readonly cp: string; + readonly c: string; +} + +/** Self-verifying token: base64url(claims).hmac — no database lookup is needed to validate it. */ +export function createUnsubscribeToken(secret: string, claims: UnsubscribeClaims): string { + const body = Buffer.from(JSON.stringify(claims)).toString('base64url'); + return `${body}.${hmacSha256Hex(secret, body)}`; +} + +export function verifyUnsubscribeToken(secret: string, token: string): UnsubscribeClaims | null { + const [body, signature] = token.split('.'); + if (!body || !signature || !safeEqualHex(hmacSha256Hex(secret, body), signature)) return null; + try { + const claims = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as Partial; + return typeof claims.w === 'string' && typeof claims.cp === 'string' && typeof claims.c === 'string' + ? { w: claims.w, cp: claims.cp, c: claims.c } + : null; + } catch { + return null; + } +} diff --git a/outreach-engine/packages/outreach-core/src/domain/templates.ts b/outreach-engine/packages/outreach-core/src/domain/templates.ts new file mode 100644 index 0000000..ed06b8f --- /dev/null +++ b/outreach-engine/packages/outreach-core/src/domain/templates.ts @@ -0,0 +1,118 @@ +import { ulid, type SqlDatabase } from '@splitin/outreach-contracts'; +import { audit, requireRole, type AuthContext } from './auth'; + +/** Tokens a template may use. `attr.` reads contact attributes from the import. */ +export const KNOWN_TOKENS = [ + 'first_name', + 'full_name', + 'title', + 'org_name', + 'org_domain', + 'sender_name', + 'sender_email', + 'sender_org', + 'sender_address', + 'unsubscribe_url', +] as const; + +const TOKEN_RE = /\{\{\s*([a-z_]+(?:\.[a-z0-9_]+)?)\s*\}\}/gi; + +export interface TemplateRow { + id: string; + workspace_id: string; + name: string; + version: number; + channel: string; + subject: string | null; + body_text: string; + body_html: string | null; + required_tokens: string; +} + +export function extractTokens(...sources: (string | null | undefined)[]): string[] { + const tokens = new Set(); + for (const source of sources) { + for (const match of (source ?? '').matchAll(TOKEN_RE)) tokens.add((match[1] ?? '').toLowerCase()); + } + return [...tokens].sort(); +} + +export function unknownTokens(tokens: readonly string[]): string[] { + return tokens.filter((token) => !(KNOWN_TOKENS as readonly string[]).includes(token) && !/^attr\.[a-z0-9_]+$/.test(token)); +} + +export class TemplateRenderError extends Error { + constructor(readonly missing: readonly string[]) { + super(`Missing template values: ${missing.join(', ')}`); + this.name = 'TemplateRenderError'; + } +} + +function stripControl(value: string): string { + let out = ''; + for (const char of value) { + const code = char.codePointAt(0) ?? 0; + out += code < 0x20 || code === 0x7f ? ' ' : char; + } + return out.replace(/ {2,}/g, ' ').trim(); +} + +export function escapeHtml(value: string): string { + return value.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] ?? c); +} + +/** Renders `{{token}}` placeholders. Fails closed on any missing or empty value. Values are escaped in HTML. */ +export function render(source: string, values: Readonly>, mode: 'text' | 'html'): string { + const missing = new Set(); + const out = source.replace(TOKEN_RE, (_, raw: string) => { + const value = values[raw.toLowerCase()]; + if (value === undefined || value.trim() === '') { + missing.add(raw.toLowerCase()); + return ''; + } + // Contact data is untrusted: strip control characters and keep it on one line. + const clean = stripControl(value); + return mode === 'html' ? escapeHtml(clean) : clean; + }); + if (missing.size) throw new TemplateRenderError([...missing].sort()); + return out; +} + +export interface TemplateInput { + readonly name: string; + readonly channel: string; + readonly subject?: string; + readonly text: string; + readonly html?: string; +} + +/** Creates the next immutable version of a template. Existing versions are never edited. */ +export function createTemplate(db: SqlDatabase, ctx: AuthContext, input: TemplateInput, now: number): TemplateRow { + requireRole(ctx, 'operator'); + if (!/^[a-z0-9][a-z0-9_.-]{0,63}$/.test(input.name)) throw new Error('template name must be lowercase [a-z0-9_.-]'); + const tokens = extractTokens(input.subject, input.text, input.html); + const unknown = unknownTokens(tokens); + if (unknown.length) throw new Error(`Unknown template tokens: ${unknown.join(', ')}`); + return db.transaction(() => { + const latest = db + .prepare('SELECT MAX(version) AS v FROM templates WHERE workspace_id = ? AND name = ?') + .get<{ v: number | null }>(ctx.workspaceId, input.name); + const version = (latest?.v ?? 0) + 1; + const id = ulid(now); + db.prepare( + `INSERT INTO templates (id, workspace_id, name, version, channel, subject, body_text, body_html, required_tokens, created_at) + VALUES (?,?,?,?,?,?,?,?,?,?)`, + ).run(id, ctx.workspaceId, input.name, version, input.channel, input.subject ?? null, input.text, input.html ?? null, JSON.stringify(tokens), now); + audit(db, ctx, now, 'template', id, 'created', { name: input.name, version }); + const row = db.prepare('SELECT * FROM templates WHERE id = ?').get(id); + if (!row) throw new Error('template vanished'); + return row; + }); +} + +export function findTemplate(db: SqlDatabase, workspaceId: string, ref: string): TemplateRow | undefined { + const [name, version] = ref.split('@'); + return db + .prepare('SELECT * FROM templates WHERE workspace_id = ? AND name = ? AND version = ?') + .get(workspaceId, name ?? '', Number(version)); +} diff --git a/outreach-engine/packages/outreach-core/src/domain/wiring.ts b/outreach-engine/packages/outreach-core/src/domain/wiring.ts new file mode 100644 index 0000000..03c9eb3 --- /dev/null +++ b/outreach-engine/packages/outreach-core/src/domain/wiring.ts @@ -0,0 +1,119 @@ +import type { ErrorEffect, SqlDatabase } from '@splitin/outreach-contracts'; +import type { Actor } from '../execution/actions-repo'; +import type { ActionEffects, ActionRow, PreflightCheck, RatePolicy, RateLimit } from '../execution/types'; +import { approvalVerdict } from './approvals'; +import { actionApprover } from './campaigns'; +import { durationMs, nextSlot, resolveZone } from './calendar'; +import { loadEnrollment, loadVersion, type DomainEnv } from './env'; +import { loadStepContext, materializeNext, stepIndex } from './materialize'; +import type { Policy } from './playbook'; +import { addSuppression, isSuppressed, stopEnrollment } from './suppressions'; + +const PAUSED_DEFER_MS = 15 * 60_000; +const DAY_MS = 86_400_000; + +function engineActor(action: ActionRow): Actor { + return { kind: 'worker', id: 'engine', source: 'worker', traceId: action.id }; +} + +function policyOf(db: SqlDatabase, action: ActionRow): Policy | null { + if (!action.enrollment_id) return null; + const enrollment = loadEnrollment(db, action.enrollment_id); + const version = enrollment ? loadVersion(db, enrollment.campaign_version_id) : undefined; + return version ? (JSON.parse(version.policy) as Policy) : null; +} + +/** Domain checks, run inside preflight after the kill-switch and expiry checks (BUILD_PLAN.md §6.4). */ +export function domainChecks(): PreflightCheck[] { + const enrollmentLive: PreflightCheck = ({ db, action, now }) => { + if (!action.enrollment_id) return { kind: 'pass' }; + const enrollment = loadEnrollment(db, action.enrollment_id); + if (!enrollment) return { kind: 'cancel', reason: 'enrollment_missing' }; + if (enrollment.status === 'paused') return { kind: 'defer', until: now + PAUSED_DEFER_MS, reason: 'enrollment_paused' }; + return enrollment.status === 'active' ? { kind: 'pass' } : { kind: 'cancel', reason: `enrollment_${enrollment.status}` }; + }; + const campaignLive: PreflightCheck = ({ db, action, now }) => { + if (!action.campaign_id) return { kind: 'pass' }; + const row = db.prepare('SELECT status FROM campaigns WHERE id = ?').get<{ status: string }>(action.campaign_id); + if (row?.status === 'paused') return { kind: 'defer', until: now + PAUSED_DEFER_MS, reason: 'campaign_paused' }; + return row?.status === 'active' ? { kind: 'pass' } : { kind: 'cancel', reason: `campaign_${row?.status ?? 'missing'}` }; + }; + const notSuppressed: PreflightCheck = ({ db, action }) => { + if (!action.recipient_norm || action.kind === 'notify.publish') return { kind: 'pass' }; + const hit = isSuppressed(db, action.workspace_id, action.recipient_norm, action.provider_account_id); + return hit ? { kind: 'cancel', reason: `suppressed:${hit}` } : { kind: 'pass' }; + }; + const approved: PreflightCheck = ({ db, action, now }) => approvalVerdict(db, action, now); + const inWindow: PreflightCheck = ({ db, action, now }) => { + if (action.kind !== 'email.send' && action.kind !== 'email.reply') return { kind: 'pass' }; + const policy = policyOf(db, action); + if (!policy || !action.enrollment_id) return { kind: 'pass' }; + const contact = db + .prepare('SELECT c.timezone FROM enrollments e JOIN contacts c ON c.id = e.contact_id WHERE e.id = ?') + .get<{ timezone: string | null }>(action.enrollment_id); + const slot = nextSlot(now, policy.window, resolveZone(policy.window, contact?.timezone)); + return slot > now ? { kind: 'defer', until: slot, reason: 'outside_send_window' } : { kind: 'pass' }; + }; + return [enrollmentLive, campaignLive, notSuppressed, approved, inWindow]; +} + +/** Per-campaign budgets from the playbook policy; notifications and manual tasks are not budgeted. */ +export function domainRatePolicy(db: SqlDatabase, action: ActionRow): RatePolicy { + const policy = policyOf(db, action); + if (!policy || (action.kind !== 'email.send' && action.kind !== 'email.reply')) return { limits: [] }; + const limits: RateLimit[] = [ + { scopeKey: `account:${action.provider_account_id}:day`, windowMs: DAY_MS, limit: policy.limits.accountPerDay }, + ]; + const domain = action.recipient_norm?.split('@')[1]; + if (domain) limits.push({ scopeKey: `domain:${domain}:day`, windowMs: DAY_MS, limit: policy.limits.domainPerDay }); + if (policy.limits.campaignPerDay && action.campaign_id) { + limits.push({ scopeKey: `campaign:${action.campaign_id}:day`, windowMs: DAY_MS, limit: policy.limits.campaignPerDay }); + } + // The gap applies from the second touch on: a reply step follows its own send by design. + const gap = action.kind === 'email.send' ? durationMs(policy.limits.recipientMinGap) : 0; + return { limits, recipientMinGapMs: gap }; +} + +/** Enrollment progression and stop rules, run inside the transaction that records each outcome. */ +export function domainEffects(env: DomainEnv): ActionEffects { + return { + onSucceeded(db, action, _receipt, now) { + if (action.approval_id) db.prepare('UPDATE approvals SET consumed_count = consumed_count + 1 WHERE id = ?').run(action.approval_id); + // A manual task advances only when a human records its outcome. + if (!action.enrollment_id || action.kind === 'manual.task') return; + const sc = loadStepContext(db, action.enrollment_id); + if (!sc) return; + materializeNext(env, sc, stepIndex(sc, action.step_id), now, engineActor(action), { + requestActionApproval: actionApprover(env, action.workspace_id, 'engine'), + }); + }, + onFailed(db, action, errorClass, now) { + if (!action.enrollment_id) return; + stopEnrollment(db, action.enrollment_id, 'error', `action_failed:${errorClass ?? action.state_reason ?? 'unknown'}`, engineActor(action), now); + }, + onCancelled(db, action, reason, now) { + if (!action.enrollment_id) return; + if (reason.startsWith('suppressed')) stopEnrollment(db, action.enrollment_id, 'stopped', reason, engineActor(action), now); + else if (reason === 'expired') stopEnrollment(db, action.enrollment_id, 'stopped', 'step_expired', engineActor(action), now); + }, + onErrorEffects(db, action, effects: readonly ErrorEffect[], now) { + if (effects.includes('suppress_recipient') && action.recipient_norm) { + addSuppression(db, { + workspaceId: action.workspace_id, + scope: 'channel', + channel: 'email', + value: action.recipient_norm, + reason: action.last_error_class === 'complaint' ? 'complaint' : 'hard_bounce', + source: `action:${action.id}`, + }, now); + } + if (effects.includes('bounce_enrollment') && action.enrollment_id) { + stopEnrollment(db, action.enrollment_id, 'bounced', action.last_error_class ?? 'bounce', engineActor(action), now); + } + if (effects.includes('pause_campaign') && action.campaign_id) { + db.prepare(`UPDATE campaigns SET status = 'paused', paused_reason = ?, updated_at = ? WHERE id = ? AND status = 'active'`) + .run(`provider:${action.last_error_class ?? 'error'}`, now, action.campaign_id); + } + }, + }; +} diff --git a/outreach-engine/packages/outreach-core/src/engine.ts b/outreach-engine/packages/outreach-core/src/engine.ts new file mode 100644 index 0000000..c6975ae --- /dev/null +++ b/outreach-engine/packages/outreach-core/src/engine.ts @@ -0,0 +1,119 @@ +import { ulid, type ManualTaskProvider, type ProviderAdapter, type SecretResolver, type SqlDatabase } from '@splitin/outreach-contracts'; +import { runExecutionPass, type ExecutionPassReport } from './execution/executor'; +import { setKillSwitch, type KillSwitchScope } from './execution/kill-switches'; +import { resolveReviewAction, type ReviewResolution } from './execution/review'; +import type { ActionRow, CrashHooks, ExecutionConfig, ExecutionDeps, SendGate } from './execution/types'; +import { actorOf, audit, requireRole, type AuthContext, type Role } from './domain/auth'; +import type { DomainEnv, UnsubscribeConfig } from './domain/env'; +import { domainChecks, domainEffects, domainRatePolicy } from './domain/wiring'; + +export interface EngineConfig { + readonly db: SqlDatabase; + readonly adapters: readonly ProviderAdapter[]; + readonly secrets: SecretResolver; + readonly workerId: string; + /** Defaults to an allowlist with no entries: nothing is emailed until an admin opens the gate. */ + readonly sendGate?: SendGate; + readonly unsubscribe?: UnsubscribeConfig; + readonly now?: () => number; + readonly execution?: Partial; + readonly manual?: ManualTaskProvider; + readonly hooks?: CrashHooks; + readonly random?: () => number; +} + +export interface Engine extends DomainEnv { + readonly exec: ExecutionDeps; + /** One worker pass: sweep leases, reconcile, execute due actions. Idempotent and safe to run concurrently. */ + runOnce(): Promise; +} + +export function createEngine(config: EngineConfig): Engine { + const now = config.now ?? Date.now; + const adapters = new Map(config.adapters.map((adapter) => [adapter.name, adapter])); + const holder: { env?: DomainEnv } = {}; + const exec: ExecutionDeps = { + db: config.db, + adapters, + secrets: config.secrets, + now, + workerId: config.workerId, + sendGate: config.sendGate ?? { mode: 'allowlist', allow: [] }, + checks: domainChecks(), + ratePolicy: domainRatePolicy, + // Effects need the domain env, which needs exec: resolve lazily. + effects: { + onSucceeded: (...args) => holder.env && domainEffects(holder.env).onSucceeded?.(...args), + onFailed: (...args) => holder.env && domainEffects(holder.env).onFailed?.(...args), + onCancelled: (...args) => holder.env && domainEffects(holder.env).onCancelled?.(...args), + onErrorEffects: (...args) => holder.env && domainEffects(holder.env).onErrorEffects?.(...args), + }, + ...(config.execution ? { config: config.execution } : {}), + ...(config.manual ? { manual: config.manual } : {}), + ...(config.hooks ? { hooks: config.hooks } : {}), + ...(config.random ? { random: config.random } : {}), + }; + const env: DomainEnv = { db: config.db, now, adapters, exec, ...(config.unsubscribe ? { unsubscribe: config.unsubscribe } : {}) }; + holder.env = env; + return { ...env, exec, runOnce: () => runExecutionPass(exec) }; +} + +/** Creates a workspace and its first admin. The only operation that needs no existing principal. */ +export function bootstrapWorkspace(db: SqlDatabase, input: { workspaceId: string; name: string; adminRef: string; adminName: string }, now = Date.now()): string { + return db.transaction(() => { + db.prepare('INSERT INTO workspaces (id, name, created_at) VALUES (?,?,?)').run(input.workspaceId, input.name, now); + const principalId = ulid(now); + const roles: Role[] = ['admin']; + db.prepare('INSERT INTO principals (id, workspace_id, external_ref, display_name, roles) VALUES (?,?,?,?,?)') + .run(principalId, input.workspaceId, input.adminRef, input.adminName, JSON.stringify(roles)); + audit(db, { workspaceId: input.workspaceId, principalId: 'system', roles: ['admin'], source: 'system', traceId: 'bootstrap' }, now, 'workspace', input.workspaceId, 'bootstrapped', { adminRef: input.adminRef }); + return principalId; + }); +} + +export function setKillSwitchAs(env: DomainEnv, ctx: AuthContext, input: { scope: KillSwitchScope; targetId?: string; engaged: boolean; reason: string }): void { + // Anyone who can operate may stop sending; only approvers may resume (BUILD_PLAN.md §9.4). + requireRole(ctx, input.engaged ? 'operator' : 'approver'); + if (input.scope === 'global') requireRole(ctx, 'admin'); + env.db.transaction(() => + setKillSwitch(env.db, { workspaceId: ctx.workspaceId, scope: input.scope, targetId: input.targetId ?? '*', engaged: input.engaged, reason: input.reason }, actorOf(ctx), env.now()), + ); +} + +export function listReview(env: DomainEnv, ctx: AuthContext): ActionRow[] { + requireRole(ctx, 'viewer'); + return env.db.prepare(`SELECT * FROM scheduled_actions WHERE workspace_id = ? AND state = 'review' ORDER BY updated_at`).all(ctx.workspaceId); +} + +export function resolveReview(env: DomainEnv, ctx: AuthContext, actionId: string, resolution: ReviewResolution): void { + requireRole(ctx, 'approver'); + const owned = env.db.prepare('SELECT 1 FROM scheduled_actions WHERE workspace_id = ? AND id = ?').get(ctx.workspaceId, actionId); + if (!owned) throw new Error(`action ${actionId} not found`); + resolveReviewAction(env.exec, actionId, resolution, actorOf(ctx)); +} + +export interface CampaignStatus { + readonly campaignId: string; + readonly status: string; + readonly enrollments: Record; + readonly actions: Record; + readonly openTasks: number; +} + +export function campaignStatus(env: DomainEnv, ctx: AuthContext, campaignId: string): CampaignStatus { + requireRole(ctx, 'viewer'); + const campaign = env.db.prepare('SELECT status FROM campaigns WHERE workspace_id = ? AND id = ?').get<{ status: string }>(ctx.workspaceId, campaignId); + if (!campaign) throw new Error(`campaign ${campaignId} not found`); + const count = (sql: string) => + Object.fromEntries(env.db.prepare(sql).all<{ k: string; n: number }>(ctx.workspaceId, campaignId).map((row) => [row.k, row.n])); + return { + campaignId, + status: campaign.status, + enrollments: count('SELECT status AS k, COUNT(*) AS n FROM enrollments WHERE workspace_id = ? AND campaign_id = ? GROUP BY status'), + actions: count('SELECT state AS k, COUNT(*) AS n FROM scheduled_actions WHERE workspace_id = ? AND campaign_id = ? GROUP BY state'), + openTasks: + env.db + .prepare(`SELECT COUNT(*) AS n FROM manual_tasks t JOIN scheduled_actions a ON a.id = t.action_id WHERE t.workspace_id = ? AND a.campaign_id = ? AND t.status = 'open'`) + .get<{ n: number }>(ctx.workspaceId, campaignId)?.n ?? 0, + }; +} diff --git a/outreach-engine/packages/outreach-core/src/index.ts b/outreach-engine/packages/outreach-core/src/index.ts index 0bf3542..c3d3623 100644 --- a/outreach-engine/packages/outreach-core/src/index.ts +++ b/outreach-engine/packages/outreach-core/src/index.ts @@ -1 +1,3 @@ export * from './execution/index'; +export * from './domain/index'; +export * from './engine'; From 78af15b60effaf70141541b6701d7f8178eed5ec Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 01:48:24 +0000 Subject: [PATCH 07/20] outreach-engine M5: inert HTML/CSV/XLSX/JSON contact importer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements milestone M5 of outreach-engine/BUILD_PLAN.md (§10, §14): detect, parse inertly, stage, map, normalize and validate, resolve duplicates, preview, then commit exactly the preview. @splitin/outreach-import (new; depends only on contracts) - detect.ts: format detection by magic bytes (ZIP means XLSX), then extension, then content sniffing. Size and row limits (25 MB / 100k by default). Decoding: BOM (UTF-8/16), strict UTF-8, otherwise a Windows-1252 fallback reported as a preview warning. - parsers.ts: * CSV/TSV via csv-parse 7 in strict array mode. Rows are never objects keyed by untrusted headers, and records are null-prototype, so "__proto__"/"constructor" headers are plain keys. csv-parse is pinned at >=7.0.2 to avoid the columns-path prototype replacement advisory. * JSON arrays and JSON Lines, with nested objects flattened to dotted keys (depth 3) and "__proto__" keys dropped. * XLSX via exceljs, reading cached values only: formulas are never evaluated, a formula without a cached result reads as empty, and hyperlinks/rich text are reduced to text. An npm override moves exceljs to uuid 11 (clears the uuid advisory). - html.ts: parse5 builds a tree only; no scripts, resources or styles. Text inside script/style/template/noscript/iframe/object is ignored and traversal is depth-limited. Table mode (header row, cells, plus mailto/http links as " link") and card mode (repeated records with a small selector language: tag, .class, [attr], descendant chains, and "selector@attr" for attributes). Anything richer is rejected, never guessed. - profile.ts: versioned, immutable mapping profiles (zod-validated): canonical field to source column(s), attributes for {{attr.*}}, required fields, consent basis and evidence, jurisdiction, name splitting. - normalize.ts: deterministic normalization and validation. Emails are lowercased and validated (mailto: stripped). Profile URLs are canonical https, no www, no query or fragment. Organization domains are validated or derived from a non-freemail email. IANA time zones are validated. Names are whitespace-collapsed. Errors are specific ("invalid email ...", "unknown time zone ...") and never duplicated by a "missing required" on the same field. - resolve.ts: explainable duplicate rules. Rows with errors are rejected; a repeat email/profile in the file merges into its first row; a known email/profile updates that contact; the same name at the same organization under a different email is ambiguous and never auto-merged; everything else creates. - importer.ts: previewImport() stages rows with locators (csv:row=N, xlsx:Sheet!N, html:table[i]/tr[j], jsonl:line=N), outcomes, errors, counts and up to 20 samples per outcome, plus a preview hash over source bytes, profile and resolved rows. It creates no contacts. commitImport() requires the preview hash and an idempotency key, re-resolves every row against the current workspace, and refuses with ImportStaleError if anything changed. It then creates or updates contacts, organizations and contact points (with source provenance, consent basis/evidence, jurisdiction, permitted channels), links import rows to contacts (used by playbook audiences with source: { importBatch }), and audits. Updates fill empty fields only. Re-committing with the same key is a no-op; a different key is refused. Commit never enrolls anyone or creates actions. - export.ts: CSV export neutralizing formula injection (cells starting with = + - @ tab CR are prefixed with a quote; all cells quoted). Tests (17 new, 116 total): mixed CSV with a merge, invalid email, missing email and an unknown zone; formula-looking cells kept inert; __proto__/constructor headers; Windows-1252 fallback and UTF-8 BOM; size and row limits; profile validation; commit provenance, consent, merge linking, idempotency, no enrollments or actions, valid audit chain; update-without-overwrite plus an ambiguous look-alike; a stale preview refused; HTML tables and cards with scripts, templates and onerror ignored; 3,000-deep nesting rejected, not crashed; unsupported selectors rejected; JSON/JSONL flattening and prototype safety; XLSX cached formula results, an unevaluated WEBSERVICE() formula, rich text, hyperlinks, a corrupt file; CSV export escaping. Performance: a 100,000-row CSV preview runs in about 3.4 s locally against the plan's 10 s budget (memory not yet measured). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MbDW8kfMcxUuZ3WBtaDKBv --- outreach-engine/package-lock.json | 1020 ++++++++++++++++- outreach-engine/package.json | 5 + .../packages/outreach-import/package.json | 50 + .../packages/outreach-import/src/detect.ts | 60 + .../packages/outreach-import/src/export.ts | 13 + .../outreach-import/src/formats.test.ts | 82 ++ .../packages/outreach-import/src/html.ts | 129 +++ .../outreach-import/src/importer.test.ts | 146 +++ .../packages/outreach-import/src/importer.ts | 215 ++++ .../packages/outreach-import/src/index.ts | 8 + .../packages/outreach-import/src/normalize.ts | 148 +++ .../packages/outreach-import/src/parsers.ts | 128 +++ .../packages/outreach-import/src/profile.ts | 93 ++ .../packages/outreach-import/src/resolve.ts | 55 + .../packages/outreach-import/tsconfig.json | 5 + .../packages/outreach-import/tsup.config.ts | 12 + 16 files changed, 2165 insertions(+), 4 deletions(-) create mode 100644 outreach-engine/packages/outreach-import/package.json create mode 100644 outreach-engine/packages/outreach-import/src/detect.ts create mode 100644 outreach-engine/packages/outreach-import/src/export.ts create mode 100644 outreach-engine/packages/outreach-import/src/formats.test.ts create mode 100644 outreach-engine/packages/outreach-import/src/html.ts create mode 100644 outreach-engine/packages/outreach-import/src/importer.test.ts create mode 100644 outreach-engine/packages/outreach-import/src/importer.ts create mode 100644 outreach-engine/packages/outreach-import/src/index.ts create mode 100644 outreach-engine/packages/outreach-import/src/normalize.ts create mode 100644 outreach-engine/packages/outreach-import/src/parsers.ts create mode 100644 outreach-engine/packages/outreach-import/src/profile.ts create mode 100644 outreach-engine/packages/outreach-import/src/resolve.ts create mode 100644 outreach-engine/packages/outreach-import/tsconfig.json create mode 100644 outreach-engine/packages/outreach-import/tsup.config.ts diff --git a/outreach-engine/package-lock.json b/outreach-engine/package-lock.json index db04823..fe3372c 100644 --- a/outreach-engine/package-lock.json +++ b/outreach-engine/package-lock.json @@ -612,6 +612,47 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@fast-csv/format": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/@fast-csv/format/-/format-4.3.5.tgz", + "integrity": "sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.isboolean": "^3.0.3", + "lodash.isequal": "^4.5.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0" + } + }, + "node_modules/@fast-csv/format/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, + "node_modules/@fast-csv/parse": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/@fast-csv/parse/-/parse-4.3.6.tgz", + "integrity": "sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.groupby": "^4.6.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0", + "lodash.isundefined": "^3.0.1", + "lodash.uniq": "^4.5.0" + } + }, + "node_modules/@fast-csv/parse/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -1096,6 +1137,10 @@ "resolved": "packages/outreach-fakes", "link": true }, + "node_modules/@splitin/outreach-import": { + "resolved": "packages/outreach-import", + "link": true + }, "node_modules/@splitin/outreach-store-sqlite": { "resolved": "packages/outreach-store-sqlite", "link": true @@ -1609,6 +1654,75 @@ "dev": true, "license": "MIT" }, + "node_modules/archiver": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", + "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^2.1.0", + "async": "^3.2.4", + "buffer-crc32": "^0.2.1", + "readable-stream": "^3.6.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^2.2.0", + "zip-stream": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/archiver-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", + "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "license": "MIT", + "dependencies": { + "glob": "^7.1.4", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^2.0.0" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/archiver-utils/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/archiver-utils/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -1626,24 +1740,137 @@ "node": ">=12" } }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/binary": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", + "integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==", + "license": "MIT", + "dependencies": { + "buffers": "~0.1.1", + "chainsaw": "~0.1.0" + }, + "engines": { + "node": "*" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bluebird": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", "license": "MIT" }, "node_modules/brace-expansion": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-indexof-polyfill": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz", + "integrity": "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/buffers": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz", + "integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==", + "engines": { + "node": ">=0.2.0" + } + }, "node_modules/bundle-require": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", @@ -1697,6 +1924,18 @@ "node": ">=18" } }, + "node_modules/chainsaw": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz", + "integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==", + "license": "MIT/X11", + "dependencies": { + "traverse": ">=0.3.0 <0.4" + }, + "engines": { + "node": "*" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1770,11 +2009,25 @@ "node": ">= 6" } }, + "node_modules/compress-commons": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", + "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "^0.2.13", + "crc32-stream": "^4.0.2", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, "license": "MIT" }, "node_modules/confbox": { @@ -1794,6 +2047,37 @@ "node": "^14.18.0 || >=16.10.0" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", + "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^3.4.0" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1809,6 +2093,18 @@ "node": ">= 8" } }, + "node_modules/csv-parse": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-7.0.3.tgz", + "integrity": "sha512-YFd3QM/yo17vH91L1IOZuvl09zM0zEEtdfTcOCKWcMdM++MNaQSyp09slXyFCLaPXvHstQFx/xC8myiFHSMUvw==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.23", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1844,6 +2140,66 @@ "dev": true, "license": "MIT" }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/duplexer2/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/duplexer2/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/duplexer2/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -2071,6 +2427,26 @@ "node": ">=0.10.0" } }, + "node_modules/exceljs": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz", + "integrity": "sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==", + "license": "MIT", + "dependencies": { + "archiver": "^5.0.0", + "dayjs": "^1.8.34", + "fast-csv": "^4.3.1", + "jszip": "^3.10.1", + "readable-stream": "^3.6.0", + "saxes": "^5.0.1", + "tmp": "^0.2.0", + "unzipper": "^0.10.11", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=8.3.0" + } + }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -2104,6 +2480,19 @@ "node": ">=12.17.0" } }, + "node_modules/fast-csv": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-4.3.6.tgz", + "integrity": "sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==", + "license": "MIT", + "dependencies": { + "@fast-csv/format": "4.3.5", + "@fast-csv/parse": "4.3.6" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2206,6 +2595,18 @@ "dev": true, "license": "ISC" }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2221,6 +2622,43 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/fstream": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", + "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "graceful-fs": "^4.1.2", + "inherits": "~2.0.0", + "mkdirp": ">=0.5 0", + "rimraf": "2" + }, + "engines": { + "node": ">=0.6" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2247,6 +2685,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2257,6 +2701,26 @@ "node": ">=8" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2267,6 +2731,12 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -2294,6 +2764,23 @@ "node": ">=0.8.19" } }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2317,6 +2804,12 @@ "node": ">=0.10.0" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2385,6 +2878,48 @@ "dev": true, "license": "MIT" }, + "node_modules/jszip": { + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.2.tgz", + "integrity": "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -2395,6 +2930,48 @@ "json-buffer": "3.0.1" } }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -2409,6 +2986,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -2429,6 +3015,12 @@ "dev": true, "license": "MIT" }, + "node_modules/listenercount": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz", + "integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==", + "license": "ISC" + }, "node_modules/load-tsconfig": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", @@ -2455,6 +3047,73 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.difference": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", + "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", + "license": "MIT" + }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, + "node_modules/lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", + "license": "MIT" + }, + "node_modules/lodash.groupby": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz", + "integrity": "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/lodash.isfunction": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz", + "integrity": "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==", + "license": "MIT" + }, + "node_modules/lodash.isnil": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/lodash.isnil/-/lodash.isnil-4.0.0.tgz", + "integrity": "sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isundefined": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash.isundefined/-/lodash.isundefined-3.0.1.tgz", + "integrity": "sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -2462,6 +3121,18 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.union": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", + "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", + "license": "MIT" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "license": "MIT" + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -2492,7 +3163,6 @@ "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -2501,6 +3171,27 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/mlly": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", @@ -2559,6 +3250,15 @@ "dev": true, "license": "MIT" }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -2569,6 +3269,15 @@ "node": ">=0.10.0" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -2619,6 +3328,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -2632,6 +3347,18 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -2642,6 +3369,15 @@ "node": ">=8" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -2793,6 +3529,12 @@ "node": ">= 0.8.0" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -2820,6 +3562,50 @@ ], "license": "MIT" }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -2844,6 +3630,19 @@ "node": ">=4" } }, + "node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, "node_modules/rollup": { "version": "4.63.5", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.5.tgz", @@ -2890,6 +3689,38 @@ "fsevents": "~2.3.2" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/saxes": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", + "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -2903,6 +3734,12 @@ "node": ">=10" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -2967,6 +3804,15 @@ "dev": true, "license": "MIT" }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -3029,6 +3875,22 @@ "node": ">=8" } }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -3113,6 +3975,24 @@ "node": ">=14.0.0" } }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/traverse": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", + "integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==", + "license": "MIT/X11", + "engines": { + "node": "*" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -3271,6 +4151,54 @@ "dev": true, "license": "MIT" }, + "node_modules/unzipper": { + "version": "0.10.14", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.10.14.tgz", + "integrity": "sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==", + "license": "MIT", + "dependencies": { + "big-integer": "^1.6.17", + "binary": "~0.3.0", + "bluebird": "~3.4.1", + "buffer-indexof-polyfill": "~1.0.0", + "duplexer2": "~0.1.4", + "fstream": "^1.0.12", + "graceful-fs": "^4.2.2", + "listenercount": "~1.0.1", + "readable-stream": "~2.3.6", + "setimmediate": "~1.0.4" + } + }, + "node_modules/unzipper/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/unzipper/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/unzipper/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -3281,6 +4209,25 @@ "punycode": "^2.1.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/uuid": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/vite": { "version": "7.3.6", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", @@ -3495,6 +4442,18 @@ "node": ">=0.10.0" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "license": "MIT" + }, "node_modules/yaml": { "version": "2.9.1", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", @@ -3523,6 +4482,41 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zip-stream": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", + "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^3.0.4", + "compress-commons": "^4.1.2", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/archiver-utils": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", + "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", + "license": "MIT", + "dependencies": { + "glob": "^7.2.3", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/zod": { "version": "4.6.5", "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", @@ -3570,6 +4564,24 @@ "node": ">=22.13" } }, + "packages/outreach-import": { + "name": "@splitin/outreach-import", + "version": "0.0.0", + "license": "MIT", + "dependencies": { + "@splitin/outreach-contracts": "0.0.0", + "csv-parse": "^7.0.3", + "exceljs": "^4.4.0", + "parse5": "^8.0.1", + "zod": "^4.6.5" + }, + "devDependencies": { + "@splitin/outreach-store-sqlite": "0.0.0" + }, + "engines": { + "node": ">=22.13" + } + }, "packages/outreach-store-sqlite": { "name": "@splitin/outreach-store-sqlite", "version": "0.0.0", diff --git a/outreach-engine/package.json b/outreach-engine/package.json index c0b56d2..d298079 100644 --- a/outreach-engine/package.json +++ b/outreach-engine/package.json @@ -33,5 +33,10 @@ "typescript": "^5.9.3", "typescript-eslint": "^8.70.1", "vitest": "^3.2.7" + }, + "overrides": { + "exceljs": { + "uuid": "^11.1.1" + } } } diff --git a/outreach-engine/packages/outreach-import/package.json b/outreach-engine/packages/outreach-import/package.json new file mode 100644 index 0000000..ff37335 --- /dev/null +++ b/outreach-engine/packages/outreach-import/package.json @@ -0,0 +1,50 @@ +{ + "name": "@splitin/outreach-import", + "version": "0.0.0", + "description": "Inert HTML/CSV/XLSX/JSON contact importer: staging, mapping profiles, preview and idempotent commit.", + "license": "MIT", + "author": "SplitInTech", + "homepage": "https://github.com/splitintech/open-internal-tools/tree/main/outreach-engine#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/splitintech/open-internal-tools.git", + "directory": "outreach-engine/packages/outreach-import" + }, + "type": "module", + "sideEffects": false, + "engines": { + "node": ">=22.13" + }, + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "files": [ + "dist", + "README.md", + "package.json" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsup", + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@splitin/outreach-contracts": "0.0.0", + "csv-parse": "^7.0.3", + "exceljs": "^4.4.0", + "parse5": "^8.0.1", + "zod": "^4.6.5" + }, + "devDependencies": { + "@splitin/outreach-store-sqlite": "0.0.0" + } +} diff --git a/outreach-engine/packages/outreach-import/src/detect.ts b/outreach-engine/packages/outreach-import/src/detect.ts new file mode 100644 index 0000000..63969a9 --- /dev/null +++ b/outreach-engine/packages/outreach-import/src/detect.ts @@ -0,0 +1,60 @@ +export type ImportFormat = 'csv' | 'xlsx' | 'html' | 'json'; + +export interface ImportLimits { + readonly maxBytes: number; + readonly maxRows: number; +} + +export const DEFAULT_LIMITS: ImportLimits = { maxBytes: 25 * 1024 * 1024, maxRows: 100_000 }; + +export class ImportRejectedError extends Error { + constructor(message: string) { + super(message); + this.name = 'ImportRejectedError'; + } +} + +/** Detects the format from magic bytes first, then the file name, then content sniffing. */ +export function detectFormat(bytes: Uint8Array, fileName: string, declared?: ImportFormat): ImportFormat { + if (declared) return declared; + if (bytes[0] === 0x50 && bytes[1] === 0x4b && bytes[2] === 0x03 && bytes[3] === 0x04) return 'xlsx'; + const extension = fileName.toLowerCase().split('.').pop() ?? ''; + if (extension === 'xlsx') return 'xlsx'; + if (extension === 'html' || extension === 'htm') return 'html'; + if (extension === 'json' || extension === 'jsonl' || extension === 'ndjson') return 'json'; + if (extension === 'csv' || extension === 'tsv') return 'csv'; + const head = new TextDecoder('utf-8').decode(bytes.subarray(0, 512)).trimStart().toLowerCase(); + if (head.startsWith('<')) return 'html'; + if (head.startsWith('[') || head.startsWith('{')) return 'json'; + return 'csv'; +} + +export interface DecodedText { + readonly text: string; + readonly encoding: 'utf-8' | 'utf-16le' | 'utf-16be' | 'windows-1252'; + readonly warnings: string[]; +} + +/** BOM, then strict UTF-8, then Windows-1252 (never fails, so it is the fallback; flagged as a warning). */ +export function decodeText(bytes: Uint8Array): DecodedText { + if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) { + return { text: new TextDecoder('utf-8').decode(bytes.subarray(3)), encoding: 'utf-8', warnings: [] }; + } + if (bytes[0] === 0xff && bytes[1] === 0xfe) return { text: new TextDecoder('utf-16le').decode(bytes.subarray(2)), encoding: 'utf-16le', warnings: [] }; + if (bytes[0] === 0xfe && bytes[1] === 0xff) return { text: new TextDecoder('utf-16be').decode(bytes.subarray(2)), encoding: 'utf-16be', warnings: [] }; + try { + return { text: new TextDecoder('utf-8', { fatal: true }).decode(bytes), encoding: 'utf-8', warnings: [] }; + } catch { + return { + text: new TextDecoder('windows-1252').decode(bytes), + encoding: 'windows-1252', + warnings: ['File is not valid UTF-8; decoded as Windows-1252. Check accented names in the preview.'], + }; + } +} + +/** One source row as column name -> raw string, plus where it came from. */ +export interface RawRow { + readonly locator: string; + readonly values: Readonly>; +} diff --git a/outreach-engine/packages/outreach-import/src/export.ts b/outreach-engine/packages/outreach-import/src/export.ts new file mode 100644 index 0000000..34a6141 --- /dev/null +++ b/outreach-engine/packages/outreach-import/src/export.ts @@ -0,0 +1,13 @@ +/** + * CSV export that cannot be turned into a spreadsheet formula (OWASP CSV injection): cells starting + * with = + - @ tab or carriage return are prefixed with a single quote, and every cell is quoted. + */ +export function escapeCsvCell(value: unknown): string { + let text = value === null || value === undefined ? '' : String(value); + if (/^[=+\-@\t\r]/.test(text)) text = `'${text}`; + return `"${text.replace(/"/g, '""')}"`; +} + +export function toCsv(headers: readonly string[], rows: readonly (readonly unknown[])[]): string { + return [headers, ...rows].map((row) => row.map(escapeCsvCell).join(',')).join('\r\n'); +} diff --git a/outreach-engine/packages/outreach-import/src/formats.test.ts b/outreach-engine/packages/outreach-import/src/formats.test.ts new file mode 100644 index 0000000..de528d3 --- /dev/null +++ b/outreach-engine/packages/outreach-import/src/formats.test.ts @@ -0,0 +1,82 @@ +import ExcelJS from 'exceljs'; +import { describe, expect, it } from 'vitest'; +import { ImportRejectedError, detectFormat } from './detect'; +import { escapeCsvCell, toCsv } from './export'; +import { parseHtmlCards, parseHtmlTable, parseSelector } from './html'; +import { parseJson, parseXlsx } from './parsers'; + +describe('HTML', () => { + const page = ` + +
+ + +
NameEmailTitle
Ada LovelacewriteCTO
Gracegrace@example.netAdmiral
+
  • Linus

    mailMaintainer
  • +
  • Other

`; + + it('extracts tables inertly, ignoring scripts, templates and event handlers', () => { + const rows = parseHtmlTable(page, 0, 100); + expect(rows.map((r) => r.values)).toEqual([ + { Name: 'Ada Lovelace', Email: 'write', 'Email link': 'mailto:ada@example.org', Title: 'CTO' }, + { Name: 'Grace', Email: 'grace@example.net', Title: 'Admiral' }, + ]); + expect(rows[0]?.locator).toBe('html:table[0]/tr[1]'); + expect((globalThis as Record).stolen).toBeUndefined(); + }); + + it('extracts repeated cards with simple selectors and attributes', () => { + const rows = parseHtmlCards(page, 'ul li.person.card[data-lead]', { Name: 'h3', Email: 'a.mail@href', Title: '.role' }, 100); + expect(rows.map((r) => r.values)).toEqual([{ Name: 'Linus', Email: 'mailto:linus@example.com', Title: 'Maintainer' }]); + }); + + it('survives pathological nesting and rejects unsupported selectors', () => { + const deep = `${'
'.repeat(3_000)}
Name
Deep
${'
'.repeat(3_000)}`; + expect(() => parseHtmlTable(deep, 0, 100)).toThrow(ImportRejectedError); + expect(() => parseSelector('div > p')).toThrow(/unsupported selector/); + expect(() => parseSelector('a:hover')).toThrow(/unsupported selector/); + }); +}); + +describe('JSON', () => { + it('reads arrays and JSON Lines, flattening nested objects', () => { + expect(parseJson('[{"name":"Ada","org":{"name":"Analytical","domain":"example.org"},"tags":["a","b"]}]', 10)[0]?.values) + .toEqual({ name: 'Ada', 'org.name': 'Analytical', 'org.domain': 'example.org', tags: 'a, b' }); + const lines = parseJson('{"email":"a@example.org"}\n\n{"email":"b@example.org","__proto__":{"polluted":1}}\n', 10); + expect(lines.map((r) => r.locator)).toEqual(['jsonl:line=1', 'jsonl:line=2']); + expect(({} as Record).polluted).toBeUndefined(); + expect(() => parseJson('{"a":', 10)).toThrow(ImportRejectedError); + }); +}); + +describe('XLSX', () => { + it('reads cached values only and never evaluates formulas', async () => { + const workbook = new ExcelJS.Workbook(); + const sheet = workbook.addWorksheet('Leads'); + sheet.addRow(['Name', 'Email', 'Score', 'Site']); + sheet.addRow(['Ada', 'ada@example.org', { formula: 'SUM(1,2)', result: 3 }, { text: 'site', hyperlink: 'https://example.org' }]); + sheet.addRow([{ richText: [{ text: 'Gra' }, { text: 'ce' }] }, 'grace@example.net', { formula: 'WEBSERVICE("http://evil.example")' }, '']); + const bytes = new Uint8Array(await workbook.xlsx.writeBuffer()); + expect(detectFormat(bytes, 'anything.bin')).toBe('xlsx'); + const rows = await parseXlsx(bytes, 100); + expect(rows.map((r) => r.values)).toEqual([ + { Name: 'Ada', Email: 'ada@example.org', Score: '3', Site: 'site' }, + { Name: 'Grace', Email: 'grace@example.net', Score: '', Site: '' }, + ]); + expect(rows[0]?.locator).toBe('xlsx:Leads!2'); + }); + + it('rejects files that are not workbooks', async () => { + await expect(parseXlsx(Uint8Array.from([0x50, 0x4b, 0x03, 0x04, 1, 2, 3]), 10)).rejects.toThrow(ImportRejectedError); + }); +}); + +describe('CSV export', () => { + it('neutralizes formula injection and quotes every cell', () => { + expect(escapeCsvCell('=HYPERLINK("http://evil.example")')).toBe(`"'=HYPERLINK(""http://evil.example"")"`); + expect(escapeCsvCell('+1 555')).toBe(`"'+1 555"`); + expect(escapeCsvCell('@SUM(A1)')).toBe(`"'@SUM(A1)"`); + expect(escapeCsvCell('Ada')).toBe('"Ada"'); + expect(toCsv(['a', 'b'], [[1, null]])).toBe('"a","b"\r\n"1",""'); + }); +}); diff --git a/outreach-engine/packages/outreach-import/src/html.ts b/outreach-engine/packages/outreach-import/src/html.ts new file mode 100644 index 0000000..6481578 --- /dev/null +++ b/outreach-engine/packages/outreach-import/src/html.ts @@ -0,0 +1,129 @@ +/** + * Inert HTML extraction with parse5: the document is only parsed into a tree. No scripts run, no + * resources load, no CSS applies. Text inside script/style/template/noscript is ignored. + */ +import { parse, type DefaultTreeAdapterMap } from 'parse5'; +import { ImportRejectedError, type RawRow } from './detect'; + +type Node = DefaultTreeAdapterMap['node']; +type Element = DefaultTreeAdapterMap['element']; + +const SKIP = new Set(['script', 'style', 'template', 'noscript', 'iframe', 'object']); +const MAX_DEPTH = 256; + +function isElement(node: Node): node is Element { + return 'tagName' in node; +} + +function children(node: Node): Node[] { + if (isElement(node) && node.tagName === 'template') return []; + return 'childNodes' in node ? (node.childNodes as Node[]) : []; +} + +function walk(node: Node, visit: (element: Element) => void, depth = 0): void { + if (depth > MAX_DEPTH) return; + for (const child of children(node)) { + if (!isElement(child)) continue; + if (SKIP.has(child.tagName)) continue; + visit(child); + walk(child, visit, depth + 1); + } +} + +export function textOf(node: Node, depth = 0): string { + if (depth > MAX_DEPTH) return ''; + if ('value' in node && node.nodeName === '#text') return node.value; + if (isElement(node) && SKIP.has(node.tagName)) return ''; + const parts = children(node).map((child) => textOf(child, depth + 1)); + const block = isElement(node) && ['p', 'div', 'br', 'li', 'tr', 'td', 'th'].includes(node.tagName); + return (block ? ' ' : '') + parts.join('') + (block ? ' ' : ''); +} + +function attr(element: Element, name: string): string | undefined { + return element.attrs.find((a) => a.name === name)?.value; +} + +interface SimpleSelector { + tag?: string; + classes: string[]; + attribute?: string; +} + +/** Supports "tag", ".class", "tag.class.other", "[data-x]", combined, and descendant chains ("ul li.person"). */ +export function parseSelector(selector: string): SimpleSelector[] { + return selector.trim().split(/\s+/).map((part) => { + const match = /^([a-z][a-z0-9-]*)?((?:\.[A-Za-z0-9_-]+)*)(?:\[([a-z][a-z0-9-]*)\])?$/.exec(part); + if (!match) throw new ImportRejectedError(`unsupported selector "${part}"`); + return { + ...(match[1] ? { tag: match[1] } : {}), + classes: (match[2] ?? '').split('.').filter(Boolean), + ...(match[3] ? { attribute: match[3] } : {}), + }; + }); +} + +function matches(element: Element, simple: SimpleSelector): boolean { + if (simple.tag && element.tagName !== simple.tag) return false; + const classes = (attr(element, 'class') ?? '').split(/\s+/); + if (!simple.classes.every((c) => classes.includes(c))) return false; + return !simple.attribute || attr(element, simple.attribute) !== undefined; +} + +export function selectAll(root: Node, selector: string): Element[] { + const chain = parseSelector(selector); + let current: Node[] = [root]; + for (const simple of chain) { + const next: Element[] = []; + const seen = new Set(); + for (const scope of current) { + walk(scope, (element) => { + if (matches(element, simple) && !seen.has(element)) { + seen.add(element); + next.push(element); + } + }); + } + current = next; + } + return current as Element[]; +} + +const clean = (value: string) => value.replace(/\s+/g, ' ').trim(); + +export function parseHtmlTable(html: string, tableIndex: number, maxRows: number): RawRow[] { + const document = parse(html); + const table = selectAll(document, 'table')[tableIndex]; + if (!table) throw new ImportRejectedError(`no at index ${tableIndex}`); + const rows = selectAll(table, 'tr'); + const [head, ...body] = rows; + if (!head) return []; + if (body.length > maxRows) throw new ImportRejectedError(`more than ${maxRows} rows`); + const cellsOf = (row: Element) => children(row).filter(isElement).filter((c) => c.tagName === 'td' || c.tagName === 'th'); + const headers = cellsOf(head).map((cell, i) => clean(textOf(cell)) || `column_${i + 1}`); + return body.map((row, index) => { + const values: Record = Object.create(null) as Record; + cellsOf(row).forEach((cell, i) => { + const header = headers[i] ?? `column_${i + 1}`; + const link = selectAll(cell, 'a')[0]; + const href = link ? attr(link, 'href') : undefined; + values[header] = clean(textOf(cell)); + if (href && /^(mailto:|https?:)/i.test(href)) values[`${header} link`] = href; + }); + return { locator: `html:table[${tableIndex}]/tr[${index + 1}]`, values }; + }); +} + +export function parseHtmlCards(html: string, cardSelector: string, fields: Readonly>, maxRows: number): RawRow[] { + const document = parse(html); + const cards = selectAll(document, cardSelector); + if (cards.length > maxRows) throw new ImportRejectedError(`more than ${maxRows} rows`); + return cards.map((card, index) => { + const values: Record = Object.create(null) as Record; + for (const [column, spec] of Object.entries(fields)) { + const [selector, attribute] = spec.split('@'); + const target = selector?.trim() ? selectAll(card, selector)[0] : card; + values[column] = target ? (attribute ? (attr(target, attribute) ?? '') : clean(textOf(target))) : ''; + } + return { locator: `html:${cardSelector}[${index}]`, values }; + }); +} diff --git a/outreach-engine/packages/outreach-import/src/importer.test.ts b/outreach-engine/packages/outreach-import/src/importer.test.ts new file mode 100644 index 0000000..6967769 --- /dev/null +++ b/outreach-engine/packages/outreach-import/src/importer.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from 'vitest'; +import { verifyAuditChain, type SqlDatabase } from '@splitin/outreach-contracts'; +import { openSqliteDatabase } from '@splitin/outreach-store-sqlite'; +import { ImportRejectedError } from './detect'; +import { ImportStaleError, commitImport, previewImport, type ImportActor } from './importer'; +import { ProfileError, saveMappingProfile } from './profile'; + +const NOW = 1_700_000_000_000; +const actor: ImportActor = { workspaceId: 'ws', principalId: 'p1', source: 'test', traceId: 't' }; +const enc = (text: string) => new TextEncoder().encode(text); + +function setup(profileOverrides: Record = {}): { db: SqlDatabase; profileId: string } { + const db = openSqliteDatabase(':memory:'); + db.prepare(`INSERT INTO workspaces (id, name, created_at) VALUES ('ws', 'W', 0)`).run(); + const profile = db.transaction(() => + saveMappingProfile(db, 'ws', 'leads', { + columns: { email: ['Email', 'E-mail'], full_name: 'Name', title: 'Title', org_name: 'Company', timezone: 'TZ', profile_url: 'Profile' }, + attributes: { segment: 'Segment' }, + consent: { basis: 'legitimate_interest', evidence: 'public business contact' }, + jurisdiction: 'US', + ...profileOverrides, + }, NOW), + ); + return { db, profileId: profile.id }; +} + +const CSV = [ + 'Name,Email,Title,Company,TZ,Segment,Profile', + 'Ada Lovelace,Ada@Example.org,CTO,Analytical,America/New_York,enterprise,https://www.linkedin.com/in/ada/?trk=x', + 'Ada Again,ada@example.org,,,,,', + 'Bad Email,not-an-email,,,,,', + 'No Email,,,,,,', + 'Grace Hopper,grace@example.net,Admiral,Navy,Mars/Base,,', + '"Quote, Inc","q@example.com",,"=HYPERLINK(""http://evil.example"")",,,', +].join('\n'); + +async function preview(db: SqlDatabase, profileId: string, text: string | Uint8Array, fileName = 'leads.csv') { + return previewImport(db, actor, { fileName, bytes: typeof text === 'string' ? enc(text) : text, profileId, now: NOW }); +} + +describe('preview', () => { + it('normalizes, validates and resolves duplicates without creating contacts', async () => { + const { db, profileId } = setup(); + const result = await preview(db, profileId, CSV); + expect(result.counts).toEqual({ create: 2, update: 0, merge: 1, reject: 3, ambiguous: 0 }); + expect(result.samples.create[0]?.contact).toMatchObject({ + email: 'ada@example.org', first_name: 'Ada', org_domain: 'example.org', profile_url: 'https://linkedin.com/in/ada', attributes: { segment: 'enterprise' }, + }); + expect(result.samples.reject.map((r) => r.note)).toEqual([ + 'invalid email "not-an-email"', + 'missing required email', + 'unknown time zone "Mars/Base"', + ]); + expect(result.samples.merge[0]).toMatchObject({ ordinal: 1, note: 'same email as an earlier row' }); + expect(db.prepare('SELECT COUNT(*) AS n FROM contacts').get<{ n: number }>()?.n).toBe(0); + }); + + it('keeps formula-looking cells as inert data', async () => { + const { db, profileId } = setup(); + const result = await preview(db, profileId, CSV); + const row = result.samples.create.find((r) => r.contact.email === 'q@example.com'); + expect(row?.contact.org_name).toBe('=HYPERLINK("http://evil.example")'); + }); + + it('treats __proto__ and constructor headers as plain keys', async () => { + const { db, profileId } = setup({ columns: { email: 'Email', full_name: '__proto__' }, attributes: { ctor: 'constructor' } }); + const result = await preview(db, profileId, '__proto__,Email,constructor\nPolly,polly@example.org,x\n'); + expect(result.samples.create[0]?.contact).toMatchObject({ full_name: 'Polly', attributes: { ctor: 'x' } }); + expect(({} as Record).polluted).toBeUndefined(); + expect(Object.prototype.toString.call({})).toBe('[object Object]'); + }); + + it('falls back to Windows-1252 with a warning and honours a UTF-8 BOM', async () => { + const { db, profileId } = setup(); + const latin = Uint8Array.from([...enc('Name,Email\nJos'), 0xe9, ...enc(',jose@example.org\n')]); + const result = await preview(db, profileId, latin); + expect(result.samples.create[0]?.contact.full_name).toBe('José'); + expect(result.warnings[0]).toMatch(/Windows-1252/); + const bom = Uint8Array.from([0xef, 0xbb, 0xbf, ...enc('Name,Email\nZoë,zoe@example.org\n')]); + expect((await preview(db, profileId, bom)).samples.create[0]?.contact.full_name).toBe('Zoë'); + }); + + it('enforces size and row limits', async () => { + const { db, profileId } = setup(); + await expect(previewImport(db, actor, { fileName: 'x.csv', bytes: enc(CSV), profileId, now: NOW, limits: { maxRows: 3 } })).rejects.toThrow(ImportRejectedError); + await expect(previewImport(db, actor, { fileName: 'x.csv', bytes: enc(CSV), profileId, now: NOW, limits: { maxBytes: 10 } })).rejects.toThrow(/larger than/); + }); + + it('rejects invalid mapping profiles with paths', () => { + const db = openSqliteDatabase(':memory:'); + db.prepare(`INSERT INTO workspaces (id, name, created_at) VALUES ('ws', 'W', 0)`).run(); + expect(() => saveMappingProfile(db, 'ws', 'bad', { columns: { mail: 'Email' }, consent: { basis: 'maybe' } }, NOW)).toThrow(ProfileError); + }); +}); + +describe('commit', () => { + it('creates contacts with provenance and consent, merges duplicates, and is idempotent', async () => { + const { db, profileId } = setup(); + const result = await preview(db, profileId, CSV); + const first = commitImport(db, actor, { batchId: result.batchId, previewHash: result.previewHash, idempotencyKey: 'k1', now: NOW }); + expect(first).toMatchObject({ created: 2, merged: 1, skipped: 3, alreadyCommitted: false }); + const point = db.prepare(`SELECT source, consent_basis, jurisdiction FROM contact_points WHERE value_norm = 'ada@example.org'`).get(); + expect(point).toEqual({ source: `import:${result.batchId}`, consent_basis: 'legitimate_interest', jurisdiction: 'US' }); + expect(db.prepare(`SELECT COUNT(*) AS n FROM contact_points WHERE kind = 'social_profile'`).get<{ n: number }>()?.n).toBe(1); + const linked = db.prepare(`SELECT COUNT(DISTINCT contact_id) AS n FROM import_rows WHERE batch_id = ? AND contact_id IS NOT NULL`).get<{ n: number }>(result.batchId); + expect(linked?.n).toBe(2); + expect(commitImport(db, actor, { batchId: result.batchId, previewHash: result.previewHash, idempotencyKey: 'k1', now: NOW }).alreadyCommitted).toBe(true); + expect(() => commitImport(db, actor, { batchId: result.batchId, previewHash: result.previewHash, idempotencyKey: 'k2', now: NOW })).toThrow(ImportStaleError); + expect(db.prepare('SELECT COUNT(*) AS n FROM enrollments').get<{ n: number }>()?.n).toBe(0); + expect(db.prepare('SELECT COUNT(*) AS n FROM scheduled_actions').get<{ n: number }>()?.n).toBe(0); + expect(verifyAuditChain(db).ok).toBe(true); + }); + + it('updates existing contacts without overwriting and flags ambiguous look-alikes', async () => { + const { db, profileId } = setup(); + const seed = await preview(db, profileId, 'Name,Email,Title,Company\nAda Lovelace,ada@example.org,CTO,Analytical\n'); + commitImport(db, actor, { batchId: seed.batchId, previewHash: seed.previewHash, idempotencyKey: 'seed', now: NOW }); + const second = await preview(db, profileId, 'Name,Email,Title,TZ\nAda L,ada@example.org,Intern,Europe/London\nAda Lovelace,ada.l@example.org,,\n'); + expect(second.counts).toMatchObject({ update: 1, ambiguous: 1 }); + commitImport(db, actor, { batchId: second.batchId, previewHash: second.previewHash, idempotencyKey: 'k', now: NOW }); + expect(db.prepare('SELECT title, timezone FROM contacts').all()).toEqual([{ title: 'CTO', timezone: 'Europe/London' }]); + expect(db.prepare(`SELECT COUNT(*) AS n FROM contact_points WHERE value_norm = 'ada.l@example.org'`).get<{ n: number }>()?.n).toBe(0); + }); + + it('refuses a stale preview when the workspace changed in between', async () => { + const { db, profileId } = setup(); + const a = await preview(db, profileId, 'Name,Email\nAda,ada@example.org\n'); + const b = await preview(db, profileId, 'Name,Email\nAda,ada@example.org\n'); + commitImport(db, actor, { batchId: a.batchId, previewHash: a.previewHash, idempotencyKey: 'a', now: NOW }); + expect(() => commitImport(db, actor, { batchId: b.batchId, previewHash: b.previewHash, idempotencyKey: 'b', now: NOW })).toThrow(/workspace changed/); + expect(() => commitImport(db, actor, { batchId: b.batchId, previewHash: 'forged', idempotencyKey: 'b', now: NOW })).toThrow(ImportStaleError); + }); +}); + +describe('performance', () => { + it('previews 100k rows within the budget', async () => { + const { db, profileId } = setup(); + const lines = ['Name,Email,Company,Segment']; + for (let i = 0; i < 100_000; i += 1) lines.push(`Person ${i},person${i}@example${i % 500}.org,Company ${i % 500},s${i % 7}`); + const started = performance.now(); + const result = await preview(db, profileId, lines.join('\n')); + const elapsed = performance.now() - started; + expect(result.counts.create).toBe(100_000); + expect(elapsed).toBeLessThan(10_000); + }, 60_000); +}); diff --git a/outreach-engine/packages/outreach-import/src/importer.ts b/outreach-engine/packages/outreach-import/src/importer.ts new file mode 100644 index 0000000..01dd178 --- /dev/null +++ b/outreach-engine/packages/outreach-import/src/importer.ts @@ -0,0 +1,215 @@ +import { appendAudit, digestCanonical, sha256Hex, ulid, type SqlDatabase } from '@splitin/outreach-contracts'; +import { DEFAULT_LIMITS, ImportRejectedError, decodeText, detectFormat, type ImportFormat, type ImportLimits, type RawRow } from './detect'; +import { parseHtmlCards, parseHtmlTable } from './html'; +import { normalizeRow, type NormalizedContact } from './normalize'; +import { parseDelimited, parseJson, parseXlsx } from './parsers'; +import { loadMappingProfile, type MappingProfile } from './profile'; +import { resolveRows, type ResolvedRow, type RowOutcome } from './resolve'; + +/** Who is importing; always supplied by the calling surface from its authenticated context. */ +export interface ImportActor { + readonly workspaceId: string; + readonly principalId: string; + readonly source: string; + readonly traceId: string; +} + +export interface PreviewInput { + readonly fileName: string; + readonly bytes: Uint8Array; + readonly profileId: string; + readonly limits?: Partial; + readonly now: number; +} + +export interface PreviewSample { + readonly ordinal: number; + readonly locator: string; + readonly contact: NormalizedContact; + readonly note?: string; +} + +export interface ImportPreview { + readonly batchId: string; + readonly previewHash: string; + readonly format: ImportFormat; + readonly counts: Record; + readonly warnings: readonly string[]; + readonly samples: Record; +} + +async function parseRows(bytes: Uint8Array, format: ImportFormat, profile: MappingProfile, limits: ImportLimits): Promise<{ rows: RawRow[]; warnings: string[] }> { + if (format === 'xlsx') return { rows: await parseXlsx(bytes, limits.maxRows, profile.sheet), warnings: [] }; + const { text, warnings } = decodeText(bytes); + if (format === 'json') return { rows: parseJson(text, limits.maxRows), warnings }; + if (format === 'csv') return { rows: parseDelimited(text, limits.maxRows, profile.delimiter), warnings }; + const html = profile.html ?? { mode: 'table' as const, table: 0 }; + const rows = html.mode === 'table' ? parseHtmlTable(text, html.table, limits.maxRows) : parseHtmlCards(text, html.card, html.fields, limits.maxRows); + return { rows, warnings }; +} + +function audit(db: SqlDatabase, actor: ImportActor, now: number, batchId: string, action: string, detail: unknown): void { + appendAudit(db, { workspaceId: actor.workspaceId, at: now, actorKind: 'principal', actorId: actor.principalId, source: actor.source, traceId: actor.traceId, resourceKind: 'import_batch', resourceId: batchId, action, detail }); +} + +function hashPreview(sourceSha: string, profileId: string, rows: readonly { contact: NormalizedContact; resolved: ResolvedRow }[]): string { + return digestCanonical({ sourceSha, profileId, rows: rows.map((row) => [row.resolved.outcome, row.resolved.mergeInto ?? null, row.resolved.existingContactId ?? null, row.contact]) }); +} + +/** Parses inertly, normalizes, resolves duplicates and stages everything. Creates no contacts. */ +export async function previewImport(db: SqlDatabase, actor: ImportActor, input: PreviewInput): Promise { + const limits = { ...DEFAULT_LIMITS, ...input.limits }; + if (input.bytes.byteLength > limits.maxBytes) throw new ImportRejectedError(`file is larger than ${limits.maxBytes} bytes`); + const stored = loadMappingProfile(db, actor.workspaceId, input.profileId); + if (!stored) throw new ImportRejectedError(`mapping profile ${input.profileId} not found`); + const format = detectFormat(input.bytes, input.fileName, stored.spec.format); + const { rows, warnings } = await parseRows(input.bytes, format, stored.spec, limits); + const normalized = rows.map((row, ordinal) => ({ ordinal, row, ...normalizeRow(row, stored.spec) })); + const sourceSha = sha256Hex(input.bytes); + + return db.transaction(() => { + const resolved = resolveRows(db, actor.workspaceId, normalized); + const joined = normalized.map((row, i) => ({ ...row, resolved: resolved[i] as ResolvedRow })); + const counts: Record = { create: 0, update: 0, merge: 0, reject: 0, ambiguous: 0 }; + const samples: Record = { create: [], update: [], merge: [], reject: [], ambiguous: [] }; + for (const row of joined) { + counts[row.resolved.outcome] += 1; + const bucket = samples[row.resolved.outcome]; + if (bucket.length < 20) bucket.push({ ordinal: row.ordinal, locator: row.row.locator, contact: row.contact, ...(row.resolved.note ? { note: row.resolved.note } : {}) }); + } + const previewHash = hashPreview(sourceSha, stored.id, joined); + const batchId = ulid(input.now); + db.prepare( + `INSERT INTO import_batches (id, workspace_id, source_name, source_sha256, format, mapping_profile_id, status, preview_hash, counts, warnings, created_by, created_at) + VALUES (?,?,?,?,?,?,'previewed',?,?,?,?,?)`, + ).run(batchId, actor.workspaceId, input.fileName.slice(0, 200), sourceSha, format, stored.id, previewHash, JSON.stringify(counts), JSON.stringify(warnings), actor.principalId, input.now); + const insert = db.prepare('INSERT INTO import_rows (id, batch_id, ordinal, locator, raw, normalized, outcome, errors, contact_id) VALUES (?,?,?,?,?,?,?,?,?)'); + for (const row of joined) { + insert.run(ulid(input.now), batchId, row.ordinal, row.row.locator, JSON.stringify(row.row.values), JSON.stringify({ contact: row.contact, resolved: row.resolved }), row.resolved.outcome, JSON.stringify(row.errors), row.resolved.existingContactId ?? null); + } + audit(db, actor, input.now, batchId, 'previewed', { counts, format, sourceSha }); + return { batchId, previewHash, format, counts, warnings, samples }; + }); +} + +export class ImportStaleError extends Error { + constructor(message: string) { + super(message); + this.name = 'ImportStaleError'; + } +} + +export interface CommitResult { + readonly batchId: string; + readonly created: number; + readonly updated: number; + readonly merged: number; + readonly skipped: number; + readonly alreadyCommitted: boolean; +} + +interface StagedRow { + id: string; + ordinal: number; + normalized: string; + errors: string; + outcome: RowOutcome; +} + +/** + * Commits exactly the previewed outcome. Outcomes are recomputed against the current workspace; any + * difference means the preview is stale and nothing is written. Never enrolls anyone or sends anything. + */ +export function commitImport(db: SqlDatabase, actor: ImportActor, input: { batchId: string; previewHash: string; idempotencyKey: string; now: number }): CommitResult { + return db.transaction(() => { + const batch = db.prepare('SELECT * FROM import_batches WHERE workspace_id = ? AND id = ?') + .get<{ id: string; status: string; preview_hash: string; idempotency_key: string | null; counts: string; mapping_profile_id: string }>(actor.workspaceId, input.batchId); + if (!batch) throw new ImportRejectedError(`import batch ${input.batchId} not found`); + if (batch.preview_hash !== input.previewHash) throw new ImportStaleError('preview hash does not match this batch'); + if (batch.status === 'committed') { + if (batch.idempotency_key !== input.idempotencyKey) throw new ImportStaleError('batch was already committed with a different idempotency key'); + const counts = JSON.parse(batch.counts) as Record; + return { batchId: batch.id, created: counts.create, updated: counts.update, merged: counts.merge, skipped: counts.reject + counts.ambiguous, alreadyCommitted: true }; + } + if (batch.status !== 'previewed') throw new ImportStaleError(`batch is ${batch.status}`); + const profile = loadMappingProfile(db, actor.workspaceId, batch.mapping_profile_id); + if (!profile) throw new ImportRejectedError('mapping profile vanished'); + + const staged = db.prepare('SELECT id, ordinal, normalized, errors, outcome FROM import_rows WHERE batch_id = ? ORDER BY ordinal').all(batch.id); + const rows = staged.map((row) => ({ ordinal: row.ordinal, contact: (JSON.parse(row.normalized) as { contact: NormalizedContact }).contact, errors: JSON.parse(row.errors) as string[] })); + const now = resolveRows(db, actor.workspaceId, rows); + const drift = staged.findIndex((row, i) => row.outcome !== now[i]?.outcome || (JSON.parse(row.normalized) as { resolved: ResolvedRow }).resolved.existingContactId !== now[i]?.existingContactId); + if (drift !== -1) throw new ImportStaleError(`workspace changed since the preview (row ${drift + 1}); preview again`); + + const contactByOrdinal = new Map(); + let created = 0; + let updated = 0; + let merged = 0; + staged.forEach((row, i) => { + const resolved = now[i] as ResolvedRow; + const contact = (rows[i] as { contact: NormalizedContact }).contact; + let contactId: string | null = null; + if (resolved.outcome === 'create') { + contactId = createContact(db, actor.workspaceId, contact, profile.spec, batch.id, input.now); + created += 1; + } else if (resolved.outcome === 'update' && resolved.existingContactId) { + contactId = resolved.existingContactId; + updateContact(db, actor.workspaceId, contactId, contact, profile.spec, batch.id, input.now); + updated += 1; + } else if (resolved.outcome === 'merge' && resolved.mergeInto !== undefined) { + contactId = contactByOrdinal.get(resolved.mergeInto) ?? null; + merged += 1; + } + if (contactId) { + contactByOrdinal.set(row.ordinal, contactId); + db.prepare('UPDATE import_rows SET contact_id = ? WHERE id = ?').run(contactId, row.id); + } + }); + db.prepare(`UPDATE import_batches SET status = 'committed', idempotency_key = ?, committed_at = ? WHERE id = ?`).run(input.idempotencyKey, input.now, batch.id); + const skipped = staged.length - created - updated - merged; + audit(db, actor, input.now, batch.id, 'committed', { created, updated, merged, skipped }); + return { batchId: batch.id, created, updated, merged, skipped, alreadyCommitted: false }; + }); +} + +function organizationFor(db: SqlDatabase, workspaceId: string, contact: NormalizedContact, now: number): string | null { + if (!contact.org_name && !contact.org_domain) return null; + const existing = contact.org_domain + ? db.prepare('SELECT id FROM organizations WHERE workspace_id = ? AND domain_norm = ?').get<{ id: string }>(workspaceId, contact.org_domain) + : db.prepare('SELECT id FROM organizations WHERE workspace_id = ? AND domain_norm IS NULL AND lower(name) = lower(?)').get<{ id: string }>(workspaceId, contact.org_name ?? ''); + if (existing) return existing.id; + const id = ulid(now); + db.prepare('INSERT INTO organizations (id, workspace_id, name, domain_norm) VALUES (?,?,?,?)').run(id, workspaceId, contact.org_name ?? contact.org_domain ?? '', contact.org_domain); + return id; +} + +function addPoint(db: SqlDatabase, workspaceId: string, contactId: string, kind: string, value: string, profile: MappingProfile, source: string, now: number): void { + db.prepare( + `INSERT INTO contact_points (id, workspace_id, contact_id, kind, value_norm, value_raw, source, consent_basis, consent_evidence, consent_at, jurisdiction, permitted_channels) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT (workspace_id, kind, value_norm) DO NOTHING`, + ).run(ulid(now), workspaceId, contactId, kind, value, value, source, profile.consent.basis, profile.consent.evidence ?? null, now, profile.jurisdiction, JSON.stringify(kind === 'email' ? ['email'] : [])); +} + +function createContact(db: SqlDatabase, workspaceId: string, contact: NormalizedContact, profile: MappingProfile, batchId: string, now: number): string { + const id = ulid(now); + db.prepare( + `INSERT INTO contacts (id, workspace_id, organization_id, full_name, first_name, title, timezone, locale, attributes, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?)`, + ).run(id, workspaceId, organizationFor(db, workspaceId, contact, now), contact.full_name ?? contact.email ?? contact.profile_url ?? 'Unknown', contact.first_name, contact.title, contact.timezone, contact.locale, JSON.stringify(contact.attributes), now, now); + if (contact.email) addPoint(db, workspaceId, id, 'email', contact.email, profile, `import:${batchId}`, now); + if (contact.profile_url) addPoint(db, workspaceId, id, 'social_profile', contact.profile_url, profile, `import:${batchId}`, now); + if (contact.phone) addPoint(db, workspaceId, id, 'phone', contact.phone, profile, `import:${batchId}`, now); + return id; +} + +/** Fills empty fields only; never overwrites what a person or earlier import already set. */ +function updateContact(db: SqlDatabase, workspaceId: string, contactId: string, contact: NormalizedContact, profile: MappingProfile, batchId: string, now: number): void { + const current = db.prepare('SELECT attributes FROM contacts WHERE id = ?').get<{ attributes: string }>(contactId); + const attributes = { ...contact.attributes, ...(JSON.parse(current?.attributes ?? '{}') as Record) }; + db.prepare( + `UPDATE contacts SET first_name = COALESCE(first_name, ?), title = COALESCE(title, ?), timezone = COALESCE(timezone, ?), + locale = COALESCE(locale, ?), organization_id = COALESCE(organization_id, ?), attributes = ?, updated_at = ? WHERE id = ?`, + ).run(contact.first_name, contact.title, contact.timezone, contact.locale, organizationFor(db, workspaceId, contact, now), JSON.stringify(attributes), now, contactId); + if (contact.email) addPoint(db, workspaceId, contactId, 'email', contact.email, profile, `import:${batchId}`, now); + if (contact.profile_url) addPoint(db, workspaceId, contactId, 'social_profile', contact.profile_url, profile, `import:${batchId}`, now); +} diff --git a/outreach-engine/packages/outreach-import/src/index.ts b/outreach-engine/packages/outreach-import/src/index.ts new file mode 100644 index 0000000..3d16e54 --- /dev/null +++ b/outreach-engine/packages/outreach-import/src/index.ts @@ -0,0 +1,8 @@ +export * from './detect'; +export * from './profile'; +export * from './normalize'; +export { parseDelimited, parseJson, parseXlsx } from './parsers'; +export { parseHtmlTable, parseHtmlCards, parseSelector } from './html'; +export * from './resolve'; +export * from './importer'; +export * from './export'; diff --git a/outreach-engine/packages/outreach-import/src/normalize.ts b/outreach-engine/packages/outreach-import/src/normalize.ts new file mode 100644 index 0000000..89d37ad --- /dev/null +++ b/outreach-engine/packages/outreach-import/src/normalize.ts @@ -0,0 +1,148 @@ +import type { RawRow } from './detect'; +import type { CanonicalField, MappingProfile } from './profile'; + +export interface NormalizedContact { + email: string | null; + full_name: string | null; + first_name: string | null; + last_name: string | null; + title: string | null; + org_name: string | null; + org_domain: string | null; + profile_url: string | null; + timezone: string | null; + locale: string | null; + phone: string | null; + attributes: Record; +} + +export interface NormalizeResult { + readonly contact: NormalizedContact; + readonly errors: string[]; +} + +/** Consumer mailbox domains are never used as the organization domain. */ +const FREEMAIL = new Set([ + 'gmail.com', 'googlemail.com', 'yahoo.com', 'hotmail.com', 'outlook.com', 'live.com', 'icloud.com', 'me.com', + 'aol.com', 'proton.me', 'protonmail.com', 'gmx.com', 'gmx.de', 'web.de', 'yandex.com', 'mail.com', 'zoho.com', +]); + +const EMAIL_RE = /^[a-z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/; + +function collapse(value: string | undefined): string | null { + const out = (value ?? '').replace(/\s+/g, ' ').trim(); + return out === '' ? null : out; +} + +export function normalizeEmail(value: string): string | null { + const email = value.trim().replace(/^mailto:/i, '').replace(/\?.*$/, '').toLowerCase(); + if (email.length > 254 || email.includes('..') || !EMAIL_RE.test(email)) return null; + return email; +} + +export function normalizeProfileUrl(value: string): string | null { + try { + const url = new URL(/^https?:\/\//i.test(value.trim()) ? value.trim() : `https://${value.trim()}`); + if (url.protocol !== 'https:' && url.protocol !== 'http:') return null; + const host = url.hostname.toLowerCase().replace(/^www\./, ''); + const path = url.pathname.replace(/\/+$/, ''); + return `https://${host}${path}`; + } catch { + return null; + } +} + +export function normalizeDomain(value: string): string | null { + const raw = value.trim().toLowerCase(); + const host = raw.includes('://') ? (() => { try { return new URL(raw).hostname; } catch { return ''; } })() : raw.split('/')[0] ?? ''; + const domain = host.replace(/^www\./, ''); + return /^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(domain) ? domain : null; +} + +function isValidZone(zone: string): boolean { + try { + new Intl.DateTimeFormat('en-US', { timeZone: zone }); + return true; + } catch { + return false; + } +} + +function lookup(row: RawRow, ref: string | readonly string[] | undefined): string | undefined { + if (!ref) return undefined; + const wanted = (Array.isArray(ref) ? ref : [ref]).map((name) => name.toLowerCase()); + const keys = Object.keys(row.values); + for (const name of wanted) { + const key = keys.find((candidate) => candidate.trim().toLowerCase() === name); + const value = key === undefined ? undefined : row.values[key]; + if (value !== undefined && value.trim() !== '') return value; + } + return undefined; +} + +/** Maps one raw row through the profile and validates it. Never invents data; reports what is wrong. */ +export function normalizeRow(row: RawRow, profile: MappingProfile): NormalizeResult { + const errors: string[] = []; + const invalid = new Set(); + const get = (field: CanonicalField) => lookup(row, profile.columns[field]); + const contact: NormalizedContact = { + email: null, + full_name: collapse(get('full_name')), + first_name: collapse(get('first_name')), + last_name: collapse(get('last_name')), + title: collapse(get('title')), + org_name: collapse(get('org_name')), + org_domain: null, + profile_url: null, + timezone: null, + locale: collapse(get('locale')), + phone: collapse(get('phone')), + attributes: {}, + }; + const rawEmail = get('email'); + if (rawEmail !== undefined) { + contact.email = normalizeEmail(rawEmail); + if (!contact.email) { + errors.push(`invalid email "${rawEmail.slice(0, 80)}"`); + invalid.add('email'); + } + } + const rawUrl = get('profile_url'); + if (rawUrl !== undefined) { + contact.profile_url = normalizeProfileUrl(rawUrl); + if (!contact.profile_url) { + errors.push(`invalid profile URL "${rawUrl.slice(0, 80)}"`); + invalid.add('profile_url'); + } + } + const rawDomain = get('org_domain'); + if (rawDomain !== undefined) { + contact.org_domain = normalizeDomain(rawDomain); + if (!contact.org_domain) errors.push(`invalid organization domain "${rawDomain.slice(0, 80)}"`); + } else if (contact.email) { + const domain = contact.email.split('@')[1] ?? ''; + if (!FREEMAIL.has(domain)) contact.org_domain = domain; + } + const rawZone = collapse(get('timezone')); + if (rawZone) { + if (isValidZone(rawZone)) contact.timezone = rawZone; + else errors.push(`unknown time zone "${rawZone.slice(0, 60)}"`); + } + if (!contact.full_name && (contact.first_name || contact.last_name)) { + contact.full_name = [contact.first_name, contact.last_name].filter(Boolean).join(' '); + } + if (profile.splitFullName && contact.full_name && !contact.first_name) { + const [first, ...rest] = contact.full_name.split(' '); + contact.first_name = first ?? null; + if (!contact.last_name && rest.length) contact.last_name = rest.join(' '); + } + for (const [key, ref] of Object.entries(profile.attributes)) { + const value = collapse(lookup(row, ref)); + if (value) contact.attributes[key] = value.slice(0, 500); + } + for (const field of profile.required) { + if (!contact[field] && !invalid.has(field)) errors.push(`missing required ${field}`); + } + if (!contact.full_name && !contact.email) errors.push('row has neither a name nor an email'); + return { contact, errors: [...new Set(errors)] }; +} diff --git a/outreach-engine/packages/outreach-import/src/parsers.ts b/outreach-engine/packages/outreach-import/src/parsers.ts new file mode 100644 index 0000000..9a33842 --- /dev/null +++ b/outreach-engine/packages/outreach-import/src/parsers.ts @@ -0,0 +1,128 @@ +import { parse as parseCsv } from 'csv-parse/sync'; +import { ImportRejectedError, type RawRow } from './detect'; + +const HEADER_MAX = 200; + +function cleanHeader(header: string, index: number): string { + const name = header.replace(/^\uFEFF/, '').replace(/\s+/g, ' ').trim().slice(0, HEADER_MAX); + return name === '' ? `column_${index + 1}` : name; +} + +function uniqueHeaders(headers: readonly string[]): string[] { + const seen = new Map(); + return headers.map((header, index) => { + const base = cleanHeader(header, index); + const count = seen.get(base.toLowerCase()) ?? 0; + seen.set(base.toLowerCase(), count + 1); + return count === 0 ? base : `${base}_${count + 1}`; + }); +} + +/** Builds a plain record with a null prototype, so "__proto__" or "constructor" headers are just keys. */ +function record(headers: readonly string[], cells: readonly unknown[]): Record { + const out: Record = Object.create(null) as Record; + headers.forEach((header, index) => { + const cell = cells[index]; + out[header] = cell === undefined || cell === null ? '' : String(cell); + }); + return out; +} + +/** Strict CSV/TSV: rows are arrays (never objects keyed by untrusted headers), quotes enforced. */ +export function parseDelimited(text: string, maxRows: number, delimiter?: string): RawRow[] { + const sample = text.slice(0, 4096); + const guessed = delimiter ?? ((sample.split('\t').length > sample.split(',').length && sample.split('\t').length > sample.split(';').length) ? '\t' : sample.split(';').length > sample.split(',').length ? ';' : ','); + let rows: string[][]; + try { + rows = parseCsv(text, { delimiter: guessed, bom: true, relax_column_count: true, skip_empty_lines: true, trim: false, to_line: maxRows + 2 }) as string[][]; + } catch (error) { + throw new ImportRejectedError(`CSV could not be parsed: ${(error as Error).message.slice(0, 200)}`); + } + const [header, ...body] = rows; + if (!header) return []; + if (body.length > maxRows) throw new ImportRejectedError(`more than ${maxRows} rows`); + const headers = uniqueHeaders(header); + return body.map((cells, index) => ({ locator: `csv:row=${index + 2}`, values: record(headers, cells) })); +} + +function flatten(value: unknown, prefix = '', out: Record = Object.create(null) as Record): Record { + if (value && typeof value === 'object' && !Array.isArray(value)) { + for (const [key, inner] of Object.entries(value as Record)) { + if (key === '__proto__') continue; + const name = prefix ? `${prefix}.${key}` : key; + if (inner && typeof inner === 'object' && !Array.isArray(inner) && prefix.split('.').length < 3) flatten(inner, name, out); + else out[name] = inner === null || inner === undefined ? '' : Array.isArray(inner) ? inner.join(', ') : String(inner); + } + } + return out; +} + +/** A JSON array of objects, or JSON Lines. Nested objects flatten to dotted keys (max depth 3). */ +export function parseJson(text: string, maxRows: number): RawRow[] { + const trimmed = text.trim(); + let items: unknown[]; + let locator: (i: number) => string; + try { + if (trimmed.startsWith('[')) { + items = JSON.parse(trimmed) as unknown[]; + locator = (i) => `json:[${i}]`; + } else { + const lines = trimmed.split(/\r?\n/).filter((line) => line.trim() !== ''); + items = lines.map((line) => JSON.parse(line) as unknown); + locator = (i) => `jsonl:line=${i + 1}`; + } + } catch (error) { + throw new ImportRejectedError(`JSON could not be parsed: ${(error as Error).message.slice(0, 200)}`); + } + if (!Array.isArray(items)) throw new ImportRejectedError('JSON must be an array of objects or JSON Lines'); + if (items.length > maxRows) throw new ImportRejectedError(`more than ${maxRows} rows`); + return items.map((item, index) => ({ locator: locator(index), values: flatten(item) })); +} + +interface XlsxCellValue { + result?: unknown; + formula?: unknown; + text?: unknown; + hyperlink?: unknown; + richText?: { text: string }[]; + error?: unknown; +} + +/** Reads cached values only. Formulas are never evaluated and external links never followed. */ +function xlsxCell(value: unknown): string { + if (value === null || value === undefined) return ''; + if (value instanceof Date) return value.toISOString(); + if (typeof value !== 'object') return String(value); + const cell = value as XlsxCellValue; + if (Array.isArray(cell.richText)) return cell.richText.map((part) => part.text).join(''); + if ('formula' in cell || 'sharedFormula' in cell) return cell.result === undefined || cell.result === null ? '' : xlsxCell(cell.result); + if ('hyperlink' in cell) return String(cell.text ?? cell.hyperlink ?? ''); + if ('error' in cell) return ''; + return ''; +} + +export async function parseXlsx(bytes: Uint8Array, maxRows: number, sheetName?: string): Promise { + const { default: ExcelJS } = await import('exceljs'); + const workbook = new ExcelJS.Workbook(); + try { + await workbook.xlsx.load(Buffer.from(bytes) as unknown as ArrayBuffer); + } catch (error) { + throw new ImportRejectedError(`XLSX could not be read: ${(error as Error).message.slice(0, 200)}`); + } + const sheet = sheetName ? workbook.getWorksheet(sheetName) : workbook.worksheets[0]; + if (!sheet) throw new ImportRejectedError(sheetName ? `sheet "${sheetName}" not found` : 'workbook has no sheets'); + if (sheet.actualRowCount > maxRows + 1) throw new ImportRejectedError(`more than ${maxRows} rows`); + const rows: RawRow[] = []; + let headers: string[] | null = null; + sheet.eachRow({ includeEmpty: false }, (row, rowNumber) => { + const cells: string[] = []; + for (let column = 1; column <= row.cellCount; column += 1) cells.push(xlsxCell(row.getCell(column).value)); + if (!headers) { + headers = uniqueHeaders(cells); + return; + } + if (cells.every((cell) => cell.trim() === '')) return; + rows.push({ locator: `xlsx:${sheet.name}!${rowNumber}`, values: record(headers, cells) }); + }); + return rows; +} diff --git a/outreach-engine/packages/outreach-import/src/profile.ts b/outreach-engine/packages/outreach-import/src/profile.ts new file mode 100644 index 0000000..8b93970 --- /dev/null +++ b/outreach-engine/packages/outreach-import/src/profile.ts @@ -0,0 +1,93 @@ +import { ulid, type SqlDatabase } from '@splitin/outreach-contracts'; +import { z } from 'zod'; + +export const CANONICAL_FIELDS = [ + 'email', + 'full_name', + 'first_name', + 'last_name', + 'title', + 'org_name', + 'org_domain', + 'profile_url', + 'timezone', + 'locale', + 'phone', +] as const; +export type CanonicalField = (typeof CANONICAL_FIELDS)[number]; + +const columnRef = z.union([z.string().min(1), z.array(z.string().min(1)).min(1)]); + +const htmlSpec = z.discriminatedUnion('mode', [ + z.object({ mode: z.literal('table'), table: z.number().int().min(0).default(0) }).strict(), + z + .object({ + mode: z.literal('cards'), + /** Simple selector for one record, e.g. "div.person" or "li[data-lead]". */ + card: z.string().min(1), + /** Column name -> selector within the card; "selector@attr" reads an attribute, "@attr" reads the card's own. */ + fields: z.record(z.string(), z.string().min(1)), + }) + .strict(), +]); + +export const MappingProfileSchema = z + .object({ + format: z.enum(['csv', 'xlsx', 'html', 'json']).optional(), + delimiter: z.string().length(1).optional(), + sheet: z.string().optional(), + html: htmlSpec.optional(), + /** Canonical field -> source column name(s); the first non-empty match wins. Matching is case-insensitive. */ + columns: z.partialRecord(z.enum(CANONICAL_FIELDS), columnRef), + /** Extra attributes kept on the contact, available to templates as {{attr.}}. */ + attributes: z.record(z.string().regex(/^[a-z0-9_]+$/), columnRef).default({}), + required: z.array(z.enum(CANONICAL_FIELDS)).default(['email']), + consent: z + .object({ + basis: z.enum(['consent', 'legitimate_interest', 'existing_relationship', 'unknown']), + evidence: z.string().max(500).optional(), + }) + .strict(), + jurisdiction: z.string().regex(/^([A-Z]{2}|unknown)$/).default('unknown'), + /** Derive first/last name from full_name when absent (first token / rest). */ + splitFullName: z.boolean().default(true), + }) + .strict(); + +export type MappingProfile = z.infer; + +export interface StoredProfile { + readonly id: string; + readonly name: string; + readonly version: number; + readonly spec: MappingProfile; +} + +export class ProfileError extends Error { + constructor(readonly issues: readonly string[]) { + super(`Invalid mapping profile:\n- ${issues.join('\n- ')}`); + this.name = 'ProfileError'; + } +} + +export function parseProfile(input: unknown): MappingProfile { + const result = MappingProfileSchema.safeParse(input); + if (!result.success) throw new ProfileError(result.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`)); + return result.data; +} + +/** Stores a new immutable version of a named profile. Must run inside a transaction. */ +export function saveMappingProfile(db: SqlDatabase, workspaceId: string, name: string, input: unknown, now: number): StoredProfile { + const spec = parseProfile(input); + const latest = db.prepare('SELECT MAX(version) AS v FROM mapping_profiles WHERE workspace_id = ? AND name = ?').get<{ v: number | null }>(workspaceId, name); + const version = (latest?.v ?? 0) + 1; + const id = ulid(now); + db.prepare('INSERT INTO mapping_profiles (id, workspace_id, name, version, spec) VALUES (?,?,?,?,?)').run(id, workspaceId, name, version, JSON.stringify(spec)); + return { id, name, version, spec }; +} + +export function loadMappingProfile(db: SqlDatabase, workspaceId: string, id: string): StoredProfile | undefined { + const row = db.prepare('SELECT id, name, version, spec FROM mapping_profiles WHERE workspace_id = ? AND id = ?') + .get<{ id: string; name: string; version: number; spec: string }>(workspaceId, id); + return row ? { id: row.id, name: row.name, version: row.version, spec: JSON.parse(row.spec) as MappingProfile } : undefined; +} diff --git a/outreach-engine/packages/outreach-import/src/resolve.ts b/outreach-engine/packages/outreach-import/src/resolve.ts new file mode 100644 index 0000000..46690a1 --- /dev/null +++ b/outreach-engine/packages/outreach-import/src/resolve.ts @@ -0,0 +1,55 @@ +import type { SqlDatabase } from '@splitin/outreach-contracts'; +import type { NormalizedContact } from './normalize'; + +export type RowOutcome = 'create' | 'update' | 'merge' | 'reject' | 'ambiguous'; + +export interface ResolvedRow { + readonly outcome: RowOutcome; + /** For merge: the ordinal of the earlier row in this file with the same identity. */ + readonly mergeInto?: number; + /** For update/ambiguous: the existing contact. */ + readonly existingContactId?: string; + readonly note?: string; +} + +export interface ResolvableRow { + readonly ordinal: number; + readonly contact: NormalizedContact; + readonly errors: readonly string[]; +} + +/** + * Deterministic duplicate resolution with explainable rules: + * 1. rows with errors are rejected; + * 2. an email (or, without email, a profile URL) seen earlier in the file merges into that row; + * 3. an email or profile URL already in the workspace updates that contact; + * 4. same name at the same organization domain under a different email is ambiguous: never auto-merged; + * 5. everything else creates a contact. + */ +export function resolveRows(db: SqlDatabase, workspaceId: string, rows: readonly ResolvableRow[]): ResolvedRow[] { + const firstByKey = new Map(); + const pointLookup = db.prepare('SELECT contact_id FROM contact_points WHERE workspace_id = ? AND kind = ? AND value_norm = ?'); + const nameLookup = db.prepare( + `SELECT c.id FROM contacts c JOIN organizations o ON o.id = c.organization_id + WHERE c.workspace_id = ? AND lower(c.full_name) = lower(?) AND o.domain_norm = ? AND c.merged_into_id IS NULL LIMIT 1`, + ); + return rows.map((row): ResolvedRow => { + if (row.errors.length) return { outcome: 'reject', note: row.errors.join('; ') }; + const { email, profile_url: profileUrl } = row.contact; + const key = email ? `email:${email}` : profileUrl ? `profile:${profileUrl}` : null; + if (key) { + const earlier = firstByKey.get(key); + if (earlier !== undefined) return { outcome: 'merge', mergeInto: earlier, note: `same ${key.split(':')[0]} as an earlier row` }; + firstByKey.set(key, row.ordinal); + } + const existing = + (email ? pointLookup.get<{ contact_id: string }>(workspaceId, 'email', email) : undefined) ?? + (profileUrl ? pointLookup.get<{ contact_id: string }>(workspaceId, 'social_profile', profileUrl) : undefined); + if (existing) return { outcome: 'update', existingContactId: existing.contact_id }; + if (row.contact.full_name && row.contact.org_domain) { + const similar = nameLookup.get<{ id: string }>(workspaceId, row.contact.full_name, row.contact.org_domain); + if (similar) return { outcome: 'ambiguous', existingContactId: similar.id, note: 'same name and organization as an existing contact with a different email' }; + } + return { outcome: 'create' }; + }); +} diff --git a/outreach-engine/packages/outreach-import/tsconfig.json b/outreach-engine/packages/outreach-import/tsconfig.json new file mode 100644 index 0000000..585a92d --- /dev/null +++ b/outreach-engine/packages/outreach-import/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist" }, + "include": ["src"] +} diff --git a/outreach-engine/packages/outreach-import/tsup.config.ts b/outreach-engine/packages/outreach-import/tsup.config.ts new file mode 100644 index 0000000..458d1fd --- /dev/null +++ b/outreach-engine/packages/outreach-import/tsup.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + dts: true, + sourcemap: true, + clean: true, + target: 'node22', + // node:sqlite exists only with the protocol prefix. + removeNodeProtocol: false, +}); From 22240b8bf91e0f386544d8b16be0abc16860b27f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 01:53:14 +0000 Subject: [PATCH 08/20] outreach-engine M6: inbound processing, reply stop, unsubscribe, e2e MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements milestone M6 of outreach-engine/BUILD_PLAN.md (§8, §13, §14): replies, bounces, complaints and opt-outs stop sequences atomically, and the full import-to-audit loop runs end to end on fake providers. @splitin/outreach-core, src/inbound/ (new) - ingest.ts: * ingestWebhook(): 1 MB cap, then the adapter verifies the RAW body (signature and timestamp) with the account's webhook secret reference before anything is parsed. Rejections are audited and store nothing. Accepted events are stored durably and processed later, so the endpoint can answer fast. * pollMailbox() / pollDueMailboxes(): cursor-based polling as the webhook fallback, with the cursor persisted in the same transaction as the events. Per-account failures never block sends or other accounts. * storeInboundEvents(): (account, provider event id) is unique, so webhook retries and poll overlap collapse. Payloads are minimized to identifiers, headers and a snippet of at most 500 characters; full bodies are never kept. - classify.ts: deterministic rules. Complaint and delivery events; DSNs (5.x.x hard, 4.x.x soft, otherwise unknown); auto-replies (Auto-Submitted unless "no", X-Autoreply/X-Autorespond, Precedence auto_reply/bulk/junk, multilingual out-of-office subjects); opt-out phrases or a bare "STOP"; everything else is a human reply. No LLM applies a class. - correlate.ts: in order of trust: provider thread id, then In-Reply-To / References / DSN original Message-ID against our generated Message-IDs (strong), then the sender or bounced recipient matching an outbound recipient within 30 days (weak). - process.ts: one transaction per event, oldest first: classify, correlate, record the inbound message, apply, audit, notify. * human reply: stop the enrollment as `replied`, cancelling pending actions (including one another worker has claimed; its preflight then finds the lease lost). Weak matches still stop, the safe direction, but go to review for a human. * opt-out: global suppression plus every live enrollment of the contact stopped as `opted_out`. * hard bounce: email-channel suppression plus `bounced`; soft bounces stop only on the third. * complaint: global suppression, stop, and the sending account's kill switch engaged (plan §18 circuit breaker). * auto-reply: recorded, no stop. Uncorrelated mail: review. Deliveries: ignored. * notifications: when a workspace has a notification account (configureNotifications), replies, opt-outs, bounces, complaints and review items enqueue durable notify.publish actions with ids and routing only, never message bodies. - unsubscribe.ts: handleUnsubscribe() behind POST /u/:token (RFC 8058 one-click). The token is verified by HMAC alone, so forged tokens cannot probe the database. Global opt-out suppression, all live enrollments of the contact stopped, audited, idempotent. - engine.runOnce() now polls due mailboxes and processes inbound events BEFORE the execution pass, so a reply received since the last pass stops a follow-up in the same pass it would have been sent. pollIntervalMs is configurable (default 5 minutes). @splitin/outreach-e2e (new, private, tests only) - Plan §13 end to end on fake providers: bootstrap a workspace with Slack-style principals; register email and notification accounts; import a CSV (4 create, 1 merge, 1 reject) through preview and commit; templates; a playbook whose audience is that import batch; campaign-version approval plus first-batch approval (only the first two wait); sends inside each recipient's window, including Helsinki; List-Unsubscribe on every message. Then: one recipient replies by signed webhook (stopped, notified), one clicks unsubscribe (globally suppressed), one hard-bounces (suppressed, notified), one stays silent, gets the manual social task, and after a human completes it, a threaded bump. Final state: replied 1, opted_out 1, bounced 1, completed 1; every message delivered exactly once; the audit chain verifies. Tests (30 new, 146 total): 18 classification cases; threaded webhook reply stops before the follow-up and notifies without the body; webhook plus poll of the same event processed once; Message-ID and weak-address correlation, with weak matches to review; a reply racing a claimed follow-up; auto-replies do not stop; opt-out suppression and notification; hard vs soft bounces; complaint engages the account kill switch; uncorrelated mail and delivery receipts; forged, stale and oversized webhooks rejected with nothing stored; unsubscribe link suppression, idempotency and forged-token rejection; the e2e suite. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MbDW8kfMcxUuZ3WBtaDKBv --- outreach-engine/README.md | 8 +- outreach-engine/package-lock.json | 16 ++ .../src/domain/domain.test-util.ts | 3 +- .../outreach-core/src/domain/operations.ts | 12 ++ .../packages/outreach-core/src/engine.ts | 27 ++- .../src/inbound/classify.test.ts | 39 ++++ .../outreach-core/src/inbound/classify.ts | 55 ++++++ .../outreach-core/src/inbound/correlate.ts | 49 +++++ .../outreach-core/src/inbound/inbound.test.ts | 173 ++++++++++++++++++ .../outreach-core/src/inbound/index.ts | 5 + .../outreach-core/src/inbound/ingest.ts | 88 +++++++++ .../outreach-core/src/inbound/process.ts | 146 +++++++++++++++ .../outreach-core/src/inbound/unsubscribe.ts | 36 ++++ .../packages/outreach-core/src/index.ts | 1 + .../packages/outreach-e2e/package.json | 18 ++ .../outreach-e2e/src/outreach.e2e.test.ts | 165 +++++++++++++++++ .../packages/outreach-e2e/tsconfig.json | 5 + 17 files changed, 840 insertions(+), 6 deletions(-) create mode 100644 outreach-engine/packages/outreach-core/src/inbound/classify.test.ts create mode 100644 outreach-engine/packages/outreach-core/src/inbound/classify.ts create mode 100644 outreach-engine/packages/outreach-core/src/inbound/correlate.ts create mode 100644 outreach-engine/packages/outreach-core/src/inbound/inbound.test.ts create mode 100644 outreach-engine/packages/outreach-core/src/inbound/index.ts create mode 100644 outreach-engine/packages/outreach-core/src/inbound/ingest.ts create mode 100644 outreach-engine/packages/outreach-core/src/inbound/process.ts create mode 100644 outreach-engine/packages/outreach-core/src/inbound/unsubscribe.ts create mode 100644 outreach-engine/packages/outreach-e2e/package.json create mode 100644 outreach-engine/packages/outreach-e2e/src/outreach.e2e.test.ts create mode 100644 outreach-engine/packages/outreach-e2e/tsconfig.json diff --git a/outreach-engine/README.md b/outreach-engine/README.md index fe05b4c..c996305 100644 --- a/outreach-engine/README.md +++ b/outreach-engine/README.md @@ -12,8 +12,11 @@ pause, and records every transition in an append-only audit log. The engine is the product. The CLI, HTTP API, MCP server, Slack and the Papr Work app are thin clients of it. -> **Status: M0 (scaffold).** No outreach logic exists yet. The full specification is -> [BUILD_PLAN.md](BUILD_PLAN.md); milestones are in §14. +> **Status: M0–M6 complete.** Contracts, fakes, SQLite store, the durable execution core, +> the campaign domain, the importer and inbound processing are built and tested on fake +> providers. Surfaces (CLI, HTTP, MCP, Papr app) and real provider adapters are M7–M9. +> Nothing is emailed until an admin opens the live-send gate. Specification: +> [BUILD_PLAN.md](BUILD_PLAN.md), milestones in §14. ## Guarantees it is being built to @@ -34,6 +37,7 @@ are thin clients of it. | `@splitin/outreach-store-sqlite` | M2 | Migrations, repositories, audit hash chain | | `@splitin/outreach-core` | M3–M4, M6 | Execution core, campaign domain, policy, inbound processing | | `@splitin/outreach-import` | M5 | Inert HTML/CSV/XLSX/JSON importer | +| `@splitin/outreach-e2e` (private) | M6 | Import-to-audit end-to-end suite on fake providers | | `@splitin/outreach-server`, `-cli`, `-mcp` | M7, M9 | Surfaces | | `@splitin/outreach-notify-slack`, `-provider-email-*` | M8 | Adapters | diff --git a/outreach-engine/package-lock.json b/outreach-engine/package-lock.json index fe3372c..34c6afe 100644 --- a/outreach-engine/package-lock.json +++ b/outreach-engine/package-lock.json @@ -1133,6 +1133,10 @@ "resolved": "packages/outreach-core", "link": true }, + "node_modules/@splitin/outreach-e2e": { + "resolved": "packages/outreach-e2e", + "link": true + }, "node_modules/@splitin/outreach-fakes": { "resolved": "packages/outreach-fakes", "link": true @@ -4553,6 +4557,18 @@ "node": ">=22.13" } }, + "packages/outreach-e2e": { + "name": "@splitin/outreach-e2e", + "version": "0.0.0", + "license": "MIT", + "devDependencies": { + "@splitin/outreach-contracts": "0.0.0", + "@splitin/outreach-core": "0.0.0", + "@splitin/outreach-fakes": "0.0.0", + "@splitin/outreach-import": "0.0.0", + "@splitin/outreach-store-sqlite": "0.0.0" + } + }, "packages/outreach-fakes": { "name": "@splitin/outreach-fakes", "version": "0.0.0", diff --git a/outreach-engine/packages/outreach-core/src/domain/domain.test-util.ts b/outreach-engine/packages/outreach-core/src/domain/domain.test-util.ts index 89f36c8..108d13c 100644 --- a/outreach-engine/packages/outreach-core/src/domain/domain.test-util.ts +++ b/outreach-engine/packages/outreach-core/src/domain/domain.test-util.ts @@ -61,6 +61,7 @@ export async function makeDomainEnv(options: { sendGate?: SendGate; start?: numb unsubscribe: { baseUrl: 'https://outreach.example.com/u/', secret: 'unsubscribe-test-secret-fixture' }, now: () => now, execution: { reconcileDelayMs: 0 }, + pollIntervalMs: 0, random: () => 0.5, }); bootstrapWorkspace(db, { workspaceId: 'ws', name: 'Test', adminRef: 'test:admin', adminName: 'Admin' }, now); @@ -103,7 +104,7 @@ export async function makeDomainEnv(options: { sendGate?: SendGate; start?: numb drain: async (passes = 5) => { for (let i = 0; i < passes; i += 1) { const report = await engine.runOnce(); - if (report.execute.claimed === 0 && report.reconcile.found + report.reconcile.absent === 0) return; + if (report.execute.claimed === 0 && report.reconcile.found + report.reconcile.absent === 0 && report.inbound.processed === 0) return; } }, contact: (email, extra = {}) => diff --git a/outreach-engine/packages/outreach-core/src/domain/operations.ts b/outreach-engine/packages/outreach-core/src/domain/operations.ts index e641c94..e92810f 100644 --- a/outreach-engine/packages/outreach-core/src/domain/operations.ts +++ b/outreach-engine/packages/outreach-core/src/domain/operations.ts @@ -127,6 +127,18 @@ export function setEnrollmentState(env: DomainEnv, ctx: AuthContext, enrollmentI }); } +/** Routes inbound notifications (replies, opt-outs, bounces, complaints) to a notification account, or turns them off. */ +export function configureNotifications(env: DomainEnv, ctx: AuthContext, notifyAccountId: string | null): void { + requireRole(ctx, 'admin'); + env.db.transaction(() => { + const now = env.now(); + const row = env.db.prepare('SELECT settings FROM workspaces WHERE id = ?').get<{ settings: string }>(ctx.workspaceId); + const settings = { ...(JSON.parse(row?.settings ?? '{}') as Record), notifyAccountId }; + env.db.prepare('UPDATE workspaces SET settings = ? WHERE id = ?').run(JSON.stringify(settings), ctx.workspaceId); + audit(env.db, ctx, now, 'workspace', ctx.workspaceId, 'notifications_configured', { notifyAccountId }); + }); +} + export interface ManualTaskRow { id: string; action_id: string; diff --git a/outreach-engine/packages/outreach-core/src/engine.ts b/outreach-engine/packages/outreach-core/src/engine.ts index c6975ae..8ea20dc 100644 --- a/outreach-engine/packages/outreach-core/src/engine.ts +++ b/outreach-engine/packages/outreach-core/src/engine.ts @@ -6,6 +6,8 @@ import type { ActionRow, CrashHooks, ExecutionConfig, ExecutionDeps, SendGate } import { actorOf, audit, requireRole, type AuthContext, type Role } from './domain/auth'; import type { DomainEnv, UnsubscribeConfig } from './domain/env'; import { domainChecks, domainEffects, domainRatePolicy } from './domain/wiring'; +import { pollDueMailboxes } from './inbound/ingest'; +import { processInboundEvents, type ProcessReport } from './inbound/process'; export interface EngineConfig { readonly db: SqlDatabase; @@ -20,12 +22,22 @@ export interface EngineConfig { readonly manual?: ManualTaskProvider; readonly hooks?: CrashHooks; readonly random?: () => number; + /** How often each mailbox is polled as a webhook fallback (default 5 minutes). */ + readonly pollIntervalMs?: number; +} + +export interface WorkerPassReport extends ExecutionPassReport { + readonly polled: number; + readonly inbound: ProcessReport; } export interface Engine extends DomainEnv { readonly exec: ExecutionDeps; - /** One worker pass: sweep leases, reconcile, execute due actions. Idempotent and safe to run concurrently. */ - runOnce(): Promise; + /** + * One worker pass: poll due mailboxes, apply inbound events (so replies stop sequences BEFORE the next + * send), then sweep leases, reconcile and execute due actions. Idempotent and safe to run concurrently. + */ + runOnce(): Promise; } export function createEngine(config: EngineConfig): Engine { @@ -55,7 +67,16 @@ export function createEngine(config: EngineConfig): Engine { }; const env: DomainEnv = { db: config.db, now, adapters, exec, ...(config.unsubscribe ? { unsubscribe: config.unsubscribe } : {}) }; holder.env = env; - return { ...env, exec, runOnce: () => runExecutionPass(exec) }; + const pollIntervalMs = config.pollIntervalMs ?? 5 * 60_000; + return { + ...env, + exec, + runOnce: async () => { + const polled = await pollDueMailboxes(env, pollIntervalMs); + const inbound = processInboundEvents(env); + return { polled, inbound, ...(await runExecutionPass(exec)) }; + }, + }; } /** Creates a workspace and its first admin. The only operation that needs no existing principal. */ diff --git a/outreach-engine/packages/outreach-core/src/inbound/classify.test.ts b/outreach-engine/packages/outreach-core/src/inbound/classify.test.ts new file mode 100644 index 0000000..19ed415 --- /dev/null +++ b/outreach-engine/packages/outreach-core/src/inbound/classify.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import type { InboundMailEvent } from '@splitin/outreach-contracts'; +import { classifyInbound } from './classify'; + +const base: InboundMailEvent = { + eventId: 'e', + kind: 'message', + providerMessageId: 'm', + references: [], + from: 'lead@example.org', + to: ['sender@example.com'], + receivedAt: 0, + headers: {}, +}; + +describe('classifyInbound', () => { + it.each([ + [{ subject: 'Re: Quick question', snippet: 'Sounds good, Tuesday works.' }, 'human_reply'], + [{ headers: { 'Auto-Submitted': 'auto-replied' } }, 'auto_reply'], + [{ headers: { 'auto-submitted': 'no' }, snippet: 'Real answer' }, 'human_reply'], + [{ headers: { 'X-Autoreply': 'yes' } }, 'auto_reply'], + [{ headers: { Precedence: 'bulk' } }, 'auto_reply'], + [{ subject: 'Out of Office: back Monday' }, 'auto_reply'], + [{ subject: 'Automatic reply: Quick question' }, 'auto_reply'], + [{ snippet: 'Please unsubscribe me from this list' }, 'opt_out'], + [{ snippet: 'remove me' }, 'opt_out'], + [{ snippet: "Don't contact me again" }, 'opt_out'], + [{ snippet: 'STOP' }, 'opt_out'], + [{ snippet: 'Stop by our booth next week!' }, 'human_reply'], + [{ kind: 'bounce', dsn: { status: '5.1.1' } }, 'hard_bounce'], + [{ kind: 'bounce', dsn: { status: '4.2.2' } }, 'soft_bounce'], + [{ kind: 'bounce' }, 'unknown'], + [{ contentType: 'multipart/report; report-type="delivery-status"', dsn: { status: '5.7.1' } }, 'hard_bounce'], + [{ kind: 'complaint' }, 'complaint'], + [{ kind: 'delivery' }, 'delivery'], + ] as const)('%j -> %s', (patch, expected) => { + expect(classifyInbound({ ...base, ...(patch as Partial) })).toBe(expected); + }); +}); diff --git a/outreach-engine/packages/outreach-core/src/inbound/classify.ts b/outreach-engine/packages/outreach-core/src/inbound/classify.ts new file mode 100644 index 0000000..8ed664c --- /dev/null +++ b/outreach-engine/packages/outreach-core/src/inbound/classify.ts @@ -0,0 +1,55 @@ +import type { InboundMailEvent } from '@splitin/outreach-contracts'; + +export type InboundClass = + | 'human_reply' + | 'auto_reply' + | 'opt_out' + | 'hard_bounce' + | 'soft_bounce' + | 'complaint' + | 'delivery' + | 'unknown'; + +const AUTO_SUBJECT = /^\s*(out of (the )?office|automatic reply|auto(matic)?[- ]?(reply|response)|abwesenheitsnotiz|réponse automatique|respuesta automática|on vacation|away from (the )?office)/i; +const OPT_OUT = /\b(unsubscribe|opt[\s-]?out|remove me|take me off|stop (emailing|contacting|messaging)|do not (email|contact)|don'?t (email|contact) me)\b/i; + +function header(event: InboundMailEvent, name: string): string | undefined { + const wanted = name.toLowerCase(); + const key = Object.keys(event.headers).find((candidate) => candidate.toLowerCase() === wanted); + return key === undefined ? undefined : event.headers[key]; +} + +function isDsn(event: InboundMailEvent): boolean { + return event.kind === 'bounce' || /report-type=("?)delivery-status\1/i.test(event.contentType ?? ''); +} + +/** + * Deterministic classification (BUILD_PLAN.md §8.2). Rules only; an LLM may suggest a class for + * `unknown` or weakly-correlated items in the review queue but never applies one. + */ +export function classifyInbound(event: InboundMailEvent): InboundClass { + if (event.kind === 'complaint') return 'complaint'; + if (event.kind === 'delivery') return 'delivery'; + if (isDsn(event)) { + const status = event.dsn?.status ?? ''; + if (/^5\.\d+\.\d+$/.test(status)) return 'hard_bounce'; + if (/^4\.\d+\.\d+$/.test(status)) return 'soft_bounce'; + return 'unknown'; + } + const autoSubmitted = header(event, 'auto-submitted'); + const precedence = header(event, 'precedence')?.toLowerCase(); + if ( + (autoSubmitted && autoSubmitted.toLowerCase() !== 'no') || + header(event, 'x-autoreply') !== undefined || + header(event, 'x-autorespond') !== undefined || + precedence === 'auto_reply' || + precedence === 'bulk' || + precedence === 'junk' || + AUTO_SUBJECT.test(event.subject ?? '') + ) { + return 'auto_reply'; + } + const text = `${event.subject ?? ''}\n${event.snippet ?? ''}`; + if (OPT_OUT.test(text) || /^\s*stop\s*[.!]?\s*$/i.test(event.snippet ?? '')) return 'opt_out'; + return 'human_reply'; +} diff --git a/outreach-engine/packages/outreach-core/src/inbound/correlate.ts b/outreach-engine/packages/outreach-core/src/inbound/correlate.ts new file mode 100644 index 0000000..7ca69c2 --- /dev/null +++ b/outreach-engine/packages/outreach-core/src/inbound/correlate.ts @@ -0,0 +1,49 @@ +import type { InboundMailEvent, SqlDatabase } from '@splitin/outreach-contracts'; + +export interface Correlation { + readonly enrollmentId: string; + /** strong: provider thread or Message-ID chain; weak: same address within 30 days only. */ + readonly strength: 'strong' | 'weak'; + readonly via: 'thread' | 'message_id' | 'recipient'; +} + +const WEAK_WINDOW_MS = 30 * 86_400_000; + +/** + * Correlates an inbound event with the enrollment it answers (BUILD_PLAN.md §8.3), in order of trust: + * provider thread id, then In-Reply-To/References/DSN original id against our Message-IDs, then the + * sender (or bounced recipient) matching a recent outbound recipient. + */ +export function correlateInbound(db: SqlDatabase, workspaceId: string, providerAccountId: string, event: InboundMailEvent): Correlation | null { + if (event.providerThreadId) { + const row = db + .prepare( + `SELECT enrollment_id FROM messages WHERE provider_account_id = ? AND provider_thread_id = ? AND direction = 'outbound' + AND enrollment_id IS NOT NULL ORDER BY at DESC LIMIT 1`, + ) + .get<{ enrollment_id: string }>(providerAccountId, event.providerThreadId); + if (row) return { enrollmentId: row.enrollment_id, strength: 'strong', via: 'thread' }; + } + const ids = [...new Set([event.inReplyTo, ...event.references, event.dsn?.originalMessageId].filter((id): id is string => !!id))].slice(0, 50); + if (ids.length) { + const placeholders = ids.map(() => '?').join(','); + const row = db + .prepare( + `SELECT enrollment_id FROM messages WHERE workspace_id = ? AND direction = 'outbound' AND enrollment_id IS NOT NULL + AND rfc_message_id IN (${placeholders}) ORDER BY at DESC LIMIT 1`, + ) + .get<{ enrollment_id: string }>(workspaceId, ...ids); + if (row) return { enrollmentId: row.enrollment_id, strength: 'strong', via: 'message_id' }; + } + const address = (event.kind === 'bounce' ? event.dsn?.recipient : event.from)?.trim().toLowerCase(); + if (address) { + const row = db + .prepare( + `SELECT enrollment_id FROM messages WHERE workspace_id = ? AND direction = 'outbound' AND recipient_norm = ? + AND enrollment_id IS NOT NULL AND at >= ? ORDER BY at DESC LIMIT 1`, + ) + .get<{ enrollment_id: string }>(workspaceId, address, event.receivedAt - WEAK_WINDOW_MS); + if (row) return { enrollmentId: row.enrollment_id, strength: 'weak', via: 'recipient' }; + } + return null; +} diff --git a/outreach-engine/packages/outreach-core/src/inbound/inbound.test.ts b/outreach-engine/packages/outreach-core/src/inbound/inbound.test.ts new file mode 100644 index 0000000..7f09df1 --- /dev/null +++ b/outreach-engine/packages/outreach-core/src/inbound/inbound.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from 'vitest'; +import { verifyAuditChain } from '@splitin/outreach-contracts'; +import { FAKE_WEBHOOK_SECRET, type FakeDelivery } from '@splitin/outreach-fakes'; +import { decideApproval, listApprovals } from '../domain/approvals'; +import { commitActivation, createCampaign, prepareActivation } from '../domain/campaigns'; +import { PLAYBOOK, makeDomainEnv, type DomainTestEnv } from '../domain/domain.test-util'; +import { configureNotifications, registerProviderAccount } from '../domain/operations'; +import { campaignStatus } from '../engine'; +import { claimActions, preflight } from '../execution/preflight'; +import { workerActor } from '../execution/actions-repo'; +import { ingestWebhook } from './ingest'; +import { processInboundEvents } from './process'; +import { handleUnsubscribe } from './unsubscribe'; + +const DAY = 86_400_000; + +async function started(options: { notify?: boolean; contacts?: string[] } = {}) { + const env = await makeDomainEnv(); + for (const email of options.contacts ?? ['ada@example.org']) env.contact(email); + if (options.notify) { + const notifyId = await registerProviderAccount(env.engine, env.admin, { provider: 'fake-notify', externalAccountId: '#gtm', sender: { name: 'Ops', address: 'ops@example.com' }, purposes: ['transactional'], secretRef: 'env:FAKE_EMAIL' }); + configureNotifications(env.engine, env.admin, notifyId); + } + const { campaignId } = createCampaign(env.engine, env.operator, { name: 'Intro', playbook: PLAYBOOK, providerAccountId: env.accountId }); + const preview = prepareActivation(env.engine, env.operator, campaignId); + decideApproval(env.engine, env.approver, { approvalId: preview.approvalId ?? '', decision: 'approved', operationHash: preview.operationHash }); + const { batchApprovalId } = commitActivation(env.engine, env.operator, { campaignId, operationHash: preview.operationHash }); + const batch = listApprovals(env.engine.db, env.approver).find((row) => row.id === batchApprovalId); + decideApproval(env.engine, env.approver, { approvalId: batch?.id ?? '', decision: 'approved', operationHash: batch?.operation_hash ?? '' }); + await env.drain(); + return { env, campaignId, first: env.fake.deliveries[0] as FakeDelivery }; +} + +const statuses = (env: DomainTestEnv, campaignId: string) => campaignStatus(env.engine, env.viewer, campaignId).enrollments; +const events = (env: DomainTestEnv) => env.engine.db.prepare('SELECT status, class, correlation FROM provider_events ORDER BY received_at, id').all(); + +describe('replies stop sequences', () => { + it('a threaded reply by webhook stops the enrollment before the follow-up, and notifies', async () => { + const { env, campaignId, first } = await started({ notify: true }); + const reply = env.fake.reply(first, { at: env.now() + 60_000 }); + const signed = env.fake.signWebhook([reply], env.now()); + expect(await ingestWebhook(env.engine, { providerAccountId: env.accountId, ...signed })).toEqual({ accepted: true, stored: 1, duplicates: 0 }); + env.advance(3 * DAY); + await env.drain(); + expect(env.fake.deliveries).toHaveLength(1); + expect(statuses(env, campaignId)).toEqual({ replied: 1 }); + expect(events(env)).toEqual([{ status: 'processed', class: 'human_reply', correlation: 'strong:thread' }]); + expect(env.notifier.published.map((n) => n.title)).toEqual(['Reply received: sequence stopped']); + expect(env.notifier.published[0]?.lines.join(' ')).not.toMatch(/happy to talk/); + const inbound = env.engine.db.prepare(`SELECT direction, enrollment_id FROM messages WHERE direction = 'inbound'`).get<{ enrollment_id: string | null }>(); + expect(inbound?.enrollment_id).toBeTruthy(); + expect(verifyAuditChain(env.engine.db).ok).toBe(true); + }); + + it('the same event from webhook and polling is processed once', async () => { + const { env, campaignId, first } = await started(); + const reply = env.fake.reply(first, { at: env.now() }); + await ingestWebhook(env.engine, { providerAccountId: env.accountId, ...env.fake.signWebhook([reply], env.now()) }); + await env.drain(); + expect(env.engine.db.prepare('SELECT COUNT(*) AS n FROM provider_events').get<{ n: number }>()?.n).toBe(1); + expect(statuses(env, campaignId)).toEqual({ replied: 1 }); + }); + + it('correlates by Message-ID when the provider gives no thread id, and weakly by address', async () => { + const { env, campaignId, first } = await started({ contacts: ['ada@example.org', 'grace@example.net'] }); + const grace = env.fake.deliveries.find((d) => d.to[0] === 'grace@example.net') as FakeDelivery; + const ada = env.fake.deliveries.find((d) => d.to[0] === 'ada@example.org') as FakeDelivery; + env.fake.pushInbound({ kind: 'message', inReplyTo: ada.rfcMessageId, references: [ada.rfcMessageId], from: 'ada@example.org', to: [ada.from], headers: {}, snippet: 'Yes', at: env.now() }); + env.fake.pushInbound({ kind: 'message', references: [], from: 'Grace@Example.net', to: [grace.from], subject: 'New thread', headers: {}, snippet: 'Hi', at: env.now() }); + await env.drain(); + expect(statuses(env, campaignId)).toEqual({ replied: 2 }); + expect(events(env)).toEqual([ + { status: 'processed', class: 'human_reply', correlation: 'strong:message_id' }, + { status: 'review', class: 'human_reply', correlation: 'weak:recipient' }, + ]); + expect(first).toBeTruthy(); + }); + + it('a reply arriving while the follow-up is claimed cancels it; the worker then loses the action', async () => { + const { env, first } = await started(); + env.advance(3 * DAY); + const actor = workerActor(env.engine.exec.workerId, 't'); + const [claimed] = claimActions(env.engine.exec, actor, 1); + expect(claimed).toBeTruthy(); + env.fake.reply(first, { at: env.now() }); + await env.engine.runOnce(); + expect(preflight(env.engine.exec, claimed ?? '', actor)).toEqual({ kind: 'lost' }); + expect(env.fake.deliveries).toHaveLength(1); + }); +}); + +describe('other inbound classes', () => { + it('out-of-office replies do not stop the sequence', async () => { + const { env, campaignId, first } = await started(); + env.fake.reply(first, { subject: 'Automatic reply: Quick question', headers: { 'Auto-Submitted': 'auto-replied' }, at: env.now() }); + env.advance(3 * DAY); + await env.drain(); + expect(statuses(env, campaignId)).toEqual({ active: 1 }); + expect(env.fake.deliveries).toHaveLength(2); + }); + + it('an opt-out reply suppresses globally and stops every live enrollment', async () => { + const { env, campaignId, first } = await started({ notify: true }); + env.fake.reply(first, { snippet: 'Please remove me from your list', at: env.now() }); + await env.drain(); + expect(statuses(env, campaignId)).toEqual({ opted_out: 1 }); + expect(env.engine.db.prepare(`SELECT scope, reason FROM suppressions WHERE value_norm = 'ada@example.org'`).get()).toEqual({ scope: 'global', reason: 'opt_out' }); + expect(env.notifier.published.map((n) => n.title)).toEqual(['Opt-out: contact suppressed']); + }); + + it('hard bounces stop at once; soft bounces only after three', async () => { + const { env, campaignId, first } = await started({ contacts: ['ada@example.org', 'grace@example.net'] }); + const grace = env.fake.deliveries.find((d) => d.to[0] === 'grace@example.net') as FakeDelivery; + const ada = env.fake.deliveries.find((d) => d.to[0] === 'ada@example.org') as FakeDelivery; + env.fake.bounce(ada, '5.1.1', env.now()); + env.fake.bounce(grace, '4.2.2', env.now()); + await env.drain(); + expect(statuses(env, campaignId)).toEqual({ bounced: 1, active: 1 }); + env.fake.bounce(grace, '4.2.2', env.now()); + env.fake.bounce(grace, '4.2.2', env.now()); + await env.drain(); + expect(statuses(env, campaignId)).toEqual({ bounced: 2 }); + expect(first).toBeTruthy(); + }); + + it('a complaint suppresses the recipient and engages the account kill switch', async () => { + const { env, first } = await started({ contacts: ['ada@example.org', 'grace@example.net'] }); + env.fake.complaint(first, env.now()); + env.advance(3 * DAY); + await env.drain(); + expect(env.fake.deliveries).toHaveLength(2); + const pending = env.engine.db.prepare(`SELECT state_reason FROM scheduled_actions WHERE state = 'scheduled'`).all(); + expect(pending).toEqual([{ state_reason: 'kill_switch:provider_account' }]); + }); + + it('uncorrelated mail goes to review; delivery receipts are ignored', async () => { + const { env } = await started(); + env.fake.pushInbound({ kind: 'message', references: [], from: 'stranger@example.com', to: ['sender@example.com'], headers: {}, at: env.now() }); + env.fake.pushInbound({ kind: 'delivery', references: [], from: 'mailer@example.net', to: [], headers: {}, at: env.now() }); + const report = processInboundEvents(env.engine); + expect(report.processed).toBe(0); + await env.engine.runOnce(); + expect(events(env).map((e) => (e as { status: string }).status)).toEqual(['review', 'ignored']); + }); +}); + +describe('webhook ingress and unsubscribe', () => { + it('rejects forged, stale and oversized webhooks and stores nothing', async () => { + const { env, first } = await started(); + const reply = env.fake.reply(first, { at: env.now() }); + const good = env.fake.signWebhook([reply], env.now()); + const forged = { rawBody: good.rawBody, headers: { ...good.headers, 'x-fake-signature': '00'.repeat(32) } }; + const stale = env.fake.signWebhook([reply], env.now() - 10 * 60_000); + expect(await ingestWebhook(env.engine, { providerAccountId: env.accountId, ...forged })).toEqual({ accepted: false, reason: 'verification_failed' }); + expect(await ingestWebhook(env.engine, { providerAccountId: env.accountId, ...stale })).toEqual({ accepted: false, reason: 'verification_failed' }); + expect(await ingestWebhook(env.engine, { providerAccountId: env.accountId, rawBody: new Uint8Array(1_000_001), headers: {} })).toEqual({ accepted: false, reason: 'too_large' }); + expect(env.engine.db.prepare('SELECT COUNT(*) AS n FROM provider_events').get<{ n: number }>()?.n).toBe(0); + expect(FAKE_WEBHOOK_SECRET).toBeTruthy(); + }); + + it('the List-Unsubscribe link suppresses immediately, stops the sequence and is idempotent', async () => { + const { env, campaignId, first } = await started(); + const url = /^<(.+)>$/.exec(first.headers['List-Unsubscribe'] ?? '')?.[1] ?? ''; + const token = url.split('/u/')[1] ?? ''; + expect(handleUnsubscribe(env.engine, token)).toEqual({ ok: true, alreadySuppressed: false }); + expect(handleUnsubscribe(env.engine, token)).toEqual({ ok: true, alreadySuppressed: true }); + expect(handleUnsubscribe(env.engine, `${token.split('.')[0]}.${'0'.repeat(64)}`)).toEqual({ ok: false, reason: 'invalid_token' }); + env.advance(3 * DAY); + await env.drain(); + expect(statuses(env, campaignId)).toEqual({ opted_out: 1 }); + expect(env.fake.deliveries).toHaveLength(1); + }); +}); diff --git a/outreach-engine/packages/outreach-core/src/inbound/index.ts b/outreach-engine/packages/outreach-core/src/inbound/index.ts new file mode 100644 index 0000000..6b2e061 --- /dev/null +++ b/outreach-engine/packages/outreach-core/src/inbound/index.ts @@ -0,0 +1,5 @@ +export { classifyInbound, type InboundClass } from './classify'; +export { correlateInbound, type Correlation } from './correlate'; +export { storeInboundEvents, ingestWebhook, pollMailbox, pollDueMailboxes, type WebhookResult } from './ingest'; +export { processInboundEvents, type ProcessReport } from './process'; +export { handleUnsubscribe, type UnsubscribeResult } from './unsubscribe'; diff --git a/outreach-engine/packages/outreach-core/src/inbound/ingest.ts b/outreach-engine/packages/outreach-core/src/inbound/ingest.ts new file mode 100644 index 0000000..ffeb547 --- /dev/null +++ b/outreach-engine/packages/outreach-core/src/inbound/ingest.ts @@ -0,0 +1,88 @@ +import { appendAudit, sha256Hex, ulid, type InboundMailEvent, type SqlDatabase } from '@splitin/outreach-contracts'; +import { loadAccount } from '../execution/actions-repo'; +import { providerContext } from '../execution/invoke'; +import type { DomainEnv } from '../domain/env'; + +const MAX_WEBHOOK_BYTES = 1_000_000; +const SNIPPET_MAX = 500; + +/** Stores events once each; webhook retries and poll overlap collapse on (account, provider event id). */ +export function storeInboundEvents(db: SqlDatabase, workspaceId: string, accountId: string, events: readonly InboundMailEvent[], now: number): { stored: number; duplicates: number } { + let stored = 0; + const insert = db.prepare( + `INSERT INTO provider_events (id, workspace_id, provider_account_id, provider_event_id, kind, payload, payload_digest, status, received_at) + VALUES (?,?,?,?,?,?,?,'pending',?) ON CONFLICT (provider_account_id, provider_event_id) DO NOTHING`, + ); + for (const event of events) { + // Minimize what we keep: identifiers, headers and a bounded snippet; never full bodies. + const minimized: InboundMailEvent = { ...event, ...(event.snippet ? { snippet: event.snippet.slice(0, SNIPPET_MAX) } : {}) }; + const payload = JSON.stringify(minimized); + stored += insert.run(ulid(now), workspaceId, accountId, event.eventId, event.kind, payload, sha256Hex(payload), event.receivedAt || now).changes; + } + return { stored, duplicates: events.length - stored }; +} + +export type WebhookResult = { accepted: true; stored: number; duplicates: number } | { accepted: false; reason: string }; + +/** + * Webhook ingress (BUILD_PLAN.md §8.1): size cap, then signature verification over the RAW body before + * anything is parsed, then durable storage. Processing happens later, so the endpoint answers fast. + */ +export async function ingestWebhook( + env: DomainEnv, + input: { providerAccountId: string; rawBody: Uint8Array; headers: Readonly> }, +): Promise { + if (input.rawBody.byteLength > MAX_WEBHOOK_BYTES) return { accepted: false, reason: 'too_large' }; + const account = loadAccount(env.db, input.providerAccountId); + const adapter = account ? env.adapters.get(account.provider) : undefined; + if (!account || !adapter?.webhook || !account.webhook_secret_ref) return { accepted: false, reason: 'not_configured' }; + const secret = await env.exec.secrets.get(account.webhook_secret_ref); + const headers = Object.fromEntries(Object.entries(input.headers).map(([key, value]) => [key.toLowerCase(), value])); + const verified = await adapter.webhook.verify(input.rawBody, headers, secret, env.now()); + if (verified === 'reject') { + env.db.transaction(() => + appendAudit(env.db, { workspaceId: account.workspace_id, at: env.now(), actorKind: 'provider', actorId: account.provider, source: 'webhook', traceId: ulid(), resourceKind: 'provider_account', resourceId: account.id, action: 'webhook_rejected', detail: {} }), + ); + return { accepted: false, reason: 'verification_failed' }; + } + return env.db.transaction(() => ({ accepted: true as const, ...storeInboundEvents(env.db, account.workspace_id, account.id, verified, env.now()) })); +} + +/** Polling fallback: fetch changes since the durable cursor. Runs alongside webhooks to fill gaps. */ +export async function pollMailbox(env: DomainEnv, providerAccountId: string): Promise<{ stored: number; duplicates: number }> { + const account = loadAccount(env.db, providerAccountId); + const adapter = account ? env.adapters.get(account.provider) : undefined; + if (!account || !adapter?.mailbox) return { stored: 0, duplicates: 0 }; + const cursor = env.db.prepare('SELECT cursor FROM provider_cursors WHERE provider_account_id = ?').get<{ cursor: string | null }>(account.id); + const result = await adapter.mailbox.readChanges(providerContext(env.exec, account, ulid(), AbortSignal.timeout(30_000)), cursor?.cursor ?? null); + return env.db.transaction(() => { + const now = env.now(); + const counts = storeInboundEvents(env.db, account.workspace_id, account.id, result.events, now); + env.db.prepare( + `INSERT INTO provider_cursors (provider_account_id, cursor, updated_at) VALUES (?,?,?) + ON CONFLICT (provider_account_id) DO UPDATE SET cursor = excluded.cursor, updated_at = excluded.updated_at`, + ).run(account.id, result.nextCursor, now); + return counts; + }); +} + +/** Polls every account whose adapter can read a mailbox and whose last poll is older than `intervalMs`. */ +export async function pollDueMailboxes(env: DomainEnv, intervalMs: number): Promise { + const now = env.now(); + const accounts = env.db + .prepare( + `SELECT a.id, a.provider FROM provider_accounts a LEFT JOIN provider_cursors c ON c.provider_account_id = a.id + WHERE a.health IN ('ok','degraded') AND (c.updated_at IS NULL OR c.updated_at <= ?)`, + ) + .all<{ id: string; provider: string }>(now - intervalMs); + let stored = 0; + for (const account of accounts) { + if (!env.adapters.get(account.provider)?.mailbox) continue; + try { + stored += (await pollMailbox(env, account.id)).stored; + } catch { + // A failing mailbox must not stop sends or other accounts; the next pass retries. + } + } + return stored; +} diff --git a/outreach-engine/packages/outreach-core/src/inbound/process.ts b/outreach-engine/packages/outreach-core/src/inbound/process.ts new file mode 100644 index 0000000..b4b461a --- /dev/null +++ b/outreach-engine/packages/outreach-core/src/inbound/process.ts @@ -0,0 +1,146 @@ +import { appendAudit, ulid, type InboundMailEvent, type SqlDatabase } from '@splitin/outreach-contracts'; +import type { Actor } from '../execution/actions-repo'; +import { enqueueAction } from '../execution/enqueue'; +import { setKillSwitch } from '../execution/kill-switches'; +import type { DomainEnv } from '../domain/env'; +import { addSuppression, liveEnrollmentsForContact, stopEnrollment } from '../domain/suppressions'; +import { classifyInbound, type InboundClass } from './classify'; +import { correlateInbound, type Correlation } from './correlate'; + +interface EventRow { + id: string; + workspace_id: string; + provider_account_id: string; + provider_event_id: string; + payload: string; +} + +export interface ProcessReport { + readonly processed: number; + readonly stopped: number; + readonly toReview: number; + readonly ignored: number; +} + +const SOFT_BOUNCES_BEFORE_STOP = 3; + +function inboundActor(eventId: string): Actor { + return { kind: 'provider', id: 'inbound', source: 'inbound', traceId: eventId }; +} + +function enrollmentRecipient(db: SqlDatabase, enrollmentId: string): { contactId: string; email: string } | undefined { + return db + .prepare('SELECT e.contact_id AS contactId, cp.value_norm AS email FROM enrollments e JOIN contact_points cp ON cp.id = e.contact_point_id WHERE e.id = ?') + .get<{ contactId: string; email: string }>(enrollmentId); +} + +function recordInboundMessage(db: SqlDatabase, row: EventRow, event: InboundMailEvent, enrollmentId: string | null, now: number): void { + if (event.kind !== 'message') return; + db.prepare( + `INSERT INTO messages (id, workspace_id, direction, provider_account_id, provider_message_id, provider_thread_id, rfc_message_id, + in_reply_to, references_ids, from_addr, to_addrs, recipient_norm, subject, at, enrollment_id) + VALUES (?,?,'inbound',?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT (provider_account_id, provider_message_id) DO NOTHING`, + ).run(ulid(now), row.workspace_id, row.provider_account_id, event.providerMessageId, event.providerThreadId ?? null, event.rfcMessageId ?? null, + event.inReplyTo ?? null, JSON.stringify(event.references), event.from, JSON.stringify(event.to), event.from.toLowerCase(), event.subject ?? null, event.receivedAt, enrollmentId); +} + +function notify(db: SqlDatabase, row: EventRow, cls: InboundClass, correlation: Correlation | null, event: InboundMailEvent, now: number): void { + const settings = JSON.parse(db.prepare('SELECT settings FROM workspaces WHERE id = ?').get<{ settings: string }>(row.workspace_id)?.settings ?? '{}') as { notifyAccountId?: string }; + if (!settings.notifyAccountId || ['auto_reply', 'delivery', 'soft_bounce'].includes(cls)) return; + const titles: Partial> = { + human_reply: 'Reply received: sequence stopped', + opt_out: 'Opt-out: contact suppressed', + hard_bounce: 'Hard bounce: address suppressed', + complaint: 'Complaint: sending account paused', + unknown: 'Inbound message needs review', + }; + const campaign = correlation + ? db.prepare('SELECT c.name FROM enrollments e JOIN campaigns c ON c.id = e.campaign_id WHERE e.id = ?').get<{ name: string }>(correlation.enrollmentId)?.name + : undefined; + enqueueAction(db, { + workspaceId: row.workspace_id, + kind: 'notify.publish', + providerAccountId: settings.notifyAccountId, + idempotencyKey: `inbound:${row.id}`, + dueAt: now, + payload: { + title: titles[cls] ?? 'Inbound event needs review', + // Identifiers and routing only: no message bodies in chat channels. + lines: [`From: ${event.from}`, ...(campaign ? [`Campaign: ${campaign}`] : []), ...(correlation?.strength === 'weak' ? ['Matched by address only; please confirm.'] : [])], + severity: cls === 'complaint' ? 'error' : cls === 'human_reply' ? 'info' : 'warning', + }, + }, inboundActor(row.id), now); +} + +/** Applies one classified event in the caller's transaction. Returns the resulting event status. */ +function apply(db: SqlDatabase, row: EventRow, event: InboundMailEvent, cls: InboundClass, correlation: Correlation | null, now: number): { status: 'processed' | 'review' | 'ignored'; stopped: boolean } { + const actor = inboundActor(row.id); + const recipient = correlation ? enrollmentRecipient(db, correlation.enrollmentId) : undefined; + if (cls === 'delivery') return { status: 'ignored', stopped: false }; + if (cls === 'unknown') return { status: 'review', stopped: false }; + + if (cls === 'complaint' || cls === 'opt_out') { + const address = (recipient?.email ?? event.from).toLowerCase(); + addSuppression(db, { workspaceId: row.workspace_id, scope: 'global', value: address, reason: cls === 'complaint' ? 'complaint' : 'opt_out', source: `event:${row.id}` }, now); + const contactId = recipient?.contactId ?? db.prepare(`SELECT contact_id FROM contact_points WHERE workspace_id = ? AND kind = 'email' AND value_norm = ?`).get<{ contact_id: string }>(row.workspace_id, address)?.contact_id; + let stopped = false; + for (const enrollment of contactId ? liveEnrollmentsForContact(db, row.workspace_id, contactId) : []) { + stopped = stopEnrollment(db, enrollment.id, 'opted_out', cls, actor, now) || stopped; + } + if (cls === 'complaint') { + setKillSwitch(db, { workspaceId: row.workspace_id, scope: 'provider_account', targetId: row.provider_account_id, engaged: true, reason: 'complaint_received' }, actor, now); + } + return { status: correlation?.strength === 'weak' ? 'review' : 'processed', stopped }; + } + + if (!correlation) return { status: cls === 'auto_reply' ? 'ignored' : 'review', stopped: false }; + if (cls === 'auto_reply') return { status: 'processed', stopped: false }; + + if (cls === 'hard_bounce' || cls === 'soft_bounce') { + const softCount = cls === 'soft_bounce' + ? (db.prepare(`SELECT COUNT(*) AS n FROM provider_events WHERE enrollment_id = ? AND class = 'soft_bounce' AND id <> ?`).get<{ n: number }>(correlation.enrollmentId, row.id)?.n ?? 0) + 1 + : 0; + if (cls === 'soft_bounce' && softCount < SOFT_BOUNCES_BEFORE_STOP) return { status: 'processed', stopped: false }; + if (recipient) addSuppression(db, { workspaceId: row.workspace_id, scope: 'channel', channel: 'email', value: recipient.email, reason: 'hard_bounce', source: `event:${row.id}` }, now); + return { status: 'processed', stopped: stopEnrollment(db, correlation.enrollmentId, 'bounced', cls, actor, now) }; + } + + // A human reply. A weak (address-only) match still stops — the safe direction — but a person confirms. + const stopped = stopEnrollment(db, correlation.enrollmentId, 'replied', `reply:${correlation.via}`, actor, now); + return { status: correlation.strength === 'weak' ? 'review' : 'processed', stopped }; +} + +/** + * Processes stored inbound events, oldest first, each in its own transaction: classify, correlate, + * record the inbound message, stop or suppress atomically, notify, and audit. + */ +export function processInboundEvents(env: DomainEnv, limit = 200): ProcessReport { + const rows = env.db + .prepare(`SELECT id, workspace_id, provider_account_id, provider_event_id, payload FROM provider_events WHERE status = 'pending' ORDER BY received_at, id LIMIT ?`) + .all(limit); + let stopped = 0; + let toReview = 0; + let ignored = 0; + for (const row of rows) { + env.db.transaction(() => { + const now = env.now(); + const event = JSON.parse(row.payload) as InboundMailEvent; + const cls = classifyInbound(event); + const correlation = correlateInbound(env.db, row.workspace_id, row.provider_account_id, event); + recordInboundMessage(env.db, row, event, correlation?.enrollmentId ?? null, now); + const result = apply(env.db, row, event, cls, correlation, now); + env.db.prepare('UPDATE provider_events SET status = ?, class = ?, correlation = ?, enrollment_id = ?, processed_at = ? WHERE id = ?') + .run(result.status, cls, correlation ? `${correlation.strength}:${correlation.via}` : 'none', correlation?.enrollmentId ?? null, now, row.id); + if (result.status !== 'ignored') notify(env.db, row, cls, correlation, event, now); + appendAudit(env.db, { + workspaceId: row.workspace_id, at: now, actorKind: 'provider', actorId: 'inbound', source: 'inbound', traceId: row.id, + resourceKind: 'provider_event', resourceId: row.id, action: `classified:${cls}`, + detail: { status: result.status, correlation: correlation ? `${correlation.strength}:${correlation.via}` : 'none', stopped: result.stopped }, + }); + if (result.stopped) stopped += 1; + if (result.status === 'review') toReview += 1; + if (result.status === 'ignored') ignored += 1; + }); + } + return { processed: rows.length, stopped, toReview, ignored }; +} diff --git a/outreach-engine/packages/outreach-core/src/inbound/unsubscribe.ts b/outreach-engine/packages/outreach-core/src/inbound/unsubscribe.ts new file mode 100644 index 0000000..da9c44f --- /dev/null +++ b/outreach-engine/packages/outreach-core/src/inbound/unsubscribe.ts @@ -0,0 +1,36 @@ +import { appendAudit } from '@splitin/outreach-contracts'; +import type { DomainEnv } from '../domain/env'; +import { addSuppression, liveEnrollmentsForContact, stopEnrollment, verifyUnsubscribeToken } from '../domain/suppressions'; + +export type UnsubscribeResult = { ok: true; alreadySuppressed: boolean } | { ok: false; reason: 'not_configured' | 'invalid_token' | 'unknown_recipient' }; + +/** + * One-click unsubscribe (RFC 8058) behind `POST /u/:token`. The token is verified by HMAC alone, so no + * lookup can be probed with forged tokens. Suppression is global and immediate; every live enrollment of + * the contact stops. Idempotent: repeated clicks are harmless. + */ +export function handleUnsubscribe(env: DomainEnv, token: string): UnsubscribeResult { + if (!env.unsubscribe) return { ok: false, reason: 'not_configured' }; + const claims = verifyUnsubscribeToken(env.unsubscribe.secret, token); + if (!claims) return { ok: false, reason: 'invalid_token' }; + return env.db.transaction((): UnsubscribeResult => { + const now = env.now(); + const point = env.db + .prepare('SELECT contact_id, value_norm FROM contact_points WHERE workspace_id = ? AND id = ?') + .get<{ contact_id: string; value_norm: string }>(claims.w, claims.cp); + if (!point) return { ok: false, reason: 'unknown_recipient' }; + const existing = env.db + .prepare(`SELECT 1 FROM suppressions WHERE workspace_id = ? AND scope = 'global' AND value_norm = ?`) + .get(claims.w, point.value_norm); + addSuppression(env.db, { workspaceId: claims.w, scope: 'global', value: point.value_norm, reason: 'opt_out', source: `unsubscribe:${claims.c}` }, now); + const actor = { kind: 'principal' as const, id: `recipient:${claims.cp}`, source: 'unsubscribe', traceId: claims.c }; + for (const enrollment of liveEnrollmentsForContact(env.db, claims.w, point.contact_id)) { + stopEnrollment(env.db, enrollment.id, 'opted_out', 'unsubscribe_link', actor, now); + } + appendAudit(env.db, { + workspaceId: claims.w, at: now, actorKind: 'principal', actorId: actor.id, source: 'unsubscribe', traceId: claims.c, + resourceKind: 'contact_point', resourceId: claims.cp, action: 'unsubscribed', detail: { campaignId: claims.c, repeat: !!existing }, + }); + return { ok: true, alreadySuppressed: !!existing }; + }); +} diff --git a/outreach-engine/packages/outreach-core/src/index.ts b/outreach-engine/packages/outreach-core/src/index.ts index c3d3623..244aa7a 100644 --- a/outreach-engine/packages/outreach-core/src/index.ts +++ b/outreach-engine/packages/outreach-core/src/index.ts @@ -1,3 +1,4 @@ export * from './execution/index'; export * from './domain/index'; export * from './engine'; +export * from './inbound/index'; diff --git a/outreach-engine/packages/outreach-e2e/package.json b/outreach-engine/packages/outreach-e2e/package.json new file mode 100644 index 0000000..d3dd6da --- /dev/null +++ b/outreach-engine/packages/outreach-e2e/package.json @@ -0,0 +1,18 @@ +{ + "name": "@splitin/outreach-e2e", + "version": "0.0.0", + "private": true, + "description": "End-to-end suite: import to campaign to replies, opt-outs, bounces, notifications and audit, on fake providers.", + "license": "MIT", + "type": "module", + "scripts": { + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "devDependencies": { + "@splitin/outreach-contracts": "0.0.0", + "@splitin/outreach-core": "0.0.0", + "@splitin/outreach-fakes": "0.0.0", + "@splitin/outreach-import": "0.0.0", + "@splitin/outreach-store-sqlite": "0.0.0" + } +} diff --git a/outreach-engine/packages/outreach-e2e/src/outreach.e2e.test.ts b/outreach-engine/packages/outreach-e2e/src/outreach.e2e.test.ts new file mode 100644 index 0000000..fd7759a --- /dev/null +++ b/outreach-engine/packages/outreach-e2e/src/outreach.e2e.test.ts @@ -0,0 +1,165 @@ +/** + * BUILD_PLAN.md §13 "End to end": import -> compile -> activate -> approve first batch -> sends -> + * inbound reply -> atomic stop -> notification -> audit verify, entirely on fake providers. + */ +import { describe, expect, it } from 'vitest'; +import { verifyAuditChain } from '@splitin/outreach-contracts'; +import { + authenticate, + bootstrapWorkspace, + campaignStatus, + commitActivation, + configureNotifications, + createCampaign, + createEngine, + createTemplate, + decideApproval, + handleUnsubscribe, + ingestWebhook, + listApprovals, + listManualTasks, + prepareActivation, + recordManualOutcome, + registerProviderAccount, + addPrincipal, +} from '@splitin/outreach-core'; +import { FAKE_EMAIL_SECRET, FakeEmailProvider, FakeNotifier, staticSecrets } from '@splitin/outreach-fakes'; +import { commitImport, previewImport, saveMappingProfile } from '@splitin/outreach-import'; +import { openSqliteDatabase } from '@splitin/outreach-store-sqlite'; + +const DAY = 86_400_000; +const CSV = `Full name,Work email,Company,Role,Time zone,Segment +Ada Lovelace,ada@analytical.example.org,Analytical Engines,CTO,America/New_York,enterprise +Grace Hopper,grace@navy.example.net,US Navy,Admiral,America/New_York,public +Linus T,linus@kernel.example.com,Kernel Co,Maintainer,Europe/Helsinki,oss +Margaret H,margaret@apollo.example.org,Apollo,Lead,America/Chicago,enterprise +Duplicate Ada,ADA@analytical.example.org,,,, +Broken Row,not-an-email,,,, +`; + +const PLAYBOOK = ` +apiVersion: outreach.splitin.net/v1alpha1 +kind: Playbook +metadata: { name: e2e-intro } +spec: + purpose: automated_outreach + audience: { source: { importBatch: BATCH } } + policy: + approval: first_batch_then_campaign + firstBatchSize: 2 + window: { timezone: recipient, fallback: America/New_York, days: [Mon, Tue, Wed, Thu, Fri], start: "08:00", end: "18:00" } + limits: { accountPerDay: 100, domainPerDay: 10, recipientMinGap: P1D } + steps: + - { id: intro, type: email.send, template: intro@1 } + - { id: wait, type: wait, duration: P2D } + - { id: social, type: manual.task, channel: linkedin, template: note@1, when: no_reply } + - { id: bump, type: email.reply, template: bump@1, when: no_reply } +`; + +describe('outreach end to end', () => { + it('runs the full loop with every stop path and a verifiable audit trail', async () => { + // Monday 2025-06-02 09:00 in New York (16:00 in Helsinki): inside every recipient's window. + let now = Date.parse('2025-06-02T13:00:00Z'); + const db = openSqliteDatabase(':memory:'); + const email = new FakeEmailProvider(); + const slack = new FakeNotifier('fake-slack'); + const engine = createEngine({ + db, + adapters: [email.adapter(), slack.adapter()], + secrets: staticSecrets({ 'env:MAIL': FAKE_EMAIL_SECRET, 'env:MAIL_HOOK': 'fake-webhook-secret-value', 'env:SLACK': FAKE_EMAIL_SECRET }), + workerId: 'e2e-worker', + sendGate: { mode: 'allowlist', allow: ['@analytical.example.org', '@navy.example.net', '@kernel.example.com', '@apollo.example.org'] }, + unsubscribe: { baseUrl: 'https://outreach.example.com/u/', secret: 'e2e-unsubscribe-fixture' }, + now: () => now, + pollIntervalMs: 0, + execution: { reconcileDelayMs: 0 }, + }); + const drain = async () => { + for (let i = 0; i < 6; i += 1) await engine.runOnce(); + }; + + // Workspace, people, accounts. + bootstrapWorkspace(db, { workspaceId: 'splitin-demo', name: 'Demo', adminRef: 'cli:admin', adminName: 'Admin' }, now); + const admin = authenticate(db, 'splitin-demo', 'cli:admin', 'cli', 'e2e'); + addPrincipal(engine, admin, { externalRef: 'slack:T1:U-op', displayName: 'Operator', roles: ['operator'] }); + addPrincipal(engine, admin, { externalRef: 'slack:T1:U-ap', displayName: 'Approver', roles: ['approver'] }); + const operator = authenticate(db, 'splitin-demo', 'slack:T1:U-op', 'slack', 'e2e'); + const approver = authenticate(db, 'splitin-demo', 'slack:T1:U-ap', 'slack', 'e2e'); + const mailAccount = await registerProviderAccount(engine, admin, { + provider: 'fake-email', externalAccountId: 'hello@example.com', purposes: ['automated_outreach'], secretRef: 'env:MAIL', webhookSecretRef: 'env:MAIL_HOOK', + sender: { name: 'Sam', address: 'hello@example.com', organization: 'Example Co', postalAddress: '1 Example Street, Springfield' }, + }); + const slackAccount = await registerProviderAccount(engine, admin, { provider: 'fake-slack', externalAccountId: '#gtm', purposes: ['transactional'], secretRef: 'env:SLACK', sender: { name: 'Outreach', address: 'bot@example.com' } }); + configureNotifications(engine, admin, slackAccount); + + // Import: preview, then commit exactly the preview. + const actor = { workspaceId: 'splitin-demo', principalId: operator.principalId, source: 'slack', traceId: 'e2e' }; + const profile = db.transaction(() => saveMappingProfile(db, 'splitin-demo', 'crm-export', { + columns: { full_name: 'Full name', email: 'Work email', org_name: 'Company', title: 'Role', timezone: 'Time zone' }, + attributes: { segment: 'Segment' }, + consent: { basis: 'legitimate_interest', evidence: 'business contact, B2B introduction' }, + jurisdiction: 'US', + }, now)); + const preview = await previewImport(db, actor, { fileName: 'crm.csv', bytes: new TextEncoder().encode(CSV), profileId: profile.id, now }); + expect(preview.counts).toEqual({ create: 4, update: 0, merge: 1, reject: 1, ambiguous: 0 }); + commitImport(db, actor, { batchId: preview.batchId, previewHash: preview.previewHash, idempotencyKey: 'crm-2025-06-02', now }); + + // Templates, campaign, activation with two-level approval. + createTemplate(db, operator, { name: 'intro', channel: 'email', subject: '{{org_name}} and Example Co', text: 'Hi {{first_name}}, a quick idea for {{org_name}} ({{attr.segment}}).' }, now); + createTemplate(db, operator, { name: 'note', channel: 'linkedin', text: 'Hi {{first_name}}, I emailed you about {{org_name}}.' }, now); + createTemplate(db, operator, { name: 'bump', channel: 'email', text: 'Bumping this, {{first_name}}.' }, now); + const { campaignId } = createCampaign(engine, operator, { name: 'June intro', playbook: PLAYBOOK.replace('BATCH', preview.batchId), providerAccountId: mailAccount }); + const activation = prepareActivation(engine, operator, campaignId); + expect(activation.audienceCount).toBe(4); + decideApproval(engine, approver, { approvalId: activation.approvalId ?? '', decision: 'approved', operationHash: activation.operationHash }); + const { enrolled, batchApprovalId } = commitActivation(engine, operator, { campaignId, operationHash: activation.operationHash }); + expect(enrolled).toBe(4); + + // Only the first two wait for the batch approval; the rest are covered by the campaign approval. + await drain(); + expect(email.deliveries).toHaveLength(2); + const batch = listApprovals(db, approver).find((row) => row.id === batchApprovalId); + expect(JSON.parse(batch?.preview ?? '{}').actions).toHaveLength(2); + decideApproval(engine, approver, { approvalId: batchApprovalId ?? '', decision: 'approved', operationHash: batch?.operation_hash ?? '' }); + now += 60_000; + await drain(); + expect(email.deliveries).toHaveLength(4); + expect(email.deliveries.every((d) => /^$/.test(d.headers['List-Unsubscribe'] ?? ''))).toBe(true); + + const to = (address: string) => { + const delivery = email.deliveries.find((d) => d.to[0] === address); + if (!delivery) throw new Error(`no delivery to ${address}`); + return delivery; + }; + // Ada replies by webhook; Grace clicks unsubscribe; Linus hard-bounces; Margaret stays silent. + const reply = email.reply(to('ada@analytical.example.org'), { at: now }); + expect(await ingestWebhook(engine, { providerAccountId: mailAccount, ...email.signWebhook([reply], now) })).toMatchObject({ accepted: true, stored: 1 }); + const graceToken = /\/u\/([^>]+)>$/.exec(to('grace@navy.example.net').headers['List-Unsubscribe'] ?? '')?.[1] ?? ''; + expect(handleUnsubscribe(engine, graceToken)).toEqual({ ok: true, alreadySuppressed: false }); + email.bounce(to('linus@kernel.example.com'), '5.1.1', now); + await drain(); + + // Two business days later only Margaret gets the social task; completing it releases the bump. + now += 2 * DAY; + await drain(); + const tasks = listManualTasks(engine, operator); + expect(tasks.map((t) => t.draft_text)).toEqual(['Hi Margaret, I emailed you about Apollo.']); + recordManualOutcome(engine, operator, tasks[0]?.id ?? '', 'done'); + await drain(); + const bumps = email.deliveries.filter((d) => d.subject.startsWith('Re:')); + expect(bumps.map((d) => d.to[0])).toEqual(['margaret@apollo.example.org']); + expect(bumps[0]?.providerThreadId).toBe(to('margaret@apollo.example.org').providerThreadId); + + const status = campaignStatus(engine, operator, campaignId); + expect(status.enrollments).toEqual({ replied: 1, opted_out: 1, bounced: 1, completed: 1 }); + expect(slack.published.map((n) => n.title).sort()).toEqual(['Hard bounce: address suppressed', 'Reply received: sequence stopped']); + const suppressed = db.prepare('SELECT value_norm, reason FROM suppressions ORDER BY value_norm').all(); + expect(suppressed).toEqual([ + { value_norm: 'grace@navy.example.net', reason: 'opt_out' }, + { value_norm: 'linus@kernel.example.com', reason: 'hard_bounce' }, + ]); + // Every send happened exactly once, and the whole history verifies. + expect(new Set(email.deliveries.map((d) => d.rfcMessageId)).size).toBe(email.deliveries.length); + expect(verifyAuditChain(db)).toMatchObject({ ok: true }); + }); +}); diff --git a/outreach-engine/packages/outreach-e2e/tsconfig.json b/outreach-engine/packages/outreach-e2e/tsconfig.json new file mode 100644 index 0000000..585a92d --- /dev/null +++ b/outreach-engine/packages/outreach-e2e/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist" }, + "include": ["src"] +} From e1060115c4fc690d807278c4f0c9c379a9131b93 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 02:02:03 +0000 Subject: [PATCH 09/20] outreach-engine M7 (1/3): HTTP API, bearer tokens, audited send gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First part of milestone M7 of outreach-engine/BUILD_PLAN.md (§11.2, §12): the HTTP surface. It includes the M7.0 Papr spike finding that shapes the rest of M7 (full write-up lands with the Papr app in part 3). @splitin/outreach-store-sqlite - Migration 0002_api_tokens: bearer tokens bound to a principal and workspace, storing only a SHA-256 of the secret, a role ceiling, expiry, revocation and last use. @splitin/outreach-core - domain/access.ts: * createApiToken() (admin only, 1 to 366 days): token format oet_.<43-char secret>, shown once. * authenticateToken(): every failure raises the same ForbiddenError, so a caller cannot tell a bad token from an expired one; constant-time hash comparison. The effective role is the lower of the principal's role and the token's ceiling, so dashboards can get read-only tokens. last_used_at is recorded. revokeApiToken(). * readSendGate()/setSendGate(): the live-send gate is now workspace state, changed only by an admin through an audited action. Opening it fully requires a written reason. The default is still an empty allowlist. - ExecutionDeps.sendGate may be a resolver; the engine defaults to readSendGate, evaluated per action inside preflight, so a gate change takes effect on the next send without restarting workers. - listCampaigns(). campaignStatus() and resolveReview() now raise NotFoundError instead of a plain Error (they surfaced as HTTP 500 before). @splitin/outreach-server (new; hono + @hono/node-server) - createApp(): thin routes. Each one authenticates, validates with strict zod schemas and calls one application service; business rules stay in core. * /v1/me; campaigns: list, create, status, activation prepare/commit, pause/resume, request a batch approval; approvals: list, decide (operation hash required), revoke; imports: raw-body preview, then commit with preview hash and idempotency key; tasks: list, record outcome; review: list (payloads omitted), resolve; enrollments pause/resume/stop; kill switches. * POST /v1/webhooks/:accountId authenticates by provider signature, not bearer token (401 forged, 404 unknown, 413 too large). * GET /u/:token only renders a confirmation form, because mail clients and scanners prefetch links; POST /u/:token performs the RFC 8058 one-click unsubscribe. Pages are noindex and escape everything. * Errors map to 400 (zod, bad JSON), 403, 404, 409 (conflict, stale import or preview, review state), 413, 422 (invalid playbook or profile, rejected import, template), and 500 without leaking messages or stacks. Every response has x-trace-id, no-store and nosniff. Body limits: 256 KB JSON, 25 MB imports. - startServer(): binds loopback by default and refuses any other address unless allowPublic is passed (TLS belongs in front of it). Tests (8 new, 154 total): identical rejection of missing, malformed, tampered, revoked and expired tokens; role ceiling enforced; trace and safety headers; a full HTTP flow (import preview/commit, create, prepare, stale-hash 409, commit, worker send, status, list); strict body validation, 422 with compile issues, 404, 413, unknown routes; signed webhook accepted, forged 401, unknown account 404; GET unsubscribe does not suppress, POST does, forged token 400; public bind refused, loopback server answers /healthz over real HTTP. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MbDW8kfMcxUuZ3WBtaDKBv --- outreach-engine/package-lock.json | 45 +++++ .../outreach-core/src/domain/access.ts | 103 ++++++++++ .../outreach-core/src/domain/index.ts | 1 + .../packages/outreach-core/src/engine.ts | 27 ++- .../outreach-core/src/execution/preflight.ts | 2 +- .../outreach-core/src/execution/types.ts | 3 +- .../packages/outreach-server/package.json | 52 +++++ .../packages/outreach-server/src/app.ts | 75 ++++++++ .../packages/outreach-server/src/errors.ts | 26 +++ .../packages/outreach-server/src/index.ts | 3 + .../outreach-server/src/routes-campaigns.ts | 82 ++++++++ .../outreach-server/src/routes-operations.ts | 54 ++++++ .../outreach-server/src/routes-public.ts | 46 +++++ .../packages/outreach-server/src/serve.ts | 23 +++ .../outreach-server/src/server.test.ts | 181 ++++++++++++++++++ .../packages/outreach-server/tsconfig.json | 5 + .../packages/outreach-server/tsup.config.ts | 12 ++ .../outreach-store-sqlite/src/index.ts | 1 + .../outreach-store-sqlite/src/migrate.ts | 6 +- .../outreach-store-sqlite/src/schema-0002.ts | 12 ++ 20 files changed, 751 insertions(+), 8 deletions(-) create mode 100644 outreach-engine/packages/outreach-core/src/domain/access.ts create mode 100644 outreach-engine/packages/outreach-server/package.json create mode 100644 outreach-engine/packages/outreach-server/src/app.ts create mode 100644 outreach-engine/packages/outreach-server/src/errors.ts create mode 100644 outreach-engine/packages/outreach-server/src/index.ts create mode 100644 outreach-engine/packages/outreach-server/src/routes-campaigns.ts create mode 100644 outreach-engine/packages/outreach-server/src/routes-operations.ts create mode 100644 outreach-engine/packages/outreach-server/src/routes-public.ts create mode 100644 outreach-engine/packages/outreach-server/src/serve.ts create mode 100644 outreach-engine/packages/outreach-server/src/server.test.ts create mode 100644 outreach-engine/packages/outreach-server/tsconfig.json create mode 100644 outreach-engine/packages/outreach-server/tsup.config.ts create mode 100644 outreach-engine/packages/outreach-store-sqlite/src/schema-0002.ts diff --git a/outreach-engine/package-lock.json b/outreach-engine/package-lock.json index 34c6afe..b2b1110 100644 --- a/outreach-engine/package-lock.json +++ b/outreach-engine/package-lock.json @@ -653,6 +653,18 @@ "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", "license": "MIT" }, + "node_modules/@hono/node-server": { + "version": "1.19.17", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz", + "integrity": "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -1145,6 +1157,10 @@ "resolved": "packages/outreach-import", "link": true }, + "node_modules/@splitin/outreach-server": { + "resolved": "packages/outreach-server", + "link": true + }, "node_modules/@splitin/outreach-store-sqlite": { "resolved": "packages/outreach-store-sqlite", "link": true @@ -2705,6 +2721,15 @@ "node": ">=8" } }, + "node_modules/hono": { + "version": "4.13.9", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.9.tgz", + "integrity": "sha512-7dMkQmZoC4E6F7AtaQSPhlWAdnBti+j7rreMZl8QB4jFiEhP9TWbGWUMi8WYzBCgmgulxuvLQupKqo+Co6Omyg==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -4598,6 +4623,26 @@ "node": ">=22.13" } }, + "packages/outreach-server": { + "name": "@splitin/outreach-server", + "version": "0.0.0", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.17", + "@splitin/outreach-contracts": "0.0.0", + "@splitin/outreach-core": "0.0.0", + "@splitin/outreach-import": "0.0.0", + "hono": "^4.13.9", + "zod": "^4.6.5" + }, + "devDependencies": { + "@splitin/outreach-fakes": "0.0.0", + "@splitin/outreach-store-sqlite": "0.0.0" + }, + "engines": { + "node": ">=22.13" + } + }, "packages/outreach-store-sqlite": { "name": "@splitin/outreach-store-sqlite", "version": "0.0.0", diff --git a/outreach-engine/packages/outreach-core/src/domain/access.ts b/outreach-engine/packages/outreach-core/src/domain/access.ts new file mode 100644 index 0000000..789bebd --- /dev/null +++ b/outreach-engine/packages/outreach-core/src/domain/access.ts @@ -0,0 +1,103 @@ +import { randomBytes } from 'node:crypto'; +import { safeEqualHex, sha256Hex, ulid, type SqlDatabase } from '@splitin/outreach-contracts'; +import type { SendGate } from '../execution/types'; +import { ForbiddenError, NotFoundError, ROLES, audit, requireRole, type AuthContext, type Role, type Surface } from './auth'; +import type { DomainEnv } from './env'; + +const TOKEN_RE = /^oet_([0-9A-HJKMNP-TV-Z]{26})\.([A-Za-z0-9_-]{43})$/; + +export interface CreatedToken { + readonly id: string; + /** Shown once. Only a SHA-256 of the secret part is stored. */ + readonly token: string; + readonly expiresAt: number; +} + +/** + * Issues a bearer token for a principal (BUILD_PLAN.md §11.2). The token can never carry more than the + * principal's own roles, and `roleCeiling` narrows it further (e.g. a read-only dashboard token). + */ +export function createApiToken( + env: DomainEnv, + ctx: AuthContext, + input: { principalId: string; name: string; roleCeiling: Role; ttlDays: number }, +): CreatedToken { + requireRole(ctx, 'admin'); + if (input.ttlDays < 1 || input.ttlDays > 366) throw new Error('ttlDays must be between 1 and 366'); + return env.db.transaction(() => { + const now = env.now(); + const principal = env.db.prepare('SELECT id FROM principals WHERE workspace_id = ? AND id = ?').get(ctx.workspaceId, input.principalId); + if (!principal) throw new NotFoundError(`principal ${input.principalId}`); + const id = ulid(now); + const secret = randomBytes(32).toString('base64url'); + const expiresAt = now + input.ttlDays * 86_400_000; + env.db.prepare( + `INSERT INTO api_tokens (id, workspace_id, principal_id, name, secret_sha256, role_ceiling, created_at, expires_at) + VALUES (?,?,?,?,?,?,?,?)`, + ).run(id, ctx.workspaceId, input.principalId, input.name, sha256Hex(secret), input.roleCeiling, now, expiresAt); + audit(env.db, ctx, now, 'api_token', id, 'created', { principalId: input.principalId, name: input.name, roleCeiling: input.roleCeiling, expiresAt }); + return { id, token: `oet_${id}.${secret}`, expiresAt }; + }); +} + +export function revokeApiToken(env: DomainEnv, ctx: AuthContext, tokenId: string): void { + requireRole(ctx, 'admin'); + env.db.transaction(() => { + const now = env.now(); + const changed = env.db.prepare('UPDATE api_tokens SET revoked_at = ? WHERE workspace_id = ? AND id = ? AND revoked_at IS NULL').run(now, ctx.workspaceId, tokenId); + if (changed.changes !== 1) throw new NotFoundError(`active token ${tokenId}`); + audit(env.db, ctx, now, 'api_token', tokenId, 'revoked'); + }); +} + +interface TokenRow { + workspace_id: string; + principal_id: string; + secret_sha256: string; + role_ceiling: Role; + expires_at: number; + revoked_at: number | null; + roles: string; +} + +/** Resolves a bearer token to an AuthContext. Every failure is the same ForbiddenError (no oracle). */ +export function authenticateToken(db: SqlDatabase, token: string, source: Surface, traceId: string, now: number): AuthContext { + const match = TOKEN_RE.exec(token.trim()); + const denied = new ForbiddenError('invalid or expired token'); + if (!match) throw denied; + const [, id, secret] = match; + const row = db + .prepare( + `SELECT t.workspace_id, t.principal_id, t.secret_sha256, t.role_ceiling, t.expires_at, t.revoked_at, p.roles + FROM api_tokens t JOIN principals p ON p.id = t.principal_id WHERE t.id = ?`, + ) + .get(id ?? ''); + if (!row || !safeEqualHex(sha256Hex(secret ?? ''), row.secret_sha256) || row.revoked_at !== null || now > row.expires_at) throw denied; + // Roles are cumulative, so the effective role is the lower of the principal's highest role and the ceiling. + const highest = Math.max(-1, ...(JSON.parse(row.roles) as string[]).map((role) => ROLES.indexOf(role as Role))); + const effective = Math.min(highest, ROLES.indexOf(row.role_ceiling)); + db.prepare('UPDATE api_tokens SET last_used_at = ? WHERE id = ?').run(now, id ?? ''); + const role = ROLES[effective]; + return { workspaceId: row.workspace_id, principalId: row.principal_id, roles: role ? [role] : [], source, traceId }; +} + +/** The live-send gate is workspace state, changed only by an admin through an audited action (plan §12). */ +export function readSendGate(db: SqlDatabase, workspaceId: string): SendGate { + const row = db.prepare('SELECT settings FROM workspaces WHERE id = ?').get<{ settings: string }>(workspaceId); + const gate = (JSON.parse(row?.settings ?? '{}') as { sendGate?: SendGate }).sendGate; + if (gate?.mode === 'open') return { mode: 'open' }; + if (gate?.mode === 'allowlist' && Array.isArray(gate.allow)) return { mode: 'allowlist', allow: gate.allow.map(String) }; + return { mode: 'allowlist', allow: [] }; +} + +export function setSendGate(env: DomainEnv, ctx: AuthContext, gate: SendGate, reason: string): void { + requireRole(ctx, 'admin'); + if (gate.mode === 'open' && reason.trim().length < 10) throw new ForbiddenError('opening the live-send gate needs a written reason (at least 10 characters)'); + env.db.transaction(() => { + const now = env.now(); + const row = env.db.prepare('SELECT settings FROM workspaces WHERE id = ?').get<{ settings: string }>(ctx.workspaceId); + const settings = { ...(JSON.parse(row?.settings ?? '{}') as Record), sendGate: gate }; + env.db.prepare('UPDATE workspaces SET settings = ? WHERE id = ?').run(JSON.stringify(settings), ctx.workspaceId); + audit(env.db, ctx, now, 'workspace', ctx.workspaceId, 'send_gate_changed', { gate, reason }); + }); +} diff --git a/outreach-engine/packages/outreach-core/src/domain/index.ts b/outreach-engine/packages/outreach-core/src/domain/index.ts index 937354e..b0b5ae2 100644 --- a/outreach-engine/packages/outreach-core/src/domain/index.ts +++ b/outreach-engine/packages/outreach-core/src/domain/index.ts @@ -10,3 +10,4 @@ export * from './campaigns'; export * from './materialize'; export * from './wiring'; export * from './operations'; +export * from './access'; diff --git a/outreach-engine/packages/outreach-core/src/engine.ts b/outreach-engine/packages/outreach-core/src/engine.ts index 8ea20dc..585ddf2 100644 --- a/outreach-engine/packages/outreach-core/src/engine.ts +++ b/outreach-engine/packages/outreach-core/src/engine.ts @@ -3,7 +3,8 @@ import { runExecutionPass, type ExecutionPassReport } from './execution/executor import { setKillSwitch, type KillSwitchScope } from './execution/kill-switches'; import { resolveReviewAction, type ReviewResolution } from './execution/review'; import type { ActionRow, CrashHooks, ExecutionConfig, ExecutionDeps, SendGate } from './execution/types'; -import { actorOf, audit, requireRole, type AuthContext, type Role } from './domain/auth'; +import { readSendGate } from './domain/access'; +import { NotFoundError, actorOf, audit, requireRole, type AuthContext, type Role } from './domain/auth'; import type { DomainEnv, UnsubscribeConfig } from './domain/env'; import { domainChecks, domainEffects, domainRatePolicy } from './domain/wiring'; import { pollDueMailboxes } from './inbound/ingest'; @@ -14,7 +15,10 @@ export interface EngineConfig { readonly adapters: readonly ProviderAdapter[]; readonly secrets: SecretResolver; readonly workerId: string; - /** Defaults to an allowlist with no entries: nothing is emailed until an admin opens the gate. */ + /** + * Defaults to the workspace's audited setting (see setSendGate), which itself defaults to an empty + * allowlist: nothing is emailed until an admin opens the gate. + */ readonly sendGate?: SendGate; readonly unsubscribe?: UnsubscribeConfig; readonly now?: () => number; @@ -50,7 +54,7 @@ export function createEngine(config: EngineConfig): Engine { secrets: config.secrets, now, workerId: config.workerId, - sendGate: config.sendGate ?? { mode: 'allowlist', allow: [] }, + sendGate: config.sendGate ?? readSendGate, checks: domainChecks(), ratePolicy: domainRatePolicy, // Effects need the domain env, which needs exec: resolve lazily. @@ -109,10 +113,23 @@ export function listReview(env: DomainEnv, ctx: AuthContext): ActionRow[] { export function resolveReview(env: DomainEnv, ctx: AuthContext, actionId: string, resolution: ReviewResolution): void { requireRole(ctx, 'approver'); const owned = env.db.prepare('SELECT 1 FROM scheduled_actions WHERE workspace_id = ? AND id = ?').get(ctx.workspaceId, actionId); - if (!owned) throw new Error(`action ${actionId} not found`); + if (!owned) throw new NotFoundError(`action ${actionId}`); resolveReviewAction(env.exec, actionId, resolution, actorOf(ctx)); } +export interface CampaignSummary { + readonly id: string; + readonly name: string; + readonly purpose: string; + readonly status: string; + readonly created_at: number; +} + +export function listCampaigns(env: DomainEnv, ctx: AuthContext): CampaignSummary[] { + requireRole(ctx, 'viewer'); + return env.db.prepare('SELECT id, name, purpose, status, created_at FROM campaigns WHERE workspace_id = ? ORDER BY created_at DESC').all(ctx.workspaceId); +} + export interface CampaignStatus { readonly campaignId: string; readonly status: string; @@ -124,7 +141,7 @@ export interface CampaignStatus { export function campaignStatus(env: DomainEnv, ctx: AuthContext, campaignId: string): CampaignStatus { requireRole(ctx, 'viewer'); const campaign = env.db.prepare('SELECT status FROM campaigns WHERE workspace_id = ? AND id = ?').get<{ status: string }>(ctx.workspaceId, campaignId); - if (!campaign) throw new Error(`campaign ${campaignId} not found`); + if (!campaign) throw new NotFoundError(`campaign ${campaignId}`); const count = (sql: string) => Object.fromEntries(env.db.prepare(sql).all<{ k: string; n: number }>(ctx.workspaceId, campaignId).map((row) => [row.k, row.n])); return { diff --git a/outreach-engine/packages/outreach-core/src/execution/preflight.ts b/outreach-engine/packages/outreach-core/src/execution/preflight.ts index f6de507..8461aad 100644 --- a/outreach-engine/packages/outreach-core/src/execution/preflight.ts +++ b/outreach-engine/packages/outreach-core/src/execution/preflight.ts @@ -79,7 +79,7 @@ function builtInChecks(deps: ExecutionDeps): { before: PreflightCheck[]; after: return { kind: 'pass' }; }; const gate: PreflightCheck = ({ action }) => - isEmail(action) && !recipientAllowed(deps.sendGate, action.recipient_norm) + isEmail(action) && !recipientAllowed(typeof deps.sendGate === 'function' ? deps.sendGate(deps.db, action.workspace_id) : deps.sendGate, action.recipient_norm) ? { kind: 'review', reason: 'not_in_live_allowlist' } : { kind: 'pass' }; return { before: [killSwitch, expiry], after: [account, gate] }; diff --git a/outreach-engine/packages/outreach-core/src/execution/types.ts b/outreach-engine/packages/outreach-core/src/execution/types.ts index f030da6..9417386 100644 --- a/outreach-engine/packages/outreach-core/src/execution/types.ts +++ b/outreach-engine/packages/outreach-core/src/execution/types.ts @@ -147,7 +147,8 @@ export interface ExecutionDeps { readonly secrets: SecretResolver; readonly now: () => number; readonly workerId: string; - readonly sendGate: SendGate; + /** A fixed gate, or a resolver read inside preflight (e.g. from workspace settings). */ + readonly sendGate: SendGate | ((db: SqlDatabase, workspaceId: string) => SendGate); readonly config?: Partial; /** Extra checks (enrollment status, suppression, approval, send window), run after the built-ins. */ readonly checks?: readonly PreflightCheck[]; diff --git a/outreach-engine/packages/outreach-server/package.json b/outreach-engine/packages/outreach-server/package.json new file mode 100644 index 0000000..7da3e0f --- /dev/null +++ b/outreach-engine/packages/outreach-server/package.json @@ -0,0 +1,52 @@ +{ + "name": "@splitin/outreach-server", + "version": "0.0.0", + "description": "Outreach HTTP API: bearer-token /v1 routes, signed provider webhooks and one-click unsubscribe.", + "license": "MIT", + "author": "SplitInTech", + "homepage": "https://github.com/splitintech/open-internal-tools/tree/main/outreach-engine#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/splitintech/open-internal-tools.git", + "directory": "outreach-engine/packages/outreach-server" + }, + "type": "module", + "sideEffects": false, + "engines": { + "node": ">=22.13" + }, + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "files": [ + "dist", + "README.md", + "package.json" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsup", + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@hono/node-server": "^1.19.17", + "@splitin/outreach-contracts": "0.0.0", + "@splitin/outreach-core": "0.0.0", + "@splitin/outreach-import": "0.0.0", + "hono": "^4.13.9", + "zod": "^4.6.5" + }, + "devDependencies": { + "@splitin/outreach-fakes": "0.0.0", + "@splitin/outreach-store-sqlite": "0.0.0" + } +} diff --git a/outreach-engine/packages/outreach-server/src/app.ts b/outreach-engine/packages/outreach-server/src/app.ts new file mode 100644 index 0000000..5744a72 --- /dev/null +++ b/outreach-engine/packages/outreach-server/src/app.ts @@ -0,0 +1,75 @@ +import { ulid } from '@splitin/outreach-contracts'; +import { authenticateToken, type AuthContext, type Engine } from '@splitin/outreach-core'; +import { Hono, type Context } from 'hono'; +import { bodyLimit } from 'hono/body-limit'; +import { toApiError } from './errors'; +import { registerCampaignRoutes } from './routes-campaigns'; +import { registerOperationRoutes } from './routes-operations'; +import { registerPublicRoutes } from './routes-public'; + +export interface ServerEnv { + Variables: { auth: AuthContext; traceId: string }; +} + +export type ServerContext = Context; + +export interface ServerOptions { + readonly engine: Engine; + /** JSON bodies; imports have their own larger limit. */ + readonly maxJsonBytes?: number; + readonly maxImportBytes?: number; +} + +export function auth(c: ServerContext): AuthContext { + return c.get('auth'); +} + +/** + * The HTTP surface (BUILD_PLAN.md §11.2). Thin by design: every route authenticates, parses with zod and + * calls one application service. Business rules live in @splitin/outreach-core only. + */ +export function createApp(options: ServerOptions): Hono { + const { engine } = options; + const app = new Hono(); + + app.use('*', async (c, next) => { + const traceId = ulid(); + c.set('traceId', traceId); + await next(); + c.header('x-trace-id', traceId); + c.header('x-content-type-options', 'nosniff'); + c.header('cache-control', 'no-store'); + }); + + app.onError((error, c) => { + const apiError = toApiError(error); + if (apiError.status === 500) console.error(`[outreach-server] ${c.get('traceId')}`, error); + return c.json({ error: apiError.code, message: apiError.message, ...(apiError.issues ? { issues: apiError.issues } : {}), traceId: c.get('traceId') }, apiError.status); + }); + + app.notFound((c) => c.json({ error: 'not_found', message: 'no such route', traceId: c.get('traceId') }, 404)); + + registerPublicRoutes(app, engine); + + app.use('/v1/*', async (c, next) => { + // Provider webhooks authenticate by signature, not by bearer token. + if (c.req.path.startsWith('/v1/webhooks/')) return next(); + const header = c.req.header('authorization') ?? ''; + const token = header.startsWith('Bearer ') ? header.slice(7) : ''; + c.set('auth', engine.db.transaction(() => authenticateToken(engine.db, token, 'http', c.get('traceId'), engine.now()))); + return next(); + }); + app.use('/v1/imports/preview', bodyLimit({ maxSize: options.maxImportBytes ?? 25 * 1024 * 1024, onError: (c) => c.json({ error: 'too_large' }, 413) })); + app.use('/v1/*', async (c, next) => { + if (c.req.path === '/v1/imports/preview' || c.req.path.startsWith('/v1/webhooks/')) return next(); + return bodyLimit({ maxSize: options.maxJsonBytes ?? 256 * 1024, onError: (ctx) => ctx.json({ error: 'too_large' }, 413) })(c, next); + }); + + app.get('/v1/me', (c) => { + const ctx = auth(c); + return c.json({ workspaceId: ctx.workspaceId, principalId: ctx.principalId, roles: ctx.roles }); + }); + registerCampaignRoutes(app, engine); + registerOperationRoutes(app, engine); + return app; +} diff --git a/outreach-engine/packages/outreach-server/src/errors.ts b/outreach-engine/packages/outreach-server/src/errors.ts new file mode 100644 index 0000000..67393c7 --- /dev/null +++ b/outreach-engine/packages/outreach-server/src/errors.ts @@ -0,0 +1,26 @@ +import { ConflictError, ForbiddenError, NotFoundError, PlaybookError, ReviewStateError, TemplateRenderError } from '@splitin/outreach-core'; +import { ImportRejectedError, ImportStaleError, ProfileError } from '@splitin/outreach-import'; +import { ZodError } from 'zod'; + +export interface ApiError { + readonly status: 400 | 401 | 403 | 404 | 409 | 413 | 422 | 500; + readonly code: string; + readonly message: string; + readonly issues?: readonly string[]; +} + +/** Maps domain errors to HTTP. Unexpected errors never leak their message or stack. */ +export function toApiError(error: unknown): ApiError { + if (error instanceof ZodError) { + return { status: 400, code: 'invalid_request', message: 'request body is invalid', issues: error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`) }; + } + if (error instanceof SyntaxError) return { status: 400, code: 'invalid_json', message: 'request body is not valid JSON' }; + if (error instanceof ForbiddenError) return { status: 403, code: 'forbidden', message: error.message }; + if (error instanceof NotFoundError) return { status: 404, code: 'not_found', message: error.message }; + if (error instanceof ConflictError || error instanceof ImportStaleError || error instanceof ReviewStateError) { + return { status: 409, code: 'conflict', message: error.message }; + } + if (error instanceof PlaybookError || error instanceof ProfileError) return { status: 422, code: 'invalid_definition', message: 'definition is invalid', issues: error.issues }; + if (error instanceof ImportRejectedError || error instanceof TemplateRenderError) return { status: 422, code: 'unprocessable', message: error.message }; + return { status: 500, code: 'internal', message: 'internal error' }; +} diff --git a/outreach-engine/packages/outreach-server/src/index.ts b/outreach-engine/packages/outreach-server/src/index.ts new file mode 100644 index 0000000..57f78e2 --- /dev/null +++ b/outreach-engine/packages/outreach-server/src/index.ts @@ -0,0 +1,3 @@ +export { createApp, type ServerOptions, type ServerEnv } from './app'; +export { startServer, PublicBindRefusedError } from './serve'; +export { toApiError, type ApiError } from './errors'; diff --git a/outreach-engine/packages/outreach-server/src/routes-campaigns.ts b/outreach-engine/packages/outreach-server/src/routes-campaigns.ts new file mode 100644 index 0000000..6990a4c --- /dev/null +++ b/outreach-engine/packages/outreach-server/src/routes-campaigns.ts @@ -0,0 +1,82 @@ +import { + campaignStatus, + commitActivation, + createCampaign, + decideApproval, + listApprovals, + listCampaigns, + prepareActivation, + requireRole, + requestBatchApproval, + revokeApproval, + setCampaignStatus, + type Engine, +} from '@splitin/outreach-core'; +import { commitImport, previewImport } from '@splitin/outreach-import'; +import type { Hono } from 'hono'; +import { z } from 'zod'; +import { auth, type ServerEnv } from './app'; + +const reason = z.object({ reason: z.string().min(1).max(500) }).strict(); + +export function registerCampaignRoutes(app: Hono, engine: Engine): void { + app.get('/v1/campaigns', (c) => c.json({ campaigns: listCampaigns(engine, auth(c)) })); + + app.post('/v1/campaigns', async (c) => { + const body = z.object({ name: z.string().min(1).max(200), playbook: z.union([z.string().max(100_000), z.record(z.string(), z.unknown())]), providerAccountId: z.string().min(1) }).strict().parse(await c.req.json()); + return c.json(createCampaign(engine, auth(c), body), 201); + }); + + app.get('/v1/campaigns/:id', (c) => c.json(campaignStatus(engine, auth(c), c.req.param('id')))); + + app.post('/v1/campaigns/:id/activation/prepare', (c) => c.json(prepareActivation(engine, auth(c), c.req.param('id')))); + + app.post('/v1/campaigns/:id/activation/commit', async (c) => { + const body = z.object({ operationHash: z.string().regex(/^[0-9a-f]{64}$/) }).strict().parse(await c.req.json()); + return c.json(commitActivation(engine, auth(c), { campaignId: c.req.param('id'), operationHash: body.operationHash })); + }); + + app.post('/v1/campaigns/:id/pause', async (c) => { + setCampaignStatus(engine, auth(c), c.req.param('id'), 'paused', reason.parse(await c.req.json()).reason); + return c.json({ status: 'paused' }); + }); + + app.post('/v1/campaigns/:id/resume', async (c) => { + setCampaignStatus(engine, auth(c), c.req.param('id'), 'active', reason.parse(await c.req.json()).reason); + return c.json({ status: 'active' }); + }); + + app.post('/v1/campaigns/:id/approvals/batch', (c) => c.json(requestBatchApproval(engine, auth(c), c.req.param('id')) ?? { approvalId: null, count: 0 })); + + app.get('/v1/approvals', (c) => { + const decision = z.enum(['pending', 'approved', 'rejected', 'revoked', 'expired']).default('pending').parse(c.req.query('decision')); + return c.json({ approvals: listApprovals(engine.db, auth(c), decision).map((row) => ({ ...row, preview: JSON.parse(row.preview) as unknown })) }); + }); + + app.post('/v1/approvals/:id/decide', async (c) => { + const body = z.object({ decision: z.enum(['approved', 'rejected']), operationHash: z.string().regex(/^[0-9a-f]{64}$/), reason: z.string().max(500).optional() }).strict().parse(await c.req.json()); + const decided = decideApproval(engine, auth(c), { approvalId: c.req.param('id'), ...body }); + return c.json({ id: decided.id, decision: decided.decision }); + }); + + app.post('/v1/approvals/:id/revoke', async (c) => { + revokeApproval(engine, auth(c), c.req.param('id'), reason.parse(await c.req.json()).reason); + return c.json({ decision: 'revoked' }); + }); + + app.post('/v1/imports/preview', async (c) => { + const query = z.object({ profileId: z.string().min(1), fileName: z.string().min(1).max(200) }).parse({ profileId: c.req.query('profileId'), fileName: c.req.query('fileName') }); + const ctx = auth(c); + requireRole(ctx, 'operator'); + const bytes = new Uint8Array(await c.req.arrayBuffer()); + const preview = await previewImport(engine.db, { workspaceId: ctx.workspaceId, principalId: ctx.principalId, source: 'http', traceId: ctx.traceId }, { ...query, bytes, now: engine.now() }); + return c.json(preview, 201); + }); + + app.post('/v1/imports/:id/commit', async (c) => { + const body = z.object({ previewHash: z.string().regex(/^[0-9a-f]{64}$/), idempotencyKey: z.string().min(1).max(200) }).strict().parse(await c.req.json()); + const ctx = auth(c); + requireRole(ctx, 'operator'); + return c.json(commitImport(engine.db, { workspaceId: ctx.workspaceId, principalId: ctx.principalId, source: 'http', traceId: ctx.traceId }, { batchId: c.req.param('id'), ...body, now: engine.now() })); + }); +} diff --git a/outreach-engine/packages/outreach-server/src/routes-operations.ts b/outreach-engine/packages/outreach-server/src/routes-operations.ts new file mode 100644 index 0000000..09252f8 --- /dev/null +++ b/outreach-engine/packages/outreach-server/src/routes-operations.ts @@ -0,0 +1,54 @@ +import { + listManualTasks, + listReview, + recordManualOutcome, + resolveReview, + setEnrollmentState, + setKillSwitchAs, + type Engine, +} from '@splitin/outreach-core'; +import type { Hono } from 'hono'; +import { z } from 'zod'; +import { auth, type ServerEnv } from './app'; + +const resolution = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('sent'), providerMessageId: z.string().min(1).max(500), providerThreadId: z.string().max(500).optional() }).strict(), + z.object({ kind: z.literal('not_sent_retry') }).strict(), + z.object({ kind: z.literal('drop'), reason: z.string().min(1).max(500) }).strict(), +]); + +export function registerOperationRoutes(app: Hono, engine: Engine): void { + app.get('/v1/tasks', (c) => { + const status = z.enum(['open', 'done', 'skipped', 'expired']).default('open').parse(c.req.query('status')); + return c.json({ tasks: listManualTasks(engine, auth(c), status) }); + }); + + app.post('/v1/tasks/:id/outcome', async (c) => { + const body = z.object({ outcome: z.enum(['done', 'skipped']), note: z.string().max(1000).optional() }).strict().parse(await c.req.json()); + recordManualOutcome(engine, auth(c), c.req.param('id'), body.outcome, body.note); + return c.json({ status: body.outcome }); + }); + + app.get('/v1/review', (c) => c.json({ actions: listReview(engine, auth(c)).map(({ payload: _payload, ...row }) => row) })); + + app.post('/v1/review/:id/resolve', async (c) => { + resolveReview(engine, auth(c), c.req.param('id'), resolution.parse(await c.req.json())); + return c.json({ resolved: true }); + }); + + app.post('/v1/enrollments/:id/:change', async (c) => { + const change = z.enum(['pause', 'resume', 'stop']).parse(c.req.param('change')); + const body = z.object({ reason: z.string().min(1).max(500) }).strict().parse(await c.req.json()); + setEnrollmentState(engine, auth(c), c.req.param('id'), change, body.reason); + return c.json({ change }); + }); + + app.post('/v1/kill-switches', async (c) => { + const body = z + .object({ scope: z.enum(['global', 'workspace', 'provider_account', 'campaign']), targetId: z.string().min(1).optional(), engaged: z.boolean(), reason: z.string().min(1).max(500) }) + .strict() + .parse(await c.req.json()); + setKillSwitchAs(engine, auth(c), { scope: body.scope, engaged: body.engaged, reason: body.reason, ...(body.targetId ? { targetId: body.targetId } : {}) }); + return c.json({ scope: body.scope, engaged: body.engaged }); + }); +} diff --git a/outreach-engine/packages/outreach-server/src/routes-public.ts b/outreach-engine/packages/outreach-server/src/routes-public.ts new file mode 100644 index 0000000..7c3fe1d --- /dev/null +++ b/outreach-engine/packages/outreach-server/src/routes-public.ts @@ -0,0 +1,46 @@ +import { escapeHtml, handleUnsubscribe, ingestWebhook, type Engine } from '@splitin/outreach-core'; +import type { Hono } from 'hono'; +import type { ServerEnv } from './app'; + +const MAX_WEBHOOK_BYTES = 1_000_000; + +const page = (title: string, body: string) => + ` +${escapeHtml(title)} + +${body}`; + +/** Routes that do not use bearer tokens: health, provider webhooks (signature) and unsubscribe (HMAC token). */ +export function registerPublicRoutes(app: Hono, engine: Engine): void { + app.get('/healthz', (c) => c.json({ ok: true })); + + app.post('/v1/webhooks/:accountId', async (c) => { + const declared = Number(c.req.header('content-length') ?? 0); + if (declared > MAX_WEBHOOK_BYTES) return c.json({ accepted: false, reason: 'too_large' }, 413); + const rawBody = new Uint8Array(await c.req.arrayBuffer()); + const headers: Record = {}; + c.req.raw.headers.forEach((value, key) => { + headers[key.toLowerCase()] = value; + }); + const result = await ingestWebhook(engine, { providerAccountId: c.req.param('accountId'), rawBody, headers }); + if (result.accepted) return c.json(result); + const status = result.reason === 'too_large' ? 413 : result.reason === 'not_configured' ? 404 : 401; + return c.json(result, status); + }); + + // Mail clients and scanners prefetch GET links, so GET only shows a confirmation form (RFC 8058). + app.get('/u/:token', (c) => + c.html(page('Unsubscribe', `

Unsubscribe

Stop receiving these emails?

+
`)), + ); + + // One-click POST from the mail client (List-Unsubscribe-Post) or the form above. + app.post('/u/:token', (c) => { + const result = handleUnsubscribe(engine, c.req.param('token')); + if (!result.ok) { + return c.html(page('Link not valid', '

This link is not valid

Reply to the email and ask us to stop, and we will.

'), result.reason === 'not_configured' ? 404 : 400); + } + return c.html(page('Unsubscribed', '

You are unsubscribed

You will not receive further emails from us.

')); + }); +} diff --git a/outreach-engine/packages/outreach-server/src/serve.ts b/outreach-engine/packages/outreach-server/src/serve.ts new file mode 100644 index 0000000..add8e60 --- /dev/null +++ b/outreach-engine/packages/outreach-server/src/serve.ts @@ -0,0 +1,23 @@ +import { serve, type ServerType } from '@hono/node-server'; +import type { Hono } from 'hono'; +import type { ServerEnv } from './app'; + +const LOOPBACK = new Set(['127.0.0.1', '::1', 'localhost']); + +export class PublicBindRefusedError extends Error { + constructor(host: string) { + super(`Refusing to listen on ${host}: pass allowPublic (CLI: --public) and put TLS termination in front of it`); + this.name = 'PublicBindRefusedError'; + } +} + +/** Starts the server. Loopback only unless explicitly allowed (BUILD_PLAN.md §11.2, §12). */ +export function startServer(app: Hono, options: { host?: string; port: number; allowPublic?: boolean }): Promise<{ server: ServerType; url: string }> { + const host = options.host ?? '127.0.0.1'; + if (!LOOPBACK.has(host) && !options.allowPublic) throw new PublicBindRefusedError(host); + return new Promise((resolve) => { + const server = serve({ fetch: app.fetch, hostname: host, port: options.port }, (info) => { + resolve({ server, url: `http://${host.includes(':') ? `[${host}]` : host}:${info.port}` }); + }); + }); +} diff --git a/outreach-engine/packages/outreach-server/src/server.test.ts b/outreach-engine/packages/outreach-server/src/server.test.ts new file mode 100644 index 0000000..fa9478f --- /dev/null +++ b/outreach-engine/packages/outreach-server/src/server.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it } from 'vitest'; +import { + addPrincipal, + authenticate, + bootstrapWorkspace, + createApiToken, + createEngine, + createTemplate, + registerProviderAccount, + revokeApiToken, + addContact, + type Engine, +} from '@splitin/outreach-core'; +import { FAKE_EMAIL_SECRET, FakeEmailProvider, staticSecrets } from '@splitin/outreach-fakes'; +import { saveMappingProfile } from '@splitin/outreach-import'; +import { openSqliteDatabase } from '@splitin/outreach-store-sqlite'; +import { createApp } from './app'; +import { PublicBindRefusedError, startServer } from './serve'; + +// Response bodies are asserted field by field below; a loose type keeps the assertions readable. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Json = Record; +const json = async (res: Response | Promise): Promise => (await (await res).json()) as Json; + +const PLAYBOOK = ` +apiVersion: outreach.splitin.net/v1alpha1 +kind: Playbook +metadata: { name: api-intro } +spec: + purpose: automated_outreach + policy: { approval: none, window: { days: [Mon, Tue, Wed, Thu, Fri, Sat, Sun], start: "00:00", end: "23:59" } } + steps: [{ id: intro, type: email.send, template: intro@1 }] +`; + +async function setup() { + let now = Date.parse('2025-06-03T14:00:00Z'); + const db = openSqliteDatabase(':memory:'); + const fake = new FakeEmailProvider(); + const engine: Engine = createEngine({ + db, + adapters: [fake.adapter()], + secrets: staticSecrets({ 'env:MAIL': FAKE_EMAIL_SECRET, 'env:HOOK': 'fake-webhook-secret-value' }), + workerId: 'api-test', + sendGate: { mode: 'open' }, + unsubscribe: { baseUrl: 'https://outreach.example.com/u/', secret: 'server-test-unsubscribe-fixture' }, + now: () => now, + pollIntervalMs: 0, + }); + bootstrapWorkspace(db, { workspaceId: 'ws', name: 'W', adminRef: 'cli:admin', adminName: 'Admin' }, now); + const admin = authenticate(db, 'ws', 'cli:admin', 'cli', 't'); + const operatorId = addPrincipal(engine, admin, { externalRef: 'http:ops', displayName: 'Ops', roles: ['operator'] }); + const approverId = addPrincipal(engine, admin, { externalRef: 'http:approver', displayName: 'Approver', roles: ['approver'] }); + const accountId = await registerProviderAccount(engine, admin, { + provider: 'fake-email', externalAccountId: 'x', purposes: ['automated_outreach'], secretRef: 'env:MAIL', webhookSecretRef: 'env:HOOK', + sender: { name: 'Sam', address: 'sam@example.com', organization: 'Example', postalAddress: '1 Example St' }, + }); + createTemplate(db, admin, { name: 'intro', channel: 'email', subject: 'Hello {{first_name}}', text: 'Hi {{first_name}}' }, now); + const tokens = { + operator: createApiToken(engine, admin, { principalId: operatorId, name: 'ops', roleCeiling: 'operator', ttlDays: 30 }).token, + approver: createApiToken(engine, admin, { principalId: approverId, name: 'appr', roleCeiling: 'approver', ttlDays: 30 }).token, + readOnly: createApiToken(engine, admin, { principalId: approverId, name: 'dash', roleCeiling: 'viewer', ttlDays: 30 }), + }; + const app = createApp({ engine }); + const call = (method: string, path: string, token: string | null, body?: unknown, headers: Record = {}) => + app.request(path, { + method, + headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), ...(body !== undefined && !(body instanceof Uint8Array) ? { 'content-type': 'application/json' } : {}), ...headers }, + ...(body === undefined ? {} : { body: body instanceof Uint8Array ? body : JSON.stringify(body) }), + }); + return { engine, db, fake, admin, accountId, tokens, app, call, advance: (ms: number) => { now += ms; } }; +} + +describe('authentication', () => { + it('rejects missing, malformed, revoked and expired tokens identically', async () => { + const t = await setup(); + for (const token of [null, 'nope', `${t.tokens.operator}x`]) { + const res = await t.call('GET', '/v1/me', token); + expect(res.status).toBe(403); + expect((await json(res)).message).toBe('invalid or expired token'); + } + revokeApiToken(t.engine, t.admin, t.tokens.readOnly.id); + expect((await t.call('GET', '/v1/me', t.tokens.readOnly.token)).status).toBe(403); + t.advance(31 * 86_400_000); + expect((await t.call('GET', '/v1/me', t.tokens.operator)).status).toBe(403); + }); + + it('caps a token at its role ceiling', async () => { + const t = await setup(); + const me = await json(t.call('GET', '/v1/me', t.tokens.readOnly.token)); + expect(me.roles).toEqual(['viewer']); + const res = await t.call('POST', '/v1/kill-switches', t.tokens.readOnly.token, { scope: 'workspace', engaged: true, reason: 'x' }); + expect(res.status).toBe(403); + }); + + it('sets trace and safety headers', async () => { + const t = await setup(); + const res = await t.call('GET', '/v1/me', t.tokens.operator); + expect(res.headers.get('x-trace-id')).toMatch(/^[0-9A-Z]{26}$/); + expect(res.headers.get('cache-control')).toBe('no-store'); + }); +}); + +describe('campaign flow over HTTP', () => { + it('imports, creates, prepares, commits and reports status; approval decisions need the approver', async () => { + const t = await setup(); + const profile = t.db.transaction(() => saveMappingProfile(t.db, 'ws', 'p', { columns: { email: 'Email', full_name: 'Name' }, consent: { basis: 'legitimate_interest' } }, Date.now())); + const preview = await t.call('POST', `/v1/imports/preview?profileId=${profile.id}&fileName=a.csv`, t.tokens.operator, new TextEncoder().encode('Name,Email\nAda Lovelace,ada@example.org\n'), { 'content-type': 'text/csv' }); + expect(preview.status).toBe(201); + const previewBody = await json(preview); + const committed = await t.call('POST', `/v1/imports/${previewBody.batchId}/commit`, t.tokens.operator, { previewHash: previewBody.previewHash, idempotencyKey: 'k' }); + expect(await json(committed)).toMatchObject({ created: 1 }); + + const created = await t.call('POST', '/v1/campaigns', t.tokens.operator, { name: 'API', playbook: PLAYBOOK, providerAccountId: t.accountId }); + expect(created.status).toBe(201); + const { campaignId } = await json(created); + const prepared = await json(t.call('POST', `/v1/campaigns/${campaignId}/activation/prepare`, t.tokens.operator)); + expect(prepared).toMatchObject({ audienceCount: 1, requiresApproval: false }); + const stale = await t.call('POST', `/v1/campaigns/${campaignId}/activation/commit`, t.tokens.operator, { operationHash: '0'.repeat(64) }); + expect(stale.status).toBe(409); + const activated = await t.call('POST', `/v1/campaigns/${campaignId}/activation/commit`, t.tokens.operator, { operationHash: prepared.operationHash }); + expect(await json(activated)).toEqual({ enrolled: 1, batchApprovalId: null }); + await t.engine.runOnce(); + expect(t.fake.deliveries).toHaveLength(1); + const status = await json(t.call('GET', `/v1/campaigns/${campaignId}`, t.tokens.readOnly.token)); + expect(status.enrollments).toEqual({ completed: 1 }); + expect((await json(t.call('GET', '/v1/campaigns', t.tokens.readOnly.token))).campaigns).toHaveLength(1); + }); + + it('validates bodies and maps domain errors', async () => { + const t = await setup(); + const bad = await t.call('POST', '/v1/campaigns', t.tokens.operator, { name: 'x', playbook: PLAYBOOK, providerAccountId: t.accountId, extra: 1 }); + expect(bad.status).toBe(400); + const invalid = await t.call('POST', '/v1/campaigns', t.tokens.operator, { name: 'x', playbook: PLAYBOOK.replace('intro@1', 'nope@1'), providerAccountId: t.accountId }); + expect(invalid.status).toBe(422); + expect((await json(invalid)).issues[0]).toMatch(/nope@1 does not exist/); + expect((await t.call('GET', '/v1/campaigns/missing', t.tokens.operator)).status).toBe(404); + const tooBig = await t.call('POST', '/v1/campaigns', t.tokens.operator, { name: 'x'.repeat(300_000), playbook: '', providerAccountId: 'x' }); + expect(tooBig.status).toBe(413); + expect((await t.call('GET', '/v1/nothing', t.tokens.operator)).status).toBe(404); + }); +}); + +describe('public routes', () => { + it('accepts signed webhooks and rejects forged ones without a bearer token', async () => { + const t = await setup(); + const event = t.fake.pushInbound({ kind: 'message', references: [], from: 'x@example.org', to: [], headers: {}, at: t.engine.now() }); + const signed = t.fake.signWebhook([event], t.engine.now()); + const ok = await t.call('POST', `/v1/webhooks/${t.accountId}`, null, signed.rawBody, signed.headers); + expect(await json(ok)).toEqual({ accepted: true, stored: 1, duplicates: 0 }); + const forged = await t.call('POST', `/v1/webhooks/${t.accountId}`, null, signed.rawBody, { ...signed.headers, 'x-fake-signature': 'ab'.repeat(32) }); + expect(forged.status).toBe(401); + expect((await t.call('POST', '/v1/webhooks/unknown', null, signed.rawBody, signed.headers)).status).toBe(404); + }); + + it('shows a confirmation on GET and unsubscribes only on POST', async () => { + const t = await setup(); + addContact(t.engine, t.admin, { fullName: 'Ada', firstName: 'Ada', email: 'ada@example.org', consentBasis: 'legitimate_interest' }); + const created = await t.call('POST', '/v1/campaigns', t.tokens.operator, { name: 'U', playbook: PLAYBOOK, providerAccountId: t.accountId }); + const { campaignId } = await json(created); + const prepared = await json(t.call('POST', `/v1/campaigns/${campaignId}/activation/prepare`, t.tokens.operator)); + await t.call('POST', `/v1/campaigns/${campaignId}/activation/commit`, t.tokens.operator, { operationHash: prepared.operationHash }); + await t.engine.runOnce(); + const token = /\/u\/([^>]+)>/.exec(t.fake.deliveries[0]?.headers['List-Unsubscribe'] ?? '')?.[1] ?? ''; + const get = await t.app.request(`/u/${token}`); + expect(await get.text()).toMatch(/
/); + expect(t.db.prepare('SELECT COUNT(*) AS n FROM suppressions').get<{ n: number }>()?.n).toBe(0); + const post = await t.app.request(`/u/${token}`, { method: 'POST' }); + expect(await post.text()).toMatch(/You are unsubscribed/); + expect(t.db.prepare('SELECT reason FROM suppressions').get()).toEqual({ reason: 'opt_out' }); + expect((await t.app.request('/u/forged.token', { method: 'POST' })).status).toBe(400); + }); + + it('refuses to bind a public address unless explicitly allowed', async () => { + const t = await setup(); + expect(() => startServer(t.app, { host: '0.0.0.0', port: 0 })).toThrow(PublicBindRefusedError); + const { server, url } = await startServer(t.app, { port: 0 }); + const res = await fetch(`${url}/healthz`); + expect(await json(res)).toEqual({ ok: true }); + server.close(); + }); +}); diff --git a/outreach-engine/packages/outreach-server/tsconfig.json b/outreach-engine/packages/outreach-server/tsconfig.json new file mode 100644 index 0000000..585a92d --- /dev/null +++ b/outreach-engine/packages/outreach-server/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist" }, + "include": ["src"] +} diff --git a/outreach-engine/packages/outreach-server/tsup.config.ts b/outreach-engine/packages/outreach-server/tsup.config.ts new file mode 100644 index 0000000..458d1fd --- /dev/null +++ b/outreach-engine/packages/outreach-server/tsup.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + dts: true, + sourcemap: true, + clean: true, + target: 'node22', + // node:sqlite exists only with the protocol prefix. + removeNodeProtocol: false, +}); diff --git a/outreach-engine/packages/outreach-store-sqlite/src/index.ts b/outreach-engine/packages/outreach-store-sqlite/src/index.ts index 3e9453d..6572b0d 100644 --- a/outreach-engine/packages/outreach-store-sqlite/src/index.ts +++ b/outreach-engine/packages/outreach-store-sqlite/src/index.ts @@ -2,3 +2,4 @@ export { wrapNativeDatabase, TransactionMisuseError, type NativeDatabase } from export { migrate, MIGRATIONS, MigrationDriftError, type Migration } from './migrate'; export { openSqliteDatabase, fromBetterSqlite3, type OpenOptions } from './open'; export { SCHEMA_0001 } from './schema'; +export { SCHEMA_0002 } from './schema-0002'; diff --git a/outreach-engine/packages/outreach-store-sqlite/src/migrate.ts b/outreach-engine/packages/outreach-store-sqlite/src/migrate.ts index e80c8c0..ad4b528 100644 --- a/outreach-engine/packages/outreach-store-sqlite/src/migrate.ts +++ b/outreach-engine/packages/outreach-store-sqlite/src/migrate.ts @@ -1,12 +1,16 @@ import { sha256Hex, type SqlDatabase } from '@splitin/outreach-contracts'; import { SCHEMA_0001 } from './schema'; +import { SCHEMA_0002 } from './schema-0002'; export interface Migration { readonly id: string; readonly sql: string; } -export const MIGRATIONS: readonly Migration[] = [{ id: '0001_init', sql: SCHEMA_0001 }]; +export const MIGRATIONS: readonly Migration[] = [ + { id: '0001_init', sql: SCHEMA_0001 }, + { id: '0002_api_tokens', sql: SCHEMA_0002 }, +]; export class MigrationDriftError extends Error { constructor(id: string) { diff --git a/outreach-engine/packages/outreach-store-sqlite/src/schema-0002.ts b/outreach-engine/packages/outreach-store-sqlite/src/schema-0002.ts new file mode 100644 index 0000000..bfef7da --- /dev/null +++ b/outreach-engine/packages/outreach-store-sqlite/src/schema-0002.ts @@ -0,0 +1,12 @@ +/** M7: bearer tokens for the HTTP API and other remote surfaces. Only a hash of the secret is stored. */ +export const SCHEMA_0002 = ` +CREATE TABLE api_tokens ( + id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL REFERENCES workspaces(id), + principal_id TEXT NOT NULL REFERENCES principals(id), + name TEXT NOT NULL, secret_sha256 TEXT NOT NULL, + role_ceiling TEXT NOT NULL CHECK (role_ceiling IN ('viewer','operator','approver','admin')), + created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, + revoked_at INTEGER, last_used_at INTEGER +) STRICT; +CREATE INDEX ix_api_tokens_principal ON api_tokens (workspace_id, principal_id); +`; From a3e086a06824a4e3ea9b7edd565b782c9589aa8f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 02:08:01 +0000 Subject: [PATCH 10/20] outreach-engine M7 (2/3): outreach CLI and multi-process soak test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second part of milestone M7 of outreach-engine/BUILD_PLAN.md (§11.1, §6.6). Adds the command line and closes the 4-process x 10k soak test deferred from M3. @splitin/outreach-cli (new; bin: outreach) - Commands, each a thin call into core/import/server: * setup: init (creates database and workspace; the caller becomes admin; the gate starts closed), principal add, token create/revoke (printed once), account add (secrets are env: references only), gate show/allowlist/open (open needs a written reason), notify set, audit verify (exit 3 if the chain is broken). * content: profile add, import preview/commit (prints the exact commit command), template add, playbook compile (exit 1 with issues), suppress. * campaigns: campaign create/list/status/prepare/activate/pause/ resume, approvals list/approve/reject/batch (approve and reject need the operation hash), tasks list/done/skip, review list/resolve, kill engage/release. * runtime: worker --once | --loop --interval (SIGINT/SIGTERM finish the current pass before exiting), and serve (loopback unless --public). - Global options: --db / OUTREACH_DB, --workspace / OUTREACH_WORKSPACE, --config (default ./outreach.config.mjs, which default-exports { adapters, manual? } so hosts plug in providers), --fake (in-memory fake providers that never send anything), --json. - Identity: OUTREACH_PRINCIPAL or cli:, resolved against registered principals. There is no implicit admin; unknown callers get "unknown principal". - Unsubscribe links are enabled when OUTREACH_PUBLIC_URL and OUTREACH_UNSUBSCRIBE_SECRET are set. - Strict argument parsing (node:util parseArgs): unknown commands and flags are usage errors with the command's usage line (exit 2); failures exit 1 with the message and any validation issues; per-command --help. Commands return their exit code, never setting process.exitCode, and read the invocation's environment, never process.env directly. This fixed `init` recording the wrong admin when run in-process. @splitin/outreach-fakes - Fake message, thread and event ids include a per-instance prefix, so several worker processes using fakes never collide on (account, provider_message_id). scripts/soak.mjs (npm run soak; now a CI step) - Builds a real database through the built CLI (init, account add, gate open), seeds 10,000 due actions, then runs rounds of 4 concurrent `outreach worker --once --fake` processes until drained, plus a settle pass. It asserts all 10,000 succeeded, no action has more than one attempt, attempts equal actions, one outbound message per action, and that work really was shared across processes. Locally: 6 rounds, 2,500 actions per process, about 13 s. The first run showed `worker --once` caps a pass at 500 actions (by design), so the script runs rounds rather than a single pass. Tests (3 new, 157 total): a full in-process CLI run (init idempotent, account, gate, profile, import preview/commit, template, playbook compile, campaign create/prepare/activate, worker, status, audit verify); help, unknown command and flag, unknown principal and invalid playbook exit codes; gate-open reason required, token format, role validation. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MbDW8kfMcxUuZ3WBtaDKBv --- .github/workflows/outreach-engine.yml | 1 + outreach-engine/eslint.config.js | 2 +- outreach-engine/package-lock.json | 23 +++ outreach-engine/package.json | 3 +- .../packages/outreach-cli/package.json | 51 +++++ .../packages/outreach-cli/src/bin.ts | 11 ++ .../packages/outreach-cli/src/cli.test.ts | 101 ++++++++++ .../outreach-cli/src/commands/campaigns.ts | 184 ++++++++++++++++++ .../outreach-cli/src/commands/content.ts | 104 ++++++++++ .../outreach-cli/src/commands/serve.ts | 57 ++++++ .../outreach-cli/src/commands/setup.ts | 164 ++++++++++++++++ .../outreach-cli/src/commands/types.ts | 43 ++++ .../packages/outreach-cli/src/index.ts | 3 + .../packages/outreach-cli/src/main.ts | 105 ++++++++++ .../packages/outreach-cli/src/output.ts | 28 +++ .../packages/outreach-cli/src/runtime.ts | 101 ++++++++++ .../packages/outreach-cli/tsconfig.json | 5 + .../packages/outreach-cli/tsup.config.ts | 22 +++ .../packages/outreach-fakes/src/fake-email.ts | 12 +- outreach-engine/scripts/soak.mjs | 96 +++++++++ 20 files changed, 1109 insertions(+), 7 deletions(-) create mode 100644 outreach-engine/packages/outreach-cli/package.json create mode 100644 outreach-engine/packages/outreach-cli/src/bin.ts create mode 100644 outreach-engine/packages/outreach-cli/src/cli.test.ts create mode 100644 outreach-engine/packages/outreach-cli/src/commands/campaigns.ts create mode 100644 outreach-engine/packages/outreach-cli/src/commands/content.ts create mode 100644 outreach-engine/packages/outreach-cli/src/commands/serve.ts create mode 100644 outreach-engine/packages/outreach-cli/src/commands/setup.ts create mode 100644 outreach-engine/packages/outreach-cli/src/commands/types.ts create mode 100644 outreach-engine/packages/outreach-cli/src/index.ts create mode 100644 outreach-engine/packages/outreach-cli/src/main.ts create mode 100644 outreach-engine/packages/outreach-cli/src/output.ts create mode 100644 outreach-engine/packages/outreach-cli/src/runtime.ts create mode 100644 outreach-engine/packages/outreach-cli/tsconfig.json create mode 100644 outreach-engine/packages/outreach-cli/tsup.config.ts create mode 100644 outreach-engine/scripts/soak.mjs diff --git a/.github/workflows/outreach-engine.yml b/.github/workflows/outreach-engine.yml index 4ccf708..4a1a01d 100644 --- a/.github/workflows/outreach-engine.yml +++ b/.github/workflows/outreach-engine.yml @@ -41,3 +41,4 @@ jobs: - run: npm run boundaries - run: npm run secrets - run: npm run loc + - run: npm run soak diff --git a/outreach-engine/eslint.config.js b/outreach-engine/eslint.config.js index c13ce37..aa7d197 100644 --- a/outreach-engine/eslint.config.js +++ b/outreach-engine/eslint.config.js @@ -8,7 +8,7 @@ export default tseslint.config( { files: ['scripts/**/*.mjs'], languageOptions: { - globals: { process: 'readonly', console: 'readonly', URL: 'readonly' }, + globals: { process: 'readonly', console: 'readonly', URL: 'readonly', performance: 'readonly' }, }, }, { diff --git a/outreach-engine/package-lock.json b/outreach-engine/package-lock.json index b2b1110..2a2c6ad 100644 --- a/outreach-engine/package-lock.json +++ b/outreach-engine/package-lock.json @@ -1137,6 +1137,10 @@ "win32" ] }, + "node_modules/@splitin/outreach-cli": { + "resolved": "packages/outreach-cli", + "link": true + }, "node_modules/@splitin/outreach-contracts": { "resolved": "packages/outreach-contracts", "link": true @@ -4555,6 +4559,25 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "packages/outreach-cli": { + "name": "@splitin/outreach-cli", + "version": "0.0.0", + "license": "MIT", + "dependencies": { + "@splitin/outreach-contracts": "0.0.0", + "@splitin/outreach-core": "0.0.0", + "@splitin/outreach-fakes": "0.0.0", + "@splitin/outreach-import": "0.0.0", + "@splitin/outreach-server": "0.0.0", + "@splitin/outreach-store-sqlite": "0.0.0" + }, + "bin": { + "outreach": "dist/bin.js" + }, + "engines": { + "node": ">=22.13" + } + }, "packages/outreach-contracts": { "name": "@splitin/outreach-contracts", "version": "0.0.0", diff --git a/outreach-engine/package.json b/outreach-engine/package.json index d298079..9a238ff 100644 --- a/outreach-engine/package.json +++ b/outreach-engine/package.json @@ -22,7 +22,8 @@ "boundaries": "node scripts/check-package-boundaries.mjs", "secrets": "node scripts/scan-secrets.mjs", "loc": "node scripts/check-max-lines.mjs", - "check": "npm run lint && npm run typecheck && npm test && npm run boundaries && npm run secrets && npm run loc" + "check": "npm run lint && npm run typecheck && npm test && npm run boundaries && npm run secrets && npm run loc", + "soak": "node scripts/soak.mjs" }, "devDependencies": { "@eslint/js": "^9.39.5", diff --git a/outreach-engine/packages/outreach-cli/package.json b/outreach-engine/packages/outreach-cli/package.json new file mode 100644 index 0000000..c2f5c16 --- /dev/null +++ b/outreach-engine/packages/outreach-cli/package.json @@ -0,0 +1,51 @@ +{ + "name": "@splitin/outreach-cli", + "version": "0.0.0", + "description": "The outreach command line: setup, imports, campaigns, approvals, tasks, review, worker and HTTP server.", + "license": "MIT", + "author": "SplitInTech", + "homepage": "https://github.com/splitintech/open-internal-tools/tree/main/outreach-engine#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/splitintech/open-internal-tools.git", + "directory": "outreach-engine/packages/outreach-cli" + }, + "type": "module", + "sideEffects": false, + "engines": { + "node": ">=22.13" + }, + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "files": [ + "dist", + "README.md", + "package.json" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsup", + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@splitin/outreach-contracts": "0.0.0", + "@splitin/outreach-core": "0.0.0", + "@splitin/outreach-import": "0.0.0", + "@splitin/outreach-store-sqlite": "0.0.0", + "@splitin/outreach-server": "0.0.0", + "@splitin/outreach-fakes": "0.0.0" + }, + "bin": { + "outreach": "./dist/bin.js" + } +} diff --git a/outreach-engine/packages/outreach-cli/src/bin.ts b/outreach-engine/packages/outreach-cli/src/bin.ts new file mode 100644 index 0000000..b39c94d --- /dev/null +++ b/outreach-engine/packages/outreach-cli/src/bin.ts @@ -0,0 +1,11 @@ +import { main } from './main'; + +main(process.argv.slice(2)).then( + (code) => { + process.exitCode = code; + }, + (error: unknown) => { + process.stderr.write(`fatal: ${(error as Error).stack ?? String(error)}\n`); + process.exitCode = 1; + }, +); diff --git a/outreach-engine/packages/outreach-cli/src/cli.test.ts b/outreach-engine/packages/outreach-cli/src/cli.test.ts new file mode 100644 index 0000000..e32128a --- /dev/null +++ b/outreach-engine/packages/outreach-cli/src/cli.test.ts @@ -0,0 +1,101 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { main } from './main'; + +const dirs: string[] = []; +afterEach(() => { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +function workspace() { + const dir = mkdtempSync(join(tmpdir(), 'outreach-cli-')); + dirs.push(dir); + const env = { OUTREACH_PRINCIPAL: 'cli:tester', OUTREACH_DB: join(dir, 'outreach.db'), OUTREACH_WORKSPACE: 'demo' }; + const file = (name: string, content: string) => { + const path = join(dir, name); + writeFileSync(path, content); + return path; + }; + const run = async (...argv: string[]) => { + let stdout = ''; + let stderr = ''; + const code = await main(argv, { env, write: (t) => { stdout += t; }, writeError: (t) => { stderr += t; } }); + return { code, stdout, stderr, json: () => JSON.parse(stdout) as Record }; + }; + return { dir, env, file, run }; +} + +describe('outreach CLI', () => { + it('runs setup -> import -> campaign -> worker -> audit with fake providers', async () => { + const w = workspace(); + expect((await w.run('init', '--fake', '--json')).json()).toMatchObject({ workspace: 'demo', created: true, admin: 'cli:tester' }); + expect((await w.run('init', '--fake', '--json')).json()).toMatchObject({ created: false }); + const account = await w.run('account', 'add', '--fake', '--json', '--provider', 'fake-email', '--external-id', 'hello@example.com', '--sender-name', 'Sam', + '--sender-email', 'hello@example.com', '--org', 'Example Co', '--postal', '1 Example St', '--purposes', 'automated_outreach', '--secret', 'env:MAIL'); + expect(account.code).toBe(0); + const accountId = account.json().providerAccountId as string; + expect((await w.run('gate', 'show', '--json')).json()).toEqual({ mode: 'allowlist', allow: [] }); + expect((await w.run('gate', 'allowlist', '@example.org', '--reason', 'pilot with our own addresses')).code).toBe(0); + + const profile = await w.run('profile', 'add', 'crm', w.file('profile.json', JSON.stringify({ columns: { email: 'Email', full_name: 'Name' }, consent: { basis: 'legitimate_interest' } })), '--json'); + const preview = await w.run('import', 'preview', w.file('leads.csv', 'Name,Email\nAda Lovelace,ada@example.org\nBad,nope\n'), '--profile', profile.json().profileId as string, '--json'); + const previewBody = preview.json(); + expect(previewBody.counts).toMatchObject({ create: 1, reject: 1 }); + const commit = await w.run('import', 'commit', previewBody.batchId as string, '--hash', previewBody.previewHash as string, '--key', 'k1', '--json'); + expect(commit.json()).toMatchObject({ created: 1 }); + + await w.run('template', 'add', 'intro', '--channel', 'email', '--subject', 'Hi {{first_name}}', '--text-file', w.file('intro.txt', 'Hello {{first_name}}')); + const playbook = w.file('playbook.yaml', ` +apiVersion: outreach.splitin.net/v1alpha1 +kind: Playbook +metadata: { name: cli-intro } +spec: + purpose: automated_outreach + policy: { approval: none, unsubscribe: reply, window: { days: [Mon, Tue, Wed, Thu, Fri, Sat, Sun], start: "00:00", end: "23:59" } } + steps: [{ id: intro, type: email.send, template: intro@1 }] +`); + expect((await w.run('playbook', 'compile', playbook, '--account', accountId, '--fake', '--json')).json()).toMatchObject({ issues: [] }); + const created = (await w.run('campaign', 'create', 'CLI', '--playbook', playbook, '--account', accountId, '--fake', '--json')).json(); + const prepared = (await w.run('campaign', 'prepare', created.campaignId as string, '--json')).json(); + expect(prepared).toMatchObject({ audienceCount: 1, requiresApproval: false }); + expect((await w.run('campaign', 'activate', created.campaignId as string, '--hash', prepared.operationHash as string, '--json')).json()).toMatchObject({ enrolled: 1 }); + + const worker = (await w.run('worker', '--once', '--fake', '--json')).json() as { execute: { executed: number } }; + expect(worker.execute.executed).toBe(1); + expect((await w.run('campaign', 'status', created.campaignId as string, '--json')).json()).toMatchObject({ enrollments: { completed: 1 } }); + const audit = await w.run('audit', 'verify', '--json'); + expect(audit.code).toBe(0); + expect(audit.json()).toMatchObject({ ok: true }); + }); + + it('prints help, rejects unknown commands and flags, and reports errors with exit codes', async () => { + const w = workspace(); + const help = await w.run('--help'); + expect(help.code).toBe(0); + expect(help.stdout).toMatch(/campaign activate/); + expect((await w.run('campaign', 'activate', '--help')).stdout).toMatch(/--hash /); + expect((await w.run('launch', 'rockets')).code).toBe(2); + const badFlag = await w.run('campaign', 'list', '--hash', 'x'); + expect(badFlag.code).toBe(2); + expect(badFlag.stderr).toMatch(/unknown option for "campaign list": --hash/); + const noWorkspace = await w.run('campaign', 'list'); + expect(noWorkspace.code).toBe(1); + expect(noWorkspace.stderr).toMatch(/unknown principal cli:tester/); + await w.run('init'); + const invalid = await w.run('playbook', 'compile', w.file('bad.yaml', 'kind: Nope'), '--account', 'x'); + expect(invalid.code).toBe(1); + expect(invalid.stderr).toMatch(/Invalid playbook/); + }); + + it('refuses to open the gate without a written reason and issues tokens once', async () => { + const w = workspace(); + await w.run('init'); + expect((await w.run('gate', 'open', '--reason', 'yolo')).stderr).toMatch(/written reason/); + await w.run('principal', 'add', 'http:dashboard', '--roles', 'viewer'); + const token = (await w.run('token', 'create', 'http:dashboard', '--name', 'dash', '--json')).json(); + expect(token.token).toMatch(/^oet_[0-9A-Z]{26}\.[A-Za-z0-9_-]{43}$/); + expect((await w.run('principal', 'add', 'x', '--roles', 'root')).stderr).toMatch(/unknown roles: root/); + }); +}); diff --git a/outreach-engine/packages/outreach-cli/src/commands/campaigns.ts b/outreach-engine/packages/outreach-cli/src/commands/campaigns.ts new file mode 100644 index 0000000..11833f0 --- /dev/null +++ b/outreach-engine/packages/outreach-cli/src/commands/campaigns.ts @@ -0,0 +1,184 @@ +import { readFileSync } from 'node:fs'; +import { + campaignStatus, + commitActivation, + createCampaign, + decideApproval, + listApprovals, + listCampaigns, + listManualTasks, + listReview, + prepareActivation, + recordManualOutcome, + requestBatchApproval, + resolveReview, + setCampaignStatus, + setKillSwitchAs, + type ApprovalDecision, + type KillSwitchScope, + type ReviewResolution, +} from '@splitin/outreach-core'; +import { arg, flag, required, type Command } from './types'; + +const decide = (decision: 'approved' | 'rejected'): Command => ({ + name: decision === 'approved' ? 'approvals approve' : 'approvals reject', + usage: `outreach approvals ${decision === 'approved' ? 'approve' : 'reject'} --hash [--reason ]`, + summary: decision === 'approved' ? 'Approve exactly the content you reviewed (approver).' : 'Reject; affected actions are cancelled.', + flags: { hash: 'string', reason: 'string' }, + async run({ args, flags, out, runtime }) { + const rt = await runtime(); + const reason = flag(flags, 'reason'); + const row = decideApproval(rt.engine, rt.ctx(), { approvalId: arg(args, 0, 'approval-id'), decision, operationHash: required(flags, 'hash'), ...(reason ? { reason } : {}) }); + out.result({ approvalId: row.id, decision: row.decision }); + }, +}); + +const setStatus = (status: 'paused' | 'active'): Command => ({ + name: status === 'paused' ? 'campaign pause' : 'campaign resume', + usage: `outreach campaign ${status === 'paused' ? 'pause' : 'resume'} --reason `, + summary: status === 'paused' ? 'Hold every pending send of a campaign.' : 'Resume a paused campaign.', + flags: { reason: 'string' }, + async run({ args, flags, out, runtime }) { + const rt = await runtime(); + setCampaignStatus(rt.engine, rt.ctx(), arg(args, 0, 'campaign-id'), status, required(flags, 'reason')); + out.result({ status }); + }, +}); + +const task = (outcome: 'done' | 'skipped'): Command => ({ + name: outcome === 'done' ? 'tasks done' : 'tasks skip', + usage: `outreach tasks ${outcome === 'done' ? 'done' : 'skip'} [--note ]`, + summary: outcome === 'done' ? 'Record that you completed a manual task yourself.' : 'Skip a manual task; the sequence continues.', + flags: { note: 'string' }, + async run({ args, flags, out, runtime }) { + const rt = await runtime(); + recordManualOutcome(rt.engine, rt.ctx(), arg(args, 0, 'task-id'), outcome, flag(flags, 'note')); + out.result({ status: outcome }); + }, +}); + +const kill = (engaged: boolean): Command => ({ + name: engaged ? 'kill engage' : 'kill release', + usage: `outreach kill ${engaged ? 'engage' : 'release'} global|workspace|provider_account|campaign [--target ] --reason `, + summary: engaged ? 'Stop all matching sends now.' : 'Release a kill switch (approver; global needs admin).', + flags: { target: 'string', reason: 'string' }, + async run({ args, flags, out, runtime }) { + const rt = await runtime(); + const scope = arg(args, 0, 'scope') as KillSwitchScope; + const target = flag(flags, 'target'); + setKillSwitchAs(rt.engine, rt.ctx(), { scope, engaged, reason: required(flags, 'reason'), ...(target ? { targetId: target } : {}) }); + out.result({ scope, engaged }); + }, +}); + +export const campaignCommands: Command[] = [ + { + name: 'campaign create', + usage: 'outreach campaign create --playbook --account ', + summary: 'Validate a playbook and create a draft campaign.', + flags: { playbook: 'string', account: 'string' }, + async run({ args, flags, out, runtime }) { + const rt = await runtime(); + out.result(createCampaign(rt.engine, rt.ctx(), { name: arg(args, 0, 'name'), playbook: readFileSync(required(flags, 'playbook'), 'utf8'), providerAccountId: required(flags, 'account') })); + }, + }, + { + name: 'campaign list', + usage: 'outreach campaign list', + summary: 'List campaigns.', + async run({ out, runtime }) { + const rt = await runtime(); + out.result(listCampaigns(rt.engine, rt.ctx())); + }, + }, + { + name: 'campaign status', + usage: 'outreach campaign status ', + summary: 'Enrollment and action counts, open tasks.', + async run({ args, out, runtime }) { + const rt = await runtime(); + out.result(campaignStatus(rt.engine, rt.ctx(), arg(args, 0, 'campaign-id'))); + }, + }, + { + name: 'campaign prepare', + usage: 'outreach campaign prepare ', + summary: 'Snapshot the audience and show the exact activation hash (and approval, if required).', + async run({ args, out, runtime }) { + const rt = await runtime(); + const preview = prepareActivation(rt.engine, rt.ctx(), arg(args, 0, 'campaign-id')); + out.result(preview); + out.line(`\nActivate with: outreach campaign activate ${arg(args, 0, 'campaign-id')} --hash ${preview.operationHash}`); + }, + }, + { + name: 'campaign activate', + usage: 'outreach campaign activate --hash ', + summary: 'Enroll exactly the prepared (and approved) audience.', + flags: { hash: 'string' }, + async run({ args, flags, out, runtime }) { + const rt = await runtime(); + out.result(commitActivation(rt.engine, rt.ctx(), { campaignId: arg(args, 0, 'campaign-id'), operationHash: required(flags, 'hash') })); + }, + }, + setStatus('paused'), + setStatus('active'), + { + name: 'approvals list', + usage: 'outreach approvals list [--decision pending|approved|rejected|revoked|expired]', + summary: 'List approvals with their exact previews.', + flags: { decision: 'string' }, + async run({ flags, out, runtime }) { + const rt = await runtime(); + const rows = listApprovals(rt.db, rt.ctx(), (flag(flags, 'decision') ?? 'pending') as ApprovalDecision); + out.result(rows.map((row) => ({ id: row.id, scope: row.scope, operationHash: row.operation_hash, expiresAt: new Date(row.expires_at).toISOString(), preview: JSON.parse(row.preview) as unknown }))); + }, + }, + decide('approved'), + decide('rejected'), + { + name: 'approvals batch', + usage: 'outreach approvals batch ', + summary: 'Gather actions waiting without a live approval into one new batch.', + async run({ args, out, runtime }) { + const rt = await runtime(); + out.result(requestBatchApproval(rt.engine, rt.ctx(), arg(args, 0, 'campaign-id')) ?? { count: 0 }); + }, + }, + { + name: 'tasks list', + usage: 'outreach tasks list', + summary: 'Open manual tasks (e.g. social touches you do yourself).', + async run({ out, runtime }) { + const rt = await runtime(); + out.result(listManualTasks(rt.engine, rt.ctx()).map((t) => ({ id: t.id, channel: t.channel, target: t.target_url, draft: t.draft_text }))); + }, + }, + task('done'), + task('skipped'), + { + name: 'review list', + usage: 'outreach review list', + summary: 'Actions a human must decide (uncertain sends, gated recipients, unsupported capabilities).', + async run({ out, runtime }) { + const rt = await runtime(); + out.result(listReview(rt.engine, rt.ctx()).map((a) => ({ id: a.id, kind: a.kind, recipient: a.recipient_norm, reason: a.state_reason, attempts: a.attempt_count }))); + }, + }, + { + name: 'review resolve', + usage: 'outreach review resolve --as sent|not_sent|drop [--provider-id ] [--reason ]', + summary: 'Record what really happened to a review item.', + flags: { as: 'string', 'provider-id': 'string', reason: 'string' }, + async run({ args, flags, out, runtime }) { + const rt = await runtime(); + const as = required(flags, 'as'); + const resolution: ReviewResolution = + as === 'sent' ? { kind: 'sent', providerMessageId: required(flags, 'provider-id') } : as === 'not_sent' ? { kind: 'not_sent_retry' } : { kind: 'drop', reason: required(flags, 'reason') }; + resolveReview(rt.engine, rt.ctx(), arg(args, 0, 'action-id'), resolution); + out.result({ resolved: as }); + }, + }, + kill(true), + kill(false), +]; diff --git a/outreach-engine/packages/outreach-cli/src/commands/content.ts b/outreach-engine/packages/outreach-cli/src/commands/content.ts new file mode 100644 index 0000000..86094da --- /dev/null +++ b/outreach-engine/packages/outreach-cli/src/commands/content.ts @@ -0,0 +1,104 @@ +import { readFileSync } from 'node:fs'; +import { basename } from 'node:path'; +import { compileIssues, createTemplate, parsePlaybook, requireRole, suppress, type SuppressionReason, type SuppressionScope } from '@splitin/outreach-core'; +import { commitImport, previewImport, saveMappingProfile } from '@splitin/outreach-import'; +import { arg, flag, required, type Command } from './types'; + +export const contentCommands: Command[] = [ + { + name: 'profile add', + usage: 'outreach profile add ', + summary: 'Store a new version of an import mapping profile.', + async run({ args, out, runtime }) { + const rt = await runtime(); + const ctx = rt.ctx(); + requireRole(ctx, 'operator'); + const spec = JSON.parse(readFileSync(arg(args, 1, 'profile.json'), 'utf8')) as unknown; + const saved = rt.db.transaction(() => saveMappingProfile(rt.db, ctx.workspaceId, arg(args, 0, 'name'), spec, rt.engine.now())); + out.result({ profileId: saved.id, name: saved.name, version: saved.version }); + }, + }, + { + name: 'import preview', + usage: 'outreach import preview --profile ', + summary: 'Parse, validate and stage a lead file. Creates no contacts.', + flags: { profile: 'string' }, + async run({ args, flags, out, runtime }) { + const rt = await runtime(); + const ctx = rt.ctx(); + requireRole(ctx, 'operator'); + const file = arg(args, 0, 'file'); + const preview = await previewImport(rt.db, { workspaceId: ctx.workspaceId, principalId: ctx.principalId, source: 'cli', traceId: ctx.traceId }, { + fileName: basename(file), + bytes: readFileSync(file), + profileId: required(flags, 'profile'), + now: rt.engine.now(), + }); + out.result(preview); + out.line(`\nCommit with: outreach import commit ${preview.batchId} --hash ${preview.previewHash} --key `); + }, + }, + { + name: 'import commit', + usage: 'outreach import commit --hash --key ', + summary: 'Create or update exactly the previewed contacts. Never enrolls or sends.', + flags: { hash: 'string', key: 'string' }, + async run({ args, flags, out, runtime }) { + const rt = await runtime(); + const ctx = rt.ctx(); + requireRole(ctx, 'operator'); + out.result(commitImport(rt.db, { workspaceId: ctx.workspaceId, principalId: ctx.principalId, source: 'cli', traceId: ctx.traceId }, { + batchId: arg(args, 0, 'batch-id'), + previewHash: required(flags, 'hash'), + idempotencyKey: required(flags, 'key'), + now: rt.engine.now(), + })); + }, + }, + { + name: 'template add', + usage: 'outreach template add --channel email| --text-file [--subject ] [--html-file ]', + summary: 'Create the next immutable version of a template.', + flags: { channel: 'string', subject: 'string', 'text-file': 'string', 'html-file': 'string' }, + async run({ args, flags, out, runtime }) { + const rt = await runtime(); + const html = flag(flags, 'html-file'); + const subject = flag(flags, 'subject'); + const row = createTemplate(rt.db, rt.ctx(), { + name: arg(args, 0, 'name'), + channel: required(flags, 'channel'), + text: readFileSync(required(flags, 'text-file'), 'utf8'), + ...(subject ? { subject } : {}), + ...(html ? { html: readFileSync(html, 'utf8') } : {}), + }, rt.engine.now()); + out.result({ template: `${row.name}@${row.version}`, tokens: JSON.parse(row.required_tokens) as string[] }); + }, + }, + { + name: 'playbook compile', + usage: 'outreach playbook compile --account ', + summary: 'Validate a playbook against this workspace without creating anything.', + flags: { account: 'string' }, + async run({ args, flags, out, runtime }) { + const rt = await runtime(); + const ctx = rt.ctx(); + const playbook = parsePlaybook(readFileSync(arg(args, 0, 'playbook.yaml'), 'utf8')); + const issues = compileIssues(rt.engine, ctx.workspaceId, playbook, required(flags, 'account')); + out.result({ name: playbook.metadata.name, steps: playbook.spec.steps.length, issues }); + return issues.length ? 1 : 0; + }, + }, + { + name: 'suppress', + usage: 'outreach suppress --reason manual|do_not_contact|legal|opt_out [--scope global|channel|domain]', + summary: 'Never contact this address or domain again.', + flags: { reason: 'string', scope: 'string' }, + async run({ args, flags, out, runtime }) { + const rt = await runtime(); + const value = arg(args, 0, 'email|domain'); + const scope = (flag(flags, 'scope') ?? (value.includes('@') ? 'global' : 'domain')) as SuppressionScope; + suppress(rt.engine, rt.ctx(), { scope, value, reason: required(flags, 'reason') as SuppressionReason, ...(scope === 'channel' ? { channel: 'email' } : {}) }); + out.result({ suppressed: value, scope }); + }, + }, +]; diff --git a/outreach-engine/packages/outreach-cli/src/commands/serve.ts b/outreach-engine/packages/outreach-cli/src/commands/serve.ts new file mode 100644 index 0000000..2a196a5 --- /dev/null +++ b/outreach-engine/packages/outreach-cli/src/commands/serve.ts @@ -0,0 +1,57 @@ +import { createApp, startServer } from '@splitin/outreach-server'; +import { flag, type Command } from './types'; + +function parseInterval(value: string | undefined): number { + const match = /^(\d+)(ms|s|m)?$/.exec(value ?? '60s'); + if (!match) throw new Error('--interval must look like 500ms, 30s or 2m'); + const amount = Number(match[1]); + return match[2] === 'ms' ? amount : match[2] === 'm' ? amount * 60_000 : amount * 1_000; +} + +export const runtimeCommands: Command[] = [ + { + name: 'worker', + usage: 'outreach worker [--once | --loop [--interval 60s]]', + summary: 'Poll mailboxes, apply inbound events, reconcile and send due actions. Safe to run concurrently.', + flags: { once: 'boolean', loop: 'boolean', interval: 'string' }, + async run({ flags, out, runtime }) { + const rt = await runtime(); + if (!flags.loop) { + const report = await rt.engine.runOnce(); + out.result(report); + return; + } + const interval = parseInterval(flag(flags, 'interval')); + let stopping = false; + const stop = () => { + stopping = true; + }; + process.once('SIGINT', stop); + process.once('SIGTERM', stop); + out.line(`worker ${rt.engine.exec.workerId} running every ${interval} ms; Ctrl+C finishes the current pass and exits`); + while (!stopping) { + const report = await rt.engine.runOnce(); + if (report.execute.claimed || report.inbound.processed || report.reconcile.found + report.reconcile.absent) out.result({ at: new Date().toISOString(), ...report }); + const until = Date.now() + interval; + while (!stopping && Date.now() < until) await new Promise((resolve) => setTimeout(resolve, Math.min(250, until - Date.now()))); + } + }, + }, + { + name: 'serve', + usage: 'outreach serve [--port 8787] [--host 127.0.0.1] [--public]', + summary: 'Run the HTTP API, webhook ingress and unsubscribe endpoint (loopback unless --public).', + flags: { port: 'string', host: 'string', public: 'boolean' }, + async run({ flags, out, runtime }) { + const rt = await runtime(); + const host = flag(flags, 'host'); + const { url, server } = await startServer(createApp({ engine: rt.engine }), { port: Number(flag(flags, 'port') ?? 8787), ...(host ? { host } : {}), allowPublic: flags.public === true }); + out.line(`outreach API listening on ${url}`); + await new Promise((resolve) => { + const close = () => server.close(() => resolve()); + process.once('SIGINT', close); + process.once('SIGTERM', close); + }); + }, + }, +]; diff --git a/outreach-engine/packages/outreach-cli/src/commands/setup.ts b/outreach-engine/packages/outreach-cli/src/commands/setup.ts new file mode 100644 index 0000000..ff31943 --- /dev/null +++ b/outreach-engine/packages/outreach-cli/src/commands/setup.ts @@ -0,0 +1,164 @@ +import { PROVIDER_PURPOSES, verifyAuditChain, type ProviderPurpose } from '@splitin/outreach-contracts'; +import { + ROLES, + addPrincipal, + bootstrapWorkspace, + configureNotifications, + createApiToken, + readSendGate, + registerProviderAccount, + revokeApiToken, + setSendGate, + type Role, +} from '@splitin/outreach-core'; +import { principalRef } from '../runtime'; +import { arg, flag, required, type Command } from './types'; + +function parseRoles(value: string): Role[] { + const roles = value.split(',').map((role) => role.trim()); + const bad = roles.filter((role) => !(ROLES as readonly string[]).includes(role)); + if (bad.length) throw new Error(`unknown roles: ${bad.join(', ')} (use ${ROLES.join(', ')})`); + return roles as Role[]; +} + +function parsePurposes(value: string): ProviderPurpose[] { + const purposes = value.split(',').map((purpose) => purpose.trim()); + const bad = purposes.filter((purpose) => !(PROVIDER_PURPOSES as readonly string[]).includes(purpose)); + if (bad.length) throw new Error(`unknown purposes: ${bad.join(', ')} (use ${PROVIDER_PURPOSES.join(', ')})`); + return purposes as ProviderPurpose[]; +} + +export const setupCommands: Command[] = [ + { + name: 'init', + usage: 'outreach init [--name ]', + summary: 'Create the database and workspace; you become its admin.', + flags: { name: 'string' }, + async run({ options, flags, out, runtime, env }) { + const rt = await runtime(); + const exists = rt.db.prepare('SELECT 1 FROM workspaces WHERE id = ?').get(options.workspace); + if (exists) { + out.result({ workspace: options.workspace, created: false }); + return; + } + bootstrapWorkspace(rt.db, { workspaceId: options.workspace, name: flag(flags, 'name') ?? options.workspace, adminRef: principalRef(env), adminName: principalRef(env) }, rt.engine.now()); + out.result({ workspace: options.workspace, created: true, admin: principalRef(env), sendGate: 'closed (empty allowlist)' }); + }, + }, + { + name: 'principal add', + usage: 'outreach principal add --roles operator[,approver] [--name ]', + summary: 'Register a person or integration (e.g. slack:T1:U2, http:dashboard).', + flags: { roles: 'string', name: 'string' }, + async run({ args, flags, out, runtime }) { + const rt = await runtime(); + const ref = arg(args, 0, 'external-ref'); + const id = addPrincipal(rt.engine, rt.ctx(), { externalRef: ref, displayName: flag(flags, 'name') ?? ref, roles: parseRoles(required(flags, 'roles')) }); + out.result({ principalId: id, externalRef: ref }); + }, + }, + { + name: 'token create', + usage: 'outreach token create --name