Skip to content

Commit eab0a87

Browse files
remi-tlcursoragent
andcommitted
Add gatekeeper-salesforce with corrected sync pipeline.
Land the Salesforce gatekeeper package plus release-manifest D1/Vectorize/workflow wiring, including P0/P1 sync fixes (getDeleted, uncapped SOQL pagination via nextRecordsUrl, D1+Vectorize deletes, nested field embeds, cursor tie-break). Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 16a626d commit eab0a87

31 files changed

Lines changed: 18283 additions & 17 deletions
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
dist
2+
dist-app
3+
.wrangler
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
# Gatekeeper Salesforce
2+
3+
Semantic search over your Salesforce org, available to Cloudflare OS agents as an ambient
4+
`SALESFORCE` binding — no connection or OAuth flow required.
5+
6+
This auto-provisioned gatekeeper mirrors read-only Salesforce data (Accounts, Contacts, Campaigns,
7+
Opportunities, Tasks, Events, Leads, and Touchless custom objects) into Cloudflare **Vectorize** +
8+
**D1**, embedding each record with Workers AI `@cf/qwen/qwen3-embedding-0.6b` (1024-dim). Agents
9+
can then ask natural-language questions like "find Toyota dealers in Wisconsin with stalled demo
10+
activity" and get semantically-ranked records back — no SOQL needed, in <500 ms.
11+
12+
## How it works
13+
14+
```
15+
Salesforce (REST /query, JWT) ──► SalesforceSyncWorkflow (Cron + resync RPC)
16+
17+
├─ chunk/embed (@cf/qwen/qwen3-embedding-0.6b)
18+
├─ D1 records (full JSON content, sync cursors)
19+
└─ Vectorize (1024-dim cosine, indexed metadata)
20+
21+
Agent ──► SalesforceIndex.search(query, opts) ──► Vectorize query → D1 fetch → observation
22+
```
23+
24+
- **Sync**: a durable Cloudflare Workflow pulls records modified since the last
25+
composite `SystemModstamp|Id` cursor (see `migrations/0001_init.sql`), every 2 hours
26+
(configurable). It only re-embeds records whose `search_text` actually changed, skips unchanged
27+
content, and reconciles deletions from Salesforce **getDeleted** (recycle-bin window) on every
28+
incremental run — including empty deltas. Full syncs may optionally reconcile against a complete
29+
ID inventory; incremental deltas are never treated as a full inventory. Removals delete from
30+
**D1 and Vectorize together**.
31+
- **Read path**: `search()` embeds the query, queries Vectorize (optionally filtered by
32+
objectType/owner/campaign/status), then hydrates the snippets from D1. Every read is authorized
33+
as an observation; reads never touch the approval queue.
34+
35+
## Agent API
36+
37+
The ambient binding (`SALESFORCE`, ts type `SalesforceIndex`) exposes:
38+
39+
```ts
40+
interface SalesforceIndex {
41+
search(query: string, opts?: {
42+
objectType?: string; // "Account" | "Contact" | "Campaign" | ...
43+
ownerId?: string; // filter to a Salesforce user Id
44+
campaignId?: string;
45+
status?: string;
46+
limit?: number; // default 20, max 50
47+
}): Promise<SalesforceSearchResult[]>;
48+
49+
getRecord(id: string): Promise<SalesforceRecord | null>;
50+
listObjects(): Promise<SalesforceObjectInfo[]>;
51+
resync(opts?: { objectType?: string }): Promise<{ started: boolean; objectType?: string }>;
52+
}
53+
```
54+
55+
See `src/salesforce-gatekeeper.ts` (`SALESFORCE_TYPES`) for the exact agent-facing declarations.
56+
57+
## Configuration
58+
59+
### 1. Salesforce connected app (JWT)
60+
61+
Reuse (or create) an External Client App in your Salesforce org for the JWT bearer flow:
62+
63+
1. In Salesforce Setup, create an External Client App.
64+
2. Enable OAuth, add the JWT bearer flow, and register the integration user.
65+
3. Upload the **certificate** whose private key you'll configure as the Worker secret.
66+
4. Allowlist the integration user (e.g. `remi@touchless.io`) on the app.
67+
68+
### 2. Worker secrets
69+
70+
Set these on the deployed `gatekeeper-salesforce` Worker (in the starter, also via
71+
`packages/gatekeeper-salesforce/deploy-inputs.json`):
72+
73+
| Secret | Value |
74+
| -------------------- | ------------------------------------------------------------------ |
75+
| `SF_CLIENT_ID` | The External Client App consumer key |
76+
| `SF_USERNAME` | The integration user, e.g. `remi@touchless.io` |
77+
| `SF_PRIVATE_KEY` | The RSA **PKCS#8** PEM private key (always stored in the vault) |
78+
| `SF_LOGIN_URL` (opt) | Defaults to `https://login.salesforce.com`; use `https://test.salesforce.com` for a sandbox |
79+
80+
### 3. Cloudflare resources
81+
82+
```sh
83+
# Vectorize index (1024 dims to match the embedding model)
84+
npx wrangler vectorize create salesforce-index --dimensions=1024 --metric=cosine
85+
86+
# Enable metadata filtering on the fields used by search() options
87+
npx wrangler vectorize create-metadata-index salesforce-index --property-name='objectType' --type='string'
88+
npx wrangler vectorize create-metadata-index salesforce-index --property-name='ownerId' --type='string'
89+
npx wrangler vectorize create-metadata-index salesforce-index --property-name='campaignId'--type='string'
90+
npx wrangler vectorize create-metadata-index salesforce-index --property-name='status' --type='string'
91+
92+
# D1 database + schema
93+
npx wrangler d1 create salesforce-vector-store
94+
npx wrangler d1 migrations apply salesforce-vector-store --remote
95+
```
96+
97+
Then point `wrangler.jsonc` `d1_databases[0].database_id` and `vectorize[0].index_name` at the
98+
created resources (the committed file uses `$D1_SF_DB_ID` / `$VECTORIZE_SF_INDEX_NAME`
99+
placeholders that the release manifest / deploy service resolves).
100+
101+
### 4. Deploy + first sync
102+
103+
```sh
104+
cd packages/gatekeeper-salesforce
105+
npx wrangler deploy # (secrets must be set first)
106+
npx wrangler workflows trigger sf-sync '{}' # full initial load
107+
```
108+
109+
The initial sync of ~100K records costs roughly **$0.30** in Workers AI embedding (the whole org
110+
fits in a single Vectorize index well below the 20M-vector limit) and completes in ~30–60 minutes
111+
thanks to Workflows' durable steps.
112+
113+
## Object coverage
114+
115+
The default registry lives in `src/sf-objects.ts` (`OBJECT_TYPE_CONFIGS`). It covers:
116+
117+
Account, Contact, Campaign, CampaignMember, CampaignMemberStatus, Opportunity, Task, Event, Lead,
118+
`Campaign_Account__c`, `Cadence_Step_Snapshot__c`, `Outreach_Scorecard_Entry__c`.
119+
120+
To add an object, insert an `object_config` row (SOQL fields + embedding fields) or extend the
121+
registry and re-run the seed step — no code change is needed for standard dosing.
122+
123+
## Development
124+
125+
```sh
126+
pnpm --filter @gadgets/gatekeeper-salesforce... install
127+
pnpm --filter @gadgets/gatekeeper-salesforce test # unit + D1 integration tests
128+
pnpm --filter @gadgets/gatekeeper-salesforce build # typecheck
129+
```
130+
131+
The dev server (`pnpm dev-server`) auto-discovers the gatekeeper and binds `GATEKEEPER_SALESFORCE`.
132+
For local dev, `wrangler.jsonc` uses `preview_database_id: "salesforce-vector-store"` and the same
133+
index name, so `wrangler dev` works against local D1 + Vectorize once you create them.
134+
135+
## Security notes
136+
137+
- Read-only: no Salesforce writes, no approval-queue actions. All reads are observations.
138+
- PII is kept inside Cloudflare (D1 + Vectorize) exactly as indexed; the sync never logs record
139+
bodies or the private key. Secrets live only in Worker secrets / the vault.
140+
- Metadata index values are capped at 64 bytes to stay within Vectorize limits.
141+
142+
## Releasing
143+
144+
Adding this package touched the release manifest:
145+
146+
- `scripts/release/manifest-lib.mjs` now understands `d1_databases`, `vectorize`, `ai`, and
147+
`workflows` config keys, emitting `$D1_<BINDING>_ID`, `$VECTORIZE_<BINDING>_NAME`, `ai`, and
148+
`workflow` binding templates. `gatekeeper-salesforce` is in `NO_DEFAULT_CRED_INPUTS` (it takes
149+
`SF_*` secrets via `deploy-inputs.json`).
150+
- `scripts/testdata/golden-manifest.json` was regenerated with the new package entry.
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
import {
3+
buildModifiedWhere,
4+
cursorFromRecords,
5+
formatSyncCursor,
6+
parseSyncCursor,
7+
resolveSfUrl,
8+
SalesforceClient,
9+
type SalesforceAccessToken,
10+
} from "../src/sf-api.js";
11+
import type { SfRecord } from "../src/salesforce-types.js";
12+
13+
const seedToken: SalesforceAccessToken = {
14+
accessToken: "token",
15+
instanceUrl: "https://example.my.salesforce.com",
16+
apiVersion: "v60.0",
17+
issuedAt: Date.now(),
18+
expiresAt: Date.now() + 60_000,
19+
};
20+
21+
const auth = {
22+
clientId: "client",
23+
username: "user@example.com",
24+
privateKeyPem: "-----BEGIN PRIVATE KEY-----\nMII\n-----END PRIVATE KEY-----",
25+
loginUrl: "https://login.salesforce.com",
26+
};
27+
28+
describe("resolveSfUrl", () => {
29+
it("joins nextRecordsUrl absolute paths without double-prefixing the API version", () => {
30+
const next = "/services/data/v60.0/query/01gXX-2000";
31+
expect(resolveSfUrl("https://example.my.salesforce.com", "v60.0", next)).toBe(
32+
"https://example.my.salesforce.com/services/data/v60.0/query/01gXX-2000",
33+
);
34+
});
35+
36+
it("prefixes relative query paths with services/data/{version}", () => {
37+
expect(resolveSfUrl("https://example.my.salesforce.com/", "v60.0", "/query?q=SELECT")).toBe(
38+
"https://example.my.salesforce.com/services/data/v60.0/query?q=SELECT",
39+
);
40+
});
41+
42+
it("passes through absolute http(s) URLs", () => {
43+
const full = "https://example.my.salesforce.com/services/data/v60.0/query/01gXX";
44+
expect(resolveSfUrl("https://other.salesforce.com", "v60.0", full)).toBe(full);
45+
});
46+
});
47+
48+
describe("sync cursor helpers", () => {
49+
it("parses and formats composite cursors", () => {
50+
expect(parseSyncCursor(null)).toBeNull();
51+
expect(parseSyncCursor("2026-08-01T10:00:00.000Z")).toEqual({
52+
modstamp: "2026-08-01T10:00:00.000Z",
53+
});
54+
expect(parseSyncCursor("2026-08-01T10:00:00.000Z|001xx000000ABC")).toEqual({
55+
modstamp: "2026-08-01T10:00:00.000Z",
56+
id: "001xx000000ABC",
57+
});
58+
expect(formatSyncCursor("2026-08-01T10:00:00.000Z", "001xx000000ABC")).toBe(
59+
"2026-08-01T10:00:00.000Z|001xx000000ABC",
60+
);
61+
});
62+
63+
it("builds a tie-safe WHERE clause", () => {
64+
expect(buildModifiedWhere(null)).toBeUndefined();
65+
expect(buildModifiedWhere({ modstamp: "2026-08-01T10:00:00.000Z" })).toBe(
66+
"SystemModstamp >= 2026-08-01T10:00:00.000Z",
67+
);
68+
expect(
69+
buildModifiedWhere({ modstamp: "2026-08-01T10:00:00.000Z", id: "001xx000000ABC" }),
70+
).toBe(
71+
"(SystemModstamp > 2026-08-01T10:00:00.000Z OR " +
72+
"(SystemModstamp = 2026-08-01T10:00:00.000Z AND Id > '001xx000000ABC'))",
73+
);
74+
});
75+
76+
it("advances the cursor from the last ordered record", () => {
77+
const records: SfRecord[] = [
78+
{ Id: "001aa", SystemModstamp: "2026-08-01T10:00:00.000Z" },
79+
{ Id: "001bb", SystemModstamp: "2026-08-01T10:00:00.000Z" },
80+
];
81+
expect(cursorFromRecords(records)).toEqual({
82+
modstamp: "2026-08-01T10:00:00.000Z",
83+
id: "001bb",
84+
});
85+
expect(cursorFromRecords([], { modstamp: "x", id: "y" })).toEqual({
86+
modstamp: "x",
87+
id: "y",
88+
});
89+
});
90+
});
91+
92+
describe("SalesforceClient pagination + getDeleted", () => {
93+
afterEach(() => {
94+
vi.unstubAllGlobals();
95+
vi.restoreAllMocks();
96+
});
97+
98+
it("paginates without a SOQL LIMIT that caps the total result set", async () => {
99+
const soqls: string[] = [];
100+
const urls: string[] = [];
101+
vi.stubGlobal(
102+
"fetch",
103+
vi.fn(async (input: RequestInfo | URL) => {
104+
const url = String(input);
105+
urls.push(url);
106+
if (url.includes("/query?q=")) {
107+
const q = decodeURIComponent(url.split("q=")[1] ?? "");
108+
soqls.push(q);
109+
expect(q).not.toMatch(/\bLIMIT\b/i);
110+
return new Response(
111+
JSON.stringify({
112+
done: false,
113+
totalSize: 3,
114+
records: [
115+
{ Id: "001aa0000000001", SystemModstamp: "2026-08-01T10:00:00.000Z" },
116+
{ Id: "001aa0000000002", SystemModstamp: "2026-08-01T10:00:01.000Z" },
117+
],
118+
nextRecordsUrl: "/services/data/v60.0/query/01gXX-2000",
119+
}),
120+
{ status: 200 },
121+
);
122+
}
123+
if (url.includes("/services/data/v60.0/query/01gXX-2000")) {
124+
expect(url).toBe(
125+
"https://example.my.salesforce.com/services/data/v60.0/query/01gXX-2000",
126+
);
127+
expect(url).not.toContain("/services/data/v60.0/services/data/");
128+
return new Response(
129+
JSON.stringify({
130+
done: true,
131+
records: [{ Id: "001aa0000000003", SystemModstamp: "2026-08-01T10:00:02.000Z" }],
132+
}),
133+
{ status: 200 },
134+
);
135+
}
136+
return new Response("not found", { status: 404 });
137+
}),
138+
);
139+
140+
const client = new SalesforceClient(auth, 200, seedToken);
141+
const { records, truncated } = await client.queryAll("Id, SystemModstamp", "Account");
142+
expect(truncated).toBe(false);
143+
expect(records).toHaveLength(3);
144+
expect(soqls[0]).toContain("ORDER BY SystemModstamp ASC");
145+
expect(soqls[0]).not.toMatch(/\bLIMIT\b/i);
146+
expect(urls.some((u) => u.includes("/services/data/v60.0/query/01gXX-2000"))).toBe(true);
147+
});
148+
149+
it("getDeleted returns IDs for the recycle-bin window", async () => {
150+
vi.stubGlobal(
151+
"fetch",
152+
vi.fn(async (input: RequestInfo | URL) => {
153+
const url = String(input);
154+
expect(url).toContain("/sobjects/Account/deleted/");
155+
expect(url).toContain("start=");
156+
expect(url).toContain("end=");
157+
return new Response(
158+
JSON.stringify({
159+
deletedRecords: [
160+
{ id: "001del000000001", deletedDate: "2026-08-02T12:00:00.000+0000" },
161+
],
162+
latestDateCovered: "2026-08-02T12:00:00.000+0000",
163+
}),
164+
{ status: 200 },
165+
);
166+
}),
167+
);
168+
169+
const client = new SalesforceClient(auth, 200, seedToken);
170+
const deleted = await client.getDeleted(
171+
"Account",
172+
"2026-08-01T00:00:00.000Z",
173+
"2026-08-03T00:00:00.000Z",
174+
);
175+
expect(deleted).toEqual([
176+
{ id: "001del000000001", deletedDate: "2026-08-02T12:00:00.000+0000" },
177+
]);
178+
});
179+
});

0 commit comments

Comments
 (0)