From 0c2add27e3a3287539b9415a35f2aaa327d08ac7 Mon Sep 17 00:00:00 2001 From: NancyBraun Date: Mon, 29 Jun 2026 11:07:43 -0400 Subject: [PATCH 1/5] feat(appfolio): read-only AppFolio Reporting API connector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a read-only connector for AppFolio's Reporting (Data) API v2 so agents can pull property data (rent roll, delinquency, work orders, leasing) out of AppFolio. The Reporting API only returns data, so there is no write path. - src/appfolio/api.ts: AppFolioAPI client — HTTP Basic auth (sent as a header, not embedded in the URL), automatic next_page_url pagination with a page cap, one 429 retry honoring Retry-After, bounded timeout. - src/bus/appfolio.ts: fetchAppfolioReport + credential loader that prefers process.env and falls back to orgs//secrets.env, so it works from both the agent PTY and a plain CLI shell. - src/cli/bus.ts: wire `bus appfolio-report ` (--filters/--max-pages/ --max-rows/--rows-only/--org). - tests/unit/bus/appfolio.test.ts: 9 unit tests (auth header, pagination, bare-array shape, caps, 429 retry, auth/404 error mapping). Credentials live in orgs//secrets.env (gitignored). Verified live against a real account; npm run build and tsc --noEmit are clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/appfolio/README.md | 68 +++++++++++ src/appfolio/api.ts | 210 ++++++++++++++++++++++++++++++++ src/bus/appfolio.ts | 89 ++++++++++++++ src/cli/bus.ts | 51 ++++++++ tests/unit/bus/appfolio.test.ts | 128 +++++++++++++++++++ 5 files changed, 546 insertions(+) create mode 100644 src/appfolio/README.md create mode 100644 src/appfolio/api.ts create mode 100644 src/bus/appfolio.ts create mode 100644 tests/unit/bus/appfolio.test.ts diff --git a/src/appfolio/README.md b/src/appfolio/README.md new file mode 100644 index 00000000..a8b76187 --- /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 for carolinas-pm (2026-06-29) + +| 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..eb925ae5 --- /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://showcase.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/cli/bus.ts b/src/cli/bus.ts index 300cdf6c..e876bde0 100644 --- a/src/cli/bus.ts +++ b/src/cli/bus.ts @@ -24,6 +24,7 @@ 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 { 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 +1728,56 @@ 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); + } + }); + // --------------------------------------------------------------------------- // 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/tests/unit/bus/appfolio.test.ts b/tests/unit/bus/appfolio.test.ts new file mode 100644 index 00000000..711fbc0a --- /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://showcase.appfolio.com/'); + await api.fetchReport('rent_roll'); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('https://showcase.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); + }); +}); From 7fdc62b724401a91932b906623de4b099d657d02 Mon Sep 17 00:00:00 2001 From: NancyBraun Date: Mon, 29 Jun 2026 12:29:53 -0400 Subject: [PATCH 2/5] feat(hostaway): read-only Hostaway Public API connector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a read-only connector for Hostaway's Public API v1 so agents can read vacation-rental data (listings, reservations, calendar, guests, conversations). Read-only by construction: the client only issues HTTP GET and exposes no create/update/delete methods. Hostaway keys are account-level (no read-only key option), so the safety guarantee is that the tool has no write code path. - src/hostaway/api.ts: HostawayAPI — OAuth2 client-credentials token exchange (cached per process), GET-only fetchResource with limit/offset pagination, bounded timeout, status:fail + auth/404 error mapping. - src/bus/hostaway.ts: fetchHostawayResource + credential loader (process.env then orgs//secrets.env), mirroring the AppFolio connector. - src/cli/bus.ts: wire `bus hostaway-get ` (--query/--max-pages/ --max-rows/--rows-only/--org). - tests/unit/bus/hostaway.test.ts: 8 unit tests (token exchange, GET-only, pagination, token reuse, caps, auth/status-fail errors). Credentials live in orgs//secrets.env (gitignored). Verified live against a real account; npm run build and tsc --noEmit are clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/bus/hostaway.ts | 83 +++++++++++++ src/cli/bus.ts | 52 ++++++++ src/hostaway/README.md | 60 ++++++++++ src/hostaway/api.ts | 204 ++++++++++++++++++++++++++++++++ tests/unit/bus/hostaway.test.ts | 130 ++++++++++++++++++++ 5 files changed, 529 insertions(+) create mode 100644 src/bus/hostaway.ts create mode 100644 src/hostaway/README.md create mode 100644 src/hostaway/api.ts create mode 100644 tests/unit/bus/hostaway.test.ts 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/cli/bus.ts b/src/cli/bus.ts index e876bde0..b5f43f9c 100644 --- a/src/cli/bus.ts +++ b/src/cli/bus.ts @@ -25,6 +25,7 @@ import { addCron, removeCron, readCrons, updateCron as updateCronDef, getCronByN 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 { 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'; @@ -1778,6 +1779,57 @@ busCommand } }); +// --------------------------------------------------------------------------- +// 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); + } + }); + // --------------------------------------------------------------------------- // 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/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/); + }); +}); From 716044c072a1f0adf573f6d4d6ffea33f34ea643 Mon Sep 17 00:00:00 2001 From: NancyBraun Date: Tue, 30 Jun 2026 10:59:49 -0400 Subject: [PATCH 3/5] docs(appfolio): use a generic example subdomain in docs/tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the real account subdomain used as the illustrative base-URL example with a neutral placeholder (yourcompany/acme.appfolio.com) and genericize the verified-reports heading. No behavior change — the test's input and expected URL change together. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/appfolio/README.md | 2 +- src/appfolio/api.ts | 2 +- tests/unit/bus/appfolio.test.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/appfolio/README.md b/src/appfolio/README.md index a8b76187..58f5ce96 100644 --- a/src/appfolio/README.md +++ b/src/appfolio/README.md @@ -39,7 +39,7 @@ cortextos bus appfolio-report work_order --filters '{"property_visibility":"acti Output is JSON: `{ ok, report, rows[], pagesFetched, truncated, rowCount }` (or just the `rows` array with `--rows-only`). -## Report names verified for carolinas-pm (2026-06-29) +## Report names verified against a live AppFolio account (2026-06) | Use case | Report name(s) | | ----------------------------- | ------------------------------------------------ | diff --git a/src/appfolio/api.ts b/src/appfolio/api.ts index eb925ae5..d72df7b4 100644 --- a/src/appfolio/api.ts +++ b/src/appfolio/api.ts @@ -60,7 +60,7 @@ export class AppFolioAPI { /** * @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://showcase.appfolio.com (trailing slash OK) + * @param baseUrl e.g. https://yourcompany.appfolio.com (trailing slash OK) */ constructor(clientId: string, clientSecret: string, baseUrl: string) { if (!clientId || !clientSecret) { diff --git a/tests/unit/bus/appfolio.test.ts b/tests/unit/bus/appfolio.test.ts index 711fbc0a..c8f37791 100644 --- a/tests/unit/bus/appfolio.test.ts +++ b/tests/unit/bus/appfolio.test.ts @@ -40,11 +40,11 @@ describe('AppFolioAPI', () => { 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://showcase.appfolio.com/'); + 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://showcase.appfolio.com/api/v2/reports/rent_roll.json'); + 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); From 4148ddeef0f3f1372854742d3bc707f0ec1f756e Mon Sep 17 00:00:00 2001 From: NancyBraun Date: Thu, 2 Jul 2026 11:38:01 -0400 Subject: [PATCH 4/5] feat(tenantturner): read-only Tenant Turner connector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a read-only Tenant Turner API v1 client, matching the AppFolio and Hostaway connector pattern: - src/tenantturner/api.ts — TenantTurnerAPI (GET-only, Basic base64(apiKey) auth, NextPage cursor pagination with page/row caps, 30s timeout). - src/bus/tenantturner.ts — creds loader (process.env -> secrets.env fallback) and fetchTenantTurnerResource. - src/cli/bus.ts — `bus tenantturner-get [--since] [--query] [--max-pages] [--max-rows] [--rows-only] [--org]`. - tests + README. The one read endpoint of note is `applications` (requires SinceDateUpdated), which returns the unified lead record: contact, income, acquisition source, pre-screening answers, and a nested Showings[] array. Read-only by construction: no create/update/delete code path (POST /v1/showings is a write and is never called). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/bus/tenantturner.ts | 82 +++++++++++++ src/cli/bus.ts | 54 +++++++++ src/tenantturner/README.md | 63 ++++++++++ src/tenantturner/api.ts | 176 ++++++++++++++++++++++++++++ tests/unit/bus/tenantturner.test.ts | 126 ++++++++++++++++++++ 5 files changed, 501 insertions(+) create mode 100644 src/bus/tenantturner.ts create mode 100644 src/tenantturner/README.md create mode 100644 src/tenantturner/api.ts create mode 100644 tests/unit/bus/tenantturner.test.ts 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/cli/bus.ts b/src/cli/bus.ts index b5f43f9c..ebf55117 100644 --- a/src/cli/bus.ts +++ b/src/cli/bus.ts @@ -26,6 +26,7 @@ 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 { 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'; @@ -1830,6 +1831,59 @@ busCommand } }); +// --------------------------------------------------------------------------- +// 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); + } + }); + // --------------------------------------------------------------------------- // 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/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/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/); + }); +}); From f41bdeb230aa2316467e6db26de52c276622c92a Mon Sep 17 00:00:00 2001 From: NancyBraun Date: Thu, 2 Jul 2026 13:51:59 -0400 Subject: [PATCH 5/5] feat(zinspector): read-only zInspector connector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a read-only zInspector API client, matching the AppFolio / Hostaway / Tenant Turner connector pattern: - src/zinspector/api.ts — ZInspectorAPI (GET-only, x-api-key header auth, required trailing-slash resource paths, follows the `next` URL for both cursor- and page-style pagination, page/row caps, 30s timeout). - src/bus/zinspector.ts — creds loader (process.env -> secrets.env fallback, base URL defaulting to portfolio.zinspector.com) and fetchZinspectorResource. - src/cli/bus.ts — `bus zinspector-get [--query] [--max-pages] [--max-rows] [--rows-only] [--org]`. - tests + README. Read endpoints of note: propertiesCursor, documents, media (photos), process. Read-only by construction: no create/update/delete code path. The key's data access is governed by its Linked User (must be an Admin) in zInspector. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/bus/zinspector.ts | 82 +++++++++++++++ src/cli/bus.ts | 52 ++++++++++ src/zinspector/README.md | 64 ++++++++++++ src/zinspector/api.ts | 163 ++++++++++++++++++++++++++++++ tests/unit/bus/zinspector.test.ts | 129 +++++++++++++++++++++++ 5 files changed, 490 insertions(+) create mode 100644 src/bus/zinspector.ts create mode 100644 src/zinspector/README.md create mode 100644 src/zinspector/api.ts create mode 100644 tests/unit/bus/zinspector.test.ts 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 ebf55117..1a4f47d9 100644 --- a/src/cli/bus.ts +++ b/src/cli/bus.ts @@ -27,6 +27,7 @@ import { queryKnowledgeBase, ingestKnowledgeBase, ensureKBDirs } from '../bus/kn 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'; @@ -1884,6 +1885,57 @@ busCommand } }); +// --------------------------------------------------------------------------- +// 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/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/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\)/); + }); +});