Skip to content

Commit a923298

Browse files
authored
feat(mail): smart contact sorting, snooze filtering, calendar fixes (#153)
* feat(mail): persist contact frequency in SQL for smarter autocomplete - Add contact_frequency table (ownerEmail, contactEmail, sendCount, receiveCount, lastContactedAt) - Increment frequency on every email send (all to/cc/bcc recipients) - Merge SQL frequency into contact sort with 10x weight boost per tracked send - Remove client-side in-memory frequency hack — server now handles sorting - Contacts you email frequently will always rank first in autocomplete * fix(calendar): fix skeleton loader logic — show on navigation, hide on refetch Remove keepPreviousData and lastEventsRef hacks that were preventing skeleton loaders during date navigation (j/k keys) while causing skeleton flash on tab refocus. Let React Query's caching handle both cases correctly: cached data stays visible during background refetch, skeleton shows when loading genuinely new date ranges. Increase gcTime to 30min so cache persists longer during background tabs. * fix(mail): hide snoozed emails from inbox until snooze expires - Add getSnoozedThreadIds() to query pending snooze jobs - Filter snoozed threads from inbox and unread views (both Gmail and local paths) - Handles Gmail eventual consistency — even if Gmail still returns the thread, we check the scheduled_jobs table and exclude it * fix(recruiting): role-based access control for org settings + concurrent agent changes - Prevent admin from removing org owner (privilege escalation) - Require owner/admin role to manage Greenhouse API key - Require owner/admin role to manage Slack webhook config - Include concurrent agent changes: multi-inbox support, db scoping, org switch
1 parent 14cc9be commit a923298

52 files changed

Lines changed: 2354 additions & 275 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,52 @@ Agent-native apps are single-tenant. Each deployment serves one organization. Yo
119119

120120
Per-user data isolation exists for multi-user organizations (via `owner_email` column convention and `AGENT_USER_EMAIL`), but large-scale multi-tenancy across organizations is not the architecture.
121121

122+
## Data Scoping
123+
124+
In production mode, the framework automatically restricts agent SQL queries (via `db-query` and `db-exec`) to the current user's data using temporary views. This is enforced at the SQL level — agents cannot bypass it.
125+
126+
### Per-User Scoping (`owner_email`)
127+
128+
Every template table that stores user-specific data **must** have an `owner_email` text column. The framework:
129+
130+
1. Detects tables with `owner_email` via schema introspection
131+
2. Creates temp views with `WHERE owner_email = <current user>` before each query
132+
3. Auto-injects `owner_email` into INSERT statements
133+
134+
The current user is resolved from `AGENT_USER_EMAIL` (set automatically from the session).
135+
136+
### Per-Org Scoping (`org_id`)
137+
138+
For multi-org apps (e.g., recruiting), tables can also include an `org_id` text column. When `AGENT_ORG_ID` is set:
139+
140+
1. Tables with `org_id` get an additional `WHERE org_id = <current org>` clause
141+
2. When both `owner_email` and `org_id` are present, both filters apply (AND)
142+
3. `org_id` is auto-injected into INSERT statements
143+
144+
Templates enable org scoping by providing a `resolveOrgId` callback in their agent-chat plugin:
145+
146+
```ts
147+
createAgentChatPlugin({
148+
resolveOrgId: async (event) => {
149+
const ctx = await getOrgContext(event);
150+
return ctx.orgId;
151+
},
152+
});
153+
```
154+
155+
### Schema Validation
156+
157+
Run `pnpm action db-check-scoping` to verify all template tables have proper ownership columns. Use `--require-org` for multi-org apps. Tables without scoping columns are accessible to all users.
158+
159+
### Column Conventions
160+
161+
| Column | Purpose | Required |
162+
| ------------- | ----------------------- | ------------------------------- |
163+
| `owner_email` | Per-user data isolation | Yes, for all user-facing tables |
164+
| `org_id` | Per-org data isolation | Yes, for multi-org apps |
165+
166+
**Hard rule: every new template table with user data must have `owner_email`.** Multi-org templates must also include `org_id`.
167+
122168
## A2A Protocol (Agent-to-Agent)
123169

124170
Agents can call other agents using the A2A protocol. From the mail app, you can tag the analytics agent to query data and include results in a draft. An agent discovers what other agents are available, calls them over the protocol, and shows results in the UI.
@@ -300,11 +346,12 @@ Run with: `pnpm action my-action --name foo`
300346

301347
### Core Actions (available automatically)
302348

303-
| Action | Purpose | Example |
304-
| ----------- | ------------------------------- | -------------------------------------------------- |
305-
| `db-schema` | Show all tables, columns, types | `pnpm action db-schema` |
306-
| `db-query` | Run a SELECT query | `pnpm action db-query --sql "SELECT * FROM forms"` |
307-
| `db-exec` | Run INSERT/UPDATE/DELETE | `pnpm action db-exec --sql "UPDATE forms SET ..."` |
349+
| Action | Purpose | Example |
350+
| ------------------ | -------------------------------- | -------------------------------------------------- |
351+
| `db-schema` | Show all tables, columns, types | `pnpm action db-schema` |
352+
| `db-query` | Run a SELECT query | `pnpm action db-query --sql "SELECT * FROM forms"` |
353+
| `db-exec` | Run INSERT/UPDATE/DELETE | `pnpm action db-exec --sql "UPDATE forms SET ..."` |
354+
| `db-check-scoping` | Validate ownership columns exist | `pnpm action db-check-scoping --require-org` |
308355

309356
Per-user data scoping is automatic in production mode via `AGENT_USER_EMAIL`.
310357

packages/core/src/client/integrations/useIntegrationStatus.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ export function useIntegrationStatus() {
1818
const fetchStatuses = useCallback(async () => {
1919
try {
2020
const res = await fetch("/_agent-native/integrations/status");
21-
if (!res.ok) return;
21+
if (!res.ok) {
22+
if (mountedRef.current) setLoading(false);
23+
return;
24+
}
2225
const data = await res.json();
2326
if (mountedRef.current) {
2427
setStatuses(Array.isArray(data) ? data : []);
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
/**
2+
* Core script: db-check-scoping
3+
*
4+
* Validates that all template tables have the required ownership columns
5+
* (owner_email, org_id) for per-user and per-org data scoping.
6+
*
7+
* Tables without these columns are invisible to the scoping system and
8+
* will be accessible to all users in production mode.
9+
*
10+
* Usage:
11+
* pnpm action db-check-scoping [--db path] [--require-org] [--format json]
12+
*/
13+
14+
import path from "path";
15+
import { createClient } from "@libsql/client";
16+
import { getDatabaseUrl, getDatabaseAuthToken } from "../../db/client.js";
17+
import { parseArgs } from "../utils.js";
18+
19+
function isPostgresUrl(url: string): boolean {
20+
return url.startsWith("postgres://") || url.startsWith("postgresql://");
21+
}
22+
23+
interface TableColumn {
24+
table: string;
25+
column: string;
26+
}
27+
28+
// Core tables that have their own scoping — skip these in validation
29+
const CORE_TABLES = new Set([
30+
"settings",
31+
"application_state",
32+
"oauth_tokens",
33+
"sessions",
34+
// framework internal tables
35+
"resources",
36+
"chat_threads",
37+
"chat_messages",
38+
"chat_tasks",
39+
"recurring_jobs",
40+
// drizzle/migration tables
41+
"__drizzle_migrations",
42+
"_litestream_lock",
43+
"_litestream_seq",
44+
]);
45+
46+
interface ValidationResult {
47+
table: string;
48+
hasOwnerEmail: boolean;
49+
hasOrgId: boolean;
50+
issues: string[];
51+
}
52+
53+
function validate(
54+
allColumns: TableColumn[],
55+
requireOrg: boolean,
56+
): ValidationResult[] {
57+
const columnsByTable = new Map<string, string[]>();
58+
for (const { table, column } of allColumns) {
59+
const cols = columnsByTable.get(table) || [];
60+
cols.push(column);
61+
columnsByTable.set(table, cols);
62+
}
63+
64+
const results: ValidationResult[] = [];
65+
66+
for (const [table, columns] of columnsByTable) {
67+
// Skip core/framework tables
68+
if (CORE_TABLES.has(table)) continue;
69+
// Skip migration-related tables
70+
if (table.startsWith("_")) continue;
71+
72+
const hasOwnerEmail = columns.includes("owner_email");
73+
const hasOrgId = columns.includes("org_id");
74+
const issues: string[] = [];
75+
76+
if (!hasOwnerEmail) {
77+
issues.push("missing owner_email column — not scoped per-user");
78+
}
79+
if (requireOrg && !hasOrgId) {
80+
issues.push("missing org_id column — not scoped per-org");
81+
}
82+
83+
results.push({ table, hasOwnerEmail, hasOrgId, issues });
84+
}
85+
86+
return results;
87+
}
88+
89+
async function discoverColumnsPostgres(pgSql: any): Promise<TableColumn[]> {
90+
const rows: any[] = await pgSql`
91+
SELECT table_name, column_name
92+
FROM information_schema.columns
93+
WHERE table_schema = 'public'
94+
ORDER BY table_name, ordinal_position
95+
`;
96+
return rows.map((r) => ({ table: r.table_name, column: r.column_name }));
97+
}
98+
99+
async function discoverColumnsSqlite(client: any): Promise<TableColumn[]> {
100+
const tablesResult = await client.execute(
101+
`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`,
102+
);
103+
const tables = tablesResult.rows.map((r: any) => (r.name ?? r[0]) as string);
104+
105+
const result: TableColumn[] = [];
106+
for (const table of tables) {
107+
const escaped = table.replace(/"/g, '""');
108+
const colsResult = await client.execute(`PRAGMA table_info("${escaped}")`);
109+
for (const row of colsResult.rows) {
110+
result.push({
111+
table,
112+
column: (row.name ?? row[1]) as string,
113+
});
114+
}
115+
}
116+
return result;
117+
}
118+
119+
export default async function dbCheckScoping(args: string[]): Promise<void> {
120+
const parsed = parseArgs(args);
121+
122+
if (parsed.help === "true") {
123+
console.log(`Usage: pnpm action db-check-scoping [options]
124+
125+
Options:
126+
--db <path> Path to SQLite database (default: data/app.db)
127+
--require-org Also check for org_id column (for multi-org apps)
128+
--format json Output as JSON
129+
--help Show this help message`);
130+
return;
131+
}
132+
133+
const requireOrg = parsed["require-org"] === "true";
134+
const format = parsed.format;
135+
136+
// Resolve database URL
137+
let url: string;
138+
if (parsed.db) {
139+
url = "file:" + path.resolve(parsed.db);
140+
} else if (getDatabaseUrl()) {
141+
url = getDatabaseUrl();
142+
} else {
143+
url = "file:" + path.resolve(process.cwd(), "data", "app.db");
144+
}
145+
146+
let allColumns: TableColumn[];
147+
148+
if (isPostgresUrl(url)) {
149+
const { default: pg } = await import("postgres");
150+
const pgSql = pg(url);
151+
try {
152+
allColumns = await discoverColumnsPostgres(pgSql);
153+
} finally {
154+
await pgSql.end();
155+
}
156+
} else {
157+
const client = createClient({
158+
url,
159+
authToken: getDatabaseAuthToken(),
160+
});
161+
try {
162+
allColumns = await discoverColumnsSqlite(client);
163+
} finally {
164+
client.close();
165+
}
166+
}
167+
168+
const results = validate(allColumns, requireOrg);
169+
170+
if (format === "json") {
171+
console.log(JSON.stringify({ tables: results }, null, 2));
172+
return;
173+
}
174+
175+
const withIssues = results.filter((r) => r.issues.length > 0);
176+
const ok = results.filter((r) => r.issues.length === 0);
177+
178+
if (ok.length > 0) {
179+
console.log("Scoped tables:");
180+
for (const r of ok) {
181+
const scopes = [
182+
r.hasOwnerEmail ? "owner_email" : null,
183+
r.hasOrgId ? "org_id" : null,
184+
]
185+
.filter(Boolean)
186+
.join(", ");
187+
console.log(` ✓ ${r.table} (${scopes})`);
188+
}
189+
console.log();
190+
}
191+
192+
if (withIssues.length > 0) {
193+
console.log("Unscoped tables (WARNING):");
194+
for (const r of withIssues) {
195+
for (const issue of r.issues) {
196+
console.log(` ✗ ${r.table}${issue}`);
197+
}
198+
}
199+
console.log();
200+
console.log(
201+
`${withIssues.length} table(s) lack scoping columns. ` +
202+
`In production, agents can see ALL rows in these tables regardless of user/org.`,
203+
);
204+
process.exitCode = 1;
205+
} else {
206+
console.log("All template tables have proper scoping columns.");
207+
}
208+
}

packages/core/src/scripts/db/exec.ts

Lines changed: 42 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@
55
* against a SQLite or Postgres database.
66
*
77
* In production mode, temporary views scope UPDATE/DELETE to the current
8-
* user's data (AGENT_USER_EMAIL). For INSERT, the `owner_email` column
9-
* is auto-injected if the target table uses the ownership convention.
8+
* user's data (AGENT_USER_EMAIL / AGENT_ORG_ID). For INSERT, the
9+
* `owner_email` and `org_id` columns are auto-injected if the target
10+
* table uses the ownership convention.
1011
*
1112
* Usage:
1213
* pnpm action db-exec --sql "UPDATE forms SET status='published' WHERE id='abc'" [--db path]
@@ -27,15 +28,14 @@ function isPostgresUrl(url: string): boolean {
2728
}
2829

2930
/**
30-
* For INSERT statements targeting a table with an owner_email column,
31-
* auto-inject the current user's email if not already present.
31+
* For INSERT statements targeting a table with owner_email / org_id columns,
32+
* auto-inject the current user's email and org ID if not already present.
3233
*
33-
* Handles both forms:
34-
* INSERT INTO table (col1, col2) VALUES (?, ?)
35-
* INSERT INTO table VALUES (...)
34+
* Handles the explicit column list form:
35+
* INSERT INTO table (col1, col2) VALUES (val1, val2)
3636
*/
37-
function injectOwnerEmail(sql: string, scoping: ScopingContext): string {
38-
if (!scoping.active || !scoping.userEmail) return sql;
37+
function injectOwnership(sql: string, scoping: ScopingContext): string {
38+
if (!scoping.active) return sql;
3939

4040
const upper = sql
4141
.replace(/^\s*--[^\n]*\n/gm, "")
@@ -49,19 +49,43 @@ function injectOwnerEmail(sql: string, scoping: ScopingContext): string {
4949
if (!match) return sql;
5050

5151
const tableName = match[1];
52-
if (!scoping.ownerEmailTables.has(tableName)) return sql;
5352

54-
// Check if owner_email is already in the column list
55-
if (/owner_email/i.test(sql)) return sql;
53+
// Determine which columns to inject
54+
const injections: { col: string; value: string }[] = [];
55+
56+
if (
57+
scoping.userEmail &&
58+
scoping.ownerEmailTables.has(tableName) &&
59+
!/owner_email/i.test(sql)
60+
) {
61+
injections.push({
62+
col: "owner_email",
63+
value: `'${scoping.userEmail.replace(/'/g, "''")}'`,
64+
});
65+
}
66+
67+
if (
68+
scoping.orgId &&
69+
scoping.orgIdTables.has(tableName) &&
70+
!/org_id/i.test(sql)
71+
) {
72+
injections.push({
73+
col: "org_id",
74+
value: `'${scoping.orgId.replace(/'/g, "''")}'`,
75+
});
76+
}
77+
78+
if (injections.length === 0) return sql;
5679

5780
// Try to inject into explicit column list: INSERT INTO t (cols) VALUES (vals)
5881
const colListMatch = sql.match(
5982
/(INSERT\s+INTO\s+["']?\w+["']?\s*)\(([^)]+)\)(\s*VALUES\s*)\(([^)]+)\)/i,
6083
);
6184
if (colListMatch) {
6285
const [, prefix, cols, valueKeyword, vals] = colListMatch;
63-
const escaped = scoping.userEmail.replace(/'/g, "''");
64-
return `${prefix}(${cols}, owner_email)${valueKeyword}(${vals}, '${escaped}')`;
86+
const extraCols = injections.map((i) => i.col).join(", ");
87+
const extraVals = injections.map((i) => i.value).join(", ");
88+
return `${prefix}(${cols}, ${extraCols})${valueKeyword}(${vals}, ${extraVals})`;
6589
}
6690

6791
return sql;
@@ -185,8 +209,8 @@ Options:
185209
await pgSql.unsafe(stmt);
186210
}
187211

188-
// For INSERT: auto-inject owner_email
189-
const finalSql = injectOwnerEmail(sql, scoping);
212+
// For INSERT: auto-inject owner_email / org_id
213+
const finalSql = injectOwnership(sql, scoping);
190214

191215
const result = await pgSql.unsafe(finalSql);
192216
const rows: Record<string, unknown>[] =
@@ -221,8 +245,8 @@ Options:
221245
await client.execute(stmt);
222246
}
223247

224-
// For INSERT: auto-inject owner_email
225-
const finalSql = injectOwnerEmail(sql, scoping);
248+
// For INSERT: auto-inject owner_email / org_id
249+
const finalSql = injectOwnership(sql, scoping);
226250

227251
const result = await client.execute(finalSql);
228252

0 commit comments

Comments
 (0)