Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions src/appfolio/README.md
Original file line number Diff line number Diff line change
@@ -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/<org>/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://<your-db>.appfolio.com
```

Get the Client ID/Secret in AppFolio: account menu → General Settings →
Manage API Settings → **Reports API Credentials**.

## Usage

```
cortextos bus appfolio-report <report> [--filters '<json>'] [--max-pages N] [--max-rows N] [--rows-only]
```

Examples:

```
# First page of the rent roll
cortextos bus appfolio-report rent_roll --max-pages 1

# Active-property delinquencies, rows only
cortextos bus appfolio-report delinquency --rows-only

# Open work orders with a filter
cortextos bus appfolio-report work_order --filters '{"property_visibility":"active"}'
```

Output is JSON: `{ ok, report, rows[], pagesFetched, truncated, rowCount }`
(or just the `rows` array with `--rows-only`).

## Report names verified against a live AppFolio account (2026-06)

| Use case | Report name(s) |
| ----------------------------- | ------------------------------------------------ |
| Rent roll / occupancy | `rent_roll`, `unit_directory`, `tenant_directory`|
| Delinquency / collections | `delinquency`, `aged_receivables_detail` |
| Maintenance / work orders | `work_order` |
| Leasing & renewals | `lease_expiration_detail`, `unit_vacancy`, `rental_applications` |

The report set differs per AppFolio account; a wrong name returns HTTP 400
(`"Id is not a valid report."`). Pass any valid report name — the connector is
generic, not limited to the list above. Note `rent_roll` already carries
`lease_to` / `lease_expires_month`, so lease expirations can also be derived
from it.

## Notes

- **Auth:** HTTP Basic (Client ID / Secret), sent as a header (never embedded in
the URL, so secrets don't leak into logs).
- **Pagination:** automatic via `next_page_url`; capped at 20 pages by default
(`--max-pages` / `--max-rows` to change). `truncated: true` means a cap, not
AppFolio, stopped the walk.
- **Rate limit:** 7 requests / 15 s on base endpoints (429). The connector
throttles between pages and retries once on 429 honoring `Retry-After`.

Code: `src/appfolio/api.ts` (client) · `src/bus/appfolio.ts` (creds + bus logic)
· command wired in `src/cli/bus.ts` · tests in `tests/unit/bus/appfolio.test.ts`.
210 changes: 210 additions & 0 deletions src/appfolio/api.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>[];
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<string, unknown>[];
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<void>((r) => setTimeout(r, ms));

export class AppFolioAPI {
private readonly baseUrl: string;
private readonly authHeader: string;

/**
* @param clientId AppFolio Reports API Client ID (Basic-auth username)
* @param clientSecret AppFolio Reports API Client Secret (Basic-auth password)
* @param baseUrl e.g. https://yourcompany.appfolio.com (trailing slash OK)
*/
constructor(clientId: string, clientSecret: string, baseUrl: string) {
if (!clientId || !clientSecret) {
throw new Error('AppFolioAPI requires both clientId and clientSecret');
}
if (!baseUrl) {
throw new Error('AppFolioAPI requires a baseUrl (e.g. https://yourco.appfolio.com)');
}
// Normalize: strip trailing slash so we can join paths cleanly.
this.baseUrl = baseUrl.replace(/\/+$/, '');
// HTTP Basic auth. We send it as a header rather than embedding the secret
// in the URL (which would leak into logs/error messages).
this.authHeader =
'Basic ' + Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
}

/**
* Fetch a report by its AppFolio path name (without the .json), following
* pagination automatically up to the configured caps.
*
* @param reportName e.g. "rent_roll", "delinquency", "unit_directory"
* @param params Report filter params sent as the JSON body, e.g.
* { properties: { property_visibility: 'active' } }. AppFolio
* reports each accept their own filters; pass {} for defaults.
*/
async fetchReport(
reportName: string,
params: Record<string, unknown> = {},
opts: FetchReportOptions = {},
): Promise<FetchReportResult> {
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<string, unknown>[] = [];
let pagesFetched = 0;
let truncated = false;

// First page: POST the report endpoint with the filter params.
let body = await this.requestJson<AppFolioReportResponse | Record<string, unknown>[]>(
`${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<string, unknown>);
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<AppFolioReportResponse | Record<string, unknown>[]>(
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<T>(url: string, init: RequestInit, reportName: string): Promise<T> {
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`);
}
}
89 changes: 89 additions & 0 deletions src/bus/appfolio.ts
Original file line number Diff line number Diff line change
@@ -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/<org>/secrets.env already sourced into
* their PTY env), falling back to reading orgs/<org>/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<string, string> {
const vars: Record<string, string> = {};
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/<org>/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<string, unknown> = {},
opts: { maxPages?: number; maxRows?: number } = {},
): Promise<FetchAppfolioReportResult> {
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 };
}
Loading