@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,77 @@
|
|
|
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 { worldEgressRefusal } from '@volter/world-core/network-policy';
|
|
28
|
+
import { appById, installsIn, keep, stored } from "./accounts.js";
|
|
29
|
+
import { currentPortal } from "./portal.js";
|
|
30
|
+
const OBJECT_TYPE_ID = { contacts: '0-1', companies: '0-2', deals: '0-3', tickets: '0-5' };
|
|
31
|
+
const SUBSCRIPTION_OBJECT = { contacts: 'contact', companies: 'company', deals: 'deal', tickets: 'ticket' };
|
|
32
|
+
/** Announce a stored write to every app installed in the request's account that subscribes to it. */
|
|
33
|
+
export async function announceWrite(e, root, at) {
|
|
34
|
+
const { hub, source, sourceId } = currentPortal();
|
|
35
|
+
for (const install of installsIn(hub, root)) {
|
|
36
|
+
const app = appById(Number(install.appId), root);
|
|
37
|
+
const hooks = app?.webhooks;
|
|
38
|
+
if (!app || !hooks?.targetUrl)
|
|
39
|
+
continue;
|
|
40
|
+
const subscription = (hooks.subscriptions ?? []).find((s) => s.active !== false
|
|
41
|
+
&& s.subscriptionType === `object.${e.kind}` && s.objectType === SUBSCRIPTION_OBJECT[e.objectType]
|
|
42
|
+
&& (e.kind !== 'propertyChange' || s.propertyName === e.propertyName));
|
|
43
|
+
if (!subscription)
|
|
44
|
+
continue;
|
|
45
|
+
const eventId = stored('_webhook_delivery', root).filter((d) => d.portalId === hub).length + 1;
|
|
46
|
+
const event = {
|
|
47
|
+
objectId: Number(e.objectId), eventId, subscriptionId: Number(subscription.id), portalId: hub, appId: app.appId,
|
|
48
|
+
occurredAt: Date.parse(at), attemptNumber: 0, changeSource: source, subscriptionType: `object.${e.kind}`,
|
|
49
|
+
objectTypeId: OBJECT_TYPE_ID[e.objectType], ...(sourceId ? { sourceId } : {}),
|
|
50
|
+
...(e.kind === 'propertyChange' ? { propertyName: e.propertyName, propertyValue: e.propertyValue ?? '' } : {}),
|
|
51
|
+
};
|
|
52
|
+
await deliver(String(hooks.targetUrl), String(app.client_secret), [event], hub, root, at);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async function deliver(to, secret, events, hub, root, at) {
|
|
56
|
+
const body = JSON.stringify(events);
|
|
57
|
+
const timestamp = String(Date.parse(at));
|
|
58
|
+
const headers = {
|
|
59
|
+
'content-type': 'application/json',
|
|
60
|
+
'x-hubspot-signature': createHash('sha256').update(secret + body).digest('hex'),
|
|
61
|
+
'x-hubspot-signature-version': 'v1',
|
|
62
|
+
'x-hubspot-signature-v3': createHmac('sha256', secret).update(`POST${to}${body}${timestamp}`).digest('base64'),
|
|
63
|
+
'x-hubspot-request-timestamp': timestamp,
|
|
64
|
+
};
|
|
65
|
+
const n = stored('_webhook_delivery', root).length + 1;
|
|
66
|
+
await keep('_webhook_delivery', `delivery:${n}`, { n, to, portalId: hub, body, headers, delivered_at: at }, root, at);
|
|
67
|
+
if (worldEgressRefusal(to) === null)
|
|
68
|
+
await fetch(to, { method: 'POST', headers, body }).then((r) => r.text()).catch(() => undefined);
|
|
69
|
+
}
|
|
70
|
+
/** GET /_twin/deliveries?to=<url>: what an app's server received from HubSpot, oldest first — each request's headers,
|
|
71
|
+
* its body as sent, and the events in it. */
|
|
72
|
+
export function deliveriesDoor(request, root) {
|
|
73
|
+
const to = new URL(request.url).searchParams.get('to') ?? '';
|
|
74
|
+
const deliveries = stored('_webhook_delivery', root).filter((d) => d.to === to).sort((a, b) => Number(a.n) - Number(b.n))
|
|
75
|
+
.map((d) => ({ n: d.n, delivered_at: d.delivered_at, headers: d.headers, body: d.body, events: JSON.parse(String(d.body)) }));
|
|
76
|
+
return Response.json({ to, deliveries });
|
|
77
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@volter/twin-hubspot",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local HubSpot CRM twin — a faithful, stateful local HubSpot v3/v4 CRM API your @hubspot/api-client integration talks to unmodified. Mirror, simulate, and fork. Built on @volter/world-core.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"twin",
|
|
7
|
+
"local",
|
|
8
|
+
"mock",
|
|
9
|
+
"mirror",
|
|
10
|
+
"simulator",
|
|
11
|
+
"fixtures",
|
|
12
|
+
"testing",
|
|
13
|
+
"api",
|
|
14
|
+
"crm",
|
|
15
|
+
"hubspot",
|
|
16
|
+
"hubapi",
|
|
17
|
+
"contacts",
|
|
18
|
+
"deals"
|
|
19
|
+
],
|
|
20
|
+
"author": "Volter (https://github.com/volter-ai)",
|
|
21
|
+
"license": "Apache-2.0",
|
|
22
|
+
"files": [
|
|
23
|
+
"src",
|
|
24
|
+
"client",
|
|
25
|
+
"README.md",
|
|
26
|
+
"LICENSE",
|
|
27
|
+
"!**/*.test.ts",
|
|
28
|
+
"!**/*.test.tsx",
|
|
29
|
+
"dist"
|
|
30
|
+
],
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/volter-ai/twin.git",
|
|
34
|
+
"directory": "packages/twin/hubspot"
|
|
35
|
+
},
|
|
36
|
+
"homepage": "https://github.com/volter-ai/twin/tree/main/packages/twin/hubspot#readme",
|
|
37
|
+
"type": "module",
|
|
38
|
+
"exports": {
|
|
39
|
+
".": {
|
|
40
|
+
"types": "./dist/src/index.d.ts",
|
|
41
|
+
"default": "./dist/src/index.js"
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"bin": {
|
|
45
|
+
"world-hubspot": "dist/src/cli.js"
|
|
46
|
+
},
|
|
47
|
+
"scripts": {
|
|
48
|
+
"test": "bun test src/*.test.ts",
|
|
49
|
+
"typecheck": "tsc --noEmit",
|
|
50
|
+
"build": "node ../../../scripts/publish/build.mjs",
|
|
51
|
+
"prepack": "node ../../../scripts/publish/prepare-publish.mjs prepack",
|
|
52
|
+
"postpack": "node ../../../scripts/publish/prepare-publish.mjs postpack"
|
|
53
|
+
},
|
|
54
|
+
"dependencies": {
|
|
55
|
+
"@volter/world-ui": "0.1.0",
|
|
56
|
+
"react": "^19.2.7",
|
|
57
|
+
"react-dom": "^19.2.7"
|
|
58
|
+
},
|
|
59
|
+
"peerDependencies": {
|
|
60
|
+
"@volter/world-core": "2.0.0"
|
|
61
|
+
},
|
|
62
|
+
"devDependencies": {
|
|
63
|
+
"@volter/world-core": "2.0.0",
|
|
64
|
+
"@volter/world-tooling": "0.1.0",
|
|
65
|
+
"@hubspot/api-client": "^14.0.1",
|
|
66
|
+
"@types/bun": "^1.2.20",
|
|
67
|
+
"@types/node": "^24.0.0",
|
|
68
|
+
"@types/react": "^19.2.17",
|
|
69
|
+
"@types/react-dom": "^19.2.3",
|
|
70
|
+
"typescript": "^5.9.0"
|
|
71
|
+
},
|
|
72
|
+
"engines": {
|
|
73
|
+
"node": ">=22.3"
|
|
74
|
+
}
|
|
75
|
+
}
|
package/src/accounts.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
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
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
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.ts';
|
|
6
|
+
import { createHubspotMirrorServer } from './hubspot-mirror-ui.ts';
|
|
7
|
+
|
|
8
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
9
|
+
const port = Number(optionValue(rest, '--port', '0')) || undefined;
|
|
10
|
+
const root = optionValue(rest, '--root') || undefined;
|
|
11
|
+
const readOnly = hasFlag(rest, '--read-only'); // a twin accepts writes unless started read-only
|
|
12
|
+
|
|
13
|
+
if (cmd === 'serve') {
|
|
14
|
+
const s = await createHubspotTwinServer({ readOnly, ...(root ? { root } : {}), ...(port ? { port } : {}) });
|
|
15
|
+
process.stdout.write(`hubspot twin (CRM v3/v4)${readOnly ? ' [read-only]' : ''} at ${s.url}\n`);
|
|
16
|
+
await keepProcessAlive();
|
|
17
|
+
} else if (cmd === 'mirror') {
|
|
18
|
+
const s = await createHubspotMirrorServer({ ...(root ? { root } : {}), ...(port ? { port } : {}) });
|
|
19
|
+
process.stdout.write(`hubspot mirror UI (CRM) at ${s.url}\n`);
|
|
20
|
+
await keepProcessAlive();
|
|
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.ts');
|
|
24
|
+
const report = await checkHubspotConformance({ ...(root ? { root } : {}) });
|
|
25
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
26
|
+
if (!report.ok) process.exitCode = 1;
|
|
27
|
+
} else {
|
|
28
|
+
process.stdout.write('Usage: world-hubspot serve|mirror|conformance [--port N] [--root DIR] [--read-only]\n');
|
|
29
|
+
}
|