@graph8/sdk 0.361.0 → 0.370.0

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.
Files changed (388) hide show
  1. package/README.md +14 -10
  2. package/blocks/_foundation/README.md +172 -1
  3. package/blocks/_foundation/block.json +234 -13
  4. package/blocks/_foundation/components/api-error.tsx +71 -25
  5. package/blocks/_foundation/components/brand-provider.tsx +23 -0
  6. package/blocks/_foundation/components/confirmed-action.tsx +90 -0
  7. package/blocks/_foundation/components/connect-graph8.tsx +280 -0
  8. package/blocks/_foundation/components/empty-state.tsx +39 -7
  9. package/blocks/_foundation/components/screen/bar-list.tsx +71 -0
  10. package/blocks/_foundation/components/screen/detail-panel.tsx +114 -0
  11. package/blocks/_foundation/components/screen/entity-avatar.tsx +56 -0
  12. package/blocks/_foundation/components/screen/form-sheet.tsx +73 -0
  13. package/blocks/_foundation/components/screen/index.ts +22 -0
  14. package/blocks/_foundation/components/screen/key-value-list.tsx +65 -0
  15. package/blocks/_foundation/components/screen/record-link.tsx +76 -0
  16. package/blocks/_foundation/components/screen/screen-layout.tsx +124 -0
  17. package/blocks/_foundation/components/screen/section-card.tsx +47 -0
  18. package/blocks/_foundation/components/screen/sparkline.tsx +77 -0
  19. package/blocks/_foundation/components/screen/split-view.tsx +125 -0
  20. package/blocks/_foundation/components/screen/stat-strip.tsx +121 -0
  21. package/blocks/_foundation/components/screen/status-pill.tsx +104 -0
  22. package/blocks/_foundation/components/screen/time-series-chart-recharts.tsx +65 -0
  23. package/blocks/_foundation/components/screen/time-series-chart.tsx +32 -0
  24. package/blocks/_foundation/components/screen/time-series-format.ts +57 -0
  25. package/blocks/_foundation/components/screen/toolbar.tsx +144 -0
  26. package/blocks/_foundation/hooks/use-brand.ts +52 -0
  27. package/blocks/_foundation/hooks/use-g8-mutation.ts +23 -0
  28. package/blocks/_foundation/hooks/use-g8-query.ts +127 -1
  29. package/blocks/_foundation/hooks/use-paginated.ts +120 -0
  30. package/blocks/_foundation/hooks/use-row-activation.ts +57 -0
  31. package/blocks/_foundation/hooks/use-url-state.ts +74 -0
  32. package/blocks/_foundation/lib/g8-cache.ts +454 -0
  33. package/blocks/_foundation/lib/g8-client.ts +1 -1
  34. package/blocks/_foundation/lib/plural.ts +23 -0
  35. package/blocks/_foundation/server/connect-route.ts +15 -0
  36. package/blocks/_foundation/server/connect.ts +127 -0
  37. package/blocks/_foundation/server/local-key.ts +70 -0
  38. package/blocks/_foundation/server/proxy.ts +103 -3
  39. package/blocks/_foundation/server/route.ts +16 -4
  40. package/blocks/agents/README.md +14 -0
  41. package/blocks/agents/block.json +33 -1
  42. package/blocks/agents/components/agent-builder.tsx +13 -8
  43. package/blocks/agents/components/agent-knowledge-panel.tsx +2 -1
  44. package/blocks/agents/components/agent-run-detail.tsx +102 -0
  45. package/blocks/agents/components/agent-runs.tsx +16 -7
  46. package/blocks/agents/components/agents-screen.tsx +184 -0
  47. package/blocks/analytics/README.md +13 -0
  48. package/blocks/analytics/block.json +45 -1
  49. package/blocks/analytics/components/analytics-screen.tsx +209 -0
  50. package/blocks/analytics/components/leaderboard.tsx +14 -7
  51. package/blocks/analytics/components/report-builder.tsx +23 -9
  52. package/blocks/analytics/components/report-detail.tsx +84 -0
  53. package/blocks/analytics/components/trend-chart.tsx +62 -0
  54. package/blocks/appointments/README.md +21 -1
  55. package/blocks/appointments/block.json +43 -2
  56. package/blocks/appointments/components/availability-editor.tsx +17 -5
  57. package/blocks/appointments/components/event-type-editor.tsx +2 -1
  58. package/blocks/appointments/components/event-type-related.tsx +77 -0
  59. package/blocks/appointments/components/meeting-list.tsx +14 -6
  60. package/blocks/appointments/components/meetings-screen.tsx +157 -0
  61. package/blocks/appointments/components/scheduling-screen.tsx +122 -0
  62. package/blocks/appointments/hooks/use-event-types.ts +17 -0
  63. package/blocks/block.schema.json +18 -0
  64. package/blocks/block.types.ts +26 -0
  65. package/blocks/campaigns/README.md +41 -11
  66. package/blocks/campaigns/block.json +105 -42
  67. package/blocks/campaigns/components/campaign-assets.tsx +16 -5
  68. package/blocks/campaigns/components/campaign-launch-step.tsx +102 -19
  69. package/blocks/campaigns/components/campaign-list.tsx +7 -3
  70. package/blocks/campaigns/components/campaign-wizard.tsx +43 -10
  71. package/blocks/campaigns/components/campaigns-screen.tsx +267 -0
  72. package/blocks/campaigns/components/icp-detail.tsx +154 -0
  73. package/blocks/campaigns/components/icp-editor.tsx +15 -15
  74. package/blocks/campaigns/components/persona-detail.tsx +172 -0
  75. package/blocks/campaigns/components/persona-editor.tsx +14 -11
  76. package/blocks/campaigns/lib/campaigns-utils.ts +134 -1
  77. package/blocks/campaigns/recipes/attach-audience-and-launch.ts +16 -3
  78. package/blocks/campaigns/recipes/generate-ideas-and-create-campaign.ts +8 -4
  79. package/blocks/coach/README.md +10 -0
  80. package/blocks/coach/block.json +16 -1
  81. package/blocks/coach/components/coach-screen.tsx +222 -0
  82. package/blocks/copilot/README.md +13 -0
  83. package/blocks/copilot/block.json +14 -1
  84. package/blocks/copilot/components/copilot-panel.tsx +14 -5
  85. package/blocks/copilot/components/copilot-screen.tsx +148 -0
  86. package/blocks/copilot/components/copilot-team-chat.tsx +7 -5
  87. package/blocks/crm/README.md +28 -0
  88. package/blocks/crm/block.json +58 -2
  89. package/blocks/crm/components/companies-screen.tsx +345 -0
  90. package/blocks/crm/components/company-detail.tsx +8 -6
  91. package/blocks/crm/components/company-table.tsx +11 -4
  92. package/blocks/crm/components/contact-detail.tsx +6 -3
  93. package/blocks/crm/components/contact-table.tsx +10 -3
  94. package/blocks/crm/components/contacts-screen.tsx +359 -0
  95. package/blocks/crm/components/dedupe-review.tsx +7 -5
  96. package/blocks/crm/components/import-wizard.tsx +4 -2
  97. package/blocks/crm/components/notes-panel.tsx +4 -4
  98. package/blocks/crm/components/record-form.tsx +5 -3
  99. package/blocks/crm/components/task-list.tsx +4 -4
  100. package/blocks/crm/components/tasks-screen.tsx +379 -0
  101. package/blocks/crm/hooks/use-custom-fields.ts +6 -1
  102. package/blocks/crm/hooks/use-page-query.ts +7 -1
  103. package/blocks/crm/lib/crm-types.ts +12 -2
  104. package/blocks/crm/lib/screen-parts.tsx +105 -0
  105. package/blocks/deals/README.md +34 -3
  106. package/blocks/deals/block.json +45 -4
  107. package/blocks/deals/components/deal-detail.tsx +27 -13
  108. package/blocks/deals/components/deal-related.tsx +87 -0
  109. package/blocks/deals/components/deals-screen.tsx +298 -0
  110. package/blocks/deals/components/pipeline-board.tsx +4 -2
  111. package/blocks/deals/components/stage-editor.tsx +4 -4
  112. package/blocks/deals/hooks/use-default-owner.ts +60 -0
  113. package/blocks/deals/hooks/use-pipeline-deals.ts +5 -1
  114. package/blocks/deals/hooks/use-pipelines.ts +1 -1
  115. package/blocks/deals/lib/deals-types.ts +14 -0
  116. package/blocks/dialer/README.md +34 -2
  117. package/blocks/dialer/block.json +67 -1
  118. package/blocks/dialer/components/call-contact-button.tsx +252 -0
  119. package/blocks/dialer/components/call-log-screen.tsx +205 -0
  120. package/blocks/dialer/components/call-log.tsx +7 -3
  121. package/blocks/dialer/components/call-queue.tsx +1 -1
  122. package/blocks/dialer/components/dialer-screen.tsx +319 -0
  123. package/blocks/dialer/components/dialer-session-detail.tsx +153 -0
  124. package/blocks/dialer/components/live-calls-table.tsx +7 -3
  125. package/blocks/dialer/components/session-start-form.tsx +4 -2
  126. package/blocks/dialer/components/softphone.tsx +3 -1
  127. package/blocks/enrichment/README.md +14 -2
  128. package/blocks/enrichment/block.json +32 -4
  129. package/blocks/enrichment/components/bulk-enrich-panel.tsx +10 -5
  130. package/blocks/enrichment/components/email-verify-badge.tsx +28 -8
  131. package/blocks/enrichment/components/enrich-button.tsx +32 -9
  132. package/blocks/enrichment/components/enrichment-job-detail.tsx +78 -0
  133. package/blocks/enrichment/components/enrichment-screen.tsx +288 -0
  134. package/blocks/enrichment/components/waterfall-config.tsx +39 -10
  135. package/blocks/enrichment/recipes/waterfall-pipeline.ts +3 -2
  136. package/blocks/forms/README.md +12 -0
  137. package/blocks/forms/block.json +39 -17
  138. package/blocks/forms/components/form-builder.tsx +3 -1
  139. package/blocks/forms/components/form-embed.tsx +3 -1
  140. package/blocks/forms/components/forms-screen.tsx +353 -0
  141. package/blocks/forms/components/submissions-table.tsx +16 -6
  142. package/blocks/inbox/README.md +27 -3
  143. package/blocks/inbox/block.json +14 -2
  144. package/blocks/inbox/components/inbox-list.tsx +7 -3
  145. package/blocks/inbox/components/inbox-screen.tsx +294 -27
  146. package/blocks/inbox/components/reply-composer.tsx +65 -9
  147. package/blocks/inbox/components/tag-and-assign.tsx +4 -2
  148. package/blocks/inbox/hooks/use-inbox-thread.ts +1 -1
  149. package/blocks/inbox/hooks/use-inbox-threads.ts +1 -0
  150. package/blocks/inbox/lib/inbox-types.ts +47 -1
  151. package/blocks/inbox/recipes/01-triage-and-reply.ts +12 -2
  152. package/blocks/integrations/README.md +9 -0
  153. package/blocks/integrations/block.json +46 -1
  154. package/blocks/integrations/components/crm-detail.tsx +100 -0
  155. package/blocks/integrations/components/integration-list.tsx +9 -4
  156. package/blocks/integrations/components/integrations-screen.tsx +135 -0
  157. package/blocks/integrations/components/sync-status.tsx +5 -3
  158. package/blocks/integrations/components/webhook-detail.tsx +160 -0
  159. package/blocks/integrations/components/webhook-manager.tsx +13 -8
  160. package/blocks/integrations/lib/integrations-utils.ts +6 -1
  161. package/blocks/intent/README.md +14 -0
  162. package/blocks/intent/block.json +27 -1
  163. package/blocks/intent/components/intent-feed.tsx +5 -2
  164. package/blocks/intent/components/intent-keyword-manager.tsx +8 -4
  165. package/blocks/intent/components/intent-screen.tsx +265 -0
  166. package/blocks/intent/components/keyword-detail.tsx +124 -0
  167. package/blocks/lifecycle/README.md +16 -0
  168. package/blocks/lifecycle/block.json +34 -7
  169. package/blocks/lifecycle/components/account-related.tsx +66 -0
  170. package/blocks/lifecycle/components/lifecycle-board.tsx +3 -1
  171. package/blocks/lifecycle/components/lifecycle-screen.tsx +149 -0
  172. package/blocks/lifecycle/lib/lifecycle.ts +1 -1
  173. package/blocks/lists/README.md +13 -0
  174. package/blocks/lists/block.json +39 -1
  175. package/blocks/lists/components/add-to-list-button.tsx +3 -2
  176. package/blocks/lists/components/list-picker.tsx +5 -1
  177. package/blocks/lists/components/list-table.tsx +11 -5
  178. package/blocks/lists/components/lists-screen.tsx +344 -0
  179. package/blocks/lists/components/suppression-manager.tsx +6 -2
  180. package/blocks/lists/components/suppressions-screen.tsx +255 -0
  181. package/blocks/mailboxes/README.md +17 -0
  182. package/blocks/mailboxes/block.json +65 -17
  183. package/blocks/mailboxes/components/connect-mailbox.tsx +23 -13
  184. package/blocks/mailboxes/components/domain-detail.tsx +142 -0
  185. package/blocks/mailboxes/components/domain-purchase.tsx +6 -3
  186. package/blocks/mailboxes/components/mailbox-detail.tsx +156 -0
  187. package/blocks/mailboxes/components/mailboxes-screen.tsx +223 -0
  188. package/blocks/mailboxes/components/warmup-health.tsx +4 -2
  189. package/blocks/mailboxes/lib/mailboxes-utils.ts +22 -1
  190. package/blocks/newsletter/README.md +13 -0
  191. package/blocks/newsletter/block.json +47 -23
  192. package/blocks/newsletter/components/issue-scheduler.tsx +2 -1
  193. package/blocks/newsletter/components/newsletter-screen.tsx +404 -0
  194. package/blocks/newsletter/components/subscriber-table.tsx +12 -6
  195. package/blocks/nurture/README.md +12 -0
  196. package/blocks/nurture/block.json +35 -13
  197. package/blocks/nurture/components/nurture-builder.tsx +3 -1
  198. package/blocks/nurture/components/nurture-enroll.tsx +6 -3
  199. package/blocks/nurture/components/nurture-form.tsx +23 -3
  200. package/blocks/nurture/components/nurture-screen.tsx +283 -0
  201. package/blocks/nurture/lib/nurture-types.ts +1 -1
  202. package/blocks/objects/README.md +17 -0
  203. package/blocks/objects/block.json +29 -16
  204. package/blocks/objects/components/object-not-found.tsx +3 -1
  205. package/blocks/objects/components/object-record-detail.tsx +2 -1
  206. package/blocks/objects/components/object-schema-editor.tsx +3 -1
  207. package/blocks/objects/components/object-table.tsx +13 -3
  208. package/blocks/objects/components/objects-screen.tsx +147 -0
  209. package/blocks/objects/lib/object-types.ts +1 -1
  210. package/blocks/pages/README.md +12 -0
  211. package/blocks/pages/block.json +35 -22
  212. package/blocks/pages/components/brand-kit-editor.tsx +5 -2
  213. package/blocks/pages/components/content-library.tsx +46 -11
  214. package/blocks/pages/components/page-list.tsx +6 -3
  215. package/blocks/pages/components/pages-screen.tsx +154 -0
  216. package/blocks/quotes/README.md +17 -0
  217. package/blocks/quotes/block.json +38 -9
  218. package/blocks/quotes/components/product-picker.tsx +3 -1
  219. package/blocks/quotes/components/quote-list.tsx +7 -3
  220. package/blocks/quotes/components/quote-related.tsx +123 -0
  221. package/blocks/quotes/components/quotes-screen.tsx +169 -0
  222. package/blocks/radar/README.md +10 -0
  223. package/blocks/radar/block.json +41 -1
  224. package/blocks/radar/components/competitor-detail.tsx +117 -0
  225. package/blocks/radar/components/competitor-tracker.tsx +3 -1
  226. package/blocks/radar/components/radar-report.tsx +4 -2
  227. package/blocks/radar/components/radar-screen.tsx +214 -0
  228. package/blocks/radar/components/report-detail.tsx +104 -0
  229. package/blocks/search/README.md +10 -0
  230. package/blocks/search/block.json +16 -1
  231. package/blocks/search/components/filter-builder.tsx +3 -3
  232. package/blocks/search/components/prospect-results.tsx +5 -3
  233. package/blocks/search/components/prospect-search-screen.tsx +235 -0
  234. package/blocks/search/components/saved-searches.tsx +4 -1
  235. package/blocks/search/hooks/use-filter-options.ts +20 -6
  236. package/blocks/search/lib/search-types.ts +2 -0
  237. package/blocks/sequencer/README.md +49 -9
  238. package/blocks/sequencer/block.json +99 -19
  239. package/blocks/sequencer/components/enroll-dialog.tsx +9 -3
  240. package/blocks/sequencer/components/sequence-builder.tsx +211 -46
  241. package/blocks/sequencer/components/sequence-detail.tsx +259 -0
  242. package/blocks/sequencer/components/sequence-enrollments.tsx +114 -0
  243. package/blocks/sequencer/components/sequence-list.tsx +33 -8
  244. package/blocks/sequencer/components/sequence-stats.tsx +3 -2
  245. package/blocks/sequencer/components/sequence-steps-panel.tsx +103 -0
  246. package/blocks/sequencer/components/sequences-screen.tsx +159 -0
  247. package/blocks/sequencer/components/step-editor.tsx +3 -1
  248. package/blocks/sequencer/lib/sequencer-utils.ts +128 -5
  249. package/blocks/sequencer/recipes/create-multichannel-sequence.ts +33 -5
  250. package/blocks/sequencer/recipes/launch-and-enroll.ts +19 -2
  251. package/blocks/social-ads/README.md +11 -0
  252. package/blocks/social-ads/block.json +101 -2
  253. package/blocks/social-ads/components/ad-campaign-list.tsx +9 -5
  254. package/blocks/social-ads/components/ads-screen.tsx +152 -0
  255. package/blocks/social-ads/components/audience-sync-detail.tsx +188 -0
  256. package/blocks/social-ads/components/audience-sync.tsx +11 -7
  257. package/blocks/social-ads/components/campaign-detail.tsx +133 -0
  258. package/blocks/social-ads/components/creative-detail.tsx +150 -0
  259. package/blocks/social-ads/components/creative-gallery.tsx +15 -5
  260. package/blocks/social-ads/components/post-composer.tsx +3 -1
  261. package/blocks/social-ads/components/post-detail.tsx +160 -0
  262. package/blocks/social-ads/components/social-post-list.tsx +11 -3
  263. package/blocks/social-ads/components/social-screen.tsx +160 -0
  264. package/blocks/visitors/README.md +10 -0
  265. package/blocks/visitors/block.json +25 -1
  266. package/blocks/visitors/components/snippet-install.tsx +3 -1
  267. package/blocks/visitors/components/visitor-company-card.tsx +2 -2
  268. package/blocks/visitors/components/visitor-detail.tsx +105 -0
  269. package/blocks/visitors/components/visitor-feed.tsx +11 -2
  270. package/blocks/visitors/components/visitors-screen.tsx +166 -0
  271. package/blocks/webchat/README.md +10 -0
  272. package/blocks/webchat/block.json +21 -7
  273. package/blocks/webchat/components/chat-widget-config.tsx +7 -4
  274. package/blocks/webchat/components/conversation-list.tsx +7 -3
  275. package/blocks/webchat/components/webchat-screen.tsx +245 -0
  276. package/blocks/workbench/README.md +11 -0
  277. package/blocks/workbench/block.json +48 -1
  278. package/blocks/workbench/components/column-recipe-editor.tsx +4 -2
  279. package/blocks/workbench/components/workbench-grid.tsx +9 -3
  280. package/blocks/workbench/components/workbench-row-detail.tsx +104 -0
  281. package/blocks/workbench/components/workbench-screen.tsx +192 -0
  282. package/blocks/workbench/components/workbench-sources.tsx +194 -0
  283. package/blocks/workflows/README.md +13 -0
  284. package/blocks/workflows/block.json +28 -9
  285. package/blocks/workflows/components/run-history.tsx +31 -9
  286. package/blocks/workflows/components/trigger-config.tsx +3 -1
  287. package/blocks/workflows/components/workflow-editor.tsx +3 -1
  288. package/blocks/workflows/components/workflow-list.tsx +13 -6
  289. package/blocks/workflows/components/workflows-screen.tsx +193 -0
  290. package/dist/cli.js +2785 -1637
  291. package/dist/errors.d.mts +34 -0
  292. package/dist/errors.d.ts +34 -0
  293. package/dist/errors.js +46 -0
  294. package/dist/errors.js.map +1 -0
  295. package/dist/errors.mjs +21 -0
  296. package/dist/errors.mjs.map +1 -0
  297. package/dist/index.browser.js +11 -5
  298. package/dist/index.browser.js.map +1 -1
  299. package/dist/index.browser.mjs +11 -5
  300. package/dist/index.browser.mjs.map +1 -1
  301. package/dist/index.d.mts +164 -25
  302. package/dist/index.d.ts +164 -25
  303. package/dist/index.js +11 -5
  304. package/dist/index.js.map +1 -1
  305. package/dist/index.mjs +11 -5
  306. package/dist/index.mjs.map +1 -1
  307. package/dist/react.browser.js +10 -4
  308. package/dist/react.browser.js.map +1 -1
  309. package/dist/react.browser.mjs +10 -4
  310. package/dist/react.browser.mjs.map +1 -1
  311. package/dist/react.d.mts +142 -5
  312. package/dist/react.d.ts +142 -5
  313. package/dist/react.js +10 -4
  314. package/dist/react.js.map +1 -1
  315. package/dist/react.mjs +10 -4
  316. package/dist/react.mjs.map +1 -1
  317. package/dist/types/blocks/add.d.ts +11 -4
  318. package/dist/types/blocks/brand.d.ts +113 -0
  319. package/dist/types/blocks/compose.d.ts +53 -0
  320. package/dist/types/blocks/create.d.ts +25 -8
  321. package/dist/types/blocks/doctor.d.ts +6 -1
  322. package/dist/types/blocks/flows.d.ts +20 -0
  323. package/dist/types/blocks/navfile.d.ts +35 -0
  324. package/dist/types/blocks/paths.d.ts +4 -0
  325. package/dist/types/blocks/prefixes.d.ts +37 -0
  326. package/dist/types/blocks/registry.d.ts +32 -0
  327. package/dist/types/blocks/screens.d.ts +53 -0
  328. package/dist/types/cli.d.ts +2 -0
  329. package/dist/types/errors.d.ts +33 -0
  330. package/dist/types/http.d.ts +2 -18
  331. package/dist/types/version.d.ts +2 -2
  332. package/docs/agents.md +15 -4
  333. package/docs/api/README.md +2 -2
  334. package/docs/api/by-path.md +1 -0
  335. package/docs/api/desk.d.ts +98 -2
  336. package/docs/api/desk.md +123 -1
  337. package/docs/api/inbox.d.ts +10 -2
  338. package/docs/api/inbox.md +10 -2
  339. package/docs/api/seo.d.ts +19 -1
  340. package/docs/api/seo.md +24 -1
  341. package/package.json +16 -2
  342. package/stacks/6sense.json +41 -16
  343. package/stacks/apollo/pages/companies.tsx.tmpl +50 -0
  344. package/stacks/apollo/pages/contacts.tsx.tmpl +117 -0
  345. package/stacks/apollo.json +118 -36
  346. package/stacks/calendly.json +60 -0
  347. package/stacks/clay/pages/companies.tsx.tmpl +15 -0
  348. package/stacks/clay/pages/contacts.tsx.tmpl +19 -0
  349. package/stacks/clay.json +59 -26
  350. package/stacks/gong.json +81 -35
  351. package/stacks/hootsuite.json +99 -0
  352. package/stacks/hubspot/pages/companies.tsx.tmpl +47 -0
  353. package/stacks/hubspot/pages/contacts.tsx.tmpl +111 -0
  354. package/stacks/hubspot/pages/deals.tsx.tmpl +11 -0
  355. package/stacks/hubspot/pages/inbox.tsx.tmpl +92 -0
  356. package/stacks/hubspot/pages/quotes.tsx.tmpl +11 -0
  357. package/stacks/hubspot.json +167 -66
  358. package/stacks/intercom.json +48 -20
  359. package/stacks/mailchimp.json +52 -33
  360. package/stacks/outreach/pages/contacts.tsx.tmpl +85 -0
  361. package/stacks/outreach.json +88 -25
  362. package/stacks/pipedrive/pages/companies.tsx.tmpl +47 -0
  363. package/stacks/pipedrive/pages/contacts.tsx.tmpl +47 -0
  364. package/stacks/pipedrive/pages/deals.tsx.tmpl +11 -0
  365. package/stacks/pipedrive/pages/inbox.tsx.tmpl +92 -0
  366. package/stacks/pipedrive/pages/quotes.tsx.tmpl +11 -0
  367. package/stacks/pipedrive.json +115 -0
  368. package/stacks/salesforce/pages/companies.tsx.tmpl +47 -0
  369. package/stacks/salesforce/pages/contacts.tsx.tmpl +47 -0
  370. package/stacks/salesforce/pages/deals.tsx.tmpl +11 -0
  371. package/stacks/salesforce/pages/quotes.tsx.tmpl +11 -0
  372. package/stacks/salesforce.json +81 -31
  373. package/stacks/stack.schema.json +6 -1
  374. package/stacks/zoominfo/pages/companies.tsx.tmpl +15 -0
  375. package/stacks/zoominfo/pages/contacts.tsx.tmpl +19 -0
  376. package/stacks/zoominfo.json +102 -0
  377. package/styles/styles.css +1 -1
  378. package/templates/next-app/README.md.tmpl +55 -8
  379. package/templates/next-app/app/globals.css.tmpl +105 -0
  380. package/templates/next-app/app/layout.tsx.tmpl +18 -4
  381. package/templates/next-app/components/app-shell.tsx.tmpl +248 -23
  382. package/templates/next-app/components/shell/connection-status.tsx.tmpl +109 -0
  383. package/templates/next-app/components/shell/nav-model.ts.tmpl +65 -0
  384. package/templates/next-app/components/shell/quick-jump.tsx.tmpl +131 -0
  385. package/templates/next-app/components/shell/theme-toggle.tsx.tmpl +40 -0
  386. package/templates/next-app/dot-gitignore.tmpl +2 -0
  387. package/templates/next-app/lib/nav.ts.tmpl +1 -1
  388. package/templates/next-app/package.json.tmpl +1 -1
@@ -0,0 +1,454 @@
1
+ "use client";
2
+
3
+ /**
4
+ * The shared query cache behind `useG8Query(..., { key })`, `usePaginated(..., { key })` and
5
+ * `useG8Mutation(..., { invalidates })`. No dependencies, framework-agnostic, SSR-safe.
6
+ *
7
+ * What a `key` buys you (all opt-in: hooks called WITHOUT a key never touch this file's state):
8
+ * - Dedupe: any number of components asking for the same key share ONE in-flight request.
9
+ * - Stale-while-revalidate: cached data renders instantly on mount, then refreshes in the background
10
+ * once it is older than `staleTime` (default 0: always revalidate, but never show a skeleton twice).
11
+ * - Revalidate on window focus / reconnect, throttled per key (default 5 s between refreshes).
12
+ * - Smart invalidation: `invalidate("contacts")` marks every key that starts with "contacts" stale and
13
+ * refetches the ones on screen. Mutations pass `invalidates: ["contacts"]` to do this on success.
14
+ * - Bounded memory: at most `maxEntries` keys (LRU, default 200); a key nobody watches is dropped
15
+ * after `gcTime` (default 5 min). Only unwatched, idle entries are ever evicted.
16
+ * - Abort: a request is aborted when its LAST subscriber unmounts (or it is superseded), never while
17
+ * another component still waits for it.
18
+ *
19
+ * Keys are a string or an array whose first item names the area: `["contacts", "list", { page, filters }]`.
20
+ * Object parts are compared by value (key order does not matter). A key must contain every input the
21
+ * fetcher reads; prefixes match whole parts: "contacts" matches ["contacts", ...] but not ["contacts-x"].
22
+ *
23
+ * On the server nothing is cached: hooks render their initial state and fetch after hydration.
24
+ */
25
+
26
+ export type G8QueryKey = string | readonly unknown[];
27
+
28
+ export interface G8QueryState<T = unknown> {
29
+ data: T | undefined;
30
+ error: unknown;
31
+ /** Epoch ms of the last successful fetch or setQueryData (0 = never). */
32
+ updatedAt: number;
33
+ /** A request for this key is in flight. */
34
+ fetching: boolean;
35
+ /** Marked stale by `invalidate` (refetched on next use even inside `staleTime`). */
36
+ invalidated: boolean;
37
+ }
38
+
39
+ export type G8Fetcher<T> = (signal: AbortSignal) => Promise<T>;
40
+
41
+ /** What a mounted hook tells the cache about itself. Read lazily, so the latest render's values win. */
42
+ export interface G8QueryObserver {
43
+ fetcher: () => G8Fetcher<unknown>;
44
+ /** A disabled hook (for example waiting for an id) is never refetched by invalidation. Default true. */
45
+ enabled?: () => boolean;
46
+ staleTime: () => number;
47
+ refetchOnFocus: () => boolean;
48
+ }
49
+
50
+ export interface G8QueryCacheOptions {
51
+ /** Most keys kept at once (LRU). Default 200. */
52
+ maxEntries?: number;
53
+ /** How long an unwatched key is kept, ms. Default 5 minutes. */
54
+ gcTime?: number;
55
+ /** Least time between two focus/reconnect refreshes of one key, ms. Default 5 s. */
56
+ focusThrottleMs?: number;
57
+ }
58
+
59
+ interface Entry {
60
+ key: readonly unknown[];
61
+ hash: string;
62
+ parts: string[];
63
+ state: G8QueryState;
64
+ listeners: Set<() => void>;
65
+ observers: Set<G8QueryObserver>;
66
+ promise?: Promise<unknown>;
67
+ controller?: AbortController;
68
+ /** Bumped by every fetch start and every cancel: only the latest fetch may write. */
69
+ gen: number;
70
+ lastFetchAt: number;
71
+ /** The synchronous run (tick) the request in flight started in. */
72
+ startedTick: number;
73
+ gcTimer?: ReturnType<typeof setTimeout>;
74
+ }
75
+
76
+ const EMPTY_STATE: G8QueryState = Object.freeze({
77
+ data: undefined,
78
+ error: undefined,
79
+ updatedAt: 0,
80
+ fetching: false,
81
+ invalidated: false,
82
+ }) as G8QueryState;
83
+
84
+ const isServer = (): boolean => typeof window === "undefined";
85
+
86
+ function stable(value: unknown): string {
87
+ if (value === undefined) return "u";
88
+ if (value === null || typeof value !== "object") return JSON.stringify(value) ?? String(value);
89
+ if (Array.isArray(value)) return `[${value.map(stable).join(",")}]`;
90
+ if (value instanceof Date) return `D${value.toISOString()}`;
91
+ const proto = Object.getPrototypeOf(value);
92
+ if (proto !== Object.prototype && proto !== null) {
93
+ // A Map, Set, URLSearchParams or class instance has no own keys to hash: two different ones would
94
+ // collide as {} and share data. Fail loudly instead.
95
+ throw new TypeError("g8 query keys must be JSON-like: strings, numbers, booleans, null, arrays, plain objects or Dates");
96
+ }
97
+ const obj = value as Record<string, unknown>;
98
+ const keys = Object.keys(obj).filter((k) => obj[k] !== undefined).sort();
99
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${stable(obj[k])}`).join(",")}}`;
100
+ }
101
+
102
+ /** A key as an array (a string key is a one-part key). */
103
+ export function normalizeKey(key: G8QueryKey): readonly unknown[] {
104
+ return typeof key === "string" ? [key] : key;
105
+ }
106
+
107
+ /** The identity of a key: equal for keys equal by value. */
108
+ export function hashKey(key: G8QueryKey): string {
109
+ return stable(normalizeKey(key));
110
+ }
111
+
112
+ function isAbort(err: unknown, signal: AbortSignal): boolean {
113
+ return signal.aborted || (err as { name?: string } | null)?.name === "AbortError";
114
+ }
115
+
116
+ /** Same JSON as before = keep the old object, so memoised rows and effects do not churn. */
117
+ function share<T>(prev: unknown, next: T): T {
118
+ if (prev === undefined || prev === next) return next;
119
+ try {
120
+ return JSON.stringify(prev) === JSON.stringify(next) ? (prev as T) : next;
121
+ } catch {
122
+ return next;
123
+ }
124
+ }
125
+
126
+ export class G8QueryCache {
127
+ readonly maxEntries: number;
128
+ readonly gcTime: number;
129
+ readonly focusThrottleMs: number;
130
+ private entries = new Map<string, Entry>();
131
+ private windowBound = false;
132
+ private tick = 0;
133
+ private tickScheduled = false;
134
+
135
+ constructor(options: G8QueryCacheOptions = {}) {
136
+ this.maxEntries = Math.max(1, options.maxEntries ?? 200);
137
+ this.gcTime = options.gcTime ?? 5 * 60_000;
138
+ this.focusThrottleMs = options.focusThrottleMs ?? 5_000;
139
+ }
140
+
141
+ /** Number of keys held (for tests and debugging). */
142
+ get size(): number {
143
+ return this.entries.size;
144
+ }
145
+
146
+ /** The current state of a key, or an empty state. Never creates an entry. */
147
+ getState<T = unknown>(key: G8QueryKey): G8QueryState<T> {
148
+ return (this.entries.get(hashKey(key))?.state ?? EMPTY_STATE) as G8QueryState<T>;
149
+ }
150
+
151
+ getQueryData<T = unknown>(key: G8QueryKey): T | undefined {
152
+ return this.getState<T>(key).data;
153
+ }
154
+
155
+ /** Watch a key: `listener` runs on every state change. Returns the unsubscribe function. */
156
+ subscribe(key: G8QueryKey, listener: () => void, observer?: G8QueryObserver): () => void {
157
+ const entry = this.ensure(key);
158
+ clearTimeout(entry.gcTimer);
159
+ entry.gcTimer = undefined;
160
+ entry.listeners.add(listener);
161
+ if (observer) entry.observers.add(observer);
162
+ this.bindWindow();
163
+ return () => {
164
+ entry.listeners.delete(listener);
165
+ if (observer) entry.observers.delete(observer);
166
+ if (entry.listeners.size > 0) return;
167
+ // Last one out: nobody needs the answer any more.
168
+ if (entry.promise) this.cancelEntry(entry);
169
+ this.scheduleGc(entry);
170
+ };
171
+ }
172
+
173
+ /**
174
+ * Fetch a key. Joins the request already in flight unless `force` (which supersedes it: the old
175
+ * request is aborted and only the new answer is kept). Resolves with the data, or undefined when the
176
+ * request failed (the error is in the state) or was superseded/aborted.
177
+ */
178
+ fetch<T>(
179
+ key: G8QueryKey,
180
+ fetcher: G8Fetcher<T>,
181
+ options: { force?: boolean; share?: boolean } = {},
182
+ ): Promise<T | undefined> {
183
+ const entry = this.ensure(key);
184
+ // A forced fetch still joins a request started in this same synchronous run: it cannot predate the
185
+ // write that asked for it (a mutation's `invalidates` plus the component's own refetch = one request).
186
+ if (entry.promise && (!options.force || entry.startedTick === this.tick)) return entry.promise as Promise<T | undefined>;
187
+ if (entry.promise) this.cancelEntry(entry);
188
+ const gen = ++entry.gen;
189
+ entry.startedTick = this.currentTick();
190
+ const controller = new AbortController();
191
+ entry.controller = controller;
192
+ entry.lastFetchAt = Date.now();
193
+ this.write(entry, { fetching: true });
194
+ const promise = (async (): Promise<T | undefined> => {
195
+ try {
196
+ const data = await fetcher(controller.signal);
197
+ if (gen !== entry.gen) return undefined;
198
+ this.write(entry, {
199
+ // Background refreshes keep the old object when nothing changed (no churn); an explicit refetch always
200
+ // hands over a new one, as the uncached hook did, so effects keyed on `data` run again.
201
+ data: options.share === false ? data : share(entry.state.data, data),
202
+ error: undefined,
203
+ updatedAt: Date.now(),
204
+ fetching: false,
205
+ invalidated: false,
206
+ });
207
+ return data;
208
+ } catch (err) {
209
+ if (gen !== entry.gen || isAbort(err, controller.signal)) return undefined;
210
+ this.write(entry, { error: err, fetching: false });
211
+ return undefined;
212
+ } finally {
213
+ if (gen === entry.gen) {
214
+ entry.promise = undefined;
215
+ entry.controller = undefined;
216
+ }
217
+ }
218
+ })();
219
+ entry.promise = promise;
220
+ return promise;
221
+ }
222
+
223
+ /** Is this key due a refresh for a reader that tolerates `staleTime` ms of age? */
224
+ isStale(key: G8QueryKey, staleTime: number): boolean {
225
+ const state = this.getState(key);
226
+ return state.invalidated || state.updatedAt === 0 || Date.now() - state.updatedAt >= staleTime;
227
+ }
228
+
229
+ /** Fetch only if there is no fresh data and no request in flight. */
230
+ ensureFresh<T>(key: G8QueryKey, fetcher: G8Fetcher<T>, staleTime: number): Promise<T | undefined> | undefined {
231
+ const entry = this.ensure(key);
232
+ if (entry.promise) return entry.promise as Promise<T | undefined>;
233
+ if (!this.isStale(key, staleTime)) return undefined;
234
+ return this.fetch(key, fetcher);
235
+ }
236
+
237
+ /**
238
+ * Mark every key under `prefix` (all keys when omitted) stale. Keys on screen refetch now
239
+ * (superseding a request that started before the write); the rest refetch when next used.
240
+ */
241
+ async invalidate(prefix?: G8QueryKey, options: { refetch?: boolean } = {}): Promise<void> {
242
+ const runs: Array<Promise<unknown>> = [];
243
+ for (const entry of this.match(prefix)) {
244
+ this.write(entry, { invalidated: true });
245
+ const observer = [...entry.observers].find((o) => o.enabled?.() ?? true);
246
+ if (options.refetch === false || entry.listeners.size === 0 || !observer) {
247
+ if (entry.promise) this.cancelEntry(entry);
248
+ continue;
249
+ }
250
+ runs.push(this.fetch(entry.key, observer.fetcher(), { force: true }));
251
+ }
252
+ await Promise.all(runs);
253
+ }
254
+
255
+ /** Replace one key's data (a value, or a function of the old data). Cancels a request in flight. */
256
+ setQueryData<T>(key: G8QueryKey, updater: T | ((old: T | undefined) => T | undefined)): T | undefined {
257
+ const entry = this.ensure(key);
258
+ if (entry.promise) this.cancelEntry(entry);
259
+ const old = entry.state.data as T | undefined;
260
+ const next =
261
+ typeof updater === "function" ? (updater as (old: T | undefined) => T | undefined)(old) : updater;
262
+ this.write(entry, { data: next, error: undefined, updatedAt: Date.now(), fetching: false, invalidated: false });
263
+ if (entry.listeners.size === 0) this.scheduleGc(entry);
264
+ return next;
265
+ }
266
+
267
+ /**
268
+ * Update the data of every key under `prefix` that has data. Returns a snapshot to pass to
269
+ * `restore` (an optimistic update that failed puts the old data back).
270
+ */
271
+ setQueriesData<T>(
272
+ prefix: G8QueryKey,
273
+ updater: (old: T, key: readonly unknown[]) => T,
274
+ ): Array<{ key: readonly unknown[]; data: unknown }> {
275
+ const snapshot: Array<{ key: readonly unknown[]; data: unknown }> = [];
276
+ for (const entry of this.match(prefix)) {
277
+ if (entry.state.data === undefined) continue;
278
+ if (entry.promise) this.cancelEntry(entry);
279
+ snapshot.push({ key: entry.key, data: entry.state.data });
280
+ this.write(entry, { data: updater(entry.state.data as T, entry.key) });
281
+ }
282
+ return snapshot;
283
+ }
284
+
285
+ /** Put back the data captured by `setQueriesData`. */
286
+ restore(snapshot: Array<{ key: readonly unknown[]; data: unknown }>): void {
287
+ for (const item of snapshot) {
288
+ const entry = this.entries.get(hashKey(item.key));
289
+ if (entry) this.write(entry, { data: item.data });
290
+ }
291
+ }
292
+
293
+ /** Abort the requests in flight under `prefix` (their answers are dropped). */
294
+ cancel(prefix?: G8QueryKey): void {
295
+ for (const entry of this.match(prefix)) if (entry.promise) this.cancelEntry(entry);
296
+ }
297
+
298
+ /** Forget keys under `prefix` that nobody is watching (all of them when omitted). */
299
+ remove(prefix?: G8QueryKey): void {
300
+ for (const entry of this.match(prefix)) {
301
+ if (entry.listeners.size > 0) continue;
302
+ this.drop(entry);
303
+ }
304
+ }
305
+
306
+ /** Forget everything, watched or not (tests, sign-out). Subscribers see an empty state. */
307
+ clear(): void {
308
+ for (const entry of [...this.entries.values()]) {
309
+ if (entry.promise) this.cancelEntry(entry);
310
+ clearTimeout(entry.gcTimer);
311
+ if (entry.listeners.size > 0) {
312
+ this.write(entry, { ...EMPTY_STATE });
313
+ } else {
314
+ this.entries.delete(entry.hash);
315
+ }
316
+ }
317
+ }
318
+
319
+ /** Refresh watched keys after the window regains focus or the network returns (throttled per key). */
320
+ revalidateOnFocus(): void {
321
+ const now = Date.now();
322
+ for (const entry of this.entries.values()) {
323
+ if (entry.listeners.size === 0 || entry.promise) continue;
324
+ const observers = [...entry.observers].filter((o) => o.enabled?.() ?? true);
325
+ // An opt-out is a veto: a form seeded from this key must not be refreshed because another view wants it.
326
+ if (observers.length === 0 || observers.some((o) => !o.refetchOnFocus())) continue;
327
+ if (now - entry.lastFetchAt < this.focusThrottleMs) continue;
328
+ const staleTime = Math.min(...observers.map((o) => o.staleTime()));
329
+ if (!this.isStale(entry.key, staleTime)) continue;
330
+ void this.fetch(entry.key, observers[0].fetcher());
331
+ }
332
+ }
333
+
334
+ private ensure(key: G8QueryKey): Entry {
335
+ const norm = normalizeKey(key);
336
+ const hash = hashKey(norm);
337
+ let entry = this.entries.get(hash);
338
+ if (entry) {
339
+ // LRU: most recently used goes to the end.
340
+ this.entries.delete(hash);
341
+ this.entries.set(hash, entry);
342
+ return entry;
343
+ }
344
+ entry = {
345
+ key: norm,
346
+ hash,
347
+ parts: norm.map(stable),
348
+ state: EMPTY_STATE,
349
+ listeners: new Set(),
350
+ observers: new Set(),
351
+ gen: 0,
352
+ lastFetchAt: 0,
353
+ startedTick: -1,
354
+ };
355
+ this.entries.set(hash, entry);
356
+ this.evict();
357
+ return entry;
358
+ }
359
+
360
+ private evict(): void {
361
+ if (this.entries.size <= this.maxEntries) return;
362
+ for (const entry of [...this.entries.values()]) {
363
+ if (this.entries.size <= this.maxEntries) return;
364
+ if (entry.listeners.size > 0 || entry.promise) continue;
365
+ this.drop(entry);
366
+ }
367
+ }
368
+
369
+ private drop(entry: Entry): void {
370
+ clearTimeout(entry.gcTimer);
371
+ if (entry.promise) this.cancelEntry(entry);
372
+ this.entries.delete(entry.hash);
373
+ }
374
+
375
+ private scheduleGc(entry: Entry): void {
376
+ clearTimeout(entry.gcTimer);
377
+ if (isServer()) {
378
+ this.drop(entry);
379
+ return;
380
+ }
381
+ entry.gcTimer = setTimeout(() => {
382
+ if (entry.listeners.size === 0 && !entry.promise) this.drop(entry);
383
+ }, this.gcTime);
384
+ }
385
+
386
+ private match(prefix?: G8QueryKey): Entry[] {
387
+ const parts = prefix === undefined ? [] : normalizeKey(prefix).map(stable);
388
+ return [...this.entries.values()].filter(
389
+ (entry) => parts.length <= entry.parts.length && parts.every((part, i) => entry.parts[i] === part),
390
+ );
391
+ }
392
+
393
+ private cancelEntry(entry: Entry): void {
394
+ entry.gen++;
395
+ entry.controller?.abort();
396
+ entry.controller = undefined;
397
+ entry.promise = undefined;
398
+ if (entry.state.fetching) this.write(entry, { fetching: false });
399
+ }
400
+
401
+ private write(entry: Entry, patch: Partial<G8QueryState>): void {
402
+ entry.state = { ...entry.state, ...patch };
403
+ for (const listener of [...entry.listeners]) listener();
404
+ }
405
+
406
+ /** Ticks advance once per macrotask/microtask boundary after a fetch starts. */
407
+ private currentTick(): number {
408
+ if (!this.tickScheduled) {
409
+ this.tickScheduled = true;
410
+ void Promise.resolve().then(() => {
411
+ this.tick++;
412
+ this.tickScheduled = false;
413
+ });
414
+ }
415
+ return this.tick;
416
+ }
417
+
418
+ private bindWindow(): void {
419
+ if (this.windowBound || isServer() || typeof window.addEventListener !== "function") return;
420
+ this.windowBound = true;
421
+ const onFocus = (): void => this.revalidateOnFocus();
422
+ window.addEventListener("focus", onFocus);
423
+ window.addEventListener("online", onFocus);
424
+ if (typeof document !== "undefined") {
425
+ document.addEventListener("visibilitychange", () => {
426
+ if (document.visibilityState === "visible") onFocus();
427
+ });
428
+ }
429
+ }
430
+ }
431
+
432
+ const SINGLETON = Symbol.for("@graph8/blocks/query-cache");
433
+
434
+ /** The app-wide cache every keyed hook uses (one per page, shared by every copy of this module). */
435
+ export const g8QueryCache: G8QueryCache = ((globalThis as Record<symbol, unknown>)[SINGLETON] as
436
+ | G8QueryCache
437
+ | undefined) ?? ((globalThis as Record<symbol, unknown>)[SINGLETON] = new G8QueryCache()) as G8QueryCache;
438
+
439
+ /** Mark keys under `prefix` stale; the ones on screen refetch now. `invalidate("contacts")`. */
440
+ export const invalidate = (prefix?: G8QueryKey, options?: { refetch?: boolean }): Promise<void> =>
441
+ g8QueryCache.invalidate(prefix, options);
442
+
443
+ /** Write one key's data (after a create/update response, or optimistically). */
444
+ export const setQueryData = <T>(key: G8QueryKey, updater: T | ((old: T | undefined) => T | undefined)) =>
445
+ g8QueryCache.setQueryData<T>(key, updater);
446
+
447
+ export const getQueryData = <T>(key: G8QueryKey): T | undefined => g8QueryCache.getQueryData<T>(key);
448
+
449
+ /** Warm a key before it is shown (hover, next page). Joins a request already in flight. */
450
+ export const prefetchQuery = <T>(key: G8QueryKey, fetcher: G8Fetcher<T>, staleTime = 0) =>
451
+ g8QueryCache.ensureFresh(key, fetcher, staleTime) ?? Promise.resolve(g8QueryCache.getQueryData<T>(key));
452
+
453
+ /** The empty state hooks render before a key has data (and on the server). */
454
+ export const EMPTY_QUERY_STATE: G8QueryState = EMPTY_STATE;
@@ -14,7 +14,7 @@
14
14
  * thing in the browser.
15
15
  */
16
16
 
17
- import { G8Error } from "@graph8/sdk";
17
+ import { G8Error } from "@graph8/sdk/errors";
18
18
  import type { PaginatedResponse } from "@graph8/sdk";
19
19
 
20
20
  /** The envelope every graph8 list/read call returns. */
@@ -0,0 +1,23 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Plurals done once, correctly: `plural(1, "contact")` -> "1 contact", `plural(2, "contact")` -> "2 contacts",
5
+ * `plural(3, "company", "companies")` -> "3 companies". The count is formatted with thousands separators.
6
+ * Use it instead of a hand-rolled `n === 1 ? "" : "s"`.
7
+ *
8
+ * SDK operations: none.
9
+ */
10
+
11
+ /** The word alone, singular for exactly 1: `pluralWord(2, "city", "cities")` -> "cities". */
12
+ export function pluralWord(count: number, singular: string, pluralForm?: string): string {
13
+ if (count === 1) return singular;
14
+ if (pluralForm) return pluralForm;
15
+ if (/[^aeiou]y$/i.test(singular)) return `${singular.slice(0, -1)}ies`;
16
+ if (/(s|x|z|ch|sh)$/i.test(singular)) return `${singular}es`;
17
+ return `${singular}s`;
18
+ }
19
+
20
+ /** The count and the word: "1 contact", "1,200 contacts". */
21
+ export function plural(count: number, singular: string, pluralForm?: string): string {
22
+ return `${count.toLocaleString("en-US")} ${pluralWord(count, singular, pluralForm)}`;
23
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Install as `app/api/g8-connect/route.ts` (the CLI copies it there). It backs the Connect graph8 screen that
3
+ * a created app shows in local preview when the server has no graph8 API key yet: the pasted key is checked
4
+ * against graph8 and stored server-side in `.g8/local-key` (never in the browser). See g8/server/connect.ts.
5
+ *
6
+ * It only answers `next dev` requests from this machine; in a production build every method is 404. Delete
7
+ * this file (and the gate in app/layout.tsx) if you never want the screen.
8
+ */
9
+
10
+ import { createG8ConnectRoute } from "@/g8/server/connect";
11
+
12
+ export const { GET, POST, DELETE } = createG8ConnectRoute();
13
+
14
+ // The answer depends on the key on disk: never cache it.
15
+ export const dynamic = "force-dynamic";
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Server side of the Connect graph8 screen (local preview only), mounted at `app/api/g8-connect/route.ts`:
3
+ *
4
+ * GET -> { localPreview: true, connected, source: "env" | "file" | null, keyHint, account }
5
+ * POST { key } -> validates the key with a cheap read (GET /api/v1/me) and stores it in .g8/local-key
6
+ * DELETE -> removes the stored key
7
+ *
8
+ * It answers ONLY `next dev` requests from this machine (same check as the proxy's local preview). In a
9
+ * production build every method is 404, as if the route did not exist. The key is never echoed back: the
10
+ * answer carries only a hint (`...wxyz`) and the key's account (name, workspace, scopes) from /me.
11
+ * When G8_API_KEY is set in the environment it wins, so POST and DELETE refuse (409) and say where to change it.
12
+ */
13
+
14
+ import { DEFAULT_API_URL, isLocalPreviewRequest } from "./proxy";
15
+ import { currentKey, keyHint, removeLocalKey, writeLocalKey, LOCAL_KEY_FILE } from "./local-key";
16
+
17
+ export interface G8ConnectConfig {
18
+ /** Replace `fetch` (tests). */
19
+ fetch?: typeof fetch;
20
+ /** Project root holding `.g8/` (tests). Default: process.cwd(). */
21
+ root?: string;
22
+ }
23
+
24
+ export interface ConnectedAccount {
25
+ name?: string;
26
+ workspace?: string;
27
+ scopes?: string[];
28
+ }
29
+
30
+ function json(status: number, body: Record<string, unknown>): Response {
31
+ return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json", "cache-control": "no-store" } });
32
+ }
33
+
34
+ const str = (v: unknown): string | undefined => (typeof v === "string" && v ? v : undefined);
35
+
36
+ /** The safe, human facts about a key from GET /me (never the key itself). */
37
+ export function accountFromMe(body: unknown): ConnectedAccount {
38
+ const root = (body ?? {}) as Record<string, unknown>;
39
+ const d = ((root.data as Record<string, unknown> | undefined) ?? root) as Record<string, unknown>;
40
+ const org = d.organization as Record<string, unknown> | undefined;
41
+ const scopes = Array.isArray(d.scopes) ? d.scopes.filter((s): s is string => typeof s === "string") : undefined;
42
+ return { name: str(d.name) ?? str(d.key_name) ?? str(d.label), workspace: str(org?.name) ?? str(d.org_name) ?? str(d.organization_name), scopes };
43
+ }
44
+
45
+ /** A pasted key that is plausibly a key: printable, no spaces, 8 to 512 characters. */
46
+ export function plausibleKey(value: unknown): string | null {
47
+ if (typeof value !== "string") return null;
48
+ const key = value.trim();
49
+ return key.length >= 8 && key.length <= 512 && /^[\x21-\x7e]+$/.test(key) ? key : null;
50
+ }
51
+
52
+ type Checked = { ok: true; account: ConnectedAccount } | { ok: false; status: number; error: string; message: string };
53
+
54
+ /** Ask graph8 who the key is: GET /api/v1/me (free, read-only). */
55
+ export async function checkKey(key: string, doFetch: typeof fetch): Promise<Checked> {
56
+ const base = (process.env.G8_API_URL ?? DEFAULT_API_URL).replace(/\/+$/, "");
57
+ let res: Response;
58
+ try {
59
+ res = await doFetch(`${base}/api/v1/me`, { headers: { authorization: `Bearer ${key}`, accept: "application/json" }, cache: "no-store", redirect: "manual" });
60
+ } catch {
61
+ return { ok: false, status: 502, error: "upstream_unreachable", message: "Could not reach the graph8 API to check the key." };
62
+ }
63
+ const body = await res.json().catch(() => ({}));
64
+ if (res.ok) return { ok: true, account: accountFromMe(body) };
65
+ if (res.status === 401 || res.status === 403) {
66
+ return { ok: false, status: 400, error: "key_rejected", message: "graph8 did not accept this key. Copy it again from graph8 (Settings > Developers > API keys)." };
67
+ }
68
+ return { ok: false, status: 502, error: "upstream_error", message: `graph8 answered HTTP ${res.status} while checking the key. Try again.` };
69
+ }
70
+
71
+ /** Build the GET/POST/DELETE handlers for `app/api/g8-connect/route.ts`. */
72
+ export function createG8ConnectRoute(config: G8ConnectConfig = {}) {
73
+ const doFetch = (): typeof fetch => config.fetch ?? globalThis.fetch;
74
+
75
+ function refuse(request: Request): Response | null {
76
+ if (process.env.NODE_ENV === "production") return json(404, { error: "not_found" });
77
+ if (!isLocalPreviewRequest(request)) {
78
+ return json(403, { error: "local_preview_only", message: "The Connect graph8 screen only works from this machine (http://localhost)." });
79
+ }
80
+ return null;
81
+ }
82
+
83
+ async function status(): Promise<Record<string, unknown>> {
84
+ const current = await currentKey(config.root);
85
+ if (!current) return { localPreview: true, connected: false, source: null, keyFile: LOCAL_KEY_FILE };
86
+ const checked = await checkKey(current.key, doFetch());
87
+ return {
88
+ localPreview: true,
89
+ connected: checked.ok,
90
+ source: current.source,
91
+ keyHint: keyHint(current.key),
92
+ keyFile: LOCAL_KEY_FILE,
93
+ ...(checked.ok ? { account: checked.account } : { problem: checked.message }),
94
+ };
95
+ }
96
+
97
+ async function GET(request: Request): Promise<Response> {
98
+ return refuse(request) ?? json(200, await status());
99
+ }
100
+
101
+ async function POST(request: Request): Promise<Response> {
102
+ const refused = refuse(request);
103
+ if (refused) return refused;
104
+ if ((await currentKey(config.root))?.source === "env") {
105
+ return json(409, { error: "key_in_environment", message: "G8_API_KEY is set in the server environment (.env.local), and it wins. Change it there, then restart `npm run dev`." });
106
+ }
107
+ const body = (await request.json().catch(() => ({}))) as { key?: unknown };
108
+ const key = plausibleKey(body.key);
109
+ if (!key) return json(400, { error: "invalid_key", message: "Paste a graph8 API key: one line, no spaces." });
110
+ const checked = await checkKey(key, doFetch());
111
+ if (!checked.ok) return json(checked.status, { error: checked.error, message: checked.message });
112
+ await writeLocalKey(key, config.root);
113
+ return json(200, { localPreview: true, connected: true, source: "file", keyHint: keyHint(key), keyFile: LOCAL_KEY_FILE, account: checked.account });
114
+ }
115
+
116
+ async function DELETE(request: Request): Promise<Response> {
117
+ const refused = refuse(request);
118
+ if (refused) return refused;
119
+ if ((await currentKey(config.root))?.source === "env") {
120
+ return json(409, { error: "key_in_environment", message: "G8_API_KEY is set in the server environment (.env.local). Remove it there, then restart `npm run dev`." });
121
+ }
122
+ await removeLocalKey(config.root);
123
+ return json(200, { localPreview: true, connected: false, source: null, keyFile: LOCAL_KEY_FILE });
124
+ }
125
+
126
+ return { GET, POST, DELETE };
127
+ }