@drawbridge/drawbridge-agents 0.1.50 → 0.1.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,45 +1,47 @@
1
1
  ---
2
2
  name: drawbridge-add-connection
3
- description: Use when adding a new vendor connection (a new manifest in drawbridge-utils) runs the auth-type assessment FIRST so the integration is built the way the vendor's own docs recommend, then walks the build against the documented checklist and its guards.
3
+ description: Use when adding a vendor connection (a new manifest in drawbridge-utils), changing one's auth type or scopes, or adding a hook, link, or step to an existing manifest and when asked to check an existing connection against the connection checklist.
4
4
  ---
5
5
 
6
6
  # Add a connection
7
7
 
8
- A connection is one manifest file in `drawbridge-utils/lib/connections/`, and the
9
- build half is documented `drawbridge-docs/reference/connection-hooks.md`,
10
- "Adding a connection". This skill exists for what comes BEFORE the file: deciding
11
- what kind of connection to build, from the vendor's documentation rather than
12
- from whichever auth mechanism is easiest to wire.
8
+ A connection is one manifest in `drawbridge-utils/lib/connections/providers/`.
9
+ The manifest is the only place vendor facts live, and it carries its own
10
+ proof: a `review` key the contract test refuses to accept incomplete. This
11
+ skill is the order of work that fills it honestly.
13
12
 
14
- The failure it prevents is real: Attentive was first built as pasted API keys
15
- because keys worked, and their docs turned out to frame private-app keys as
16
- "integrate your own account" the public OAuth app was the documented path for
17
- platforms like Drawbridge. The rebuild cost an afternoon; the assessment costs
18
- ten minutes.
19
-
20
- ## When to use
21
-
22
- Any new vendor manifest, and any auth-type change to an existing one (keys →
23
- OAuth is a rebuild of the auth surface, not a tweak).
13
+ **A manifest ships complete: every hook the vendor can serve, every link the
14
+ dashboard exposes, every scope those need, copy humanized, walked on dev.
15
+ Nothing is deferred for a deadline. Darren, 2026-09-11: "Don't skip shit, do
16
+ this right, our production launch deadline is somewhat flexible." If the work
17
+ does not fit the time, the time moves.**
24
18
 
25
19
  ## Steps
26
20
 
27
- 1. **Fetch the vendor's developer docs and answer these in writing, with the
28
- URL of the page that answers each.** Never from memory the Klaviyo consent
29
- url was once written from memory, pointed at their API host, and stalled
21
+ 1. **Read the vendor twice, and write down where.** Fetch, never recall — a
22
+ consent url once written from memory pointed at the api host and stalled
30
23
  every merchant on a blank page.
31
-
32
- - What auth mechanisms exist, and **who does the vendor say each is for**?
33
- The distinction that matters is *your own account* versus *accounts outside
34
- your organization*. Drawbridge is always the second: merchants connect
35
- THEIR account to OUR platform.
36
- - Is there a partner / public-app / marketplace program? What does
37
- registration take, and what credentials does it issue?
38
- - For OAuth: authorize url, token url, how client credentials are sent
39
- (form fields vs Basic header), whether PKCE is required, token lifetime,
40
- whether refresh tokens exist, scope names.
41
-
42
- 2. **Pick the `auth.type` from the answers, not from convenience:**
24
+ - **API reference**: auth mechanisms and who the vendor says each is for
25
+ (your own account vs accounts outside your organization Drawbridge is
26
+ always the second), partner or public-app program, OAuth urls and
27
+ transport, token lifetime, refresh, every endpoint a hook will call.
28
+ - **Dashboard docs** (help centre, app UI): the page url for each object
29
+ we will hold an id for (list, segment, tag, contact, order), where the
30
+ vendor says the id appears, what the vendor calls each thing, and what a
31
+ disconnect actually does on their side. This read fills `urls.*` and the
32
+ merchant copy. A vendor whose UI urls are not documented gets an
33
+ observed form, marked observed, confirmed on dev before promote.
34
+ `review.api` and `review.dashboard` take the urls read.
35
+
36
+ 2. **Scope for the whole manifest, not today's hooks.** List every scope the
37
+ vendor offers with its url in `review.scopes`, then request every scope any
38
+ hook in the contract could need for this vendor — contacts, segments,
39
+ tags, custom fields, lists, webhooks — whether or not that hook ships
40
+ now. Scopes are app-level at Klaviyo and Attentive: adding one later costs
41
+ every connected merchant a re-consent. "Minimum for what I built today" is
42
+ the wrong minimum.
43
+
44
+ 3. **Pick `auth.type` from the vendor's own framing:**
43
45
 
44
46
  | vendor's shape | type |
45
47
  |---|---|
@@ -48,32 +50,53 @@ OAuth is a rebuild of the auth surface, not a tweak).
48
50
  | the vendor offers ONLY per-account credentials | `keys` |
49
51
  | no third party behind it at all | `generated` |
50
52
 
51
- `keys` when OAuth exists is a decision someone must make out loud usually
52
- as an explicitly-flagged interim while a partner registration is pending, and
53
- the manifest header says so.
54
-
55
- 3. **If the app is not yet registered, build it dormant.** `requires` names the
56
- client env vars that will exist after registration; until they land on a
57
- deployment, the connection is offered nowhere. Shipping the manifest complete
58
- and inert beats waiting adding the env vars becomes the launch.
59
-
60
- 4. **Record the assessment in the manifest header**: every cited fact, every
61
- contradiction between the vendor's own pages (cite both and say which one the
62
- code models), and for OAuth the questions only a live registration
63
- answers: does consent echo `state` back, what is the real token lifetime,
64
- does one app accept both environments' redirect urls.
65
-
66
- 5. **Build against the checklist** in
67
- `drawbridge-docs/reference/connection-hooks.md` "Adding a connection". Its
68
- guards are the enforcement: the directory-registration test, the
69
- feature-grant test, `build()` itself. Trust the named failures over your
53
+ `keys` is only for a vendor that offers nothing else to third parties.
54
+ Where OAuth exists, build OAuth; a registration that has not landed ships
55
+ the manifest dormant (`requires` names the client vars), never as keys.
56
+
57
+ 4. **Implement every hook the vendor can serve.** The only `false` is "the
58
+ vendor has no endpoint for this", with the one-line reason and the page
59
+ that shows the absence. Time is never a reason. "Flip later" is how segment
60
+ labels shipped as tags with no id kept. Every `urls` entry the dashboard
61
+ read found is implemented too.
62
+
63
+ 5. **Write the merchant copy from the two reads, then run the humanizer.**
64
+ Excerpt, description, guide, confirm, error copy: the vendor's own names
65
+ for things, the guide's navigation path from the help centre, and what
66
+ disconnect really does. Then invoke `humanizer:humanizer` on the copy
67
+ before commit and put the date in `review.content`. Not "if time".
68
+
69
+ 6. **Build against the checklist** in
70
+ `drawbridge-docs/reference/connection-hooks.md` "Adding a connection".
71
+ Its guards are the enforcement: directory registration, feature grant,
72
+ `build()`, the `review` contract test. Trust a named failure over your
70
73
  memory of the steps.
71
74
 
75
+ 7. **Walk it on dev, then date `review.verified`.** Connect a real account,
76
+ pick the resource, sync one contact and one opted-out contact, open each
77
+ `urls.*` link. `verified` is `null` on the publish that first carries the
78
+ manifest (the walk needs it deployed) and dated on the next; a manifest
79
+ still `null` at promote does not promote. A green suite is not the walk.
80
+
81
+ ## Checking an existing connection
82
+
83
+ Same steps, read as questions: is each `review` entry a real url or date, do
84
+ the requested scopes cover every hook the contract lists, does each `urls.*`
85
+ entry exist and open, was the copy humanized. A gap is a manifest fix, not a
86
+ note.
87
+
88
+ ## Red flags — stop
89
+
90
+ - "Minimum scopes for now" — re-consent for every merchant later.
91
+ - "Help centre only for the guide" — `urls.*` will be empty.
92
+ - "Humanizer if time" — it is a step with a date, not a polish.
93
+ - "Verified in the header comment" — `review.verified` is the record.
94
+ - "Flip that hook later", "skipped because of the deadline" — nothing is
95
+ skipped. Say the time is short and keep going.
96
+ - "Keys as an interim, OAuth after the demo" — the OAuth build is the work.
97
+
72
98
  ## Notes
73
99
 
74
- - The manifest is the only place vendor facts live. If the assessment finds a
75
- fact that must hold across repos, record it with `drawbridge-record-contract`.
76
- - The picker/search question is part of the assessment: if the vendor's list
77
- endpoint filters by name server-side, the field must NOT declare
78
- `search : false` — Attentive was the first vendor that could, and a test pins
79
- that it stays searchable.
100
+ - A fact that must hold across repos goes through `drawbridge-record-contract`.
101
+ - If the vendor's list endpoint filters by name server-side, the field must
102
+ NOT declare `search : false`; a test pins that for Attentive.
@@ -7,7 +7,7 @@ skill) so it stops living only in tribal memory.
7
7
 
8
8
  ## Shared secrets must match across services
9
9
 
10
- - `OAUTH_TOKEN_HMAC_KEY` must be identical in **drawbridge-api** and **drawbridge-sync** — both
10
+ - `HMAC_OAUTH_TOKEN_KEY` must be identical in **drawbridge-api** and **drawbridge-sync** — both
11
11
  `assertEnv` it at boot. A mismatch silently breaks realtime token verification.
12
12
  - `HMAC_UNSUBSCRIBE_TOKEN_KEY` must be identical in **drawbridge-api** and **drawbridge-sync** —
13
13
  both `assertEnv` it at boot. Sync signs the unsubscribe tokens it builds into notification
@@ -68,13 +68,27 @@ skill) so it stops living only in tribal memory.
68
68
  `lib/suppression.js canSend()` — a new lead-facing send path must call it, or opted-out
69
69
  recipients get contacted (TCPA/CAN-SPAM exposure). Lead-facing = the notification carries
70
70
  `lead`/`workflow`/`connection`; those sends also REQUIRE an org connection (no system-sender
71
- fallback), while org-less system mail (OTC codes, member invites, security notices) keeps the
72
- system sender and is never suppressed. Inbound STOP flows webhooks
71
+ fallback), while system mail (OTC codes, member invites, security notices) keeps the system
72
+ sender and is never suppressed. **System mail is identified by `audience`, not by whether an
73
+ organization is present** — `queue/identity.js` sends member mail WITH an org, and
74
+ `stream/transaction.js` sends AI-balance notices with none. Inbound STOP flows webhooks
73
75
  (`POST /connection/:id/twilio`, signature-verified against the URL built from
74
76
  `APP_CLIENT_WEBHOOKS_URI`) → `buffer` doc → sync drain → platform-global suppression row;
75
77
  api registers the number's SmsUrl at twilio connect (`lib/twilio.js`) and `scripts.js
76
78
  registerTwilioWebhooks` backfills — a deployed webhooks route with unregistered numbers hears
77
79
  nothing, silently.
80
+ - **`audience` picks the FROM-address; `intent` picks the opt-out machinery** (utils ↔ sync ↔
81
+ api). `resolveEmailSender` (sync `queue/notification.js`) resolves verified `identity` row →
82
+ `subAddress( leadSender, organization.shortId )` for lead-facing mail → `accountSender` for
83
+ member and platform mail. The tag is `shortId`, never `subdomain` — a rename and a custom-plan
84
+ downgrade both rewrite subdomain, and the from-address is frozen into delivered mail; api's
85
+ `identityDefaults.from` must tag with the SAME field or the settings screen shows a merchant an
86
+ address their leads never see. Drop the `leadFacing` branch and the fallback is `leadSender || accountSender`
87
+ for everything, so the moment a lead address is configured, Drawbridge's own mail leaves from
88
+ the merchant-facing lead domain — silently, with nothing raised. `subAddress` lives in utils
89
+ `lib/email.js` and is the inverse of `toCanonicalEmail`, which suppression uses to strip the
90
+ same tag; break the round-trip and consent lookups stop matching. api mirrors ONLY the
91
+ lead-facing branch (`identityDefaults.from`). Detail: drawbridge-docs `reference/providers.md`.
78
92
  - **Adding an email/SMS vendor is a two-repo change** (api ↔ sync). A connection is deliverable
79
93
  only if sync implements a sender for its slug — `lib/email.js` (`{ mailchimp, sendgrid }`) and
80
94
  `lib/sms.js` (`{ twilio }`), which `queue/notification.js` reads **directly** as its guard and
@@ -108,6 +122,14 @@ skill) so it stops living only in tribal memory.
108
122
  missing until 2026-08-13 (Shopify renames took 5+ min to reach share pages). Product scope is
109
123
  special: the doc carries no campaign/org refs, so the worker resolves featuring campaigns via
110
124
  `campaign.products[]`, per-page org (canonical product docs can span orgs).
125
+ Since 2026-09-09 share's HTML cache also stores a **404 render** under the same html key,
126
+ for the soft TTL only and never background-revalidated: every miss leaves share's single
127
+ server IP and lands in the api's global per-IP limiter, so a scanner walking a subdomain
128
+ (Sentry DRAWBRIDGE-SHARE-FQ) or a burst on an unpublished slug used to cost an api call per
129
+ request. The seam therefore MUST bust on page **insert** as well as update/delete (sync
130
+ `stream/page.js`), or a page created already published answers "not found" until the entry
131
+ expires. A slug can never carry a dot (`slugify` strips them), which is what lets share's
132
+ proxy refuse extension paths before they reach the api at all.
111
133
  - Scraped-asset file reuse keys on `organization + meta.origin + meta.element` **plus
112
134
  `meta.render` for pipeline-rendered marks** (sync `resolveAsset` writes/queries it; the field
113
135
  and its index live in drawbridge-api `schema/file.js`). `origin` identifies the source asset,
@@ -153,17 +175,34 @@ skill) so it stops living only in tribal memory.
153
175
  would have zeroed every invoice once the products were archived.
154
176
  - **The v2 pricing-plan layer (`bpp_*`, billing cadences) is never touched by script.** Canceling a
155
177
  billing cadence is terminal and unrecoverable. Dashboard, by hand, only.
156
- - **The Shopify meter handle is a config contract no code validates** (shopify ↔ Partner
157
- Dashboard ↔ sync/utils). `SHOPIFY_USAGE_ORDERS_EVENT_HANDLE` in `@drawbridge/shopify`
158
- (`drawbridge-orders`) must exactly match — case-sensitive, handle not display name — a usage
159
- meter on the App Pricing plan version each merchant has APPROVED (meters are never
160
- retroactive). A mismatch, a missing meter, or a merchant on a pre-meter plan still 202s every
161
- event, logs it as a plain app event, and bills nothing; no error surfaces anywhere in code
162
- (the 19/99 write-off). Any change to the handle constant, the Partner Dashboard pricing
163
- config, or a plan version requires verifying a fresh order classifies as a billing event in
164
- Dev Dashboard Logs (type "App billing event"). The trace key both sides share is
165
- `billed.transaction` = the event's `reference` = `drawbridge-orders.<orderId>`. Detail:
166
- drawbridge-docs `reference/shopify-app.md` (Billing).
178
+ - **The Shopify meter handle is manifest data, provider-overridable, and sender-verified
179
+ (≥ utils 0.0.151)** (utils ↔ sync ↔ api ↔ Partner Dashboard). The utils shopify manifest
180
+ publishes `events.order.handle` = `slugify('drawbridge-orders')`; the provider row's
181
+ `orderEventHandle` field (admin-editable, `format:'slug'` normalized on save AND read)
182
+ overrides it at mint time. The order hook puts the effective handle on the billing job and
183
+ mints `billed.transaction` = `<handle>.<orderId>` (also the event's `reference` attribute
184
+ the shared trace key). `sendAppEvent` has NO default handle any more, and sync's sender
185
+ REFUSES to send when the job carries none (`shopify.usage.billing.handle.missing`) or when
186
+ the transaction prefix disagrees (`…handle.mismatch`) both before any provider read, both
187
+ page through the abandoned alert. The handle must still exactly match — case-sensitive,
188
+ handle not display name — a usage meter on the plan version each merchant APPROVED: meters
189
+ attach only at approval and are never retroactive, and a pre-meter approval 202s every event
190
+ and bills nothing (the 19/99 write-off; also every pre-2026-09 production approval).
191
+ - **Shopify billing proof comes only from the Partner ledger; absence is never a verdict**
192
+ (utils ↔ sync ↔ api ↔ app-web). No Admin API version materializes a managed-pricing meter
193
+ (measured 2026-09-04), so `connection.source.metered` is a billing-PROOF reference: the
194
+ latest positive `CHARGE_USAGE` charge id from the Partner API (provider row fields
195
+ `partnerOrgId`/`partnerAppId`/`partnerToken`), `null` only from measured unmetered evidence,
196
+ absent = never proven — and every consumer (task card, checklist item, billing Issue,
197
+ presented-error status, admin badges, App Home error row) gates on the explicit null, never
198
+ on silence. Writers: the daily health run, the approval webhook's instant restamp, and the
199
+ reselect flow's stale-null lift (a completed reselect returns a null-stamped store to
200
+ no-verdict — the null described the approval that was just replaced). Charge rows exist only
201
+ at billing boundaries (usage accrues invisibly between them); each row is reconciled once
202
+ against its window's orders — balanced stamps `billed.receipt`, a mismatch pages
203
+ `shopify.usage.reconciliation.mismatch`, and admin-voided orders (`billed.void`, the
204
+ write-off for pre-meter losses) leave both the sums and the dashboard's unreconciled count.
205
+ Detail: drawbridge-docs `reference/shopify-app.md` (Billing).
167
206
  - **A subscription must open with the plan item.** It used to open with the metered actions price
168
207
  and attach the plan afterwards with `always_invoice`. With no metered item, Stripe rejects an
169
208
  itemless subscription, so the plan goes in at creation — changing the first invoice's shape.
@@ -190,6 +229,14 @@ skill) so it stops living only in tribal memory.
190
229
  sync upserts the same invoice from three concurrent webhooks (finalized/payment_succeeded/voided)
191
230
  and relies on the index to reject the losing insert so the catch can redo it as an update. Drop
192
231
  the index → silent duplicate invoice rows; remove the catch → concurrent events throw + retry.
232
+ - The `buffer` collection's **unique partial index on Stripe `provider.id`** (drawbridge-api
233
+ `schema/buffer.js`, Stripe rows with a string id only) and webhooks' E11000 catch in
234
+ `route/stripe.js` are one contract: the route dedupes a Stripe redelivery with a read before its
235
+ insert, two retries arriving together both pass that read, and the index refuses the second
236
+ insert, which the route answers 200 without touching the first row. Drop the index → the race
237
+ reopens silently and sync processes one event twice; remove the catch → a concurrent retry 500s
238
+ and Stripe keeps retrying. The index is manual (the resetIndexes block is the runbook) and its
239
+ creation fails while duplicates exist — dedupe first.
193
240
  - The `contact` collection's **unique multikey index on `leads`** (drawbridge-api
194
241
  `schema/contact.js`) and sync's contact resolution (`lib/contact.js` + `queue/contact.js`) are
195
242
  one contract: a lead belongs to at most one contact. One submission enqueues two resolve jobs
@@ -438,6 +485,18 @@ sweep (`queue/identity.js`), which re-points any number whose Twilio config drif
438
485
  the path in one place and the sweep "repairs" every number to the wrong URL — inbound SMS
439
486
  (STOP included) goes dark with all rows reading healthy.
440
487
 
488
+ **Provider rows are keyed by VENDOR, and no manifest owns a vendor (utils ↔ api ↔ sync ↔
489
+ webhooks, since utils 0.0.153).** A manifest declares only the fields it spends under
490
+ `provider.vendors.<vendor>`; `mergeVendors()` in utils unions every manifest's slices into
491
+ one entry per vendor, and the `provider` collection holds one row per key of that merge —
492
+ `sendgrid`, `twilio`, `hubspot` for the private `drawbridge` connection, one `google` row for
493
+ however many Google connections come. Build refuses a vendor drawn two ways, a shared field
494
+ declared differently, and a credential name claimed twice. A connection reads its vendors
495
+ merged through `vendorSettings({ controller, slug })` (sync's `drawbridgeProvider()`, the
496
+ api's identity/organization routes); the merged keys keep the old names. Reading
497
+ `providerSettings({ slug : 'drawbridge' })` anywhere finds no row — it was split on dev
498
+ 2026-09-08 and prod is seeded per-vendor. Detail: `drawbridge-docs/reference/providers.md`.
499
+
441
500
  ## Import surfaces
442
501
 
443
502
  - Import names against a package's **actual exports** — a missing export resolves to `undefined`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drawbridge/drawbridge-agents",
3
- "version": "0.1.50",
3
+ "version": "0.1.55",
4
4
  "description": "Shared agent-instruction content (rules, code style, conventions) for the drawbridge-* monorepo.",
5
5
  "license": "UNLICENSED",
6
6
  "publishConfig": {