diff --git a/.github/workflows/outreach-engine.yml b/.github/workflows/outreach-engine.yml new file mode 100644 index 0000000..4a1a01d --- /dev/null +++ b/.github/workflows/outreach-engine.yml @@ -0,0 +1,44 @@ +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 + - run: npm run soak 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 new file mode 100644 index 0000000..f3bc5f5 --- /dev/null +++ b/outreach-engine/BUILD_PLAN.md @@ -0,0 +1,1041 @@ +# Outreach Engine — Build Plan + +- Status: approved spec; implementation progress is tracked per milestone in §14 +- 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.config.ts + eslint.config.js + packages/ + 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 + 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) + 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 | +|---|---| +| Own SQLite file (not a registry database) | `~/Papr/outreach/outreach.db`. Papr's synced registry databases block raw multi-statement writes, so the engine keeps its own file (ADR 0004). | +| `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. Done: the findings are in ADR 0004 (`docs/adr/0004-papr-host-integration.md`). + +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 the workspace allowlist (exact addresses or domains). This is enforced in preflight, not in the UI. The gate cannot open until a signed-off jurisdiction policy exists (D5). + +--- + +## 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/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.** | +| 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 | Mailbox adapters per D1 (Gmail, then Graph, then Zoho Mail) behind an `AccessTokenSource` port + conformance against a test mailbox; Papr unsubscribe app (D3); Slack notifier (Socket Mode); the Slack control app lands separately in `slack-agent-hq`. Done: jurisdiction policy (D5); Gmail and Outlook adapters + `outreach account connect gmail|outlook` + `file:` secrets (with write-back for rotated tokens); mailto List-Unsubscribe (D3); account health checker (§6.5); Slack notifier. Remaining: Zoho Mail adapter, Slack control app (separate PR 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 | Mailbox adapters per D1 (Gmail, Graph, Zoho Mail); each adapter declares `automated_outreach` only after a review of that provider's terms, otherwise `manual_correspondence`. Zoho Campaigns excluded for cold outreach. | +| 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. Decisions + +Principle (2026-09-28): **build on what Papr Work already provides wherever its guarantees are enough, and own only what they are not.** Evidence for every Papr claim below: `Papr-ai/paprwork@faf6de5`, read-only review. + +| ID | Decision | Status | Blocks | +|---|---|---|---| +| D1 | **Mailbox adapters: Gmail API, then Microsoft Graph (Outlook), then Zoho Mail.** Send as the rep, from a secondary warmed domain, 30-50 cold emails per inbox per day. Replies are polled from the same mailbox; "did it send?" reconciliation searches Sent by `Message-ID`. Graph sends via draft-then-send so a message id exists. **Zoho Campaigns is rejected for cold outreach:** its anti-spam policy requires permission-based lists (fine for opted-in audiences only). Each adapter declares `automated_outreach` only after a review of that provider's acceptable-use terms; otherwise it is limited to `manual_correspondence`. Adapters take access tokens from an `AccessTokenSource` port so the grant can later come from Papr's planned server-side connectors (`docs/CONNECTORS_PLUGINS_ROADMAP.md`, not shipped; Papr removed desktop Google OAuth in 2026-09). Until then: our own Google Cloud app, user type **Internal** to the Workspace (no Google verification). | Decided | M8 | +| D2 | **Worker runs as a Papr job, `local-only`.** Papr's cloud scheduler is live (`SYNC_V3_DISPATCH_PUSH`; `cloud-preferred` jobs run in a cloud sandbox), but the engine cannot use it yet: a cloud job's durable storage is Papr's synced databases, whose atomic `write-batch` appends to a workspace log and reports `changes: 1` per statement without executing guards (`TursoDbAdapter.ts`), at most 25 statements, no reads inside. The engine's claim step needs to know whether its guarded `UPDATE` matched; without that, at-most-once (ADR 0002) cannot hold. Cloud placement becomes possible if Papr confirms single-flight per job with fencing and read-after-write on the log (Appendix A, Q3), or exposes a transactional endpoint. A standalone `worker --loop` remains the option for teams that need sending while the Mac sleeps. | Decided for pilot; cloud pending Papr | Pilot | +| D3 | **No tunnel or VM.** Inbound is mailbox polling (D1). Unsubscribe (built): every message carries `List-Unsubscribe: `, so the mailbox provider's own Unsubscribe button mails the polled sending mailbox and the classifier applies an opt-out (suppression is by address, so it holds even without thread correlation); `List-Unsubscribe-Post` is only emitted when the link is the engine's own POST endpoint (`UnsubscribeConfig.oneClick`). Optional later: a footer link served by a small public Papr app on apps.papr.ai whose backend action writes one `INSERT` (token only) into a Papr database; the engine reads that table and applies each token after verifying its HMAC, so forged rows are inert. RFC 8058 machine one-click (`List-Unsubscribe-Post`) needs Papr to pass query parameters to backend actions (Appendix A, Q4); Google only mandates it above 5,000 messages/day to Gmail. Slack uses Socket Mode (no public URL). | Decided; unsubscribe app to verify on a live install | M8 | +| D4 | **Identity: Papr's Auth0 tenant.** Papr login (desktop and apps.papr.ai) is Auth0 PKCE. Papr has no MCP server or MCP client in this repo (an MCP bridge is optional milestone 4B, not built), so on Papr the agent path is the skill + CLI + backend actions already shipped in M7. Remote MCP (M9 stage 2, for Claude/ChatGPT clients) validates JWTs from Papr's Auth0 tenant if Papr registers the API (Appendix A, Q2); otherwise a separate Auth0 tenant. Stage 1 (stdio) needs neither. | Decided; stage 2 pending Papr | M9 stage 2 | +| D5 | **Per-country rules with recorded sign-off, enforced by the engine** (Papr has no compliance code). Built: `outreach jurisdiction set` records `allow` / `consent_required` / `block` per ISO country plus rules for unlisted and unknown countries, with who signed it off and where; admin-only and audited. Enforced at activation (excluded from the audience) and again before every send (cancelled, enrollment stopped). The live-send gate cannot open without it. Imports take a per-row `country` column (ISO code or English name). Which countries and message class the first campaign uses is still Legal's call. | Mechanism built; policy content pending Legal | Pilot | + +--- + +## Appendix A — Upstream issue draft (U2) + +> **Building an outreach app on Papr Work: four questions** +> +> We are building an MIT-licensed outreach engine (`splitintech/open-internal-tools/outreach-engine`) as a Papr app: versioned sequences, a durable action queue with leases and explicit "uncertain" states (no blind resends after timeouts), reply/bounce/opt-out stop, approvals bound to exact content. It ships a mini-app console (backend actions, server-side keys), a worker job and an agent skill. We want to use Papr's primitives rather than duplicate them, and need four answers: +> +> 1. **Connectors:** will the planned server-side connectors (CONNECTORS_PLUGINS_ROADMAP.md) expose Gmail and Microsoft Graph access tokens to apps and jobs, and roughly when? +> 2. **Identity:** can a third-party API (a remote MCP server) accept access tokens from Papr's Auth0 tenant, i.e. would you register it as an API/audience? +> 3. **Cloud jobs:** for `cloud-preferred` jobs, does the scheduler run lease guarantee single-flight per job with fencing, and does a run see all workspace-log writes from the previous run (read-after-write)? Is there, or could there be, a write batch that returns real `changes` and aborts on a failed guard? +> 4. **Public backend actions:** could `/api/app/backend/:action` pass URL query parameters to the handler (needed for RFC 8058 one-click unsubscribe POSTs, which carry the token only in the URL)? +> +> Separately, we found three security issues and will report them privately first. Social-network automation is out of scope. 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 +``` 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..fa4ac50 --- /dev/null +++ b/outreach-engine/README.md @@ -0,0 +1,75 @@ +

+ 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–M7 complete, the D5 jurisdiction policy, the Gmail and Outlook adapters, Slack notifications and the health checker (M8).** Contracts, fakes, SQLite store, the durable execution core, +> the campaign domain, the importer, inbound processing, the `outreach` CLI, the HTTP API +> and the Papr Work console are built and tested on fake providers. Real provider adapters +> and Slack are M8, MCP is 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 + +- **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-e2e` (private) | M6 | Import-to-audit end-to-end suite on fake providers | +| `@splitin/outreach-server` | M7 | HTTP API: bearer tokens, webhooks, one-click unsubscribe | +| `@splitin/outreach-cli` | M7 | The `outreach` command (setup, imports, campaigns, approvals, worker, serve) | +| `apps/papr` | M7 | Papr Work bundle: Outreach Console mini-app, worker job, agent skill | +| `@splitin/outreach-mcp` | M9 | MCP server | +| `@splitin/outreach-provider-email-gmail` | M8 | Gmail adapter: send as the mailbox user, Sent-folder reconciliation, History API inbound, loopback OAuth | +| `@splitin/outreach-provider-email-outlook` | M8 | Outlook / Exchange Online adapter via Microsoft Graph: MIME draft-then-send, Sent Items reconciliation, rotating refresh tokens | +| `@splitin/outreach-provider-kit` | M8 | Shared adapter building blocks: HTTP outcome classification, loopback OAuth (PKCE), DSN parsing | +| `@splitin/outreach-notify-slack` | M8 | Slack notifications: incoming webhook or `chat.postMessage`, escaped Block Kit | +| Zoho Mail adapter | M8 | Remaining | + +## 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/apps/papr/README.md b/outreach-engine/apps/papr/README.md new file mode 100644 index 0000000..e4e30be --- /dev/null +++ b/outreach-engine/apps/papr/README.md @@ -0,0 +1,46 @@ +# Outreach Console for Papr Work + +A Papr Work bundle that gives operators a console for the outreach engine: what goes out in the next 24 hours and what is holding it, approvals with the exact content being approved, the human review queue, manual social touches, campaign health, and one button that stops all sending. + +The engine is **not** inside Papr. It keeps its own SQLite database, because it needs multi-statement transactions that Papr's synced registry databases do not allow (see [ADR 0004](../../docs/adr/0004-papr-host-integration.md)). The console talks to the engine's HTTP API through a server-side backend handler, so the API token never reaches the browser. + +```text +Papr mini-app (app.js) --POST /api/app/backend/:action--> backend/outreach.mjs --Bearer token--> outreach serve (loopback) +Papr job (optional) --node run.mjs--> outreach worker --once \-> outreach.db +``` + +## Contents + +| Path | What it is | +| --- | --- | +| `bundle/manifest.json` | Papr portable bundle manifest (schema 1.0.0) | +| `bundle/apps/outreach-console/` | The mini-app: `index.html`, `app.js`, `style.css` | +| `bundle/apps/outreach-console/backend/` | `manifest.json` + `outreach.mjs`: a fixed allowlist of actions mapped to fixed API routes | +| `bundle/jobs/outreach-worker/` | Optional Papr job running one worker pass per minute (pilot only) | +| `bundle/skills/outreach.md` | Agent rules: draft, validate and report; never approve, activate or send | + +## Setup + +1. Initialise the engine (outside Papr): + ```zsh + export OUTREACH_DB=~/Papr/outreach/outreach.db + outreach init + outreach account add ... # your provider account (decision D1) + outreach principal add papr:console --roles approver + outreach token create papr:console --name console --role approver + outreach serve # loopback only, port 8787 + ``` +2. Import `bundle/` into Papr Work. +3. In **Settings → Integration Keys**, add `OUTREACH_API_URL` (`http://127.0.0.1:8787`) and `OUTREACH_API_TOKEN` (the token from step 1). For a read-only wall display, issue a `--role viewer` token instead. +4. The worker must run somewhere: + - **Production (recommended):** `outreach worker --loop` under launchd or systemd. + - **Pilot:** enable the bundled `outreach-worker` job, set it to **local-only**, and point its `package.json` at a local build of `@splitin/outreach-cli` until the package is published. + +## Acceptance on a live Papr install (not automated here) + +The bundle is validated against a copy of Papr's manifest schema, and the backend handler is tested against a real API server, both in-process and through the `PAPR_ACTION` subprocess contract. The first run on a real Papr install should still confirm: + +- [ ] bundle import succeeds and the app opens; +- [ ] the app id Papr assigns matches `outreach-console`, or pass `?appId=` (Papr apps usually hardcode their id); +- [ ] `/api/app/backend/overview` returns data (keys injected); +- [ ] the worker job installs its dependency and completes a pass. diff --git a/outreach-engine/apps/papr/bundle/apps/outreach-console/app.js b/outreach-engine/apps/papr/bundle/apps/outreach-console/app.js new file mode 100644 index 0000000..39e10d7 --- /dev/null +++ b/outreach-engine/apps/papr/bundle/apps/outreach-console/app.js @@ -0,0 +1,217 @@ +// Outreach Console — Papr mini-app. All data flows through this app's backend handler (backend/outreach.mjs), +// which holds the API token server-side. Every value from the engine is untrusted: rendered with textContent only. +(() => { + const APP_ID = new URLSearchParams(location.search).get('appId') || document.documentElement.dataset.appId || 'outreach-console'; + const $ = (id) => document.getElementById(id); + + async function call(action, params = {}) { + const res = await fetch(`/api/app/backend/${action}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ appId: APP_ID, params }), + }); + const envelope = await res.json().catch(() => ({})); + let result; + try { + result = JSON.parse(envelope.stdout || '{}'); + } catch { + result = { ok: false, error: 'the backend returned no result' }; + } + if (!result.ok) throw new Error(result.error || 'request failed'); + return result.data; + } + + function el(tag, props = {}, ...children) { + const node = document.createElement(tag); + for (const [key, value] of Object.entries(props)) { + if (key === 'class') node.className = value; + else if (key === 'onclick') node.addEventListener('click', value); + else if (key === 'text') node.textContent = value; + else node.setAttribute(key, value); + } + for (const child of children.flat()) if (child) node.append(child); + return node; + } + + function toast(message) { + const node = $('toast'); + node.textContent = message; + node.classList.add('show'); + clearTimeout(toast.timer); + toast.timer = setTimeout(() => node.classList.remove('show'), 2600); + } + + function empty(listId, message) { + const list = $(listId); + list.replaceChildren(el('li', { class: 'empty', text: message })); + } + + /** Runs an action, disables the button meanwhile, reports the outcome, then refreshes. */ + async function act(button, action, params, done) { + button.disabled = true; + try { + await call(action, params); + toast(done); + await refresh(); + } catch (error) { + toast(error.message); + } finally { + button.disabled = false; + } + } + + const HOLDS = { + approval: ['hold', 'needs approval'], + outside_send_window: ['hold', 'outside send window'], + campaign_paused: ['stop', 'campaign paused'], + enrollment_paused: ['stop', 'contact paused'], + recipient_min_gap: ['hold', 'spacing out touches'], + }; + + function chipFor(action) { + const reason = action.waitingOn || ''; + if (action.state === 'executing' || action.state === 'claimed') return el('span', { class: 'chip ready', text: 'sending now' }); + if (!reason) return el('span', { class: 'chip ready', text: 'ready' }); + if (reason.startsWith('kill_switch')) return el('span', { class: 'chip stop', text: 'kill switch' }); + if (reason.startsWith('rate_limit')) return el('span', { class: 'chip hold', text: 'daily limit' }); + if (reason.startsWith('account_')) return el('span', { class: 'chip stop', text: 'account needs attention' }); + const [tone, label] = HOLDS[reason] || ['hold', reason.replace(/_/g, ' ')]; + return el('span', { class: `chip ${tone}`, text: label }); + } + + function renderTimeline(actions) { + if (!actions.length) return empty('timeline', 'Nothing is scheduled to go out in the next 24 hours.'); + const fmt = new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' }); + $('timeline').replaceChildren( + ...actions.map((a) => + el( + 'li', + { class: 'slot' }, + el('time', { datetime: new Date(a.dueAt).toISOString(), text: a.dueAt <= Date.now() ? 'now' : fmt.format(a.dueAt) }), + el('div', {}, el('div', { class: 'who', text: a.recipient || a.kind }, chipFor(a)), el('div', { class: 'what', text: `${a.subject || a.kind} · ${a.campaignName || 'no campaign'}` })), + a.campaignId + ? el('button', { type: 'button', class: 'ghost', onclick: (e) => act(e.currentTarget, 'campaign-pause', { campaignId: a.campaignId, reason: 'paused from Outreach Console' }, 'Campaign paused'), text: 'Pause campaign' }) + : null, + ), + ), + ); + } + + function renderApprovals(approvals) { + if (!approvals.length) return empty('approvals', 'No approvals waiting.'); + $('approvals').replaceChildren( + ...approvals.map((a) => { + const items = (a.preview && a.preview.actions) || []; + const summary = a.scope === 'campaign_version' + ? `Activate "${a.preview.campaign}" for ${a.preview.audienceCount} people` + : `${items.length} message${items.length === 1 ? '' : 's'}`; + return el( + 'li', + { class: 'card' }, + el('strong', { text: summary }), + items.length ? el('pre', { text: items.slice(0, 5).map((i) => `${i.recipient} — ${i.subject || ''}`).join('\n') + (items.length > 5 ? `\n… and ${items.length - 5} more` : '') }) : null, + el('div', { class: 'meta', text: `hash ${a.operation_hash.slice(0, 12)}… · expires ${new Date(a.expires_at).toLocaleString()}` }), + el( + 'div', + { class: 'row' }, + el('button', { type: 'button', class: 'primary', text: 'Approve exactly this', onclick: (e) => act(e.currentTarget, 'approval-decide', { approvalId: a.id, decision: 'approved', operationHash: a.operation_hash }, 'Approved') }), + el('button', { type: 'button', text: 'Reject', onclick: (e) => act(e.currentTarget, 'approval-decide', { approvalId: a.id, decision: 'rejected', operationHash: a.operation_hash, reason: 'rejected in Outreach Console' }, 'Rejected') }), + ), + ); + }), + ); + } + + function renderReview(actions) { + if (!actions.length) return empty('review', 'Nothing needs a human right now.'); + $('review').replaceChildren( + ...actions.map((a) => + el( + 'li', + { class: 'card' }, + el('strong', { text: a.recipient_norm || a.kind }), + el('div', { class: 'meta', text: `${(a.state_reason || '').replace(/_/g, ' ')} · ${a.attempt_count} attempt(s)` }), + el( + 'div', + { class: 'row' }, + el('button', { type: 'button', text: 'It went out', onclick: (e) => { + const id = prompt('Provider message id from your sent folder (required to record it as sent):'); + if (id) act(e.currentTarget, 'review-resolve', { actionId: a.id, kind: 'sent', providerMessageId: id }, 'Recorded as sent'); + } }), + el('button', { type: 'button', text: 'Not sent — send it', onclick: (e) => act(e.currentTarget, 'review-resolve', { actionId: a.id, kind: 'not_sent_retry' }, 'Rescheduled') }), + el('button', { type: 'button', class: 'ghost', text: 'Drop', onclick: (e) => act(e.currentTarget, 'review-resolve', { actionId: a.id, kind: 'drop', reason: 'dropped in Outreach Console' }, 'Dropped') }), + ), + ), + ), + ); + } + + function renderTasks(tasks) { + if (!tasks.length) return empty('tasks', 'No manual touches waiting.'); + $('tasks').replaceChildren( + ...tasks.map((t) => + el( + 'li', + { class: 'card' }, + el('strong', { text: `${t.channel} touch` }), + el('pre', { text: t.draft_text }), + el( + 'div', + { class: 'row' }, + t.target_url && /^https:\/\//.test(t.target_url) ? el('a', { href: t.target_url, target: '_blank', rel: 'noopener noreferrer', text: 'Open profile' }) : null, + el('button', { type: 'button', text: 'Copy draft', onclick: () => navigator.clipboard.writeText(t.draft_text).then(() => toast('Draft copied')) }), + el('button', { type: 'button', class: 'primary', text: 'I did it', onclick: (e) => act(e.currentTarget, 'task-outcome', { taskId: t.id, outcome: 'done' }, 'Recorded') }), + el('button', { type: 'button', class: 'ghost', text: 'Skip', onclick: (e) => act(e.currentTarget, 'task-outcome', { taskId: t.id, outcome: 'skipped' }, 'Skipped') }), + ), + ), + ), + ); + } + + const COLORS = { active: 'var(--accent)', completed: 'var(--ok)', replied: 'var(--ok)', paused: 'var(--hold)', opted_out: 'var(--muted)', bounced: 'var(--stop)', stopped: 'var(--muted)', error: 'var(--stop)' }; + + function renderCampaigns(campaigns, statuses) { + if (!campaigns.length) return empty('campaigns', 'No campaigns yet. Create one with `outreach campaign create`.'); + const byId = new Map(statuses.map((s) => [s.campaignId, s])); + $('campaigns').replaceChildren( + ...campaigns.map((c) => { + const counts = (byId.get(c.id) || {}).enrollments || {}; + const total = Object.values(counts).reduce((sum, n) => sum + n, 0) || 1; + return el( + 'li', + { class: 'campaign' }, + el('strong', { text: c.name }), + el('div', { class: 'meta', text: `${c.status} · ${Object.entries(counts).map(([k, n]) => `${n} ${k.replace('_', ' ')}`).join(' · ') || 'no enrollments'}` }), + el('div', { class: 'bars' }, ...Object.entries(counts).map(([k, n]) => el('span', { style: `width:${(n / total) * 100}%;background:${COLORS[k] || 'var(--muted)'}` }))), + c.status === 'paused' + ? el('button', { type: 'button', text: 'Resume', onclick: (e) => act(e.currentTarget, 'campaign-resume', { campaignId: c.id, reason: 'resumed from Outreach Console' }, 'Resumed') }) + : null, + ); + }), + ); + } + + async function refresh() { + try { + const data = await call('overview'); + renderTimeline(data.upcoming); + renderApprovals(data.approvals); + renderReview(data.review); + renderTasks(data.tasks); + renderCampaigns(data.campaigns, data.statuses); + const waiting = data.approvals.length + data.review.length + data.tasks.length; + $('summary').textContent = `${data.upcoming.length} going out in 24h · ${waiting} waiting on you`; + } catch (error) { + $('summary').textContent = `Cannot reach the outreach engine: ${error.message}. Is \`outreach serve\` running, and are OUTREACH_API_URL / OUTREACH_API_TOKEN set?`; + } + } + + $('refresh').addEventListener('click', refresh); + $('stop-all').addEventListener('click', (e) => { + if (confirm('Stop every send in this workspace now? Resuming needs an approver.')) { + act(e.currentTarget, 'kill-switch', { scope: 'workspace', engaged: 'true', reason: 'stopped from Outreach Console' }, 'All sending stopped'); + } + }); + refresh(); + setInterval(refresh, 30_000); +})(); diff --git a/outreach-engine/apps/papr/bundle/apps/outreach-console/backend/manifest.json b/outreach-engine/apps/papr/bundle/apps/outreach-console/backend/manifest.json new file mode 100644 index 0000000..568c966 --- /dev/null +++ b/outreach-engine/apps/papr/bundle/apps/outreach-console/backend/manifest.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "actions": { + "overview": { "handler": "outreach.mjs", "runtime": "node", "keys": ["OUTREACH_API_URL", "OUTREACH_API_TOKEN"], "timeoutMs": 15000 }, + "approval-decide": { "handler": "outreach.mjs", "runtime": "node", "keys": ["OUTREACH_API_URL", "OUTREACH_API_TOKEN"], "timeoutMs": 15000 }, + "task-outcome": { "handler": "outreach.mjs", "runtime": "node", "keys": ["OUTREACH_API_URL", "OUTREACH_API_TOKEN"], "timeoutMs": 15000 }, + "review-resolve": { "handler": "outreach.mjs", "runtime": "node", "keys": ["OUTREACH_API_URL", "OUTREACH_API_TOKEN"], "timeoutMs": 15000 }, + "campaign-pause": { "handler": "outreach.mjs", "runtime": "node", "keys": ["OUTREACH_API_URL", "OUTREACH_API_TOKEN"], "timeoutMs": 15000 }, + "campaign-resume": { "handler": "outreach.mjs", "runtime": "node", "keys": ["OUTREACH_API_URL", "OUTREACH_API_TOKEN"], "timeoutMs": 15000 }, + "kill-switch": { "handler": "outreach.mjs", "runtime": "node", "keys": ["OUTREACH_API_URL", "OUTREACH_API_TOKEN"], "timeoutMs": 15000 } + } +} diff --git a/outreach-engine/apps/papr/bundle/apps/outreach-console/backend/outreach.mjs b/outreach-engine/apps/papr/bundle/apps/outreach-console/backend/outreach.mjs new file mode 100644 index 0000000..1ba939b --- /dev/null +++ b/outreach-engine/apps/papr/bundle/apps/outreach-console/backend/outreach.mjs @@ -0,0 +1,108 @@ +// Papr backend handler for the Outreach Console (ADR 0004). Node 18+, no dependencies. +// Maps a FIXED set of action names to FIXED engine API routes. It never forwards arbitrary paths or +// methods, and the API token stays server-side (declared as a manifest key, never sent to the browser). + +import { pathToFileURL } from 'node:url'; + +const ID = /^[0-9A-HJKMNP-TV-Z]{26}$/; +const HASH = /^[0-9a-f]{64}$/; + +class BadInput extends Error {} + +function need(params, name, pattern) { + const value = params[name]; + if (typeof value !== 'string' || value === '' || (pattern && !pattern.test(value))) throw new BadInput(`invalid or missing ${name}`); + return value; +} + +function optional(params, name, max = 500) { + const value = params[name]; + if (value === undefined || value === '') return undefined; + if (typeof value !== 'string' || value.length > max) throw new BadInput(`invalid ${name}`); + return value; +} + +async function api(method, path, body) { + const base = (process.env.OUTREACH_API_URL ?? '').replace(/\/+$/, ''); + const token = process.env.OUTREACH_API_TOKEN ?? ''; + if (!base || !token) throw new BadInput('OUTREACH_API_URL and OUTREACH_API_TOKEN must be set in Settings → Integration Keys'); + const url = new URL(path, `${base}/`); + if (!['http:', 'https:'].includes(url.protocol)) throw new BadInput('OUTREACH_API_URL must be http(s)'); + const res = await fetch(url, { + method, + headers: { authorization: `Bearer ${token}`, ...(body ? { 'content-type': 'application/json' } : {}) }, + ...(body ? { body: JSON.stringify(body) } : {}), + signal: AbortSignal.timeout(10_000), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + const error = new Error(data.message ?? `engine answered ${res.status}`); + error.status = res.status; + error.issues = data.issues; + throw error; + } + return data; +} + +const ACTIONS = { + async overview() { + const [campaigns, approvals, review, tasks, upcoming] = await Promise.all([ + api('GET', 'v1/campaigns'), + api('GET', 'v1/approvals'), + api('GET', 'v1/review'), + api('GET', 'v1/tasks'), + api('GET', 'v1/actions/upcoming?hours=24'), + ]); + const live = campaigns.campaigns.filter((c) => c.status === 'active' || c.status === 'paused').slice(0, 20); + const statuses = await Promise.all(live.map((c) => api('GET', `v1/campaigns/${encodeURIComponent(c.id)}`))); + return { campaigns: campaigns.campaigns, statuses, approvals: approvals.approvals, review: review.actions, tasks: tasks.tasks, upcoming: upcoming.actions }; + }, + 'approval-decide': (p) => + api('POST', `v1/approvals/${need(p, 'approvalId', ID)}/decide`, { + decision: need(p, 'decision', /^(approved|rejected)$/), + operationHash: need(p, 'operationHash', HASH), + ...(optional(p, 'reason') ? { reason: optional(p, 'reason') } : {}), + }), + 'task-outcome': (p) => + api('POST', `v1/tasks/${need(p, 'taskId', ID)}/outcome`, { + outcome: need(p, 'outcome', /^(done|skipped)$/), + ...(optional(p, 'note', 1000) ? { note: optional(p, 'note', 1000) } : {}), + }), + 'review-resolve': (p) => { + const kind = need(p, 'kind', /^(sent|not_sent_retry|drop)$/); + const body = kind === 'sent' ? { kind, providerMessageId: need(p, 'providerMessageId') } : kind === 'drop' ? { kind, reason: need(p, 'reason') } : { kind }; + return api('POST', `v1/review/${need(p, 'actionId', ID)}/resolve`, body); + }, + 'campaign-pause': (p) => api('POST', `v1/campaigns/${need(p, 'campaignId', ID)}/pause`, { reason: need(p, 'reason') }), + 'campaign-resume': (p) => api('POST', `v1/campaigns/${need(p, 'campaignId', ID)}/resume`, { reason: need(p, 'reason') }), + 'kill-switch': (p) => + api('POST', 'v1/kill-switches', { + scope: need(p, 'scope', /^(workspace|provider_account|campaign)$/), + engaged: need(p, 'engaged', /^(true|false)$/) === 'true', + reason: need(p, 'reason'), + ...(optional(p, 'targetId') ? { targetId: need(p, 'targetId', ID) } : {}), + }), +}; + +export async function handle(action, params) { + const run = ACTIONS[action]; + if (!run) return { ok: false, error: `unknown action ${action}` }; + try { + return { ok: true, data: await run(params ?? {}) }; + } catch (error) { + return { ok: false, error: error.message, ...(error.status ? { status: error.status } : {}), ...(error.issues ? { issues: error.issues } : {}) }; + } +} + +// Run as a Papr backend action: PAPR_ACTION + PAPR_ACTION_PARAMS in, one JSON line on stdout. +if (process.env.PAPR_ACTION && process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + let params = {}; + try { + params = JSON.parse(process.env.PAPR_ACTION_PARAMS ?? '{}'); + } catch { + params = {}; + } + const result = await handle(process.env.PAPR_ACTION, params); + console.log(JSON.stringify(result)); + process.exitCode = result.ok ? 0 : 1; +} diff --git a/outreach-engine/apps/papr/bundle/apps/outreach-console/index.html b/outreach-engine/apps/papr/bundle/apps/outreach-console/index.html new file mode 100644 index 0000000..63ce52d --- /dev/null +++ b/outreach-engine/apps/papr/bundle/apps/outreach-console/index.html @@ -0,0 +1,55 @@ + + + + + + Outreach Console + + + +
+
+

Outreach

+

Loading…

+
+
+ + +
+
+ +
+
+
+

Next 24 hours

+ Everything that will leave, what is holding it, and the one control that stops it. +
+
    +
    + +
    +
    +

    Waiting for your approval

    +
      +
      +
      +

      Needs a human

      +
        +
        +
        +

        Your manual touches

        +
          +
          +
          + +
          +

          Campaigns

          +
            +
            +
            + +
            + + + + diff --git a/outreach-engine/apps/papr/bundle/apps/outreach-console/style.css b/outreach-engine/apps/papr/bundle/apps/outreach-console/style.css new file mode 100644 index 0000000..4e9a13d --- /dev/null +++ b/outreach-engine/apps/papr/bundle/apps/outreach-console/style.css @@ -0,0 +1,76 @@ +:root { + --bg: #f7f7f5; + --panel: #ffffff; + --ink: #1d1f22; + --muted: #6b7078; + --line: #e6e6e1; + --accent: #2f5bea; + --ok: #1f8a4c; + --hold: #b7791f; + --stop: #c2362b; + --radius: 14px; + color-scheme: light dark; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #111214; + --panel: #1a1b1e; + --ink: #eceef1; + --muted: #9aa0a8; + --line: #2a2c30; + --accent: #7c9cff; + --ok: #4cc38a; + --hold: #e0a84a; + --stop: #ff6b5e; + } +} + +* { box-sizing: border-box; } +body { margin: 0; background: var(--bg); color: var(--ink); font: 14px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; } +h1, h2 { margin: 0; font-weight: 650; letter-spacing: -0.01em; } +h1 { font-size: 22px; } +h2 { font-size: 15px; } +button { font: inherit; border-radius: 999px; padding: 6px 14px; border: 1px solid var(--line); background: var(--panel); color: var(--ink); cursor: pointer; } +button:hover { border-color: var(--muted); } +button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +button:disabled { opacity: 0.5; cursor: progress; } +button.primary { background: var(--accent); border-color: var(--accent); color: #fff; } +button.danger { background: var(--stop); border-color: var(--stop); color: #fff; } +button.ghost { background: transparent; } +.bar { position: sticky; top: 0; z-index: 2; display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 18px 28px; background: color-mix(in srgb, var(--bg) 88%, transparent); backdrop-filter: blur(10px); border-bottom: 1px solid var(--line); } +.bar-actions { display: flex; gap: 8px; } +.sub, .hint { color: var(--muted); margin: 2px 0 0; } +main { padding: 20px 28px 60px; display: grid; gap: 20px; max-width: 1280px; margin: 0 auto; } +.panel { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 18px 20px; } +.panel-head { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; } +.columns { display: grid; gap: 20px; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); } +ul, ol { list-style: none; margin: 0; padding: 0; } + +.timeline { display: grid; gap: 2px; } +.slot { display: grid; grid-template-columns: 72px 1fr auto; gap: 14px; align-items: center; padding: 10px 12px; border-radius: 10px; } +.slot:hover { background: color-mix(in srgb, var(--accent) 6%, transparent); } +.slot time { font-variant-numeric: tabular-nums; color: var(--muted); } +.slot .who { font-weight: 600; } +.slot .what { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.chip { display: inline-block; font-size: 12px; padding: 1px 8px; border-radius: 999px; border: 1px solid currentColor; margin-left: 8px; } +.chip.ready { color: var(--ok); } +.chip.hold { color: var(--hold); } +.chip.stop { color: var(--stop); } + +.cards { display: grid; gap: 10px; } +.card { border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; display: grid; gap: 8px; } +.card .meta { color: var(--muted); font-size: 12px; } +.card .row { display: flex; gap: 8px; flex-wrap: wrap; } +.card pre { margin: 0; white-space: pre-wrap; font: 13px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; background: color-mix(in srgb, var(--line) 40%, transparent); padding: 8px 10px; border-radius: 8px; } +.empty { color: var(--muted); padding: 10px 0; } + +.campaigns { display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); } +.campaign { border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; display: grid; gap: 6px; } +.bars { display: flex; height: 6px; border-radius: 999px; overflow: hidden; background: var(--line); } +.bars span { display: block; } + +.toast { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%) translateY(20px); opacity: 0; transition: all 160ms ease; background: var(--ink); color: var(--bg); padding: 8px 14px; border-radius: 999px; pointer-events: none; } +.toast.show { opacity: 1; transform: translateX(-50%); } + +@media (prefers-reduced-motion: reduce) { .toast { transition: none; } } diff --git a/outreach-engine/apps/papr/bundle/jobs/outreach-worker/code/package.json b/outreach-engine/apps/papr/bundle/jobs/outreach-worker/code/package.json new file mode 100644 index 0000000..b111bb0 --- /dev/null +++ b/outreach-engine/apps/papr/bundle/jobs/outreach-worker/code/package.json @@ -0,0 +1,9 @@ +{ + "name": "outreach-worker-job", + "private": true, + "type": "module", + "description": "Papr Work job: one outreach worker pass per run (ADR 0004). Replace the version with a local path until @splitin/outreach-cli is published.", + "dependencies": { + "@splitin/outreach-cli": "^0.1.0" + } +} diff --git a/outreach-engine/apps/papr/bundle/jobs/outreach-worker/code/run.mjs b/outreach-engine/apps/papr/bundle/jobs/outreach-worker/code/run.mjs new file mode 100644 index 0000000..f99594a --- /dev/null +++ b/outreach-engine/apps/papr/bundle/jobs/outreach-worker/code/run.mjs @@ -0,0 +1,17 @@ +// One worker pass per Papr job run: poll mailboxes, apply replies/opt-outs, reconcile, send what is due. +// The engine keeps its own database outside Papr's registry (ADR 0004). Provider adapters come from +// outreach.config.mjs next to that database; secrets arrive as env vars declared on the job. +import { existsSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { main } from '@splitin/outreach-cli'; + +const home = process.env.OUTREACH_HOME ?? join(process.env.PAPR_HOME ?? join(homedir(), 'Papr'), 'outreach'); +const env = { ...process.env, OUTREACH_DB: process.env.OUTREACH_DB ?? join(home, 'outreach.db') }; +const config = join(home, 'outreach.config.mjs'); +if (!existsSync(env.OUTREACH_DB)) { + console.error(`No outreach database at ${env.OUTREACH_DB}. Run \`outreach init\` first (see the Outreach Console README).`); + process.exit(1); +} +const code = await main(['worker', '--once', '--json', ...(existsSync(config) ? ['--config', config] : [])], { env }); +process.exit(code); diff --git a/outreach-engine/apps/papr/bundle/manifest.json b/outreach-engine/apps/papr/bundle/manifest.json new file mode 100644 index 0000000..fedc3d0 --- /dev/null +++ b/outreach-engine/apps/papr/bundle/manifest.json @@ -0,0 +1,59 @@ +{ + "schemaVersion": "1.0.0", + "bundleId": "bundle-splitin-outreach", + "name": "Outreach Console", + "version": "0.1.0", + "createdAt": "2026-09-28T00:00:00.000Z", + "createdBy": "SplitInTech", + "minPaprworkVersion": "2.6.0", + "description": "Operator console for the MIT outreach engine: what goes out next, approvals, review, manual tasks and kill switches. The engine runs outside Papr's databases (ADR 0004).", + "requirements": [ + { + "name": "OUTREACH_API_URL", + "service": "outreach-engine", + "category": "other", + "description": "Loopback URL of `outreach serve`, e.g. http://127.0.0.1:8787", + "credentialScope": "user", + "clientAccess": "server" + }, + { + "name": "OUTREACH_API_TOKEN", + "service": "outreach-engine", + "category": "other", + "description": "Token from `outreach token create --role approver` (viewer for read-only).", + "credentialScope": "user", + "clientAccess": "server" + } + ], + "platform": ["macos", "windows", "linux"], + "app": { + "id": "outreach-console", + "name": "Outreach Console", + "version": "0.1.0", + "entryFile": "index.html", + "appPath": "apps/outreach-console", + "description": "What leaves the building next, and the one control that stops it." + }, + "jobs": [ + { + "id": "outreach-worker", + "name": "Outreach worker", + "type": "node", + "entryPoint": "code/run.mjs", + "command": "node run.mjs", + "schedule": { "enabled": false, "intervalMs": 60000, "catchUpMissed": false }, + "folder": "outreach", + "appIds": ["outreach-console"], + "env": {} + } + ], + "deploymentProfiles": [ + { + "id": "local-desktop", + "name": "Desktop pilot", + "runtimeTarget": "local", + "environment": {}, + "notes": "Set the worker job to local-only after import; enable its schedule once `outreach init` and a provider account exist." + } + ] +} diff --git a/outreach-engine/apps/papr/bundle/skills/outreach.md b/outreach-engine/apps/papr/bundle/skills/outreach.md new file mode 100644 index 0000000..5d0c502 --- /dev/null +++ b/outreach-engine/apps/papr/bundle/skills/outreach.md @@ -0,0 +1,29 @@ +--- +name: outreach +description: Operate the SplitIn outreach engine safely from Papr Work — drafting, previews and status; never sending on your own. +--- + +# Outreach engine — agent rules + +The outreach engine is a separate, MIT-licensed service (`outreach` CLI + `outreach serve`). It owns its own database; never write to it with sqlite3 and never put its data in a Papr registry database. + +## What you may do + +- **Draft**: write or improve templates and playbooks (YAML) as files for the user to review. Use only the tokens the engine allows: `{{first_name}}`, `{{full_name}}`, `{{title}}`, `{{org_name}}`, `{{org_domain}}`, `{{sender_name}}`, `{{sender_email}}`, `{{sender_org}}`, `{{sender_address}}`, `{{unsubscribe_url}}`, `{{attr.}}`. +- **Validate**: `outreach playbook compile --account ` and `outreach import preview --profile `. Both change nothing. +- **Report**: `outreach campaign status --json`, `outreach approvals list --json`, `outreach review list --json`, `outreach tasks list --json`. +- **Explain** what a pending approval contains (recipients, subjects, content hash) so the human can decide. + +## What you must never do + +- Never run `outreach campaign activate`, `outreach approvals approve`, `outreach import commit`, `outreach gate open`, `outreach jurisdiction set`, `outreach review resolve --as sent`, or `outreach kill release` yourself. These are human decisions; ask the user to run them or to click them in the Outreach Console. +- Never invent contacts or email addresses, and never edit an import file to add people who were not in it. +- Never automate LinkedIn or any social network (connection requests, messages, scraping, browser clicks). Social steps in a playbook are `manual.task` only; the human performs them on the native site and records the outcome. +- Never bypass suppressions, the live-send gate, rate limits or send windows, and never try to "unstick" an uncertain send by re-sending it. Uncertain sends are resolved by reconciliation or by a human in the review queue. + +## When the user asks to "run outbound" + +1. Check there is a provider account and that the purpose fits: `outreach playbook compile`. +2. Preview the import and show the counts and rejected rows. +3. Prepare the campaign (`outreach campaign prepare`) and show the audience count, exclusions and the approval that was requested. +4. Stop. Tell the user exactly which approvals they need to give and where. diff --git a/outreach-engine/apps/papr/package.json b/outreach-engine/apps/papr/package.json new file mode 100644 index 0000000..7f420fd --- /dev/null +++ b/outreach-engine/apps/papr/package.json @@ -0,0 +1,18 @@ +{ + "name": "@splitin/outreach-app-papr", + "version": "0.0.0", + "private": true, + "description": "Papr Work bundle: operator console mini-app, worker job and agent skill for the outreach engine.", + "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-server": "0.0.0", + "@splitin/outreach-store-sqlite": "0.0.0" + } +} diff --git a/outreach-engine/apps/papr/src/bundle.test.ts b/outreach-engine/apps/papr/src/bundle.test.ts new file mode 100644 index 0000000..36fb21f --- /dev/null +++ b/outreach-engine/apps/papr/src/bundle.test.ts @@ -0,0 +1,149 @@ +import { spawnSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { z } from 'zod'; +import { + addContact, + addPrincipal, + authenticate, + bootstrapWorkspace, + commitActivation, + createApiToken, + createCampaign, + createEngine, + createTemplate, + prepareActivation, + registerProviderAccount, +} from '@splitin/outreach-core'; +import { FAKE_EMAIL_SECRET, FakeEmailProvider, staticSecrets } from '@splitin/outreach-fakes'; +import { createApp, startServer } from '@splitin/outreach-server'; +import { openSqliteDatabase } from '@splitin/outreach-store-sqlite'; + +const bundle = resolve(dirname(fileURLToPath(import.meta.url)), '../bundle'); +const read = (path: string) => readFileSync(join(bundle, path), 'utf8'); + +// The subset of Papr Work's BundleManifestSchema (src/core/types/bundles.ts @ v2.6.18) this bundle relies on. +const PaprManifest = z.object({ + schemaVersion: z.literal('1.0.0'), + bundleId: z.string().min(1), + name: z.string().min(1), + version: z.string().min(1), + createdAt: z.string().min(1), + minPaprworkVersion: z.string().min(1), + requirements: z.array(z.object({ name: z.string().min(1), service: z.string().min(1), clientAccess: z.enum(['server', 'client']) })), + app: z.object({ id: z.string().min(1), name: z.string().min(1), version: z.string().min(1), entryFile: z.string().min(1), appPath: z.string().min(1) }), + jobs: z.array(z.object({ id: z.string().min(1), name: z.string().min(1), type: z.enum(['python', 'node', 'swift', 'bash', 'agent']), entryPoint: z.string().optional(), appIds: z.array(z.string()) })), + deploymentProfiles: z.array(z.object({ id: z.string(), name: z.string(), runtimeTarget: z.enum(['local', 'cloud', 'hybrid']) })), +}); + +describe('bundle structure', () => { + const manifest = PaprManifest.parse(JSON.parse(read('manifest.json'))); + + it('matches the Papr manifest contract and points at real files', () => { + expect(existsSync(join(bundle, manifest.app.appPath, manifest.app.entryFile))).toBe(true); + for (const job of manifest.jobs) { + expect(job.appIds).toContain(manifest.app.id); + expect(existsSync(join(bundle, 'jobs', job.id, job.entryPoint ?? ''))).toBe(true); + } + }); + + it('keeps the API token server-side and every backend action wired to a declared key', () => { + const backend = JSON.parse(read(`${manifest.app.appPath}/backend/manifest.json`)) as { version: number; actions: Record }; + const declared = new Set(manifest.requirements.map((r) => r.name)); + expect(manifest.requirements.every((r) => r.clientAccess === 'server')).toBe(true); + for (const [name, action] of Object.entries(backend.actions)) { + expect(action.runtime, name).toBe('node'); + expect(existsSync(join(bundle, manifest.app.appPath, 'backend', action.handler)), name).toBe(true); + for (const key of action.keys) expect(declared.has(key), `${name}:${key}`).toBe(true); + } + // The browser never fetches credentials and never injects HTML; only the handler sees the token. + expect(read(`${manifest.app.appPath}/app.js`)).not.toMatch(/api\/credentials|process\.env|innerHTML|insertAdjacentHTML/); + }); + + it('ships JavaScript that parses', () => { + for (const file of [`${manifest.app.appPath}/app.js`, `${manifest.app.appPath}/backend/outreach.mjs`, 'jobs/outreach-worker/code/run.mjs']) { + expect(spawnSync(process.execPath, ['--check', join(bundle, file)]).status, file).toBe(0); + } + }); +}); + +describe('backend handler against a live engine API', () => { + const PLAYBOOK = ` +apiVersion: outreach.splitin.net/v1alpha1 +kind: Playbook +metadata: { name: papr-demo } +spec: + purpose: automated_outreach + policy: { 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 }] +`; + let url = ''; + let approverToken = ''; + let viewerToken = ''; + let close: () => void = () => {}; + type Handle = (action: string, params?: Record) => Promise<{ ok: boolean; data?: Record; error?: string; status?: number }>; + let handle: Handle; + + beforeAll(async () => { + const db = openSqliteDatabase(':memory:'); + const engine = createEngine({ db, adapters: [new FakeEmailProvider().adapter()], secrets: staticSecrets({ 'env:MAIL': FAKE_EMAIL_SECRET }), workerId: 'papr-test', sendGate: { mode: 'open' } }); + bootstrapWorkspace(db, { workspaceId: 'ws', name: 'W', adminRef: 'cli:admin', adminName: 'Admin' }); + const admin = authenticate(db, 'ws', 'cli:admin', 'cli', 't'); + const approverId = addPrincipal(engine, admin, { externalRef: 'papr:console', displayName: 'Console', roles: ['approver'] }); + approverToken = createApiToken(engine, admin, { principalId: approverId, name: 'console', roleCeiling: 'approver', ttlDays: 7 }).token; + viewerToken = createApiToken(engine, admin, { principalId: approverId, name: 'wall', roleCeiling: 'viewer', ttlDays: 7 }).token; + const account = await registerProviderAccount(engine, admin, { provider: 'fake-email', externalAccountId: 'x', purposes: ['automated_outreach'], secretRef: 'env:MAIL', sender: { name: 'Sam', address: 'sam@example.com', postalAddress: '1 Example St' } }); + createTemplate(db, admin, { name: 'intro', channel: 'email', subject: 'Hi {{first_name}}', text: 'Hello {{first_name}}' }, Date.now()); + addContact(engine, admin, { fullName: 'Ada Lovelace', firstName: 'Ada', email: 'ada@example.org', consentBasis: 'legitimate_interest' }); + const { campaignId } = createCampaign(engine, admin, { name: 'Papr demo', playbook: PLAYBOOK, providerAccountId: account }); + const preview = prepareActivation(engine, admin, campaignId); + // Leave the campaign-version approval pending so the console has something to show. + expect(preview.requiresApproval).toBe(true); + expect(() => commitActivation(engine, admin, { campaignId, operationHash: preview.operationHash })).toThrow(); + const started = await startServer(createApp({ engine }), { port: 0 }); + url = started.url; + close = () => started.server.close(); + ({ handle } = (await import(join(bundle, 'apps/outreach-console/backend/outreach.mjs'))) as { handle: Handle }); + }); + afterAll(() => close()); + + const withEnv = async (token: string, run: () => Promise): Promise => { + process.env.OUTREACH_API_URL = url; + process.env.OUTREACH_API_TOKEN = token; + try { + return await run(); + } finally { + delete process.env.OUTREACH_API_URL; + delete process.env.OUTREACH_API_TOKEN; + } + }; + + it('returns the overview the console renders', async () => { + const result = await withEnv(approverToken, () => handle('overview')); + expect(result.ok).toBe(true); + expect(Object.keys(result.data ?? {}).sort()).toEqual(['approvals', 'campaigns', 'review', 'statuses', 'tasks', 'upcoming']); + expect((result.data as unknown as { approvals: unknown[] }).approvals).toHaveLength(1); + }); + + it('validates inputs before calling the API and allowlists actions', async () => { + expect(await withEnv(approverToken, () => handle('approval-decide', { approvalId: '../../v1/kill-switches', decision: 'approved', operationHash: 'x' }))).toEqual({ ok: false, error: 'invalid or missing approvalId' }); + expect(await withEnv(approverToken, () => handle('delete-everything'))).toEqual({ ok: false, error: 'unknown action delete-everything' }); + expect((await handle('overview')).error).toMatch(/OUTREACH_API_URL and OUTREACH_API_TOKEN/); + }); + + it('passes the engine’s authorization through (viewer tokens cannot stop sending)', async () => { + const result = await withEnv(viewerToken, () => handle('kill-switch', { scope: 'workspace', engaged: 'true', reason: 'test' })); + expect(result).toMatchObject({ ok: false, status: 403 }); + }); + + it('speaks the Papr backend contract as a subprocess (PAPR_ACTION in, one JSON line out)', () => { + const run = spawnSync(process.execPath, [join(bundle, 'apps/outreach-console/backend/outreach.mjs')], { + env: { ...process.env, PAPR_ACTION: 'task-outcome', PAPR_ACTION_PARAMS: JSON.stringify({ taskId: 'bad', outcome: 'done' }), OUTREACH_API_URL: url, OUTREACH_API_TOKEN: approverToken }, + encoding: 'utf8', + }); + expect(run.status).toBe(1); + expect(JSON.parse(run.stdout)).toEqual({ ok: false, error: 'invalid or missing taskId' }); + }); +}); diff --git a/outreach-engine/apps/papr/tsconfig.json b/outreach-engine/apps/papr/tsconfig.json new file mode 100644 index 0000000..585a92d --- /dev/null +++ b/outreach-engine/apps/papr/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist" }, + "include": ["src"] +} 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/docs/adr/0004-papr-host-integration.md b/outreach-engine/docs/adr/0004-papr-host-integration.md new file mode 100644 index 0000000..3bf867e --- /dev/null +++ b/outreach-engine/docs/adr/0004-papr-host-integration.md @@ -0,0 +1,36 @@ +# ADR 0004 — How Papr Work hosts the engine (M7.0 spike) + +- Status: Accepted +- Date: 2026-09-28 +- Evidence: `Papr-ai/paprwork@faf6de5` (v2.6.18), read-only review + +## Questions the spike had to answer (BUILD_PLAN.md §11.5) + +1. How does a Papr job receive its database? +2. Can a `node` job run an npm binary? +3. How do Papr custom keys reach a job? +4. How is local-only placement declared? +5. How does a mini-app reach server-side code? + +## Findings + +1. **Databases.** `CommandJobExecutor` gives every job `JOB_DIR` and a per-job `JOB_DB`, plus `PAPR_DB_` variables for registry databases in `writeDbIds` (`jobAppDatabase.ts`, `jobWriteDatabaseEnv`). Registry databases in replica mode (Turso-synced) are opened read-only; writes must go through the gateway proxy, and `replicaBashSqliteGuard.ts` / `replicaJobScriptGuard.ts` block raw SQLite writes to them. The proxy executes one statement per request, with no multi-statement transactions. +2. **npm binaries.** A `node` job whose `JOB_DIR` has a `package.json` gets `npm install --production` before its first run, so a job can depend on `@splitin/outreach-cli` and run it. +3. **Keys.** Keys are injected as child-process environment variables, only when listed in the job's `requiredKeys` or referenced as `${KEY}` in its command. Keys set to "ask" prompt the user. +4. **Placement.** `executionCapability` is `local-only | local-preferred | cloud-preferred`. App-linked jobs default to `local-preferred`, and the portable bundle `JobSpec` has no field for it, so placement is set after import. +5. **Mini-app to server.** Mini-apps call `POST /api/app/backend/:action`. Handlers live in `apps/{appId}/backend/` with a `manifest.json` declaring `handler`, `runtime` (python, node, typescript) and `keys`. The handler gets `PAPR_ACTION_PARAMS` (string values) and the declared keys as environment variables, and writes its answer to stdout. + +## Decisions + +- **The engine keeps its own SQLite file, not a Papr registry database.** The engine depends on `BEGIN IMMEDIATE` multi-statement transactions (claim, preflight, result, atomic stop). Papr's replica proxy is one statement per request, and raw writes are blocked by design. Default path: `~/Papr/outreach/outreach.db` (`OUTREACH_DB` overrides it). +- **The Papr app is a client of the HTTP API.** The mini-app's backend handler (`outreach.mjs`, Node, no dependencies) calls the engine's loopback API with `OUTREACH_API_URL` and `OUTREACH_API_TOKEN`, declared as keys with `clientAccess: server`. The token never reaches the browser. A read-only dashboard gets a `viewer`-ceiling token; approving needs an `approver` token. +- **Only allowlisted actions.** The handler maps a fixed set of action names to fixed routes; it never forwards arbitrary paths or methods. +- **Papr may host the worker for pilots.** `jobs/outreach-worker` is a `node` job whose `package.json` depends on `@splitin/outreach-cli` and runs `outreach worker --once` every minute. After import, set it to `local-only`: provider secrets and the database are on this machine. +- **Production does not depend on the desktop staying awake** (decision D2): run `outreach worker --loop` and `outreach serve` under launchd/systemd, and use the Papr app only as the operator UI. + +## Consequences + +- No Papr core change is needed, and nothing about the integration depends on upstream review. +- Two things must run for the Papr UI to work: the API (`outreach serve`) and a worker (the Papr job or a service). The app's empty state says so. +- Not yet verified on a live Papr install: bundle import, backend action invocation and the worker job's npm install. The bundle is validated against a copy of Papr's manifest schema and the backend handler is tested against a real API server, but the first run on a real install is still an acceptance step. +- `@splitin/outreach-cli` must be published (M10) before the worker job can install it from npm; until then, point its `package.json` at a local path. diff --git a/outreach-engine/eslint.config.js b/outreach-engine/eslint.config.js new file mode 100644 index 0000000..5c2562a --- /dev/null +++ b/outreach-engine/eslint.config.js @@ -0,0 +1,36 @@ +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', performance: 'readonly' }, + }, + }, + { + // Papr mini-app: runs in the browser. + files: ['apps/papr/bundle/apps/**/app.js'], + languageOptions: { + sourceType: 'script', + globals: { document: 'readonly', location: 'readonly', fetch: 'readonly', navigator: 'readonly', confirm: 'readonly', prompt: 'readonly', URLSearchParams: 'readonly', Intl: 'readonly', setTimeout: 'readonly', clearTimeout: 'readonly', setInterval: 'readonly' }, + }, + }, + { + // Papr backend handlers and jobs: run in Node. + files: ['apps/papr/bundle/**/*.mjs'], + languageOptions: { + globals: { process: 'readonly', console: 'readonly', fetch: 'readonly', URL: 'readonly', AbortSignal: '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..65c865e --- /dev/null +++ b/outreach-engine/package-lock.json @@ -0,0 +1,4770 @@ +{ + "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", + "fast-check": "^4.10.2", + "tsup": "^8.5.1", + "typescript": "^5.9.3", + "typescript-eslint": "^8.70.1", + "vitest": "^3.2.7" + }, + "engines": { + "node": ">=22.13" + } + }, + "apps/papr": { + "name": "@splitin/outreach-app-papr", + "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-server": "0.0.0", + "@splitin/outreach-store-sqlite": "0.0.0" + } + }, + "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/@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/@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", + "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-app-papr": { + "resolved": "apps/papr", + "link": true + }, + "node_modules/@splitin/outreach-cli": { + "resolved": "packages/outreach-cli", + "link": true + }, + "node_modules/@splitin/outreach-contracts": { + "resolved": "packages/outreach-contracts", + "link": true + }, + "node_modules/@splitin/outreach-core": { + "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 + }, + "node_modules/@splitin/outreach-import": { + "resolved": "packages/outreach-import", + "link": true + }, + "node_modules/@splitin/outreach-notify-slack": { + "resolved": "packages/outreach-notify-slack", + "link": true + }, + "node_modules/@splitin/outreach-provider-email-gmail": { + "resolved": "packages/outreach-provider-email-gmail", + "link": true + }, + "node_modules/@splitin/outreach-provider-email-outlook": { + "resolved": "packages/outreach-provider-email-outlook", + "link": true + }, + "node_modules/@splitin/outreach-provider-kit": { + "resolved": "packages/outreach-provider-kit", + "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 + }, + "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/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", + "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/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", + "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/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==", + "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==", + "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", + "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/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", + "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/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==", + "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/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", + "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/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", + "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/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", + "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/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", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "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-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", + "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/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", + "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/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", + "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/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", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "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", + "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", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "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", + "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/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", + "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/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", + "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/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", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "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", + "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/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", + "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/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", + "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.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", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "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", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "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", + "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==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "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", + "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/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", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "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", + "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/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", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "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", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "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", + "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/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", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "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/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", + "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/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", + "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/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", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "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", + "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/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", + "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/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", + "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/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", + "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/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", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "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", + "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/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", + "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", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "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", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "license": "MIT", + "funding": { + "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-provider-email-gmail": "0.0.0", + "@splitin/outreach-provider-email-outlook": "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", + "license": "MIT", + "engines": { + "node": ">=22.13" + } + }, + "packages/outreach-core": { + "name": "@splitin/outreach-core", + "version": "0.0.0", + "license": "MIT", + "dependencies": { + "@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", + "@types/luxon": "^3.7.5" + }, + "engines": { + "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-provider-email-gmail": "0.0.0", + "@splitin/outreach-provider-email-outlook": "0.0.0", + "@splitin/outreach-store-sqlite": "0.0.0" + } + }, + "packages/outreach-fakes": { + "name": "@splitin/outreach-fakes", + "version": "0.0.0", + "license": "MIT", + "dependencies": { + "@splitin/outreach-contracts": "0.0.0" + }, + "engines": { + "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-notify-slack": { + "name": "@splitin/outreach-notify-slack", + "version": "0.0.0", + "license": "MIT", + "dependencies": { + "@splitin/outreach-contracts": "0.0.0", + "@splitin/outreach-provider-kit": "0.0.0" + }, + "engines": { + "node": ">=22.13" + } + }, + "packages/outreach-provider-email-gmail": { + "name": "@splitin/outreach-provider-email-gmail", + "version": "0.0.0", + "license": "MIT", + "dependencies": { + "@splitin/outreach-contracts": "0.0.0", + "@splitin/outreach-provider-kit": "0.0.0" + }, + "devDependencies": { + "@splitin/outreach-fakes": "0.0.0" + }, + "engines": { + "node": ">=22.13" + } + }, + "packages/outreach-provider-email-outlook": { + "name": "@splitin/outreach-provider-email-outlook", + "version": "0.0.0", + "license": "MIT", + "dependencies": { + "@splitin/outreach-contracts": "0.0.0", + "@splitin/outreach-provider-kit": "0.0.0" + }, + "devDependencies": { + "@splitin/outreach-fakes": "0.0.0" + }, + "engines": { + "node": ">=22.13" + } + }, + "packages/outreach-provider-kit": { + "name": "@splitin/outreach-provider-kit", + "version": "0.0.0", + "license": "MIT", + "dependencies": { + "@splitin/outreach-contracts": "0.0.0" + }, + "engines": { + "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", + "license": "MIT", + "dependencies": { + "@splitin/outreach-contracts": "0.0.0" + }, + "engines": { + "node": ">=22.13" + } + } + } +} diff --git a/outreach-engine/package.json b/outreach-engine/package.json new file mode 100644 index 0000000..2bddd7b --- /dev/null +++ b/outreach-engine/package.json @@ -0,0 +1,43 @@ +{ + "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": "node scripts/build.mjs", + "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", + "soak": "node scripts/soak.mjs" + }, + "devDependencies": { + "@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", + "vitest": "^3.2.7" + }, + "overrides": { + "exceljs": { + "uuid": "^11.1.1" + } + } +} diff --git a/outreach-engine/packages/outreach-cli/package.json b/outreach-engine/packages/outreach-cli/package.json new file mode 100644 index 0000000..eb3703d --- /dev/null +++ b/outreach-engine/packages/outreach-cli/package.json @@ -0,0 +1,53 @@ +{ + "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-fakes": "0.0.0", + "@splitin/outreach-import": "0.0.0", + "@splitin/outreach-provider-email-gmail": "0.0.0", + "@splitin/outreach-provider-email-outlook": "0.0.0", + "@splitin/outreach-server": "0.0.0", + "@splitin/outreach-store-sqlite": "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..1c3a830 --- /dev/null +++ b/outreach-engine/packages/outreach-cli/src/cli.test.ts @@ -0,0 +1,111 @@ +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/); + expect((await w.run('gate', 'open', '--reason', 'provider chosen, going live')).stderr).toMatch(/jurisdiction policy/); + expect((await w.run('jurisdiction', 'set', '--allow', 'US,GB', '--consent', 'de', '--block', 'DE', '--default', 'block', '--unknown', 'allow', + '--signed-off-by', 'Counsel', '--reference', 'LEGAL-12')).stderr).toMatch(/DE is listed under more than one rule/); + expect((await w.run('jurisdiction', 'set', '--allow', 'US', '--default', 'maybe', '--unknown', 'allow', '--signed-off-by', 'Counsel', '--reference', 'LEGAL-12')).stderr) + .toMatch(/--default must be one of/); + const policy = await w.run('jurisdiction', 'set', '--allow', 'US,GB', '--consent', 'DE,CA', '--default', 'block', '--unknown', 'allow', + '--signed-off-by', 'Counsel', '--reference', 'LEGAL-12 outreach memo', '--json'); + expect(policy.json()).toMatchObject({ rules: { US: 'allow', GB: 'allow', DE: 'consent_required', CA: 'consent_required' }, default: 'block', signoff: { by: 'Counsel' } }); + expect((await w.run('jurisdiction', 'show', '--json')).json()).toMatchObject({ unknown: 'allow' }); + expect((await w.run('gate', 'open', '--reason', 'provider chosen, going live')).code).toBe(0); + 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/connect.ts b/outreach-engine/packages/outreach-cli/src/commands/connect.ts new file mode 100644 index 0000000..4c3694b --- /dev/null +++ b/outreach-engine/packages/outreach-cli/src/commands/connect.ts @@ -0,0 +1,84 @@ +import { chmodSync, mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { authorizeGoogle, GMAIL_SCOPES } from '@splitin/outreach-provider-email-gmail'; +import { authorizeMicrosoft } from '@splitin/outreach-provider-email-outlook'; +import { expandHome, UsageError } from '../runtime'; +import { flag, required, type Command, type Flags } from './types'; + +/** Writes a grant to an owner-only file (0600 in a 0700 directory) and returns its absolute path. */ +function saveGrant(flags: Flags, env: NodeJS.ProcessEnv, provider: string, emailAddress: string, grant: unknown): string { + const path = resolve(expandHome(flag(flags, 'out') ?? `~/.config/outreach/${provider}-${emailAddress}.json`, env)); + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + writeFileSync(path, `${JSON.stringify(grant)}\n`, { mode: 0o600 }); + chmodSync(path, 0o600); // writeFileSync keeps the old mode when the file already existed. + return path; +} + +const nextStep = (provider: string, address: string, path: string) => + `outreach account add --provider ${provider} --external-id ${address} --sender-email ${address} ` + + `--sender-name "" --postal "" --purposes --secret file:${path}`; + +export const connectCommands: readonly Command[] = [ + { + name: 'account connect gmail', + usage: 'outreach account connect gmail --client-id --client-secret-env [--login-hint
            ] [--out ]', + summary: 'Sign in to Google and store the Gmail grant in an owner-only file for a file: secret reference.', + flags: { 'client-id': 'string', 'client-secret-env': 'string', 'login-hint': 'string', out: 'string' }, + async run({ flags, out, env, notice, openUrl }) { + // The client secret is read from the environment, never from argv (visible to other users in `ps`). + const secretEnv = required(flags, 'client-secret-env'); + const clientSecret = env[secretEnv]; + if (!clientSecret) throw new UsageError(`environment variable ${secretEnv} is empty; export the OAuth client secret there`); + const loginHint = flag(flags, 'login-hint'); + const result = await authorizeGoogle({ + clientId: required(flags, 'client-id'), + clientSecret, + scopes: GMAIL_SCOPES, + ...(loginHint ? { loginHint } : {}), + onUrl: (url) => { + notice(`Open this URL in a browser on this machine and sign in to the mailbox you will send from:\n\n ${url}\n\nWaiting for Google...`); + openUrl?.(url); + }, + // Endpoint overrides exist for testing against a fake Google; leave them unset in real use. + ...(env.OUTREACH_GOOGLE_AUTH_URL ? { authUrl: env.OUTREACH_GOOGLE_AUTH_URL } : {}), + ...(env.OUTREACH_GOOGLE_TOKEN_URL ? { tokenUrl: env.OUTREACH_GOOGLE_TOKEN_URL } : {}), + ...(env.OUTREACH_GMAIL_API ? { api: env.OUTREACH_GMAIL_API } : {}), + }); + if (loginHint && loginHint.toLowerCase() !== result.emailAddress) { + throw new Error(`you signed in as ${result.emailAddress}, not ${loginHint}; nothing was saved`); + } + const path = saveGrant(flags, env, 'gmail', result.emailAddress, result.grant); + out.result({ connected: result.emailAddress, secretRef: `file:${path}`, next: nextStep('gmail', result.emailAddress, path) }); + }, + }, + { + name: 'account connect outlook', + usage: 'outreach account connect outlook --tenant --client-id [--client-secret-env ] [--login-hint
            ] [--out ]', + summary: 'Sign in to Microsoft 365 and store the Outlook grant in an owner-only file (rotated tokens are written back).', + flags: { tenant: 'string', 'client-id': 'string', 'client-secret-env': 'string', 'login-hint': 'string', out: 'string' }, + async run({ flags, out, env, notice, openUrl }) { + const secretEnv = flag(flags, 'client-secret-env'); + const clientSecret = secretEnv ? env[secretEnv] : undefined; + if (secretEnv && !clientSecret) throw new UsageError(`environment variable ${secretEnv} is empty`); + const loginHint = flag(flags, 'login-hint'); + const result = await authorizeMicrosoft({ + tenant: required(flags, 'tenant'), + clientId: required(flags, 'client-id'), + ...(clientSecret ? { clientSecret } : {}), + ...(loginHint ? { loginHint } : {}), + onUrl: (url) => { + notice(`Open this URL in a browser on this machine and sign in to the mailbox you will send from:\n\n ${url}\n\nWaiting for Microsoft...`); + openUrl?.(url); + }, + // Endpoint overrides exist for testing against a fake Microsoft; leave them unset in real use. + ...(env.OUTREACH_MICROSOFT_AUTHORITY ? { authority: env.OUTREACH_MICROSOFT_AUTHORITY } : {}), + ...(env.OUTREACH_GRAPH_API ? { api: env.OUTREACH_GRAPH_API } : {}), + }); + if (loginHint && loginHint.toLowerCase() !== result.emailAddress) { + throw new Error(`you signed in as ${result.emailAddress}, not ${loginHint}; nothing was saved`); + } + const path = saveGrant(flags, env, 'outlook', result.emailAddress, result.grant); + out.result({ connected: result.emailAddress, secretRef: `file:${path}`, next: nextStep('outlook', result.emailAddress, path) }); + }, + }, +]; 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..d38fbba --- /dev/null +++ b/outreach-engine/packages/outreach-cli/src/commands/setup.ts @@ -0,0 +1,225 @@ +import { PROVIDER_PURPOSES, verifyAuditChain, type ProviderPurpose } from '@splitin/outreach-contracts'; +import { + ROLES, + addPrincipal, + bootstrapWorkspace, + checkAccountHealth, + configureNotifications, + createApiToken, + JURISDICTION_RULES, + readJurisdictionPolicy, + readSendGate, + registerProviderAccount, + revokeApiToken, + setJurisdictionPolicy, + setSendGate, + type JurisdictionRule, + 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 parseRule(value: string, name: string): JurisdictionRule { + if (!(JURISDICTION_RULES as readonly string[]).includes(value)) throw new Error(`--${name} must be one of ${JURISDICTION_RULES.join(', ')}`); + return value as JurisdictionRule; +} + +/** `--allow US,GB --consent DE,CA --block FR` into a rules map; a country listed twice is an error. */ +function parseRules(flags: Readonly>): Record { + const rules: Record = {}; + const lists: [string, JurisdictionRule][] = [['allow', 'allow'], ['consent', 'consent_required'], ['block', 'block']]; + for (const [name, rule] of lists) { + for (const country of (flag(flags, name) ?? '').split(',').map((c) => c.trim().toUpperCase()).filter(Boolean)) { + if (rules[country]) throw new Error(`${country} is listed under more than one rule`); + rules[country] = rule; + } + } + return rules; +} + +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
            + + +
            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..6488202 --- /dev/null +++ b/outreach-engine/packages/outreach-import/src/importer.test.ts @@ -0,0 +1,177 @@ +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('country column (decision D5)', () => { + const COUNTRY_CSV = ['Name,Email,Country', 'Ada,ada@example.org,US', 'Hans,hans@example.de,Germany', 'Kim,kim@example.kr,south korea', + 'Nobody,nobody@example.org,', 'Bad,bad@example.org,Atlantis', 'Pseudo,eu@example.org,EU'].join('\n'); + + it('maps codes and English names per row, falls back to the profile, and rejects what it cannot resolve', async () => { + const { db, profileId } = setup({ columns: { email: 'Email', full_name: 'Name', country: 'Country' }, jurisdiction: 'unknown' }); + const result = await preview(db, profileId, COUNTRY_CSV); + expect(result.samples.reject.map((r) => r.note)).toEqual(['unknown country "Atlantis"', 'unknown country "EU"']); + commitImport(db, actor, { batchId: result.batchId, previewHash: result.previewHash, idempotencyKey: 'c1', now: NOW }); + const rows = db.prepare(`SELECT value_norm, jurisdiction FROM contact_points ORDER BY value_norm`).all(); + expect(rows).toEqual([ + { value_norm: 'ada@example.org', jurisdiction: 'US' }, + { value_norm: 'hans@example.de', jurisdiction: 'DE' }, + { value_norm: 'kim@example.kr', jurisdiction: 'KR' }, + { value_norm: 'nobody@example.org', jurisdiction: 'unknown' }, + ]); + }); + + it('fills in an unknown country on re-import but never overwrites a known one', async () => { + const { db, profileId } = setup({ columns: { email: 'Email', full_name: 'Name', country: 'Country' }, jurisdiction: 'unknown' }); + const first = await preview(db, profileId, 'Name,Email,Country\nAda,ada@example.org,\nHans,hans@example.de,DE'); + commitImport(db, actor, { batchId: first.batchId, previewHash: first.previewHash, idempotencyKey: 'c1', now: NOW }); + const second = await preview(db, profileId, 'Name,Email,Country\nAda,ada@example.org,GB\nHans,hans@example.de,AT'); + commitImport(db, actor, { batchId: second.batchId, previewHash: second.previewHash, idempotencyKey: 'c2', now: NOW }); + expect(db.prepare(`SELECT value_norm, jurisdiction FROM contact_points ORDER BY value_norm`).all()).toEqual([ + { value_norm: 'ada@example.org', jurisdiction: 'GB' }, + { value_norm: 'hans@example.de', jurisdiction: 'DE' }, + ]); + }); +}); + +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..0c43f79 --- /dev/null +++ b/outreach-engine/packages/outreach-import/src/importer.ts @@ -0,0 +1,217 @@ +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, country: string | null, 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 UPDATE SET jurisdiction = excluded.jurisdiction + WHERE coalesce(contact_points.jurisdiction, 'unknown') = 'unknown' AND excluded.jurisdiction <> 'unknown'`, + ).run(ulid(now), workspaceId, contactId, kind, value, value, source, profile.consent.basis, profile.consent.evidence ?? null, now, country ?? 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, contact.country, `import:${batchId}`, now); + if (contact.profile_url) addPoint(db, workspaceId, id, 'social_profile', contact.profile_url, profile, contact.country, `import:${batchId}`, now); + if (contact.phone) addPoint(db, workspaceId, id, 'phone', contact.phone, profile, contact.country, `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, contact.country, `import:${batchId}`, now); + if (contact.profile_url) addPoint(db, workspaceId, contactId, 'social_profile', contact.profile_url, profile, contact.country, `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..91fc361 --- /dev/null +++ b/outreach-engine/packages/outreach-import/src/normalize.ts @@ -0,0 +1,156 @@ +import type { RawRow } from './detect'; +import { normalizeCountry } from './country'; +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; + country: 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')), + country: null, + 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)}"`); + } + const rawCountry = collapse(get('country')); + if (rawCountry) { + contact.country = normalizeCountry(rawCountry); + if (!contact.country) errors.push(`unknown country "${rawCountry.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..fea5f53 --- /dev/null +++ b/outreach-engine/packages/outreach-import/src/profile.ts @@ -0,0 +1,95 @@ +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', + /** ISO 3166 code or English country name; overrides the profile-wide `jurisdiction` for that row. */ + 'country', +] 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, +}); diff --git a/outreach-engine/packages/outreach-notify-slack/README.md b/outreach-engine/packages/outreach-notify-slack/README.md new file mode 100644 index 0000000..101b5ef --- /dev/null +++ b/outreach-engine/packages/outreach-notify-slack/README.md @@ -0,0 +1,33 @@ +# @splitin/outreach-notify-slack + +This adapter posts the engine's notifications to Slack: replies, opt-outs, bounces, complaints and account-health changes. It implements only the `notify` port, so it never sends email. Notifications carry identifiers and routing only, never message bodies. + +## Modes + +The account's secret decides how the adapter posts: + +| Secret | Posts with | Destination | +| --- | --- | --- | +| `https://hooks.slack.com/services/...` (incoming webhook) | the webhook | that webhook's fixed channel | +| `xoxb-...` (bot token with `chat:write`) | `chat.postMessage` | the channel id in the account's `--external-id` (invite the bot first) | + +```js +// outreach.config.mjs +import { slackNotifier } from '@splitin/outreach-notify-slack'; +export default { adapters: [/* email adapters */, slackNotifier()] }; +``` + +```zsh +export SLACK_WEBHOOK='https://hooks.slack.com/services/...' +outreach account add --provider slack --external-id '#gtm' --sender-name Outreach --sender-email ops@yourdomain.com \ + --purposes transactional --secret env:SLACK_WEBHOOK +outreach notify set +``` + +## Behaviour + +- **Escaping:** all content is escaped for Slack markup (`&`, `<`, `>`). An engine value can never ping `` or render as a link. Link buttons appear only for `https://` URLs. +- **Outcomes:** a refused connection is a transient rejection and is retried. A dropped connection or a 5xx is `unknown`, and per ADR 0002 an unknown notification is never retried. HTTP 429 and `ratelimited` map to `rate_limited`. `invalid_auth` and `token_revoked` map to `auth_revoked`. `channel_not_found`, `not_in_channel` and `is_archived` map to `forbidden`. +- **Health:** bot tokens are checked with `auth.test`. An incoming webhook cannot be checked without posting, so it reports `ok` with a note saying so. + +Slack control (approve and pause buttons, `/outreach`) is a separate app in `slack-agent-hq` (BUILD_PLAN.md §11.4). diff --git a/outreach-engine/packages/outreach-notify-slack/package.json b/outreach-engine/packages/outreach-notify-slack/package.json new file mode 100644 index 0000000..52178e5 --- /dev/null +++ b/outreach-engine/packages/outreach-notify-slack/package.json @@ -0,0 +1,44 @@ +{ + "name": "@splitin/outreach-notify-slack", + "version": "0.0.0", + "description": "Slack notifier for the outreach engine: incoming webhook or chat.postMessage, Block Kit with escaped content, error codes mapped to the engine 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-notify-slack" + }, + "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-provider-kit": "0.0.0" + } +} diff --git a/outreach-engine/packages/outreach-notify-slack/src/index.ts b/outreach-engine/packages/outreach-notify-slack/src/index.ts new file mode 100644 index 0000000..8a5a270 --- /dev/null +++ b/outreach-engine/packages/outreach-notify-slack/src/index.ts @@ -0,0 +1,141 @@ +import type { ErrorClass, Notification, ProviderAdapter, ProviderContext, SendResult } from '@splitin/outreach-contracts'; +import { ProviderNetworkError, textRequest, type HttpDeps } from '@splitin/outreach-provider-kit'; + +export const SLACK_API = 'https://slack.com/api'; + +export interface SlackNotifierOptions { + readonly api?: string; + readonly fetch?: typeof fetch; + readonly requestTimeoutMs?: number; + /** Allowed incoming-webhook origin; overridable for tests only. */ + readonly webhookOrigin?: string; +} + +/** + * The account's secret decides the mode: + * - an incoming-webhook URL (https://hooks.slack.com/services/...) posts to that webhook's fixed channel; + * - a bot token (xoxb-...) posts with chat.postMessage to the channel in the account's externalAccountId. + */ +type Mode = { readonly kind: 'webhook'; readonly url: string } | { readonly kind: 'bot'; readonly token: string }; + +const SEVERITY = { info: ':information_source:', warning: ':warning:', error: ':rotating_light:' } as const; + +/** Slack mrkdwn needs &, < and > escaped; everything the engine sends is data, never markup. */ +export const escapeSlack = (text: string) => text.replace(/&/g, '&').replace(//g, '>'); + +export function slackBlocks(notification: Notification): { text: string; blocks: unknown[] } { + const title = `${SEVERITY[notification.severity]} ${escapeSlack(notification.title)}`.slice(0, 3000); + const lines = notification.lines.map(escapeSlack).join('\n').slice(0, 3000); + const link = notification.link && /^https:\/\//.test(notification.link) ? notification.link : undefined; + return { + // Fallback for notifications and clients without Block Kit. + text: `${title}${lines ? `\n${lines}` : ''}`.slice(0, 4000), + blocks: [ + { type: 'section', text: { type: 'mrkdwn', text: `*${title}*` } }, + ...(lines ? [{ type: 'section', text: { type: 'mrkdwn', text: lines } }] : []), + ...(link ? [{ type: 'actions', elements: [{ type: 'button', text: { type: 'plain_text', text: 'Open' }, url: link }] }] : []), + ], + }; +} + +/** chat.postMessage / auth.test `error` codes to the engine taxonomy. */ +export function classifySlackError(code: string): ErrorClass { + if (code === 'ratelimited' || code === 'rate_limited') return 'rate_limited'; + if (/^(invalid_auth|not_authed|token_revoked|token_expired|account_inactive|no_permission|missing_scope)$/.test(code)) return 'auth_revoked'; + if (/^(channel_not_found|not_in_channel|is_archived|channel_is_archived|restricted_action|no_service|no_service_id|no_team|team_disabled|action_prohibited|posting_to_general_channel_denied)$/.test(code)) return 'forbidden'; + return 'content_rejected'; +} + +export function slackNotifier(options: SlackNotifierOptions = {}): ProviderAdapter { + const http: HttpDeps = { fetch: options.fetch ?? fetch, timeoutMs: options.requestTimeoutMs ?? 15_000 }; + const api = options.api ?? SLACK_API; + const webhookOrigin = options.webhookOrigin ?? 'https://hooks.slack.com'; + + const modeOf = async (ctx: ProviderContext): Promise => { + const secret = (await ctx.secrets.get(ctx.secretRef)).trim(); + if (secret.startsWith(`${webhookOrigin}/`)) return { kind: 'webhook', url: secret }; + if (secret.startsWith('xoxb-')) return { kind: 'bot', token: secret }; + throw new Error('the Slack secret must be an incoming-webhook URL (https://hooks.slack.com/...) or a bot token (xoxb-...)'); + }; + + const publish = async (ctx: ProviderContext, notification: Notification & { idempotencyKey: string }): Promise => { + let mode: Mode; + try { + mode = await modeOf(ctx); + } catch (error) { + return { kind: 'rejected', errorClass: 'auth_revoked', detail: (error as Error).message }; + } + const payload = slackBlocks(notification); + let response: { status: number; text: string; retryAfterMs?: number }; + try { + response = mode.kind === 'webhook' + ? await textRequest(http, { url: mode.url, method: 'POST', json: payload, signal: ctx.signal }) + : await textRequest(http, { url: `${api}/chat.postMessage`, method: 'POST', token: mode.token, json: { channel: ctx.account.externalAccountId, unfurl_links: false, ...payload }, signal: ctx.signal }); + } catch (error) { + if (error instanceof ProviderNetworkError && !error.reachedServer) return { kind: 'rejected', errorClass: 'transient', detail: error.message }; + return { kind: 'unknown', detail: error instanceof ProviderNetworkError ? error.message : (error as Error).name }; + } + if (response.status === 429) return { kind: 'rejected', errorClass: 'rate_limited', retryAfterMs: response.retryAfterMs ?? 30_000, detail: 'Slack HTTP 429' }; + if (response.status >= 500) return { kind: 'unknown', detail: `Slack HTTP ${response.status}` }; + if (mode.kind === 'webhook') { + // Incoming webhooks answer "ok" or a plain-text error code (invalid_payload, no_service, channel_is_archived...). + if (response.status === 200 && response.text.trim() === 'ok') { + return { kind: 'accepted', receipt: { providerMessageId: `webhook:${notification.idempotencyKey}`, acceptedAt: ctx.now() } }; + } + const code = response.text.trim().slice(0, 60) || `http_${response.status}`; + return { kind: 'rejected', errorClass: classifySlackError(code), detail: `Slack webhook ${response.status}: ${code}` }; + } + let body: { ok?: boolean; ts?: string; channel?: string; error?: string } = {}; + try { + body = JSON.parse(response.text) as typeof body; + } catch { + return { kind: 'unknown', detail: `Slack HTTP ${response.status} with an unreadable body` }; + } + if (body.ok && body.ts) { + return { kind: 'accepted', receipt: { providerMessageId: `${body.channel ?? ctx.account.externalAccountId}:${body.ts}`, acceptedAt: ctx.now() } }; + } + const code = body.error ?? `http_${response.status}`; + return { kind: 'rejected', errorClass: classifySlackError(code), ...(code === 'ratelimited' ? { retryAfterMs: response.retryAfterMs ?? 30_000 } : {}), detail: `Slack ${code}` }; + }; + + return { + name: 'slack', + purposes: ['transactional'], + account: { + async discover(ctx) { + return { + provider: 'slack', + send: false, + replyInThread: false, + customHeaders: false, + externalIdempotency: false, + inboundWebhook: false, + mailboxPolling: false, + reconcileBySentSearch: false, + maxRecipientsPerMessage: 1, + discoveredAt: ctx.now(), + }; + }, + async health(ctx) { + let mode: Mode; + try { + mode = await modeOf(ctx); + } catch (error) { + return { status: 'reauth_required', detail: (error as Error).message }; + } + // An incoming webhook cannot be checked without posting to the channel. + if (mode.kind === 'webhook') return { status: 'ok', detail: 'incoming webhook (not verifiable without posting)' }; + try { + const response = await textRequest(http, { url: `${api}/auth.test`, method: 'POST', token: mode.token, signal: ctx.signal }); + const body = JSON.parse(response.text || '{}') as { ok?: boolean; error?: string }; + if (body.ok) return { status: 'ok' }; + const errorClass = classifySlackError(body.error ?? ''); + return { status: errorClass === 'auth_revoked' ? 'reauth_required' : 'degraded', detail: `Slack ${body.error ?? response.status}` }; + } catch (error) { + return { status: 'degraded', detail: (error as Error).message }; + } + }, + }, + notify: { publish }, + }; +} diff --git a/outreach-engine/packages/outreach-notify-slack/src/slack.test.ts b/outreach-engine/packages/outreach-notify-slack/src/slack.test.ts new file mode 100644 index 0000000..e260fc0 --- /dev/null +++ b/outreach-engine/packages/outreach-notify-slack/src/slack.test.ts @@ -0,0 +1,100 @@ +import { createServer, type Server } from 'node:http'; +import { createServer as createTcpServer } from 'node:net'; +import type { AddressInfo } from 'node:net'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { ProviderContext } from '@splitin/outreach-contracts'; +import { escapeSlack, slackBlocks, slackNotifier } from './index'; + +interface Received { + path: string; + auth: string | undefined; + body: Record; +} + +/** Minimal Slack: /services/* incoming webhooks, /api/chat.postMessage and /api/auth.test. */ +async function fakeSlack(reply: (req: Received) => { status: number; body: string; headers?: Record } | 'drop') { + const received: Received[] = []; + const server: Server = createServer((req, res) => { + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => chunks.push(chunk)); + req.on('end', () => { + const text = Buffer.concat(chunks).toString('utf8'); + const entry = { path: req.url ?? '', auth: req.headers.authorization, body: text ? (JSON.parse(text) as Record) : {} }; + received.push(entry); + const answer = reply(entry); + if (answer === 'drop') return void req.socket.destroy(); + res.writeHead(answer.status, answer.headers ?? {}); + res.end(answer.body); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const url = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + cleanup.push(() => new Promise((resolve) => server.close(() => resolve()))); + return { url, received }; +} + +const cleanup: (() => Promise)[] = []; +afterEach(async () => { + for (const step of cleanup.splice(0)) await step(); +}); + +function ctx(secret: string, channel = 'C0123'): ProviderContext { + return { + workspaceId: 'ws', + account: { id: 'slack-1', provider: 'slack', externalAccountId: channel, sender: { name: 'Outreach', address: 'ops@example.com' } }, + secretRef: 'env:SLACK', + secrets: { get: async () => secret }, + traceId: 't', + signal: new AbortController().signal, + now: () => 1_000, + }; +} + +const note = { title: 'Reply from ', lines: ['Campaign: Intro & friends'], severity: 'info' as const, idempotencyKey: 'inbound:1' }; + +describe('Slack notifier', () => { + it('posts escaped Block Kit through an incoming webhook', async () => { + const slack = await fakeSlack(() => ({ status: 200, body: 'ok' })); + const notifier = slackNotifier({ webhookOrigin: slack.url }); + const result = await notifier.notify!.publish(ctx(`${slack.url}/services/T/B/x`), note); + expect(result).toMatchObject({ kind: 'accepted', receipt: { providerMessageId: 'webhook:inbound:1' } }); + expect(JSON.stringify(slack.received[0]?.body)).toContain('Reply from <ada@example.org>'); + expect(JSON.stringify(slack.received[0]?.body)).toContain('Intro & friends'); + }); + + it('posts with a bot token to the account channel and maps Slack error codes', async () => { + let answer = { ok: true, ts: '1712.0001', channel: 'C0123' } as Record; + const slack = await fakeSlack((req) => ({ status: 200, body: JSON.stringify(req.path.endsWith('auth.test') ? { ok: true } : answer) })); + const notifier = slackNotifier({ api: `${slack.url}/api` }); + const bot = ctx('xoxb-fake'); + expect(await notifier.notify!.publish(bot, note)).toMatchObject({ kind: 'accepted', receipt: { providerMessageId: 'C0123:1712.0001' } }); + expect(slack.received[0]).toMatchObject({ path: '/api/chat.postMessage', auth: 'Bearer xoxb-fake', body: { channel: 'C0123' } }); + answer = { ok: false, error: 'channel_not_found' }; + expect(await notifier.notify!.publish(bot, note)).toMatchObject({ kind: 'rejected', errorClass: 'forbidden' }); + answer = { ok: false, error: 'token_revoked' }; + expect(await notifier.notify!.publish(bot, note)).toMatchObject({ kind: 'rejected', errorClass: 'auth_revoked' }); + answer = { ok: false, error: 'ratelimited' }; + expect(await notifier.notify!.publish(bot, note)).toMatchObject({ kind: 'rejected', errorClass: 'rate_limited' }); + expect(await notifier.account.health(bot)).toEqual({ status: 'ok' }); + }); + + it('is unknown only when the post may have landed', async () => { + const slack = await fakeSlack(() => 'drop'); + const notifier = slackNotifier({ webhookOrigin: slack.url, requestTimeoutMs: 2_000 }); + expect((await notifier.notify!.publish(ctx(`${slack.url}/services/T/B/x`), note)).kind).toBe('unknown'); + const closed = createTcpServer(); + await new Promise((resolve) => closed.listen(0, '127.0.0.1', resolve)); + const port = (closed.address() as AddressInfo).port; + await new Promise((resolve) => closed.close(() => resolve())); + const unreachable = slackNotifier({ webhookOrigin: `http://127.0.0.1:${port}` }); + expect(await unreachable.notify!.publish(ctx(`http://127.0.0.1:${port}/services/T/B/x`), note)).toMatchObject({ kind: 'rejected', errorClass: 'transient' }); + }); + + it('refuses a secret that is neither a webhook URL nor a bot token, and drops non-https links', async () => { + const notifier = slackNotifier(); + expect(await notifier.notify!.publish(ctx('https://evil.example.com/hook'), note)).toMatchObject({ kind: 'rejected', errorClass: 'auth_revoked' }); + expect((await notifier.account.health(ctx('nope'))).status).toBe('reauth_required'); + expect(JSON.stringify(slackBlocks({ ...note, link: 'javascript:alert(1)' }))).not.toContain('javascript:'); + expect(escapeSlack('')).toBe('<!channel>'); + }); +}); diff --git a/outreach-engine/packages/outreach-notify-slack/tsconfig.json b/outreach-engine/packages/outreach-notify-slack/tsconfig.json new file mode 100644 index 0000000..585a92d --- /dev/null +++ b/outreach-engine/packages/outreach-notify-slack/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist" }, + "include": ["src"] +} diff --git a/outreach-engine/packages/outreach-notify-slack/tsup.config.ts b/outreach-engine/packages/outreach-notify-slack/tsup.config.ts new file mode 100644 index 0000000..1343a99 --- /dev/null +++ b/outreach-engine/packages/outreach-notify-slack/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/packages/outreach-provider-email-gmail/README.md b/outreach-engine/packages/outreach-provider-email-gmail/README.md new file mode 100644 index 0000000..9418d3b --- /dev/null +++ b/outreach-engine/packages/outreach-provider-email-gmail/README.md @@ -0,0 +1,64 @@ +# @splitin/outreach-provider-email-gmail + +Gmail adapter for the outreach engine. It sends as the connected mailbox user through the Gmail API. It reconciles sends whose outcome is unknown by checking the Sent folder, and it reads replies and bounces through the History API. Decision D1 in [BUILD_PLAN.md §19](../../BUILD_PLAN.md) explains why the engine uses mailbox adapters. + +## How it behaves + +| Concern | Behaviour | +| --- | --- | +| Send | `users.messages.send` with a MIME message the adapter builds. Non-ASCII names and subjects are RFC 2047 encoded, and bodies are base64 UTF-8. HTML becomes `multipart/alternative`. Header injection, reserved headers and odd addresses are refused before anything is sent. | +| Outcome unknown | Connection refused or DNS failure means nothing reached Google, so the send is a transient rejection. Timeouts, resets and 5xx responses may have been processed, so they are reported as `unknown` and never retried blindly (ADR 0002). | +| Reconcile | 1. `rfc822msgid:` search. 2. A walk of the Sent label (spam and trash included) that matches the `X-Outreach-Key` header, which does not depend on search indexing. The adapter answers `absent` only after that walk and only once `settleMs` (default 3 min) has passed since the attempt. | +| Threading | Replies carry `threadId`, `In-Reply-To` and `References`. The adapter reports the Message-ID Gmail actually stored, because Gmail may assign its own. If the thread was deleted, Gmail refuses the threaded send and the adapter sends unthreaded. | +| Inbound | History API with a cursor that resumes after the last processed record, `pollBudget` messages per poll. Our own sent mail is skipped. An expired cursor falls back to recent inbox mail, and events are deduplicated by id. | +| Bounces | Status, recipient and original Message-ID come from the `message/delivery-status` part. A bounce without that part is left unclassified, so it goes to the review queue and nobody is suppressed on a guess. | +| Health | `ok` only if the connected mailbox is the account's sender address. A revoked grant reports `reauth_required`. | +| Errors | 429 and quota reasons map to `rate_limited`, with Retry-After or 1 h for daily limits. 401 maps to `auth_expired` and forces a token refresh. `domainPolicy` maps to `policy_blocked`. Other 403s map to `forbidden`. A bad recipient maps to `invalid_recipient`. | + +## Purposes + +By default the adapter declares `manual_correspondence` only. Declare `automated_outreach` yourself, in `outreach.config.mjs`, after you have reviewed Google's terms for your account type: + +```js +// outreach.config.mjs (next to your outreach.db) +import { gmailAdapter } from '@splitin/outreach-provider-email-gmail'; + +export default { + adapters: [gmailAdapter({ purposes: ['manual_correspondence', 'automated_outreach'] })], +}; +``` + +A campaign runs only if its purpose is permitted by both the adapter and the account (`outreach account add --purposes`). + +## Setup (Google Workspace) + +1. **Google Cloud project.** Enable the Gmail API. Set the OAuth consent screen's user type to **Internal**, which keeps it inside your Workspace and needs no Google verification. Scopes: `gmail.send` and `gmail.readonly`. +2. **OAuth client.** Create one of type *Desktop app*, then export its secret so it never appears in shell history or `ps`: + ```zsh + export GOOGLE_CLIENT_SECRET='...' + ``` +3. **Connect the mailbox you will send from.** The engine prints a URL. Open it on the same machine, sign in and approve. The grant is written to an owner-only (0600) file: + ```zsh + outreach account connect gmail --client-id .apps.googleusercontent.com \ + --client-secret-env GOOGLE_CLIENT_SECRET --login-hint sam@yourdomain.com + ``` +4. **Register the account** with the `secretRef` the previous step printed: + ```zsh + outreach account add --provider gmail --external-id sam@yourdomain.com \ + --sender-email sam@yourdomain.com --sender-name "Sam" --postal "1 Example St, City" \ + --purposes automated_outreach --secret file:~/.config/outreach/gmail-sam@yourdomain.com.json + ``` + +Use a Workspace mailbox on a secondary, warmed-up domain, not a consumer `@gmail.com` address. Workspace allows 2,000 messages a day per user, but deliverability limits you long before that. Keep the playbook's `accountPerDay` at 30–50 for cold outreach. + +## Tokens from elsewhere + +The adapter takes access tokens from an `AccessTokenSource`, which is the refresh-token file by default. A host that already holds the Google grant can pass its own source, for example Papr's planned server-side connectors: + +```js +gmailAdapter({ tokens: { get: async (ctx) => host.tokenFor(ctx.account.externalAccountId), invalidate: () => {} } }); +``` + +## Testing + +`FakeGmailServer` in `@splitin/outreach-fakes` speaks the parts of the Gmail API and Google's OAuth endpoints this adapter uses, over real HTTP. It can drop connections before or after accepting a send, rewrite Message-IDs, lag its search index and expire history cursors. The adapter passes the email conformance kit against it. `outreach-e2e/src/gmail.e2e.test.ts` runs the engine end to end through it. diff --git a/outreach-engine/packages/outreach-provider-email-gmail/package.json b/outreach-engine/packages/outreach-provider-email-gmail/package.json new file mode 100644 index 0000000..8fc6031 --- /dev/null +++ b/outreach-engine/packages/outreach-provider-email-gmail/package.json @@ -0,0 +1,47 @@ +{ + "name": "@splitin/outreach-provider-email-gmail", + "version": "0.0.0", + "description": "Gmail API adapter for the outreach engine: send as the mailbox user, reconcile via the Sent folder, read replies and bounces via the History API.", + "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-provider-email-gmail" + }, + "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-provider-kit": "0.0.0" + }, + "devDependencies": { + "@splitin/outreach-fakes": "0.0.0" + } +} diff --git a/outreach-engine/packages/outreach-provider-email-gmail/src/gmail.test.ts b/outreach-engine/packages/outreach-provider-email-gmail/src/gmail.test.ts new file mode 100644 index 0000000..dfb9f8a --- /dev/null +++ b/outreach-engine/packages/outreach-provider-email-gmail/src/gmail.test.ts @@ -0,0 +1,225 @@ +import { createServer } from 'node:net'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { ApprovedEmail, ProviderContext, ProviderAdapter } from '@splitin/outreach-contracts'; +import { FakeGmailServer, runEmailSenderConformance, staticSecrets, type FakeGmailOptions } from '@splitin/outreach-fakes'; +import { buildMime, gmailAdapter, googleRefreshTokenSource, MimeError } from './index'; + +const servers: FakeGmailServer[] = []; +afterEach(async () => { + await Promise.all(servers.splice(0).map((server) => server.stop())); +}); + +async function setup(options: FakeGmailOptions & { settleMs?: number; pollBudget?: number; secret?: string } = {}) { + const server = await new FakeGmailServer(options).start(); + servers.push(server); + let now = Date.now(); + const secret = options.secret ?? server.grant(); + const adapter = gmailAdapter({ + api: server.url, + tokens: googleRefreshTokenSource({ tokenUrl: `${server.url}/token` }), + settleMs: options.settleMs ?? 180_000, + requestTimeoutMs: 2_000, + ...(options.pollBudget ? { pollBudget: options.pollBudget } : {}), + }); + const ctx: ProviderContext = { + workspaceId: 'ws', + account: { id: 'acct-1', provider: 'gmail', externalAccountId: server.mailbox, sender: { name: 'Sam Sender', address: server.mailbox } }, + secretRef: 'env:GMAIL', + secrets: staticSecrets({ 'env:GMAIL': secret }), + traceId: 'trace', + signal: new AbortController().signal, + now: () => now, + }; + return { server, adapter, ctx, secret, advance: (ms: number) => (now += ms), email: adapter.email! }; +} + +function email(overrides: Partial = {}): ApprovedEmail { + return { + actionId: 'a1', + idempotencyKey: 'act:a1', + rfcMessageId: '', + contentHash: 'h', + from: { address: 'sender@example.com', name: 'Sam Sender' }, + to: [{ address: 'ada@example.org', name: 'Ada' }], + subject: 'Quick question', + text: 'Hello Ada', + headers: { 'List-Unsubscribe': '' }, + ...overrides, + }; +} + +describe('conformance kit over HTTP', () => { + it('passes every case, including dropped connections before and after Gmail accepts', async () => { + const harnesses: Awaited>[] = []; + for (let i = 0; i < 7; i += 1) harnesses.push(await setup({ settleMs: 0 })); + let next = 0; + const skipped = await runEmailSenderConformance(() => { + const h = harnesses[next++]!; + return { sender: h.email, ctx: h.ctx, secretValue: 'refresh-token-value', recipient: 'ada@example.org', force: (mode) => h.server.force(mode) }; + }); + expect(skipped).toEqual([]); + }); +}); + +describe('MIME', () => { + it('encodes non-ASCII names and subjects, keeps custom headers and adds the idempotency header', () => { + const mime = buildMime(email({ subject: 'Grüße aus München — kurze Frage', to: [{ address: 'jo@example.de', name: 'Jörg Müller' }] }), new Date(0)); + expect(mime).toMatch(/^Subject: =\?UTF-8\?B\?/m); + expect(mime).toMatch(/^To: =\?UTF-8\?B\?.+\?= $/m); + expect(mime).toMatch(/^List-Unsubscribe: line.length <= 998)).toBe(true); + }); + + it('builds multipart/alternative with the text part first', () => { + const mime = buildMime(email({ html: '

            Hello Ada

            ' }), new Date(0), 'B'); + expect(mime.indexOf('text/plain')).toBeLessThan(mime.indexOf('text/html')); + expect(mime).toContain('--B--'); + }); + + it('refuses header injection, reserved headers and odd addresses', () => { + expect(() => buildMime(email({ subject: 'Hi\r\nBcc: victim@example.org' }), new Date(0))).toThrow(MimeError); + expect(() => buildMime(email({ headers: { Bcc: 'x@example.org' } }), new Date(0))).toThrow(/set by the adapter/); + expect(() => buildMime(email({ to: [{ address: 'a@b.org>, evil@x.org' }] }), new Date(0))).toThrow(MimeError); + }); +}); + +describe('send', () => { + it('reports the Message-ID Gmail stored, so replies correlate even when Gmail rewrites it', async () => { + const { email: sender, ctx } = await setup({ replaceMessageId: true }); + const result = await sender.send(ctx, email()); + expect(result.kind).toBe('accepted'); + if (result.kind === 'accepted') expect(result.receipt.rfcMessageId).toMatch(/@mail\.gmail\.com>$/); + }); + + it('sends unthreaded when the thread was deleted (the threaded attempt was refused, not sent)', async () => { + const { email: sender, ctx, server } = await setup(); + const result = await sender.send(ctx, email({ providerThreadId: 'gone' })); + expect(result.kind).toBe('accepted'); + expect(server.sendCalls).toBe(2); + expect(server.messages).toHaveLength(1); + }); + + it('maps errors: 5xx is unknown, domain policy is policy_blocked, connection refused is a transient rejection', async () => { + const { email: sender, ctx, server } = await setup(); + server.force({ kind: 'reject', errorClass: 'transient' }); + expect((await sender.send(ctx, email())).kind).toBe('unknown'); + server.force({ kind: 'reject', errorClass: 'policy_blocked' }); + expect(await sender.send(ctx, email())).toMatchObject({ kind: 'rejected', errorClass: 'policy_blocked' }); + const closed = createServer(); + await new Promise((resolve) => closed.listen(0, '127.0.0.1', resolve)); + const port = (closed.address() as { port: number }).port; + await new Promise((resolve) => closed.close(() => resolve())); + const offline = gmailAdapter({ api: `http://127.0.0.1:${port}`, tokens: { get: async () => 'token', invalidate: () => {} }, requestTimeoutMs: 2_000 }); + expect(await offline.email!.send(ctx, email())).toMatchObject({ kind: 'rejected', errorClass: 'transient' }); + }); + + it('refreshes the access token after a 401 and reports a revoked grant as auth_revoked', async () => { + const { email: sender, ctx, server } = await setup(); + expect((await sender.send(ctx, email())).kind).toBe('accepted'); + server.expireTokens(); + expect(await sender.send(ctx, email())).toMatchObject({ kind: 'rejected', errorClass: 'auth_expired' }); + expect((await sender.send(ctx, email())).kind).toBe('accepted'); + + const revoked = await setup({ secret: JSON.stringify({ clientId: 'client-id', clientSecret: 'client-secret-value', refreshToken: 'wrong' }) }); + const result = await revoked.email.send(revoked.ctx, email()); + expect(result).toMatchObject({ kind: 'rejected', errorClass: 'auth_revoked' }); + expect(JSON.stringify(result)).not.toContain('client-secret-value'); + }); +}); + +describe('reconcile', () => { + it('finds a lost send through the Sent folder when search has not indexed it yet', async () => { + const { email: sender, ctx, server } = await setup({ searchLag: true }); + server.force({ kind: 'unknown_after_accept' }); + expect((await sender.send(ctx, email())).kind).toBe('unknown'); + const result = await sender.reconcile(ctx, { actionId: 'a1', idempotencyKey: 'act:a1', rfcMessageId: '', to: ['ada@example.org'], attemptedAt: ctx.now() }); + expect(result.kind).toBe('found'); + }); + + it('answers absent only after the settle window', async () => { + const { email: sender, ctx, server, advance } = await setup({ settleMs: 180_000 }); + server.force({ kind: 'unknown_before_accept' }); + const attemptedAt = ctx.now(); + expect((await sender.send(ctx, email())).kind).toBe('unknown'); + const uncertain = { actionId: 'a1', idempotencyKey: 'act:a1', rfcMessageId: '', to: ['ada@example.org'], attemptedAt }; + expect((await sender.reconcile(ctx, uncertain)).kind).toBe('still_unknown'); + advance(181_000); + expect((await sender.reconcile(ctx, uncertain)).kind).toBe('absent'); + }); +}); + +describe('mailbox', () => { + const readAll = async (adapter: ProviderAdapter, ctx: ProviderContext, cursor: string | null) => adapter.mailbox!.readChanges(ctx, cursor); + + it('starts from now, then returns replies and parsed bounces but never our own sent mail', async () => { + const { adapter, ctx, server, email: sender } = await setup(); + server.deliver({ headers: { From: 'old@example.org', Subject: 'before connect' } }); + const first = await readAll(adapter, ctx, null); + expect(first.events).toEqual([]); + + const sent = await sender.send(ctx, email()); + const threadId = sent.kind === 'accepted' ? sent.receipt.providerThreadId : undefined; + server.deliver({ + threadId, + snippet: 'Please remove me from your list & thanks', + headers: { From: 'Ada ', To: 'sender@example.com', Subject: 'Re: Quick question', 'Message-ID': '', 'In-Reply-To': '', References: '' }, + }); + const dsn = Buffer.from('Reporting-MTA: dns; googlemail.com\r\n\r\nFinal-Recipient: rfc822; gone@example.net\r\nAction: failed\r\nStatus: 5.1.1\r\n').toString('base64url'); + const original = Buffer.from('Message-ID: \r\nSubject: Quick question\r\n').toString('base64url'); + server.deliver({ + headers: { From: 'Mail Delivery Subsystem ', Subject: 'Delivery Status Notification (Failure)', 'Content-Type': 'multipart/report; report-type=delivery-status; boundary=x' }, + payload: { mimeType: 'multipart/report', parts: [{ mimeType: 'text/plain' }, { mimeType: 'message/delivery-status', body: { data: dsn } }, { mimeType: 'text/rfc822-headers', body: { data: original } }] }, + }); + + const second = await readAll(adapter, ctx, first.nextCursor); + expect(second.events).toHaveLength(2); + const [reply, bounce] = second.events; + expect(reply).toMatchObject({ kind: 'message', from: 'ada@example.org', inReplyTo: '', providerThreadId: threadId, snippet: 'Please remove me from your list & thanks' }); + expect(bounce).toMatchObject({ kind: 'bounce', dsn: { status: '5.1.1', recipient: 'gone@example.net', originalMessageId: '' } }); + expect((await readAll(adapter, ctx, second.nextCursor)).events).toEqual([]); + expect(JSON.stringify(second)).not.toContain('refresh-token-value'); + }); + + it('leaves a bounce without a delivery-status part unclassified (status empty) instead of guessing', async () => { + const { adapter, ctx, server } = await setup(); + const { nextCursor } = await readAll(adapter, ctx, null); + server.deliver({ headers: { From: 'postmaster@example.net', Subject: 'Undeliverable', 'X-Failed-Recipients': 'x@example.net' } }); + const { events } = await readAll(adapter, ctx, nextCursor); + expect(events[0]).toMatchObject({ kind: 'bounce', dsn: { status: '' } }); + }); + + it('resumes after the budget without skipping, and recovers from an expired cursor', async () => { + const { adapter, ctx, server } = await setup({ pollBudget: 2, historyPageSize: 1 }); + let cursor = (await readAll(adapter, ctx, null)).nextCursor; + for (let i = 0; i < 5; i += 1) server.deliver({ headers: { From: `p${i}@example.org`, Subject: `m${i}` } }); + const seen: string[] = []; + for (let pass = 0; pass < 5; pass += 1) { + const result = await readAll(adapter, ctx, cursor); + seen.push(...result.events.map((event) => event.from)); + cursor = result.nextCursor; + } + expect(seen).toEqual(['p0@example.org', 'p1@example.org', 'p2@example.org', 'p3@example.org', 'p4@example.org']); + + server.oldestHistoryId = Number.MAX_SAFE_INTEGER; + const recovered = await readAll(adapter, ctx, cursor); + expect(recovered.events.length).toBeGreaterThan(0); + expect(recovered.events.every((event) => event.eventId.startsWith('gmail:'))).toBe(true); + }); +}); + +describe('account health', () => { + it('is ok for the right mailbox, unhealthy for a different one, and asks to reconnect when the grant is revoked', async () => { + const { adapter, ctx } = await setup(); + expect(await adapter.account.health(ctx)).toEqual({ status: 'ok' }); + const other = { ...ctx, account: { ...ctx.account, sender: { name: 'X', address: 'someone-else@example.com' } } }; + expect((await adapter.account.health(other)).status).toBe('unhealthy'); + const revoked = await setup({ secret: JSON.stringify({ clientId: 'client-id', clientSecret: 'client-secret-value', refreshToken: 'wrong' }) }); + expect((await revoked.adapter.account.health(revoked.ctx)).status).toBe('reauth_required'); + }); + + it('defaults to manual correspondence until automated outreach is declared', () => { + expect(gmailAdapter().purposes).toEqual(['manual_correspondence']); + expect(gmailAdapter({ purposes: ['automated_outreach'] }).purposes).toEqual(['automated_outreach']); + }); +}); diff --git a/outreach-engine/packages/outreach-provider-email-gmail/src/http.ts b/outreach-engine/packages/outreach-provider-email-gmail/src/http.ts new file mode 100644 index 0000000..dd7ec03 --- /dev/null +++ b/outreach-engine/packages/outreach-provider-email-gmail/src/http.ts @@ -0,0 +1,55 @@ +import type { ErrorClass, ProviderContext } from '@splitin/outreach-contracts'; +import { jsonRequest, type HttpDeps as KitHttpDeps, type JsonResponse } from '@splitin/outreach-provider-kit'; + +export const GMAIL_API = 'https://gmail.googleapis.com'; + +export interface HttpDeps extends KitHttpDeps { + readonly api: string; +} + +export type GmailResponse = JsonResponse; + +export async function gmailRequest( + deps: HttpDeps, + ctx: ProviderContext, + token: string, + method: 'GET' | 'POST', + path: string, + options: { query?: Record; json?: unknown } = {}, +): Promise { + const url = new URL(path, deps.api); + for (const [name, value] of Object.entries(options.query ?? {})) { + if (value === undefined) continue; + for (const item of typeof value === 'string' ? [value] : value) url.searchParams.append(name, item); + } + return jsonRequest(deps, { url, method, token, signal: ctx.signal, ...(options.json === undefined ? {} : { json: options.json }) }); +} + +/** Google's error shape: {error: {code, message, status, errors: [{reason, message}]}}. */ +function errorDetail(body: Record): { reason: string; message: string } { + const error = (body.error ?? {}) as { message?: unknown; status?: unknown; errors?: { reason?: unknown }[] }; + const reason = typeof error.errors?.[0]?.reason === 'string' ? error.errors[0].reason : typeof error.status === 'string' ? error.status : ''; + return { reason, message: typeof error.message === 'string' ? error.message : '' }; +} + +const RATE_REASONS = /rateLimitExceeded|userRateLimitExceeded|dailyLimitExceeded|quotaExceeded|RESOURCE_EXHAUSTED/i; + +/** + * Maps a non-2xx Gmail response to the error taxonomy. `null` means the response does not prove the request + * was rejected (5xx): the caller must treat the outcome as unknown. + */ +export function classifyGmailError(response: GmailResponse): { errorClass: ErrorClass; retryAfterMs?: number; detail: string } | null { + const { status, body } = response; + const { reason, message } = errorDetail(body); + const detail = `Gmail HTTP ${status}${reason ? ` ${reason}` : ''}${message ? `: ${message.slice(0, 160)}` : ''}`; + if (status >= 500) return null; + if (status === 401) return { errorClass: 'auth_expired', detail }; + if (status === 429 || (status === 403 && RATE_REASONS.test(reason))) { + const fallback = /daily/i.test(reason) || /daily/i.test(message) ? 3_600_000 : 60_000; + return { errorClass: 'rate_limited', retryAfterMs: response.retryAfterMs ?? fallback, detail }; + } + if (status === 403 && /domainPolicy/i.test(reason)) return { errorClass: 'policy_blocked', detail }; + if (status === 403) return { errorClass: 'forbidden', detail }; + if (status === 400 && /recipient|to header|invalid to|address/i.test(message)) return { errorClass: 'invalid_recipient', detail }; + return { errorClass: 'content_rejected', detail }; +} diff --git a/outreach-engine/packages/outreach-provider-email-gmail/src/index.ts b/outreach-engine/packages/outreach-provider-email-gmail/src/index.ts new file mode 100644 index 0000000..4894ba4 --- /dev/null +++ b/outreach-engine/packages/outreach-provider-email-gmail/src/index.ts @@ -0,0 +1,92 @@ +import { TokenError, type AccessTokenSource, type AccountPort, type ProviderAdapter, type ProviderPurpose } from '@splitin/outreach-contracts'; +import { ProviderNetworkError } from '@splitin/outreach-provider-kit'; +import { GMAIL_API, gmailRequest, type HttpDeps } from './http'; +import { gmailMailbox } from './mailbox'; +import { gmailSender } from './sender'; +import { googleRefreshTokenSource } from './token'; + +export { buildMime, IDEMPOTENCY_HEADER, MimeError } from '@splitin/outreach-contracts'; +export { classifyGmailError, GMAIL_API } from './http'; +export { authorizeGoogle, GOOGLE_AUTH_URL, type GoogleAuthorization, type GoogleAuthorizeOptions } from './oauth'; +export { GOOGLE_TOKEN_URL, googleRefreshTokenSource, parseGoogleGrant, type GoogleGrant, type GoogleTokenOptions } from './token'; + +/** OAuth scopes the adapter needs: send, plus read for reconciliation (Sent search) and replies (History). */ +export const GMAIL_SCOPES = ['https://www.googleapis.com/auth/gmail.send', 'https://www.googleapis.com/auth/gmail.readonly'] as const; + +export interface GmailAdapterOptions { + /** + * Purposes this adapter may be used for. Defaults to `manual_correspondence` only: declare + * `automated_outreach` after reviewing Google's terms for your account (BUILD_PLAN.md §19 D1). + */ + readonly purposes?: readonly ProviderPurpose[]; + /** Where access tokens come from. Defaults to a refresh token stored behind the account's secretRef. */ + readonly tokens?: AccessTokenSource; + readonly api?: string; + readonly fetch?: typeof fetch; + readonly requestTimeoutMs?: number; + /** See SenderOptions.settleMs. Default 3 minutes. */ + readonly settleMs?: number; + /** Messages read per mailbox poll. Default 200. */ + readonly pollBudget?: number; +} + +/** The Gmail adapter: sends as the connected mailbox user through the Gmail API. */ +export function gmailAdapter(options: GmailAdapterOptions = {}): ProviderAdapter { + const doFetch = options.fetch ?? fetch; + const http: HttpDeps = { api: options.api ?? GMAIL_API, fetch: doFetch, timeoutMs: options.requestTimeoutMs ?? 30_000 }; + const tokens = options.tokens ?? googleRefreshTokenSource({ fetch: doFetch }); + + const account: AccountPort = { + async discover(ctx) { + return { + provider: 'gmail', + send: true, + replyInThread: true, + customHeaders: true, + externalIdempotency: false, + inboundWebhook: false, + mailboxPolling: true, + reconcileBySentSearch: true, + maxRecipientsPerMessage: 100, + discoveredAt: ctx.now(), + }; + }, + async health(ctx) { + let token: string; + try { + token = await tokens.get(ctx); + } catch (error) { + if (error instanceof TokenError && (error.errorClass === 'auth_revoked' || error.errorClass === 'auth_expired')) { + return { status: 'reauth_required', detail: error.message }; + } + return { status: 'degraded', detail: (error as Error).message }; + } + try { + const profile = await gmailRequest(http, ctx, token, 'GET', '/gmail/v1/users/me/profile'); + if (profile.status === 200) { + const address = typeof profile.body.emailAddress === 'string' ? profile.body.emailAddress.toLowerCase() : ''; + if (address && address !== ctx.account.sender.address.toLowerCase()) { + return { status: 'unhealthy', detail: `connected mailbox is ${address}, not the sender ${ctx.account.sender.address}` }; + } + return { status: 'ok' }; + } + if (profile.status === 401) { + tokens.invalidate(ctx); + return { status: 'reauth_required', detail: 'Gmail rejected the access token' }; + } + if (profile.status === 403) return { status: 'unhealthy', detail: 'Gmail refused access (scopes or account policy)' }; + return { status: 'degraded', detail: `Gmail HTTP ${profile.status}` }; + } catch (error) { + return { status: 'degraded', detail: error instanceof ProviderNetworkError ? error.message : (error as Error).name }; + } + }, + }; + + return { + name: 'gmail', + purposes: options.purposes ?? ['manual_correspondence'], + account, + email: gmailSender({ http, tokens, settleMs: options.settleMs ?? 180_000 }), + mailbox: gmailMailbox({ http, tokens, ...(options.pollBudget ? { budget: options.pollBudget } : {}) }), + }; +} diff --git a/outreach-engine/packages/outreach-provider-email-gmail/src/mailbox.ts b/outreach-engine/packages/outreach-provider-email-gmail/src/mailbox.ts new file mode 100644 index 0000000..3374132 --- /dev/null +++ b/outreach-engine/packages/outreach-provider-email-gmail/src/mailbox.ts @@ -0,0 +1,200 @@ +import type { AccessTokenSource, InboundMailEvent, MailboxReader, ProviderContext } from '@splitin/outreach-contracts'; +import { decodeHtmlEntities, looksLikeBounce as looksLikeBounceKit, parseDeliveryStatus } from '@splitin/outreach-provider-kit'; +import { gmailRequest, type HttpDeps } from './http'; + +export interface MailboxOptions { + readonly http: HttpDeps; + readonly tokens: AccessTokenSource; + /** Messages fetched per poll; the cursor resumes after the last processed history record. */ + readonly budget?: number; +} + +const USERS_ME = '/gmail/v1/users/me'; +const METADATA = [ + 'From', 'To', 'Subject', 'Message-ID', 'In-Reply-To', 'References', 'Content-Type', 'Auto-Submitted', + 'Precedence', 'X-Autoreply', 'X-Autorespond', 'X-Failed-Recipients', 'List-Id', +]; +const ADDRESS = /[^\s<>,;:"()]+@[^\s<>,;:"()]+/g; + +interface Part { + mimeType?: string; + headers?: { name?: string; value?: string }[]; + body?: { data?: string }; + parts?: Part[]; +} + +interface GmailMessage { + id: string; + threadId?: string; + labelIds?: string[]; + snippet?: string; + internalDate?: string; + payload?: Part; +} + +/** Cursor: the last history record processed. JSON so the format can grow. */ +interface Cursor { + readonly h: string; +} + +function readCursor(cursor: string | null): Cursor | null { + if (!cursor) return null; + try { + const value = JSON.parse(cursor) as Partial; + return typeof value.h === 'string' ? { h: value.h } : null; + } catch { + return null; + } +} + +const write = (h: string): string => JSON.stringify({ h }); + +function headersOf(part: Part | undefined): Record { + const out: Record = {}; + for (const header of part?.headers ?? []) if (header.name && header.value !== undefined) out[header.name] = header.value; + return out; +} + +function find(headers: Record, name: string): string | undefined { + const key = Object.keys(headers).find((candidate) => candidate.toLowerCase() === name.toLowerCase()); + return key === undefined ? undefined : headers[key]; +} + +function decode(data: string | undefined): string { + return data ? Buffer.from(data, 'base64url').toString('utf8') : ''; +} + +function walk(part: Part | undefined, visit: (part: Part) => void): void { + if (!part) return; + visit(part); + for (const child of part.parts ?? []) walk(child, visit); +} + +function looksLikeBounce(headers: Record): boolean { + return looksLikeBounceKit(find(headers, 'From') ?? '', find(headers, 'Content-Type') ?? ''); +} + +/** + * Status, recipient and original Message-ID from the message/delivery-status part. Without that part the + * status stays empty, so the classifier sends the message to review instead of suppressing anyone. + */ +function parseDsn(message: GmailMessage): InboundMailEvent['dsn'] { + let status = ''; + let recipient: string | undefined; + let originalMessageId: string | undefined; + walk(message.payload, (part) => { + const type = part.mimeType?.toLowerCase(); + if (type === 'message/delivery-status') { + const parsed = parseDeliveryStatus(decode(part.body?.data)); + status ||= parsed.status; + recipient ??= parsed.recipient; + } + if (type === 'text/rfc822-headers' || type === 'message/rfc822') { + const text = decode(part.body?.data); + originalMessageId = /^Message-ID:\s*(<[^>\s]+>)/im.exec(text)?.[1] ?? find(headersOf(part), 'Message-ID') ?? originalMessageId; + for (const child of part.parts ?? []) originalMessageId ??= find(headersOf(child), 'Message-ID'); + } + }); + return { status, ...(recipient ? { recipient } : {}), ...(originalMessageId ? { originalMessageId } : {}) }; +} + +export function gmailMailbox(options: MailboxOptions): MailboxReader { + const { http, tokens } = options; + const budget = options.budget ?? 200; + + const get = async (ctx: ProviderContext, token: string, path: string, query: Record) => { + const response = await gmailRequest(http, ctx, token, 'GET', path, { query }); + if (response.status === 401) tokens.invalidate(ctx); + return response; + }; + + const toEvent = async (ctx: ProviderContext, token: string, id: string): Promise => { + const meta = await get(ctx, token, `${USERS_ME}/messages/${encodeURIComponent(id)}`, { format: 'metadata', metadataHeaders: METADATA }); + if (meta.status === 404) return null; // Deleted since it arrived. + if (meta.status !== 200) throw new Error(`Gmail HTTP ${meta.status} reading message`); + let message = meta.body as unknown as GmailMessage; + if ((message.labelIds ?? []).some((label) => label === 'SENT' || label === 'DRAFT')) return null; + const headers = headersOf(message.payload); + const bounce = looksLikeBounce(headers); + if (bounce) { + const full = await get(ctx, token, `${USERS_ME}/messages/${encodeURIComponent(id)}`, { format: 'full' }); + if (full.status === 200) message = full.body as unknown as GmailMessage; + } + const rfcMessageId = find(headers, 'Message-ID'); + const inReplyTo = find(headers, 'In-Reply-To')?.match(/<[^>\s]+>/)?.[0]; + const contentType = find(headers, 'Content-Type'); + const subject = find(headers, 'Subject'); + return { + eventId: `gmail:${message.id}`, + kind: bounce ? 'bounce' : 'message', + providerMessageId: message.id, + ...(message.threadId ? { providerThreadId: message.threadId } : {}), + ...(rfcMessageId ? { rfcMessageId } : {}), + ...(inReplyTo ? { inReplyTo } : {}), + references: find(headers, 'References')?.match(/<[^>\s]+>/g) ?? [], + from: (find(headers, 'From')?.match(ADDRESS)?.[0] ?? '').toLowerCase(), + to: (find(headers, 'To')?.match(ADDRESS) ?? []).map((address) => address.toLowerCase()), + ...(subject !== undefined ? { subject } : {}), + receivedAt: Number(message.internalDate) || ctx.now(), + headers, + ...(contentType ? { contentType } : {}), + ...(bounce ? { dsn: parseDsn(message) } : {}), + ...(message.snippet ? { snippet: decodeHtmlEntities(message.snippet).slice(0, 500) } : {}), + }; + }; + + return { + async readChanges(ctx, cursorText) { + const token = await tokens.get(ctx); + const cursor = readCursor(cursorText); + if (!cursor) { + // First poll: start from now. Mail that arrived before the account was connected is not replayed. + const profile = await get(ctx, token, `${USERS_ME}/profile`, {}); + if (profile.status !== 200 || typeof profile.body.historyId !== 'string') throw new Error(`Gmail HTTP ${profile.status} reading profile`); + return { events: [], nextCursor: write(profile.body.historyId) }; + } + + const events: InboundMailEvent[] = []; + let fetched = 0; + let last = cursor.h; + let pageToken: string | undefined; + do { + const page = await get(ctx, token, `${USERS_ME}/history`, { startHistoryId: cursor.h, historyTypes: 'messageAdded', pageToken }); + if (page.status === 404) return recover(ctx, token); + if (page.status !== 200) throw new Error(`Gmail HTTP ${page.status} reading history`); + const records = (page.body.history as { id: string; messagesAdded?: { message: GmailMessage }[] }[] | undefined) ?? []; + for (const record of records) { + const added = record.messagesAdded ?? []; + if (fetched > 0 && fetched + added.length > budget) return { events, nextCursor: write(last) }; + for (const { message } of added) { + if ((message.labelIds ?? []).some((label) => label === 'SENT' || label === 'DRAFT')) continue; + fetched += 1; + const event = await toEvent(ctx, token, message.id); + if (event) events.push(event); + } + last = record.id; + } + pageToken = typeof page.body.nextPageToken === 'string' ? page.body.nextPageToken : undefined; + if (!pageToken && typeof page.body.historyId === 'string') last = page.body.historyId; + } while (pageToken); + return { events, nextCursor: write(last) }; + }, + }; + + /** + * The history cursor expired (Gmail keeps about a week). Re-read recent inbox mail instead; events are + * deduplicated by `eventId`, so overlap with earlier polls is harmless. + */ + async function recover(ctx: ProviderContext, token: string) { + const profile = await get(ctx, token, `${USERS_ME}/profile`, {}); + if (profile.status !== 200 || typeof profile.body.historyId !== 'string') throw new Error(`Gmail HTTP ${profile.status} reading profile`); + const listed = await get(ctx, token, `${USERS_ME}/messages`, { labelIds: 'INBOX', maxResults: String(Math.min(budget, 100)) }); + if (listed.status !== 200) throw new Error(`Gmail HTTP ${listed.status} listing inbox`); + const events: InboundMailEvent[] = []; + for (const item of (listed.body.messages as { id: string }[] | undefined) ?? []) { + const event = await toEvent(ctx, token, item.id); + if (event) events.push(event); + } + return { events, nextCursor: write(profile.body.historyId) }; + } +} diff --git a/outreach-engine/packages/outreach-provider-email-gmail/src/oauth.test.ts b/outreach-engine/packages/outreach-provider-email-gmail/src/oauth.test.ts new file mode 100644 index 0000000..3f2deb6 --- /dev/null +++ b/outreach-engine/packages/outreach-provider-email-gmail/src/oauth.test.ts @@ -0,0 +1,48 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { FakeGmailServer } from '@splitin/outreach-fakes'; +import { authorizeGoogle, GMAIL_SCOPES } from './index'; + +const servers: FakeGmailServer[] = []; +afterEach(async () => { + await Promise.all(servers.splice(0).map((server) => server.stop())); +}); + +async function connect(server: FakeGmailServer, visit: (url: string) => Promise = (url) => fetch(url)) { + return authorizeGoogle({ + clientId: 'client-id', + clientSecret: 'client-secret-value', + scopes: GMAIL_SCOPES, + authUrl: `${server.url}/authorize`, + tokenUrl: `${server.url}/token`, + api: server.url, + timeoutMs: 5_000, + onUrl: (url) => void visit(url), // Stands in for the user's browser: follows Google's redirect to the loopback. + }); +} + +describe('authorizeGoogle (loopback + PKCE)', () => { + it('returns a refresh-token grant and the mailbox Gmail reports', async () => { + const server = await new FakeGmailServer({ mailbox: 'Sam@Example.com' }).start(); + servers.push(server); + const result = await connect(server); + expect(result.emailAddress).toBe('sam@example.com'); + expect(result.grant).toEqual({ clientId: 'client-id', clientSecret: 'client-secret-value', refreshToken: 'refresh-token-value' }); + }); + + it('refuses a grant missing a scope the adapter needs', async () => { + const server = await new FakeGmailServer().start(); + servers.push(server); + server.grantedScopes = ['https://www.googleapis.com/auth/gmail.send']; + await expect(connect(server)).rejects.toThrow(/missing scopes: .*gmail\.readonly/); + }); + + it('rejects a callback with the wrong state', async () => { + const server = await new FakeGmailServer().start(); + servers.push(server); + const forged = (url: string) => { + const redirect = new URL(new URL(url).searchParams.get('redirect_uri') ?? ''); + return fetch(`${redirect.origin}/callback?code=code-1&state=forged`); + }; + await expect(connect(server, forged)).rejects.toThrow(/mismatched state/); + }); +}); diff --git a/outreach-engine/packages/outreach-provider-email-gmail/src/oauth.ts b/outreach-engine/packages/outreach-provider-email-gmail/src/oauth.ts new file mode 100644 index 0000000..2d5ef08 --- /dev/null +++ b/outreach-engine/packages/outreach-provider-email-gmail/src/oauth.ts @@ -0,0 +1,74 @@ +import { loopbackAuthorize } from '@splitin/outreach-provider-kit'; +import { GOOGLE_TOKEN_URL, type GoogleGrant } from './token'; + +export const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'; + +export interface GoogleAuthorizeOptions { + readonly clientId: string; + readonly clientSecret: string; + readonly scopes: readonly string[]; + /** Pre-selects the mailbox in Google's account chooser. */ + readonly loginHint?: string; + /** Receives the URL the user must open; the CLI prints it (and may open a browser). */ + readonly onUrl: (url: string) => void; + readonly authUrl?: string; + readonly tokenUrl?: string; + readonly api?: string; + readonly fetch?: typeof fetch; + readonly timeoutMs?: number; +} + +export interface GoogleAuthorization { + readonly grant: GoogleGrant; + /** The mailbox that granted access, read from Gmail itself (not from what the user typed). */ + readonly emailAddress: string; + readonly scopes: readonly string[]; +} + +/** Google's installed-app flow (loopback redirect, PKCE). Resolves with a refresh-token grant. */ +export async function authorizeGoogle(options: GoogleAuthorizeOptions): Promise { + const doFetch = options.fetch ?? fetch; + return loopbackAuthorize({ + authUrl: options.authUrl ?? GOOGLE_AUTH_URL, + params: { + client_id: options.clientId, + scope: options.scopes.join(' '), + access_type: 'offline', + prompt: 'consent', + ...(options.loginHint ? { login_hint: options.loginHint } : {}), + }, + redirectHost: '127.0.0.1', + onUrl: options.onUrl, + ...(options.timeoutMs ? { timeoutMs: options.timeoutMs } : {}), + exchange: async ({ code, verifier, redirectUri }) => { + const exchange = await doFetch(options.tokenUrl ?? GOOGLE_TOKEN_URL, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: options.clientId, + client_secret: options.clientSecret, + code, + code_verifier: verifier, + redirect_uri: redirectUri, + grant_type: 'authorization_code', + }).toString(), + }); + const tokens = (await exchange.json().catch(() => ({}))) as { access_token?: string; refresh_token?: string; scope?: string; error?: string }; + if (!exchange.ok || !tokens.access_token) throw new Error(`token exchange failed (${tokens.error ?? `HTTP ${exchange.status}`})`); + if (!tokens.refresh_token) throw new Error('Google returned no refresh token; remove the app under myaccount.google.com/permissions and connect again'); + const granted = (tokens.scope ?? options.scopes.join(' ')).split(' '); + const missing = options.scopes.filter((scope) => !granted.includes(scope)); + if (missing.length) throw new Error(`the grant is missing scopes: ${missing.join(', ')} (tick every box on the consent screen)`); + const profile = await doFetch(new URL('/gmail/v1/users/me/profile', options.api ?? 'https://gmail.googleapis.com'), { + headers: { authorization: `Bearer ${tokens.access_token}` }, + }); + const body = (await profile.json().catch(() => ({}))) as { emailAddress?: string }; + if (!profile.ok || !body.emailAddress) throw new Error(`could not read the connected mailbox (HTTP ${profile.status})`); + return { + grant: { clientId: options.clientId, clientSecret: options.clientSecret, refreshToken: tokens.refresh_token }, + emailAddress: body.emailAddress.toLowerCase(), + scopes: granted, + }; + }, + }); +} diff --git a/outreach-engine/packages/outreach-provider-email-gmail/src/sender.ts b/outreach-engine/packages/outreach-provider-email-gmail/src/sender.ts new file mode 100644 index 0000000..9fba4ea --- /dev/null +++ b/outreach-engine/packages/outreach-provider-email-gmail/src/sender.ts @@ -0,0 +1,180 @@ +import { + TokenError, + type AccessTokenSource, + type ApprovedEmail, + type EmailSender, + type ProviderContext, + type ProviderReceipt, + type ReconcileResult, + type SendResult, + type UncertainEmail, +} from '@splitin/outreach-contracts'; +import { ProviderNetworkError } from '@splitin/outreach-provider-kit'; +import { classifyGmailError, gmailRequest, type GmailResponse, type HttpDeps } from './http'; +import { buildMime, IDEMPOTENCY_HEADER, MimeError } from '@splitin/outreach-contracts'; + +export interface SenderOptions { + readonly http: HttpDeps; + readonly tokens: AccessTokenSource; + /** + * How long after an attempt the Sent folder must be free of the message before reconciliation may answer + * "absent". Covers a request Gmail accepted after our timeout fired. + */ + readonly settleMs: number; + /** How far back from the attempt the Sent scan looks, to allow for clock skew. */ + readonly scanWindowMs?: number; + /** Upper bound on Sent messages inspected per reconciliation. */ + readonly scanLimit?: number; +} + +const USERS_ME = '/gmail/v1/users/me'; + +function tokenFailure(error: unknown): { errorClass: TokenError['errorClass']; detail: string } { + if (error instanceof TokenError) return { errorClass: error.errorClass, detail: error.message }; + return { errorClass: 'transient', detail: `token unavailable (${(error as Error).name})` }; +} + +function headerValue(message: Record, name: string): string | undefined { + const headers = ((message.payload as { headers?: { name?: string; value?: string }[] } | undefined)?.headers ?? []); + return headers.find((header) => header.name?.toLowerCase() === name.toLowerCase())?.value; +} + +export function gmailSender(options: SenderOptions): EmailSender { + const { http, tokens } = options; + const scanWindowMs = options.scanWindowMs ?? 15 * 60_000; + const scanLimit = options.scanLimit ?? 200; + + const receiptFor = async (ctx: ProviderContext, token: string, id: string, threadId: string | undefined): Promise => { + // Gmail may assign its own Message-ID; replies reference the stored one, so read it back (best effort). + let rfcMessageId: string | undefined; + try { + const stored = await gmailRequest(http, ctx, token, 'GET', `${USERS_ME}/messages/${encodeURIComponent(id)}`, { + query: { format: 'metadata', metadataHeaders: ['Message-ID'] }, + }); + if (stored.status === 200) rfcMessageId = headerValue(stored.body, 'Message-ID'); + } catch { + // The send is accepted either way; the engine falls back to the Message-ID it generated. + } + return { + providerMessageId: id, + ...(threadId ? { providerThreadId: threadId } : {}), + ...(rfcMessageId ? { rfcMessageId } : {}), + acceptedAt: ctx.now(), + raw: { gmailId: id, ...(threadId ? { gmailThreadId: threadId } : {}) }, + }; + }; + + const post = (ctx: ProviderContext, token: string, raw: string, threadId: string | undefined): Promise => + gmailRequest(http, ctx, token, 'POST', `${USERS_ME}/messages/send`, { json: { raw, ...(threadId ? { threadId } : {}) } }); + + return { + async send(ctx, email: ApprovedEmail): Promise { + let raw: string; + try { + raw = Buffer.from(buildMime(email, new Date(ctx.now())), 'utf8').toString('base64url'); + } catch (error) { + if (error instanceof MimeError) return { kind: 'rejected', errorClass: 'content_rejected', detail: error.message }; + throw error; + } + let token: string; + try { + token = await tokens.get(ctx); + } catch (error) { + return { kind: 'rejected', ...tokenFailure(error) }; + } + let response: GmailResponse; + try { + response = await post(ctx, token, raw, email.providerThreadId); + // A thread the user deleted is refused outright (nothing sent), so sending unthreaded is safe. On send + // the thread is the only entity that can be missing, so any 404 with a thread id means that. + const staleThread = response.status === 404 || (response.status === 400 && /thread/i.test(JSON.stringify(response.body))); + if (email.providerThreadId && staleThread) { + response = await post(ctx, token, raw, undefined); + } + } catch (error) { + if (error instanceof ProviderNetworkError && !error.reachedServer) return { kind: 'rejected', errorClass: 'transient', detail: error.message }; + return { kind: 'unknown', detail: error instanceof ProviderNetworkError ? error.message : `send failed (${(error as Error).name})` }; + } + if (response.status === 200 && typeof response.body.id === 'string') { + const threadId = typeof response.body.threadId === 'string' ? response.body.threadId : undefined; + return { kind: 'accepted', receipt: await receiptFor(ctx, token, response.body.id, threadId) }; + } + if (response.status === 401) tokens.invalidate(ctx); + const rejected = classifyGmailError(response); + if (!rejected) return { kind: 'unknown', detail: `Gmail HTTP ${response.status} on send` }; + return { kind: 'rejected', ...rejected }; + }, + + async reconcile(ctx, email: UncertainEmail): Promise { + let token: string; + try { + token = await tokens.get(ctx); + } catch (error) { + return { kind: 'still_unknown', detail: tokenFailure(error).detail }; + } + try { + const matches = async (id: string): Promise => { + const message = await gmailRequest(http, ctx, token, 'GET', `${USERS_ME}/messages/${encodeURIComponent(id)}`, { + query: { format: 'metadata', metadataHeaders: ['Message-ID', IDEMPOTENCY_HEADER] }, + }); + if (message.status !== 200) return null; + const key = headerValue(message.body, IDEMPOTENCY_HEADER); + const rfc = headerValue(message.body, 'Message-ID'); + if (key === email.idempotencyKey || rfc === email.rfcMessageId) { + const threadId = typeof message.body.threadId === 'string' ? message.body.threadId : undefined; + return { + providerMessageId: id, + ...(threadId ? { providerThreadId: threadId } : {}), + ...(rfc ? { rfcMessageId: rfc } : {}), + acceptedAt: Number(message.body.internalDate) || ctx.now(), + raw: { gmailId: id, via: 'reconcile' }, + }; + } + return Number(message.body.internalDate) < email.attemptedAt - scanWindowMs ? 'older' : null; + }; + + // Fast path: the search index usually has the message within seconds. + const search = await gmailRequest(http, ctx, token, 'GET', `${USERS_ME}/messages`, { + query: { q: `rfc822msgid:${email.rfcMessageId.replace(/^<|>$/g, '')}`, includeSpamTrash: 'true', maxResults: '5' }, + }); + if (search.status === 401) { + tokens.invalidate(ctx); + return { kind: 'still_unknown', detail: 'access token rejected; retrying with a fresh one next time' }; + } + for (const hit of (search.body.messages as { id: string }[] | undefined) ?? []) { + const found = await matches(hit.id); + if (found && found !== 'older') return { kind: 'found', receipt: found }; + } + + // Authoritative path: walk the Sent label newest first, independent of search indexing. + let pageToken: string | undefined; + let inspected = 0; + let reachedOlder = false; + scan: do { + const page = await gmailRequest(http, ctx, token, 'GET', `${USERS_ME}/messages`, { + query: { labelIds: 'SENT', includeSpamTrash: 'true', maxResults: '50', pageToken }, + }); + if (page.status !== 200) return { kind: 'still_unknown', detail: `Sent scan failed with HTTP ${page.status}` }; + for (const item of (page.body.messages as { id: string }[] | undefined) ?? []) { + inspected += 1; + const found = await matches(item.id); + if (found === 'older') { + reachedOlder = true; + break scan; + } + if (found) return { kind: 'found', receipt: found }; + if (inspected >= scanLimit) break scan; + } + pageToken = typeof page.body.nextPageToken === 'string' ? page.body.nextPageToken : undefined; + if (!pageToken) reachedOlder = true; // The whole Sent folder was inspected. + } while (pageToken); + + if (!reachedOlder) return { kind: 'still_unknown', detail: `Sent folder not fully scanned (${inspected} messages inspected)` }; + if (ctx.now() - email.attemptedAt < options.settleMs) return { kind: 'still_unknown', detail: 'not in Sent yet; waiting for the settle window' }; + return { kind: 'absent' }; + } catch (error) { + return { kind: 'still_unknown', detail: error instanceof ProviderNetworkError ? error.message : `reconcile failed (${(error as Error).name})` }; + } + }, + }; +} diff --git a/outreach-engine/packages/outreach-provider-email-gmail/src/token.ts b/outreach-engine/packages/outreach-provider-email-gmail/src/token.ts new file mode 100644 index 0000000..58699bc --- /dev/null +++ b/outreach-engine/packages/outreach-provider-email-gmail/src/token.ts @@ -0,0 +1,75 @@ +import { TokenError, type AccessTokenSource, type ProviderContext } from '@splitin/outreach-contracts'; + +export const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'; + +/** What the account's `secretRef` must resolve to: a JSON object from an installed-app OAuth grant. */ +export interface GoogleGrant { + readonly clientId: string; + readonly clientSecret: string; + readonly refreshToken: string; +} + +export function parseGoogleGrant(raw: string): GoogleGrant { + let value: Partial; + try { + value = JSON.parse(raw) as Partial; + } catch { + throw new TokenError('auth_revoked', 'the Gmail account secret is not JSON {clientId, clientSecret, refreshToken}'); + } + if (typeof value.clientId !== 'string' || typeof value.clientSecret !== 'string' || typeof value.refreshToken !== 'string') { + throw new TokenError('auth_revoked', 'the Gmail account secret must hold clientId, clientSecret and refreshToken'); + } + return value as GoogleGrant; +} + +export interface GoogleTokenOptions { + readonly tokenUrl?: string; + readonly fetch?: typeof fetch; +} + +/** + * Access tokens from a refresh token held behind the account's `secretRef`, cached per account until one + * minute before expiry. Error messages never contain the secret or the tokens. + */ +export function googleRefreshTokenSource(options: GoogleTokenOptions = {}): AccessTokenSource { + const cache = new Map(); + const doFetch = options.fetch ?? fetch; + const key = (ctx: ProviderContext) => `${ctx.account.id}\u0000${ctx.secretRef}`; + return { + async get(ctx) { + const cached = cache.get(key(ctx)); + if (cached && cached.expiresAt > ctx.now()) return cached.token; + const grant = parseGoogleGrant(await ctx.secrets.get(ctx.secretRef)); + let response: Response; + try { + response = await doFetch(options.tokenUrl ?? GOOGLE_TOKEN_URL, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: grant.clientId, + client_secret: grant.clientSecret, + refresh_token: grant.refreshToken, + grant_type: 'refresh_token', + }).toString(), + signal: ctx.signal, + }); + } catch { + throw new TokenError('transient', 'the Google token endpoint could not be reached'); + } + const body = (await response.json().catch(() => ({}))) as { access_token?: string; expires_in?: number; error?: string }; + if (!response.ok || typeof body.access_token !== 'string') { + if (body.error === 'invalid_grant' || body.error === 'invalid_client' || body.error === 'unauthorized_client') { + throw new TokenError('auth_revoked', `Google refused the stored grant (${body.error}); reconnect the account`); + } + const errorClass = response.status >= 500 || response.status === 429 ? 'transient' : 'auth_expired'; + throw new TokenError(errorClass, `token refresh failed with HTTP ${response.status}`); + } + const lifetimeSeconds = typeof body.expires_in === 'number' ? body.expires_in : 3600; + cache.set(key(ctx), { token: body.access_token, expiresAt: ctx.now() + Math.max(0, lifetimeSeconds - 60) * 1000 }); + return body.access_token; + }, + invalidate(ctx) { + cache.delete(key(ctx)); + }, + }; +} diff --git a/outreach-engine/packages/outreach-provider-email-gmail/tsconfig.json b/outreach-engine/packages/outreach-provider-email-gmail/tsconfig.json new file mode 100644 index 0000000..585a92d --- /dev/null +++ b/outreach-engine/packages/outreach-provider-email-gmail/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist" }, + "include": ["src"] +} diff --git a/outreach-engine/packages/outreach-provider-email-gmail/tsup.config.ts b/outreach-engine/packages/outreach-provider-email-gmail/tsup.config.ts new file mode 100644 index 0000000..1343a99 --- /dev/null +++ b/outreach-engine/packages/outreach-provider-email-gmail/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/packages/outreach-provider-email-outlook/README.md b/outreach-engine/packages/outreach-provider-email-outlook/README.md new file mode 100644 index 0000000..d540463 --- /dev/null +++ b/outreach-engine/packages/outreach-provider-email-outlook/README.md @@ -0,0 +1,52 @@ +# @splitin/outreach-provider-email-outlook + +Outlook adapter for the outreach engine. It sends as the connected Exchange Online (Microsoft 365) mailbox through Microsoft Graph. Decision D1 in [BUILD_PLAN.md §19](../../BUILD_PLAN.md) explains why the engine uses mailbox adapters. + +## How it behaves + +| Concern | Behaviour | +| --- | --- | +| Send | Draft first, then send. The message is created from the shared MIME builder (`POST /me/messages` with base64 MIME), because Graph's `internetMessageHeaders` only accepts `X-` headers and `List-Unsubscribe` would otherwise be lost. Then `POST /me/messages/{id}/send`. | +| Outcome unknown | Creating a draft never sends anything, so every failure before the send call is a transient rejection. Only the send call can be `unknown`: a timeout, a reset or a 5xx. A refused send discards its draft. An unknown one keeps it until reconciliation decides. | +| Reconcile | 1. Sent Items filtered by our Message-ID. 2. A walk of Sent Items, newest first, that matches the `X-Outreach-Key` header, because Exchange may replace the Message-ID. The adapter answers `absent` only after that walk and after `settleMs` (default 3 min). It then deletes the orphaned draft so nobody sends it by hand later. | +| Ids | The provider message id is the `internetMessageId`. Exchange gives the Sent Items copy a new item id, so the Graph item id is not stable across the send. | +| Inbound | The Inbox is polled by `receivedDateTime`, with a 2-minute overlap for late indexing. Events are deduplicated by id. The first poll starts from now. | +| Bounces | Exchange NDRs (`postmaster@`, `MicrosoftExchange…@`, `Undeliverable:`) are read as raw MIME (`/$value`), and their delivery-status part gives status, recipient and original Message-ID. Without that part the bounce goes to review. | +| Tokens | Microsoft rotates refresh tokens on every use. The adapter writes the new one back through the secret store (`file:` secrets support this), so the grant doesn't lapse after about 90 days. With `env:` secrets it cannot write back. | +| Health | `ok` only if the connected mailbox (`mail` or `userPrincipalName`) is the account's sender address. A revoked grant reports `reauth_required`. | +| Errors | 429, `ApplicationThrottled` and quota codes map to `rate_limited`, with Retry-After or 1 h for quotas. `ErrorMessageSubmissionBlocked` maps to `policy_blocked`. Other 403s map to `forbidden`. `ErrorInvalidRecipients` maps to `invalid_recipient`. | + +Only the Inbox is read, so replies that Exchange files as junk are not seen. + +## Purposes + +The adapter defaults to `manual_correspondence`. Declare `automated_outreach` after reviewing Microsoft's terms for your tenant: + +```js +// outreach.config.mjs +import { outlookAdapter } from '@splitin/outreach-provider-email-outlook'; + +export default { adapters: [outlookAdapter({ purposes: ['manual_correspondence', 'automated_outreach'] })] }; +``` + +## Setup (Microsoft Entra ID) + +1. **Register an app.** Choose "Accounts in this organizational directory only" and the platform **Mobile and desktop applications**, with redirect URI `http://localhost`. No client secret is needed; this is a public client and uses PKCE. +2. **Add delegated Microsoft Graph permissions:** `Mail.Send`, `Mail.ReadWrite`, `User.Read` and `offline_access`. `Mail.ReadWrite` is required because sending starts from a draft. Grant admin consent if your tenant requires it. +3. **Connect the mailbox you will send from.** The grant is written to an owner-only (0600) file, and rotated tokens are saved back to it: + ```zsh + outreach account connect outlook --tenant --client-id \ + --login-hint sam@contoso.com + ``` +4. **Register the account** with the printed `secretRef`: + ```zsh + outreach account add --provider outlook --external-id sam@contoso.com --sender-email sam@contoso.com \ + --sender-name "Sam" --postal "1 Example St, City" --purposes automated_outreach \ + --secret file:~/.config/outreach/outlook-sam@contoso.com.json + ``` + +Exchange Online allows 10,000 recipients a day and 30 messages a minute per mailbox, and Microsoft is tightening external-recipient limits. Deliverability limits you first: keep `accountPerDay` at 30–50 for cold outreach, from a secondary warmed-up domain. + +## Testing + +`FakeGraphServer` in `@splitin/outreach-fakes` speaks the parts of Graph and the Microsoft identity platform this adapter uses, over real HTTP. It supports drafts, send moving the message to Sent Items under a new id, OData filters and paging, raw MIME, authorization-code and refresh grants with rotation, and forced send outcomes. The adapter passes the email conformance kit against it. `outreach-e2e/src/outlook.e2e.test.ts` covers the engine end to end: a lost send, a reply, an NDR and token rotation. diff --git a/outreach-engine/packages/outreach-provider-email-outlook/package.json b/outreach-engine/packages/outreach-provider-email-outlook/package.json new file mode 100644 index 0000000..7d83452 --- /dev/null +++ b/outreach-engine/packages/outreach-provider-email-outlook/package.json @@ -0,0 +1,47 @@ +{ + "name": "@splitin/outreach-provider-email-outlook", + "version": "0.0.0", + "description": "Microsoft Graph (Outlook / Exchange Online) adapter for the outreach engine: MIME draft-then-send, Sent Items reconciliation, inbox polling, rotating refresh tokens.", + "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-provider-email-outlook" + }, + "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-provider-kit": "0.0.0" + }, + "devDependencies": { + "@splitin/outreach-fakes": "0.0.0" + } +} diff --git a/outreach-engine/packages/outreach-provider-email-outlook/src/graph.ts b/outreach-engine/packages/outreach-provider-email-outlook/src/graph.ts new file mode 100644 index 0000000..1c1d8cc --- /dev/null +++ b/outreach-engine/packages/outreach-provider-email-outlook/src/graph.ts @@ -0,0 +1,67 @@ +import type { ErrorClass, ProviderContext } from '@splitin/outreach-contracts'; +import { jsonRequest, textRequest, type HttpDeps as KitHttpDeps, type JsonResponse } from '@splitin/outreach-provider-kit'; + +export const GRAPH_API = 'https://graph.microsoft.com'; + +export interface HttpDeps extends KitHttpDeps { + readonly api: string; +} + +type Query = Record; + +function url(deps: HttpDeps, path: string, query: Query = {}): URL { + // `path` may be a full @odata.nextLink; it must stay on the configured API host. + const target = /^https?:\/\//.test(path) ? new URL(path) : new URL(`/v1.0${path}`, deps.api); + if (target.origin !== new URL(deps.api).origin) throw new Error(`refusing to follow a link to ${target.origin}`); + for (const [name, value] of Object.entries(query)) if (value !== undefined) target.searchParams.set(name, value); + return target; +} + +export function graphGet(deps: HttpDeps, ctx: ProviderContext, token: string, path: string, query?: Query): Promise { + return jsonRequest(deps, { url: url(deps, path, query), method: 'GET', token, signal: ctx.signal }); +} + +export function graphRaw(deps: HttpDeps, ctx: ProviderContext, token: string, path: string): Promise<{ status: number; text: string }> { + return textRequest(deps, { url: url(deps, path), method: 'GET', token, signal: ctx.signal }); +} + +export function graphPost(deps: HttpDeps, ctx: ProviderContext, token: string, path: string, body?: { mime: string }): Promise { + return jsonRequest(deps, { + url: url(deps, path), + method: 'POST', + token, + signal: ctx.signal, + // Graph accepts a message as base64 MIME with Content-Type text/plain; that is the only way to set + // non-X- headers such as List-Unsubscribe. + ...(body ? { text: body.mime, contentType: 'text/plain' } : {}), + }); +} + +export function graphDelete(deps: HttpDeps, ctx: ProviderContext, token: string, path: string): Promise { + return jsonRequest(deps, { url: url(deps, path), method: 'DELETE', token, signal: ctx.signal }); +} + +/** Graph's error shape: {error: {code, message}}. */ +function errorOf(body: Record): { code: string; message: string } { + const error = (body.error ?? {}) as { code?: unknown; message?: unknown }; + return { code: typeof error.code === 'string' ? error.code : '', message: typeof error.message === 'string' ? error.message : '' }; +} + +/** + * Maps a non-2xx Graph response to the error taxonomy. `null` means the response does not prove the request + * was refused (5xx): for a send, the outcome is unknown. + */ +export function classifyGraphError(response: JsonResponse): { errorClass: ErrorClass; retryAfterMs?: number; detail: string } | null { + const { status } = response; + const { code, message } = errorOf(response.body); + const detail = `Graph HTTP ${status}${code ? ` ${code}` : ''}${message ? `: ${message.slice(0, 160)}` : ''}`; + if (status >= 500) return null; + if (status === 401) return { errorClass: 'auth_expired', detail }; + if (status === 429 || /QuotaExceeded|ExceededMessageLimit|SendQuota|ApplicationThrottled/i.test(code)) { + return { errorClass: 'rate_limited', retryAfterMs: response.retryAfterMs ?? (/Quota|Limit/i.test(code) ? 3_600_000 : 60_000), detail }; + } + if (/MessageSubmissionBlocked|AccountSuspended/i.test(code)) return { errorClass: 'policy_blocked', detail }; + if (status === 403) return { errorClass: 'forbidden', detail }; + if (/InvalidRecipients|InvalidRecipient/i.test(code)) return { errorClass: 'invalid_recipient', detail }; + return { errorClass: 'content_rejected', detail }; +} diff --git a/outreach-engine/packages/outreach-provider-email-outlook/src/index.ts b/outreach-engine/packages/outreach-provider-email-outlook/src/index.ts new file mode 100644 index 0000000..ad79b53 --- /dev/null +++ b/outreach-engine/packages/outreach-provider-email-outlook/src/index.ts @@ -0,0 +1,89 @@ +import { TokenError, type AccessTokenSource, type AccountPort, type ProviderAdapter, type ProviderPurpose } from '@splitin/outreach-contracts'; +import { ProviderNetworkError } from '@splitin/outreach-provider-kit'; +import { GRAPH_API, graphGet, type HttpDeps } from './graph'; +import { outlookMailbox } from './mailbox'; +import { outlookSender } from './sender'; +import { microsoftRefreshTokenSource } from './token'; + +export { classifyGraphError, GRAPH_API } from './graph'; +export { authorizeMicrosoft, type MicrosoftAuthorization, type MicrosoftAuthorizeOptions } from './oauth'; +export { GRAPH_SCOPES, MICROSOFT_AUTHORITY, microsoftRefreshTokenSource, parseMicrosoftGrant, type MicrosoftGrant, type MicrosoftTokenOptions } from './token'; + +export interface OutlookAdapterOptions { + /** + * Purposes this adapter may be used for. Defaults to `manual_correspondence` only: declare + * `automated_outreach` after reviewing Microsoft's terms for your tenant (BUILD_PLAN.md §19 D1). + */ + readonly purposes?: readonly ProviderPurpose[]; + /** Where access tokens come from. Defaults to a (rotating) refresh token behind the account's secretRef. */ + readonly tokens?: AccessTokenSource; + readonly api?: string; + readonly authority?: string; + readonly fetch?: typeof fetch; + readonly requestTimeoutMs?: number; + /** See SenderOptions.settleMs. Default 3 minutes. */ + readonly settleMs?: number; + /** Messages read per mailbox poll. Default 200. */ + readonly pollBudget?: number; +} + +/** The Outlook adapter: sends as the connected Exchange Online mailbox through Microsoft Graph. */ +export function outlookAdapter(options: OutlookAdapterOptions = {}): ProviderAdapter { + const doFetch = options.fetch ?? fetch; + const http: HttpDeps = { api: options.api ?? GRAPH_API, fetch: doFetch, timeoutMs: options.requestTimeoutMs ?? 30_000 }; + const tokens = options.tokens ?? microsoftRefreshTokenSource({ fetch: doFetch, ...(options.authority ? { authority: options.authority } : {}) }); + + const account: AccountPort = { + async discover(ctx) { + return { + provider: 'outlook', + send: true, + replyInThread: true, + customHeaders: true, + externalIdempotency: false, + inboundWebhook: false, + mailboxPolling: true, + reconcileBySentSearch: true, + maxRecipientsPerMessage: 100, + discoveredAt: ctx.now(), + }; + }, + async health(ctx) { + let token: string; + try { + token = await tokens.get(ctx); + } catch (error) { + if (error instanceof TokenError && (error.errorClass === 'auth_revoked' || error.errorClass === 'auth_expired')) { + return { status: 'reauth_required', detail: error.message }; + } + return { status: 'degraded', detail: (error as Error).message }; + } + try { + const me = await graphGet(http, ctx, token, '/me', { $select: 'mail,userPrincipalName' }); + if (me.status === 200) { + const addresses = [me.body.mail, me.body.userPrincipalName].filter((v): v is string => typeof v === 'string').map((v) => v.toLowerCase()); + if (!addresses.includes(ctx.account.sender.address.toLowerCase())) { + return { status: 'unhealthy', detail: `connected mailbox is ${addresses[0] ?? 'unknown'}, not the sender ${ctx.account.sender.address}` }; + } + return { status: 'ok' }; + } + if (me.status === 401) { + tokens.invalidate(ctx); + return { status: 'reauth_required', detail: 'Graph rejected the access token' }; + } + if (me.status === 403) return { status: 'unhealthy', detail: 'Graph refused access (permissions or tenant policy)' }; + return { status: 'degraded', detail: `Graph HTTP ${me.status}` }; + } catch (error) { + return { status: 'degraded', detail: error instanceof ProviderNetworkError ? error.message : (error as Error).name }; + } + }, + }; + + return { + name: 'outlook', + purposes: options.purposes ?? ['manual_correspondence'], + account, + email: outlookSender({ http, tokens, settleMs: options.settleMs ?? 180_000 }), + mailbox: outlookMailbox({ http, tokens, ...(options.pollBudget ? { budget: options.pollBudget } : {}) }), + }; +} diff --git a/outreach-engine/packages/outreach-provider-email-outlook/src/mailbox.ts b/outreach-engine/packages/outreach-provider-email-outlook/src/mailbox.ts new file mode 100644 index 0000000..d8ae2ae --- /dev/null +++ b/outreach-engine/packages/outreach-provider-email-outlook/src/mailbox.ts @@ -0,0 +1,116 @@ +import type { AccessTokenSource, InboundMailEvent, MailboxReader, ProviderContext } from '@splitin/outreach-contracts'; +import { looksLikeBounce, parseRawDsn } from '@splitin/outreach-provider-kit'; +import { graphGet, graphRaw, type HttpDeps } from './graph'; + +export interface MailboxOptions { + readonly http: HttpDeps; + readonly tokens: AccessTokenSource; + /** Messages read per poll. */ + readonly budget?: number; + /** Re-read window before the cursor, covering messages indexed late; duplicates are dropped by event id. */ + readonly overlapMs?: number; +} + +interface GraphInbound { + id: string; + conversationId?: string; + internetMessageId?: string; + subject?: string; + receivedDateTime?: string; + bodyPreview?: string; + from?: { emailAddress?: { address?: string } }; + toRecipients?: { emailAddress?: { address?: string } }[]; + internetMessageHeaders?: { name?: string; value?: string }[]; +} + +const SELECT = 'id,conversationId,internetMessageId,subject,receivedDateTime,bodyPreview,from,toRecipients,internetMessageHeaders'; + +function readCursor(cursor: string | null): number | null { + if (!cursor) return null; + try { + const value = JSON.parse(cursor) as { t?: unknown }; + return typeof value.t === 'number' ? value.t : null; + } catch { + return null; + } +} + +function headersOf(message: GraphInbound): Record { + const out: Record = {}; + for (const header of message.internetMessageHeaders ?? []) if (header.name && header.value !== undefined) out[header.name] = header.value; + return out; +} + +function find(headers: Record, name: string): string | undefined { + const key = Object.keys(headers).find((candidate) => candidate.toLowerCase() === name.toLowerCase()); + return key === undefined ? undefined : headers[key]; +} + +export function outlookMailbox(options: MailboxOptions): MailboxReader { + const { http, tokens } = options; + const budget = options.budget ?? 200; + const overlapMs = options.overlapMs ?? 2 * 60_000; + + const toEvent = async (ctx: ProviderContext, token: string, message: GraphInbound): Promise => { + const headers = headersOf(message); + const from = message.from?.emailAddress?.address?.toLowerCase() ?? ''; + const contentType = find(headers, 'Content-Type'); + const bounce = looksLikeBounce(from, contentType ?? '', message.subject ?? ''); + let dsn: InboundMailEvent['dsn']; + if (bounce) { + const raw = await graphRaw(http, ctx, token, `/me/messages/${encodeURIComponent(message.id)}/$value`); + dsn = raw.status === 200 ? parseRawDsn(raw.text) : { status: '' }; + } + const inReplyTo = find(headers, 'In-Reply-To')?.match(/<[^>\s]+>/)?.[0]; + return { + eventId: `graph:${message.id}`, + kind: bounce ? 'bounce' : 'message', + providerMessageId: message.id, + ...(message.conversationId ? { providerThreadId: message.conversationId } : {}), + ...(message.internetMessageId ? { rfcMessageId: message.internetMessageId } : {}), + ...(inReplyTo ? { inReplyTo } : {}), + references: find(headers, 'References')?.match(/<[^>\s]+>/g) ?? [], + from, + to: (message.toRecipients ?? []).flatMap((recipient) => (recipient.emailAddress?.address ? [recipient.emailAddress.address.toLowerCase()] : [])), + ...(message.subject !== undefined ? { subject: message.subject } : {}), + receivedAt: Date.parse(message.receivedDateTime ?? '') || ctx.now(), + headers, + ...(contentType ? { contentType } : {}), + ...(dsn ? { dsn } : {}), + ...(message.bodyPreview ? { snippet: message.bodyPreview.slice(0, 500) } : {}), + }; + }; + + return { + async readChanges(ctx, cursorText) { + const since = readCursor(cursorText); + // First poll: start from now. Mail that arrived before the account was connected is not replayed. + if (since === null) return { events: [], nextCursor: JSON.stringify({ t: ctx.now() }) }; + const token = await tokens.get(ctx); + const events: InboundMailEvent[] = []; + let latest = since; + let next: string | undefined = '/me/mailFolders/inbox/messages'; + let first = true; + while (next && events.length < budget) { + const page = await graphGet(http, ctx, token, next, first + ? { + $filter: `receivedDateTime ge ${new Date(since - overlapMs).toISOString()}`, + $orderby: 'receivedDateTime asc', + $top: String(Math.min(50, budget)), + $select: SELECT, + } + : undefined); + first = false; + if (page.status === 401) tokens.invalidate(ctx); + if (page.status !== 200) throw new Error(`Graph HTTP ${page.status} reading the inbox`); + for (const message of (page.body.value as GraphInbound[] | undefined) ?? []) { + if (events.length >= budget) break; + events.push(await toEvent(ctx, token, message)); + latest = Math.max(latest, Date.parse(message.receivedDateTime ?? '') || latest); + } + next = typeof page.body['@odata.nextLink'] === 'string' ? page.body['@odata.nextLink'] : undefined; + } + return { events, nextCursor: JSON.stringify({ t: latest }) }; + }, + }; +} diff --git a/outreach-engine/packages/outreach-provider-email-outlook/src/oauth.ts b/outreach-engine/packages/outreach-provider-email-outlook/src/oauth.ts new file mode 100644 index 0000000..3bb4ad2 --- /dev/null +++ b/outreach-engine/packages/outreach-provider-email-outlook/src/oauth.ts @@ -0,0 +1,80 @@ +import { loopbackAuthorize } from '@splitin/outreach-provider-kit'; +import { GRAPH_SCOPES, MICROSOFT_AUTHORITY, tokenEndpoint, type MicrosoftGrant } from './token'; + +export interface MicrosoftAuthorizeOptions { + /** Directory (tenant) id or domain. `organizations` works for any work account but prefer your tenant. */ + readonly tenant: string; + readonly clientId: string; + /** Only for confidential (web) app registrations; desktop registrations are public clients. */ + readonly clientSecret?: string; + readonly loginHint?: string; + readonly onUrl: (url: string) => void; + readonly authority?: string; + readonly api?: string; + readonly fetch?: typeof fetch; + readonly timeoutMs?: number; +} + +export interface MicrosoftAuthorization { + readonly grant: MicrosoftGrant; + /** The mailbox that granted access, read from Graph itself. */ + readonly emailAddress: string; + readonly scopes: readonly string[]; +} + +const short = (scope: string) => scope.replace(/^https:\/\/graph\.microsoft\.com\//i, '').toLowerCase(); + +/** + * Microsoft identity platform authorization code flow for a desktop registration whose redirect URI is + * `http://localhost` (any port is accepted for loopback). Resolves with a refresh-token grant. + */ +export async function authorizeMicrosoft(options: MicrosoftAuthorizeOptions): Promise { + const doFetch = options.fetch ?? fetch; + const authority = options.authority ?? MICROSOFT_AUTHORITY; + return loopbackAuthorize({ + authUrl: `${authority}/${encodeURIComponent(options.tenant)}/oauth2/v2.0/authorize`, + params: { + client_id: options.clientId, + scope: GRAPH_SCOPES.join(' '), + response_mode: 'query', + prompt: 'select_account', + ...(options.loginHint ? { login_hint: options.loginHint } : {}), + }, + redirectHost: 'localhost', + redirectPath: '/', + onUrl: options.onUrl, + ...(options.timeoutMs ? { timeoutMs: options.timeoutMs } : {}), + exchange: async ({ code, verifier, redirectUri }) => { + const exchange = await doFetch(tokenEndpoint(authority, options.tenant), { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: options.clientId, + ...(options.clientSecret ? { client_secret: options.clientSecret } : {}), + code, + code_verifier: verifier, + redirect_uri: redirectUri, + grant_type: 'authorization_code', + scope: GRAPH_SCOPES.join(' '), + }).toString(), + }); + const tokens = (await exchange.json().catch(() => ({}))) as { access_token?: string; refresh_token?: string; scope?: string; error?: string }; + if (!exchange.ok || !tokens.access_token) throw new Error(`token exchange failed (${tokens.error ?? `HTTP ${exchange.status}`})`); + if (!tokens.refresh_token) throw new Error('Microsoft returned no refresh token; the offline_access permission is missing'); + const granted = (tokens.scope ?? '').split(' ').filter(Boolean).map(short); + const missing = GRAPH_SCOPES.filter((scope) => scope !== 'offline_access' && !granted.includes(short(scope))); + if (missing.length) throw new Error(`the grant is missing permissions: ${missing.map(short).join(', ')} (an admin may need to consent)`); + const me = await doFetch(new URL('/v1.0/me?$select=mail,userPrincipalName', options.api ?? 'https://graph.microsoft.com'), { + headers: { authorization: `Bearer ${tokens.access_token}` }, + }); + const profile = (await me.json().catch(() => ({}))) as { mail?: string | null; userPrincipalName?: string }; + const address = (profile.mail ?? profile.userPrincipalName ?? '').toLowerCase(); + if (!me.ok || !address) throw new Error(`could not read the connected mailbox (HTTP ${me.status})`); + return { + grant: { tenant: options.tenant, clientId: options.clientId, ...(options.clientSecret ? { clientSecret: options.clientSecret } : {}), refreshToken: tokens.refresh_token }, + emailAddress: address, + scopes: granted, + }; + }, + }); +} diff --git a/outreach-engine/packages/outreach-provider-email-outlook/src/outlook.test.ts b/outreach-engine/packages/outreach-provider-email-outlook/src/outlook.test.ts new file mode 100644 index 0000000..9d8fd4c --- /dev/null +++ b/outreach-engine/packages/outreach-provider-email-outlook/src/outlook.test.ts @@ -0,0 +1,212 @@ +import { createServer } from 'node:net'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { ApprovedEmail, ProviderContext, SecretResolver } from '@splitin/outreach-contracts'; +import { FakeGraphServer, runEmailSenderConformance, type FakeGraphOptions } from '@splitin/outreach-fakes'; +import { authorizeMicrosoft, microsoftRefreshTokenSource, outlookAdapter } from './index'; + +const servers: FakeGraphServer[] = []; +afterEach(async () => { + await Promise.all(servers.splice(0).map((server) => server.stop())); +}); + +/** A writable secret store, like the CLI's file: secrets. */ +function memorySecrets(initial: Record): SecretResolver & { values: Record } { + const values = { ...initial }; + return { + values, + get: async (ref) => { + const value = values[ref]; + if (value === undefined) throw new Error(`unknown ${ref}`); + return value; + }, + put: async (ref, value) => { + values[ref] = value; + }, + }; +} + +async function setup(options: FakeGraphOptions & { settleMs?: number; secret?: string } = {}) { + const server = await new FakeGraphServer(options).start(); + servers.push(server); + let now = Date.now(); + const secrets = memorySecrets({ 'file:grant.json': options.secret ?? server.grant() }); + const adapter = outlookAdapter({ + api: server.url, + tokens: microsoftRefreshTokenSource({ authority: server.url }), + settleMs: options.settleMs ?? 180_000, + requestTimeoutMs: 2_000, + }); + const ctx: ProviderContext = { + workspaceId: 'ws', + account: { id: 'acct-1', provider: 'outlook', externalAccountId: server.mailbox, sender: { name: 'Sam', address: server.mailbox } }, + secretRef: 'file:grant.json', + secrets, + traceId: 'trace', + signal: new AbortController().signal, + now: () => now, + }; + return { server, adapter, ctx, secrets, advance: (ms: number) => (now += ms), email: adapter.email! }; +} + +function email(overrides: Partial = {}): ApprovedEmail { + return { + actionId: 'a1', + idempotencyKey: 'act:a1', + rfcMessageId: '', + contentHash: 'h', + from: { address: 'sam@contoso.example', name: 'Sam' }, + to: [{ address: 'ada@example.org' }], + subject: 'Quick question', + text: 'Hello Ada', + headers: { 'List-Unsubscribe': '' }, + ...overrides, + }; +} + +const uncertain = (attemptedAt: number) => ({ actionId: 'a1', idempotencyKey: 'act:a1', rfcMessageId: '', to: ['ada@example.org'], attemptedAt }); + +describe('conformance kit over HTTP', () => { + it('passes every case, including dropped connections before and after Exchange accepts the send', async () => { + const harnesses: Awaited>[] = []; + for (let i = 0; i < 7; i += 1) harnesses.push(await setup({ settleMs: 0 })); + let next = 0; + const skipped = await runEmailSenderConformance(() => { + const h = harnesses[next++]!; + return { sender: h.email, ctx: h.ctx, secretValue: 'ms-refresh-1', recipient: 'ada@example.org', force: (mode) => h.server.force(mode) }; + }); + expect(skipped).toEqual([]); + }); +}); + +describe('send and reconcile', () => { + it('sends a MIME draft (custom headers intact) and reports the Message-ID Exchange stored', async () => { + const { email: sender, ctx, server } = await setup({ replaceMessageId: true }); + const result = await sender.send(ctx, email()); + expect(result.kind).toBe('accepted'); + const sent = server.messages.find((m) => m.folder === 'sentitems'); + expect(sent?.headers.find((h) => h.name === 'List-Unsubscribe')?.value).toBe(''); + if (result.kind === 'accepted') expect(result.receipt.rfcMessageId).toBe(sent?.internetMessageId); + expect(server.messages.filter((m) => m.folder === 'drafts')).toHaveLength(0); + }); + + it('finds a lost send by its idempotency header when Exchange replaced the Message-ID; absent only after settling', async () => { + const { email: sender, ctx, server, advance } = await setup({ replaceMessageId: true }); + server.force({ kind: 'unknown_after_accept' }); + const at = ctx.now(); + expect((await sender.send(ctx, email())).kind).toBe('unknown'); + expect((await sender.reconcile(ctx, uncertain(at))).kind).toBe('found'); + + const second = await setup(); + second.server.force({ kind: 'unknown_before_accept' }); + const at2 = second.ctx.now(); + expect((await second.email.send(second.ctx, email())).kind).toBe('unknown'); + expect((await second.email.reconcile(second.ctx, uncertain(at2))).kind).toBe('still_unknown'); + second.advance(181_000); + expect(second.server.messages.filter((m) => m.folder === 'drafts')).toHaveLength(1); + expect((await second.email.reconcile(second.ctx, uncertain(at2))).kind).toBe('absent'); + expect(second.server.messages.filter((m) => m.folder === 'drafts')).toHaveLength(0); // Orphaned draft removed. + void advance; + }); + + it('treats an unreachable Graph as a transient rejection (a draft never sends) and maps Exchange errors', async () => { + const { email: sender, ctx, server } = await setup(); + server.force({ kind: 'reject', errorClass: 'policy_blocked' }); + expect(await sender.send(ctx, email())).toMatchObject({ kind: 'rejected', errorClass: 'policy_blocked' }); + server.force({ kind: 'reject', errorClass: 'transient' }); + expect((await sender.send(ctx, email())).kind).toBe('unknown'); + server.force({ kind: 'reject', errorClass: 'rate_limited', retryAfterMs: 30_000 }); + expect(await sender.send(ctx, email())).toMatchObject({ kind: 'rejected', errorClass: 'rate_limited', retryAfterMs: 30_000 }); + // Refused sends discard their draft; the 5xx (outcome unknown) keeps it until reconciliation decides. + expect(server.messages.filter((m) => m.folder === 'drafts')).toHaveLength(1); + + const closed = createServer(); + await new Promise((resolve) => closed.listen(0, '127.0.0.1', resolve)); + const port = (closed.address() as { port: number }).port; + await new Promise((resolve) => closed.close(() => resolve())); + const offline = outlookAdapter({ api: `http://127.0.0.1:${port}`, tokens: { get: async () => 't', invalidate: () => {} }, requestTimeoutMs: 2_000 }); + expect(await offline.email!.send(ctx, email())).toMatchObject({ kind: 'rejected', errorClass: 'transient' }); + }); +}); + +describe('tokens', () => { + it('writes rotated refresh tokens back so the grant does not lapse', async () => { + const { email: sender, ctx, server, secrets } = await setup({ rotateRefreshTokens: true }); + expect((await sender.send(ctx, email())).kind).toBe('accepted'); + expect(JSON.parse(secrets.values['file:grant.json'] ?? '{}')).toMatchObject({ refreshToken: 'ms-refresh-2', tenant: server.tenant }); + server.validRefreshTokens.delete('ms-refresh-1'); // The old token no longer works; the stored one must. + server.expireTokens(); + expect(await sender.send(ctx, email())).toMatchObject({ kind: 'rejected', errorClass: 'auth_expired' }); + expect((await sender.send(ctx, email())).kind).toBe('accepted'); + }); + + it('reports a revoked grant as auth_revoked and reauth_required', async () => { + const { adapter, ctx, email: sender } = await setup({ secret: JSON.stringify({ tenant: 'contoso.example', clientId: 'ms-client', refreshToken: 'rt-secret-value-9' }) }); + const result = await sender.send(ctx, email()); + expect(result).toMatchObject({ kind: 'rejected', errorClass: 'auth_revoked' }); + expect(JSON.stringify(result)).not.toContain('rt-secret-value-9'); + expect((await adapter.account.health(ctx)).status).toBe('reauth_required'); + }); +}); + +describe('mailbox', () => { + it('starts from now, then returns replies and parsed Exchange NDRs', async () => { + const { adapter, ctx, server } = await setup(); + const first = await adapter.mailbox!.readChanges(ctx, null); + expect(first.events).toEqual([]); + server.deliver({ + bodyPreview: 'Sounds good, Thursday?', + conversationId: 'conv-x', + mime: 'From: Ada \r\nTo: sam@contoso.example\r\nSubject: RE: Quick question\r\nMessage-ID: \r\nIn-Reply-To: \r\nReferences: \r\n\r\nSounds good', + }); + server.deliver({ + mime: [ + 'From: postmaster@contoso.example', + 'To: sam@contoso.example', + 'Subject: Undeliverable: Quick question', + 'Content-Type: multipart/report; report-type=delivery-status; boundary="nd"', + '', + '--nd', + 'Content-Type: message/delivery-status', + '', + 'Final-Recipient: rfc822; gone@example.net', + 'Status: 5.1.10', + '--nd', + 'Content-Type: text/rfc822-headers', + '', + 'Message-ID: ', + '--nd--', + ].join('\r\n'), + }); + const second = await adapter.mailbox!.readChanges(ctx, first.nextCursor); + const [reply, bounce] = second.events; + expect(reply).toMatchObject({ kind: 'message', from: 'ada@example.org', providerThreadId: 'conv-x', inReplyTo: '', snippet: 'Sounds good, Thursday?' }); + expect(bounce).toMatchObject({ kind: 'bounce', dsn: { status: '5.1.10', recipient: 'gone@example.net', originalMessageId: '' } }); + }); +}); + +describe('health and purposes', () => { + it('is ok only for the sender mailbox, and defaults to manual correspondence', async () => { + const { adapter, ctx } = await setup(); + expect(await adapter.account.health(ctx)).toEqual({ status: 'ok' }); + const other = { ...ctx, account: { ...ctx.account, sender: { name: 'X', address: 'other@contoso.example' } } }; + expect((await adapter.account.health(other)).status).toBe('unhealthy'); + expect(outlookAdapter().purposes).toEqual(['manual_correspondence']); + }); +}); + +describe('authorizeMicrosoft', () => { + it('runs the localhost loopback flow with PKCE and returns a grant for the mailbox Graph reports', async () => { + const server = await new FakeGraphServer().start(); + servers.push(server); + const result = await authorizeMicrosoft({ + tenant: server.tenant, + clientId: 'ms-client', + authority: server.url, + api: server.url, + timeoutMs: 5_000, + onUrl: (url) => void fetch(url), + }); + expect(result.emailAddress).toBe('sam@contoso.example'); + expect(result.grant).toEqual({ tenant: 'contoso.example', clientId: 'ms-client', refreshToken: 'ms-refresh-1' }); + }); +}); diff --git a/outreach-engine/packages/outreach-provider-email-outlook/src/sender.ts b/outreach-engine/packages/outreach-provider-email-outlook/src/sender.ts new file mode 100644 index 0000000..4115c0f --- /dev/null +++ b/outreach-engine/packages/outreach-provider-email-outlook/src/sender.ts @@ -0,0 +1,187 @@ +import { + buildMime, + IDEMPOTENCY_HEADER, + MimeError, + TokenError, + type AccessTokenSource, + type ApprovedEmail, + type EmailSender, + type ProviderContext, + type ReconcileResult, + type SendResult, + type UncertainEmail, +} from '@splitin/outreach-contracts'; +import { ProviderNetworkError } from '@splitin/outreach-provider-kit'; +import { classifyGraphError, graphDelete, graphGet, graphPost, type HttpDeps } from './graph'; + +export interface SenderOptions { + readonly http: HttpDeps; + readonly tokens: AccessTokenSource; + /** How long after an attempt Sent Items must lack the message before reconciliation may answer "absent". */ + readonly settleMs: number; + readonly scanWindowMs?: number; + readonly scanLimit?: number; +} + +interface GraphMessage { + id: string; + internetMessageId?: string; + conversationId?: string; + sentDateTime?: string; + internetMessageHeaders?: { name?: string; value?: string }[]; +} + +function tokenFailure(error: unknown): { errorClass: TokenError['errorClass']; detail: string } { + if (error instanceof TokenError) return { errorClass: error.errorClass, detail: error.message }; + return { errorClass: 'transient', detail: `token unavailable (${(error as Error).name})` }; +} + +const odataString = (value: string) => `'${value.replace(/'/g, "''")}'`; + +export function outlookSender(options: SenderOptions): EmailSender { + const { http, tokens } = options; + const scanWindowMs = options.scanWindowMs ?? 15 * 60_000; + const scanLimit = options.scanLimit ?? 200; + + const discardDraft = async (ctx: ProviderContext, token: string, id: string) => { + await graphDelete(http, ctx, token, `/me/messages/${encodeURIComponent(id)}`).catch(() => {}); + }; + + return { + /** + * Draft, then send. Creating a draft never sends anything, so every failure before the send call is a + * safe transient rejection; only the send call itself can leave the outcome unknown. + */ + async send(ctx, email: ApprovedEmail): Promise { + let mime: string; + try { + mime = Buffer.from(buildMime(email, new Date(ctx.now())), 'utf8').toString('base64'); + } catch (error) { + if (error instanceof MimeError) return { kind: 'rejected', errorClass: 'content_rejected', detail: error.message }; + throw error; + } + let token: string; + try { + token = await tokens.get(ctx); + } catch (error) { + return { kind: 'rejected', ...tokenFailure(error) }; + } + + let draft: GraphMessage; + try { + const created = await graphPost(http, ctx, token, '/me/messages', { mime }); + if (created.status !== 201 || typeof created.body.id !== 'string') { + if (created.status === 401) tokens.invalidate(ctx); + const rejected = classifyGraphError(created); + return { kind: 'rejected', ...(rejected ?? { errorClass: 'transient', detail: `Graph HTTP ${created.status} creating the draft` }) }; + } + draft = created.body as unknown as GraphMessage; + } catch (error) { + return { kind: 'rejected', errorClass: 'transient', detail: `draft not created (${(error as Error).message})` }; + } + + let sent; + try { + sent = await graphPost(http, ctx, token, `/me/messages/${encodeURIComponent(draft.id)}/send`); + } catch (error) { + if (error instanceof ProviderNetworkError && !error.reachedServer) { + await discardDraft(ctx, token, draft.id); + return { kind: 'rejected', errorClass: 'transient', detail: error.message }; + } + return { kind: 'unknown', detail: error instanceof ProviderNetworkError ? error.message : `send failed (${(error as Error).name})` }; + } + if (sent.status === 202 || sent.status === 200) { + return { + kind: 'accepted', + receipt: { + // Exchange gives the Sent Items copy a new item id, so the stable identifier is internetMessageId. + providerMessageId: draft.internetMessageId ?? draft.id, + ...(draft.conversationId ? { providerThreadId: draft.conversationId } : {}), + ...(draft.internetMessageId ? { rfcMessageId: draft.internetMessageId } : {}), + acceptedAt: ctx.now(), + raw: { graphDraftId: draft.id }, + }, + }; + } + if (sent.status === 401) tokens.invalidate(ctx); + const rejected = classifyGraphError(sent); + if (!rejected) return { kind: 'unknown', detail: `Graph HTTP ${sent.status} on send` }; + await discardDraft(ctx, token, draft.id); + return { kind: 'rejected', ...rejected }; + }, + + async reconcile(ctx, email: UncertainEmail): Promise { + let token: string; + try { + token = await tokens.get(ctx); + } catch (error) { + return { kind: 'still_unknown', detail: tokenFailure(error).detail }; + } + const select = 'id,internetMessageId,conversationId,sentDateTime,internetMessageHeaders'; + const found = (message: GraphMessage): ReconcileResult => ({ + kind: 'found', + receipt: { + providerMessageId: message.internetMessageId ?? message.id, + ...(message.conversationId ? { providerThreadId: message.conversationId } : {}), + ...(message.internetMessageId ? { rfcMessageId: message.internetMessageId } : {}), + acceptedAt: Date.parse(message.sentDateTime ?? '') || ctx.now(), + raw: { graphId: message.id, via: 'reconcile' }, + }, + }); + const matches = (message: GraphMessage) => + message.internetMessageId === email.rfcMessageId || + (message.internetMessageHeaders ?? []).some((h) => h.name?.toLowerCase() === IDEMPOTENCY_HEADER.toLowerCase() && h.value === email.idempotencyKey); + try { + // Fast path: our Message-ID, when Exchange kept it. + const direct = await graphGet(http, ctx, token, '/me/mailFolders/sentitems/messages', { + $filter: `internetMessageId eq ${odataString(email.rfcMessageId)}`, + $select: select, + }); + if (direct.status === 401) { + tokens.invalidate(ctx); + return { kind: 'still_unknown', detail: 'access token rejected; retrying with a fresh one next time' }; + } + const hit = ((direct.body.value as GraphMessage[] | undefined) ?? []).find(matches); + if (hit) return found(hit); + + // Authoritative path: Sent Items newest first, matched on our idempotency header. + let next: string | undefined = '/me/mailFolders/sentitems/messages'; + let first = true; + let inspected = 0; + let reachedOlder = false; + while (next) { + const page = await graphGet(http, ctx, token, next, first ? { $orderby: 'sentDateTime desc', $top: '50', $select: select } : undefined); + first = false; + if (page.status !== 200) return { kind: 'still_unknown', detail: `Sent Items scan failed with HTTP ${page.status}` }; + for (const message of (page.body.value as GraphMessage[] | undefined) ?? []) { + inspected += 1; + if (matches(message)) return found(message); + if (Date.parse(message.sentDateTime ?? '') < email.attemptedAt - scanWindowMs) { + reachedOlder = true; + next = undefined; + break; + } + if (inspected >= scanLimit) { + next = undefined; + break; + } + } + if (next !== undefined) { + next = typeof page.body['@odata.nextLink'] === 'string' ? page.body['@odata.nextLink'] : undefined; + if (!next) reachedOlder = true; // All of Sent Items was inspected. + } + } + if (!reachedOlder) return { kind: 'still_unknown', detail: `Sent Items not fully scanned (${inspected} messages inspected)` }; + if (ctx.now() - email.attemptedAt < options.settleMs) return { kind: 'still_unknown', detail: 'not in Sent Items yet; waiting for the settle window' }; + // Never sent: remove the orphaned draft so nobody sends it by hand later (the engine's retry makes a new one). + const drafts = await graphGet(http, ctx, token, '/me/mailFolders/drafts/messages', { $top: '50', $select: 'id,internetMessageId,internetMessageHeaders' }); + for (const draft of (drafts.body.value as GraphMessage[] | undefined) ?? []) { + if (matches(draft)) await discardDraft(ctx, token, draft.id); + } + return { kind: 'absent' }; + } catch (error) { + return { kind: 'still_unknown', detail: error instanceof ProviderNetworkError ? error.message : `reconcile failed (${(error as Error).name})` }; + } + }, + }; +} diff --git a/outreach-engine/packages/outreach-provider-email-outlook/src/token.ts b/outreach-engine/packages/outreach-provider-email-outlook/src/token.ts new file mode 100644 index 0000000..8a73dd9 --- /dev/null +++ b/outreach-engine/packages/outreach-provider-email-outlook/src/token.ts @@ -0,0 +1,92 @@ +import { TokenError, type AccessTokenSource, type ProviderContext } from '@splitin/outreach-contracts'; + +export const MICROSOFT_AUTHORITY = 'https://login.microsoftonline.com'; + +/** Delegated Graph permissions the adapter needs. Mail.ReadWrite covers creating drafts and reading replies. */ +export const GRAPH_SCOPES = [ + 'offline_access', + 'https://graph.microsoft.com/Mail.Send', + 'https://graph.microsoft.com/Mail.ReadWrite', + 'https://graph.microsoft.com/User.Read', +] as const; + +/** What the account's `secretRef` resolves to. `clientSecret` is absent for public (desktop) clients. */ +export interface MicrosoftGrant { + readonly tenant: string; + readonly clientId: string; + readonly clientSecret?: string; + readonly refreshToken: string; +} + +export function parseMicrosoftGrant(raw: string): MicrosoftGrant { + let value: Partial; + try { + value = JSON.parse(raw) as Partial; + } catch { + throw new TokenError('auth_revoked', 'the Outlook account secret is not JSON {tenant, clientId, refreshToken}'); + } + if (typeof value.tenant !== 'string' || typeof value.clientId !== 'string' || typeof value.refreshToken !== 'string') { + throw new TokenError('auth_revoked', 'the Outlook account secret must hold tenant, clientId and refreshToken'); + } + return value as MicrosoftGrant; +} + +export const tokenEndpoint = (authority: string, tenant: string) => `${authority}/${encodeURIComponent(tenant)}/oauth2/v2.0/token`; + +export interface MicrosoftTokenOptions { + readonly authority?: string; + readonly fetch?: typeof fetch; +} + +/** + * Access tokens from the refresh token behind `secretRef`. Microsoft rotates refresh tokens on every use; + * the new one is written back through `secrets.put` when the resolver supports it, so the grant does not + * lapse. Error messages never contain the secret or tokens. + */ +export function microsoftRefreshTokenSource(options: MicrosoftTokenOptions = {}): AccessTokenSource { + const cache = new Map(); + const doFetch = options.fetch ?? fetch; + const key = (ctx: ProviderContext) => `${ctx.account.id}\u0000${ctx.secretRef}`; + return { + async get(ctx) { + const cached = cache.get(key(ctx)); + if (cached && cached.expiresAt > ctx.now()) return cached.token; + const grant = parseMicrosoftGrant(await ctx.secrets.get(ctx.secretRef)); + let response: Response; + try { + response = await doFetch(tokenEndpoint(options.authority ?? MICROSOFT_AUTHORITY, grant.tenant), { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: grant.clientId, + ...(grant.clientSecret ? { client_secret: grant.clientSecret } : {}), + refresh_token: grant.refreshToken, + grant_type: 'refresh_token', + scope: GRAPH_SCOPES.join(' '), + }).toString(), + signal: ctx.signal, + }); + } catch { + throw new TokenError('transient', 'the Microsoft token endpoint could not be reached'); + } + const body = (await response.json().catch(() => ({}))) as { access_token?: string; refresh_token?: string; expires_in?: number; error?: string }; + if (!response.ok || typeof body.access_token !== 'string') { + if (body.error === 'invalid_grant' || body.error === 'interaction_required' || body.error === 'invalid_client' || body.error === 'unauthorized_client') { + throw new TokenError('auth_revoked', `Microsoft refused the stored grant (${body.error}); reconnect the account`); + } + const errorClass = response.status >= 500 || response.status === 429 ? 'transient' : 'auth_expired'; + throw new TokenError(errorClass, `token refresh failed with HTTP ${response.status}`); + } + if (body.refresh_token && body.refresh_token !== grant.refreshToken && ctx.secrets.put) { + // Best effort: if the write fails, the current refresh token stays valid until its own expiry. + await ctx.secrets.put(ctx.secretRef, JSON.stringify({ ...grant, refreshToken: body.refresh_token })).catch(() => {}); + } + const lifetimeSeconds = typeof body.expires_in === 'number' ? body.expires_in : 3600; + cache.set(key(ctx), { token: body.access_token, expiresAt: ctx.now() + Math.max(0, lifetimeSeconds - 60) * 1000 }); + return body.access_token; + }, + invalidate(ctx) { + cache.delete(key(ctx)); + }, + }; +} diff --git a/outreach-engine/packages/outreach-provider-email-outlook/tsconfig.json b/outreach-engine/packages/outreach-provider-email-outlook/tsconfig.json new file mode 100644 index 0000000..585a92d --- /dev/null +++ b/outreach-engine/packages/outreach-provider-email-outlook/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist" }, + "include": ["src"] +} diff --git a/outreach-engine/packages/outreach-provider-email-outlook/tsup.config.ts b/outreach-engine/packages/outreach-provider-email-outlook/tsup.config.ts new file mode 100644 index 0000000..1343a99 --- /dev/null +++ b/outreach-engine/packages/outreach-provider-email-outlook/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/packages/outreach-provider-kit/package.json b/outreach-engine/packages/outreach-provider-kit/package.json new file mode 100644 index 0000000..60a0069 --- /dev/null +++ b/outreach-engine/packages/outreach-provider-kit/package.json @@ -0,0 +1,43 @@ +{ + "name": "@splitin/outreach-provider-kit", + "version": "0.0.0", + "description": "Shared building blocks for outreach provider adapters: JSON HTTP with send-outcome classification, loopback OAuth (PKCE), delivery-status parsing.", + "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-provider-kit" + }, + "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-provider-kit/src/dsn.ts b/outreach-engine/packages/outreach-provider-kit/src/dsn.ts new file mode 100644 index 0000000..596877a --- /dev/null +++ b/outreach-engine/packages/outreach-provider-kit/src/dsn.ts @@ -0,0 +1,63 @@ +import type { InboundMailEvent } from '@splitin/outreach-contracts'; + +export type DsnDetails = NonNullable; + +/** Fields of one `message/delivery-status` body (RFC 3464). Status stays empty when absent. */ +export function parseDeliveryStatus(text: string): { status: string; recipient?: string } { + const status = /^Status:\s*(\d\.\d{1,3}\.\d{1,3})/im.exec(text)?.[1] ?? ''; + const recipient = /^Final-Recipient:\s*rfc822;\s*(\S+)/im.exec(text)?.[1]?.toLowerCase(); + return { status, ...(recipient ? { recipient } : {}) }; +} + +function decodePart(headers: string, body: string): string { + if (/content-transfer-encoding:\s*base64/i.test(headers)) return Buffer.from(body.replace(/\s+/g, ''), 'base64').toString('utf8'); + if (/content-transfer-encoding:\s*quoted-printable/i.test(headers)) { + return body.replace(/=\r?\n/g, '').replace(/=([0-9A-F]{2})/gi, (_, hex: string) => String.fromCharCode(Number.parseInt(hex, 16))); + } + return body; +} + +/** + * Status, recipient and original Message-ID from a raw multipart/report MIME message. Anything it cannot find + * stays empty, so the classifier routes the message to review rather than suppressing on a guess. + */ +export function parseRawDsn(mime: string): DsnDetails { + let status = ''; + let recipient: string | undefined; + let originalMessageId: string | undefined; + const boundaries = [...mime.matchAll(/boundary="?([^";\r\n]+)"?/gi)].map((match) => match[1] as string); + for (const boundary of boundaries) { + for (const part of mime.split(`--${boundary}`)) { + const split = part.search(/\r?\n\r?\n/); + if (split < 0) continue; + const headers = part.slice(0, split); + const body = decodePart(headers, part.slice(split).trim()); + if (/content-type:\s*message\/delivery-status/i.test(headers)) { + const parsed = parseDeliveryStatus(body); + status ||= parsed.status; + recipient ??= parsed.recipient; + } else if (/content-type:\s*(text\/rfc822-headers|message\/rfc822)/i.test(headers)) { + originalMessageId ??= /^Message-ID:\s*(<[^>\s]+>)/im.exec(body)?.[1]; + } + } + } + return { status, ...(recipient ? { recipient } : {}), ...(originalMessageId ? { originalMessageId } : {}) }; +} + +/** Whether headers look like a delivery report (a bounce), before fetching the body. */ +export function looksLikeBounce(from: string, contentType: string, subject = ''): boolean { + return ( + /mailer-daemon@|postmaster@|microsoftexchange[0-9a-f]*@/i.test(from) || + /report-type="?delivery-status/i.test(contentType) || + /^(undeliverable|delivery status notification \(failure\)|mail delivery failed)/i.test(subject) + ); +} + +export function decodeHtmlEntities(text: string): string { + return text + .replace(/&#(\d+);/g, (_, code: string) => String.fromCodePoint(Number(code))) + .replace(/"/g, '"') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&'); +} diff --git a/outreach-engine/packages/outreach-provider-kit/src/http.ts b/outreach-engine/packages/outreach-provider-kit/src/http.ts new file mode 100644 index 0000000..95954fb --- /dev/null +++ b/outreach-engine/packages/outreach-provider-kit/src/http.ts @@ -0,0 +1,115 @@ +export interface HttpDeps { + readonly fetch: typeof fetch; + readonly timeoutMs: number; +} + +export interface JsonResponse { + readonly status: number; + readonly body: Record; + readonly retryAfterMs?: number; +} + +/** + * The request failed without an HTTP response. `reachedServer` is false only when the connection was never + * established, which proves the provider did nothing; every other failure may have been processed. + */ +export class ProviderNetworkError extends Error { + constructor( + readonly reachedServer: boolean, + message: string, + ) { + super(message); + this.name = 'ProviderNetworkError'; + } +} + +const CONNECT_FAILURES = new Set(['ECONNREFUSED', 'ENOTFOUND', 'EAI_AGAIN', 'ENETUNREACH', 'EHOSTUNREACH', 'UND_ERR_CONNECT_TIMEOUT']); + +function failureCode(error: unknown): string { + const cause = (error as { cause?: { code?: unknown } }).cause; + return typeof cause?.code === 'string' ? cause.code : ''; +} + +export function parseRetryAfter(header: string | null, now = Date.now()): number | undefined { + if (!header) return undefined; + const seconds = Number(header); + if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000); + const at = Date.parse(header); + return Number.isFinite(at) ? Math.max(0, at - now) : undefined; +} + +export interface JsonRequest { + readonly url: URL | string; + readonly method: 'GET' | 'POST' | 'DELETE' | 'PATCH'; + readonly token?: string; + readonly signal: AbortSignal; + /** Serialized as JSON. */ + readonly json?: unknown; + /** Sent as-is with `contentType` (e.g. base64 MIME for Microsoft Graph). */ + readonly text?: string; + readonly contentType?: string; + readonly headers?: Readonly>; +} + +/** + * One HTTP call whose response body is returned as text (raw MIME, Slack's plain "ok"). A body cut short + * after the status line still counts as a response: the outcome is known from the status. + */ +export async function textRequest(deps: HttpDeps, request: JsonRequest): Promise<{ status: number; text: string; retryAfterMs?: number }> { + const headers: Record = { ...(request.headers ?? {}) }; + if (request.token) headers.authorization = `Bearer ${request.token}`; + let body: string | undefined; + if (request.json !== undefined) { + headers['content-type'] = 'application/json; charset=utf-8'; + body = JSON.stringify(request.json); + } else if (request.text !== undefined) { + headers['content-type'] = request.contentType ?? 'text/plain'; + body = request.text; + } + let response: Response; + try { + response = await deps.fetch(request.url, { method: request.method, headers, ...(body === undefined ? {} : { body }), signal: AbortSignal.any([request.signal, AbortSignal.timeout(deps.timeoutMs)]) }); + } catch (error) { + const code = failureCode(error); + throw new ProviderNetworkError(!CONNECT_FAILURES.has(code), `request failed (${code || (error as Error).name})`); + } + const text = await response.text().catch(() => ''); + const wait = parseRetryAfter(response.headers.get('retry-after')); + return { status: response.status, text, ...(wait === undefined ? {} : { retryAfterMs: wait }) }; +} + +/** One HTTP call with a JSON (or empty) response; network failures carry whether the server was reached. */ +export async function jsonRequest(deps: HttpDeps, request: JsonRequest): Promise { + const headers: Record = { ...(request.headers ?? {}) }; + if (request.token) headers.authorization = `Bearer ${request.token}`; + let body: string | undefined; + if (request.json !== undefined) { + headers['content-type'] = 'application/json'; + body = JSON.stringify(request.json); + } else if (request.text !== undefined) { + headers['content-type'] = request.contentType ?? 'text/plain'; + body = request.text; + } + let response: Response; + try { + response = await deps.fetch(request.url, { + method: request.method, + headers, + ...(body === undefined ? {} : { body }), + signal: AbortSignal.any([request.signal, AbortSignal.timeout(deps.timeoutMs)]), + }); + } catch (error) { + const code = failureCode(error); + throw new ProviderNetworkError(!CONNECT_FAILURES.has(code), `request failed (${code || (error as Error).name})`); + } + let parsed: Record = {}; + try { + const text = await response.text(); + parsed = text ? (JSON.parse(text) as Record) : {}; + } catch (error) { + // The status line arrived, so the outcome is known even if the body was cut short. + if (response.ok) throw new ProviderNetworkError(true, `response body unreadable (${(error as Error).name})`); + } + const wait = parseRetryAfter(response.headers.get('retry-after')); + return { status: response.status, body: parsed, ...(wait === undefined ? {} : { retryAfterMs: wait }) }; +} diff --git a/outreach-engine/packages/outreach-provider-kit/src/index.ts b/outreach-engine/packages/outreach-provider-kit/src/index.ts new file mode 100644 index 0000000..13b0e92 --- /dev/null +++ b/outreach-engine/packages/outreach-provider-kit/src/index.ts @@ -0,0 +1,3 @@ +export * from './http'; +export * from './oauth'; +export * from './dsn'; diff --git a/outreach-engine/packages/outreach-provider-kit/src/kit.test.ts b/outreach-engine/packages/outreach-provider-kit/src/kit.test.ts new file mode 100644 index 0000000..30202d7 --- /dev/null +++ b/outreach-engine/packages/outreach-provider-kit/src/kit.test.ts @@ -0,0 +1,80 @@ +import { createServer } from 'node:net'; +import { describe, expect, it } from 'vitest'; +import { jsonRequest, loopbackAuthorize, parseRawDsn, parseRetryAfter, ProviderNetworkError } from './index'; + +const signal = new AbortController().signal; +const deps = { fetch, timeoutMs: 2_000 }; + +describe('jsonRequest', () => { + it('reports a refused connection as never having reached the server', async () => { + const closed = createServer(); + await new Promise((resolve) => closed.listen(0, '127.0.0.1', resolve)); + const port = (closed.address() as { port: number }).port; + await new Promise((resolve) => closed.close(() => resolve())); + const error = await jsonRequest(deps, { url: `http://127.0.0.1:${port}/`, method: 'POST', signal }).catch((e: unknown) => e); + expect(error).toBeInstanceOf(ProviderNetworkError); + expect((error as ProviderNetworkError).reachedServer).toBe(false); + }); + + it('parses Retry-After as seconds or an HTTP date', () => { + expect(parseRetryAfter('30')).toBe(30_000); + expect(parseRetryAfter(new Date(10_000 + 5_000).toUTCString(), 10_000)).toBe(5_000); + expect(parseRetryAfter(null)).toBeUndefined(); + }); +}); + +describe('parseRawDsn', () => { + it('reads status, recipient and original Message-ID from a multipart/report, including base64 parts', () => { + const status = Buffer.from('Reporting-MTA: dns; mx.example.net\r\n\r\nFinal-Recipient: rfc822; Gone@Example.net\r\nAction: failed\r\nStatus: 5.1.1\r\n').toString('base64'); + const mime = [ + 'Content-Type: multipart/report; report-type=delivery-status; boundary="b1"', + '', + '--b1', + 'Content-Type: text/plain', + '', + 'Your message could not be delivered.', + '--b1', + 'Content-Type: message/delivery-status', + 'Content-Transfer-Encoding: base64', + '', + status, + '--b1', + 'Content-Type: text/rfc822-headers', + '', + 'Message-ID: ', + 'Subject: Quick question', + '--b1--', + ].join('\r\n'); + expect(parseRawDsn(mime)).toEqual({ status: '5.1.1', recipient: 'gone@example.net', originalMessageId: '' }); + }); + + it('leaves status empty without a delivery-status part', () => { + expect(parseRawDsn('Content-Type: text/plain\r\n\r\nUndeliverable')).toEqual({ status: '' }); + }); +}); + +describe('loopbackAuthorize', () => { + it('accepts exactly one callback with the right state and hands the PKCE verifier to the exchange', async () => { + let authUrl = ''; + const result = loopbackAuthorize({ + authUrl: 'https://auth.example.com/authorize', + params: { client_id: 'c' }, + redirectHost: 'localhost', + redirectPath: '/', + onUrl: (url) => (authUrl = url), + exchange: async ({ code, verifier, redirectUri }) => ({ code, verifier, redirectUri }), + timeoutMs: 5_000, + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + const url = new URL(authUrl); + expect(url.searchParams.get('code_challenge_method')).toBe('S256'); + const redirect = url.searchParams.get('redirect_uri') ?? ''; + expect(redirect).toMatch(/^http:\/\/localhost:\d+\/$/); + const port = new URL(redirect).port; + const response = await fetch(`http://127.0.0.1:${port}/?code=abc&state=${url.searchParams.get('state')}`); + expect(response.status).toBe(200); + const done = await result; + expect(done).toMatchObject({ code: 'abc', redirectUri: redirect }); + expect(done.verifier).toMatch(/^[A-Za-z0-9_-]{43}$/); + }); +}); diff --git a/outreach-engine/packages/outreach-provider-kit/src/oauth.ts b/outreach-engine/packages/outreach-provider-kit/src/oauth.ts new file mode 100644 index 0000000..507e7a9 --- /dev/null +++ b/outreach-engine/packages/outreach-provider-kit/src/oauth.ts @@ -0,0 +1,96 @@ +import { createHash, randomBytes } from 'node:crypto'; +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; + +export interface LoopbackAuthorizeOptions { + /** The provider's authorization endpoint. */ + readonly authUrl: string; + /** Query parameters besides redirect_uri, state and the PKCE pair (client_id, scope, ...). */ + readonly params: Readonly>; + /** + * Host in the redirect URI. Google accepts `127.0.0.1`; Microsoft registers `http://localhost`. + * The listener always binds loopback addresses only. + */ + readonly redirectHost: '127.0.0.1' | 'localhost'; + /** Path of the redirect URI, e.g. `/callback` (Microsoft's `http://localhost` registration uses `/`). */ + readonly redirectPath?: string; + /** Receives the URL the user must open. */ + readonly onUrl: (url: string) => void; + /** Exchanges the authorization code (with the PKCE verifier) for whatever the caller needs. */ + readonly exchange: (input: { code: string; verifier: string; redirectUri: string }) => Promise; + readonly timeoutMs?: number; +} + +const PAGE = (message: string) => + `Outreach engine

            ${message}

            `; + +async function listen(server: Server, host: string, port: number): Promise { + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, host, () => resolve((server.address() as AddressInfo).port)); + }); +} + +/** + * The installed-app authorization code flow with a loopback redirect (RFC 8252): PKCE S256, a random + * `state`, listeners bound to loopback addresses only, and exactly one callback accepted. + */ +export async function loopbackAuthorize(options: LoopbackAuthorizeOptions): Promise { + const verifier = randomBytes(32).toString('base64url'); + const challenge = createHash('sha256').update(verifier).digest('base64url'); + const state = randomBytes(16).toString('base64url'); + const path = options.redirectPath ?? '/callback'; + + let settle: (value: { code: string } | Error) => void = () => {}; + const callback = new Promise<{ code: string } | Error>((resolve) => (settle = resolve)); + let done = false; + const handler: Parameters[1] = (req, res) => { + const url = new URL(req.url ?? '/', 'http://localhost'); + if (url.pathname !== path || done) { + res.writeHead(404).end(); + return; + } + const error = url.searchParams.get('error'); + const code = url.searchParams.get('code'); + const ok = !error && code !== null && url.searchParams.get('state') === state; + done = true; + res.writeHead(ok ? 200 : 400, { 'content-type': 'text/html; charset=utf-8' }); + res.end(PAGE(ok ? 'Connected. You can close this tab and return to the terminal.' : 'Authorization failed. Return to the terminal.')); + settle(ok ? { code } : new Error(error ? `the provider returned ${error}` : 'callback had a missing code or a mismatched state')); + }; + + // `localhost` may resolve to either loopback family in the browser, so listen on both. + const servers = [createServer(handler)]; + const port = await listen(servers[0]!, '127.0.0.1', 0); + if (options.redirectHost === 'localhost') { + const v6 = createServer(handler); + try { + await listen(v6, '::1', port); + servers.push(v6); + } catch { + // No IPv6 loopback on this machine; IPv4 is enough. + } + } + const redirectUri = `http://${options.redirectHost}:${port}${path}`; + try { + const auth = new URL(options.authUrl); + auth.search = new URLSearchParams({ + ...options.params, + redirect_uri: redirectUri, + response_type: 'code', + code_challenge: challenge, + code_challenge_method: 'S256', + state, + }).toString(); + options.onUrl(auth.toString()); + const timeout = new Promise((resolve) => setTimeout(() => resolve(new Error('timed out waiting for the sign-in')), options.timeoutMs ?? 300_000).unref()); + const result = await Promise.race([callback, timeout]); + if (result instanceof Error) throw result; + return await options.exchange({ code: result.code, verifier, redirectUri }); + } finally { + for (const server of servers) { + server.closeAllConnections(); + server.close(); + } + } +} diff --git a/outreach-engine/packages/outreach-provider-kit/tsconfig.json b/outreach-engine/packages/outreach-provider-kit/tsconfig.json new file mode 100644 index 0000000..585a92d --- /dev/null +++ b/outreach-engine/packages/outreach-provider-kit/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist" }, + "include": ["src"] +} diff --git a/outreach-engine/packages/outreach-provider-kit/tsup.config.ts b/outreach-engine/packages/outreach-provider-kit/tsup.config.ts new file mode 100644 index 0000000..1343a99 --- /dev/null +++ b/outreach-engine/packages/outreach-provider-kit/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/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..9609886 --- /dev/null +++ b/outreach-engine/packages/outreach-server/src/routes-operations.ts @@ -0,0 +1,60 @@ +import { + listManualTasks, + listUpcoming, + 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/actions/upcoming', (c) => { + const hours = z.coerce.number().int().min(1).max(168).default(24).parse(c.req.query('hours') ?? undefined); + return c.json({ actions: listUpcoming(engine, auth(c), hours) }); + }); + + 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/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..6572b0d --- /dev/null +++ b/outreach-engine/packages/outreach-store-sqlite/src/index.ts @@ -0,0 +1,5 @@ +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'; +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 new file mode 100644 index 0000000..ad4b528 --- /dev/null +++ b/outreach-engine/packages/outreach-store-sqlite/src/migrate.ts @@ -0,0 +1,51 @@ +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 }, + { id: '0002_api_tokens', sql: SCHEMA_0002 }, +]; + +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..8753f2e --- /dev/null +++ b/outreach-engine/packages/outreach-store-sqlite/src/open.ts @@ -0,0 +1,39 @@ +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 { + // busy_timeout first: `journal_mode` needs a lock, and another process closing the last connection holds an + // exclusive one while it checkpoints. Without a timeout already in place, opening then fails at once with + // "database is locked" instead of waiting (seen in the multi-process soak). + db.exec(`PRAGMA busy_timeout = ${Math.max(0, Math.floor(busyTimeoutMs))}`); + if (path !== ':memory:') db.exec('PRAGMA journal_mode = WAL'); + 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-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); +`; 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, +}); diff --git a/outreach-engine/scripts/build.mjs b/outreach-engine/scripts/build.mjs new file mode 100644 index 0000000..df5da75 --- /dev/null +++ b/outreach-engine/scripts/build.mjs @@ -0,0 +1,38 @@ +#!/usr/bin/env node +// Builds workspace packages in dependency order. npm's --workspaces order is alphabetical, which built +// @splitin/outreach-cli before the packages whose type declarations it needs (a clean CI checkout failed). +import { execFileSync } from 'node:child_process'; +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const packages = new Map(); +for (const group of ['packages', 'apps']) { + const base = join(root, group); + if (!existsSync(base)) continue; + for (const name of readdirSync(base)) { + const file = join(base, name, 'package.json'); + if (!existsSync(file)) continue; + const pkg = JSON.parse(readFileSync(file, 'utf8')); + const deps = Object.keys({ ...pkg.dependencies, ...pkg.devDependencies, ...pkg.peerDependencies }); + packages.set(pkg.name, { deps, build: Boolean(pkg.scripts?.build) }); + } +} + +const order = []; +const state = new Map(); +function visit(name, trail) { + if (state.get(name) === 'done') return; + if (state.get(name) === 'visiting') throw new Error(`dependency cycle: ${[...trail, name].join(' -> ')}`); + state.set(name, 'visiting'); + for (const dep of packages.get(name).deps) if (packages.has(dep)) visit(dep, [...trail, name]); + state.set(name, 'done'); + order.push(name); +} +for (const name of [...packages.keys()].sort()) visit(name, []); + +for (const name of order.filter((n) => packages.get(n).build)) { + process.stdout.write(`\n> building ${name}\n`); + execFileSync('npm', ['run', 'build', '--workspace', name], { cwd: root, stdio: 'inherit' }); +} 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..f307ca5 --- /dev/null +++ b/outreach-engine/scripts/check-package-boundaries.mjs @@ -0,0 +1,123 @@ +#!/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-kit']], + [/^@splitin\/outreach-provider-kit$/, ['@splitin/outreach-contracts']], + [/^@splitin\/outreach-provider-[a-z0-9-]+$/, ['@splitin/outreach-contracts', '@splitin/outreach-provider-kit']], + [/^@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. +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}`); + } + + 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(-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]; + 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) && !(isTest && devDeps.has(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/scripts/soak.mjs b/outreach-engine/scripts/soak.mjs new file mode 100644 index 0000000..925b34d --- /dev/null +++ b/outreach-engine/scripts/soak.mjs @@ -0,0 +1,97 @@ +#!/usr/bin/env node +// Multi-process soak (BUILD_PLAN.md §6.6, deferred from M3): N real `outreach worker` processes drain +// one SQLite file holding M due actions. Requires `npm run build`. Asserts every action was attempted +// exactly once, succeeded, and produced exactly one outbound message. +import { spawn, spawnSync } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const bin = join(root, 'packages/outreach-cli/dist/bin.js'); +const WORKERS = Number(process.env.SOAK_WORKERS ?? 4); +const ACTIONS = Number(process.env.SOAK_ACTIONS ?? 10_000); +const dir = mkdtempSync(join(tmpdir(), 'outreach-soak-')); +const env = { ...process.env, OUTREACH_DB: join(dir, 'soak.db'), OUTREACH_WORKSPACE: 'soak', OUTREACH_PRINCIPAL: 'cli:soak', NODE_NO_WARNINGS: '1' }; + +function cli(...args) { + const result = spawnSync(process.execPath, [bin, ...args, '--json'], { env, encoding: 'utf8' }); + if (result.status !== 0) throw new Error(`outreach ${args.join(' ')} failed:\n${result.stderr}`); + return JSON.parse(result.stdout); +} + +function worker() { + return new Promise((resolveRun, reject) => { + const child = spawn(process.execPath, [bin, 'worker', '--once', '--fake', '--json'], { env, stdio: ['ignore', 'pipe', 'pipe'] }); + let out = ''; + let err = ''; + child.stdout.on('data', (chunk) => (out += chunk)); + child.stderr.on('data', (chunk) => (err += chunk)); + child.on('close', (code) => (code === 0 ? resolveRun(JSON.parse(out)) : reject(new Error(`worker exited ${code}:\n${err}`)))); + }); +} + +try { + cli('init', '--fake'); + const { providerAccountId } = cli('account', 'add', '--fake', '--provider', 'fake-email', '--external-id', 'soak@example.com', '--sender-name', 'Soak', + '--sender-email', 'soak@example.com', '--purposes', 'automated_outreach', '--secret', 'env:SOAK_SECRET'); + cli('jurisdiction', 'set', '--default', 'allow', '--unknown', 'allow', '--signed-off-by', 'soak', '--reference', 'fake providers only'); + cli('gate', 'open', '--reason', 'soak test against fake providers only'); + + const { openSqliteDatabase } = await import(join(root, 'packages/outreach-store-sqlite/dist/index.js')); + const { enqueueAction } = await import(join(root, 'packages/outreach-core/dist/index.js')); + const db = openSqliteDatabase(env.OUTREACH_DB); + const now = Date.now(); + const actor = { kind: 'system', id: 'soak', source: 'soak', traceId: 'soak' }; + db.transaction(() => { + for (let i = 0; i < ACTIONS; i += 1) { + enqueueAction(db, { + workspaceId: 'soak', kind: 'email.send', providerAccountId, purpose: 'automated_outreach', recipient: `r${i}@example.org`, + idempotencyKey: `soak:${i}`, dueAt: now - 1_000, senderDomain: 'example.com', + payload: { from: { address: 'soak@example.com' }, to: [{ address: `r${i}@example.org` }], subject: `Soak ${i}`, text: 'x', headers: {} }, + }, actor, now); + } + }); + db.close(); + + // Each `worker --once` pass executes at most 500 actions, so run rounds of concurrent workers until drained. + const started = performance.now(); + const executedPerWorker = Array.from({ length: WORKERS }, () => 0); + let rounds = 0; + for (;;) { + rounds += 1; + const reports = await Promise.all(Array.from({ length: WORKERS }, worker)); + reports.forEach((report, i) => (executedPerWorker[i] += report.execute.executed)); + if (reports.every((report) => report.execute.claimed === 0) || rounds > 100) break; + } + const settle = await worker(); + const elapsed = Math.round(performance.now() - started); + + const check = openSqliteDatabase(env.OUTREACH_DB, { migrate: false }); + const count = (sql) => check.prepare(sql).get().n; + const results = { + workers: WORKERS, + actions: ACTIONS, + elapsedMs: elapsed, + rounds, + executedPerWorker, + settlePassExecuted: settle.execute.executed, + succeeded: count(`SELECT COUNT(*) AS n FROM scheduled_actions WHERE state = 'succeeded'`), + actionsWithMultipleAttempts: count(`SELECT COUNT(*) AS n FROM (SELECT action_id FROM action_attempts GROUP BY action_id HAVING COUNT(*) > 1)`), + attempts: count(`SELECT COUNT(*) AS n FROM action_attempts`), + outboundMessages: count(`SELECT COUNT(DISTINCT action_id) AS n FROM messages WHERE direction = 'outbound'`), + }; + check.close(); + process.stdout.write(`${JSON.stringify(results, null, 2)}\n`); + const ok = results.succeeded === ACTIONS && results.actionsWithMultipleAttempts === 0 && results.attempts === ACTIONS && results.outboundMessages === ACTIONS; + const sharedWork = results.executedPerWorker.filter((n) => n > 0).length >= Math.min(2, WORKERS); + if (!ok) throw new Error('soak invariants violated'); + if (!sharedWork) throw new Error('workers did not run concurrently; the soak proved nothing'); + process.stdout.write('Soak ok: every action attempted exactly once across concurrent worker processes.\n'); +} catch (error) { + process.stderr.write(`${error.stack ?? error}\n`); + process.exitCode = 1; +} finally { + rmSync(dir, { recursive: true, force: true }); +} 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..cf79e59 --- /dev/null +++ b/outreach-engine/vitest.config.ts @@ -0,0 +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, + }, +});