@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,43 @@
1
+ :root {
2
+ --bg: #f5f8fa;
3
+ --panel: #ffffff;
4
+ --line: #dfe3eb;
5
+ --text: #33475b;
6
+ --muted: #7c98b6;
7
+ --accent: #eaf0f6;
8
+ --brand: #ff7a59;
9
+ --ok: #00bda5;
10
+ --warn: #f5c26b;
11
+ --bad: #f2545b;
12
+ }
13
+ * { box-sizing: border-box; }
14
+ body { margin: 0; font-family: -apple-system, system-ui, "Helvetica Neue", sans-serif; background: var(--bg); color: var(--text); }
15
+ .app { display: grid; grid-template-columns: 220px 1fr; grid-template-rows: 56px 1fr; min-height: 100vh; }
16
+ .topbar { grid-column: 1 / 3; display: flex; align-items: center; justify-content: space-between; padding: 0 20px; border-bottom: 1px solid var(--line); background: var(--panel); }
17
+ .brand { font-weight: 700; color: var(--brand); letter-spacing: 0.2px; }
18
+ .view-toggle { border: 1px solid var(--line); background: var(--panel); color: var(--text); border-radius: 4px; padding: 6px 14px; cursor: pointer; font-size: 13px; }
19
+ .view-toggle:hover { background: var(--accent); }
20
+ .sidebar { border-right: 1px solid var(--line); padding: 14px 10px; background: var(--panel); }
21
+ .nav-item { display: block; width: 100%; text-align: left; background: none; border: none; color: var(--muted); padding: 9px 12px; border-radius: 4px; cursor: pointer; font-size: 14px; }
22
+ .nav-item.active, .nav-item:hover { background: var(--accent); color: var(--text); }
23
+ .content { padding: 24px; overflow: auto; }
24
+ table { width: 100%; border-collapse: collapse; background: var(--panel); border: 1px solid var(--line); border-radius: 6px; }
25
+ th { text-align: left; color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.4px; padding: 10px 12px; border-bottom: 1px solid var(--line); }
26
+ td { padding: 12px; border-bottom: 1px solid var(--line); font-size: 14px; }
27
+ tr:last-child td { border-bottom: none; }
28
+ .cell-title { font-weight: 600; }
29
+ .cell-id { color: var(--muted); font-variant-numeric: tabular-nums; }
30
+ .cell-prop-name { color: var(--muted); font-size: 12px; width: 40%; }
31
+ .pill { display: inline-block; padding: 3px 10px; border-radius: 999px; font-size: 12px; background: var(--accent); }
32
+ .pill-ok { background: rgba(0, 189, 165, 0.16); color: var(--ok); }
33
+ .pill-warn { background: rgba(245, 194, 107, 0.22); color: #b07d1a; }
34
+ .pill-bad { background: rgba(242, 84, 91, 0.16); color: var(--bad); }
35
+ .empty { color: var(--muted); padding: 28px; background: var(--panel); border: 1px dashed var(--line); border-radius: 6px; }
36
+ .deal-board { display: flex; gap: 14px; align-items: flex-start; overflow-x: auto; padding-bottom: 8px; }
37
+ .board-column { min-width: 220px; background: var(--panel); border: 1px solid var(--line); border-radius: 6px; padding: 12px; }
38
+ .board-column-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
39
+ .board-count { color: var(--muted); font-size: 12px; }
40
+ .deal-card { border: 1px solid var(--line); border-radius: 4px; padding: 10px; margin-bottom: 8px; }
41
+ .deal-amount { color: var(--muted); font-size: 13px; margin-top: 4px; }
42
+ .record-detail { background: var(--panel); border: 1px solid var(--line); border-radius: 6px; padding: 20px; }
43
+ .detail-title { margin: 0 0 14px; font-size: 20px; }
@@ -0,0 +1,15 @@
1
+ import { type HubspotRow, type MirrorSectionKey } from '../src/hubspot-mirror-ui.js';
2
+ /** A CRM record list — one `list-row` landmark per record, carrying its HubSpot record id. */
3
+ export declare function RecordsTable({ section, rows }: {
4
+ section: MirrorSectionKey;
5
+ rows: HubspotRow[];
6
+ }): import("react").JSX.Element;
7
+ /** The deal pipeline board — the screen a sales rep actually works in, one column per stage. */
8
+ export declare function DealBoard({ rows }: {
9
+ rows: HubspotRow[];
10
+ }): import("react").JSX.Element;
11
+ /** Record detail — the property sheet HubSpot shows on the right of a record. */
12
+ export declare function RecordDetail({ section, rows }: {
13
+ section: MirrorSectionKey;
14
+ rows: HubspotRow[];
15
+ }): import("react").JSX.Element;
@@ -0,0 +1,59 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ // HubSpot mirror — React/TSX CRM client. Renders the twin's contacts, companies, deals and
3
+ // tickets by fetching the twin's OWN CRM REST API on the same origin
4
+ // (`/crm/v3/objects/contacts`, …) — HubSpot's real paths, the same ones the SDK calls.
5
+ // API↔UI parity: the screen shows real twin state, not a hardcoded shell.
6
+ import { createRoot } from 'react-dom/client';
7
+ import { useEffect, useState } from 'react';
8
+ import { MIRROR_SECTIONS, SECTION_COLUMNS, formatAmount, groupByStage, recordTitle, stageTone, } from "../src/hubspot-mirror-ui.js";
9
+ // WHERE THIS MIRROR LIVES. Served at a vendor root the base is '' (fetches are root-relative, as
10
+ // before); served under a path prefix with a <base> tag every read and write resolves inside that
11
+ // prefix instead of escaping it.
12
+ const WIRE_BASE = typeof document === 'undefined' || document.querySelector('base[href]') === null ? '' : new URL('.', document.baseURI).pathname.replace(/\/$/, '');
13
+ // Map a tone to a STATIC literal class name (a template-literal class is erased by the
14
+ // minifier, so `pill-${tone}` would vanish from the bundle — assert these instead).
15
+ const PILL_CLASS = { ok: 'pill pill-ok', warn: 'pill pill-warn', bad: 'pill pill-bad', '': 'pill' };
16
+ /** Unwrap HubSpot's collection envelope (`{ results, paging }`) to its results array. */
17
+ async function fetchRows(path) {
18
+ const res = await fetch(`${WIRE_BASE}${path}`);
19
+ const body = (await res.json());
20
+ return Array.isArray(body.results) ? body.results : [];
21
+ }
22
+ /** A CRM record list — one `list-row` landmark per record, carrying its HubSpot record id. */
23
+ export function RecordsTable({ section, rows }) {
24
+ if (rows.length === 0)
25
+ return _jsxs("div", { className: "empty", children: ["No ", section, " yet."] });
26
+ const columns = SECTION_COLUMNS[section];
27
+ return (_jsxs("table", { className: "records-table", children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { children: "Name" }), columns.map((c) => _jsx("th", { children: c.label }, c.prop)), _jsx("th", { children: "Record ID" })] }) }), _jsx("tbody", { children: rows.map((r) => (_jsxs("tr", { className: "list-row", "data-record-id": r.id, children: [_jsx("td", { className: "cell-title", children: recordTitle(section, r.properties) }), columns.map((c) => (_jsx("td", { className: "cell-prop", children: c.prop === 'amount' ? formatAmount(r.properties?.amount)
28
+ : c.prop === 'dealstage'
29
+ ? _jsx("span", { className: PILL_CLASS[stageTone(r.properties?.dealstage)], children: r.properties?.dealstage ?? '—' })
30
+ : (r.properties?.[c.prop] ?? '—') }, c.prop))), _jsx("td", { className: "cell-id", children: r.id })] }, r.id))) })] }));
31
+ }
32
+ /** The deal pipeline board — the screen a sales rep actually works in, one column per stage. */
33
+ export function DealBoard({ rows }) {
34
+ const groups = groupByStage(rows);
35
+ if (groups.length === 0)
36
+ return _jsx("div", { className: "empty", children: "No deals yet." });
37
+ return (_jsx("div", { className: "deal-board", children: groups.map((g) => (_jsxs("section", { className: "board-column", "data-stage": g.stage, children: [_jsxs("header", { className: "board-column-head", children: [_jsx("span", { className: PILL_CLASS[stageTone(g.stage)], children: g.stage }), _jsx("span", { className: "board-count", children: g.rows.length })] }), g.rows.map((r) => (_jsxs("article", { className: "deal-card list-row", "data-record-id": r.id, children: [_jsx("div", { className: "cell-title", children: recordTitle('deals', r.properties) }), _jsx("div", { className: "deal-amount", children: formatAmount(r.properties?.amount) })] }, r.id)))] }, g.stage))) }));
38
+ }
39
+ /** Record detail — the property sheet HubSpot shows on the right of a record. */
40
+ export function RecordDetail({ section, rows }) {
41
+ const r = rows[0];
42
+ if (!r)
43
+ return _jsx("div", { className: "empty", children: "No record." });
44
+ const entries = Object.entries(r.properties ?? {});
45
+ return (_jsxs("section", { className: "record-detail", "data-record-id": r.id, children: [_jsx("h2", { className: "detail-title", children: recordTitle(section, r.properties) }), _jsx("table", { className: "property-table", children: _jsx("tbody", { children: entries.map(([k, v]) => (_jsxs("tr", { className: "list-row", "data-prop": k, children: [_jsx("td", { className: "cell-prop-name", children: k }), _jsx("td", { className: "cell-prop-value", children: v ?? '—' })] }, k))) }) })] }));
46
+ }
47
+ function App() {
48
+ const [section, setSection] = useState('contacts');
49
+ const [board, setBoard] = useState(false);
50
+ const [rows, setRows] = useState([]);
51
+ useEffect(() => {
52
+ const sec = MIRROR_SECTIONS.find((s) => s.key === section);
53
+ fetchRows(sec.path).then(setRows).catch(() => setRows([]));
54
+ }, [section]);
55
+ return (_jsxs("div", { className: "app", children: [_jsxs("header", { className: "topbar", children: [_jsx("span", { className: "brand", children: "HubSpot twin" }), section === 'deals' ? (_jsx("button", { className: "view-toggle", onClick: () => setBoard((b) => !b), children: board ? 'Table' : 'Board' })) : null] }), _jsx("nav", { className: "sidebar", children: MIRROR_SECTIONS.map((s) => (_jsx("button", { className: `nav-item${s.key === section ? ' active' : ''}`, onClick: () => { setSection(s.key); setBoard(false); }, children: s.label }, s.key))) }), _jsx("main", { className: "content", children: section === 'deals' && board ? _jsx(DealBoard, { rows: rows }) : _jsx(RecordsTable, { section: section, rows: rows }) })] }));
56
+ }
57
+ const el = typeof document !== 'undefined' ? document.getElementById('root') : null;
58
+ if (el)
59
+ createRoot(el).render(_jsx(App, {}));
@@ -0,0 +1,132 @@
1
+ // HubSpot mirror — React/TSX CRM client. Renders the twin's contacts, companies, deals and
2
+ // tickets by fetching the twin's OWN CRM REST API on the same origin
3
+ // (`/crm/v3/objects/contacts`, …) — HubSpot's real paths, the same ones the SDK calls.
4
+ // API↔UI parity: the screen shows real twin state, not a hardcoded shell.
5
+ import { createRoot } from 'react-dom/client';
6
+ import { useEffect, useState } from 'react';
7
+ import {
8
+ MIRROR_SECTIONS, SECTION_COLUMNS, formatAmount, groupByStage, recordTitle, stageTone,
9
+ type HubspotRow, type MirrorSectionKey, type PillTone,
10
+ } from '../src/hubspot-mirror-ui.ts';
11
+
12
+ // WHERE THIS MIRROR LIVES. Served at a vendor root the base is '' (fetches are root-relative, as
13
+ // before); served under a path prefix with a <base> tag every read and write resolves inside that
14
+ // prefix instead of escaping it.
15
+ const WIRE_BASE = typeof document === 'undefined' || document.querySelector('base[href]') === null ? '' : new URL('.', document.baseURI).pathname.replace(/\/$/, '');
16
+
17
+ // Map a tone to a STATIC literal class name (a template-literal class is erased by the
18
+ // minifier, so `pill-${tone}` would vanish from the bundle — assert these instead).
19
+ const PILL_CLASS: Record<PillTone, string> = { ok: 'pill pill-ok', warn: 'pill pill-warn', bad: 'pill pill-bad', '': 'pill' };
20
+
21
+ /** Unwrap HubSpot's collection envelope (`{ results, paging }`) to its results array. */
22
+ async function fetchRows(path: string): Promise<HubspotRow[]> {
23
+ const res = await fetch(`${WIRE_BASE}${path}`);
24
+ const body = (await res.json()) as { results?: unknown };
25
+ return Array.isArray(body.results) ? (body.results as HubspotRow[]) : [];
26
+ }
27
+
28
+ /** A CRM record list — one `list-row` landmark per record, carrying its HubSpot record id. */
29
+ export function RecordsTable({ section, rows }: { section: MirrorSectionKey; rows: HubspotRow[] }) {
30
+ if (rows.length === 0) return <div className="empty">No {section} yet.</div>;
31
+ const columns = SECTION_COLUMNS[section];
32
+ return (
33
+ <table className="records-table">
34
+ <thead>
35
+ <tr><th>Name</th>{columns.map((c) => <th key={c.prop}>{c.label}</th>)}<th>Record ID</th></tr>
36
+ </thead>
37
+ <tbody>
38
+ {rows.map((r) => (
39
+ <tr className="list-row" data-record-id={r.id} key={r.id}>
40
+ <td className="cell-title">{recordTitle(section, r.properties)}</td>
41
+ {columns.map((c) => (
42
+ <td className="cell-prop" key={c.prop}>
43
+ {c.prop === 'amount' ? formatAmount(r.properties?.amount)
44
+ : c.prop === 'dealstage'
45
+ ? <span className={PILL_CLASS[stageTone(r.properties?.dealstage)]}>{r.properties?.dealstage ?? '—'}</span>
46
+ : (r.properties?.[c.prop] ?? '—')}
47
+ </td>
48
+ ))}
49
+ <td className="cell-id">{r.id}</td>
50
+ </tr>
51
+ ))}
52
+ </tbody>
53
+ </table>
54
+ );
55
+ }
56
+
57
+ /** The deal pipeline board — the screen a sales rep actually works in, one column per stage. */
58
+ export function DealBoard({ rows }: { rows: HubspotRow[] }) {
59
+ const groups = groupByStage(rows);
60
+ if (groups.length === 0) return <div className="empty">No deals yet.</div>;
61
+ return (
62
+ <div className="deal-board">
63
+ {groups.map((g) => (
64
+ <section className="board-column" data-stage={g.stage} key={g.stage}>
65
+ <header className="board-column-head">
66
+ <span className={PILL_CLASS[stageTone(g.stage)]}>{g.stage}</span>
67
+ <span className="board-count">{g.rows.length}</span>
68
+ </header>
69
+ {g.rows.map((r) => (
70
+ <article className="deal-card list-row" data-record-id={r.id} key={r.id}>
71
+ <div className="cell-title">{recordTitle('deals', r.properties)}</div>
72
+ <div className="deal-amount">{formatAmount(r.properties?.amount)}</div>
73
+ </article>
74
+ ))}
75
+ </section>
76
+ ))}
77
+ </div>
78
+ );
79
+ }
80
+
81
+ /** Record detail — the property sheet HubSpot shows on the right of a record. */
82
+ export function RecordDetail({ section, rows }: { section: MirrorSectionKey; rows: HubspotRow[] }) {
83
+ const r = rows[0];
84
+ if (!r) return <div className="empty">No record.</div>;
85
+ const entries = Object.entries(r.properties ?? {});
86
+ return (
87
+ <section className="record-detail" data-record-id={r.id}>
88
+ <h2 className="detail-title">{recordTitle(section, r.properties)}</h2>
89
+ <table className="property-table">
90
+ <tbody>
91
+ {entries.map(([k, v]) => (
92
+ <tr className="list-row" data-prop={k} key={k}>
93
+ <td className="cell-prop-name">{k}</td>
94
+ <td className="cell-prop-value">{v ?? '—'}</td>
95
+ </tr>
96
+ ))}
97
+ </tbody>
98
+ </table>
99
+ </section>
100
+ );
101
+ }
102
+
103
+ function App() {
104
+ const [section, setSection] = useState<MirrorSectionKey>('contacts');
105
+ const [board, setBoard] = useState(false);
106
+ const [rows, setRows] = useState<HubspotRow[]>([]);
107
+ useEffect(() => {
108
+ const sec = MIRROR_SECTIONS.find((s) => s.key === section)!;
109
+ fetchRows(sec.path).then(setRows).catch(() => setRows([]));
110
+ }, [section]);
111
+ return (
112
+ <div className="app">
113
+ <header className="topbar">
114
+ <span className="brand">HubSpot twin</span>
115
+ {section === 'deals' ? (
116
+ <button className="view-toggle" onClick={() => setBoard((b) => !b)}>{board ? 'Table' : 'Board'}</button>
117
+ ) : null}
118
+ </header>
119
+ <nav className="sidebar">
120
+ {MIRROR_SECTIONS.map((s) => (
121
+ <button key={s.key} className={`nav-item${s.key === section ? ' active' : ''}`} onClick={() => { setSection(s.key); setBoard(false); }}>{s.label}</button>
122
+ ))}
123
+ </nav>
124
+ <main className="content">
125
+ {section === 'deals' && board ? <DealBoard rows={rows} /> : <RecordsTable section={section} rows={rows} />}
126
+ </main>
127
+ </div>
128
+ );
129
+ }
130
+
131
+ const el = typeof document !== 'undefined' ? document.getElementById('root') : null;
132
+ if (el) createRoot(el).render(<App />);
@@ -0,0 +1,30 @@
1
+ export type Row = Record<string, unknown>;
2
+ export declare const hex: (s: string) => string;
3
+ /** Every live stored subject of a bookkeeping type (these are not scoped to an account: each names its own). */
4
+ export declare function stored(type: string, root?: string): Row[];
5
+ /** Write a bookkeeping subject. */
6
+ export declare function keep(type: string, id: string, fields: Row, root: string | undefined, at: string, actor?: string): Promise<void>;
7
+ export declare const passwordHash: (email: string, password: string) => string;
8
+ export declare function accountOf(hubId: number, root?: string): Row | undefined;
9
+ export declare function personOf(email: string, root?: string): Row | undefined;
10
+ export declare function usersOf(hubId: number, root?: string): Row[];
11
+ /** An account's owners, as GET /crm/v3/owners answers them (the spec's PublicOwner): one per user. */
12
+ export declare function ownersOf(hubId: number, root?: string): Row[];
13
+ /** POST /_twin/users/:email/password {password, firstName, lastName, account?, subscription?, renewsOn?, hubId?} — stands in for two pages the
14
+ * twin does not build: HubSpot's sign-up (a new account, named `account`, whose first user is its super admin) and a
15
+ * user's acceptance of an invitation from an account's Users & Teams settings (`hubId`: the user joins that account).
16
+ * The World keeps the password's hash, never the password; 201 with the account's Hub ID and the user's ids. */
17
+ export declare function signUpDoor(email: string, request: Request, root: string | undefined, at: string): Promise<Response>;
18
+ /** The subscription an account has at `at`: a paid one whose auto-renewal was cancelled ends on its renewal date, when
19
+ * "your account will be downgraded to HubSpot's free tools"
20
+ * (https://knowledge.hubspot.com/account/how-do-i-cancel-my-hubspot-account); one that renews carries on. */
21
+ export declare function subscriptionAt(account: Row, at: string): string;
22
+ export declare const SESSION_COOKIE = "hs_session";
23
+ /** The person the request's session cookie names, or undefined. */
24
+ export declare function signedIn(request: Request, root?: string): Row | undefined;
25
+ export declare function appByClientId(clientId: string, root?: string): Row | undefined;
26
+ export declare function appById(appId: number, root?: string): Row | undefined;
27
+ export declare function appsOf(hubId: number, root?: string): Row[];
28
+ /** The live installs in an account. */
29
+ export declare function installsIn(hubId: number, root?: string): Row[];
30
+ export declare function installOf(appId: number, hubId: number, root?: string): Row | undefined;
@@ -0,0 +1,122 @@
1
+ // HUBSPOT ACCOUNTS, THEIR USERS, AND WHAT THEY HOLD OUTSIDE THE CRM — the state HubSpot's pages and its OAuth install
2
+ // keep, which no CRM operation reads or writes: an account (its Hub ID, name and subscription, closed or not), the
3
+ // people who sign in to it (each a user, and so an owner of its records), their browser sessions, the apps an account
4
+ // builds as developer projects (each with its client ID and secret, redirect URLs, scopes and webhook subscriptions),
5
+ // and each app's installs in accounts (who installed it, when, which scopes, uninstalled or not). Kept in the kernel's
6
+ // log under `_`-prefixed types that never reach the wire.
7
+ //
8
+ // Where HubSpot's documentation stops and the twin decides: an account the World makes has a Hub ID from 146000001 up,
9
+ // a user id from 80000001 and an owner id from 90000001 (HubSpot documents their existence, not their ranges); every
10
+ // user of an account the story names is its super admin or one of its users with the seats the story gives them, and
11
+ // the twin keeps no finer permission than super admin or not.
12
+ import { createHash } from 'node:crypto';
13
+ import { applyTwinWrite, projectResources } from '@volter/world-core';
14
+ const SERVICE = 'hubspot';
15
+ export const hex = (s) => createHash('sha256').update(s).digest('hex');
16
+ /** Every live stored subject of a bookkeeping type (these are not scoped to an account: each names its own). */
17
+ export function stored(type, root) {
18
+ return projectResources(SERVICE, root).filter((r) => r.type === type && r.deleted !== true);
19
+ }
20
+ /** Write a bookkeeping subject. */
21
+ export async function keep(type, id, fields, root, at, actor = 'hubspot') {
22
+ await applyTwinWrite(SERVICE, { operation: `hubspot.${type}`, subjectType: type, subjectId: id, fields, occurredAt: at, actor: { kind: 'human', id: actor } }, root);
23
+ }
24
+ // ── accounts and users ─────────────────────────────────────────────────────────────────────
25
+ export const passwordHash = (email, password) => hex(`hubspot-password:${email.toLowerCase()}:${password}`);
26
+ export function accountOf(hubId, root) {
27
+ return stored('_account', root).find((a) => a.hubId === hubId);
28
+ }
29
+ export function personOf(email, root) {
30
+ return stored('_person', root).find((p) => p.email === email.toLowerCase());
31
+ }
32
+ export function usersOf(hubId, root) {
33
+ return stored('_person', root).filter((p) => p.hubId === hubId).sort((a, b) => Number(a.userId) - Number(b.userId));
34
+ }
35
+ /** An account's owners, as GET /crm/v3/owners answers them (the spec's PublicOwner): one per user. */
36
+ export function ownersOf(hubId, root) {
37
+ return usersOf(hubId, root).map((p) => ({
38
+ id: String(p.ownerId), email: p.email, firstName: p.firstName, lastName: p.lastName, userId: p.userId,
39
+ userIdIncludingInactive: p.userId, type: 'PERSON', archived: false, teams: [],
40
+ createdAt: p.created_at, updatedAt: p.created_at,
41
+ }));
42
+ }
43
+ /** POST /_twin/users/:email/password {password, firstName, lastName, account?, subscription?, renewsOn?, hubId?} — stands in for two pages the
44
+ * twin does not build: HubSpot's sign-up (a new account, named `account`, whose first user is its super admin) and a
45
+ * user's acceptance of an invitation from an account's Users & Teams settings (`hubId`: the user joins that account).
46
+ * The World keeps the password's hash, never the password; 201 with the account's Hub ID and the user's ids. */
47
+ export async function signUpDoor(email, request, root, at) {
48
+ let body = {};
49
+ try {
50
+ body = JSON.parse(await request.text());
51
+ }
52
+ catch { /* refused below */ }
53
+ const password = typeof body.password === 'string' ? body.password : '';
54
+ const firstName = typeof body.firstName === 'string' ? body.firstName : '';
55
+ const lastName = typeof body.lastName === 'string' ? body.lastName : '';
56
+ const refuse = (message) => Response.json({ status: 'error', message, category: 'VALIDATION_ERROR' }, { status: 400 });
57
+ if (!/^[^@\s]+@[^@\s]+$/.test(email) || password.length < 8 || !firstName || !lastName)
58
+ return refuse('an email, a password of at least 8 characters, a firstName and a lastName are required');
59
+ if (personOf(email, root))
60
+ return refuse(`${email} already has a HubSpot login`);
61
+ let hubId;
62
+ let superAdmin;
63
+ if (body.hubId !== undefined) {
64
+ hubId = Number(body.hubId);
65
+ const account = accountOf(hubId, root);
66
+ if (!account || account.closed_at)
67
+ return refuse(`no open account ${String(body.hubId)}`);
68
+ superAdmin = body.superAdmin === true;
69
+ }
70
+ else {
71
+ const name = typeof body.account === 'string' ? body.account.trim() : '';
72
+ if (!name)
73
+ return refuse('an account name (a new account) or a hubId (an account to join) is required');
74
+ hubId = 146000000 + stored('_account', root).length + 1;
75
+ const subscription = typeof body.subscription === 'string' ? body.subscription : 'free';
76
+ // a paid subscription's commitment term ends on `renewsOn` (a date), when it renews unless its auto-renewal was cancelled
77
+ const renewsOn = typeof body.renewsOn === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(body.renewsOn) ? body.renewsOn : null;
78
+ if (subscription !== 'free' && !renewsOn)
79
+ return refuse('a paid subscription needs the date its commitment term ends (renewsOn)');
80
+ await keep('_account', `account:${hubId}`, { hubId, name, subscription, renews_on: renewsOn, auto_renew: subscription !== 'free', created_at: at, closed_at: null }, root, at, email);
81
+ superAdmin = true;
82
+ }
83
+ const n = stored('_person', root).length + 1;
84
+ const person = { email: email.toLowerCase(), hash: passwordHash(email, password), hubId, userId: 80000000 + n, ownerId: String(90000000 + n), firstName, lastName, superAdmin, created_at: at };
85
+ await keep('_person', `person:${email.toLowerCase()}`, person, root, at, email);
86
+ return Response.json({ hubId, userId: person.userId, ownerId: person.ownerId }, { status: 201 });
87
+ }
88
+ /** The subscription an account has at `at`: a paid one whose auto-renewal was cancelled ends on its renewal date, when
89
+ * "your account will be downgraded to HubSpot's free tools"
90
+ * (https://knowledge.hubspot.com/account/how-do-i-cancel-my-hubspot-account); one that renews carries on. */
91
+ export function subscriptionAt(account, at) {
92
+ const paid = String(account.subscription ?? 'free');
93
+ if (paid === 'free' || account.auto_renew !== false || !account.renews_on)
94
+ return paid;
95
+ return Date.parse(at) >= Date.parse(`${String(account.renews_on)}T00:00:00Z`) ? 'free' : paid;
96
+ }
97
+ // ── sessions ───────────────────────────────────────────────────────────────────────────────
98
+ export const SESSION_COOKIE = 'hs_session';
99
+ /** The person the request's session cookie names, or undefined. */
100
+ export function signedIn(request, root) {
101
+ const cookie = request.headers.get('cookie') ?? '';
102
+ const token = new RegExp(`(?:^|;\\s*)${SESSION_COOKIE}=([^;]+)`).exec(cookie)?.[1];
103
+ const session = token ? stored('_web_session', root).find((s) => s.token === token) : undefined;
104
+ return session ? personOf(String(session.email), root) : undefined;
105
+ }
106
+ // ── apps and installs ──────────────────────────────────────────────────────────────────────
107
+ export function appByClientId(clientId, root) {
108
+ return stored('_app', root).find((a) => a.client_id === clientId && !a.deleted_at);
109
+ }
110
+ export function appById(appId, root) {
111
+ return stored('_app', root).find((a) => a.appId === appId && !a.deleted_at);
112
+ }
113
+ export function appsOf(hubId, root) {
114
+ return stored('_app', root).filter((a) => a.hubId === hubId && !a.deleted_at);
115
+ }
116
+ /** The live installs in an account. */
117
+ export function installsIn(hubId, root) {
118
+ return stored('_install', root).filter((i) => i.hubId === hubId && !i.uninstalled_at);
119
+ }
120
+ export function installOf(appId, hubId, root) {
121
+ return installsIn(hubId, root).find((i) => i.appId === appId);
122
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env node
2
+ import { keepProcessAlive } from '@volter/world-core/lifecycle';
3
+ // world-hubspot CLI: serve the HubSpot CRM API twin, the CRM mirror UI, or run conformance.
4
+ import { hasFlag, optionValue } from '@volter/world-core/args';
5
+ import { createHubspotTwinServer } from "./hubspot-server.js";
6
+ import { createHubspotMirrorServer } from "./hubspot-mirror-ui.js";
7
+ const [cmd, ...rest] = process.argv.slice(2);
8
+ const port = Number(optionValue(rest, '--port', '0')) || undefined;
9
+ const root = optionValue(rest, '--root') || undefined;
10
+ const readOnly = hasFlag(rest, '--read-only'); // a twin accepts writes unless started read-only
11
+ if (cmd === 'serve') {
12
+ const s = await createHubspotTwinServer({ readOnly, ...(root ? { root } : {}), ...(port ? { port } : {}) });
13
+ process.stdout.write(`hubspot twin (CRM v3/v4)${readOnly ? ' [read-only]' : ''} at ${s.url}\n`);
14
+ await keepProcessAlive();
15
+ }
16
+ else if (cmd === 'mirror') {
17
+ const s = await createHubspotMirrorServer({ ...(root ? { root } : {}), ...(port ? { port } : {}) });
18
+ process.stdout.write(`hubspot mirror UI (CRM) at ${s.url}\n`);
19
+ await keepProcessAlive();
20
+ }
21
+ else if (cmd === 'conformance') {
22
+ // dev-only; lazy so the bin runs without @volter/world-tooling in the runtime graph
23
+ const { checkHubspotConformance } = await import("./hubspot-conformance.js");
24
+ const report = await checkHubspotConformance({ ...(root ? { root } : {}) });
25
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
26
+ if (!report.ok)
27
+ process.exitCode = 1;
28
+ }
29
+ else {
30
+ process.stdout.write('Usage: world-hubspot serve|mirror|conformance [--port N] [--root DIR] [--read-only]\n');
31
+ }