@volter/twin-hubspot 0.1.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 (84) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +197 -0
  3. package/client/hubspot-mirror.css +43 -0
  4. package/client/hubspot-mirror.tsx +132 -0
  5. package/dist/client/hubspot-mirror.bundle.js +449 -0
  6. package/dist/client/hubspot-mirror.css +43 -0
  7. package/dist/client/hubspot-mirror.d.ts +15 -0
  8. package/dist/client/hubspot-mirror.js +59 -0
  9. package/dist/client/hubspot-mirror.tsx +132 -0
  10. package/dist/src/accounts.d.ts +30 -0
  11. package/dist/src/accounts.js +122 -0
  12. package/dist/src/cli.d.ts +2 -0
  13. package/dist/src/cli.js +31 -0
  14. package/dist/src/generated/surface.gen.json +1 -0
  15. package/dist/src/generated/ui.gen.json +1 -0
  16. package/dist/src/hubspot-areas.d.ts +10 -0
  17. package/dist/src/hubspot-areas.js +114 -0
  18. package/dist/src/hubspot-budget.d.ts +58 -0
  19. package/dist/src/hubspot-budget.js +176 -0
  20. package/dist/src/hubspot-capabilities.d.ts +3 -0
  21. package/dist/src/hubspot-capabilities.js +1588 -0
  22. package/dist/src/hubspot-conformance.d.ts +16 -0
  23. package/dist/src/hubspot-conformance.js +523 -0
  24. package/dist/src/hubspot-connector.d.ts +125 -0
  25. package/dist/src/hubspot-connector.js +390 -0
  26. package/dist/src/hubspot-deferred-capabilities.d.ts +6 -0
  27. package/dist/src/hubspot-deferred-capabilities.js +64 -0
  28. package/dist/src/hubspot-mirror-ui.d.ts +62 -0
  29. package/dist/src/hubspot-mirror-ui.js +152 -0
  30. package/dist/src/hubspot-oauth.d.ts +8 -0
  31. package/dist/src/hubspot-oauth.js +291 -0
  32. package/dist/src/hubspot-server.d.ts +24 -0
  33. package/dist/src/hubspot-server.js +116 -0
  34. package/dist/src/hubspot-twin.d.ts +65 -0
  35. package/dist/src/hubspot-twin.js +1558 -0
  36. package/dist/src/index.d.ts +11 -0
  37. package/dist/src/index.js +94 -0
  38. package/dist/src/manifest.d.ts +2 -0
  39. package/dist/src/manifest.js +68 -0
  40. package/dist/src/portal.d.ts +20 -0
  41. package/dist/src/portal.js +30 -0
  42. package/dist/src/screens/account.d.ts +1 -0
  43. package/dist/src/screens/account.js +139 -0
  44. package/dist/src/screens/crm.d.ts +2 -0
  45. package/dist/src/screens/crm.js +153 -0
  46. package/dist/src/screens/developer.d.ts +4 -0
  47. package/dist/src/screens/developer.js +191 -0
  48. package/dist/src/screens/forms.d.ts +5 -0
  49. package/dist/src/screens/forms.js +126 -0
  50. package/dist/src/screens/page.d.ts +21 -0
  51. package/dist/src/screens/page.js +49 -0
  52. package/dist/src/screens/session.d.ts +1 -0
  53. package/dist/src/screens/session.js +32 -0
  54. package/dist/src/semantics/crm.d.ts +8 -0
  55. package/dist/src/semantics/crm.js +101 -0
  56. package/dist/src/webhooks.d.ts +12 -0
  57. package/dist/src/webhooks.js +77 -0
  58. package/package.json +75 -0
  59. package/src/accounts.ts +127 -0
  60. package/src/cli.ts +29 -0
  61. package/src/generated/surface.gen.json +1 -0
  62. package/src/generated/ui.gen.json +1 -0
  63. package/src/hubspot-areas.ts +155 -0
  64. package/src/hubspot-budget.ts +202 -0
  65. package/src/hubspot-capabilities.ts +1523 -0
  66. package/src/hubspot-conformance.ts +537 -0
  67. package/src/hubspot-connector.ts +419 -0
  68. package/src/hubspot-deferred-capabilities.ts +99 -0
  69. package/src/hubspot-journey.uitest.ts +104 -0
  70. package/src/hubspot-mirror-ui.ts +166 -0
  71. package/src/hubspot-oauth.tsx +296 -0
  72. package/src/hubspot-server.ts +115 -0
  73. package/src/hubspot-twin.ts +1534 -0
  74. package/src/index.ts +152 -0
  75. package/src/manifest.ts +96 -0
  76. package/src/portal.ts +40 -0
  77. package/src/screens/account.tsx +129 -0
  78. package/src/screens/crm.tsx +154 -0
  79. package/src/screens/developer.tsx +181 -0
  80. package/src/screens/forms.tsx +117 -0
  81. package/src/screens/page.tsx +55 -0
  82. package/src/screens/session.tsx +36 -0
  83. package/src/semantics/crm.ts +116 -0
  84. package/src/webhooks.ts +80 -0
@@ -0,0 +1,166 @@
1
+ // HubSpot MIRROR UI — a HubSpot-CRM-like view served as a React/TSX app (bundled by Bun).
2
+ //
3
+ // WHY THIS PACK MIRRORS AT ALL (docs/contributing/adding-a-twin.md, "Does this vendor get a mirror?"): the
4
+ // question is not "does HubSpot have a UI" but "when someone does this vendor's core job, do
5
+ // they open a browser or write code?" A sales rep works a deal by DRAGGING IT ACROSS THE
6
+ // PIPELINE BOARD and a support agent works a ticket in the ticket view — the CRM screen IS the
7
+ // product, the way a board is Jira's product. So: mirror.
8
+ //
9
+ // ARCHETYPE A (API passthrough), the recipe's default and the strongest parity claim available:
10
+ // non-asset requests fall through to the pack's OWN fetch adapter, and the browser client fetches
11
+ // HubSpot's REAL API paths (`/crm/v3/objects/contacts`, …). There is exactly ONE code path, so
12
+ // API↔UI parity cannot drift — it is not "kept in sync", it is structurally the same read.
13
+ // PURE FRONTEND (R3): the mirror imports no handler and no twin internals — it MOUNTS the pack's
14
+ // fetch adapter as its API backend and reads every byte of state back over the wire.
15
+ import { readFile } from 'node:fs/promises';
16
+ import { bundleClient, fileResponse } from '@volter/world-core';
17
+ import { serveHttp } from '@volter/world-core';
18
+ import { createHubspotTwinFetch } from './hubspot-server.ts';
19
+
20
+ const CLIENT_ENTRY = () => new URL('../client/hubspot-mirror.tsx', import.meta.url).pathname; // lazy: workerd rejects top-level relative URL from import.meta.url (bundled via pack index)
21
+ const CLIENT_CSS = () => new URL('../client/hubspot-mirror.css', import.meta.url).pathname; // lazy: workerd rejects top-level relative URL from import.meta.url (bundled via pack index)
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Pure, dependency-free render/format helpers (importable by the React client; Bun
25
+ // tree-shakes the server-only exports out of the browser bundle). Keep free of any
26
+ // @volter/world-core / Bun / handler usage.
27
+ // ---------------------------------------------------------------------------
28
+
29
+ /** One rendered CRM record, as the vendor's own API returns it (SimplePublicObject). */
30
+ export type HubspotRow = { id: string; properties: Record<string, string | null>; createdAt?: string; updatedAt?: string; archived?: boolean };
31
+
32
+ /** The four record sections the mirror shows, keyed by HubSpot's own object-type path segment. */
33
+ export const MIRROR_SECTIONS = [
34
+ { key: 'contacts', label: 'Contacts', path: '/crm/v3/objects/contacts?limit=100&properties=email,firstname,lastname,phone,lifecyclestage' },
35
+ { key: 'companies', label: 'Companies', path: '/crm/v3/objects/companies?limit=100&properties=name,domain,city,industry' },
36
+ { key: 'deals', label: 'Deals', path: '/crm/v3/objects/deals?limit=100&properties=dealname,amount,dealstage,pipeline,closedate' },
37
+ { key: 'tickets', label: 'Tickets', path: '/crm/v3/objects/tickets?limit=100&properties=subject,content,hs_pipeline_stage,hs_ticket_priority' },
38
+ ] as const;
39
+ export type MirrorSectionKey = (typeof MIRROR_SECTIONS)[number]['key'];
40
+
41
+ /** The columns each section's table shows — HubSpot property names, in display order. */
42
+ export const SECTION_COLUMNS: Record<MirrorSectionKey, { prop: string; label: string }[]> = {
43
+ contacts: [
44
+ { prop: 'firstname', label: 'First name' }, { prop: 'lastname', label: 'Last name' },
45
+ { prop: 'email', label: 'Email' }, { prop: 'phone', label: 'Phone' }, { prop: 'lifecyclestage', label: 'Lifecycle stage' },
46
+ ],
47
+ companies: [
48
+ { prop: 'name', label: 'Name' }, { prop: 'domain', label: 'Domain' },
49
+ { prop: 'city', label: 'City' }, { prop: 'industry', label: 'Industry' },
50
+ ],
51
+ deals: [
52
+ { prop: 'dealname', label: 'Deal name' }, { prop: 'amount', label: 'Amount' },
53
+ { prop: 'dealstage', label: 'Stage' }, { prop: 'closedate', label: 'Close date' },
54
+ ],
55
+ tickets: [
56
+ { prop: 'subject', label: 'Ticket name' }, { prop: 'hs_ticket_priority', label: 'Priority' },
57
+ { prop: 'hs_pipeline_stage', label: 'Status' },
58
+ ],
59
+ };
60
+
61
+ /** The property whose value titles a record in the list — HubSpot's own primary display property. */
62
+ export const TITLE_PROPERTY: Record<MirrorSectionKey, string[]> = {
63
+ contacts: ['firstname', 'lastname', 'email'],
64
+ companies: ['name', 'domain'],
65
+ deals: ['dealname'],
66
+ tickets: ['subject'],
67
+ };
68
+
69
+ /** The label a row shows for a record — the vendor's primary display properties, joined. */
70
+ export function recordTitle(section: MirrorSectionKey, properties: Record<string, string | null> | undefined): string {
71
+ const props = properties ?? {};
72
+ const parts = TITLE_PROPERTY[section].map((p) => props[p]).filter((v): v is string => typeof v === 'string' && v !== '');
73
+ return parts.length ? parts.join(' ') : '(no name)';
74
+ }
75
+
76
+ /** Status pill tone for a deal stage. Closed-won reads good, closed-lost bad, anything open warn. */
77
+ export type PillTone = 'ok' | 'warn' | 'bad' | '';
78
+ export function stageTone(dealstage: unknown): PillTone {
79
+ const s = String(dealstage ?? '').toLowerCase();
80
+ if (s === '') return '';
81
+ if (s === 'closedwon') return 'ok';
82
+ if (s === 'closedlost') return 'bad';
83
+ return 'warn';
84
+ }
85
+
86
+ /** Format a deal amount the way a CRM list does: `$1,250` / `—` when unset. */
87
+ export function formatAmount(amount: unknown): string {
88
+ const n = Number(amount);
89
+ if (amount === null || amount === undefined || amount === '' || !Number.isFinite(n)) return '—';
90
+ return `$${n.toLocaleString('en-US', { maximumFractionDigits: 2 })}`;
91
+ }
92
+
93
+ /** Group deal rows by their `dealstage` property — the pipeline board's own projection. */
94
+ export function groupByStage(rows: HubspotRow[]): { stage: string; rows: HubspotRow[] }[] {
95
+ const buckets = new Map<string, HubspotRow[]>();
96
+ for (const r of rows) {
97
+ const stage = String(r.properties?.dealstage ?? '') || 'unstaged';
98
+ const list = buckets.get(stage);
99
+ if (list) list.push(r); else buckets.set(stage, [r]);
100
+ }
101
+ return [...buckets.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([stage, list]) => ({ stage, rows: list }));
102
+ }
103
+
104
+ const APP_SHELL = `<!doctype html>
105
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
106
+ <base href="/"><title>HubSpot CRM mirror (twin)</title><link rel="stylesheet" href="assets/styles.css"></head>
107
+ <body><div id="root"></div><script type="module" src="assets/app.js"></script></body></html>`;
108
+
109
+ let clientBundle: Promise<string> | null = null;
110
+ /** Build the React/TSX CRM client to browser JS (Bun bundles TSX); cached at module scope, so a
111
+ * pack running on its own does exactly ONE build. */
112
+ export function buildHubspotMirrorClient(): Promise<string> {
113
+ if (!clientBundle) {
114
+ clientBundle = bundleClient(CLIENT_ENTRY())
115
+ .catch((error) => { clientBundle = null; throw error; });
116
+ }
117
+ return clientBundle;
118
+ }
119
+
120
+ /** Serve the HubSpot CRM mirror UI (React app) + its backing CRM REST API. */
121
+ export async function createHubspotMirrorServer(options: { root?: string; port?: number }): Promise<{ port: number; url: string; stop: () => void }> {
122
+ const twin = createHubspotTwinFetch(options);
123
+ const server = await serveHttp({
124
+ // LOOPBACK-SPECIFIC bind: with the default wildcard hostname, `port: 0` can be handed a port
125
+ // some long-running app already LISTENS on at 127.0.0.1 (SO_REUSEADDR allows the overlapping
126
+ // non-identical bind), and the more specific loopback listener then shadows this server for
127
+ // every 127.0.0.1 fetch — the verify would talk to a STRANGER. Binding 127.0.0.1 makes the
128
+ // kernel allocate a port that is actually free on loopback.
129
+ hostname: '127.0.0.1',
130
+ port: options.port ?? 0,
131
+ idleTimeout: 60,
132
+ async fetch(request) {
133
+ const url = new URL(request.url);
134
+ if (request.method === 'GET' && url.pathname === '/assets/app.js') {
135
+ try { return new Response(await buildHubspotMirrorClient(), { headers: { 'content-type': 'text/javascript; charset=utf-8' } }); }
136
+ catch (error) { return new Response(String(error), { status: 500 }); }
137
+ }
138
+ if (request.method === 'GET' && url.pathname === '/assets/styles.css') {
139
+ return fileResponse(CLIENT_CSS(), { headers: { 'content-type': 'text/css; charset=utf-8' } });
140
+ }
141
+ if (request.method === 'GET' && (url.pathname === '/' || url.pathname === '')) {
142
+ return new Response(APP_SHELL, { headers: { 'content-type': 'text/html; charset=utf-8' } });
143
+ }
144
+ // Everything else → the twin's OWN FETCH ADAPTER (composition, R2/R3): the React client
145
+ // fetches HubSpot's real endpoints, and the adapter is the same closure
146
+ // `createHubspotTwinServer` serves, so there is exactly ONE serving code path — the world
147
+ // instant on every write (the kernel dedupes an action by content + millisecond, so a
148
+ // pinned stamp would swallow an A→B→A revert as `replayed`), the rate-limit response
149
+ // headers, an honorable `readOnly` and contained handler throws all come from it, and the
150
+ // mirror port cannot drift from the API port.
151
+ return twin(request);
152
+ },
153
+ });
154
+ const port = server.port ?? options.port ?? 0;
155
+ return { port, url: `http://127.0.0.1:${port}`, stop: () => server.stop(true) };
156
+ }
157
+
158
+ /** The app-shell HTML (pure, for tests). The CRM itself is the React client. */
159
+ export function hubspotMirrorHtml(): string {
160
+ return APP_SHELL;
161
+ }
162
+
163
+ /** The mirror's stylesheet, for a host serving the shell's `assets/styles.css` itself (the hosted mirror mount). */
164
+ export function hubspotMirrorStyles(): Promise<string> {
165
+ return readFile(CLIENT_CSS(), 'utf8');
166
+ }
@@ -0,0 +1,296 @@
1
+ // HUBSPOT OAUTH — the install an app's "Connect HubSpot" starts (docs/contributing/architecture.md, "Screens": a hosted
2
+ // flow). From HubSpot's OAuth guide (developers.hubspot.com/docs/api/oauth-quickstart-guide):
3
+ // - the app sends a person to https://app.hubspot.com/oauth/authorize with `client_id`, `scope` (space-separated),
4
+ // `redirect_uri`, and optionally `optional_scope` and `state`; its consent shows "the name of your app and a short
5
+ // description of the HubSpot API services it's requesting permission to access", and on approval HubSpot redirects
6
+ // to the redirect_uri "with a `code` query parameter";
7
+ // - the app exchanges the code at POST https://api.hubapi.com/oauth/v1/token with `grant_type=authorization_code`,
8
+ // client_id, client_secret, redirect_uri and code, and later refreshes with `grant_type=refresh_token`;
9
+ // - both answer `token_type`, `refresh_token`, `hub_id`, `scopes`, `access_token` and `expires_in` (1800) (HubSpot's
10
+ // changelog, "Additional details returned when generating OAuth access tokens");
11
+ // - GET /oauth/v1/access-tokens/{token} answers the token's metadata (token, user, hub_domain, scopes, hub_id,
12
+ // app_id, expires_in, user_id, token_type "access"), and a bad refresh token is `invalid_grant` with status
13
+ // BAD_REFRESH_TOKEN (the v1 OAuth tokens guide).
14
+ // The consent is authored from @volter/world-ui's piece under HubSpot's skin; nothing of HubSpot's page is copied.
15
+ //
16
+ // AN APP AN ACCOUNT BUILT (screens/developer.tsx) is installed as HubSpot's install page describes
17
+ // (https://developers.hubspot.com/docs/apps/developer-platform/build-apps/manage-apps-in-hubspot, "Install an OAuth
18
+ // marketplace app"): the person is signed in (screens/session.tsx), the redirect URL is one the app registered, an
19
+ // OAuth marketplace app installs outside test accounts only once its AUP is signed and at most 25 times before it is
20
+ // listed, the person selects the account to install it in, reviews the scopes and clicks **Connect app**, and for an
21
+ // app HubSpot has not verified confirms in a dialog ("type **I accept the risk**, and click **Connect**"). The code is
22
+ // the account's; its exchange checks the app's client secret, installs the app in the account (the page's
23
+ // "TOKEN_EXCHANGE: … At this point, the app is installed in the user's account"), and answers the account's `hub_id`;
24
+ // every token it issues acts in that account (portal.ts) until it expires, the install is uninstalled or the account
25
+ // is closed (tokenPortal).
26
+ //
27
+ // Where the documentation stops and the twin decides, for an app no account of the World built: the portal is the
28
+ // World's own (hub 2953265, user twin@hubspot.local), so a World pointing an app at the twin with a client_id of its own
29
+ // keeps working: any redirect_uri may start an install and the client_secret is not checked; the consent lists each scope by its name (the twin has no table of HubSpot's scope
30
+ // descriptions); a code is redeemed once, within ten minutes, by the app it was issued to at the redirect it
31
+ // named, and a bad code is `invalid_grant` with status BAD_AUTH_CODE; each refresh mints a new refresh token and the
32
+ // earlier ones stay valid; another grant type, and the metadata of an unknown or expired token (401 in the API's error
33
+ // envelope), are worded by the twin; a denial redirects with `error=access_denied` (RFC 6749 §4.1.2.1); the consent is
34
+ // served only on HubSpot's app hosts (*.hubspot.com), the token endpoints on the API host; the rest of the API accepts
35
+ // any bearer, as it did before (hubspot-server.ts), so a minted token is not required there.
36
+ import { createHash } from 'node:crypto';
37
+ import { applyTwinWrite, projectResources } from '@volter/world-core';
38
+ import { Consent, CONSENT_CSS, flowPage, Portal, PORTAL_CSS } from '@volter/world-ui';
39
+ import { accountOf, appByClientId, installOf, keep, signedIn, stored, type Row as AccountRow } from './accounts.ts';
40
+ import { TWIN_HUB_ID, type PortalScope } from './portal.ts';
41
+
42
+ export { TWIN_HUB_ID };
43
+ const SERVICE = 'hubspot';
44
+ const TWIN_USER = { id: 1, email: 'twin@hubspot.local', domain: 'twin.hubspot.local' };
45
+ const CODE_TTL_MS = 10 * 60_000;
46
+ const EXPIRES_IN = 1800;
47
+ const INSTALL_CAP = 25;
48
+ const ACCEPT_RISK = 'I accept the risk';
49
+
50
+ const SKIN = `
51
+ body { background: #ffffff; color: #33475b; font-family: "Lexend Deca", Helvetica, Arial, sans-serif; }
52
+ .consent-badge { background: #f5f8fa; border: 1px solid #cbd6e2; color: #33475b; }
53
+ .consent-vendor { background: #ff7a59; }
54
+ .consent-link, .consent-box, .consent-actions { border-color: #cbd6e2; }
55
+ .consent-who small, .consent-permission summary small, .consent-note { color: #516f90; }
56
+ .consent-deny { background: #ffffff; border-color: #cbd6e2; color: #33475b; }
57
+ .consent-allow, .portal-primary { background: #ff7a59; border-color: #ff7a59; color: #ffffff; }
58
+ .portal-side { background: #f5f8fa; border-right: 1px solid #cbd6e2; }
59
+ .portal-notice { background: #fef8f0; border: 1px solid #f5c26b; }
60
+ `;
61
+
62
+ type Row = Record<string, unknown>;
63
+ const hex = (s: string): string => createHash('sha256').update(s).digest('hex');
64
+ const rows = (type: string, root?: string): Row[] => (projectResources(SERVICE, root) as unknown as Row[]).filter((r) => r.type === type);
65
+ async function write(type: string, id: string, fields: Row, root: string | undefined, at: string, actor = TWIN_USER.email): Promise<void> {
66
+ await applyTwinWrite(SERVICE, { operation: `oauth.${type}`, subjectType: type, subjectId: id, fields, occurredAt: at, actor: { kind: 'human', id: actor } }, root);
67
+ }
68
+
69
+ async function fieldsOf(request: Request): Promise<Record<string, string>> {
70
+ const out: Record<string, string> = Object.fromEntries(new URL(request.url).searchParams);
71
+ if (request.method !== 'POST') return out;
72
+ const text = await request.text();
73
+ if ((request.headers.get('content-type') ?? '').includes('json')) {
74
+ try { for (const [k, v] of Object.entries(JSON.parse(text) as Row)) if (v != null) out[k] = String(v); return out; } catch { /* read as a form */ }
75
+ }
76
+ return { ...out, ...Object.fromEntries(new URLSearchParams(text)) };
77
+ }
78
+
79
+ const scopesOf = (raw: string | undefined): string[] => [...new Set((raw ?? '').split(/[\s,+]+/).filter(Boolean))];
80
+ const parses = (u: string | undefined): u is string => { try { return !!u && !!new URL(u); } catch { return false; } };
81
+ const errorPage = (status: number, message: string): Response =>
82
+ flowPage({ title: 'HubSpot', status, css: [CONSENT_CSS, SKIN], body: <main className="consent"><h1 className="consent-heading">{message}</h1></main> });
83
+ function redirect(to: string, params: Record<string, string | undefined>): Response {
84
+ const url = new URL(to);
85
+ for (const [k, v] of Object.entries(params)) if (v !== undefined) url.searchParams.set(k, v);
86
+ return new Response(null, { status: 302, headers: { location: url.toString() } });
87
+ }
88
+ const tokenError = (status: string, message: string): Response =>
89
+ Response.json({ error: 'invalid_grant', error_description: message, status, message }, { status: 400 });
90
+
91
+ /** The fields the consent carries from the install URL to its answer. */
92
+ function carried(p: Record<string, string>): Record<string, string> {
93
+ return { client_id: p.client_id!, redirect_uri: p.redirect_uri!, scope: scopesOf(p.scope).join(' '), ...(p.optional_scope ? { optional_scope: p.optional_scope } : {}), ...(p.state !== undefined ? { state: p.state } : {}) };
94
+ }
95
+
96
+ // ── an app no account of the World built: the World's own portal ────────────────────────────
97
+ async function consent(request: Request): Promise<Response> {
98
+ const p = await fieldsOf(request);
99
+ if (!p.client_id) return errorPage(400, 'Missing client_id');
100
+ if (!parses(p.redirect_uri)) return errorPage(400, 'Missing or invalid redirect_uri');
101
+ const scopes = [...scopesOf(p.scope), ...scopesOf(p.optional_scope)];
102
+ if (!scopes.length) return errorPage(400, 'Missing scope');
103
+ return flowPage({
104
+ title: `Connect ${p.client_id} to HubSpot | HubSpot`,
105
+ css: [CONSENT_CSS, SKIN],
106
+ body: (
107
+ <Consent
108
+ heading={`Connect ${p.client_id} to your HubSpot account`}
109
+ app={{ name: p.client_id }}
110
+ request={`${TWIN_USER.domain} · Hub ID ${TWIN_HUB_ID}`}
111
+ permissions={scopes.map((s) => ({ title: s, detail: 'Requested scope' }))}
112
+ action="/oauth/authorize"
113
+ fields={carried(p)}
114
+ deny={{ name: 'answer', value: 'cancel', label: 'Cancel' }}
115
+ allow={{ name: 'answer', value: 'allow', label: 'Connect app' }}
116
+ note={`You will be sent to ${new URL(p.redirect_uri).origin}`}
117
+ />
118
+ ),
119
+ });
120
+ }
121
+
122
+ async function answer(request: Request, root: string | undefined, at: string): Promise<Response> {
123
+ const p = await fieldsOf(request);
124
+ if (!p.client_id || !parses(p.redirect_uri)) return errorPage(400, 'Missing client_id or redirect_uri');
125
+ if (p.answer !== 'allow') return redirect(p.redirect_uri, { error: 'access_denied', state: p.state });
126
+ const scopes = [...scopesOf(p.scope), ...scopesOf(p.optional_scope)];
127
+ const code = await mintCode(p.client_id, p.redirect_uri, scopes, { hubId: TWIN_HUB_ID }, root, at);
128
+ return redirect(p.redirect_uri, { code, state: p.state });
129
+ }
130
+
131
+ async function mintCode(clientId: string, redirectUri: string, scopes: string[], extra: Row, root: string | undefined, at: string, actor?: string): Promise<string> {
132
+ const n = rows('oauth_code', root).length + 1;
133
+ const code = `na1-${hex(`hubspotcode:${clientId}:${at}:${n}`).slice(0, 32)}`;
134
+ await write('oauth_code', code, { code, client_id: clientId, redirect_uri: redirectUri, scopes, created_at: at, used: false, ...extra }, root, at, actor);
135
+ return code;
136
+ }
137
+
138
+ // ── an app an account of the World built ───────────────────────────────────────────────────
139
+ /** Why the app cannot be installed from this URL, or undefined. */
140
+ function refusalFor(app: AccountRow, p: Record<string, string>, root?: string): string | undefined {
141
+ if (!(app.redirectUrls as string[]).includes(p.redirect_uri ?? '')) return 'The redirect URL is not one this app allows.';
142
+ const scopes = scopesOf(p.scope);
143
+ const missing = (app.requiredScopes as string[]).filter((s) => !scopes.includes(s));
144
+ if (missing.length) return `The install URL is missing required scopes: ${missing.join(', ')}.`;
145
+ if (app.distribution === 'marketplace' && !app.aup_signed_at) return 'This app can only be installed in developer test accounts.';
146
+ if (app.distribution === 'marketplace' && stored('_install', root).filter((i) => i.appId === app.appId && !i.uninstalled_at).length >= INSTALL_CAP) return 'This app has reached its install limit.';
147
+ return undefined;
148
+ }
149
+
150
+ function appConsent(app: AccountRow, person: AccountRow, p: Record<string, string>, root?: string): Response {
151
+ const account = accountOf(Number(person.hubId), root)!;
152
+ return flowPage({
153
+ title: `Connect ${String(app.name)} | HubSpot`,
154
+ css: [CONSENT_CSS, SKIN],
155
+ body: (
156
+ <Consent
157
+ heading={`Connect ${String(app.name)} to HubSpot`}
158
+ app={{ name: String(app.name) }}
159
+ request={`${String(person.email)} · This app has not been verified by HubSpot`}
160
+ permissions={['oauth', ...(app.requiredScopes as string[])].filter((s, i, all) => all.indexOf(s) === i).map((s) => ({ title: s, detail: 'Required' }))}
161
+ action="/oauth/authorize"
162
+ fields={carried(p)}
163
+ choices={[{ id: 'account', label: 'Select an account', value: String(account.hubId), options: [{ value: String(account.hubId), label: `${String(account.name)} (${String(account.hubId)})` }] }]}
164
+ deny={{ name: 'answer', value: 'cancel', label: 'Cancel' }}
165
+ allow={{ name: 'answer', value: 'allow', label: 'Connect app' }}
166
+ note={`You will be sent to ${new URL(p.redirect_uri!).origin}`}
167
+ />
168
+ ),
169
+ });
170
+ }
171
+
172
+ /** The dialog an unverified app's install asks a person to confirm in. */
173
+ function acceptRisk(app: AccountRow, p: Record<string, string>, notice?: string): Response {
174
+ const fields = { ...carried(p), account: p.account ?? '', answer: 'allow' };
175
+ return flowPage({
176
+ title: `Connect ${String(app.name)} | HubSpot`,
177
+ css: [PORTAL_CSS, SKIN],
178
+ body: (
179
+ <Portal merchant={`Connect ${String(app.name)}`} notice={notice ?? 'This app has not been verified by HubSpot. Double-check all details of the app to confirm that it matches what you expect.'}
180
+ sections={[{ heading: 'App details', items: [{ title: String(app.name), detail: String(app.description ?? ''), note: `Requests: ${['oauth', ...(app.requiredScopes as string[])].join(', ')}` }] }]}
181
+ forms={[{ heading: 'Connect unverified app', action: '/oauth/authorize', fields: [
182
+ ...Object.entries(fields).map(([id, value]) => ({ id, label: '', type: 'hidden', value })),
183
+ { id: 'confirm', label: `Type ${ACCEPT_RISK}` },
184
+ ], submit: { label: 'Connect' } }]} />
185
+ ),
186
+ });
187
+ }
188
+
189
+ async function appAnswer(app: AccountRow, request: Request, p: Record<string, string>, root: string | undefined, at: string): Promise<Response> {
190
+ if (p.answer !== 'allow') return redirect(p.redirect_uri!, { error: 'access_denied', state: p.state });
191
+ const person = signedIn(request, root);
192
+ if (!person) return errorPage(401, 'Log in to HubSpot to connect this app.');
193
+ const hubId = Number(p.account);
194
+ if (hubId !== Number(person.hubId) || accountOf(hubId, root)?.closed_at) return errorPage(403, "You don't have access to this account.");
195
+ if (p.confirm === undefined) return acceptRisk(app, p);
196
+ if (p.confirm !== ACCEPT_RISK) return acceptRisk(app, p, `Type ${ACCEPT_RISK} to connect this app.`);
197
+ const code = await mintCode(String(app.client_id), p.redirect_uri!, ['oauth', ...(app.requiredScopes as string[])].filter((s, i, all) => all.indexOf(s) === i), { hubId, appId: app.appId, person: person.email }, root, at, String(person.email));
198
+ return redirect(p.redirect_uri!, { code, state: p.state });
199
+ }
200
+
201
+ async function authorize(request: Request, root: string | undefined, at: string): Promise<Response> {
202
+ const p = await fieldsOf(request);
203
+ const app = p.client_id ? appByClientId(p.client_id, root) : undefined;
204
+ if (!app) return request.method === 'GET' ? consent(new Request(request.url)) : answer(new Request(request.url, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(p).toString() }), root, at);
205
+ if (!parses(p.redirect_uri)) return errorPage(400, 'Missing or invalid redirect_uri');
206
+ const refused = refusalFor(app, p, root);
207
+ if (refused) return errorPage(400, refused);
208
+ if (request.method === 'POST') return appAnswer(app, request, p, root, at);
209
+ const person = signedIn(request, root);
210
+ const url = new URL(request.url);
211
+ if (!person) return new Response(null, { status: 302, headers: { location: `/login?loginRedirectUrl=${encodeURIComponent(url.pathname + url.search)}` } });
212
+ return appConsent(app, person, p, root);
213
+ }
214
+
215
+ // ── the token endpoints ────────────────────────────────────────────────────────────────────
216
+ async function issue(clientId: string, scopes: string[], seed: string, extra: Row, root: string | undefined, at: string): Promise<Response> {
217
+ const access = `CN${hex(`hubspotaccess:${seed}`).slice(0, 48)}`;
218
+ const refresh = `na1-${hex(`hubspotrefresh:${seed}`).slice(0, 8)}-${hex(`hubspotrefresh2:${seed}`).slice(0, 28)}`;
219
+ const hubId = Number(extra.hubId ?? TWIN_HUB_ID);
220
+ await write('oauth_token', access, { access_token: access, refresh_token: refresh, client_id: clientId, scopes, issued_at: at, ...extra }, root, at);
221
+ return Response.json({ token_type: 'bearer', refresh_token: refresh, hub_id: hubId, scopes, access_token: access, expires_in: EXPIRES_IN });
222
+ }
223
+
224
+ async function token(request: Request, root: string | undefined, at: string): Promise<Response> {
225
+ const p = await fieldsOf(request);
226
+ const app = p.client_id ? appByClientId(p.client_id, root) : undefined;
227
+ // a built app's client secret is checked; where the documentation stops, HubSpot's words for a wrong one are the twin's
228
+ if (app && p.client_secret !== app.client_secret) return Response.json({ error: 'invalid_client', error_description: 'client_id or client_secret is invalid', status: 'BAD_CLIENT_ID', message: 'client_id or client_secret is invalid' }, { status: 400 });
229
+ if (p.grant_type === 'authorization_code') {
230
+ const code = rows('oauth_code', root).find((r) => r.code === p.code);
231
+ if (!code || code.used === true || code.client_id !== p.client_id || code.redirect_uri !== p.redirect_uri || Date.parse(at) - Date.parse(String(code.created_at)) > CODE_TTL_MS) {
232
+ return tokenError('BAD_AUTH_CODE', 'auth code is invalid, expired or already used');
233
+ }
234
+ await write('oauth_code', String(code.code), { used: true }, root, at);
235
+ const extra: Row = { hubId: code.hubId ?? TWIN_HUB_ID, ...(app ? { appId: app.appId } : {}) };
236
+ if (app) {
237
+ const hubId = Number(code.hubId);
238
+ const live = installOf(Number(app.appId), hubId, root);
239
+ if (!live) await keep('_install', `install:${String(app.appId)}:${hubId}:${at}`, { appId: app.appId, hubId, installed_by: code.person, scopes: code.scopes, installed_at: at, uninstalled_at: null }, root, at, String(code.person));
240
+ }
241
+ return issue(String(p.client_id), (code.scopes as string[]) ?? [], `${code.code}`, extra, root, at);
242
+ }
243
+ if (p.grant_type === 'refresh_token') {
244
+ const held = rows('oauth_token', root).filter((r) => r.refresh_token === p.refresh_token && r.client_id === p.client_id);
245
+ const first = held[0];
246
+ if (!first || (app && !installOf(Number(app.appId), Number(first.hubId), root))) return tokenError('BAD_REFRESH_TOKEN', 'refresh token is invalid, expired or revoked');
247
+ return issue(String(p.client_id), (first.scopes as string[]) ?? [], `${p.refresh_token}:${at}:${held.length}`, { hubId: first.hubId ?? TWIN_HUB_ID, ...(app ? { appId: app.appId } : {}) }, root, at);
248
+ }
249
+ return Response.json({ error: 'unsupported_grant_type', error_description: 'grant_type must be authorization_code or refresh_token', status: 'BAD_GRANT_TYPE', message: 'grant_type must be authorization_code or refresh_token' }, { status: 400 });
250
+ }
251
+
252
+ function tokenInfo(access: string, root: string | undefined, at: string): Response {
253
+ const t = rows('oauth_token', root).find((r) => r.access_token === access);
254
+ const expiresIn = t ? EXPIRES_IN - Math.floor((Date.parse(at) - Date.parse(String(t.issued_at))) / 1000) : -1;
255
+ if (!t || expiresIn <= 0) return Response.json({ status: 'error', message: 'The OAuth token used to make this call expired', category: 'EXPIRED_AUTHENTICATION' }, { status: 401 });
256
+ const hubId = Number(t.hubId ?? TWIN_HUB_ID);
257
+ return Response.json({ token: access, user: TWIN_USER.email, hub_domain: TWIN_USER.domain, scopes: t.scopes, hub_id: hubId, app_id: t.appId ?? Number.parseInt(hex(String(t.client_id)).slice(0, 6), 16), expires_in: expiresIn, user_id: TWIN_USER.id, token_type: 'access' });
258
+ }
259
+
260
+ /** The account a CRM request's bearer acts in: an access token the install issued (refused once expired, its app
261
+ * uninstalled or its account closed), a static app's token, or, for a credential the twin never issued, undefined (the
262
+ * World's own account). Where the documentation stops: HubSpot's refusal words for a revoked token are the twin's. */
263
+ export function tokenPortal(authorization: string | null, root: string | undefined, at: string): PortalScope | Response | undefined {
264
+ const bearer = /^Bearer\s+(.+)$/i.exec(authorization ?? '')?.[1]?.trim();
265
+ if (!bearer) return undefined;
266
+ const refuse = (category: string, message: string): Response => Response.json({ status: 'error', message, correlationId: hex(`${category}:${bearer}:${at}`).replace(/^(.{8})(.{4})(.{4})(.{4})(.{12}).*$/, '$1-$2-$3-$4-$5'), category }, { status: 401 });
267
+ const staticInstall = stored('_install', root).find((i) => i.static_token === bearer);
268
+ if (staticInstall) {
269
+ if (staticInstall.uninstalled_at || accountOf(Number(staticInstall.hubId), root)?.closed_at) return refuse('INVALID_AUTHENTICATION', 'Authentication credentials not found. This API supports OAuth 2.0 authentication.');
270
+ return { hub: Number(staticInstall.hubId), source: 'INTEGRATION', sourceId: String(staticInstall.appId) };
271
+ }
272
+ const t = rows('oauth_token', root).find((r) => r.access_token === bearer);
273
+ if (!t || t.appId === undefined) return undefined;
274
+ const hubId = Number(t.hubId);
275
+ const ago = Math.floor((Date.parse(at) - Date.parse(String(t.issued_at))) / 1000) - EXPIRES_IN;
276
+ if (ago >= 0) return refuse('EXPIRED_AUTHENTICATION', `The OAuth token used to make this call expired ${ago} second(s) ago.`);
277
+ if (!installOf(Number(t.appId), hubId, root) || accountOf(hubId, root)?.closed_at) return refuse('INVALID_AUTHENTICATION', 'Authentication credentials not found. This API supports OAuth 2.0 authentication.');
278
+ return { hub: hubId, source: 'INTEGRATION', sourceId: String(t.appId) };
279
+ }
280
+
281
+ /** app.hubspot.com's consent and api.hubapi.com's OAuth v1 endpoints, or undefined for any other request. */
282
+ export async function hubspotOAuth(request: Request, root: string | undefined, now: () => string): Promise<Response | undefined> {
283
+ const url = new URL(request.url);
284
+ const path = url.pathname.replace(/\/+$/, '');
285
+ // the vendor host a redirected request names (the injector, a hosted World), else its Host, else the URL's
286
+ const host = (request.headers.get('x-volter-twin-original-host') ?? request.headers.get('host') ?? url.host).split(':')[0]!.toLowerCase();
287
+ const appHost = host === 'hubspot.com' || host.endsWith('.hubspot.com') || url.hostname.endsWith('.hubspot.com');
288
+ if (path === '/oauth/authorize' && appHost) {
289
+ if (request.method === 'GET' || request.method === 'POST') return authorize(request, root, now());
290
+ return undefined;
291
+ }
292
+ if (path === '/oauth/v1/token' && request.method === 'POST') return token(request, root, now());
293
+ const info = /^\/oauth\/v1\/access-tokens\/([^/]+)$/.exec(path);
294
+ if (info && request.method === 'GET') return tokenInfo(decodeURIComponent(info[1]!), root, now());
295
+ return undefined;
296
+ }
@@ -0,0 +1,115 @@
1
+ // HubSpot twin HTTP server — the pack's wire as the derived dispatch over HubSpot's nine published documents
2
+ // (docs/contributing/architecture.md, "Protocol 3"), so an application calling HubSpot with raw fetch (Dub) or an
3
+ // UNMODIFIED `@hubspot/api-client` (pointed here with its own `basePath` option, or routed by the world injector's
4
+ // api.hubapi.com interception) works against it. Writable by default; pass readOnly to reject writes (R4). The CRM API
5
+ // accepts any bearer: a token the install minted is not required there.
6
+ //
7
+ // FETCH-FIRST (runtime contract R12b): the serve path is the plain fetch below, the server one line of Bun.serve around
8
+ // the same closure. The 204-with-no-body case is the kernel adapter's own null-body rule — `handleHubspotTwinRequest`
9
+ // returns `{ status: 204, body: null, headers }`, served as a genuinely empty response carrying those headers.
10
+ import { createDerivedFetch, createTwinFetchFromHandler, serveHttp, statefulTwinManifest, worldNow, type DerivedFetch, type DerivedSurface } from '@volter/world-core';
11
+ import surface from './generated/surface.gen.json' with { type: 'json' };
12
+ import { hubspotHandlers } from './semantics/crm.ts';
13
+ import { handleHubspotTwinRequest, hubspotUnknownRequest, specObjectPath } from './hubspot-twin.ts';
14
+ import { hubspotOAuth, tokenPortal } from './hubspot-oauth.tsx';
15
+ import { signUpDoor } from './accounts.ts';
16
+ import { inPortal } from './portal.ts';
17
+ import { deliveriesDoor } from './webhooks.ts';
18
+ import { accountPages } from './screens/account.tsx';
19
+ import { crmPages } from './screens/crm.tsx';
20
+ import { developerPages, uploadDoor } from './screens/developer.tsx';
21
+ import { FORMS_HOST, formPages, formSubmission } from './screens/forms.tsx';
22
+ import { signIn } from './screens/session.tsx';
23
+
24
+ /** Options every HubSpot-twin HTTP surface needs, independent of who owns the socket. */
25
+ export interface HubspotTwinFetchOptions {
26
+ root?: string;
27
+ readOnly?: boolean;
28
+ }
29
+
30
+ /**
31
+ * The pack's wire: the twin's discovery door (`GET /twin`) and HubSpot's OAuth consent (app.hubspot.com's
32
+ * /oauth/authorize, a hosted flow: hubspot-oauth.tsx) in front, then the derived dispatch over the nine documents'
33
+ * union. A /crm/v3/objects/ path naming its object type in the spelling the spec's paths do not use (`deals` for the
34
+ * Deals document's `0-3`, `0-1` for `contacts`) is dispatched as the spelling they use (`specObjectPath`). The
35
+ * operations the twin serves go to their handlers (semantics/crm.ts); every other one, and a path no document has,
36
+ * is the gap.
37
+ */
38
+ export function createHubspotTwinFetch(options: HubspotTwinFetchOptions): DerivedFetch {
39
+ const answer = createTwinFetchFromHandler(handleHubspotTwinRequest, {
40
+ ...options,
41
+ manifest: statefulTwinManifest({
42
+ vendor: 'hubspot',
43
+ twinOf: 'the HubSpot CRM v3/v4 API and its OAuth v1 token endpoints',
44
+ stores: 'CRM records (contacts, companies, deals, tickets), their properties and property groups, pipelines and stages, owners, and associations; OAuth codes and tokens',
45
+ }),
46
+ });
47
+ // a read-only twin installs nothing: its token endpoints answer as the request handler answers any write
48
+ const oauth = async (request: Request): Promise<Response> =>
49
+ (options.readOnly ? undefined : await hubspotOAuth(request, options.root, worldNow)) ?? answer(request);
50
+ const derived = createDerivedFetch({
51
+ surface: surface as DerivedSurface,
52
+ handlers: hubspotHandlers(answer, oauth),
53
+ gap: (request) => {
54
+ const url = new URL(request.url);
55
+ const refused = hubspotUnknownRequest(request.method, url.pathname);
56
+ return Response.json(refused.body, { status: refused.status, headers: refused.headers });
57
+ },
58
+ });
59
+ return Object.assign(async function hubspotTwinFetch(request: Request): Promise<Response> {
60
+ try {
61
+ const url = new URL(request.url);
62
+ const bare = url.pathname.replace(/\/+$/, '') || '/';
63
+ if (request.method === 'GET' && bare === '/twin') return await answer(request);
64
+ const root = options.root;
65
+ const at = worldNow();
66
+ // the World's doors, on every host (accounts.ts, screens/developer.tsx, webhooks.ts)
67
+ if (request.method === 'GET' && bare === '/_twin/deliveries') return deliveriesDoor(request, root);
68
+ if (!options.readOnly && request.method === 'POST') {
69
+ const user = /^\/_twin\/users\/([^/]+)\/password$/.exec(bare);
70
+ if (user) return await signUpDoor(decodeURIComponent(user[1]!).toLowerCase(), request, root, at);
71
+ const project = /^\/_twin\/accounts\/(\d+)\/projects$/.exec(bare);
72
+ if (project) return await uploadDoor(Number(project[1]), request, root, at);
73
+ }
74
+ // the vendor host a redirected request names (the injector, a hosted World), else its own
75
+ const host = (request.headers.get('x-volter-twin-original-host') ?? url.host).split(':')[0]!.toLowerCase();
76
+ if (host === FORMS_HOST) return (await formSubmission(request, root, at)) ?? notFoundPage();
77
+ if (host === 'app.hubspot.com') {
78
+ if (options.readOnly && request.method !== 'GET') return notFoundPage();
79
+ if (bare === '/login') return await signIn(request, root, at);
80
+ if (bare === '/oauth/authorize') return (await hubspotOAuth(request, root, worldNow)) ?? notFoundPage();
81
+ return (await accountPages(request, root, at)) ?? (await developerPages(request, root, at)) ?? (await crmPages(request, root, at)) ?? (await formPages(request, root, at)) ?? notFoundPage();
82
+ }
83
+ // the consent on another hubspot.com host a World routes here (hubspotOAuth answers only on HubSpot's app hosts)
84
+ if (!options.readOnly && bare === '/oauth/authorize') {
85
+ const consent = await hubspotOAuth(request, root, worldNow);
86
+ if (consent) return consent;
87
+ }
88
+ const path = specObjectPath(url.pathname);
89
+ const routed = path === url.pathname ? request : new Request(Object.assign(url, { pathname: path }), request);
90
+ // the account the bearer acts in (portal.ts): refused if the twin issued it and it no longer works
91
+ const scope = tokenPortal(request.headers.get('authorization'), root, at);
92
+ if (scope instanceof Response) return scope;
93
+ return scope ? await inPortal(scope, () => derived(routed)) : await derived(routed);
94
+ } catch (error) {
95
+ // Never let an exception escape the fetch callback (§8): answer with a vendor-shaped 500
96
+ // so an in-process fidelity test sees a response, not a hang.
97
+ return new Response(JSON.stringify({ status: 'error', message: `internal twin error: ${String(error)}`, category: 'INTERNAL_ERROR', correlationId: '00000000-0000-0000-0000-000000000000' }), {
98
+ status: 500, headers: { 'content-type': 'application/json' },
99
+ });
100
+ }
101
+ }, { owners: () => derived.owners() });
102
+ }
103
+
104
+ const notFoundPage = (): Response => new Response('Not Found', { status: 404, headers: { 'content-type': 'text/plain' } });
105
+
106
+ export async function createHubspotTwinServer(options: { root?: string; port?: number; readOnly?: boolean }): Promise<{ port: number; url: string; stop: () => void }> {
107
+ const server = await serveHttp({
108
+ hostname: '127.0.0.1',
109
+ port: options.port ?? 0,
110
+ idleTimeout: 60,
111
+ fetch: createHubspotTwinFetch(options),
112
+ });
113
+ const port = server.port ?? options.port ?? 0;
114
+ return { port, url: `http://127.0.0.1:${port}`, stop: () => server.stop(true) };
115
+ }