@drawbridge/drawbridge-agents 0.1.30 → 0.1.31

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.
@@ -226,6 +226,20 @@ skill) so it stops living only in tribal memory.
226
226
  - The `write_resource_feedbacks` and `read_publications` scopes live in drawbridge-shopify-app's
227
227
  `shopify.app.*.toml` but power other repos: sync's product ResourceFeedback sends and the
228
228
  embedded app's publishing section. Removing either → silent Shopify userErrors, no crash.
229
+ - **No Shopify webhook reaches the embedded app.** Every `[[webhooks.subscriptions]]` in
230
+ drawbridge-shopify-app's `shopify.app.*.toml` — `app/uninstalled` included — is delivered to
231
+ drawbridge-webhooks (`route/shopify.js`, topic-agnostic → `buffer` doc), which drawbridge-sync
232
+ consumes (`lib/buffer.js` `shopify.app/uninstalled` → delete connections, hard-delete the `shop`
233
+ record). So `app/routes/webhooks.app.uninstalled.tsx` is unreachable and **nothing in the
234
+ embedded app may hold per-shop state that needs invalidating on uninstall** — no memo, no cache,
235
+ no "already done" set. Violated once: `ensureChannel` cached "this shop is bound to our channel
236
+ spec" in a process-local Set cleared only by that dead route, so uninstall → reinstall skipped
237
+ `channelCreate` and the merchant silently lost the Drawbridge sales channel (2026-08-07 QA).
238
+ - Shopify Admin GraphQL failures come in **two shapes**, and sales-channel code must handle both:
239
+ domain failures land in `userErrors` (HTTP 200), while a revoked/expired offline token,
240
+ throttling, or a field missing on the API version land in a **top-level `errors`** — a bare
241
+ string for a 401, an array of objects otherwise — with a null `data`. Reading only `userErrors`
242
+ turns an auth failure into a silent success.
229
243
  - `SHOPIFY_REQUIRED_SCOPES` (`@drawbridge/shopify` `lib/constants.js`) must stay in lockstep with
230
244
  `[access_scopes]` in drawbridge-shopify-app's `shopify.app.*.toml` (both files). drawbridge-api
231
245
  diffs each store's granted scopes against it to flag "update permissions" on the connections
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drawbridge/drawbridge-agents",
3
- "version": "0.1.30",
3
+ "version": "0.1.31",
4
4
  "description": "Shared agent-instruction content (rules, code style, conventions) for the drawbridge-* monorepo.",
5
5
  "license": "UNLICENSED",
6
6
  "publishConfig": {
@@ -0,0 +1,88 @@
1
+ // What communication channels are actually flowing, HubSpot's count against ours.
2
+ //
3
+ // Exists because "the integration is connected" and "the data is arriving" are
4
+ // different claims, and the second is the only one that matters here. HubSpot's
5
+ // communication object holds SMS, WhatsApp and LinkedIn messages, told apart by
6
+ // channel — so whatever writes LinkedIn messages into that object, this pipeline
7
+ // reads. The question is never whether we can read them; it is whether anything is
8
+ // writing them.
9
+ //
10
+ // Same shape as the `missing` figure on the contact ingest, which is what caught 50
11
+ // contacts that had been silently absent for the life of the project.
12
+
13
+ const { growth } = require( '../lib/mongodb' );
14
+ const { enabled, request } = require( '../lib/hubspot' );
15
+ const { run } = require( '../lib/cycle' );
16
+
17
+ // The channels HubSpot files under the communication object. PHYSICAL_MAIL is
18
+ // hidden on this portal but counted anyway, because a hidden property still holds
19
+ // records.
20
+ const CHANNELS = [
21
+ 'LINKEDIN_MESSAGE',
22
+ 'SMS',
23
+ 'WHATS_APP',
24
+ 'PHYSICAL_MAIL',
25
+ 'CUSTOM_CHANNEL_CONVERSATION'
26
+ ];
27
+
28
+ run( 'channels', async () => {
29
+
30
+ if( ! enabled() ) return { skipped : 'HUBSPOT_ACCESS_TOKEN not set' };
31
+
32
+ const { db, database } = await growth();
33
+
34
+ const report = {};
35
+
36
+ for( const channel of CHANNELS ){
37
+
38
+ const body = await request({
39
+ body : {
40
+ filterGroups : [
41
+ {
42
+ filters : [
43
+ {
44
+ operator : 'EQ',
45
+ propertyName : 'hs_communication_channel_type',
46
+ value : channel
47
+ }
48
+ ]
49
+ }
50
+ ],
51
+ limit : 1
52
+ },
53
+ method : 'POST',
54
+ path : '/crm/v3/objects/communications/search'
55
+ });
56
+
57
+ const inHubspot = Number( body?.total || 0 );
58
+
59
+ // Ours is counted on the touch, which is one row per contact on the message,
60
+ // so it can legitimately exceed HubSpot's count of messages.
61
+ const held = await database.collection( 'touch' ).countDocuments({
62
+ channel,
63
+ type : 'communication'
64
+ });
65
+
66
+ report[ channel ] = {
67
+ held,
68
+ inHubspot,
69
+ // The number that answers "is anything writing this channel".
70
+ missing : inHubspot === 0 ? 0 : Math.max( 0, inHubspot - held )
71
+ };
72
+
73
+ }
74
+
75
+ const flowing = Object.entries( report )
76
+ .filter( ( [ , value ] ) => value.inHubspot > 0 )
77
+ .map( ( [ key ] ) => key );
78
+
79
+ return {
80
+ byChannel : report,
81
+ // Empty means nothing outside email, calls, meetings, notes and tasks is
82
+ // reaching HubSpot at all - which is a setup answer, not a pipeline answer.
83
+ flowing : flowing.length ? flowing : 'nothing beyond email/calls/meetings/notes/tasks',
84
+ note : 'LINKEDIN_MESSAGE at 0 means no tool is writing LinkedIn messages into'
85
+ + ' HubSpot. Whatever writes them, this pipeline already reads them.'
86
+ };
87
+
88
+ });