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