diff --git a/src/appfolio/README.md b/src/appfolio/README.md new file mode 100644 index 00000000..58f5ce96 --- /dev/null +++ b/src/appfolio/README.md @@ -0,0 +1,68 @@ +# AppFolio data connector (read-only) + +Read-only access to AppFolio's **Reporting (Data) API v2**. Agents (and the CLI) +can pull report data out of AppFolio; this connector has **no write path** — the +Reporting API only ever returns data, so it cannot change anything in AppFolio. + +## Configuration + +Credentials live in `orgs//secrets.env` (gitignored — never committed): + +``` +APPFOLIO_CLIENT_ID=... # Reports API Client ID (Basic-auth username) +APPFOLIO_CLIENT_SECRET=... # Reports API Client Secret (Basic-auth password) +APPFOLIO_API_BASE_URL=https://.appfolio.com +``` + +Get the Client ID/Secret in AppFolio: account menu → General Settings → +Manage API Settings → **Reports API Credentials**. + +## Usage + +``` +cortextos bus appfolio-report [--filters ''] [--max-pages N] [--max-rows N] [--rows-only] +``` + +Examples: + +``` +# First page of the rent roll +cortextos bus appfolio-report rent_roll --max-pages 1 + +# Active-property delinquencies, rows only +cortextos bus appfolio-report delinquency --rows-only + +# Open work orders with a filter +cortextos bus appfolio-report work_order --filters '{"property_visibility":"active"}' +``` + +Output is JSON: `{ ok, report, rows[], pagesFetched, truncated, rowCount }` +(or just the `rows` array with `--rows-only`). + +## Report names verified against a live AppFolio account (2026-06) + +| Use case | Report name(s) | +| ----------------------------- | ------------------------------------------------ | +| Rent roll / occupancy | `rent_roll`, `unit_directory`, `tenant_directory`| +| Delinquency / collections | `delinquency`, `aged_receivables_detail` | +| Maintenance / work orders | `work_order` | +| Leasing & renewals | `lease_expiration_detail`, `unit_vacancy`, `rental_applications` | + +The report set differs per AppFolio account; a wrong name returns HTTP 400 +(`"Id is not a valid report."`). Pass any valid report name — the connector is +generic, not limited to the list above. Note `rent_roll` already carries +`lease_to` / `lease_expires_month`, so lease expirations can also be derived +from it. + +## Notes + +- **Auth:** HTTP Basic (Client ID / Secret), sent as a header (never embedded in + the URL, so secrets don't leak into logs). +- **Pagination:** automatic via `next_page_url`; capped at 20 pages by default + (`--max-pages` / `--max-rows` to change). `truncated: true` means a cap, not + AppFolio, stopped the walk. +- **Rate limit:** 7 requests / 15 s on base endpoints (429). The connector + throttles between pages and retries once on 429 honoring `Retry-After`. + +Code: `src/appfolio/api.ts` (client) · `src/bus/appfolio.ts` (creds + bus logic) +· command wired in `src/cli/bus.ts` · tests in `tests/unit/bus/appfolio.test.ts`. diff --git a/src/appfolio/api.ts b/src/appfolio/api.ts new file mode 100644 index 00000000..d72df7b4 --- /dev/null +++ b/src/appfolio/api.ts @@ -0,0 +1,210 @@ +/** + * Minimal, READ-ONLY AppFolio Reporting (Data) API v2 client using built-in + * fetch (Node 20+). + * + * AppFolio's Reporting API is read-only by design: every endpoint is a report + * under /api/v2/reports/{report_name}.json and only ever returns data — there + * are no write/mutate endpoints here, so this connector cannot change anything + * in AppFolio. That is intentional (see the AppFolio connector plan): the + * agents get to *read* property data, nothing more. + * + * Auth: HTTP Basic — username = Client ID, password = Client Secret. + * Call: POST https://{db}.appfolio.com/api/v2/reports/{report}.json + * with a JSON body of filter params (`application/json`). + * Reply: { "results": [ ...rows ], "next_page_url": "https://..." | null } + * When paginate_results=false the body is a bare array of rows. + * Paging: follow `next_page_url` (a GET, valid ~30 min, not rate-limited). + * Limits: 7 requests / 15 s on the base endpoints (429 on exceed); the + * next_page_url is exempt. + */ + +/** Base report calls can be slow (server-side report generation), so this is + * deliberately longer than the chat-API clients' 10s. */ +const API_TIMEOUT_MS = 60_000; + +/** Stay comfortably under "7 requests / 15s" when walking pages on the base + * endpoint. next_page_url is exempt from rate limits, but throttling the + * whole walk is the simplest safe behavior. */ +const PAGE_THROTTLE_MS = 2_300; + +/** Safety cap so a runaway/huge report can't spin forever or blow up memory. + * At 5,000 rows/page this is up to ~100k rows. Override via opts.maxPages. */ +const DEFAULT_MAX_PAGES = 20; + +export interface AppFolioReportResponse { + results?: Record[]; + next_page_url?: string | null; +} + +export interface FetchReportOptions { + /** Stop after this many pages (default DEFAULT_MAX_PAGES). */ + maxPages?: number; + /** Stop once this many rows have been collected (across pages). */ + maxRows?: number; +} + +export interface FetchReportResult { + report: string; + rows: Record[]; + pagesFetched: number; + /** True when a page cap / row cap stopped us before AppFolio ran out of pages. */ + truncated: boolean; +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +export class AppFolioAPI { + private readonly baseUrl: string; + private readonly authHeader: string; + + /** + * @param clientId AppFolio Reports API Client ID (Basic-auth username) + * @param clientSecret AppFolio Reports API Client Secret (Basic-auth password) + * @param baseUrl e.g. https://yourcompany.appfolio.com (trailing slash OK) + */ + constructor(clientId: string, clientSecret: string, baseUrl: string) { + if (!clientId || !clientSecret) { + throw new Error('AppFolioAPI requires both clientId and clientSecret'); + } + if (!baseUrl) { + throw new Error('AppFolioAPI requires a baseUrl (e.g. https://yourco.appfolio.com)'); + } + // Normalize: strip trailing slash so we can join paths cleanly. + this.baseUrl = baseUrl.replace(/\/+$/, ''); + // HTTP Basic auth. We send it as a header rather than embedding the secret + // in the URL (which would leak into logs/error messages). + this.authHeader = + 'Basic ' + Buffer.from(`${clientId}:${clientSecret}`).toString('base64'); + } + + /** + * Fetch a report by its AppFolio path name (without the .json), following + * pagination automatically up to the configured caps. + * + * @param reportName e.g. "rent_roll", "delinquency", "unit_directory" + * @param params Report filter params sent as the JSON body, e.g. + * { properties: { property_visibility: 'active' } }. AppFolio + * reports each accept their own filters; pass {} for defaults. + */ + async fetchReport( + reportName: string, + params: Record = {}, + opts: FetchReportOptions = {}, + ): Promise { + const safeName = reportName.replace(/\.json$/i, '').trim(); + if (!/^[a-z0-9_]+$/i.test(safeName)) { + throw new Error(`Invalid report name "${reportName}" (expected like "rent_roll")`); + } + + const maxPages = opts.maxPages ?? DEFAULT_MAX_PAGES; + const rows: Record[] = []; + let pagesFetched = 0; + let truncated = false; + + // First page: POST the report endpoint with the filter params. + let body = await this.requestJson[]>( + `${this.baseUrl}/api/v2/reports/${safeName}.json`, + { + method: 'POST', + headers: { + Authorization: this.authHeader, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify(params), + }, + safeName, + ); + + while (true) { + // paginate_results=false yields a bare array; the normal shape is { results, next_page_url }. + const pageRows = Array.isArray(body) ? body : body.results ?? []; + const nextUrl = Array.isArray(body) ? null : body.next_page_url ?? null; + pagesFetched += 1; + + for (const row of pageRows) { + rows.push(row as Record); + if (opts.maxRows != null && rows.length >= opts.maxRows) { + truncated = nextUrl != null || rows.length < pageRows.length; + return { report: safeName, rows, pagesFetched, truncated }; + } + } + + if (!nextUrl) break; + if (pagesFetched >= maxPages) { + truncated = true; + break; + } + + // next_page_url is a fully-qualified GET URL and is exempt from rate + // limits, but we still throttle gently to be a good citizen. + await sleep(PAGE_THROTTLE_MS); + body = await this.requestJson[]>( + nextUrl, + { + method: 'GET', + headers: { + Authorization: this.authHeader, + Accept: 'application/json', + }, + }, + safeName, + ); + } + + return { report: safeName, rows, pagesFetched, truncated }; + } + + /** + * Shared fetch wrapper: bounded timeout, HTTP-status checks before JSON + * parsing, one automatic retry on 429 honoring Retry-After. Error messages + * include a snippet of the response body (AppFolio returns useful JSON + * errors) but never the credentials. + */ + private async requestJson(url: string, init: RequestInit, reportName: string): Promise { + for (let attempt = 0; attempt < 2; attempt++) { + const response = await fetch(url, { + ...init, + signal: AbortSignal.timeout(API_TIMEOUT_MS), + }); + + if (response.status === 429 && attempt === 0) { + const retryAfter = Number(response.headers.get('retry-after')) || 15; + await sleep(retryAfter * 1000); + continue; + } + + if (!response.ok) { + let detail = ''; + try { + detail = (await response.text()).slice(0, 300); + } catch { + /* ignore — body already consumed or unreadable */ + } + if (response.status === 401 || response.status === 403) { + throw new Error( + `AppFolio report "${reportName}" auth failed (HTTP ${response.status}). ` + + `Check APPFOLIO_CLIENT_ID / APPFOLIO_CLIENT_SECRET and that the ` + + `Reports API is enabled for this database.${detail ? ` — ${detail}` : ''}`, + ); + } + if (response.status === 404) { + throw new Error( + `AppFolio report "${reportName}" not found (HTTP 404). ` + + `The report path name may differ for your account.${detail ? ` — ${detail}` : ''}`, + ); + } + if (response.status === 429) { + throw new Error(`AppFolio report "${reportName}" rate limited (HTTP 429) after retry`); + } + throw new Error( + `AppFolio report "${reportName}" failed: HTTP ${response.status}${detail ? ` — ${detail}` : ''}`, + ); + } + + return (await response.json()) as T; + } + // Unreachable: the loop either returns or throws. + throw new Error(`AppFolio report "${reportName}" failed after retry`); + } +} diff --git a/src/bus/appfolio.ts b/src/bus/appfolio.ts new file mode 100644 index 00000000..4bf63ce5 --- /dev/null +++ b/src/bus/appfolio.ts @@ -0,0 +1,89 @@ +/** + * Bus logic for the read-only AppFolio data connector. + * + * Loads the AppFolio Reports API credentials (Client ID / Secret / base URL) + * and fetches a report by name. Credentials come from the process environment + * when present (agents run with orgs//secrets.env already sourced into + * their PTY env), falling back to reading orgs//secrets.env directly so + * the same command works when invoked from a plain CLI shell. + */ +import { existsSync, readFileSync } from 'fs'; +import { join } from 'path'; +import { AppFolioAPI, type FetchReportResult } from '../appfolio/api.js'; + +export interface AppFolioCreds { + clientId: string; + clientSecret: string; + baseUrl: string; +} + +/** + * Parse a .env-style file into a flat key→value map, stripping comments and + * surrounding quotes — same shape as the loader used by knowledge-base.ts. + */ +function parseEnvFile(path: string): Record { + const vars: Record = {}; + if (!existsSync(path)) return vars; + for (const line of readFileSync(path, 'utf-8').split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const idx = trimmed.indexOf('='); + if (idx <= 0) continue; + let val = trimmed.slice(idx + 1).trim(); + if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) { + val = val.slice(1, -1); + } + vars[trimmed.slice(0, idx).trim()] = val; + } + return vars; +} + +/** + * Resolve AppFolio creds: prefer process.env (agent context), then + * orgs//secrets.env (CLI context). Throws a clear, actionable error if + * anything is missing — and never echoes secret values. + */ +export function loadAppfolioCreds(frameworkRoot: string, org: string): AppFolioCreds { + const fileVars = parseEnvFile(join(frameworkRoot, 'orgs', org, 'secrets.env')); + const pick = (key: string): string => + (process.env[key] && process.env[key]!.trim()) || fileVars[key] || ''; + + const clientId = pick('APPFOLIO_CLIENT_ID'); + const clientSecret = pick('APPFOLIO_CLIENT_SECRET'); + const baseUrl = pick('APPFOLIO_API_BASE_URL'); + + const missing: string[] = []; + if (!clientId) missing.push('APPFOLIO_CLIENT_ID'); + if (!clientSecret) missing.push('APPFOLIO_CLIENT_SECRET'); + if (!baseUrl) missing.push('APPFOLIO_API_BASE_URL'); + if (missing.length > 0) { + throw new Error( + `AppFolio not configured: missing ${missing.join(', ')}. ` + + `Add them to orgs/${org}/secrets.env (Reports API → Client ID/Secret; ` + + `base URL is your AppFolio web address, e.g. https://yourco.appfolio.com).`, + ); + } + return { clientId, clientSecret, baseUrl }; +} + +export interface FetchAppfolioReportResult extends FetchReportResult { + ok: true; + rowCount: number; +} + +/** + * Fetch one AppFolio report by name with optional filter params. + * Read-only — see AppFolioAPI for why no write path exists. + */ +export async function fetchAppfolioReport( + frameworkRoot: string, + org: string, + reportName: string, + params: Record = {}, + opts: { maxPages?: number; maxRows?: number } = {}, +): Promise { + const { clientId, clientSecret, baseUrl } = loadAppfolioCreds(frameworkRoot, org); + const client = new AppFolioAPI(clientId, clientSecret, baseUrl); + const result = await client.fetchReport(reportName, params, opts); + return { ok: true, ...result, rowCount: result.rows.length }; +} diff --git a/src/bus/hostaway.ts b/src/bus/hostaway.ts new file mode 100644 index 00000000..1e641755 --- /dev/null +++ b/src/bus/hostaway.ts @@ -0,0 +1,83 @@ +/** + * Bus logic for the read-only Hostaway data connector. + * + * Loads the Hostaway Public API credentials (Account ID / API Key) and fetches + * a list resource by name. Like the AppFolio connector, credentials come from + * process.env when present (agent PTY context), falling back to reading + * orgs//secrets.env directly so the same command works from a plain CLI. + * + * This path is READ-ONLY: it only calls HostawayAPI.fetchResource, which only + * issues HTTP GET. There is no write path here by design. + */ +import { existsSync, readFileSync } from 'fs'; +import { join } from 'path'; +import { HostawayAPI, type FetchResourceResult } from '../hostaway/api.js'; + +export interface HostawayCreds { + accountId: string; + apiKey: string; +} + +function parseEnvFile(path: string): Record { + const vars: Record = {}; + if (!existsSync(path)) return vars; + for (const line of readFileSync(path, 'utf-8').split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const idx = trimmed.indexOf('='); + if (idx <= 0) continue; + let val = trimmed.slice(idx + 1).trim(); + if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) { + val = val.slice(1, -1); + } + vars[trimmed.slice(0, idx).trim()] = val; + } + return vars; +} + +/** + * Resolve Hostaway creds: prefer process.env (agent context), then + * orgs//secrets.env (CLI context). Throws an actionable error if missing, + * and never echoes secret values. + */ +export function loadHostawayCreds(frameworkRoot: string, org: string): HostawayCreds { + const fileVars = parseEnvFile(join(frameworkRoot, 'orgs', org, 'secrets.env')); + const pick = (key: string): string => + (process.env[key] && process.env[key]!.trim()) || fileVars[key] || ''; + + const accountId = pick('HOSTAWAY_ACCOUNT_ID'); + const apiKey = pick('HOSTAWAY_API_KEY'); + + const missing: string[] = []; + if (!accountId) missing.push('HOSTAWAY_ACCOUNT_ID'); + if (!apiKey) missing.push('HOSTAWAY_API_KEY'); + if (missing.length > 0) { + throw new Error( + `Hostaway not configured: missing ${missing.join(', ')}. ` + + `Add them to orgs/${org}/secrets.env (Hostaway dashboard → Settings → Hostaway API → Create).`, + ); + } + return { accountId, apiKey }; +} + +export interface FetchHostawayResourceResult extends FetchResourceResult { + ok: true; + rowCount: number; +} + +/** + * Fetch one read-only Hostaway resource (e.g. listings, reservations, calendar) + * with optional query params. Read-only — see HostawayAPI for why no write + * path exists. + */ +export async function fetchHostawayResource( + frameworkRoot: string, + org: string, + resource: string, + opts: { query?: Record; maxPages?: number; maxRows?: number } = {}, +): Promise { + const { accountId, apiKey } = loadHostawayCreds(frameworkRoot, org); + const client = new HostawayAPI(accountId, apiKey); + const result = await client.fetchResource(resource, opts); + return { ok: true, ...result, rowCount: result.rows.length }; +} diff --git a/src/bus/tenantturner.ts b/src/bus/tenantturner.ts new file mode 100644 index 00000000..26b0c713 --- /dev/null +++ b/src/bus/tenantturner.ts @@ -0,0 +1,82 @@ +/** + * Bus logic for the read-only Tenant Turner data connector. + * + * Loads the Tenant Turner private API key and fetches a list resource by name. + * Like the AppFolio and Hostaway connectors, the credential comes from + * process.env when present (agent PTY context), falling back to reading + * orgs//secrets.env directly so the same command works from a plain CLI. + * + * This path is READ-ONLY: it only calls TenantTurnerAPI.fetchResource, which + * only issues HTTP GET. There is no write path here by design. + */ +import { existsSync, readFileSync } from 'fs'; +import { join } from 'path'; +import { TenantTurnerAPI, type FetchResourceResult } from '../tenantturner/api.js'; + +export interface TenantTurnerCreds { + apiKey: string; +} + +function parseEnvFile(path: string): Record { + const vars: Record = {}; + if (!existsSync(path)) return vars; + for (const line of readFileSync(path, 'utf-8').split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const idx = trimmed.indexOf('='); + if (idx <= 0) continue; + let val = trimmed.slice(idx + 1).trim(); + if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) { + val = val.slice(1, -1); + } + vars[trimmed.slice(0, idx).trim()] = val; + } + return vars; +} + +/** + * Resolve the Tenant Turner API key: prefer process.env (agent context), then + * orgs//secrets.env (CLI context). Throws an actionable error if missing, + * and never echoes the secret value. + */ +export function loadTenantTurnerCreds(frameworkRoot: string, org: string): TenantTurnerCreds { + const fileVars = parseEnvFile(join(frameworkRoot, 'orgs', org, 'secrets.env')); + const pick = (key: string): string => + (process.env[key] && process.env[key]!.trim()) || fileVars[key] || ''; + + const apiKey = pick('TENANTTURNER_API_KEY'); + if (!apiKey) { + throw new Error( + `Tenant Turner not configured: missing TENANTTURNER_API_KEY. ` + + `Add it to orgs/${org}/secrets.env (Tenant Turner → Settings → Tenant Turner API → Refresh API Key).`, + ); + } + return { apiKey }; +} + +export interface FetchTenantTurnerResourceResult extends FetchResourceResult { + ok: true; + rowCount: number; +} + +/** + * Fetch one read-only Tenant Turner resource (e.g. applications, properties) + * with an optional SinceDateUpdated filter and extra query params. Read-only — + * see TenantTurnerAPI for why no write path exists. + */ +export async function fetchTenantTurnerResource( + frameworkRoot: string, + org: string, + resource: string, + opts: { + sinceDateUpdated?: string; + query?: Record; + maxPages?: number; + maxRows?: number; + } = {}, +): Promise { + const { apiKey } = loadTenantTurnerCreds(frameworkRoot, org); + const client = new TenantTurnerAPI(apiKey); + const result = await client.fetchResource(resource, opts); + return { ok: true, ...result, rowCount: result.rows.length }; +} diff --git a/src/bus/zinspector.ts b/src/bus/zinspector.ts new file mode 100644 index 00000000..3eb75890 --- /dev/null +++ b/src/bus/zinspector.ts @@ -0,0 +1,82 @@ +/** + * Bus logic for the read-only zInspector data connector. + * + * Loads the zInspector Encoded API key (+ base URL) and fetches a list resource + * by name. Like the other connectors, the credential comes from process.env + * when present (agent PTY context), falling back to reading orgs//secrets.env + * directly so the same command works from a plain CLI. + * + * This path is READ-ONLY: it only calls ZInspectorAPI.fetchResource, which only + * issues HTTP GET. There is no write path here by design. + */ +import { existsSync, readFileSync } from 'fs'; +import { join } from 'path'; +import { ZInspectorAPI, type FetchResourceResult } from '../zinspector/api.js'; + +const DEFAULT_BASE_URL = 'https://portfolio.zinspector.com'; + +export interface ZInspectorCreds { + apiKey: string; + baseUrl: string; +} + +function parseEnvFile(path: string): Record { + const vars: Record = {}; + if (!existsSync(path)) return vars; + for (const line of readFileSync(path, 'utf-8').split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const idx = trimmed.indexOf('='); + if (idx <= 0) continue; + let val = trimmed.slice(idx + 1).trim(); + if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) { + val = val.slice(1, -1); + } + vars[trimmed.slice(0, idx).trim()] = val; + } + return vars; +} + +/** + * Resolve the zInspector credential: prefer process.env (agent context), then + * orgs//secrets.env (CLI context). The base URL defaults to the standard + * portfolio host when unset. Throws an actionable error if the key is missing, + * and never echoes the secret value. + */ +export function loadZinspectorCreds(frameworkRoot: string, org: string): ZInspectorCreds { + const fileVars = parseEnvFile(join(frameworkRoot, 'orgs', org, 'secrets.env')); + const pick = (key: string): string => + (process.env[key] && process.env[key]!.trim()) || fileVars[key] || ''; + + const apiKey = pick('ZINSPECTOR_API_KEY'); + const baseUrl = pick('ZINSPECTOR_API_BASE_URL') || DEFAULT_BASE_URL; + if (!apiKey) { + throw new Error( + `zInspector not configured: missing ZINSPECTOR_API_KEY. Add the "Encoded API key" to ` + + `orgs/${org}/secrets.env (zInspector → Configuration → API Keys → create, Linked User = an Admin).`, + ); + } + return { apiKey, baseUrl }; +} + +export interface FetchZinspectorResourceResult extends FetchResourceResult { + ok: true; + rowCount: number; +} + +/** + * Fetch one read-only zInspector resource (e.g. propertiesCursor, documents, + * media, process) with optional query params. Read-only — see ZInspectorAPI for + * why no write path exists. + */ +export async function fetchZinspectorResource( + frameworkRoot: string, + org: string, + resource: string, + opts: { query?: Record; maxPages?: number; maxRows?: number } = {}, +): Promise { + const { apiKey, baseUrl } = loadZinspectorCreds(frameworkRoot, org); + const client = new ZInspectorAPI(apiKey, baseUrl); + const result = await client.fetchResource(resource, opts); + return { ok: true, ...result, rowCount: result.rows.length }; +} diff --git a/src/cli/bus.ts b/src/cli/bus.ts index 300cdf6c..1a4f47d9 100644 --- a/src/cli/bus.ts +++ b/src/cli/bus.ts @@ -24,6 +24,10 @@ import { updateCronFire, parseDurationMs, readCronState } from '../bus/cron-stat import { addCron, removeCron, readCrons, updateCron as updateCronDef, getCronByName, getExecutionLog } from '../bus/crons.js'; import { nextFireFromCron } from '../daemon/cron-scheduler.js'; import { queryKnowledgeBase, ingestKnowledgeBase, ensureKBDirs } from '../bus/knowledge-base.js'; +import { fetchAppfolioReport } from '../bus/appfolio.js'; +import { fetchHostawayResource } from '../bus/hostaway.js'; +import { fetchTenantTurnerResource } from '../bus/tenantturner.js'; +import { fetchZinspectorResource } from '../bus/zinspector.js'; import { checkUsageApi, refreshOAuthToken, rotateOAuth, loadAccounts, ALERT_5H, ALERT_7D } from '../bus/oauth.js'; import { createSkillPr } from '../bus/skill-autopr.js'; import { atomicWriteSync } from '../utils/atomic.js'; @@ -1727,6 +1731,211 @@ busCommand } }); +// --------------------------------------------------------------------------- +// AppFolio (read-only data connector) +// --------------------------------------------------------------------------- +busCommand + .command('appfolio-report ') + .description('Fetch a read-only AppFolio report by name (e.g. rent_roll, delinquency, unit_directory)') + .option('--org ', 'Organization name (defaults to CTX_ORG)') + .option('--filters ', 'Report filter params as a JSON object', '{}') + .option('--max-pages ', 'Stop after N pages (default 20)') + .option('--max-rows ', 'Stop after N rows') + .option('--rows-only', 'Print just the array of row objects (omit metadata)') + .action(async ( + report: string, + opts: { org?: string; filters: string; maxPages?: string; maxRows?: string; rowsOnly?: boolean }, + ) => { + const env = resolveEnv(); + const org = opts.org || env.org; + if (!org) { + console.error('ERROR: --org or CTX_ORG required'); + process.exit(1); + } + const frameworkRoot = env.frameworkRoot || process.cwd(); + + let filters: Record; + try { + filters = JSON.parse(opts.filters); + if (typeof filters !== 'object' || filters === null || Array.isArray(filters)) { + throw new Error('filters must be a JSON object'); + } + } catch (e) { + console.error(`ERROR: --filters is not valid JSON object: ${(e as Error).message}`); + process.exit(1); + } + + try { + const result = await fetchAppfolioReport(frameworkRoot, org, report, filters, { + maxPages: opts.maxPages ? Number(opts.maxPages) : undefined, + maxRows: opts.maxRows ? Number(opts.maxRows) : undefined, + }); + if (opts.rowsOnly) { + console.log(JSON.stringify(result.rows, null, 2)); + } else { + console.log(JSON.stringify(result, null, 2)); + } + } catch (e) { + console.error(`ERROR: ${(e as Error).message}`); + process.exit(1); + } + }); + +// --------------------------------------------------------------------------- +// Hostaway (read-only data connector) +// --------------------------------------------------------------------------- +busCommand + .command('hostaway-get ') + .description('Fetch a read-only Hostaway resource by name (e.g. listings, reservations, calendar)') + .option('--org ', 'Organization name (defaults to CTX_ORG)') + .option('--query ', 'Extra query params as a JSON object (e.g. {"listingId":123})', '{}') + .option('--max-pages ', 'Stop after N pages (default 20)') + .option('--max-rows ', 'Stop after N rows') + .option('--rows-only', 'Print just the array of row objects (omit metadata)') + .action(async ( + resource: string, + opts: { org?: string; query: string; maxPages?: string; maxRows?: string; rowsOnly?: boolean }, + ) => { + const env = resolveEnv(); + const org = opts.org || env.org; + if (!org) { + console.error('ERROR: --org or CTX_ORG required'); + process.exit(1); + } + const frameworkRoot = env.frameworkRoot || process.cwd(); + + let query: Record; + try { + query = JSON.parse(opts.query); + if (typeof query !== 'object' || query === null || Array.isArray(query)) { + throw new Error('query must be a JSON object'); + } + } catch (e) { + console.error(`ERROR: --query is not a valid JSON object: ${(e as Error).message}`); + process.exit(1); + } + + try { + const result = await fetchHostawayResource(frameworkRoot, org, resource, { + query, + maxPages: opts.maxPages ? Number(opts.maxPages) : undefined, + maxRows: opts.maxRows ? Number(opts.maxRows) : undefined, + }); + if (opts.rowsOnly) { + console.log(JSON.stringify(result.rows, null, 2)); + } else { + console.log(JSON.stringify(result, null, 2)); + } + } catch (e) { + console.error(`ERROR: ${(e as Error).message}`); + process.exit(1); + } + }); + +// --------------------------------------------------------------------------- +// Tenant Turner (read-only data connector) +// --------------------------------------------------------------------------- +busCommand + .command('tenantturner-get ') + .description('Fetch a read-only Tenant Turner resource by name (e.g. applications, properties)') + .option('--org ', 'Organization name (defaults to CTX_ORG)') + .option('--since ', 'SinceDateUpdated filter (YYYY-MM-DD, within the last 2 years) — required for applications') + .option('--query ', 'Extra query params as a JSON object', '{}') + .option('--max-pages ', 'Stop after N pages (default 20)') + .option('--max-rows ', 'Stop after N rows') + .option('--rows-only', 'Print just the array of row objects (omit metadata)') + .action(async ( + resource: string, + opts: { org?: string; since?: string; query: string; maxPages?: string; maxRows?: string; rowsOnly?: boolean }, + ) => { + const env = resolveEnv(); + const org = opts.org || env.org; + if (!org) { + console.error('ERROR: --org or CTX_ORG required'); + process.exit(1); + } + const frameworkRoot = env.frameworkRoot || process.cwd(); + + let query: Record; + try { + query = JSON.parse(opts.query); + if (typeof query !== 'object' || query === null || Array.isArray(query)) { + throw new Error('query must be a JSON object'); + } + } catch (e) { + console.error(`ERROR: --query is not a valid JSON object: ${(e as Error).message}`); + process.exit(1); + } + + try { + const result = await fetchTenantTurnerResource(frameworkRoot, org, resource, { + sinceDateUpdated: opts.since, + query, + maxPages: opts.maxPages ? Number(opts.maxPages) : undefined, + maxRows: opts.maxRows ? Number(opts.maxRows) : undefined, + }); + if (opts.rowsOnly) { + console.log(JSON.stringify(result.rows, null, 2)); + } else { + console.log(JSON.stringify(result, null, 2)); + } + } catch (e) { + console.error(`ERROR: ${(e as Error).message}`); + process.exit(1); + } + }); + +// --------------------------------------------------------------------------- +// zInspector (read-only data connector) +// --------------------------------------------------------------------------- +busCommand + .command('zinspector-get ') + .description('Fetch a read-only zInspector resource by name (e.g. propertiesCursor, documents, media, process)') + .option('--org ', 'Organization name (defaults to CTX_ORG)') + .option('--query ', 'Extra query params as a JSON object', '{}') + .option('--max-pages ', 'Stop after N pages (default 20)') + .option('--max-rows ', 'Stop after N rows') + .option('--rows-only', 'Print just the array of row objects (omit metadata)') + .action(async ( + resource: string, + opts: { org?: string; query: string; maxPages?: string; maxRows?: string; rowsOnly?: boolean }, + ) => { + const env = resolveEnv(); + const org = opts.org || env.org; + if (!org) { + console.error('ERROR: --org or CTX_ORG required'); + process.exit(1); + } + const frameworkRoot = env.frameworkRoot || process.cwd(); + + let query: Record; + try { + query = JSON.parse(opts.query); + if (typeof query !== 'object' || query === null || Array.isArray(query)) { + throw new Error('query must be a JSON object'); + } + } catch (e) { + console.error(`ERROR: --query is not a valid JSON object: ${(e as Error).message}`); + process.exit(1); + } + + try { + const result = await fetchZinspectorResource(frameworkRoot, org, resource, { + query, + maxPages: opts.maxPages ? Number(opts.maxPages) : undefined, + maxRows: opts.maxRows ? Number(opts.maxRows) : undefined, + }); + if (opts.rowsOnly) { + console.log(JSON.stringify(result.rows, null, 2)); + } else { + console.log(JSON.stringify(result, null, 2)); + } + } catch (e) { + console.error(`ERROR: ${(e as Error).message}`); + process.exit(1); + } + }); + // --------------------------------------------------------------------------- // Hook subcommands — cross-platform replacements for hook-*.sh bash scripts // These are invoked by Claude Code settings.json hooks on all platforms. diff --git a/src/hostaway/README.md b/src/hostaway/README.md new file mode 100644 index 00000000..3b75c2ce --- /dev/null +++ b/src/hostaway/README.md @@ -0,0 +1,60 @@ +# Hostaway data connector (read-only) + +Read-only access to the **Hostaway Public API v1** (vacation/short-term rentals). +Agents (and the CLI) can pull data out of Hostaway; this connector is read-only +**by construction** — it only ever issues HTTP GET and exposes no +create/update/delete methods. Hostaway's API key is account-level and *could* +write, so the safety guarantee is that the agents' tool has no write code path. + +## Configuration + +Credentials live in `orgs//secrets.env` (gitignored — never committed): + +``` +HOSTAWAY_ACCOUNT_ID=... # OAuth client_id +HOSTAWAY_API_KEY=... # OAuth client_secret (shown only once in Hostaway) +``` + +Get them in Hostaway: **Settings → Hostaway API → Create** → name it → save the +**Account ID** and **API Key** (the key is shown only once). Revoke anytime from +the same screen. + +The connector trades these for a Bearer access token automatically +(client-credentials grant at `/v1/accessTokens`); you don't manage the token. + +## Usage + +``` +cortextos bus hostaway-get [--query ''] [--max-pages N] [--max-rows N] [--rows-only] +``` + +Examples: + +``` +# Your listings +cortextos bus hostaway-get listings --rows-only + +# Reservations (first page) +cortextos bus hostaway-get reservations --max-pages 1 + +# Calendar for one listing +cortextos bus hostaway-get calendar --query '{"listingId":151111}' +``` + +Output is JSON: `{ ok, resource, rows[], pagesFetched, truncated, rowCount }` +(or just the `rows` array with `--rows-only`). + +Common read-only resources: `listings`, `reservations`, `calendar`, +`conversations`, `guests`, `reviews`. The exact set depends on your Hostaway +plan; a wrong name returns an error. + +## Notes + +- **Auth:** OAuth2 client-credentials. Account ID + API Key → Bearer token + (minted per process, reused across that run's requests). Sent as a header. +- **Pagination:** automatic via `limit`/`offset`, capped at 20 pages by default + (`--max-pages` / `--max-rows`). `truncated: true` means a cap stopped the walk. +- **Read-only:** there is no write method anywhere in this client. + +Code: `src/hostaway/api.ts` (client) · `src/bus/hostaway.ts` (creds + bus logic) +· command wired in `src/cli/bus.ts` · tests in `tests/unit/bus/hostaway.test.ts`. diff --git a/src/hostaway/api.ts b/src/hostaway/api.ts new file mode 100644 index 00000000..712f3013 --- /dev/null +++ b/src/hostaway/api.ts @@ -0,0 +1,204 @@ +/** + * Minimal, READ-ONLY Hostaway Public API v1 client using built-in fetch + * (Node 20+). + * + * This connector is read-only BY CONSTRUCTION: it only ever issues HTTP GET + * requests and exposes no create/update/delete methods. Hostaway's API key is + * account-level and *could* write, so the safety guarantee here is that the + * agents' tool simply has no code path that writes — see the connector plan. + * + * Auth: OAuth2 client-credentials. Trade Account ID (client_id) + API Key + * (client_secret) for a Bearer access token at /v1/accessTokens, then + * send `Authorization: Bearer ` on every call. We mint a token + * per process (CLI invocations are short-lived) and reuse it for all + * requests in that run. + * Call: GET https://api.hostaway.com/v1/{resource}?limit=&offset= + * Reply: { status: 'success', result: [...], count, limit, offset } + * Paging: limit/offset — walk until a short page or the row cap is hit. + */ + +const BASE_URL = 'https://api.hostaway.com/v1'; +const API_TIMEOUT_MS = 30_000; +const PAGE_SIZE = 100; +const DEFAULT_MAX_PAGES = 20; + +export interface HostawayListResponse { + status: string; + result?: unknown[]; + count?: number; + limit?: number; + offset?: number; + message?: string; +} + +export interface FetchResourceOptions { + /** Extra query params merged into each request (e.g. { listingId: 123 }). */ + query?: Record; + maxPages?: number; + maxRows?: number; +} + +export interface FetchResourceResult { + resource: string; + rows: unknown[]; + pagesFetched: number; + truncated: boolean; +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +export class HostawayAPI { + private readonly accountId: string; + private readonly apiKey: string; + private token: string | null = null; + + /** + * @param accountId Hostaway Account ID (OAuth client_id) + * @param apiKey Hostaway API Key (OAuth client_secret) + */ + constructor(accountId: string, apiKey: string) { + if (!accountId || !apiKey) { + throw new Error('HostawayAPI requires both accountId and apiKey'); + } + this.accountId = accountId; + this.apiKey = apiKey; + } + + /** + * Obtain (and cache for this process) a Bearer access token via the + * client-credentials grant. scope=general is the standard read scope. + */ + private async getToken(): Promise { + if (this.token) return this.token; + + const body = new URLSearchParams({ + grant_type: 'client_credentials', + client_id: this.accountId, + client_secret: this.apiKey, + scope: 'general', + }); + + const response = await fetch(`${BASE_URL}/accessTokens`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Cache-Control': 'no-cache', + }, + body, + signal: AbortSignal.timeout(API_TIMEOUT_MS), + }); + + if (!response.ok) { + let detail = ''; + try { + detail = (await response.text()).slice(0, 300); + } catch { + /* ignore */ + } + if (response.status === 401 || response.status === 403) { + throw new Error( + `Hostaway auth failed (HTTP ${response.status}). Check HOSTAWAY_ACCOUNT_ID / ` + + `HOSTAWAY_API_KEY in secrets.env (Settings → Hostaway API).${detail ? ` — ${detail}` : ''}`, + ); + } + throw new Error(`Hostaway token request failed: HTTP ${response.status}${detail ? ` — ${detail}` : ''}`); + } + + const data = (await response.json()) as { access_token?: string }; + if (!data.access_token) { + throw new Error('Hostaway token response did not include an access_token'); + } + this.token = data.access_token; + return this.token; + } + + /** + * Fetch a read-only Hostaway list resource (e.g. "listings", "reservations", + * "calendar"), following limit/offset pagination up to the configured caps. + * + * Only GET is ever issued — there is no write counterpart on this client. + */ + async fetchResource(resource: string, opts: FetchResourceOptions = {}): Promise { + const safe = resource.replace(/^\/+|\/+$/g, '').trim(); + if (!/^[a-z0-9/_-]+$/i.test(safe)) { + throw new Error(`Invalid Hostaway resource "${resource}" (expected like "listings")`); + } + + const token = await this.getToken(); + const maxPages = opts.maxPages ?? DEFAULT_MAX_PAGES; + const rows: unknown[] = []; + let pagesFetched = 0; + let truncated = false; + let offset = 0; + + while (true) { + const params = new URLSearchParams({ limit: String(PAGE_SIZE), offset: String(offset) }); + for (const [k, v] of Object.entries(opts.query ?? {})) params.set(k, String(v)); + + const data = await this.requestGet( + `${BASE_URL}/${safe}?${params}`, + token, + safe, + ); + + const pageRows = Array.isArray(data.result) ? data.result : []; + pagesFetched += 1; + + for (const row of pageRows) { + rows.push(row); + if (opts.maxRows != null && rows.length >= opts.maxRows) { + return { resource: safe, rows, pagesFetched, truncated: true }; + } + } + + // A short page means we've reached the end. + if (pageRows.length < PAGE_SIZE) break; + if (pagesFetched >= maxPages) { + truncated = true; + break; + } + offset += PAGE_SIZE; + await sleep(200); // gentle throttle between pages + } + + return { resource: safe, rows, pagesFetched, truncated }; + } + + /** + * Shared GET wrapper: bounded timeout, HTTP-status checks, Hostaway-level + * `status:fail` detection. Never throws the credentials into the message. + */ + private async requestGet(url: string, token: string, resource: string): Promise { + const response = await fetch(url, { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + 'Cache-Control': 'no-cache', + Accept: 'application/json', + }, + signal: AbortSignal.timeout(API_TIMEOUT_MS), + }); + + if (!response.ok) { + let detail = ''; + try { + detail = (await response.text()).slice(0, 300); + } catch { + /* ignore */ + } + if (response.status === 404) { + throw new Error( + `Hostaway resource "${resource}" not found (HTTP 404). The resource name may be wrong.` + + `${detail ? ` — ${detail}` : ''}`, + ); + } + throw new Error(`Hostaway resource "${resource}" failed: HTTP ${response.status}${detail ? ` — ${detail}` : ''}`); + } + + const data = (await response.json()) as T & { status?: string; message?: string }; + if (data.status && data.status !== 'success') { + throw new Error(`Hostaway resource "${resource}" returned status=${data.status}${data.message ? `: ${data.message}` : ''}`); + } + return data; + } +} diff --git a/src/tenantturner/README.md b/src/tenantturner/README.md new file mode 100644 index 00000000..2ccfb913 --- /dev/null +++ b/src/tenantturner/README.md @@ -0,0 +1,63 @@ +# Tenant Turner connector (read-only) + +A minimal, **read-only** client for the [Tenant Turner](https://tenantturner.com) +API v1 — the leasing / showing-scheduling platform. It lets agents *read* the +leasing pipeline (leads, their scheduled showings, and pre-screening answers). +It cannot change anything in Tenant Turner: the client only issues HTTP `GET` +and exposes no create/update/delete methods. + +> **Safety note.** Tenant Turner's private API key is account-level and *can* +> write (`POST /v1/showings` books a showing). The guarantee here is +> *read-only by construction* — there is simply no write code path — plus the +> key living in gitignored `secrets.env` and being revocable/refreshable in the +> Tenant Turner dashboard. Records include applicant PII (income, eviction / +> bankruptcy history); treat output accordingly. + +## Credentials + +Set in `orgs//secrets.env` (preferred) or the process env: + +| Variable | Meaning | +| ----------------------- | ---------------------------------------------------------- | +| `TENANTTURNER_API_KEY` | Account private API key — Settings → Tenant Turner API | + +The connector reads `process.env` first (agent PTY context), then falls back to +`orgs//secrets.env` so the same command works from a plain CLI. + +## API shape + +- **Auth:** HTTP Basic where the value is `base64(apiKey)` (the bare key, no + `username:password`). Sent as an `Authorization` header, never in the URL. +- **Base URL:** `https://api.tenantturner.com` +- **Call:** `GET /v1/{resource}?SinceDateUpdated=YYYY-MM-DD` + - `SinceDateUpdated` is **required** by `applications` (and must be within the + last 2 years); `properties` needs no params. +- **Reply:** `{ TotalCount, NextPage: "" | null, Data: [ ...rows ] }` +- **Paging:** follow the opaque `NextPage` cursor by passing it back as + `?NextPage=` (it already encodes the page size + `SinceDateUpdated`). + ~10 rows/page. The client walks pages up to `--max-pages` (default 20). + +### Read resources + +| Resource | Notes | +| -------------- | --------------------------------------------------------------------------------- | +| `applications` | The unified lead record: contact, income, acquisition source, pre-screening answers, and a nested `Showings[]` array (times + status). Needs `--since`. | +| `properties` | Listings pushed to Tenant Turner. No `--since` required. | + +`GET /v1/showings` is **write-only** (POST books a showing) and is never used. + +## CLI usage + +```bash +# New / updated leads (with their showings) since a date +cortextos bus tenantturner-get applications --since 2026-06-25 + +# Just the row objects (for scripts), capped +cortextos bus tenantturner-get applications --since 2026-06-25 --rows-only --max-rows 200 + +# Listings +cortextos bus tenantturner-get properties +``` + +Flags: `--org` (defaults to `CTX_ORG`), `--since `, `--query `, +`--max-pages `, `--max-rows `, `--rows-only`. diff --git a/src/tenantturner/api.ts b/src/tenantturner/api.ts new file mode 100644 index 00000000..36f03bec --- /dev/null +++ b/src/tenantturner/api.ts @@ -0,0 +1,176 @@ +/** + * Minimal, READ-ONLY Tenant Turner API v1 client using built-in fetch + * (Node 20+). + * + * This connector is read-only BY CONSTRUCTION: it only ever issues HTTP GET + * requests and exposes no create/update/delete methods. Tenant Turner's private + * API key is account-level and *could* write (POST /v1/showings books a + * showing), so the safety guarantee here is that this client has no code path + * that writes — see the connector plan. + * + * Auth: HTTP Basic where the credential is base64 of the bare API key (there + * is no username:password pair — just the key). Sent as an + * `Authorization: Basic ` header, never in the URL. + * Call: GET https://api.tenantturner.com/v1/{resource}?SinceDateUpdated=YYYY-MM-DD + * (SinceDateUpdated is REQUIRED by some resources, e.g. "applications", + * and must be within the last 2 years; "properties" needs no params.) + * Reply: { TotalCount, NextPage: "" | null, Data: [ ...rows ] } + * Paging: follow the opaque `NextPage` cursor — pass it back as + * ?NextPage= (it already encodes the page size + SinceDateUpdated). + * Walk until NextPage is absent or the page/row cap is hit. + */ + +const BASE_URL = 'https://api.tenantturner.com'; +const API_TIMEOUT_MS = 30_000; + +/** Safety cap so a huge account (thousands of leads) can't spin forever. The + * API returns ~10 rows/page, so 20 pages ≈ 200 rows. Override via opts.maxPages. + * Reports should pass a recent `sinceDateUpdated` to keep the walk short. */ +const DEFAULT_MAX_PAGES = 20; + +export interface TenantTurnerListResponse { + TotalCount?: number; + NextPage?: string | null; + Data?: unknown[]; + StatusCode?: number; + ErrorMessages?: string[]; +} + +export interface FetchResourceOptions { + /** SinceDateUpdated filter (YYYY-MM-DD, within the last 2 years). Required by + * resources like "applications"; omit for resources that don't need it. */ + sinceDateUpdated?: string; + /** Extra query params merged into the first request. */ + query?: Record; + maxPages?: number; + maxRows?: number; +} + +export interface FetchResourceResult { + resource: string; + rows: unknown[]; + /** Server-reported total across all pages (null if the resource omits it). */ + totalCount: number | null; + pagesFetched: number; + /** True when a page cap / row cap stopped us before the data ran out. */ + truncated: boolean; +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +export class TenantTurnerAPI { + private readonly authHeader: string; + + /** + * @param apiKey Tenant Turner account private API key (Settings → Tenant + * Turner API). The Basic-auth value is base64 of the bare key. + */ + constructor(apiKey: string) { + if (!apiKey) { + throw new Error('TenantTurnerAPI requires an apiKey'); + } + // HTTP Basic auth: base64 of the bare key (no colon / username), sent as a + // header rather than embedded in the URL so it can't leak into logs. + this.authHeader = 'Basic ' + Buffer.from(apiKey).toString('base64'); + } + + /** + * Fetch a read-only Tenant Turner list resource (e.g. "applications", + * "properties"), following the NextPage cursor up to the configured caps. + * + * Only GET is ever issued — there is no write counterpart on this client. + */ + async fetchResource(resource: string, opts: FetchResourceOptions = {}): Promise { + const safe = resource.replace(/^\/+|\/+$/g, '').trim(); + if (!/^[a-z0-9/_-]+$/i.test(safe)) { + throw new Error(`Invalid Tenant Turner resource "${resource}" (expected like "applications")`); + } + + const maxPages = opts.maxPages ?? DEFAULT_MAX_PAGES; + const rows: unknown[] = []; + let totalCount: number | null = null; + let pagesFetched = 0; + let truncated = false; + + // First page: SinceDateUpdated + any extra query params. + const firstParams = new URLSearchParams(); + if (opts.sinceDateUpdated) firstParams.set('SinceDateUpdated', opts.sinceDateUpdated); + for (const [k, v] of Object.entries(opts.query ?? {})) firstParams.set(k, String(v)); + let url = `${BASE_URL}/v1/${safe}${firstParams.toString() ? `?${firstParams}` : ''}`; + + while (true) { + const data = await this.requestGet(url, safe); + if (typeof data.TotalCount === 'number') totalCount = data.TotalCount; + + const pageRows = Array.isArray(data.Data) ? data.Data : []; + pagesFetched += 1; + + for (const row of pageRows) { + rows.push(row); + if (opts.maxRows != null && rows.length >= opts.maxRows) { + return { resource: safe, rows, totalCount, pagesFetched, truncated: true }; + } + } + + // No cursor means we've reached the last page. + if (!data.NextPage) break; + if (pagesFetched >= maxPages) { + truncated = true; + break; + } + + // The NextPage cursor already encodes the page size + SinceDateUpdated, so + // subsequent pages carry only it. + const nextParams = new URLSearchParams({ NextPage: data.NextPage }); + url = `${BASE_URL}/v1/${safe}?${nextParams}`; + await sleep(200); // gentle throttle between pages + } + + return { resource: safe, rows, totalCount, pagesFetched, truncated }; + } + + /** + * Shared GET wrapper: bounded timeout, HTTP-status checks, and actionable + * error mapping. Never puts the API key into the message. + */ + private async requestGet(url: string, resource: string): Promise { + const response = await fetch(url, { + method: 'GET', + headers: { + Authorization: this.authHeader, + Accept: 'application/json', + }, + signal: AbortSignal.timeout(API_TIMEOUT_MS), + }); + + if (!response.ok) { + let detail = ''; + try { + detail = (await response.text()).slice(0, 300); + } catch { + /* ignore — body already consumed or unreadable */ + } + if (response.status === 401 || response.status === 403) { + throw new Error( + `Tenant Turner auth failed (HTTP ${response.status}). Check TENANTTURNER_API_KEY ` + + `in secrets.env (Tenant Turner → Settings → Tenant Turner API).${detail ? ` — ${detail}` : ''}`, + ); + } + if (response.status === 404) { + throw new Error( + `Tenant Turner resource "${resource}" not found (HTTP 404). ` + + `The resource name may be wrong (try "applications" or "properties").${detail ? ` — ${detail}` : ''}`, + ); + } + if (response.status === 422) { + const hint = /SinceDateUpdated/i.test(detail) + ? ' — pass --since YYYY-MM-DD (a date within the last 2 years).' + : ''; + throw new Error(`Tenant Turner resource "${resource}" rejected the request (HTTP 422).${detail ? ` ${detail}` : ''}${hint}`); + } + throw new Error(`Tenant Turner resource "${resource}" failed: HTTP ${response.status}${detail ? ` — ${detail}` : ''}`); + } + + return (await response.json()) as TenantTurnerListResponse; + } +} diff --git a/src/zinspector/README.md b/src/zinspector/README.md new file mode 100644 index 00000000..2002a518 --- /dev/null +++ b/src/zinspector/README.md @@ -0,0 +1,64 @@ +# zInspector connector (read-only) + +A minimal, **read-only** client for the [zInspector](https://zinspector.com) +property-inspection API. It lets agents *read* inspection content — properties, +documents, **photos/media**, and inspection processes — the room-by-room detail +that AppFolio's inspection report doesn't carry. It cannot change anything in +zInspector: the client only issues HTTP `GET` and exposes no +create/update/delete methods. + +> **Safety note.** Read-only by construction (no write code path), the key lives +> in gitignored `secrets.env`, and it can be revoked in zInspector anytime. +> Records include property/tenant detail and inspection media; treat output +> accordingly. + +## Credentials + +Set in `orgs//secrets.env` (preferred) or the process env: + +| Variable | Meaning | +| -------------------------- | ----------------------------------------------------------- | +| `ZINSPECTOR_API_KEY` | The **Encoded API key** (base64 of `KeyID:Secret`) | +| `ZINSPECTOR_API_BASE_URL` | Base URL — defaults to `https://portfolio.zinspector.com` | + +**Creating a working key** (zInspector → **Configuration → API Keys** → +`portfolio.zinspector.com/APIKey` → green **+**): + +- **Linked User** must be an **Admin/Owner** — this is what grants the key data + access. A key linked to an Editor gets `403 permission_denied` on everything. +- **At least one Whitelisted Domain or Source IP is required.** For this + server-to-server connector, whitelist the **public IP of the machine that runs + it**. If that IP changes (ISP), update the whitelist or the key will 403. +- Copy the **Encoded API key** into `ZINSPECTOR_API_KEY`. + +## API shape + +- **Auth:** header `x-api-key: ` (never in the URL). +- **Call:** `GET {baseUrl}/api/{resource}/` — the **trailing slash is required** + (a slashless path 301-redirects). +- **Reply:** `{ results: [ ...rows ], next: "" | null, previous, count? }` +- **Paging:** two styles exist — cursor (`?cursor=` for `propertiesCursor`) and + page (`?page=N` for `documents`/`media`) — but **both expose `next` as a full + URL**, so the client just follows `next` until it's null (up to `--max-pages`, + default 20). + +### Read resources (examples) + +| Resource | Notes | +| ------------------- | ------------------------------------------------- | +| `propertiesCursor` | Properties (cursor-paginated). | +| `documents` | Inspection documents/reports (page-paginated). | +| `media` | Inspection photos (page-paginated). | +| `process` | Inspection processes / tasks. | +| `contactsCursor` | Contacts (cursor-paginated). | + +## CLI usage + +```bash +cortextos bus zinspector-get propertiesCursor +cortextos bus zinspector-get media --rows-only --max-rows 200 +cortextos bus zinspector-get documents --query '{"property":123}' +``` + +Flags: `--org` (defaults to `CTX_ORG`), `--query `, `--max-pages `, +`--max-rows `, `--rows-only`. diff --git a/src/zinspector/api.ts b/src/zinspector/api.ts new file mode 100644 index 00000000..c20b04cb --- /dev/null +++ b/src/zinspector/api.ts @@ -0,0 +1,163 @@ +/** + * Minimal, READ-ONLY zInspector API v1 client using built-in fetch (Node 20+). + * + * This connector is read-only BY CONSTRUCTION: it only ever issues HTTP GET + * requests and exposes no create/update/delete methods. The key's write ability + * (if any) is governed by its Linked User in zInspector; the safety guarantee + * here is that this client simply has no code path that writes. + * + * Auth: header `x-api-key: ` where the encoded key is + * base64(KeyID:Secret) — exactly the value zInspector shows as the + * "Encoded API key". Sent as a header, never in the URL. + * Call: GET {baseUrl}/api/{resource}/ (the TRAILING SLASH is required — a + * slashless path 301-redirects). + * Reply: { results: [ ...rows ], next: "" | null, previous, count? } + * Paging: two styles exist (cursor `?cursor=` for propertiesCursor, page + * `?page=N` for documents/media) but BOTH expose `next` as a fully + * qualified URL, so we just follow `next` until it's null or a cap hits. + */ + +const API_TIMEOUT_MS = 30_000; +const DEFAULT_BASE_URL = 'https://portfolio.zinspector.com'; + +/** Safety cap so a large account can't spin forever. Override via opts.maxPages. */ +const DEFAULT_MAX_PAGES = 20; + +export interface ZInspectorListResponse { + results?: unknown[]; + next?: string | null; + previous?: string | null; + count?: number; +} + +export interface FetchResourceOptions { + /** Extra query params merged into the first request (e.g. { property: 123 }). */ + query?: Record; + maxPages?: number; + maxRows?: number; +} + +export interface FetchResourceResult { + resource: string; + rows: unknown[]; + /** Server-reported total when the endpoint provides it (page-style), else null. */ + count: number | null; + pagesFetched: number; + /** True when a page cap / row cap stopped us before the data ran out. */ + truncated: boolean; +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +export class ZInspectorAPI { + private readonly baseUrl: string; + private readonly apiKey: string; + + /** + * @param apiKey The zInspector "Encoded API key" (base64 of KeyID:Secret). + * @param baseUrl e.g. https://portfolio.zinspector.com (trailing slash OK). + */ + constructor(apiKey: string, baseUrl: string = DEFAULT_BASE_URL) { + if (!apiKey) { + throw new Error('ZInspectorAPI requires an apiKey'); + } + this.apiKey = apiKey; + this.baseUrl = (baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, ''); + } + + /** + * Fetch a read-only zInspector list resource (e.g. "propertiesCursor", + * "documents", "media", "process"), following the `next` cursor up to the caps. + * + * Only GET is ever issued — there is no write counterpart on this client. + */ + async fetchResource(resource: string, opts: FetchResourceOptions = {}): Promise { + const safe = resource.replace(/^\/+|\/+$/g, '').trim(); + if (!/^[a-z0-9/_-]+$/i.test(safe)) { + throw new Error(`Invalid zInspector resource "${resource}" (expected like "propertiesCursor")`); + } + + const maxPages = opts.maxPages ?? DEFAULT_MAX_PAGES; + const rows: unknown[] = []; + let count: number | null = null; + let pagesFetched = 0; + let truncated = false; + + // First page: the trailing slash is required. Merge any extra query params. + const params = new URLSearchParams(); + for (const [k, v] of Object.entries(opts.query ?? {})) params.set(k, String(v)); + let url = `${this.baseUrl}/api/${safe}/${params.toString() ? `?${params}` : ''}`; + + while (true) { + const data = await this.requestGet(url, safe); + if (typeof data.count === 'number') count = data.count; + + const pageRows = Array.isArray(data.results) ? data.results : []; + pagesFetched += 1; + + for (const row of pageRows) { + rows.push(row); + if (opts.maxRows != null && rows.length >= opts.maxRows) { + return { resource: safe, rows, count, pagesFetched, truncated: true }; + } + } + + // `next` is a fully-qualified URL for both cursor- and page-style paging. + if (!data.next) break; + if (pagesFetched >= maxPages) { + truncated = true; + break; + } + url = data.next; + await sleep(200); // gentle throttle between pages + } + + return { resource: safe, rows, count, pagesFetched, truncated }; + } + + /** + * Shared GET wrapper: bounded timeout, HTTP-status checks, and actionable + * error mapping. Never puts the key into the message. + */ + private async requestGet(url: string, resource: string): Promise { + const response = await fetch(url, { + method: 'GET', + headers: { + 'x-api-key': this.apiKey, + Accept: 'application/json', + }, + signal: AbortSignal.timeout(API_TIMEOUT_MS), + }); + + if (!response.ok) { + let detail = ''; + try { + detail = (await response.text()).slice(0, 300); + } catch { + /* ignore — body already consumed or unreadable */ + } + if (response.status === 401) { + throw new Error( + `zInspector auth failed (HTTP 401). Check ZINSPECTOR_API_KEY in secrets.env ` + + `(it must be the "Encoded API key" value).${detail ? ` — ${detail}` : ''}`, + ); + } + if (response.status === 403) { + throw new Error( + `zInspector denied resource "${resource}" (HTTP 403 permission_denied). The API key ` + + `must be created with its Linked User set to an Admin/Owner, and your current public ` + + `IP must be in the key's whitelist.${detail ? ` — ${detail}` : ''}`, + ); + } + if (response.status === 404) { + throw new Error( + `zInspector resource "${resource}" not found (HTTP 404). Check the resource name ` + + `(e.g. propertiesCursor, documents, media, process).${detail ? ` — ${detail}` : ''}`, + ); + } + throw new Error(`zInspector resource "${resource}" failed: HTTP ${response.status}${detail ? ` — ${detail}` : ''}`); + } + + return (await response.json()) as ZInspectorListResponse; + } +} diff --git a/tests/unit/bus/appfolio.test.ts b/tests/unit/bus/appfolio.test.ts new file mode 100644 index 00000000..c8f37791 --- /dev/null +++ b/tests/unit/bus/appfolio.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { AppFolioAPI } from '../../../src/appfolio/api.js'; + +/** + * These tests drive a mocked global.fetch so nothing hits the network. They + * cover the contract that matters for the read-only AppFolio connector: + * Basic-auth header construction, POST-then-follow-next_page_url pagination, + * the bare-array (paginate_results=false) response shape, row/page caps, and + * error mapping (auth, 404, 429-retry). + */ + +const realFetch = global.fetch; + +function jsonResponse(body: unknown, init: { status?: number; headers?: Record } = {}) { + const status = init.status ?? 200; + return { + ok: status >= 200 && status < 300, + status, + headers: { get: (k: string) => init.headers?.[k.toLowerCase()] ?? null }, + json: async () => body, + text: async () => (typeof body === 'string' ? body : JSON.stringify(body)), + } as unknown as Response; +} + +describe('AppFolioAPI', () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(); + global.fetch = fetchMock as unknown as typeof fetch; + // Make the page-throttle sleeps instant so tests don't wait seconds. + vi.useFakeTimers(); + }); + + afterEach(() => { + global.fetch = realFetch; + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('constructs a Basic auth header and normalizes the base URL', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ results: [], next_page_url: null })); + const api = new AppFolioAPI('cid', 'secret', 'https://acme.appfolio.com/'); + await api.fetchReport('rent_roll'); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('https://acme.appfolio.com/api/v2/reports/rent_roll.json'); + expect(init.method).toBe('POST'); + const expected = 'Basic ' + Buffer.from('cid:secret').toString('base64'); + expect((init.headers as Record).Authorization).toBe(expected); + }); + + it('throws when credentials or base URL are missing', () => { + expect(() => new AppFolioAPI('', 'secret', 'https://x.appfolio.com')).toThrow(/clientId/); + expect(() => new AppFolioAPI('cid', '', 'https://x.appfolio.com')).toThrow(/clientSecret/); + expect(() => new AppFolioAPI('cid', 'secret', '')).toThrow(/baseUrl/); + }); + + it('rejects an invalid report name before making a request', async () => { + const api = new AppFolioAPI('cid', 'secret', 'https://x.appfolio.com'); + await expect(api.fetchReport('../../etc/passwd')).rejects.toThrow(/Invalid report name/); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('follows next_page_url and aggregates rows across pages', async () => { + fetchMock + .mockResolvedValueOnce( + jsonResponse({ results: [{ id: 1 }], next_page_url: 'https://x.appfolio.com/next?p=2' }), + ) + .mockResolvedValueOnce(jsonResponse({ results: [{ id: 2 }], next_page_url: null })); + + const api = new AppFolioAPI('cid', 'secret', 'https://x.appfolio.com'); + const promise = api.fetchReport('delinquency'); + await vi.runAllTimersAsync(); // flush the inter-page throttle sleep + const result = await promise; + + expect(result.rows).toEqual([{ id: 1 }, { id: 2 }]); + expect(result.pagesFetched).toBe(2); + expect(result.truncated).toBe(false); + // Page 2 is fetched as a GET against the exact next_page_url. + expect(fetchMock.mock.calls[1][0]).toBe('https://x.appfolio.com/next?p=2'); + expect(fetchMock.mock.calls[1][1].method).toBe('GET'); + }); + + it('handles the bare-array (paginate_results=false) response shape', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse([{ id: 1 }, { id: 2 }])); + const api = new AppFolioAPI('cid', 'secret', 'https://x.appfolio.com'); + const result = await api.fetchReport('unit_directory'); + expect(result.rows).toHaveLength(2); + expect(result.truncated).toBe(false); + }); + + it('stops at maxPages and marks the result truncated', async () => { + fetchMock.mockResolvedValue( + jsonResponse({ results: [{ id: 1 }], next_page_url: 'https://x.appfolio.com/next' }), + ); + const api = new AppFolioAPI('cid', 'secret', 'https://x.appfolio.com'); + const promise = api.fetchReport('rent_roll', {}, { maxPages: 2 }); + await vi.runAllTimersAsync(); + const result = await promise; + expect(result.pagesFetched).toBe(2); + expect(result.truncated).toBe(true); + }); + + it('retries once on HTTP 429 honoring Retry-After, then succeeds', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse({ message: 'slow down' }, { status: 429, headers: { 'retry-after': '1' } })) + .mockResolvedValueOnce(jsonResponse({ results: [{ id: 1 }], next_page_url: null })); + const api = new AppFolioAPI('cid', 'secret', 'https://x.appfolio.com'); + const promise = api.fetchReport('rent_roll'); + await vi.runAllTimersAsync(); // flush the retry-after sleep + const result = await promise; + expect(result.rows).toEqual([{ id: 1 }]); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('maps 401/403 to a clear auth error', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ message: 'unauthorized' }, { status: 401 })); + const api = new AppFolioAPI('cid', 'secret', 'https://x.appfolio.com'); + await expect(api.fetchReport('rent_roll')).rejects.toThrow(/auth failed/i); + }); + + it('maps 404 to a "report not found" error', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ message: 'not a valid report' }, { status: 404 })); + const api = new AppFolioAPI('cid', 'secret', 'https://x.appfolio.com'); + await expect(api.fetchReport('made_up_report')).rejects.toThrow(/not found/i); + }); +}); diff --git a/tests/unit/bus/hostaway.test.ts b/tests/unit/bus/hostaway.test.ts new file mode 100644 index 00000000..9bf4134d --- /dev/null +++ b/tests/unit/bus/hostaway.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { HostawayAPI } from '../../../src/hostaway/api.js'; + +/** + * Mocked-fetch tests for the read-only Hostaway connector: client-credentials + * token exchange, GET-only resource fetch, limit/offset pagination, row caps, + * and error mapping. Nothing hits the network. + */ + +const realFetch = global.fetch; + +function jsonResponse(body: unknown, init: { status?: number } = {}) { + const status = init.status ?? 200; + return { + ok: status >= 200 && status < 300, + status, + headers: { get: () => null }, + json: async () => body, + text: async () => (typeof body === 'string' ? body : JSON.stringify(body)), + } as unknown as Response; +} + +const tokenOk = () => jsonResponse({ access_token: 'tok_abc', token_type: 'Bearer', expires_in: 999 }); + +describe('HostawayAPI', () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(); + global.fetch = fetchMock as unknown as typeof fetch; + vi.useFakeTimers(); + }); + + afterEach(() => { + global.fetch = realFetch; + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('exchanges account id + api key for a Bearer token, then sends it', async () => { + fetchMock + .mockResolvedValueOnce(tokenOk()) + .mockResolvedValueOnce(jsonResponse({ status: 'success', result: [{ id: 1 }] })); + + const api = new HostawayAPI('99', 'key'); + await api.fetchResource('listings'); + + // First call is the token exchange (POST form body). + const [tokenUrl, tokenInit] = fetchMock.mock.calls[0]; + expect(tokenUrl).toBe('https://api.hostaway.com/v1/accessTokens'); + expect(tokenInit.method).toBe('POST'); + expect(String(tokenInit.body)).toContain('grant_type=client_credentials'); + expect(String(tokenInit.body)).toContain('client_id=99'); + + // Second call is the GET with the bearer token. + const [getUrl, getInit] = fetchMock.mock.calls[1]; + expect(getUrl).toContain('https://api.hostaway.com/v1/listings'); + expect(getInit.method).toBe('GET'); + expect((getInit.headers as Record).Authorization).toBe('Bearer tok_abc'); + }); + + it('throws when account id or api key is missing', () => { + expect(() => new HostawayAPI('', 'key')).toThrow(/accountId/); + expect(() => new HostawayAPI('99', '')).toThrow(/accountId|apiKey/); + }); + + it('rejects an invalid resource name before any request', async () => { + const api = new HostawayAPI('99', 'key'); + await expect(api.fetchResource('http://evil.com')).rejects.toThrow(/Invalid Hostaway resource/); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('paginates by limit/offset until a short page', async () => { + const fullPage = Array.from({ length: 100 }, (_, i) => ({ id: i })); + fetchMock + .mockResolvedValueOnce(tokenOk()) + .mockResolvedValueOnce(jsonResponse({ status: 'success', result: fullPage })) + .mockResolvedValueOnce(jsonResponse({ status: 'success', result: [{ id: 100 }] })); + + const api = new HostawayAPI('99', 'key'); + const promise = api.fetchResource('reservations'); + await vi.runAllTimersAsync(); + const result = await promise; + + expect(result.rows).toHaveLength(101); + expect(result.pagesFetched).toBe(2); + expect(result.truncated).toBe(false); + // offset advanced to 100 on the second GET. + expect(fetchMock.mock.calls[2][0]).toContain('offset=100'); + }); + + it('reuses one token across pages (single token exchange)', async () => { + const fullPage = Array.from({ length: 100 }, (_, i) => ({ id: i })); + fetchMock + .mockResolvedValueOnce(tokenOk()) + .mockResolvedValueOnce(jsonResponse({ status: 'success', result: fullPage })) + .mockResolvedValueOnce(jsonResponse({ status: 'success', result: [] })); + const api = new HostawayAPI('99', 'key'); + const promise = api.fetchResource('listings'); + await vi.runAllTimersAsync(); + await promise; + const tokenCalls = fetchMock.mock.calls.filter((c) => String(c[0]).endsWith('/accessTokens')); + expect(tokenCalls).toHaveLength(1); + }); + + it('stops at maxRows and marks truncated', async () => { + const fullPage = Array.from({ length: 100 }, (_, i) => ({ id: i })); + fetchMock + .mockResolvedValueOnce(tokenOk()) + .mockResolvedValueOnce(jsonResponse({ status: 'success', result: fullPage })); + const api = new HostawayAPI('99', 'key'); + const result = await api.fetchResource('listings', { maxRows: 5 }); + expect(result.rows).toHaveLength(5); + expect(result.truncated).toBe(true); + }); + + it('maps a failed token exchange (401) to a clear auth error', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ message: 'bad creds' }, { status: 401 })); + const api = new HostawayAPI('99', 'key'); + await expect(api.fetchResource('listings')).rejects.toThrow(/auth failed/i); + }); + + it('surfaces a Hostaway status:fail body as an error', async () => { + fetchMock + .mockResolvedValueOnce(tokenOk()) + .mockResolvedValueOnce(jsonResponse({ status: 'fail', message: 'nope' })); + const api = new HostawayAPI('99', 'key'); + await expect(api.fetchResource('listings')).rejects.toThrow(/status=fail.*nope/); + }); +}); diff --git a/tests/unit/bus/tenantturner.test.ts b/tests/unit/bus/tenantturner.test.ts new file mode 100644 index 00000000..089eb507 --- /dev/null +++ b/tests/unit/bus/tenantturner.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { TenantTurnerAPI } from '../../../src/tenantturner/api.js'; + +/** + * Mocked-fetch tests for the read-only Tenant Turner connector: base64-key Basic + * auth, GET-only resource fetch, NextPage cursor pagination, row caps, and error + * mapping. Nothing hits the network. + */ + +const realFetch = global.fetch; + +function jsonResponse(body: unknown, init: { status?: number } = {}) { + const status = init.status ?? 200; + return { + ok: status >= 200 && status < 300, + status, + headers: { get: () => null }, + json: async () => body, + text: async () => (typeof body === 'string' ? body : JSON.stringify(body)), + } as unknown as Response; +} + +describe('TenantTurnerAPI', () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(); + global.fetch = fetchMock as unknown as typeof fetch; + vi.useFakeTimers(); + }); + + afterEach(() => { + global.fetch = realFetch; + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('sends base64(apiKey) Basic auth on a GET with the SinceDateUpdated filter', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ TotalCount: 1, NextPage: null, Data: [{ ApplicationId: 1 }] })); + + const api = new TenantTurnerAPI('key'); + const result = await api.fetchResource('applications', { sinceDateUpdated: '2026-06-20' }); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toContain('https://api.tenantturner.com/v1/applications'); + expect(url).toContain('SinceDateUpdated=2026-06-20'); + expect(init.method).toBe('GET'); + // Basic auth value is base64 of the bare key: base64('key') === 'a2V5'. + expect((init.headers as Record).Authorization).toBe('Basic a2V5'); + expect(result.totalCount).toBe(1); + expect(result.rows).toHaveLength(1); + }); + + it('throws when the api key is missing', () => { + expect(() => new TenantTurnerAPI('')).toThrow(/apiKey/); + }); + + it('rejects an invalid resource name before any request', async () => { + const api = new TenantTurnerAPI('key'); + await expect(api.fetchResource('http://evil.com')).rejects.toThrow(/Invalid Tenant Turner resource/); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('follows the NextPage cursor until it is absent', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse({ TotalCount: 2, NextPage: 'Y3Vyc29y', Data: [{ ApplicationId: 1 }] })) + .mockResolvedValueOnce(jsonResponse({ TotalCount: 2, NextPage: null, Data: [{ ApplicationId: 2 }] })); + + const api = new TenantTurnerAPI('key'); + const promise = api.fetchResource('applications', { sinceDateUpdated: '2026-06-20' }); + await vi.runAllTimersAsync(); + const result = await promise; + + expect(result.rows).toHaveLength(2); + expect(result.pagesFetched).toBe(2); + expect(result.truncated).toBe(false); + // Second request carries only the cursor (URL-encoded). + expect(fetchMock.mock.calls[1][0]).toContain('NextPage=Y3Vyc29y'); + expect(fetchMock.mock.calls[1][0]).not.toContain('SinceDateUpdated'); + }); + + it('stops at maxRows and marks truncated', async () => { + const fullPage = Array.from({ length: 10 }, (_, i) => ({ ApplicationId: i })); + fetchMock.mockResolvedValueOnce(jsonResponse({ TotalCount: 100, NextPage: 'more', Data: fullPage })); + + const api = new TenantTurnerAPI('key'); + const result = await api.fetchResource('applications', { sinceDateUpdated: '2026-06-20', maxRows: 4 }); + + expect(result.rows).toHaveLength(4); + expect(result.truncated).toBe(true); + }); + + it('stops at maxPages and marks truncated', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse({ NextPage: 'p2', Data: [{ ApplicationId: 1 }] })) + .mockResolvedValueOnce(jsonResponse({ NextPage: 'p3', Data: [{ ApplicationId: 2 }] })); + + const api = new TenantTurnerAPI('key'); + const promise = api.fetchResource('applications', { sinceDateUpdated: '2026-06-20', maxPages: 2 }); + await vi.runAllTimersAsync(); + const result = await promise; + + expect(result.pagesFetched).toBe(2); + expect(result.truncated).toBe(true); + }); + + it('maps a 401 to a clear auth error', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ message: 'bad key' }, { status: 401 })); + const api = new TenantTurnerAPI('key'); + await expect(api.fetchResource('applications', { sinceDateUpdated: '2026-06-20' })).rejects.toThrow(/auth failed/i); + }); + + it('maps a 404 to a resource-not-found error', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse('not found', { status: 404 })); + const api = new TenantTurnerAPI('key'); + await expect(api.fetchResource('leads')).rejects.toThrow(/not found \(HTTP 404\)/); + }); + + it('maps a 422 SinceDateUpdated error to an actionable hint', async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ StatusCode: 422, ErrorMessages: ['SinceDateUpdated is required and must be less than 2 years ago.'] }, { status: 422 }), + ); + const api = new TenantTurnerAPI('key'); + await expect(api.fetchResource('applications')).rejects.toThrow(/pass --since/); + }); +}); diff --git a/tests/unit/bus/zinspector.test.ts b/tests/unit/bus/zinspector.test.ts new file mode 100644 index 00000000..af1f2577 --- /dev/null +++ b/tests/unit/bus/zinspector.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { ZInspectorAPI } from '../../../src/zinspector/api.js'; + +/** + * Mocked-fetch tests for the read-only zInspector connector: x-api-key auth, + * GET-only resource fetch with the required trailing slash, `next`-URL + * pagination (cursor + page styles both expose a full next URL), row caps, and + * error mapping. Nothing hits the network. + */ + +const realFetch = global.fetch; + +function jsonResponse(body: unknown, init: { status?: number } = {}) { + const status = init.status ?? 200; + return { + ok: status >= 200 && status < 300, + status, + headers: { get: () => null }, + json: async () => body, + text: async () => (typeof body === 'string' ? body : JSON.stringify(body)), + } as unknown as Response; +} + +describe('ZInspectorAPI', () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(); + global.fetch = fetchMock as unknown as typeof fetch; + vi.useFakeTimers(); + }); + + afterEach(() => { + global.fetch = realFetch; + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('sends the x-api-key header on a GET to the trailing-slash path', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ results: [{ id: 1 }], next: null })); + + const api = new ZInspectorAPI('enc_key', 'https://portfolio.zinspector.com'); + const result = await api.fetchResource('propertiesCursor'); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('https://portfolio.zinspector.com/api/propertiesCursor/'); + expect(init.method).toBe('GET'); + expect((init.headers as Record)['x-api-key']).toBe('enc_key'); + expect(result.rows).toHaveLength(1); + }); + + it('throws when the api key is missing', () => { + expect(() => new ZInspectorAPI('')).toThrow(/apiKey/); + }); + + it('defaults the base URL and strips a trailing slash from it', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ results: [], next: null })); + const api = new ZInspectorAPI('enc_key', 'https://portfolio.zinspector.com/'); + await api.fetchResource('documents'); + expect(fetchMock.mock.calls[0][0]).toBe('https://portfolio.zinspector.com/api/documents/'); + }); + + it('rejects an invalid resource name before any request', async () => { + const api = new ZInspectorAPI('enc_key'); + await expect(api.fetchResource('http://evil.com')).rejects.toThrow(/Invalid zInspector resource/); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('follows the next URL until it is null and records count', async () => { + fetchMock + .mockResolvedValueOnce( + jsonResponse({ count: 2, results: [{ id: 1 }], next: 'https://portfolio.zinspector.com/api/media/?page=2' }), + ) + .mockResolvedValueOnce(jsonResponse({ count: 2, results: [{ id: 2 }], next: null })); + + const api = new ZInspectorAPI('enc_key'); + const promise = api.fetchResource('media'); + await vi.runAllTimersAsync(); + const result = await promise; + + expect(result.rows).toHaveLength(2); + expect(result.pagesFetched).toBe(2); + expect(result.count).toBe(2); + expect(result.truncated).toBe(false); + // Second request goes straight to the next URL zInspector handed back. + expect(fetchMock.mock.calls[1][0]).toBe('https://portfolio.zinspector.com/api/media/?page=2'); + }); + + it('stops at maxRows and marks truncated', async () => { + const fullPage = Array.from({ length: 10 }, (_, i) => ({ id: i })); + fetchMock.mockResolvedValueOnce(jsonResponse({ results: fullPage, next: 'https://x/api/media/?page=2' })); + const api = new ZInspectorAPI('enc_key'); + const result = await api.fetchResource('media', { maxRows: 3 }); + expect(result.rows).toHaveLength(3); + expect(result.truncated).toBe(true); + }); + + it('stops at maxPages and marks truncated', async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse({ results: [{ id: 1 }], next: 'https://x/api/documents/?page=2' })) + .mockResolvedValueOnce(jsonResponse({ results: [{ id: 2 }], next: 'https://x/api/documents/?page=3' })); + const api = new ZInspectorAPI('enc_key'); + const promise = api.fetchResource('documents', { maxPages: 2 }); + await vi.runAllTimersAsync(); + const result = await promise; + expect(result.pagesFetched).toBe(2); + expect(result.truncated).toBe(true); + }); + + it('maps a 401 to a clear auth error', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse('unauthorized', { status: 401 })); + const api = new ZInspectorAPI('enc_key'); + await expect(api.fetchResource('documents')).rejects.toThrow(/auth failed/i); + }); + + it('maps a 403 permission_denied to an actionable message about Linked User + IP', async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ code: 'permission_denied', detail: 'Request not allowed for this API key.' }, { status: 403 }), + ); + const api = new ZInspectorAPI('enc_key'); + await expect(api.fetchResource('propertiesCursor')).rejects.toThrow(/Linked User.*Admin|whitelist/); + }); + + it('maps a 404 to a resource-not-found error', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse('not found', { status: 404 })); + const api = new ZInspectorAPI('enc_key'); + await expect(api.fetchResource('nope')).rejects.toThrow(/not found \(HTTP 404\)/); + }); +});