@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.
- package/LICENSE +202 -0
- package/README.md +197 -0
- package/client/hubspot-mirror.css +43 -0
- package/client/hubspot-mirror.tsx +132 -0
- package/dist/client/hubspot-mirror.bundle.js +449 -0
- package/dist/client/hubspot-mirror.css +43 -0
- package/dist/client/hubspot-mirror.d.ts +15 -0
- package/dist/client/hubspot-mirror.js +59 -0
- package/dist/client/hubspot-mirror.tsx +132 -0
- package/dist/src/accounts.d.ts +30 -0
- package/dist/src/accounts.js +122 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +31 -0
- package/dist/src/generated/surface.gen.json +1 -0
- package/dist/src/generated/ui.gen.json +1 -0
- package/dist/src/hubspot-areas.d.ts +10 -0
- package/dist/src/hubspot-areas.js +114 -0
- package/dist/src/hubspot-budget.d.ts +58 -0
- package/dist/src/hubspot-budget.js +176 -0
- package/dist/src/hubspot-capabilities.d.ts +3 -0
- package/dist/src/hubspot-capabilities.js +1588 -0
- package/dist/src/hubspot-conformance.d.ts +16 -0
- package/dist/src/hubspot-conformance.js +523 -0
- package/dist/src/hubspot-connector.d.ts +125 -0
- package/dist/src/hubspot-connector.js +390 -0
- package/dist/src/hubspot-deferred-capabilities.d.ts +6 -0
- package/dist/src/hubspot-deferred-capabilities.js +64 -0
- package/dist/src/hubspot-mirror-ui.d.ts +62 -0
- package/dist/src/hubspot-mirror-ui.js +152 -0
- package/dist/src/hubspot-oauth.d.ts +8 -0
- package/dist/src/hubspot-oauth.js +291 -0
- package/dist/src/hubspot-server.d.ts +24 -0
- package/dist/src/hubspot-server.js +116 -0
- package/dist/src/hubspot-twin.d.ts +65 -0
- package/dist/src/hubspot-twin.js +1558 -0
- package/dist/src/index.d.ts +11 -0
- package/dist/src/index.js +94 -0
- package/dist/src/manifest.d.ts +2 -0
- package/dist/src/manifest.js +68 -0
- package/dist/src/portal.d.ts +20 -0
- package/dist/src/portal.js +30 -0
- package/dist/src/screens/account.d.ts +1 -0
- package/dist/src/screens/account.js +139 -0
- package/dist/src/screens/crm.d.ts +2 -0
- package/dist/src/screens/crm.js +153 -0
- package/dist/src/screens/developer.d.ts +4 -0
- package/dist/src/screens/developer.js +191 -0
- package/dist/src/screens/forms.d.ts +5 -0
- package/dist/src/screens/forms.js +126 -0
- package/dist/src/screens/page.d.ts +21 -0
- package/dist/src/screens/page.js +49 -0
- package/dist/src/screens/session.d.ts +1 -0
- package/dist/src/screens/session.js +32 -0
- package/dist/src/semantics/crm.d.ts +8 -0
- package/dist/src/semantics/crm.js +101 -0
- package/dist/src/webhooks.d.ts +12 -0
- package/dist/src/webhooks.js +77 -0
- package/package.json +75 -0
- package/src/accounts.ts +127 -0
- package/src/cli.ts +29 -0
- package/src/generated/surface.gen.json +1 -0
- package/src/generated/ui.gen.json +1 -0
- package/src/hubspot-areas.ts +155 -0
- package/src/hubspot-budget.ts +202 -0
- package/src/hubspot-capabilities.ts +1523 -0
- package/src/hubspot-conformance.ts +537 -0
- package/src/hubspot-connector.ts +419 -0
- package/src/hubspot-deferred-capabilities.ts +99 -0
- package/src/hubspot-journey.uitest.ts +104 -0
- package/src/hubspot-mirror-ui.ts +166 -0
- package/src/hubspot-oauth.tsx +296 -0
- package/src/hubspot-server.ts +115 -0
- package/src/hubspot-twin.ts +1534 -0
- package/src/index.ts +152 -0
- package/src/manifest.ts +96 -0
- package/src/portal.ts +40 -0
- package/src/screens/account.tsx +129 -0
- package/src/screens/crm.tsx +154 -0
- package/src/screens/developer.tsx +181 -0
- package/src/screens/forms.tsx +117 -0
- package/src/screens/page.tsx +55 -0
- package/src/screens/session.tsx +36 -0
- package/src/semantics/crm.ts +116 -0
- package/src/webhooks.ts +80 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// HubSpot twin HTTP server — the pack's wire as the derived dispatch over HubSpot's nine published documents
|
|
2
|
+
// (docs/contributing/architecture.md, "Protocol 3"), so an application calling HubSpot with raw fetch (Dub) or an
|
|
3
|
+
// UNMODIFIED `@hubspot/api-client` (pointed here with its own `basePath` option, or routed by the world injector's
|
|
4
|
+
// api.hubapi.com interception) works against it. Writable by default; pass readOnly to reject writes (R4). The CRM API
|
|
5
|
+
// accepts any bearer: a token the install minted is not required there.
|
|
6
|
+
//
|
|
7
|
+
// FETCH-FIRST (runtime contract R12b): the serve path is the plain fetch below, the server one line of Bun.serve around
|
|
8
|
+
// the same closure. The 204-with-no-body case is the kernel adapter's own null-body rule — `handleHubspotTwinRequest`
|
|
9
|
+
// returns `{ status: 204, body: null, headers }`, served as a genuinely empty response carrying those headers.
|
|
10
|
+
import { createDerivedFetch, createTwinFetchFromHandler, serveHttp, statefulTwinManifest, worldNow } from '@volter/world-core';
|
|
11
|
+
import surface from './generated/surface.gen.json' with { type: 'json' };
|
|
12
|
+
import { hubspotHandlers } from "./semantics/crm.js";
|
|
13
|
+
import { handleHubspotTwinRequest, hubspotUnknownRequest, specObjectPath } from "./hubspot-twin.js";
|
|
14
|
+
import { hubspotOAuth, tokenPortal } from "./hubspot-oauth.js";
|
|
15
|
+
import { signUpDoor } from "./accounts.js";
|
|
16
|
+
import { inPortal } from "./portal.js";
|
|
17
|
+
import { deliveriesDoor } from "./webhooks.js";
|
|
18
|
+
import { accountPages } from "./screens/account.js";
|
|
19
|
+
import { crmPages } from "./screens/crm.js";
|
|
20
|
+
import { developerPages, uploadDoor } from "./screens/developer.js";
|
|
21
|
+
import { FORMS_HOST, formPages, formSubmission } from "./screens/forms.js";
|
|
22
|
+
import { signIn } from "./screens/session.js";
|
|
23
|
+
/**
|
|
24
|
+
* The pack's wire: the twin's discovery door (`GET /twin`) and HubSpot's OAuth consent (app.hubspot.com's
|
|
25
|
+
* /oauth/authorize, a hosted flow: hubspot-oauth.tsx) in front, then the derived dispatch over the nine documents'
|
|
26
|
+
* union. A /crm/v3/objects/ path naming its object type in the spelling the spec's paths do not use (`deals` for the
|
|
27
|
+
* Deals document's `0-3`, `0-1` for `contacts`) is dispatched as the spelling they use (`specObjectPath`). The
|
|
28
|
+
* operations the twin serves go to their handlers (semantics/crm.ts); every other one, and a path no document has,
|
|
29
|
+
* is the gap.
|
|
30
|
+
*/
|
|
31
|
+
export function createHubspotTwinFetch(options) {
|
|
32
|
+
const answer = createTwinFetchFromHandler(handleHubspotTwinRequest, {
|
|
33
|
+
...options,
|
|
34
|
+
manifest: statefulTwinManifest({
|
|
35
|
+
vendor: 'hubspot',
|
|
36
|
+
twinOf: 'the HubSpot CRM v3/v4 API and its OAuth v1 token endpoints',
|
|
37
|
+
stores: 'CRM records (contacts, companies, deals, tickets), their properties and property groups, pipelines and stages, owners, and associations; OAuth codes and tokens',
|
|
38
|
+
}),
|
|
39
|
+
});
|
|
40
|
+
// a read-only twin installs nothing: its token endpoints answer as the request handler answers any write
|
|
41
|
+
const oauth = async (request) => (options.readOnly ? undefined : await hubspotOAuth(request, options.root, worldNow)) ?? answer(request);
|
|
42
|
+
const derived = createDerivedFetch({
|
|
43
|
+
surface: surface,
|
|
44
|
+
handlers: hubspotHandlers(answer, oauth),
|
|
45
|
+
gap: (request) => {
|
|
46
|
+
const url = new URL(request.url);
|
|
47
|
+
const refused = hubspotUnknownRequest(request.method, url.pathname);
|
|
48
|
+
return Response.json(refused.body, { status: refused.status, headers: refused.headers });
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
return Object.assign(async function hubspotTwinFetch(request) {
|
|
52
|
+
try {
|
|
53
|
+
const url = new URL(request.url);
|
|
54
|
+
const bare = url.pathname.replace(/\/+$/, '') || '/';
|
|
55
|
+
if (request.method === 'GET' && bare === '/twin')
|
|
56
|
+
return await answer(request);
|
|
57
|
+
const root = options.root;
|
|
58
|
+
const at = worldNow();
|
|
59
|
+
// the World's doors, on every host (accounts.ts, screens/developer.tsx, webhooks.ts)
|
|
60
|
+
if (request.method === 'GET' && bare === '/_twin/deliveries')
|
|
61
|
+
return deliveriesDoor(request, root);
|
|
62
|
+
if (!options.readOnly && request.method === 'POST') {
|
|
63
|
+
const user = /^\/_twin\/users\/([^/]+)\/password$/.exec(bare);
|
|
64
|
+
if (user)
|
|
65
|
+
return await signUpDoor(decodeURIComponent(user[1]).toLowerCase(), request, root, at);
|
|
66
|
+
const project = /^\/_twin\/accounts\/(\d+)\/projects$/.exec(bare);
|
|
67
|
+
if (project)
|
|
68
|
+
return await uploadDoor(Number(project[1]), request, root, at);
|
|
69
|
+
}
|
|
70
|
+
// the vendor host a redirected request names (the injector, a hosted World), else its own
|
|
71
|
+
const host = (request.headers.get('x-volter-twin-original-host') ?? url.host).split(':')[0].toLowerCase();
|
|
72
|
+
if (host === FORMS_HOST)
|
|
73
|
+
return (await formSubmission(request, root, at)) ?? notFoundPage();
|
|
74
|
+
if (host === 'app.hubspot.com') {
|
|
75
|
+
if (options.readOnly && request.method !== 'GET')
|
|
76
|
+
return notFoundPage();
|
|
77
|
+
if (bare === '/login')
|
|
78
|
+
return await signIn(request, root, at);
|
|
79
|
+
if (bare === '/oauth/authorize')
|
|
80
|
+
return (await hubspotOAuth(request, root, worldNow)) ?? notFoundPage();
|
|
81
|
+
return (await accountPages(request, root, at)) ?? (await developerPages(request, root, at)) ?? (await crmPages(request, root, at)) ?? (await formPages(request, root, at)) ?? notFoundPage();
|
|
82
|
+
}
|
|
83
|
+
// the consent on another hubspot.com host a World routes here (hubspotOAuth answers only on HubSpot's app hosts)
|
|
84
|
+
if (!options.readOnly && bare === '/oauth/authorize') {
|
|
85
|
+
const consent = await hubspotOAuth(request, root, worldNow);
|
|
86
|
+
if (consent)
|
|
87
|
+
return consent;
|
|
88
|
+
}
|
|
89
|
+
const path = specObjectPath(url.pathname);
|
|
90
|
+
const routed = path === url.pathname ? request : new Request(Object.assign(url, { pathname: path }), request);
|
|
91
|
+
// the account the bearer acts in (portal.ts): refused if the twin issued it and it no longer works
|
|
92
|
+
const scope = tokenPortal(request.headers.get('authorization'), root, at);
|
|
93
|
+
if (scope instanceof Response)
|
|
94
|
+
return scope;
|
|
95
|
+
return scope ? await inPortal(scope, () => derived(routed)) : await derived(routed);
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
// Never let an exception escape the fetch callback (§8): answer with a vendor-shaped 500
|
|
99
|
+
// so an in-process fidelity test sees a response, not a hang.
|
|
100
|
+
return new Response(JSON.stringify({ status: 'error', message: `internal twin error: ${String(error)}`, category: 'INTERNAL_ERROR', correlationId: '00000000-0000-0000-0000-000000000000' }), {
|
|
101
|
+
status: 500, headers: { 'content-type': 'application/json' },
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}, { owners: () => derived.owners() });
|
|
105
|
+
}
|
|
106
|
+
const notFoundPage = () => new Response('Not Found', { status: 404, headers: { 'content-type': 'text/plain' } });
|
|
107
|
+
export async function createHubspotTwinServer(options) {
|
|
108
|
+
const server = await serveHttp({
|
|
109
|
+
hostname: '127.0.0.1',
|
|
110
|
+
port: options.port ?? 0,
|
|
111
|
+
idleTimeout: 60,
|
|
112
|
+
fetch: createHubspotTwinFetch(options),
|
|
113
|
+
});
|
|
114
|
+
const port = server.port ?? options.port ?? 0;
|
|
115
|
+
return { port, url: `http://127.0.0.1:${port}`, stop: () => server.stop(true) };
|
|
116
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
export type HubspotRequest = {
|
|
2
|
+
method: string;
|
|
3
|
+
path: string;
|
|
4
|
+
body?: string;
|
|
5
|
+
occurredAt?: string;
|
|
6
|
+
root?: string;
|
|
7
|
+
readOnly?: boolean;
|
|
8
|
+
};
|
|
9
|
+
export type HubspotResponse = {
|
|
10
|
+
status: number;
|
|
11
|
+
body: unknown;
|
|
12
|
+
headers?: Record<string, string>;
|
|
13
|
+
};
|
|
14
|
+
export declare const HUBSPOT_RATE_LIMIT_HEADERS: Readonly<Record<string, string>>;
|
|
15
|
+
export declare const OBJECT_TYPES: readonly ["contacts", "companies", "deals", "tickets"];
|
|
16
|
+
export type ObjectType = (typeof OBJECT_TYPES)[number];
|
|
17
|
+
/** Resolve a {objectType} path segment to a modeled object type, or null. */
|
|
18
|
+
export declare function resolveObjectType(raw: string): ObjectType | null;
|
|
19
|
+
/**
|
|
20
|
+
* The floor this twin mints record ids from — NAMESPACED out of HubSpot's own range.
|
|
21
|
+
*
|
|
22
|
+
* HubSpot record ids are opaque numeric strings; the largest this repo has observed in the wild
|
|
23
|
+
* are ~2.7e10 (`26879063901`-shaped), and contact ids in particular are routinely in the low
|
|
24
|
+
* thousands. 9e11 is more than an order of magnitude above the largest of those and far below
|
|
25
|
+
* `Number.MAX_SAFE_INTEGER`, so a locally minted id and a real portal id cannot meet — in EITHER
|
|
26
|
+
* direction. See header note 3.
|
|
27
|
+
*/
|
|
28
|
+
export declare const HUBSPOT_LOCAL_ID_BASE = 900000000000;
|
|
29
|
+
export declare const SEARCH_FILTER_OPERATORS: readonly ["EQ", "NEQ", "LT", "LTE", "GT", "GTE", "BETWEEN", "IN", "NOT_IN", "HAS_PROPERTY", "NOT_HAS_PROPERTY", "CONTAINS_TOKEN", "NOT_CONTAINS_TOKEN"];
|
|
30
|
+
/** Documented caps. List: 100/page. Search: 200/page, 10 000 total results, 5 filterGroups of
|
|
31
|
+
* 6 filters (18 filters overall). All from HubSpot's own reference pages, read 2026-08-31. */
|
|
32
|
+
export declare const LIST_LIMIT_MAX = 100;
|
|
33
|
+
export declare const SEARCH_LIMIT_MAX = 200;
|
|
34
|
+
export declare const SEARCH_RESULT_CAP = 10000;
|
|
35
|
+
export declare const SEARCH_MAX_FILTER_GROUPS = 5;
|
|
36
|
+
export declare const SEARCH_MAX_FILTERS_PER_GROUP = 6;
|
|
37
|
+
export declare const SEARCH_MAX_FILTERS_TOTAL = 18;
|
|
38
|
+
/** A DETERMINISTIC correlation id (see note 2 in the header). Shaped like the UUID real
|
|
39
|
+
* HubSpot returns, derived from the request + outcome so a replay is byte-identical. */
|
|
40
|
+
export declare function correlationIdFor(seed: string): string;
|
|
41
|
+
export type ErrorContext = {
|
|
42
|
+
method: string;
|
|
43
|
+
pathname: string;
|
|
44
|
+
};
|
|
45
|
+
export declare function handleHubspotTwinRequest(req: HubspotRequest): Promise<HubspotResponse>;
|
|
46
|
+
/** What the twin answers a request for no operation it serves (the derived dispatch's gap, hubspot-server.ts): an
|
|
47
|
+
* object type HubSpot cannot resolve under /crm/v3/objects/ is refused as the vendor refuses it, a type it has and
|
|
48
|
+
* the twin does not model says so (`refuseObjectType`), and anything else is the twin's 404 in HubSpot's error body.
|
|
49
|
+
* Where the documentation stops: no page prints HubSpot's answer to an unknown path; the 404 is the twin's. */
|
|
50
|
+
export declare function hubspotUnknownRequest(method: string, pathname: string): HubspotResponse;
|
|
51
|
+
/** A /crm/v3/objects/ path with its object type spelled as the spec's paths spell it; any other path unchanged. */
|
|
52
|
+
export declare function specObjectPath(pathname: string): string;
|
|
53
|
+
export declare const RESOURCE_TYPES: readonly ["crm_object", "property", "property_group", "pipeline", "association", "owner"];
|
|
54
|
+
/**
|
|
55
|
+
* THE ENDPOINT CENSUS — what this twin CLAIMS to serve, in canonical `{placeholder}` form.
|
|
56
|
+
*
|
|
57
|
+
* Hand-authored from the dispatch above, and held to a two-way bijection with
|
|
58
|
+
* `hubspot-conformance.ts`'s probe table (every claim needs a live probe; every probe needs a
|
|
59
|
+
* claim), while that file's independent ROUTER_SURFACE closes the third direction — surface the
|
|
60
|
+
* router SERVES that this list does not name. It exists here, next to the router, so a branch
|
|
61
|
+
* added without a claim is visible in the same diff.
|
|
62
|
+
*/
|
|
63
|
+
export declare function hubspotTwinSnapshot(): {
|
|
64
|
+
implementedEndpoints: string[];
|
|
65
|
+
};
|