@volter/twin-hubspot 0.1.36 → 1.0.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 (135) hide show
  1. package/README.md +9 -193
  2. package/dist/generated/pack-facts.json +42 -0
  3. package/dist/journeys/coverage.json +60 -0
  4. package/dist/journeys/customer-life.json +7 -0
  5. package/dist/journeys/customer-life.outline.md +6 -0
  6. package/dist/journeys/decisions.json +623 -0
  7. package/dist/journeys/demand.json +171 -0
  8. package/dist/journeys/score.json +82 -0
  9. package/dist/journeys/unreachable.json +12 -0
  10. package/dist/journeys/vendor-examples.json +7 -0
  11. package/dist/spec/SOURCE.md +40 -0
  12. package/dist/spec/openapi/associations.json.gz +0 -0
  13. package/dist/spec/openapi/companies.json.gz +0 -0
  14. package/dist/spec/openapi/contacts.json.gz +0 -0
  15. package/dist/spec/openapi/deals.json.gz +0 -0
  16. package/dist/spec/openapi/meetings.json.gz +0 -0
  17. package/dist/spec/openapi/oauth.json.gz +0 -0
  18. package/dist/spec/openapi/owners.json.gz +0 -0
  19. package/dist/spec/openapi/pipelines.json.gz +0 -0
  20. package/dist/spec/openapi/properties.json.gz +0 -0
  21. package/dist/spec/openapi/tickets.json.gz +0 -0
  22. package/dist/spec/patches.json +235 -0
  23. package/dist/spec/sources.json +32 -0
  24. package/dist/src/cli.js +10 -22
  25. package/dist/src/fetch.d.ts +2 -0
  26. package/dist/src/fetch.js +13 -0
  27. package/dist/src/generated/surface.gen.json +1 -1
  28. package/dist/src/index.d.ts +5 -11
  29. package/dist/src/index.js +10 -94
  30. package/dist/src/manifest.js +32 -66
  31. package/dist/src/semantics/index.d.ts +2 -0
  32. package/dist/src/semantics/index.js +1 -0
  33. package/dist/src/semantics/states.d.ts +1 -0
  34. package/dist/src/semantics/states.js +2 -0
  35. package/dist/src/server.d.ts +7 -0
  36. package/dist/src/server.js +7 -0
  37. package/generated/pack-facts.json +42 -0
  38. package/journeys/coverage.json +60 -0
  39. package/journeys/customer-life.json +7 -0
  40. package/journeys/customer-life.outline.md +6 -0
  41. package/journeys/decisions.json +623 -0
  42. package/journeys/demand.json +171 -0
  43. package/journeys/score.json +82 -0
  44. package/journeys/unreachable.json +12 -0
  45. package/journeys/vendor-examples.json +7 -0
  46. package/package.json +10 -23
  47. package/spec/SOURCE.md +40 -0
  48. package/spec/openapi/associations.json.gz +0 -0
  49. package/spec/openapi/companies.json.gz +0 -0
  50. package/spec/openapi/contacts.json.gz +0 -0
  51. package/spec/openapi/deals.json.gz +0 -0
  52. package/spec/openapi/meetings.json.gz +0 -0
  53. package/spec/openapi/oauth.json.gz +0 -0
  54. package/spec/openapi/owners.json.gz +0 -0
  55. package/spec/openapi/pipelines.json.gz +0 -0
  56. package/spec/openapi/properties.json.gz +0 -0
  57. package/spec/openapi/tickets.json.gz +0 -0
  58. package/spec/patches.json +235 -0
  59. package/spec/sources.json +32 -0
  60. package/src/cli.ts +10 -19
  61. package/src/fetch.ts +14 -0
  62. package/src/generated/surface.gen.json +1 -1
  63. package/src/index.ts +12 -152
  64. package/src/manifest.ts +33 -92
  65. package/src/semantics/index.ts +6 -0
  66. package/src/semantics/states.ts +2 -0
  67. package/src/server.ts +8 -0
  68. package/client/hubspot-mirror.css +0 -43
  69. package/client/hubspot-mirror.tsx +0 -132
  70. package/dist/client/hubspot-mirror.bundle.js +0 -516
  71. package/dist/client/hubspot-mirror.css +0 -43
  72. package/dist/client/hubspot-mirror.d.ts +0 -15
  73. package/dist/client/hubspot-mirror.js +0 -59
  74. package/dist/client/hubspot-mirror.tsx +0 -132
  75. package/dist/src/accounts.d.ts +0 -30
  76. package/dist/src/accounts.js +0 -122
  77. package/dist/src/hubspot-areas.d.ts +0 -10
  78. package/dist/src/hubspot-areas.js +0 -114
  79. package/dist/src/hubspot-budget.d.ts +0 -58
  80. package/dist/src/hubspot-budget.js +0 -176
  81. package/dist/src/hubspot-capabilities.d.ts +0 -3
  82. package/dist/src/hubspot-capabilities.js +0 -1588
  83. package/dist/src/hubspot-conformance.d.ts +0 -16
  84. package/dist/src/hubspot-conformance.js +0 -523
  85. package/dist/src/hubspot-connector.d.ts +0 -125
  86. package/dist/src/hubspot-connector.js +0 -390
  87. package/dist/src/hubspot-deferred-capabilities.d.ts +0 -6
  88. package/dist/src/hubspot-deferred-capabilities.js +0 -64
  89. package/dist/src/hubspot-mirror-ui.d.ts +0 -62
  90. package/dist/src/hubspot-mirror-ui.js +0 -152
  91. package/dist/src/hubspot-oauth.d.ts +0 -8
  92. package/dist/src/hubspot-oauth.js +0 -291
  93. package/dist/src/hubspot-server.d.ts +0 -24
  94. package/dist/src/hubspot-server.js +0 -116
  95. package/dist/src/hubspot-twin.d.ts +0 -65
  96. package/dist/src/hubspot-twin.js +0 -1558
  97. package/dist/src/portal.d.ts +0 -20
  98. package/dist/src/portal.js +0 -30
  99. package/dist/src/screens/account.d.ts +0 -1
  100. package/dist/src/screens/account.js +0 -139
  101. package/dist/src/screens/crm.d.ts +0 -2
  102. package/dist/src/screens/crm.js +0 -153
  103. package/dist/src/screens/developer.d.ts +0 -4
  104. package/dist/src/screens/developer.js +0 -191
  105. package/dist/src/screens/forms.d.ts +0 -5
  106. package/dist/src/screens/forms.js +0 -126
  107. package/dist/src/screens/page.d.ts +0 -21
  108. package/dist/src/screens/page.js +0 -49
  109. package/dist/src/screens/session.d.ts +0 -1
  110. package/dist/src/screens/session.js +0 -32
  111. package/dist/src/semantics/crm.d.ts +0 -8
  112. package/dist/src/semantics/crm.js +0 -101
  113. package/dist/src/webhooks.d.ts +0 -12
  114. package/dist/src/webhooks.js +0 -79
  115. package/src/accounts.ts +0 -127
  116. package/src/hubspot-areas.ts +0 -155
  117. package/src/hubspot-budget.ts +0 -202
  118. package/src/hubspot-capabilities.ts +0 -1523
  119. package/src/hubspot-conformance.ts +0 -537
  120. package/src/hubspot-connector.ts +0 -419
  121. package/src/hubspot-deferred-capabilities.ts +0 -99
  122. package/src/hubspot-journey.uitest.ts +0 -104
  123. package/src/hubspot-mirror-ui.ts +0 -166
  124. package/src/hubspot-oauth.tsx +0 -296
  125. package/src/hubspot-server.ts +0 -115
  126. package/src/hubspot-twin.ts +0 -1534
  127. package/src/portal.ts +0 -40
  128. package/src/screens/account.tsx +0 -129
  129. package/src/screens/crm.tsx +0 -154
  130. package/src/screens/developer.tsx +0 -181
  131. package/src/screens/forms.tsx +0 -117
  132. package/src/screens/page.tsx +0 -55
  133. package/src/screens/session.tsx +0 -36
  134. package/src/semantics/crm.ts +0 -116
  135. package/src/webhooks.ts +0 -82
@@ -1,126 +0,0 @@
1
- // HUBSPOT FORMS — an account's forms, built on its form builder and filled in on a website
2
- // (https://knowledge.hubspot.com/forms/create-forms; Dub's guide, https://dub.co/docs/integrations/hubspot, "Option 1: Using HubSpot
3
- // Forms": "In the HubSpot form builder, add a hidden field and map it to the **Dub Id** contact property").
4
- //
5
- // - app.hubspot.com/forms/:hubId: the account's forms, and **Create form** with its name (a form starts with Email,
6
- // First name and Last name, contact fields).
7
- // - app.hubspot.com/forms/:hubId/editor/:formGuid/edit: the form's fields, and **Add hidden field** mapped to a contact
8
- // property a form may use (a property with `formField`, as Dub makes its Dub Click ID: `formField: true`, "Allow the
9
- // property to be used in a HubSpot form").
10
- // - POST https://api.hsforms.com/submissions/v3/integration/submit/:portalId/:formGuid: a visitor's submission, as HubSpot's
11
- // Forms API takes it ("Submit data to a form (unauthenticated)",
12
- // https://developers.hubspot.com/docs/api-reference/legacy/marketing/forms/v3-legacy/submit-data-unauthenticated):
13
- // `fields`, each `{objectTypeId, name, value}`, and `context`; a field the form does not have is refused
14
- // (`FIELD_NOT_IN_FORM_DEFINITION`), and an accepted submission answers `inlineMessage`. It makes or updates the
15
- // contact with that email in the form's account (portal.ts: `FORM`), whose webhook events follow.
16
- //
17
- // Where the documentation stops and the twin decides: the builder's paths and layout; a new form's three fields and its
18
- // thank-you message ("Thanks for submitting the form."); the embed Dub's guide puts on a site (js.hsforms.net) submits
19
- // through a request HubSpot does not document, so a visitor's submission is the Forms API's documented one; a submission
20
- // without an email, or with one that is not an address, is refused as `INVALID_EMAIL`; a contact is found by its email
21
- // to be updated, as HubSpot deduplicates contacts by email.
22
- import { accountOf, hex, keep, stored } from "../accounts.js";
23
- import { handleHubspotTwinRequest } from "../hubspot-twin.js";
24
- import { inPortal } from "../portal.js";
25
- import { formOf, personFor, portalPage, redirect, who } from "./page.js";
26
- export const FORMS_HOST = 'api.hsforms.com';
27
- const DEFAULT_FIELDS = [
28
- { name: 'email', label: 'Email', hidden: false },
29
- { name: 'firstname', label: 'First name', hidden: false },
30
- { name: 'lastname', label: 'Last name', hidden: false },
31
- ];
32
- const formsOf = (hub, root) => stored('_form', root).filter((f) => f.hubId === hub && !f.archived);
33
- const uuid = (seed) => { const h = hex(seed); return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`; };
34
- /** app.hubspot.com/forms/…, or undefined for another path. */
35
- export async function formPages(request, root, at) {
36
- const path = new URL(request.url).pathname.replace(/\/+$/, '');
37
- const m = /^\/forms\/(\d+)(?:\/editor\/([0-9a-f-]+)\/edit)?$/.exec(path);
38
- if (!m)
39
- return undefined;
40
- const hub = Number(m[1]);
41
- const got = personFor(request, hub, root);
42
- if (got instanceof Response)
43
- return got;
44
- const { person, account } = got;
45
- const actor = String(person.email);
46
- if (!m[2]) {
47
- if (request.method === 'POST') {
48
- const name = ((await formOf(request)).name ?? '').trim();
49
- if (!name)
50
- return listPage(hub, account, person, root, 'A form needs a name.', 422);
51
- const guid = uuid(`hubspot-form:${hub}:${name}:${at}`);
52
- await keep('_form', `form:${guid}`, { guid, hubId: hub, name, fields: DEFAULT_FIELDS, created_at: at, created_by: actor, archived: false }, root, at, actor);
53
- return redirect(`/forms/${hub}/editor/${guid}/edit`);
54
- }
55
- return listPage(hub, account, person, root);
56
- }
57
- const form = formsOf(hub, root).find((f) => f.guid === m[2]);
58
- if (!form)
59
- return portalPage('Forms', { merchant: who(account, person), notice: 'This form does not exist.', sections: [] }, 404);
60
- const properties = await inPortal({ hub, source: 'CRM_UI' }, () => handleHubspotTwinRequest({ method: 'GET', path: '/crm/v3/properties/contacts', root, occurredAt: at }));
61
- const usable = (properties.body.results ?? []).filter((p) => p.formField === true && p.archived !== true);
62
- let notice;
63
- if (request.method === 'POST') {
64
- const wanted = (await formOf(request)).property;
65
- const property = usable.find((p) => p.name === wanted);
66
- const fields = form.fields;
67
- if (!property)
68
- notice = 'Choose a contact property that can be used in forms.';
69
- else if (fields.some((f) => f.name === property.name))
70
- notice = `${String(property.label)} is already on this form.`;
71
- else {
72
- await keep('_form', `form:${String(form.guid)}`, { ...form, fields: [...fields, { name: property.name, label: property.label, hidden: true }], updated_at: at }, root, at, actor);
73
- return redirect(`/forms/${hub}/editor/${String(form.guid)}/edit`);
74
- }
75
- }
76
- return portalPage(String(form.name), { merchant: who(account, person), back: { href: `/forms/${hub}`, label: 'Forms' }, ...(notice ? { notice } : {}),
77
- sections: [{ heading: 'Fields', items: form.fields.map((f) => ({ title: String(f.label), detail: String(f.name), ...(f.hidden ? { badge: 'Hidden' } : {}) })) },
78
- { heading: 'Embed', items: [{ title: 'Form ID', detail: String(form.guid) }, { title: 'Portal ID', detail: String(hub) }] }],
79
- forms: [{ heading: 'Add hidden field', action: `/forms/${hub}/editor/${String(form.guid)}/edit`, submit: { label: 'Add' },
80
- fields: [{ id: 'property', label: 'Contact property', options: usable.map((p) => ({ value: String(p.name), label: String(p.label) })) }] }] });
81
- }
82
- function listPage(hub, account, person, root, notice, status = 200) {
83
- return portalPage('Forms', { merchant: who(account, person), ...(notice ? { notice } : {}),
84
- sections: [{ heading: 'Forms', empty: 'No forms yet.', items: formsOf(hub, root).map((f) => ({ title: String(f.name), detail: String(f.guid), actions: [{ label: 'Edit', action: `/forms/${hub}/editor/${String(f.guid)}/edit`, method: 'get' }] })) }],
85
- forms: [{ heading: 'Create form', action: `/forms/${hub}`, submit: { label: 'Create form' }, fields: [{ id: 'name', label: 'Form name' }] }] }, status);
86
- }
87
- const submitError = (errorType, message) => Response.json({ status: 'error', message: 'The request is not valid', errors: [{ message, errorType }] }, { status: 400 });
88
- /** api.hsforms.com's submission, or undefined for another request. */
89
- export async function formSubmission(request, root, at) {
90
- const m = /^\/submissions\/v3\/integration\/submit\/(\d+)\/([0-9a-f-]+)$/.exec(new URL(request.url).pathname.replace(/\/+$/, ''));
91
- if (!m || request.method !== 'POST')
92
- return undefined;
93
- const hub = Number(m[1]);
94
- const form = accountOf(hub, root)?.closed_at ? undefined : formsOf(hub, root).find((f) => f.guid === m[2]);
95
- if (!form)
96
- return Response.json({ status: 'error', message: 'Form not found', category: 'OBJECT_NOT_FOUND' }, { status: 404 });
97
- let body = {};
98
- try {
99
- body = JSON.parse(await request.text());
100
- }
101
- catch {
102
- return submitError('INVALID_METADATA', 'The request body is not valid JSON.');
103
- }
104
- const fields = Array.isArray(body.fields) ? body.fields : [];
105
- const names = new Set(form.fields.map((f) => String(f.name)));
106
- const properties = {};
107
- for (const f of fields) {
108
- if (!names.has(String(f.name)))
109
- return submitError('FIELD_NOT_IN_FORM_DEFINITION', `${String(f.name)} is not a field of this form.`);
110
- properties[String(f.name)] = String(f.value ?? '');
111
- }
112
- const email = (properties.email ?? '').trim().toLowerCase();
113
- if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email))
114
- return submitError('INVALID_EMAIL', 'Email address is invalid.');
115
- properties.email = email;
116
- await inPortal({ hub, source: 'FORM', sourceId: String(form.guid) }, async () => {
117
- const found = await handleHubspotTwinRequest({ method: 'POST', path: '/crm/v3/objects/contacts/search', body: JSON.stringify({ filterGroups: [{ filters: [{ propertyName: 'email', operator: 'EQ', value: email }] }], limit: 1 }), root, occurredAt: at });
118
- const id = ((found.body.results ?? [])[0])?.id;
119
- await handleHubspotTwinRequest(id === undefined
120
- ? { method: 'POST', path: '/crm/v3/objects/contacts', body: JSON.stringify({ properties }), root, occurredAt: at }
121
- : { method: 'PATCH', path: `/crm/v3/objects/contacts/${String(id)}`, body: JSON.stringify({ properties }), root, occurredAt: at });
122
- });
123
- const n = stored('_form_submission', root).length + 1;
124
- await keep('_form_submission', `submission:${n}`, { n, formGuid: form.guid, hubId: hub, fields, context: body.context ?? null, submitted_at: at }, root, at, email);
125
- return Response.json({ inlineMessage: 'Thanks for submitting the form.' });
126
- }
@@ -1,21 +0,0 @@
1
- import type { ReactElement } from 'react';
2
- import { type PortalProps } from '@volter/world-ui';
3
- import { type Row } from '../accounts.js';
4
- export declare const SKIN = "\nbody { background: #ffffff; color: #33475b; font-family: \"Lexend Deca\", Helvetica, Arial, sans-serif; }\n.sign-in-mark, .consent-vendor { background: #ff7a59; }\n.sign-in-box, .consent-box { background: #ffffff; border-color: #cbd6e2; }\n.sign-in-error { background: #fdedee; border-color: #f2545b; }\n.sign-in-submit, .consent-allow, .portal-primary { background: #ff7a59; border-color: #ff7a59; color: #ffffff; }\n.consent-badge { background: #f5f8fa; border: 1px solid #cbd6e2; color: #33475b; }\n.consent-link, .consent-actions { border-color: #cbd6e2; }\n.consent-who small, .consent-permission summary small, .consent-note { color: #516f90; }\n.consent-deny { background: #ffffff; border-color: #cbd6e2; color: #33475b; }\n.portal-side { background: #f5f8fa; border-right: 1px solid #cbd6e2; }\n.portal-section h2 { border-color: #cbd6e2; color: #516f90; }\n.portal-notice { background: #e5f5f8; border: 1px solid #7fd1de; word-break: break-all; }\n.portal-button { background: #ffffff; border-color: #cbd6e2; color: #33475b; }\n.portal-danger { color: #d94c53; }\n";
5
- export declare const CSS: {
6
- signIn: string[];
7
- consent: string[];
8
- portal: string[];
9
- };
10
- /** A settings or record page: the account and person on the left, the page's sections and forms. */
11
- export declare function portalPage(title: string, props: PortalProps, status?: number): Response;
12
- export declare function page(title: string, css: string[], body: ReactElement, status?: number): Response;
13
- export declare function redirect(location: string): Response;
14
- export declare function formOf(request: Request): Promise<Record<string, string>>;
15
- /** The person a page needs, signed in to the account the page is of; a redirect to the sign-in, or a refusal, if not. */
16
- export declare function personFor(request: Request, hubId: number | undefined, root?: string): {
17
- person: Row;
18
- account: Row;
19
- } | Response;
20
- /** The left column every account page carries. */
21
- export declare const who: (account: Row, person: Row) => string;
@@ -1,49 +0,0 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
2
- import { CONSENT_CSS, flowPage, Portal, PORTAL_CSS, SIGN_IN_CSS } from '@volter/world-ui';
3
- import { accountOf, signedIn } from "../accounts.js";
4
- // HubSpot's skin: its slate ink, light grey panels and orange primary buttons
5
- export const SKIN = `
6
- body { background: #ffffff; color: #33475b; font-family: "Lexend Deca", Helvetica, Arial, sans-serif; }
7
- .sign-in-mark, .consent-vendor { background: #ff7a59; }
8
- .sign-in-box, .consent-box { background: #ffffff; border-color: #cbd6e2; }
9
- .sign-in-error { background: #fdedee; border-color: #f2545b; }
10
- .sign-in-submit, .consent-allow, .portal-primary { background: #ff7a59; border-color: #ff7a59; color: #ffffff; }
11
- .consent-badge { background: #f5f8fa; border: 1px solid #cbd6e2; color: #33475b; }
12
- .consent-link, .consent-actions { border-color: #cbd6e2; }
13
- .consent-who small, .consent-permission summary small, .consent-note { color: #516f90; }
14
- .consent-deny { background: #ffffff; border-color: #cbd6e2; color: #33475b; }
15
- .portal-side { background: #f5f8fa; border-right: 1px solid #cbd6e2; }
16
- .portal-section h2 { border-color: #cbd6e2; color: #516f90; }
17
- .portal-notice { background: #e5f5f8; border: 1px solid #7fd1de; word-break: break-all; }
18
- .portal-button { background: #ffffff; border-color: #cbd6e2; color: #33475b; }
19
- .portal-danger { color: #d94c53; }
20
- `;
21
- export const CSS = { signIn: [SIGN_IN_CSS, SKIN], consent: [CONSENT_CSS, SKIN], portal: [PORTAL_CSS, SKIN] };
22
- /** A settings or record page: the account and person on the left, the page's sections and forms. */
23
- export function portalPage(title, props, status = 200) {
24
- return flowPage({ title: `${title} | HubSpot`, css: CSS.portal, status, body: _jsx(Portal, { ...props }) });
25
- }
26
- export function page(title, css, body, status = 200) {
27
- return flowPage({ title: `${title} | HubSpot`, css, status, body });
28
- }
29
- export function redirect(location) {
30
- return new Response(null, { status: 302, headers: { location } });
31
- }
32
- export async function formOf(request) {
33
- return Object.fromEntries(new URLSearchParams(await request.text()));
34
- }
35
- /** The person a page needs, signed in to the account the page is of; a redirect to the sign-in, or a refusal, if not. */
36
- export function personFor(request, hubId, root) {
37
- const person = signedIn(request, root);
38
- const url = new URL(request.url);
39
- if (!person)
40
- return redirect(`/login?loginRedirectUrl=${encodeURIComponent(url.pathname + url.search)}`);
41
- const account = accountOf(Number(person.hubId), root);
42
- if (!account || (hubId !== undefined && Number(person.hubId) !== hubId))
43
- return page('HubSpot', CSS.portal, _jsx("main", { className: "portal-main", children: _jsx("h1", { children: "You don't have access to this account." }) }), 403);
44
- if (account.closed_at)
45
- return page('HubSpot', CSS.portal, _jsx("main", { className: "portal-main", children: _jsx("h1", { children: "This account has been closed." }) }), 403);
46
- return { person, account };
47
- }
48
- /** The left column every account page carries. */
49
- export const who = (account, person) => `${String(account.name)} · ${String(person.email)}`;
@@ -1 +0,0 @@
1
- export declare function signIn(request: Request, root: string | undefined, at: string): Promise<Response>;
@@ -1,32 +0,0 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
2
- // HUBSPOT'S SIGN-IN — app.hubspot.com/login (docs/contributing/architecture.md, "Who is on a screen"): a person gives
3
- // the email and password the World gave them (accounts.ts's sign-up door), and the session cookie names them to every
4
- // page after; a page asked for by nobody signed in sends its visitor here with `loginRedirectUrl`, and the sign-in returns
5
- // them there. Built from @volter/world-ui's sign-in under HubSpot's skin.
6
- //
7
- // Where the documentation stops and the twin decides: the cookie is `hs_session`; a refused sign-in shows the form again
8
- // with "The email or password you entered is incorrect." (200); HubSpot's own sign-in also offers Google, Microsoft and
9
- // single sign-on, which the twin does not model.
10
- import { SignIn } from '@volter/world-ui';
11
- import { hex, keep, passwordHash, personOf, SESSION_COOKIE, stored } from "../accounts.js";
12
- import { CSS, formOf, page } from "./page.js";
13
- /** A return address on app.hubspot.com, never another site. */
14
- const back = (raw) => (raw && raw.startsWith('/') && !raw.startsWith('//') ? raw : '/');
15
- function signInPage(to, email, error) {
16
- return page('Log in', CSS.signIn, (_jsx(SignIn, { heading: "Log in to HubSpot", action: "/login", fields: { loginRedirectUrl: to }, account: { name: 'email', label: 'Email address', ...(email ? { value: email } : {}) }, password: { name: 'password', label: 'Password' }, submit: "Log in", ...(error ? { error } : {}) })));
17
- }
18
- export async function signIn(request, root, at) {
19
- if (request.method === 'GET')
20
- return signInPage(back(new URL(request.url).searchParams.get('loginRedirectUrl')));
21
- const f = await formOf(request);
22
- const email = (f.email ?? '').trim().toLowerCase();
23
- const to = back(f.loginRedirectUrl);
24
- const person = personOf(email, root);
25
- if (!person || person.hash !== passwordHash(email, f.password ?? ''))
26
- return signInPage(to, email, 'The email or password you entered is incorrect.');
27
- const token = hex(`hubspot-session:${email}:${at}:${stored('_web_session', root).length + 1}`).slice(0, 40);
28
- await keep('_web_session', `session:${token}`, { token, email, created_at: at }, root, at, email);
29
- const headers = new Headers({ location: to });
30
- headers.append('set-cookie', `${SESSION_COOKIE}=${token}; Path=/; HttpOnly; Secure; SameSite=Lax`);
31
- return new Response(null, { status: 302, headers });
32
- }
@@ -1,8 +0,0 @@
1
- import type { DerivedHandler } from '@volter/world-core';
2
- /** The operations served by the pack's request handler, every CRM operation the twin serves. */
3
- export declare const CRM_HANDLED: readonly ["delete-/crm/v3/objects/0-3/{dealId}_archive", "delete-/crm/v3/objects/companies/{companyId}_archive", "delete-/crm/v3/objects/contacts/{contactId}_archive", "delete-/crm/v3/objects/tickets/{ticketId}_archive", "get-/crm/v3/objects/0-3_getPage", "get-/crm/v3/objects/0-3/{dealId}_getById", "get-/crm/v3/objects/companies_getPage", "get-/crm/v3/objects/companies/{companyId}_getById", "get-/crm/v3/objects/contacts_getPage", "get-/crm/v3/objects/contacts/{contactId}_getById", "get-/crm/v3/objects/tickets_getPage", "get-/crm/v3/objects/tickets/{ticketId}_getById", "patch-/crm/v3/objects/0-3/{dealId}_update", "patch-/crm/v3/objects/companies/{companyId}_update", "patch-/crm/v3/objects/contacts/{contactId}_update", "patch-/crm/v3/objects/tickets/{ticketId}_update", "post-/crm/v3/objects/0-3_create", "post-/crm/v3/objects/0-3/batch/archive_archive", "post-/crm/v3/objects/0-3/batch/create_create", "post-/crm/v3/objects/0-3/batch/read_read", "post-/crm/v3/objects/0-3/batch/update_update", "post-/crm/v3/objects/0-3/batch/upsert_upsert", "post-/crm/v3/objects/0-3/merge_merge", "post-/crm/v3/objects/0-3/search_doSearch", "post-/crm/v3/objects/companies_create", "post-/crm/v3/objects/companies/batch/archive_archive", "post-/crm/v3/objects/companies/batch/create_create", "post-/crm/v3/objects/companies/batch/read_read", "post-/crm/v3/objects/companies/batch/update_update", "post-/crm/v3/objects/companies/batch/upsert_upsert", "post-/crm/v3/objects/companies/merge_merge", "post-/crm/v3/objects/companies/search_doSearch", "post-/crm/v3/objects/contacts_create", "post-/crm/v3/objects/contacts/batch/archive_archive", "post-/crm/v3/objects/contacts/batch/create_create", "post-/crm/v3/objects/contacts/batch/read_read", "post-/crm/v3/objects/contacts/batch/update_update", "post-/crm/v3/objects/contacts/batch/upsert_upsert", "post-/crm/v3/objects/contacts/merge_merge", "post-/crm/v3/objects/contacts/search_doSearch", "post-/crm/v3/objects/tickets_create", "post-/crm/v3/objects/tickets/batch/archive_archive", "post-/crm/v3/objects/tickets/batch/create_create", "post-/crm/v3/objects/tickets/batch/read_read", "post-/crm/v3/objects/tickets/batch/update_update", "post-/crm/v3/objects/tickets/batch/upsert_upsert", "post-/crm/v3/objects/tickets/merge_merge", "post-/crm/v3/objects/tickets/search_doSearch", "get-/crm/v3/owners/{ownerId}_getById", "get-/crm/v3/owners_/crm/v3/owners", "delete-/crm/v3/pipelines/{objectType}/{pipelineId}_archive", "delete-/crm/v3/pipelines/{objectType}/{pipelineId}/stages/{stageId}_archive", "get-/crm/v3/pipelines/{objectType}_getAll", "get-/crm/v3/pipelines/{objectType}/{pipelineId}_getById", "get-/crm/v3/pipelines/{objectType}/{pipelineId}/stages_getAll", "get-/crm/v3/pipelines/{objectType}/{pipelineId}/stages/{stageId}_getById", "patch-/crm/v3/pipelines/{objectType}/{pipelineId}_update", "patch-/crm/v3/pipelines/{objectType}/{pipelineId}/stages/{stageId}_update", "post-/crm/v3/pipelines/{objectType}_create", "post-/crm/v3/pipelines/{objectType}/{pipelineId}/stages_create", "put-/crm/v3/pipelines/{objectType}/{pipelineId}_replace", "put-/crm/v3/pipelines/{objectType}/{pipelineId}/stages/{stageId}_replace", "delete-/crm/v3/properties/{objectType}/{propertyName}_archive", "delete-/crm/v3/properties/{objectType}/groups/{groupName}_archive", "get-/crm/v3/properties/{objectType}_getAll", "get-/crm/v3/properties/{objectType}/{propertyName}_getByName", "get-/crm/v3/properties/{objectType}/groups_getAll", "get-/crm/v3/properties/{objectType}/groups/{groupName}_getByName", "patch-/crm/v3/properties/{objectType}/{propertyName}_update", "patch-/crm/v3/properties/{objectType}/groups/{groupName}_update", "post-/crm/v3/properties/{objectType}_create", "post-/crm/v3/properties/{objectType}/batch/create_create", "post-/crm/v3/properties/{objectType}/groups_create", "post-/crm/v4/associations/{fromObjectType}/{toObjectType}/batch/archive_archive", "post-/crm/v4/associations/{fromObjectType}/{toObjectType}/batch/create_create", "post-/crm/v4/associations/{fromObjectType}/{toObjectType}/batch/read_getPage", "delete-/crm/v4/objects/{objectType}/{objectId}/associations/{toObjectType}/{toObjectId}_archive", "get-/crm/v4/objects/{objectType}/{objectId}/associations/{toObjectType}_getPage", "put-/crm/v4/objects/{fromObjectType}/{fromObjectId}/associations/default/{toObjectType}/{toObjectId}_createDefault", "put-/crm/v4/objects/{objectType}/{objectId}/associations/{toObjectType}/{toObjectId}_create"];
4
- /** The operations served by the OAuth install's token endpoints. */
5
- export declare const OAUTH_HANDLED: readonly ["get-/oauth/v1/access-tokens/{token}_get", "post-/oauth/v1/token_create"];
6
- /** A handler per operation above: the CRM ones answer through `answer` (the pack's request handler as a fetch), the
7
- * OAuth ones through `oauth`. */
8
- export declare function hubspotHandlers(answer: (request: Request) => Promise<Response>, oauth: (request: Request) => Promise<Response>): Record<string, DerivedHandler>;
@@ -1,101 +0,0 @@
1
- /** The operations served by the pack's request handler, every CRM operation the twin serves. */
2
- export const CRM_HANDLED = [
3
- // /crm/v3/objects
4
- 'delete-/crm/v3/objects/0-3/{dealId}_archive',
5
- 'delete-/crm/v3/objects/companies/{companyId}_archive',
6
- 'delete-/crm/v3/objects/contacts/{contactId}_archive',
7
- 'delete-/crm/v3/objects/tickets/{ticketId}_archive',
8
- 'get-/crm/v3/objects/0-3_getPage',
9
- 'get-/crm/v3/objects/0-3/{dealId}_getById',
10
- 'get-/crm/v3/objects/companies_getPage',
11
- 'get-/crm/v3/objects/companies/{companyId}_getById',
12
- 'get-/crm/v3/objects/contacts_getPage',
13
- 'get-/crm/v3/objects/contacts/{contactId}_getById',
14
- 'get-/crm/v3/objects/tickets_getPage',
15
- 'get-/crm/v3/objects/tickets/{ticketId}_getById',
16
- 'patch-/crm/v3/objects/0-3/{dealId}_update',
17
- 'patch-/crm/v3/objects/companies/{companyId}_update',
18
- 'patch-/crm/v3/objects/contacts/{contactId}_update',
19
- 'patch-/crm/v3/objects/tickets/{ticketId}_update',
20
- 'post-/crm/v3/objects/0-3_create',
21
- 'post-/crm/v3/objects/0-3/batch/archive_archive',
22
- 'post-/crm/v3/objects/0-3/batch/create_create',
23
- 'post-/crm/v3/objects/0-3/batch/read_read',
24
- 'post-/crm/v3/objects/0-3/batch/update_update',
25
- 'post-/crm/v3/objects/0-3/batch/upsert_upsert',
26
- 'post-/crm/v3/objects/0-3/merge_merge',
27
- 'post-/crm/v3/objects/0-3/search_doSearch',
28
- 'post-/crm/v3/objects/companies_create',
29
- 'post-/crm/v3/objects/companies/batch/archive_archive',
30
- 'post-/crm/v3/objects/companies/batch/create_create',
31
- 'post-/crm/v3/objects/companies/batch/read_read',
32
- 'post-/crm/v3/objects/companies/batch/update_update',
33
- 'post-/crm/v3/objects/companies/batch/upsert_upsert',
34
- 'post-/crm/v3/objects/companies/merge_merge',
35
- 'post-/crm/v3/objects/companies/search_doSearch',
36
- 'post-/crm/v3/objects/contacts_create',
37
- 'post-/crm/v3/objects/contacts/batch/archive_archive',
38
- 'post-/crm/v3/objects/contacts/batch/create_create',
39
- 'post-/crm/v3/objects/contacts/batch/read_read',
40
- 'post-/crm/v3/objects/contacts/batch/update_update',
41
- 'post-/crm/v3/objects/contacts/batch/upsert_upsert',
42
- 'post-/crm/v3/objects/contacts/merge_merge',
43
- 'post-/crm/v3/objects/contacts/search_doSearch',
44
- 'post-/crm/v3/objects/tickets_create',
45
- 'post-/crm/v3/objects/tickets/batch/archive_archive',
46
- 'post-/crm/v3/objects/tickets/batch/create_create',
47
- 'post-/crm/v3/objects/tickets/batch/read_read',
48
- 'post-/crm/v3/objects/tickets/batch/update_update',
49
- 'post-/crm/v3/objects/tickets/batch/upsert_upsert',
50
- 'post-/crm/v3/objects/tickets/merge_merge',
51
- 'post-/crm/v3/objects/tickets/search_doSearch',
52
- // /crm/v3/owners
53
- 'get-/crm/v3/owners/{ownerId}_getById',
54
- 'get-/crm/v3/owners_/crm/v3/owners',
55
- // /crm/v3/pipelines
56
- 'delete-/crm/v3/pipelines/{objectType}/{pipelineId}_archive',
57
- 'delete-/crm/v3/pipelines/{objectType}/{pipelineId}/stages/{stageId}_archive',
58
- 'get-/crm/v3/pipelines/{objectType}_getAll',
59
- 'get-/crm/v3/pipelines/{objectType}/{pipelineId}_getById',
60
- 'get-/crm/v3/pipelines/{objectType}/{pipelineId}/stages_getAll',
61
- 'get-/crm/v3/pipelines/{objectType}/{pipelineId}/stages/{stageId}_getById',
62
- 'patch-/crm/v3/pipelines/{objectType}/{pipelineId}_update',
63
- 'patch-/crm/v3/pipelines/{objectType}/{pipelineId}/stages/{stageId}_update',
64
- 'post-/crm/v3/pipelines/{objectType}_create',
65
- 'post-/crm/v3/pipelines/{objectType}/{pipelineId}/stages_create',
66
- 'put-/crm/v3/pipelines/{objectType}/{pipelineId}_replace',
67
- 'put-/crm/v3/pipelines/{objectType}/{pipelineId}/stages/{stageId}_replace',
68
- // /crm/v3/properties
69
- 'delete-/crm/v3/properties/{objectType}/{propertyName}_archive',
70
- 'delete-/crm/v3/properties/{objectType}/groups/{groupName}_archive',
71
- 'get-/crm/v3/properties/{objectType}_getAll',
72
- 'get-/crm/v3/properties/{objectType}/{propertyName}_getByName',
73
- 'get-/crm/v3/properties/{objectType}/groups_getAll',
74
- 'get-/crm/v3/properties/{objectType}/groups/{groupName}_getByName',
75
- 'patch-/crm/v3/properties/{objectType}/{propertyName}_update',
76
- 'patch-/crm/v3/properties/{objectType}/groups/{groupName}_update',
77
- 'post-/crm/v3/properties/{objectType}_create',
78
- 'post-/crm/v3/properties/{objectType}/batch/create_create',
79
- 'post-/crm/v3/properties/{objectType}/groups_create',
80
- // /crm/v4/associations
81
- 'post-/crm/v4/associations/{fromObjectType}/{toObjectType}/batch/archive_archive',
82
- 'post-/crm/v4/associations/{fromObjectType}/{toObjectType}/batch/create_create',
83
- 'post-/crm/v4/associations/{fromObjectType}/{toObjectType}/batch/read_getPage',
84
- // /crm/v4/objects
85
- 'delete-/crm/v4/objects/{objectType}/{objectId}/associations/{toObjectType}/{toObjectId}_archive',
86
- 'get-/crm/v4/objects/{objectType}/{objectId}/associations/{toObjectType}_getPage',
87
- 'put-/crm/v4/objects/{fromObjectType}/{fromObjectId}/associations/default/{toObjectType}/{toObjectId}_createDefault',
88
- 'put-/crm/v4/objects/{objectType}/{objectId}/associations/{toObjectType}/{toObjectId}_create',
89
- ];
90
- /** The operations served by the OAuth install's token endpoints. */
91
- export const OAUTH_HANDLED = [
92
- 'get-/oauth/v1/access-tokens/{token}_get',
93
- 'post-/oauth/v1/token_create',
94
- ];
95
- /** A handler per operation above: the CRM ones answer through `answer` (the pack's request handler as a fetch), the
96
- * OAuth ones through `oauth`. */
97
- export function hubspotHandlers(answer, oauth) {
98
- const crm = ({ request }) => answer(request);
99
- const token = ({ request }) => oauth(request);
100
- return { ...Object.fromEntries(CRM_HANDLED.map((id) => [id, crm])), ...Object.fromEntries(OAUTH_HANDLED.map((id) => [id, token])) };
101
- }
@@ -1,12 +0,0 @@
1
- export type WriteEvent = {
2
- objectType: string;
3
- objectId: string;
4
- kind: 'creation' | 'propertyChange';
5
- propertyName?: string;
6
- propertyValue?: string | null;
7
- };
8
- /** Announce a stored write to every app installed in the request's account that subscribes to it. */
9
- export declare function announceWrite(e: WriteEvent, root: string | undefined, at: string): Promise<void>;
10
- /** GET /_twin/deliveries?to=<url>: what an app's server received from HubSpot, oldest first — each request's headers,
11
- * its body as sent, and the events in it. */
12
- export declare function deliveriesDoor(request: Request, root?: string): Response;
@@ -1,79 +0,0 @@
1
- // HUBSPOT'S WEBHOOK EVENTS — what HubSpot sends an installed app when a record changes in the account it is installed
2
- // in (docs/contributing/architecture.md, "Events and webhooks"). An app declares its subscriptions in its project's
3
- // webhooks component: a `targetUrl` and, in `crmObjects`, the `object.*` events it wants by `subscriptionType`,
4
- // `objectType` and, for a property change, `propertyName`
5
- // (https://developers.hubspot.com/docs/apps/developer-platform/add-features/configure-webhooks). Every write the request
6
- // handler stores (hubspot-twin.ts, whoever made it: an app's API call, a person on a record's page, a form's submission)
7
- // is announced here, and each live install in the account whose app subscribes to it is sent a POST to its target URL:
8
- // a JSON array of events, each with `objectId`, `eventId`, `subscriptionId`, `portalId`, `appId`, `occurredAt`,
9
- // `attemptNumber`, `changeSource`, `subscriptionType`, `objectTypeId`, `sourceId`, and for a property change
10
- // `propertyName` and `propertyValue` (the generic payload's fields,
11
- // https://developers.hubspot.com/docs/apps/legacy-apps/public-apps/create-generic-webhook-subscriptions).
12
- //
13
- // Signed as HubSpot's request-validation page says: `X-HubSpot-Signature-v3`, the base64 HMAC SHA-256 of the method,
14
- // the URI, the body and `X-HubSpot-Request-Timestamp` with the app's client secret, and "for backwards compatibility"
15
- // the older signature, `X-HubSpot-Signature` with `X-HubSpot-Signature-Version: v1`, the SHA-256 of "Client secret +
16
- // request body" (https://developers.hubspot.com/docs/apps/developer-platform/build-apps/authentication/request-validation).
17
- //
18
- // The app's server is outside HubSpot, so what it received is read at a door, `GET /_twin/deliveries?to=<target URL>`,
19
- // as slack's deliveries are; each delivery is also POSTed to the target URL where the World lets it out.
20
- //
21
- // Where the documentation stops and the twin decides: one event per request (HubSpot may batch up to a hundred); the
22
- // event's `eventId` counts the account's events; `subscriptionId` is the subscription's index in the app, from the app's
23
- // id; `changeSource` is `CRM_UI` for a person on a page, `FORM` for a form's submission, `INTEGRATION` for an app's API
24
- // call and `API` otherwise, and `sourceId` is `userId:<id>` or `<appId>`; the v1 signature is sent on every event, which
25
- // the page does not promise for a developer-platform app's `object.*` event.
26
- import { createHash, createHmac } from 'node:crypto';
27
- import { appDestination, appFetch } from '@volter/world-core/app-route';
28
- import { worldEgressRefusal } from '@volter/world-core/network-policy';
29
- import { appById, installsIn, keep, stored } from "./accounts.js";
30
- import { currentPortal } from "./portal.js";
31
- const OBJECT_TYPE_ID = { contacts: '0-1', companies: '0-2', deals: '0-3', tickets: '0-5' };
32
- const SUBSCRIPTION_OBJECT = { contacts: 'contact', companies: 'company', deals: 'deal', tickets: 'ticket' };
33
- /** Announce a stored write to every app installed in the request's account that subscribes to it. */
34
- export async function announceWrite(e, root, at) {
35
- const { hub, source, sourceId } = currentPortal();
36
- for (const install of installsIn(hub, root)) {
37
- const app = appById(Number(install.appId), root);
38
- const hooks = app?.webhooks;
39
- if (!app || !hooks?.targetUrl)
40
- continue;
41
- const subscription = (hooks.subscriptions ?? []).find((s) => s.active !== false
42
- && s.subscriptionType === `object.${e.kind}` && s.objectType === SUBSCRIPTION_OBJECT[e.objectType]
43
- && (e.kind !== 'propertyChange' || s.propertyName === e.propertyName));
44
- if (!subscription)
45
- continue;
46
- const eventId = stored('_webhook_delivery', root).filter((d) => d.portalId === hub).length + 1;
47
- const event = {
48
- objectId: Number(e.objectId), eventId, subscriptionId: Number(subscription.id), portalId: hub, appId: app.appId,
49
- occurredAt: Date.parse(at), attemptNumber: 0, changeSource: source, subscriptionType: `object.${e.kind}`,
50
- objectTypeId: OBJECT_TYPE_ID[e.objectType], ...(sourceId ? { sourceId } : {}),
51
- ...(e.kind === 'propertyChange' ? { propertyName: e.propertyName, propertyValue: e.propertyValue ?? '' } : {}),
52
- };
53
- await deliver(String(hooks.targetUrl), String(app.client_secret), [event], hub, root, at);
54
- }
55
- }
56
- async function deliver(to, secret, events, hub, root, at) {
57
- const body = JSON.stringify(events);
58
- const timestamp = String(Date.parse(at));
59
- const headers = {
60
- 'content-type': 'application/json',
61
- 'x-hubspot-signature': createHash('sha256').update(secret + body).digest('hex'),
62
- 'x-hubspot-signature-version': 'v1',
63
- 'x-hubspot-signature-v3': createHmac('sha256', secret).update(`POST${to}${body}${timestamp}`).digest('base64'),
64
- 'x-hubspot-request-timestamp': timestamp,
65
- };
66
- const n = stored('_webhook_delivery', root).length + 1;
67
- await keep('_webhook_delivery', `delivery:${n}`, { n, to, portalId: hub, body, headers, delivered_at: at }, root, at);
68
- // the application's own hostnames reach it inside the World; elsewhere, the World's egress rule decides
69
- if (appDestination(to) || worldEgressRefusal(to) === null)
70
- await appFetch(to, { method: 'POST', headers, body }).then((r) => r.text()).catch(() => undefined);
71
- }
72
- /** GET /_twin/deliveries?to=<url>: what an app's server received from HubSpot, oldest first — each request's headers,
73
- * its body as sent, and the events in it. */
74
- export function deliveriesDoor(request, root) {
75
- const to = new URL(request.url).searchParams.get('to') ?? '';
76
- const deliveries = stored('_webhook_delivery', root).filter((d) => d.to === to).sort((a, b) => Number(a.n) - Number(b.n))
77
- .map((d) => ({ n: d.n, delivered_at: d.delivered_at, headers: d.headers, body: d.body, events: JSON.parse(String(d.body)) }));
78
- return Response.json({ to, deliveries });
79
- }
package/src/accounts.ts DELETED
@@ -1,127 +0,0 @@
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
-
15
- const SERVICE = 'hubspot';
16
- export type Row = Record<string, unknown>;
17
-
18
- export const hex = (s: string): string => createHash('sha256').update(s).digest('hex');
19
-
20
- /** Every live stored subject of a bookkeeping type (these are not scoped to an account: each names its own). */
21
- export function stored(type: string, root?: string): Row[] {
22
- return (projectResources(SERVICE, root) as unknown as Row[]).filter((r) => r.type === type && r.deleted !== true);
23
- }
24
-
25
- /** Write a bookkeeping subject. */
26
- export async function keep(type: string, id: string, fields: Row, root: string | undefined, at: string, actor = 'hubspot'): Promise<void> {
27
- await applyTwinWrite(SERVICE, { operation: `hubspot.${type}`, subjectType: type, subjectId: id, fields, occurredAt: at, actor: { kind: 'human', id: actor } }, root);
28
- }
29
-
30
- // ── accounts and users ─────────────────────────────────────────────────────────────────────
31
- export const passwordHash = (email: string, password: string): string => hex(`hubspot-password:${email.toLowerCase()}:${password}`);
32
-
33
- export function accountOf(hubId: number, root?: string): Row | undefined {
34
- return stored('_account', root).find((a) => a.hubId === hubId);
35
- }
36
-
37
- export function personOf(email: string, root?: string): Row | undefined {
38
- return stored('_person', root).find((p) => p.email === email.toLowerCase());
39
- }
40
-
41
- export function usersOf(hubId: number, root?: string): Row[] {
42
- return stored('_person', root).filter((p) => p.hubId === hubId).sort((a, b) => Number(a.userId) - Number(b.userId));
43
- }
44
-
45
- /** An account's owners, as GET /crm/v3/owners answers them (the spec's PublicOwner): one per user. */
46
- export function ownersOf(hubId: number, root?: string): Row[] {
47
- return usersOf(hubId, root).map((p) => ({
48
- id: String(p.ownerId), email: p.email, firstName: p.firstName, lastName: p.lastName, userId: p.userId,
49
- userIdIncludingInactive: p.userId, type: 'PERSON', archived: false, teams: [],
50
- createdAt: p.created_at, updatedAt: p.created_at,
51
- }));
52
- }
53
-
54
- /** POST /_twin/users/:email/password {password, firstName, lastName, account?, subscription?, renewsOn?, hubId?} — stands in for two pages the
55
- * twin does not build: HubSpot's sign-up (a new account, named `account`, whose first user is its super admin) and a
56
- * user's acceptance of an invitation from an account's Users & Teams settings (`hubId`: the user joins that account).
57
- * The World keeps the password's hash, never the password; 201 with the account's Hub ID and the user's ids. */
58
- export async function signUpDoor(email: string, request: Request, root: string | undefined, at: string): Promise<Response> {
59
- let body: Row = {};
60
- try { body = JSON.parse(await request.text()) as Row; } catch { /* refused below */ }
61
- const password = typeof body.password === 'string' ? body.password : '';
62
- const firstName = typeof body.firstName === 'string' ? body.firstName : '';
63
- const lastName = typeof body.lastName === 'string' ? body.lastName : '';
64
- const refuse = (message: string): Response => Response.json({ status: 'error', message, category: 'VALIDATION_ERROR' }, { status: 400 });
65
- if (!/^[^@\s]+@[^@\s]+$/.test(email) || password.length < 8 || !firstName || !lastName) return refuse('an email, a password of at least 8 characters, a firstName and a lastName are required');
66
- if (personOf(email, root)) return refuse(`${email} already has a HubSpot login`);
67
- let hubId: number;
68
- let superAdmin: boolean;
69
- if (body.hubId !== undefined) {
70
- hubId = Number(body.hubId);
71
- const account = accountOf(hubId, root);
72
- if (!account || account.closed_at) return refuse(`no open account ${String(body.hubId)}`);
73
- superAdmin = body.superAdmin === true;
74
- } else {
75
- const name = typeof body.account === 'string' ? body.account.trim() : '';
76
- if (!name) return refuse('an account name (a new account) or a hubId (an account to join) is required');
77
- hubId = 146000000 + stored('_account', root).length + 1;
78
- const subscription = typeof body.subscription === 'string' ? body.subscription : 'free';
79
- // a paid subscription's commitment term ends on `renewsOn` (a date), when it renews unless its auto-renewal was cancelled
80
- const renewsOn = typeof body.renewsOn === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(body.renewsOn) ? body.renewsOn : null;
81
- if (subscription !== 'free' && !renewsOn) return refuse('a paid subscription needs the date its commitment term ends (renewsOn)');
82
- await keep('_account', `account:${hubId}`, { hubId, name, subscription, renews_on: renewsOn, auto_renew: subscription !== 'free', created_at: at, closed_at: null }, root, at, email);
83
- superAdmin = true;
84
- }
85
- const n = stored('_person', root).length + 1;
86
- const person = { email: email.toLowerCase(), hash: passwordHash(email, password), hubId, userId: 80000000 + n, ownerId: String(90000000 + n), firstName, lastName, superAdmin, created_at: at };
87
- await keep('_person', `person:${email.toLowerCase()}`, person, root, at, email);
88
- return Response.json({ hubId, userId: person.userId, ownerId: person.ownerId }, { status: 201 });
89
- }
90
-
91
- /** The subscription an account has at `at`: a paid one whose auto-renewal was cancelled ends on its renewal date, when
92
- * "your account will be downgraded to HubSpot's free tools"
93
- * (https://knowledge.hubspot.com/account/how-do-i-cancel-my-hubspot-account); one that renews carries on. */
94
- export function subscriptionAt(account: Row, at: string): string {
95
- const paid = String(account.subscription ?? 'free');
96
- if (paid === 'free' || account.auto_renew !== false || !account.renews_on) return paid;
97
- return Date.parse(at) >= Date.parse(`${String(account.renews_on)}T00:00:00Z`) ? 'free' : paid;
98
- }
99
-
100
- // ── sessions ───────────────────────────────────────────────────────────────────────────────
101
- export const SESSION_COOKIE = 'hs_session';
102
-
103
- /** The person the request's session cookie names, or undefined. */
104
- export function signedIn(request: Request, root?: string): Row | undefined {
105
- const cookie = request.headers.get('cookie') ?? '';
106
- const token = new RegExp(`(?:^|;\\s*)${SESSION_COOKIE}=([^;]+)`).exec(cookie)?.[1];
107
- const session = token ? stored('_web_session', root).find((s) => s.token === token) : undefined;
108
- return session ? personOf(String(session.email), root) : undefined;
109
- }
110
-
111
- // ── apps and installs ──────────────────────────────────────────────────────────────────────
112
- export function appByClientId(clientId: string, root?: string): Row | undefined {
113
- return stored('_app', root).find((a) => a.client_id === clientId && !a.deleted_at);
114
- }
115
- export function appById(appId: number, root?: string): Row | undefined {
116
- return stored('_app', root).find((a) => a.appId === appId && !a.deleted_at);
117
- }
118
- export function appsOf(hubId: number, root?: string): Row[] {
119
- return stored('_app', root).filter((a) => a.hubId === hubId && !a.deleted_at);
120
- }
121
- /** The live installs in an account. */
122
- export function installsIn(hubId: number, root?: string): Row[] {
123
- return stored('_install', root).filter((i) => i.hubId === hubId && !i.uninstalled_at);
124
- }
125
- export function installOf(appId: number, hubId: number, root?: string): Row | undefined {
126
- return installsIn(hubId, root).find((i) => i.appId === appId);
127
- }