diff --git a/README.md b/README.md index a5e8914..47458f2 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Profile-bound CLI for agent-first access to growth providers. This repository has one CLI and one workspace package: `gkit`. The reviewed provider surface includes DataForSEO, PostHog, Google Ads, Google -Search Console, and Bing Webmaster. +Search Console, Bing Webmaster, and HubSpot. ## Install @@ -21,7 +21,7 @@ gkit --schema Install an exact version instead: ```bash -bun add --global "gkit@https://github.com/celados/gkit/releases/download/v0.1.1/gkit-0.1.1.tgz" +bun add --global "gkit@https://github.com/celados/gkit/releases/download/v0.1.3/gkit-0.1.3.tgz" gkit --schema ``` @@ -72,6 +72,12 @@ Provider execution must bind exactly one App profile. Create one JSON file at "secrets": { "serviceAccountFile": "env:MY_APP_GSC_SERVICE_ACCOUNT_FILE" } + }, + "hubspot": { + "config": {}, + "secrets": { + "accessToken": "env:MY_APP_HUBSPOT_ACCESS_TOKEN" + } } } } @@ -92,6 +98,7 @@ Select a profile explicitly: ```bash gkit --profile my-app posthog doctor gkit --profile my-app gsc doctor +gkit --profile my-app hubspot doctor ``` Or bind it for one process through the environment: @@ -104,6 +111,72 @@ GKIT_PROFILE=my-app gkit posthog doctor or falls back to another App profile. Compare multiple Apps by running separate invocations and joining their outputs outside gkit. +### Check the profile + +Run `doctor` before making a provider request. It checks the selected profile +and its provider configuration without printing secret values: + +```bash +gkit --profile my-app gsc doctor +gkit --profile my-app hubspot doctor +``` + +### Preview, then execute + +Start with the exact example returned by `describe` and keep `--dry-run` while +reviewing the request: + +```bash +gkit --profile my-app gsc api call \ + --operation-id gsc.properties.list \ + --input '{}' \ + --out ./gsc-properties-plan.json \ + --dry-run +``` + +Remove `--dry-run` only when the profile and request are correct: + +```bash +gkit --profile my-app gsc api call \ + --operation-id gsc.properties.list \ + --input '{}' \ + --out ./gsc-properties.json +``` + +Artifacts use no-replace behavior by default. Choose a new output path for a +later run, or add `--force` only after reviewing the existing destination. +DataForSEO operations that can spend money additionally require both +`--allow-spend` and an explicit `--max-spend-usd` limit. + +### HubSpot read-only example + +HubSpot uses one profile-bound private-app access token and calls the REST API +directly; `@hubspot/cli` and `hs` are not runtime dependencies. The V1 surface +uses HubSpot's current `2026-03` date-versioned endpoints and exposes only +reviewed reads. CRM Search remains a POST because that is HubSpot's read API, +but create, update, delete, send, and import operations are inventory-only and +cannot be dispatched. + +```bash +gkit --profile my-app hubspot doctor +gkit describe --id hubspot.crm.objects.search +gkit --profile my-app hubspot api call \ + --operation-id hubspot.crm.objects.search \ + --input @hubspot-search.json \ + --out ./hubspot-contact-search.json \ + --dry-run +``` + +HubSpot artifacts can contain PII and confidential business data, including +contact and owner names or email addresses, ticket text, event URLs and +properties, object and association identifiers, company or deal details, and +pipeline or property metadata. Results are therefore artifact-only: the CLI +prints a compact envelope and receipt, never the unbounded CRM payload. Keep +artifacts access-controlled and request only the reviewed properties required +for the analysis. Search pages are capped at 200 and one search query cannot +page beyond 10,000 results; other list surfaces use lower reviewed page and +total-result bounds documented by `describe`. + ## Configure an Agent Agents do not need provider-specific CLIs or their own copies of credentials. diff --git a/bun.lock b/bun.lock index 0487c6c..544c0a2 100644 --- a/bun.lock +++ b/bun.lock @@ -14,7 +14,7 @@ }, "packages/gkit": { "name": "gkit", - "version": "0.1.2", + "version": "0.1.3", "bin": { "gkit": "bin/gkit.js", }, diff --git a/packages/gkit/docs/providers/hubspot/capabilities.md b/packages/gkit/docs/providers/hubspot/capabilities.md new file mode 100644 index 0000000..444d3d0 --- /dev/null +++ b/packages/gkit/docs/providers/hubspot/capabilities.md @@ -0,0 +1,472 @@ +--- +type: Reference +title: HubSpot reviewed executable capabilities +description: > + Generated, searchable documentation for the reviewed HubSpot operations + that gkit is allowed to route and execute. +provider: hubspot +manifestRevision: 2026-08-30.hubspot.read-v1.1 +--- + +# HubSpot reviewed executable capabilities + +This file is byte-stably rendered from `generated/hubspot/manifest.json`. +The committed manifest remains the only runtime, validation, effect, cost, and discovery source. + +## Data sensitivity + +HubSpot artifacts can contain personal data: contact names and email addresses; owner names, email addresses, and teams; ticket subjects or content; event URLs, page titles, object IDs, and event properties. Company records, deal names and amounts, association IDs, pipeline labels, and property metadata can also disclose confidential business context. + +Treat every HubSpot artifact as sensitive. Store it only at an access-controlled path, do not paste raw CRM rows into prompts or logs, and request only the reviewed properties required for the bounded analysis. The properties capability requests HubSpot's default non-sensitive metadata view and does not opt into sensitive-property definitions. + +## hubspot.crm.associations.list + +List bounded associations from one reviewed CRM record to one reviewed object type. + +- Provider: `hubspot` +- Adapter key: `crm.associations.list` +- Capability revision: `1` +- Effects: `read` + +### Input schema + +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "required": [ + "fromObjectType", + "objectId", + "toObjectType", + "limit" + ], + "properties": { + "fromObjectType": { + "enum": [ + "companies", + "contacts", + "deals", + "tickets" + ] + }, + "objectId": { + "type": "string", + "pattern": "^[1-9]\\d{0,19}$" + }, + "toObjectType": { + "enum": [ + "companies", + "contacts", + "deals", + "tickets" + ] + }, + "pageSize": { + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 5000 + } + } +} +``` + +### Invocation + +```bash +gkit --profile app-a hubspot api call --operation-id hubspot.crm.associations.list --input @request.json --out hubspot-associations.json --dry-run +``` + +## hubspot.crm.objects.list + +List bounded records for one reviewed CRM object type and reviewed properties. + +- Provider: `hubspot` +- Adapter key: `crm.objects.list` +- Capability revision: `1` +- Effects: `read` + +### Input schema + +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "required": [ + "objectType", + "properties", + "limit" + ], + "properties": { + "objectType": { + "enum": [ + "companies", + "contacts", + "deals", + "tickets" + ] + }, + "properties": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "maxItems": 50, + "items": { + "$ref": "#/definitions/property" + } + }, + "archived": { + "type": "boolean" + }, + "pageSize": { + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 5000 + } + }, + "definitions": { + "property": { + "enum": [ + "amount", + "city", + "closedate", + "content", + "country", + "createdate", + "dealname", + "dealstage", + "domain", + "email", + "firstname", + "hs_analytics_source", + "hs_analytics_source_data_1", + "hs_analytics_source_data_2", + "hs_lastmodifieddate", + "hs_lead_status", + "hs_object_id", + "hs_pipeline", + "hs_pipeline_stage", + "hs_ticket_category", + "hs_ticket_priority", + "hubspot_owner_id", + "industry", + "lastmodifieddate", + "lastname", + "lifecyclestage", + "name", + "numberofemployees", + "pipeline", + "state", + "subject" + ] + } + } +} +``` + +### Invocation + +```bash +gkit --profile app-a hubspot api call --operation-id hubspot.crm.objects.list --input @request.json --out hubspot-contacts.json --dry-run +``` + +## hubspot.crm.objects.search + +Run one bounded HubSpot CRM Search POST over reviewed objects, properties, filters, and sorts. + +- Provider: `hubspot` +- Adapter key: `crm.objects.search` +- Capability revision: `1` +- Effects: `read` + +### Input schema + +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "required": [ + "objectType", + "properties", + "limit" + ], + "properties": { + "objectType": { + "enum": [ + "companies", + "contacts", + "deals", + "tickets" + ] + }, + "properties": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "maxItems": 50, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 100 + } + }, + "query": { + "type": "string", + "minLength": 1, + "maxLength": 3000 + }, + "filterGroups": { + "type": "array", + "maxItems": 5, + "items": { + "type": "object" + } + }, + "sorts": { + "type": "array", + "maxItems": 1, + "items": { + "type": "object" + } + }, + "pageSize": { + "type": "integer", + "minimum": 1, + "maximum": 200 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 10000 + } + } +} +``` + +### Invocation + +```bash +gkit --profile app-a hubspot api call --operation-id hubspot.crm.objects.search --input @request.json --out hubspot-contact-search.json --dry-run +``` + +## hubspot.crm.owners.list + +List bounded active or archived HubSpot owners for attribution joins. + +- Provider: `hubspot` +- Adapter key: `crm.owners.list` +- Capability revision: `1` +- Effects: `read` + +### Input schema + +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "required": [ + "limit" + ], + "properties": { + "archived": { + "type": "boolean" + }, + "pageSize": { + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 5000 + } + } +} +``` + +### Invocation + +```bash +gkit --profile app-a hubspot api call --operation-id hubspot.crm.owners.list --input '{"limit":100}' --out hubspot-owners.json --dry-run +``` + +## hubspot.crm.pipelines.list + +List pipelines and stages for deals or tickets. + +- Provider: `hubspot` +- Adapter key: `crm.pipelines.list` +- Capability revision: `1` +- Effects: `read` + +### Input schema + +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "required": [ + "objectType" + ], + "properties": { + "objectType": { + "enum": [ + "deals", + "tickets" + ] + } + } +} +``` + +### Invocation + +```bash +gkit --profile app-a hubspot api call --operation-id hubspot.crm.pipelines.list --input '{"objectType":"deals"}' --out hubspot-deal-pipelines.json --dry-run +``` + +## hubspot.crm.properties.list + +List non-sensitive property definitions for one reviewed CRM object type. + +- Provider: `hubspot` +- Adapter key: `crm.properties.list` +- Capability revision: `1` +- Effects: `read` + +### Input schema + +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "required": [ + "objectType" + ], + "properties": { + "objectType": { + "enum": [ + "companies", + "contacts", + "deals", + "tickets" + ] + } + } +} +``` + +### Invocation + +```bash +gkit --profile app-a hubspot api call --operation-id hubspot.crm.properties.list --input '{"objectType":"contacts"}' --out hubspot-contact-properties.json --dry-run +``` + +## hubspot.events.occurrences.list + +List bounded HubSpot event occurrences within an explicit time window. + +- Provider: `hubspot` +- Adapter key: `events.occurrences.list` +- Capability revision: `1` +- Effects: `read` + +### Input schema + +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "required": [ + "occurredAfter", + "occurredBefore", + "properties", + "limit" + ], + "properties": { + "occurredAfter": { + "type": "string", + "format": "date-time", + "maxLength": 64 + }, + "occurredBefore": { + "type": "string", + "format": "date-time", + "maxLength": 64 + }, + "eventType": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "objectType": { + "enum": [ + "company", + "contact", + "deal", + "ticket" + ] + }, + "objectId": { + "type": "string", + "pattern": "^[1-9]\\d{0,19}$" + }, + "properties": { + "type": "array", + "minItems": 1, + "maxItems": 20, + "uniqueItems": true, + "items": { + "enum": [ + "hs_browser", + "hs_city", + "hs_content_type", + "hs_country", + "hs_device_name", + "hs_device_type", + "hs_page_title", + "hs_referrer", + "hs_touchpoint_source", + "hs_url", + "hs_utm_campaign", + "hs_utm_medium", + "hs_utm_source" + ] + } + }, + "pageSize": { + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 5000 + } + } +} +``` + +### Invocation + +```bash +gkit --profile app-a hubspot api call --operation-id hubspot.events.occurrences.list --input @request.json --out hubspot-events.json --dry-run +``` diff --git a/packages/gkit/docs/providers/hubspot/inventory.md b/packages/gkit/docs/providers/hubspot/inventory.md new file mode 100644 index 0000000..bcea109 --- /dev/null +++ b/packages/gkit/docs/providers/hubspot/inventory.md @@ -0,0 +1,35 @@ +--- +type: Reference +title: HubSpot operation inventory +description: > + Generated inventory of pinned HubSpot operations and their gkit exposure decisions. +provider: hubspot +inventoryRevision: 2026-08-30.hubspot.read-v1.1 +--- + +# HubSpot operation inventory + +This pinned inventory contains 17 operations: 7 executable and 10 inventory-only. +Inventory-only operations cannot be routed by `gkit hubspot api call`. + +HubSpot record, owner, event, association, pipeline, and property artifacts may contain PII or confidential business data. The inventory records endpoint exposure only; it does not authorize copying provider data into logs or prompts. + +| Method | Path | Operation ID | Exposure | Decision | +| --- | --- | --- | --- | --- | +| `POST` | `/crm/imports/2026-03/imports` | `crm.imports.create` | `inventory` | Imports are mutations and are outside the read-only HubSpot V1 provider. | +| `GET` | `/crm/objects/2026-03/{fromObjectType}/{objectId}/associations/{toObjectType}` | `crm.associations.list` | `executable` | Reviewed adapter, input, effect, and response contracts are committed.; capability: `hubspot.crm.associations.list` | +| `DELETE` | `/crm/objects/2026-03/{fromObjectType}/{objectId}/associations/{toObjectType}/{toObjectId}` | `crm.associations.delete` | `inventory` | Destructive mutation is outside the read-only HubSpot V1 provider. | +| `PUT` | `/crm/objects/2026-03/{fromObjectType}/{objectId}/associations/default/{toObjectType}/{toObjectId}` | `crm.associations.create` | `inventory` | Mutation is outside the read-only HubSpot V1 provider. | +| `GET` | `/crm/objects/2026-03/{objectType}` | `crm.objects.list` | `executable` | Reviewed adapter, input, effect, and response contracts are committed.; capability: `hubspot.crm.objects.list` | +| `POST` | `/crm/objects/2026-03/{objectType}` | `crm.objects.create` | `inventory` | Mutation is outside the read-only HubSpot V1 provider. | +| `DELETE` | `/crm/objects/2026-03/{objectType}/{recordId}` | `crm.objects.archive` | `inventory` | Destructive mutation is outside the read-only HubSpot V1 provider. | +| `PATCH` | `/crm/objects/2026-03/{objectType}/{recordId}` | `crm.objects.update` | `inventory` | Mutation is outside the read-only HubSpot V1 provider. | +| `POST` | `/crm/objects/2026-03/{objectType}/search` | `crm.objects.search` | `executable` | Reviewed adapter, input, effect, and response contracts are committed.; capability: `hubspot.crm.objects.search` | +| `GET` | `/crm/owners/2026-03` | `crm.owners.list` | `executable` | Reviewed adapter, input, effect, and response contracts are committed.; capability: `hubspot.crm.owners.list` | +| `GET` | `/crm/pipelines/2026-03/{objectType}` | `crm.pipelines.list` | `executable` | Reviewed adapter, input, effect, and response contracts are committed.; capability: `hubspot.crm.pipelines.list` | +| `POST` | `/crm/pipelines/2026-03/{objectType}` | `crm.pipelines.create` | `inventory` | Mutation is outside the read-only HubSpot V1 provider. | +| `DELETE` | `/crm/pipelines/2026-03/{objectType}/{pipelineId}` | `crm.pipelines.delete` | `inventory` | Destructive mutation is outside the read-only HubSpot V1 provider. | +| `GET` | `/crm/properties/2026-03/{objectType}` | `crm.properties.list` | `executable` | Reviewed adapter, input, effect, and response contracts are committed.; capability: `hubspot.crm.properties.list` | +| `POST` | `/crm/properties/2026-03/{objectType}` | `crm.properties.create` | `inventory` | Mutation is outside the read-only HubSpot V1 provider. | +| `POST` | `/events/2026-03/send` | `events.occurrences.send` | `inventory` | Event sending is outside the read-only HubSpot V1 provider. | +| `GET` | `/events/event-occurrences/2026-03` | `events.occurrences.list` | `executable` | Reviewed adapter, input, effect, and response contracts are committed.; capability: `hubspot.events.occurrences.list` | diff --git a/packages/gkit/generated/hubspot/inventory.json b/packages/gkit/generated/hubspot/inventory.json new file mode 100644 index 0000000..fb7d790 --- /dev/null +++ b/packages/gkit/generated/hubspot/inventory.json @@ -0,0 +1,138 @@ +{ + "version": 1, + "provider": "hubspot", + "revision": "2026-08-30.hubspot.read-v1.1", + "source": { + "url": "https://developers.hubspot.com/docs/api/how-to-use-hubspot-api", + "revision": "hubspot-2026-03-reviewed-2026-08-30", + "checksum": "sha256:7bb3fe9ae46ca78a2fcc4fd0fec51f4bbf8f32c5fba92600c1b09ad190048da3" + }, + "operations": [ + { + "path": "/crm/imports/2026-03/imports", + "method": "post", + "operationId": "crm.imports.create", + "exposure": "inventory", + "reason": "Imports are mutations and are outside the read-only HubSpot V1 provider." + }, + { + "path": "/crm/objects/2026-03/{fromObjectType}/{objectId}/associations/{toObjectType}", + "method": "get", + "operationId": "crm.associations.list", + "exposure": "executable", + "capabilityId": "hubspot.crm.associations.list", + "reason": "Reviewed adapter, input, effect, and response contracts are committed." + }, + { + "path": "/crm/objects/2026-03/{fromObjectType}/{objectId}/associations/{toObjectType}/{toObjectId}", + "method": "delete", + "operationId": "crm.associations.delete", + "exposure": "inventory", + "reason": "Destructive mutation is outside the read-only HubSpot V1 provider." + }, + { + "path": "/crm/objects/2026-03/{fromObjectType}/{objectId}/associations/default/{toObjectType}/{toObjectId}", + "method": "put", + "operationId": "crm.associations.create", + "exposure": "inventory", + "reason": "Mutation is outside the read-only HubSpot V1 provider." + }, + { + "path": "/crm/objects/2026-03/{objectType}", + "method": "get", + "operationId": "crm.objects.list", + "exposure": "executable", + "capabilityId": "hubspot.crm.objects.list", + "reason": "Reviewed adapter, input, effect, and response contracts are committed." + }, + { + "path": "/crm/objects/2026-03/{objectType}", + "method": "post", + "operationId": "crm.objects.create", + "exposure": "inventory", + "reason": "Mutation is outside the read-only HubSpot V1 provider." + }, + { + "path": "/crm/objects/2026-03/{objectType}/{recordId}", + "method": "delete", + "operationId": "crm.objects.archive", + "exposure": "inventory", + "reason": "Destructive mutation is outside the read-only HubSpot V1 provider." + }, + { + "path": "/crm/objects/2026-03/{objectType}/{recordId}", + "method": "patch", + "operationId": "crm.objects.update", + "exposure": "inventory", + "reason": "Mutation is outside the read-only HubSpot V1 provider." + }, + { + "path": "/crm/objects/2026-03/{objectType}/search", + "method": "post", + "operationId": "crm.objects.search", + "exposure": "executable", + "capabilityId": "hubspot.crm.objects.search", + "reason": "Reviewed adapter, input, effect, and response contracts are committed." + }, + { + "path": "/crm/owners/2026-03", + "method": "get", + "operationId": "crm.owners.list", + "exposure": "executable", + "capabilityId": "hubspot.crm.owners.list", + "reason": "Reviewed adapter, input, effect, and response contracts are committed." + }, + { + "path": "/crm/pipelines/2026-03/{objectType}", + "method": "get", + "operationId": "crm.pipelines.list", + "exposure": "executable", + "capabilityId": "hubspot.crm.pipelines.list", + "reason": "Reviewed adapter, input, effect, and response contracts are committed." + }, + { + "path": "/crm/pipelines/2026-03/{objectType}", + "method": "post", + "operationId": "crm.pipelines.create", + "exposure": "inventory", + "reason": "Mutation is outside the read-only HubSpot V1 provider." + }, + { + "path": "/crm/pipelines/2026-03/{objectType}/{pipelineId}", + "method": "delete", + "operationId": "crm.pipelines.delete", + "exposure": "inventory", + "reason": "Destructive mutation is outside the read-only HubSpot V1 provider." + }, + { + "path": "/crm/properties/2026-03/{objectType}", + "method": "get", + "operationId": "crm.properties.list", + "exposure": "executable", + "capabilityId": "hubspot.crm.properties.list", + "reason": "Reviewed adapter, input, effect, and response contracts are committed." + }, + { + "path": "/crm/properties/2026-03/{objectType}", + "method": "post", + "operationId": "crm.properties.create", + "exposure": "inventory", + "reason": "Mutation is outside the read-only HubSpot V1 provider." + }, + { + "path": "/events/2026-03/send", + "method": "post", + "operationId": "events.occurrences.send", + "exposure": "inventory", + "reason": "Event sending is outside the read-only HubSpot V1 provider." + }, + { + "path": "/events/event-occurrences/2026-03", + "method": "get", + "operationId": "events.occurrences.list", + "exposure": "executable", + "capabilityId": "hubspot.events.occurrences.list", + "reason": "Reviewed adapter, input, effect, and response contracts are committed." + } + ] +} diff --git a/packages/gkit/generated/hubspot/manifest.json b/packages/gkit/generated/hubspot/manifest.json new file mode 100644 index 0000000..1632a97 --- /dev/null +++ b/packages/gkit/generated/hubspot/manifest.json @@ -0,0 +1,489 @@ +{ + "version": 1, + "provider": "hubspot", + "revision": "2026-08-30.hubspot.read-v1.1", + "source": { + "url": "https://developers.hubspot.com/docs/api/how-to-use-hubspot-api", + "revision": "hubspot-2026-03-reviewed-2026-08-30", + "checksum": "sha256:7bb3fe9ae46ca78a2fcc4fd0fec51f4bbf8f32c5fba92600c1b09ad190048da3" + }, + "reviewedAt": "2026-08-30T05:00:00.000Z", + "capabilities": [ + { + "id": "hubspot.crm.associations.list", + "provider": "hubspot", + "revision": "1", + "adapterKey": "crm.associations.list", + "title": "List record associations", + "description": "List bounded associations from one reviewed CRM record to one reviewed object type.", + "effects": [ + "read" + ], + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "required": [ + "fromObjectType", + "objectId", + "toObjectType", + "limit" + ], + "properties": { + "fromObjectType": { + "enum": [ + "companies", + "contacts", + "deals", + "tickets" + ] + }, + "objectId": { + "type": "string", + "pattern": "^[1-9]\\d{0,19}$" + }, + "toObjectType": { + "enum": [ + "companies", + "contacts", + "deals", + "tickets" + ] + }, + "pageSize": { + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 5000 + } + } + }, + "examples": [ + { + "input": { + "fromObjectType": "contacts", + "objectId": "123", + "toObjectType": "companies", + "limit": 100 + }, + "command": "gkit --profile app-a hubspot api call --operation-id hubspot.crm.associations.list --input @request.json --out hubspot-associations.json --dry-run" + } + ] + }, + { + "id": "hubspot.crm.objects.list", + "provider": "hubspot", + "revision": "1", + "adapterKey": "crm.objects.list", + "title": "List CRM records", + "description": "List bounded records for one reviewed CRM object type and reviewed properties.", + "effects": [ + "read" + ], + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "required": [ + "objectType", + "properties", + "limit" + ], + "properties": { + "objectType": { + "enum": [ + "companies", + "contacts", + "deals", + "tickets" + ] + }, + "properties": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "maxItems": 50, + "items": { + "$ref": "#/definitions/property" + } + }, + "archived": { + "type": "boolean" + }, + "pageSize": { + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 5000 + } + }, + "definitions": { + "property": { + "enum": [ + "amount", + "city", + "closedate", + "content", + "country", + "createdate", + "dealname", + "dealstage", + "domain", + "email", + "firstname", + "hs_analytics_source", + "hs_analytics_source_data_1", + "hs_analytics_source_data_2", + "hs_lastmodifieddate", + "hs_lead_status", + "hs_object_id", + "hs_pipeline", + "hs_pipeline_stage", + "hs_ticket_category", + "hs_ticket_priority", + "hubspot_owner_id", + "industry", + "lastmodifieddate", + "lastname", + "lifecyclestage", + "name", + "numberofemployees", + "pipeline", + "state", + "subject" + ] + } + } + }, + "examples": [ + { + "input": { + "objectType": "contacts", + "properties": [ + "email", + "lifecyclestage" + ], + "limit": 100 + }, + "command": "gkit --profile app-a hubspot api call --operation-id hubspot.crm.objects.list --input @request.json --out hubspot-contacts.json --dry-run" + } + ] + }, + { + "id": "hubspot.crm.objects.search", + "provider": "hubspot", + "revision": "1", + "adapterKey": "crm.objects.search", + "title": "Search CRM records", + "description": "Run one bounded HubSpot CRM Search POST over reviewed objects, properties, filters, and sorts.", + "effects": [ + "read" + ], + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "required": [ + "objectType", + "properties", + "limit" + ], + "properties": { + "objectType": { + "enum": [ + "companies", + "contacts", + "deals", + "tickets" + ] + }, + "properties": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "maxItems": 50, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 100 + } + }, + "query": { + "type": "string", + "minLength": 1, + "maxLength": 3000 + }, + "filterGroups": { + "type": "array", + "maxItems": 5, + "items": { + "type": "object" + } + }, + "sorts": { + "type": "array", + "maxItems": 1, + "items": { + "type": "object" + } + }, + "pageSize": { + "type": "integer", + "minimum": 1, + "maximum": 200 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 10000 + } + } + }, + "examples": [ + { + "input": { + "objectType": "contacts", + "properties": [ + "email", + "lifecyclestage" + ], + "filterGroups": [ + { + "filters": [ + { + "propertyName": "lifecyclestage", + "operator": "EQ", + "value": "customer" + } + ] + } + ], + "limit": 200 + }, + "command": "gkit --profile app-a hubspot api call --operation-id hubspot.crm.objects.search --input @request.json --out hubspot-contact-search.json --dry-run" + } + ] + }, + { + "id": "hubspot.crm.owners.list", + "provider": "hubspot", + "revision": "1", + "adapterKey": "crm.owners.list", + "title": "List CRM owners", + "description": "List bounded active or archived HubSpot owners for attribution joins.", + "effects": [ + "read" + ], + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "required": [ + "limit" + ], + "properties": { + "archived": { + "type": "boolean" + }, + "pageSize": { + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 5000 + } + } + }, + "examples": [ + { + "input": { + "limit": 100 + }, + "command": "gkit --profile app-a hubspot api call --operation-id hubspot.crm.owners.list --input '{\"limit\":100}' --out hubspot-owners.json --dry-run" + } + ] + }, + { + "id": "hubspot.crm.pipelines.list", + "provider": "hubspot", + "revision": "1", + "adapterKey": "crm.pipelines.list", + "title": "List CRM pipelines", + "description": "List pipelines and stages for deals or tickets.", + "effects": [ + "read" + ], + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "required": [ + "objectType" + ], + "properties": { + "objectType": { + "enum": [ + "deals", + "tickets" + ] + } + } + }, + "examples": [ + { + "input": { + "objectType": "deals" + }, + "command": "gkit --profile app-a hubspot api call --operation-id hubspot.crm.pipelines.list --input '{\"objectType\":\"deals\"}' --out hubspot-deal-pipelines.json --dry-run" + } + ] + }, + { + "id": "hubspot.crm.properties.list", + "provider": "hubspot", + "revision": "1", + "adapterKey": "crm.properties.list", + "title": "List CRM property metadata", + "description": "List non-sensitive property definitions for one reviewed CRM object type.", + "effects": [ + "read" + ], + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "required": [ + "objectType" + ], + "properties": { + "objectType": { + "enum": [ + "companies", + "contacts", + "deals", + "tickets" + ] + } + } + }, + "examples": [ + { + "input": { + "objectType": "contacts" + }, + "command": "gkit --profile app-a hubspot api call --operation-id hubspot.crm.properties.list --input '{\"objectType\":\"contacts\"}' --out hubspot-contact-properties.json --dry-run" + } + ] + }, + { + "id": "hubspot.events.occurrences.list", + "provider": "hubspot", + "revision": "1", + "adapterKey": "events.occurrences.list", + "title": "List event occurrences", + "description": "List bounded HubSpot event occurrences within an explicit time window.", + "effects": [ + "read" + ], + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "required": [ + "occurredAfter", + "occurredBefore", + "properties", + "limit" + ], + "properties": { + "occurredAfter": { + "type": "string", + "format": "date-time", + "maxLength": 64 + }, + "occurredBefore": { + "type": "string", + "format": "date-time", + "maxLength": 64 + }, + "eventType": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "objectType": { + "enum": [ + "company", + "contact", + "deal", + "ticket" + ] + }, + "objectId": { + "type": "string", + "pattern": "^[1-9]\\d{0,19}$" + }, + "properties": { + "type": "array", + "minItems": 1, + "maxItems": 20, + "uniqueItems": true, + "items": { + "enum": [ + "hs_browser", + "hs_city", + "hs_content_type", + "hs_country", + "hs_device_name", + "hs_device_type", + "hs_page_title", + "hs_referrer", + "hs_touchpoint_source", + "hs_url", + "hs_utm_campaign", + "hs_utm_medium", + "hs_utm_source" + ] + } + }, + "pageSize": { + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 5000 + } + } + }, + "examples": [ + { + "input": { + "occurredAfter": "2026-08-01T00:00:00Z", + "occurredBefore": "2026-08-02T00:00:00Z", + "eventType": "e_visited_page", + "properties": [ + "hs_url", + "hs_page_title" + ], + "limit": 100 + }, + "command": "gkit --profile app-a hubspot api call --operation-id hubspot.events.occurrences.list --input @request.json --out hubspot-events.json --dry-run" + } + ] + } + ] +} diff --git a/packages/gkit/package.json b/packages/gkit/package.json index c7f4ad9..b1868f2 100644 --- a/packages/gkit/package.json +++ b/packages/gkit/package.json @@ -1,6 +1,6 @@ { "name": "gkit", - "version": "0.1.2", + "version": "0.1.3", "private": true, "description": "Profile-bound, agent-first growth provider CLI.", "bin": { @@ -26,6 +26,8 @@ "generate:google-ads:check": "bun run ./scripts/generate-google-ads.ts --check", "generate:gsc": "bun run ./scripts/generate-gsc.ts", "generate:gsc:check": "bun run ./scripts/generate-gsc.ts --check", + "generate:hubspot": "bun run ./scripts/generate-hubspot.ts", + "generate:hubspot:check": "bun run ./scripts/generate-hubspot.ts --check", "generate:posthog": "bun run ./scripts/generate-posthog.ts", "generate:posthog:check": "bun run ./scripts/generate-posthog.ts --check", "eval:slice5": "bun run ./src/eval.ts", diff --git a/packages/gkit/policy/hubspot.reviewed.json b/packages/gkit/policy/hubspot.reviewed.json new file mode 100644 index 0000000..aa2f6f5 --- /dev/null +++ b/packages/gkit/policy/hubspot.reviewed.json @@ -0,0 +1,300 @@ +{ + "version": 1, + "provider": "hubspot", + "manifestRevision": "2026-08-30.hubspot.read-v1.1", + "reviewedAt": "2026-08-30T05:00:00.000Z", + "entries": [ + { + "path": "/crm/properties/2026-03/{objectType}", + "method": "get", + "operationId": "crm.properties.list", + "exposure": "executable", + "capability": { + "id": "hubspot.crm.properties.list", + "provider": "hubspot", + "revision": "1", + "adapterKey": "crm.properties.list", + "title": "List CRM property metadata", + "description": "List non-sensitive property definitions for one reviewed CRM object type.", + "effects": ["read"], + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "required": ["objectType"], + "properties": { "objectType": { "enum": ["companies", "contacts", "deals", "tickets"] } } + }, + "examples": [{ + "input": { "objectType": "contacts" }, + "command": "gkit --profile app-a hubspot api call --operation-id hubspot.crm.properties.list --input '{\"objectType\":\"contacts\"}' --out hubspot-contact-properties.json --dry-run" + }] + } + }, + { + "path": "/crm/properties/2026-03/{objectType}", + "method": "post", + "operationId": "crm.properties.create", + "exposure": "inventory", + "reason": "Mutation is outside the read-only HubSpot V1 provider." + }, + { + "path": "/crm/objects/2026-03/{objectType}", + "method": "get", + "operationId": "crm.objects.list", + "exposure": "executable", + "capability": { + "id": "hubspot.crm.objects.list", + "provider": "hubspot", + "revision": "1", + "adapterKey": "crm.objects.list", + "title": "List CRM records", + "description": "List bounded records for one reviewed CRM object type and reviewed properties.", + "effects": ["read"], + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "required": ["objectType", "properties", "limit"], + "properties": { + "objectType": { "enum": ["companies", "contacts", "deals", "tickets"] }, + "properties": { "type": "array", "minItems": 1, "uniqueItems": true, "maxItems": 50, "items": { "$ref": "#/definitions/property" } }, + "archived": { "type": "boolean" }, + "pageSize": { "type": "integer", "minimum": 1, "maximum": 100 }, + "limit": { "type": "integer", "minimum": 1, "maximum": 5000 } + }, + "definitions": { "property": { "enum": ["amount", "city", "closedate", "content", "country", "createdate", "dealname", "dealstage", "domain", "email", "firstname", "hs_analytics_source", "hs_analytics_source_data_1", "hs_analytics_source_data_2", "hs_lastmodifieddate", "hs_lead_status", "hs_object_id", "hs_pipeline", "hs_pipeline_stage", "hs_ticket_category", "hs_ticket_priority", "hubspot_owner_id", "industry", "lastmodifieddate", "lastname", "lifecyclestage", "name", "numberofemployees", "pipeline", "state", "subject"] } } + }, + "examples": [{ + "input": { "objectType": "contacts", "properties": ["email", "lifecyclestage"], "limit": 100 }, + "command": "gkit --profile app-a hubspot api call --operation-id hubspot.crm.objects.list --input @request.json --out hubspot-contacts.json --dry-run" + }] + } + }, + { + "path": "/crm/objects/2026-03/{objectType}", + "method": "post", + "operationId": "crm.objects.create", + "exposure": "inventory", + "reason": "Mutation is outside the read-only HubSpot V1 provider." + }, + { + "path": "/crm/objects/2026-03/{objectType}/search", + "method": "post", + "operationId": "crm.objects.search", + "exposure": "executable", + "capability": { + "id": "hubspot.crm.objects.search", + "provider": "hubspot", + "revision": "1", + "adapterKey": "crm.objects.search", + "title": "Search CRM records", + "description": "Run one bounded HubSpot CRM Search POST over reviewed objects, properties, filters, and sorts.", + "effects": ["read"], + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "required": ["objectType", "properties", "limit"], + "properties": { + "objectType": { "enum": ["companies", "contacts", "deals", "tickets"] }, + "properties": { "type": "array", "minItems": 1, "uniqueItems": true, "maxItems": 50, "items": { "type": "string", "minLength": 1, "maxLength": 100 } }, + "query": { "type": "string", "minLength": 1, "maxLength": 3000 }, + "filterGroups": { "type": "array", "maxItems": 5, "items": { "type": "object" } }, + "sorts": { "type": "array", "maxItems": 1, "items": { "type": "object" } }, + "pageSize": { "type": "integer", "minimum": 1, "maximum": 200 }, + "limit": { "type": "integer", "minimum": 1, "maximum": 10000 } + } + }, + "examples": [{ + "input": { "objectType": "contacts", "properties": ["email", "lifecyclestage"], "filterGroups": [{ "filters": [{ "propertyName": "lifecyclestage", "operator": "EQ", "value": "customer" }] }], "limit": 200 }, + "command": "gkit --profile app-a hubspot api call --operation-id hubspot.crm.objects.search --input @request.json --out hubspot-contact-search.json --dry-run" + }] + } + }, + { + "path": "/crm/objects/2026-03/{objectType}/{recordId}", + "method": "patch", + "operationId": "crm.objects.update", + "exposure": "inventory", + "reason": "Mutation is outside the read-only HubSpot V1 provider." + }, + { + "path": "/crm/objects/2026-03/{objectType}/{recordId}", + "method": "delete", + "operationId": "crm.objects.archive", + "exposure": "inventory", + "reason": "Destructive mutation is outside the read-only HubSpot V1 provider." + }, + { + "path": "/crm/objects/2026-03/{fromObjectType}/{objectId}/associations/{toObjectType}", + "method": "get", + "operationId": "crm.associations.list", + "exposure": "executable", + "capability": { + "id": "hubspot.crm.associations.list", + "provider": "hubspot", + "revision": "1", + "adapterKey": "crm.associations.list", + "title": "List record associations", + "description": "List bounded associations from one reviewed CRM record to one reviewed object type.", + "effects": ["read"], + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "required": ["fromObjectType", "objectId", "toObjectType", "limit"], + "properties": { + "fromObjectType": { "enum": ["companies", "contacts", "deals", "tickets"] }, + "objectId": { "type": "string", "pattern": "^[1-9]\\d{0,19}$" }, + "toObjectType": { "enum": ["companies", "contacts", "deals", "tickets"] }, + "pageSize": { "type": "integer", "minimum": 1, "maximum": 100 }, + "limit": { "type": "integer", "minimum": 1, "maximum": 5000 } + } + }, + "examples": [{ + "input": { "fromObjectType": "contacts", "objectId": "123", "toObjectType": "companies", "limit": 100 }, + "command": "gkit --profile app-a hubspot api call --operation-id hubspot.crm.associations.list --input @request.json --out hubspot-associations.json --dry-run" + }] + } + }, + { + "path": "/crm/objects/2026-03/{fromObjectType}/{objectId}/associations/default/{toObjectType}/{toObjectId}", + "method": "put", + "operationId": "crm.associations.create", + "exposure": "inventory", + "reason": "Mutation is outside the read-only HubSpot V1 provider." + }, + { + "path": "/crm/objects/2026-03/{fromObjectType}/{objectId}/associations/{toObjectType}/{toObjectId}", + "method": "delete", + "operationId": "crm.associations.delete", + "exposure": "inventory", + "reason": "Destructive mutation is outside the read-only HubSpot V1 provider." + }, + { + "path": "/events/event-occurrences/2026-03", + "method": "get", + "operationId": "events.occurrences.list", + "exposure": "executable", + "capability": { + "id": "hubspot.events.occurrences.list", + "provider": "hubspot", + "revision": "1", + "adapterKey": "events.occurrences.list", + "title": "List event occurrences", + "description": "List bounded HubSpot event occurrences within an explicit time window.", + "effects": ["read"], + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "required": ["occurredAfter", "occurredBefore", "properties", "limit"], + "properties": { + "occurredAfter": { "type": "string", "format": "date-time", "maxLength": 64 }, + "occurredBefore": { "type": "string", "format": "date-time", "maxLength": 64 }, + "eventType": { "type": "string", "minLength": 1, "maxLength": 200 }, + "objectType": { "enum": ["company", "contact", "deal", "ticket"] }, + "objectId": { "type": "string", "pattern": "^[1-9]\\d{0,19}$" }, + "properties": { + "type": "array", + "minItems": 1, + "maxItems": 20, + "uniqueItems": true, + "items": { "enum": ["hs_browser", "hs_city", "hs_content_type", "hs_country", "hs_device_name", "hs_device_type", "hs_page_title", "hs_referrer", "hs_touchpoint_source", "hs_url", "hs_utm_campaign", "hs_utm_medium", "hs_utm_source"] } + }, + "pageSize": { "type": "integer", "minimum": 1, "maximum": 100 }, + "limit": { "type": "integer", "minimum": 1, "maximum": 5000 } + } + }, + "examples": [{ + "input": { "occurredAfter": "2026-08-01T00:00:00Z", "occurredBefore": "2026-08-02T00:00:00Z", "eventType": "e_visited_page", "properties": ["hs_url", "hs_page_title"], "limit": 100 }, + "command": "gkit --profile app-a hubspot api call --operation-id hubspot.events.occurrences.list --input @request.json --out hubspot-events.json --dry-run" + }] + } + }, + { + "path": "/events/2026-03/send", + "method": "post", + "operationId": "events.occurrences.send", + "exposure": "inventory", + "reason": "Event sending is outside the read-only HubSpot V1 provider." + }, + { + "path": "/crm/pipelines/2026-03/{objectType}", + "method": "get", + "operationId": "crm.pipelines.list", + "exposure": "executable", + "capability": { + "id": "hubspot.crm.pipelines.list", + "provider": "hubspot", + "revision": "1", + "adapterKey": "crm.pipelines.list", + "title": "List CRM pipelines", + "description": "List pipelines and stages for deals or tickets.", + "effects": ["read"], + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "required": ["objectType"], + "properties": { "objectType": { "enum": ["deals", "tickets"] } } + }, + "examples": [{ + "input": { "objectType": "deals" }, + "command": "gkit --profile app-a hubspot api call --operation-id hubspot.crm.pipelines.list --input '{\"objectType\":\"deals\"}' --out hubspot-deal-pipelines.json --dry-run" + }] + } + }, + { + "path": "/crm/pipelines/2026-03/{objectType}", + "method": "post", + "operationId": "crm.pipelines.create", + "exposure": "inventory", + "reason": "Mutation is outside the read-only HubSpot V1 provider." + }, + { + "path": "/crm/pipelines/2026-03/{objectType}/{pipelineId}", + "method": "delete", + "operationId": "crm.pipelines.delete", + "exposure": "inventory", + "reason": "Destructive mutation is outside the read-only HubSpot V1 provider." + }, + { + "path": "/crm/owners/2026-03", + "method": "get", + "operationId": "crm.owners.list", + "exposure": "executable", + "capability": { + "id": "hubspot.crm.owners.list", + "provider": "hubspot", + "revision": "1", + "adapterKey": "crm.owners.list", + "title": "List CRM owners", + "description": "List bounded active or archived HubSpot owners for attribution joins.", + "effects": ["read"], + "inputSchema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "required": ["limit"], + "properties": { + "archived": { "type": "boolean" }, + "pageSize": { "type": "integer", "minimum": 1, "maximum": 100 }, + "limit": { "type": "integer", "minimum": 1, "maximum": 5000 } + } + }, + "examples": [{ + "input": { "limit": 100 }, + "command": "gkit --profile app-a hubspot api call --operation-id hubspot.crm.owners.list --input '{\"limit\":100}' --out hubspot-owners.json --dry-run" + }] + } + }, + { + "path": "/crm/imports/2026-03/imports", + "method": "post", + "operationId": "crm.imports.create", + "exposure": "inventory", + "reason": "Imports are mutations and are outside the read-only HubSpot V1 provider." + } + ] +} diff --git a/packages/gkit/scripts/generate-contract-provider.ts b/packages/gkit/scripts/generate-contract-provider.ts index 92cbbda..7affdd1 100644 --- a/packages/gkit/scripts/generate-contract-provider.ts +++ b/packages/gkit/scripts/generate-contract-provider.ts @@ -219,7 +219,12 @@ function renderInventoryDocs( operations: InventoryOperation[], ): string { const executable = operations.filter((operation) => operation.exposure === "executable").length; - const title = provider === "gsc" ? "Google Search Console" : "Bing Webmaster"; + const title = + provider === "gsc" + ? "Google Search Console" + : provider === "hubspot" + ? "HubSpot" + : "Bing Webmaster"; const lines = [ "---", "type: Reference", @@ -238,6 +243,14 @@ function renderInventoryDocs( "| Method | Path | Operation ID | Exposure | Decision |", "| --- | --- | --- | --- | --- |", ]; + if (provider === "hubspot") { + lines.splice( + lines.length - 2, + 0, + "HubSpot record, owner, event, association, pipeline, and property artifacts may contain PII or confidential business data. The inventory records endpoint exposure only; it does not authorize copying provider data into logs or prompts.", + "", + ); + } for (const operation of operations) { const capability = operation.capabilityId ? `; capability: \`${operation.capabilityId}\`` : ""; lines.push( diff --git a/packages/gkit/scripts/generate-hubspot.ts b/packages/gkit/scripts/generate-hubspot.ts new file mode 100644 index 0000000..bac6044 --- /dev/null +++ b/packages/gkit/scripts/generate-hubspot.ts @@ -0,0 +1,11 @@ +import { resolve } from "node:path"; + +import { writeContractProviderArtifacts } from "./generate-contract-provider"; + +if (import.meta.main) { + await writeContractProviderArtifacts({ + packageRoot: resolve(new URL("..", import.meta.url).pathname), + provider: "hubspot", + check: process.argv.slice(2).includes("--check"), + }); +} diff --git a/packages/gkit/sources/hubspot/contract.json b/packages/gkit/sources/hubspot/contract.json new file mode 100644 index 0000000..9bc00de --- /dev/null +++ b/packages/gkit/sources/hubspot/contract.json @@ -0,0 +1,23 @@ +{ + "version": 1, + "provider": "hubspot", + "operations": [ + { "path": "/crm/properties/2026-03/{objectType}", "method": "get", "operationId": "crm.properties.list" }, + { "path": "/crm/properties/2026-03/{objectType}", "method": "post", "operationId": "crm.properties.create" }, + { "path": "/crm/objects/2026-03/{objectType}", "method": "get", "operationId": "crm.objects.list" }, + { "path": "/crm/objects/2026-03/{objectType}", "method": "post", "operationId": "crm.objects.create" }, + { "path": "/crm/objects/2026-03/{objectType}/search", "method": "post", "operationId": "crm.objects.search" }, + { "path": "/crm/objects/2026-03/{objectType}/{recordId}", "method": "patch", "operationId": "crm.objects.update" }, + { "path": "/crm/objects/2026-03/{objectType}/{recordId}", "method": "delete", "operationId": "crm.objects.archive" }, + { "path": "/crm/objects/2026-03/{fromObjectType}/{objectId}/associations/{toObjectType}", "method": "get", "operationId": "crm.associations.list" }, + { "path": "/crm/objects/2026-03/{fromObjectType}/{objectId}/associations/default/{toObjectType}/{toObjectId}", "method": "put", "operationId": "crm.associations.create" }, + { "path": "/crm/objects/2026-03/{fromObjectType}/{objectId}/associations/{toObjectType}/{toObjectId}", "method": "delete", "operationId": "crm.associations.delete" }, + { "path": "/events/event-occurrences/2026-03", "method": "get", "operationId": "events.occurrences.list" }, + { "path": "/events/2026-03/send", "method": "post", "operationId": "events.occurrences.send" }, + { "path": "/crm/pipelines/2026-03/{objectType}", "method": "get", "operationId": "crm.pipelines.list" }, + { "path": "/crm/pipelines/2026-03/{objectType}", "method": "post", "operationId": "crm.pipelines.create" }, + { "path": "/crm/pipelines/2026-03/{objectType}/{pipelineId}", "method": "delete", "operationId": "crm.pipelines.delete" }, + { "path": "/crm/owners/2026-03", "method": "get", "operationId": "crm.owners.list" }, + { "path": "/crm/imports/2026-03/imports", "method": "post", "operationId": "crm.imports.create" } + ] +} diff --git a/packages/gkit/sources/hubspot/source.json b/packages/gkit/sources/hubspot/source.json new file mode 100644 index 0000000..7d0c931 --- /dev/null +++ b/packages/gkit/sources/hubspot/source.json @@ -0,0 +1,5 @@ +{ + "url": "https://developers.hubspot.com/docs/api/how-to-use-hubspot-api", + "revision": "hubspot-2026-03-reviewed-2026-08-30", + "checksum": "sha256:7bb3fe9ae46ca78a2fcc4fd0fec51f4bbf8f32c5fba92600c1b09ad190048da3" +} diff --git a/packages/gkit/src/args.test.ts b/packages/gkit/src/args.test.ts index 07d38f0..e5651dc 100644 --- a/packages/gkit/src/args.test.ts +++ b/packages/gkit/src/args.test.ts @@ -134,6 +134,7 @@ describe("gkit argv parser", () => { it.each([ ["bing", "bing.sites.list", "bing-call"], ["gsc", "gsc.properties.list", "gsc-call"], + ["hubspot", "hubspot.crm.objects.list", "hubspot-call"], ] as const)( "accepts the read-only %s call without spend flags", (provider, operationId, kind) => { diff --git a/packages/gkit/src/args.ts b/packages/gkit/src/args.ts index d41578b..1661b14 100644 --- a/packages/gkit/src/args.ts +++ b/packages/gkit/src/args.ts @@ -20,6 +20,7 @@ export type ParsedCommand = | { kind: "google-ads-doctor"; profileFlag: string | null } | { kind: "gsc-doctor"; profileFlag: string | null } | { kind: "posthog-doctor"; profileFlag: string | null } + | { kind: "hubspot-doctor"; profileFlag: string | null } | { kind: "dataforseo-call"; profileFlag: string | null; @@ -66,6 +67,15 @@ export type ParsedCommand = out: string | null; force: boolean; dryRun: boolean; + } + | { + kind: "hubspot-call"; + profileFlag: string | null; + operationId: string; + input: string; + out: string | null; + force: boolean; + dryRun: boolean; }; type FlagValue = string | true; @@ -223,6 +233,7 @@ export function parseArgs(argv: string[]): ParsedCommand { rest[0] !== "dataforseo" && rest[0] !== "google-ads" && rest[0] !== "gsc" && + rest[0] !== "hubspot" && rest[0] !== "posthog" ) { invalid(`Unknown command: ${rest[0]}`); @@ -239,7 +250,9 @@ export function parseArgs(argv: string[]): ParsedCommand { ? "google-ads-doctor" : provider === "gsc" ? "gsc-doctor" - : "posthog-doctor", + : provider === "hubspot" + ? "hubspot-doctor" + : "posthog-doctor", profileFlag, }; } @@ -255,6 +268,8 @@ export function parseArgs(argv: string[]): ParsedCommand { kind: provider === "posthog" ? "posthog-call" + : provider === "hubspot" + ? "hubspot-call" : provider === "google-ads" ? "google-ads-call" : provider === "gsc" @@ -326,6 +341,11 @@ export function renderHelp(): string { " gkit --profile gsc api call --operation-id --input @request.json --out --dry-run", " gkit --profile gsc api call --operation-id --input @request.json --out ", "", + "HubSpot:", + " gkit --profile hubspot doctor", + " gkit --profile hubspot api call --operation-id --input @request.json --out --dry-run", + " gkit --profile hubspot api call --operation-id --input @request.json --out ", + "", "Spend ledger:", " gkit ledger", " gkit ledger reconcile --attempt --outcome --evidence-ref [--cost-usd ]", diff --git a/packages/gkit/src/cli.test.ts b/packages/gkit/src/cli.test.ts index 3deb514..d389bbc 100644 --- a/packages/gkit/src/cli.test.ts +++ b/packages/gkit/src/cli.test.ts @@ -31,12 +31,14 @@ describe("gkit process contract", () => { expect(result.stdout).toContain("gkit --profile google-ads api call"); expect(result.stdout).toContain("gkit --profile bing api call"); expect(result.stdout).toContain("gkit --profile gsc api call"); + expect(result.stdout).toContain("gkit --profile hubspot api call"); expect(result.stdout).toContain("gkit describe --id "); expect(result.stdout).toContain("argc dotted commands and @run are intentionally not exposed"); expect(result.stdout).not.toContain("gkit dataforseo.api.call"); expect(result.stdout).not.toContain("gkit google-ads.api.call"); expect(result.stdout).not.toContain("gkit bing.api.call"); expect(result.stdout).not.toContain("gkit gsc.api.call"); + expect(result.stdout).not.toContain("gkit hubspot.api.call"); const selected = await runCli(["--schema", "posthog"], fixture.env); expect(selected.exitCode).toBe(0); @@ -47,6 +49,47 @@ describe("gkit process contract", () => { expect(googleAds.exitCode).toBe(0); expect(googleAds.stdout).toContain('"google-ads":'); expect(googleAds.stdout).not.toContain("posthog:"); + + const hubspot = await runCli(["--schema", "hubspot"], fixture.env); + expect(hubspot.exitCode).toBe(0); + expect(hubspot.stdout).toContain("hubspot:"); + expect(hubspot.stdout).not.toContain("posthog:"); + }); + + it("dispatches HubSpot through the common api call envelope and artifact contract", async () => { + const fixture = await createCliFixture(); + const outPath = join(fixture.root, "hubspot-contacts.json"); + const result = await runMainHarness( + [ + "--profile", + "app-a", + "hubspot", + "api", + "call", + "--operation-id", + "hubspot.crm.objects.list", + "--input", + JSON.stringify({ objectType: "contacts", properties: ["email"], limit: 1 }), + "--out", + outPath, + ], + { ...fixture.env, TEST_HUBSPOT_ACCESS_TOKEN: "hubspot-secret" }, + `async (_input, init) => { + if (new Headers(init?.headers).get("authorization") !== "Bearer hubspot-secret") throw new Error("missing auth"); + return new Response(JSON.stringify({ results: [{ id: "1", properties: { email: "person@example.test" } }] }), { status: 200, headers: { "x-hubspot-correlation-id": "hubspot_request" } }); + }`, + ).result; + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).not.toContain("hubspot-secret"); + expect(result.stdout).not.toContain("person@example.test"); + expect(JSON.parse(result.stdout)).toMatchObject({ + ok: true, + data: { pages: 1, rowCount: 1, artifactFormat: "json-array-of-exact-hubspot-pages" }, + meta: { provider: "hubspot", providerRequestId: "hubspot_request" }, + }); + expect(await readFile(outPath, "utf8")).toContain("person@example.test"); }); it("keeps describe offline and profile-free", async () => { @@ -605,6 +648,11 @@ async function createCliFixture() { policy: {}, secrets: { serviceAccountFile: "env:TEST_GSC_SERVICE_ACCOUNT_FILE" }, }, + hubspot: { + config: {}, + policy: {}, + secrets: { accessToken: "env:TEST_HUBSPOT_ACCESS_TOKEN" }, + }, }, }), ); diff --git a/packages/gkit/src/cli.ts b/packages/gkit/src/cli.ts index db039d3..3879498 100644 --- a/packages/gkit/src/cli.ts +++ b/packages/gkit/src/cli.ts @@ -10,6 +10,7 @@ import { runDataForSeoDoctor, runGoogleAdsDoctor, runGscDoctor, + runHubSpotDoctor, runPostHogDoctor, } from "./doctor"; import { @@ -25,6 +26,7 @@ import { executeDataForSeoCall } from "./execute"; import { executeBingCall } from "./execute-bing"; import { executeGoogleAdsCall } from "./execute-google-ads"; import { executeGscCall } from "./execute-gsc"; +import { executeHubSpotCall } from "./execute-hubspot"; import { executePostHogCall } from "./execute-posthog"; import { appendSettled, @@ -62,6 +64,12 @@ export const gscManifestPath = fileURLToPath( new URL("../generated/gsc/manifest.json", import.meta.url), ); export const gscDocsDirectory = fileURLToPath(new URL("../docs/providers/gsc", import.meta.url)); +export const hubSpotManifestPath = fileURLToPath( + new URL("../generated/hubspot/manifest.json", import.meta.url), +); +export const hubSpotDocsDirectory = fileURLToPath( + new URL("../docs/providers/hubspot", import.meta.url), +); export const providerDocsDirectory = fileURLToPath(new URL("../docs/providers", import.meta.url)); type TerminalEmitter = { @@ -100,6 +108,7 @@ export async function main( manifestPath?: string; googleAdsManifestPath?: string; gscManifestPath?: string; + hubSpotManifestPath?: string; postHogManifestPath?: string; } = {}, ): Promise { @@ -137,6 +146,7 @@ export async function main( command.provider !== "bing" && command.provider !== "google-ads" && command.provider !== "gsc" && + command.provider !== "hubspot" && command.provider !== "posthog" ) { throw new GkitFailure({ @@ -153,9 +163,11 @@ export async function main( ? googleAdsDocsDirectory : command.provider === "gsc" ? gscDocsDirectory - : command.provider === "posthog" - ? postHogDocsDirectory - : providerDocsDirectory; + : command.provider === "hubspot" + ? hubSpotDocsDirectory + : command.provider === "posthog" + ? postHogDocsDirectory + : providerDocsDirectory; await emitter.writeText(`${directory}\n`); process.exitCode = abortController.signal.aborted ? 130 : 0; return; @@ -264,6 +276,15 @@ export async function main( process.exitCode = abortController.signal.aborted ? 130 : result.envelope.ok ? 0 : 1; return; } + if (command.kind === "hubspot-doctor") { + const result = await runHubSpotDoctor({ + profileFlag: command.profileFlag, + signal: abortController.signal, + }); + await emitter.writeEnvelope(result.envelope, result.secrets); + process.exitCode = abortController.signal.aborted ? 130 : result.envelope.ok ? 0 : 1; + return; + } if (command.kind === "schema") { const manifests = await loadDiscoveryManifests(options); @@ -306,6 +327,14 @@ export async function main( ), signal: abortController.signal, }) + : command.kind === "hubspot-call" + ? await executeHubSpotCall({ + command, + manifest: await loadExecutableManifest( + options.hubSpotManifestPath ?? hubSpotManifestPath, + ), + signal: abortController.signal, + }) : await executePostHogCall({ command, manifest: await loadExecutableManifest( @@ -362,6 +391,7 @@ async function loadDiscoveryManifests(options: { manifestPath?: string; googleAdsManifestPath?: string; gscManifestPath?: string; + hubSpotManifestPath?: string; postHogManifestPath?: string; }) { return await Promise.all([ @@ -369,6 +399,7 @@ async function loadDiscoveryManifests(options: { loadExecutableManifest(options.manifestPath ?? dataForSeoManifestPath), loadExecutableManifest(options.googleAdsManifestPath ?? googleAdsManifestPath), loadExecutableManifest(options.gscManifestPath ?? gscManifestPath), + loadExecutableManifest(options.hubSpotManifestPath ?? hubSpotManifestPath), loadExecutableManifest(options.postHogManifestPath ?? postHogManifestPath), ]); } diff --git a/packages/gkit/src/docs.test.ts b/packages/gkit/src/docs.test.ts index 2139c28..c7fa0f5 100644 --- a/packages/gkit/src/docs.test.ts +++ b/packages/gkit/src/docs.test.ts @@ -10,6 +10,7 @@ describe("provider docs", () => { ["dataforseo", "backlinks.md"], ["google-ads", "capabilities.md"], ["gsc", "capabilities.md"], + ["hubspot", "capabilities.md"], ["posthog", "capabilities.md"], ])("keeps %s docs as a byte-stable manifest projection", async (provider, file) => { const manifest = await loadExecutableManifest( diff --git a/packages/gkit/src/docs.ts b/packages/gkit/src/docs.ts index f0bbdec..a7b26ff 100644 --- a/packages/gkit/src/docs.ts +++ b/packages/gkit/src/docs.ts @@ -2,9 +2,12 @@ import type { LoadedExecutableManifest, ManifestRecord } from "./manifest"; export function renderProviderDocs(manifest: LoadedExecutableManifest): string { const providerLabel = - { dataforseo: "DataForSEO", "google-ads": "Google Ads", posthog: "PostHog" }[ - manifest.document.provider - ] ?? manifest.document.provider; + { + dataforseo: "DataForSEO", + "google-ads": "Google Ads", + hubspot: "HubSpot", + posthog: "PostHog", + }[manifest.document.provider] ?? manifest.document.provider; const lines = [ "---", "type: Reference", @@ -23,6 +26,17 @@ export function renderProviderDocs(manifest: LoadedExecutableManifest): string { "", ]; + if (manifest.document.provider === "hubspot") { + lines.push( + "## Data sensitivity", + "", + "HubSpot artifacts can contain personal data: contact names and email addresses; owner names, email addresses, and teams; ticket subjects or content; event URLs, page titles, object IDs, and event properties. Company records, deal names and amounts, association IDs, pipeline labels, and property metadata can also disclose confidential business context.", + "", + "Treat every HubSpot artifact as sensitive. Store it only at an access-controlled path, do not paste raw CRM rows into prompts or logs, and request only the reviewed properties required for the bounded analysis. The properties capability requests HubSpot's default non-sensitive metadata view and does not opt into sensitive-property definitions.", + "", + ); + } + for (const record of manifest.document.capabilities) { lines.push(...renderCapability(record)); } diff --git a/packages/gkit/src/doctor-hubspot.test.ts b/packages/gkit/src/doctor-hubspot.test.ts new file mode 100644 index 0000000..8b9cba9 --- /dev/null +++ b/packages/gkit/src/doctor-hubspot.test.ts @@ -0,0 +1,135 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { runHubSpotDoctor } from "./doctor"; +import { serializeEnvelope } from "./envelope"; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +describe("HubSpot doctor", () => { + it("probes only fixed account details and redacts the token", async () => { + const fixture = await createFixture(); + let capturedUrl = ""; + const result = await runHubSpotDoctor({ + profileFlag: "app-a", + env: { TEST_HUBSPOT_ACCESS_TOKEN: "private-secret" }, + xdgConfigHome: fixture.xdgConfigHome, + signal: new AbortController().signal, + fetch: async (input, init) => { + capturedUrl = String(input); + expect(new Headers(init?.headers).get("authorization")).toBe("Bearer private-secret"); + return new Response( + JSON.stringify({ + portalId: 123456, + accountType: "STANDARD", + timeZone: "Asia/Singapore", + companyCurrency: "SGD", + }), + { status: 200, headers: { "x-hubspot-correlation-id": "doctor_123" } }, + ); + }, + }); + + expect(capturedUrl).toBe("https://api.hubapi.com/account-info/2026-03/details"); + expect(result.envelope).toMatchObject({ + ok: true, + data: { + provider: "hubspot", + portalId: "123456", + authMode: "private_app_token", + networkProbe: "connected", + }, + meta: { provider: "hubspot", providerRequestId: "doctor_123" }, + }); + expect(serializeEnvelope(result.envelope, result.secrets)).not.toContain("private-secret"); + }); + + it.each([ + [401, "AUTH_FAILED", false], + [403, "AUTH_FAILED", false], + [429, "RATE_LIMITED", true], + [400, "PROVIDER_ERROR", false], + [500, "UNKNOWN_OUTCOME", true], + ] as const)("maps safe connectivity HTTP %i", async (status, code, retryable) => { + const fixture = await createFixture(); + const result = await runHubSpotDoctor({ + profileFlag: "app-a", + env: { TEST_HUBSPOT_ACCESS_TOKEN: "private-secret" }, + xdgConfigHome: fixture.xdgConfigHome, + signal: new AbortController().signal, + fetch: async () => + new Response( + JSON.stringify({ + category: "MISSING_SCOPES", + correlationId: "doctor_error", + message: "person@example.com private-secret", + }), + { status }, + ), + }); + + expect(result.envelope).toMatchObject({ + ok: false, + error: { code, retryable }, + meta: { providerRequestId: "doctor_error" }, + }); + const serialized = serializeEnvelope(result.envelope, result.secrets); + expect(serialized).not.toContain("person@example.com"); + expect(serialized).not.toContain("private-secret"); + }); + + it("reports a bounded connectivity timeout without leaking the token", async () => { + const fixture = await createFixture(); + const result = await runHubSpotDoctor({ + profileFlag: "app-a", + env: { TEST_HUBSPOT_ACCESS_TOKEN: "private-secret" }, + xdgConfigHome: fixture.xdgConfigHome, + signal: new AbortController().signal, + timeoutMs: 1, + fetch: async (_input, init) => + await new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(new Error("aborted")), { + once: true, + }); + }), + }); + + expect(result.envelope).toMatchObject({ + ok: false, + error: { code: "TIMEOUT", retryable: true, outcome: "unknown" }, + }); + expect(serializeEnvelope(result.envelope, result.secrets)).not.toContain("private-secret"); + }); +}); + +async function createFixture() { + const root = await mkdtemp(join(tmpdir(), "gkit-hubspot-doctor-")); + temporaryDirectories.push(root); + const xdgConfigHome = join(root, "config"); + const profilePath = join(xdgConfigHome, "gkit/profiles/app-a.json"); + await mkdir(dirname(profilePath), { recursive: true }); + await writeFile( + profilePath, + JSON.stringify({ + version: 1, + name: "app-a", + providers: { + hubspot: { + config: {}, + policy: {}, + secrets: { accessToken: "env:TEST_HUBSPOT_ACCESS_TOKEN" }, + }, + }, + }), + ); + return { root, xdgConfigHome }; +} diff --git a/packages/gkit/src/doctor.ts b/packages/gkit/src/doctor.ts index dc6effb..dea97c1 100644 --- a/packages/gkit/src/doctor.ts +++ b/packages/gkit/src/doctor.ts @@ -1,4 +1,5 @@ import { readFile } from "node:fs/promises"; +import { Buffer } from "node:buffer"; import type { Envelope, EnvelopeMeta } from "./envelope"; import { GkitFailure, SecretRegistry, toFailureEnvelope } from "./envelope"; @@ -19,17 +20,18 @@ import { export type DoctorResult = { profilePath: string; - provider: "bing" | "dataforseo" | "google-ads" | "gsc" | "posthog"; + provider: "bing" | "dataforseo" | "google-ads" | "gsc" | "hubspot" | "posthog"; environment: "production" | "sandbox" | null; host?: string; projectId?: string; customerId?: string; siteUrl?: string; - authMode?: "service_account"; + portalId?: string; + authMode?: "private_app_token" | "service_account"; profileConfigured: true; secretsConfigured: true; spendPolicyConfigured: boolean; - networkProbe: "unknown"; + networkProbe: "connected" | "unknown"; note: string; }; @@ -243,6 +245,228 @@ export async function runPostHogDoctor(options: { } } +export async function runHubSpotDoctor(options: { + profileFlag: string | null; + signal: AbortSignal; + env?: Readonly>; + xdgConfigHome?: string; + home?: string; + fetch?: (input: string | URL | Request, init?: RequestInit) => Promise; + timeoutMs?: number; +}): Promise { + const env = options.env ?? process.env; + const secrets = new SecretRegistry(); + let selectedProfile = options.profileFlag ?? env.GKIT_PROFILE ?? null; + let providerRequestId: string | null = null; + + try { + selectedProfile = selectProfileName(options.profileFlag ?? undefined, env); + const profile = await loadProfile(selectedProfile, { + xdgConfigHome: options.xdgConfigHome, + home: options.home, + }); + getProviderProfile(profile, "hubspot"); + const profileEnvironment = await loadProfileEnvironment(profile, env); + const resolved = resolveProviderSecrets(profile, "hubspot", profileEnvironment); + const accessToken = resolved.accessToken; + if (!accessToken) { + throw new ProfileError( + "invalid_profile", + "HubSpot requires an accessToken env reference under secrets.", + ); + } + secrets.register(accessToken); + if (options.signal.aborted) { + throw new GkitFailure({ + code: "CANCELLED", + message: "The invocation was cancelled before the HubSpot connectivity probe.", + outcome: "not_dispatched", + meta: doctorMeta(profile.name, "hubspot"), + }); + } + + const timeoutMs = options.timeoutMs ?? 10_000; + if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) { + throw new GkitFailure({ code: "INVALID_INPUT", message: "HubSpot doctor timeout is invalid." }); + } + const dispatchSignal = createDoctorSignal(options.signal, timeoutMs); + let response: Response; + let rawBytes: Uint8Array; + try { + response = await (options.fetch ?? globalThis.fetch)( + "https://api.hubapi.com/account-info/2026-03/details", + { + method: "GET", + headers: { authorization: `Bearer ${accessToken}`, accept: "application/json" }, + signal: dispatchSignal.signal, + }, + ); + rawBytes = new Uint8Array(await response.arrayBuffer()); + } catch { + throw new GkitFailure({ + code: dispatchSignal.timedOut() + ? "TIMEOUT" + : options.signal.aborted + ? "UNKNOWN_OUTCOME" + : "NETWORK_ERROR", + message: dispatchSignal.timedOut() + ? "The HubSpot connectivity probe exceeded its deadline." + : "The HubSpot connectivity probe ended without a confirmed response.", + retryable: true, + outcome: "unknown", + meta: doctorMeta(profile.name, "hubspot"), + }); + } finally { + dispatchSignal.dispose(); + } + + const payload = parseDoctorJson(rawBytes); + providerRequestId = hubSpotDoctorRequestId(response, payload, accessToken); + const meta = { ...doctorMeta(profile.name, "hubspot"), providerRequestId }; + if (!response.ok) { + const code = + response.status === 401 || response.status === 403 + ? "AUTH_FAILED" + : response.status === 429 + ? "RATE_LIMITED" + : response.status === 408 || response.status >= 500 + ? "UNKNOWN_OUTCOME" + : "PROVIDER_ERROR"; + throw new GkitFailure({ + code, + message: + code === "AUTH_FAILED" + ? "HubSpot rejected the configured token or account-info scope." + : code === "RATE_LIMITED" + ? "HubSpot rate-limited the connectivity probe." + : "HubSpot did not accept the connectivity probe.", + retryable: code === "RATE_LIMITED" || code === "UNKNOWN_OUTCOME", + outcome: code === "UNKNOWN_OUTCOME" ? "unknown" : "confirmed", + details: { + httpStatus: response.status, + ...hubSpotDoctorErrorDetails(payload), + }, + meta, + }); + } + if (!isDoctorRecord(payload)) { + throw new GkitFailure({ + code: "PROVIDER_ERROR", + message: "HubSpot returned an invalid account-details response.", + outcome: "confirmed", + meta, + }); + } + const portalId = payload.portalId; + const portalIdText = typeof portalId === "number" ? String(portalId) : portalId; + if (typeof portalIdText !== "string" || !/^[1-9]\d*$/.test(portalIdText)) { + throw new GkitFailure({ + code: "PROVIDER_ERROR", + message: "HubSpot returned account details without a valid portal identifier.", + outcome: "confirmed", + meta, + }); + } + return { + envelope: { + ok: true, + data: { + profilePath: profile.path, + provider: "hubspot", + environment: null, + portalId: portalIdText, + authMode: "private_app_token", + profileConfigured: true, + secretsConfigured: true, + spendPolicyConfigured: false, + networkProbe: "connected", + note: "HubSpot readiness verified the profile-bound account through the fixed account-details endpoint.", + }, + meta, + }, + secrets, + }; + } catch (error) { + if (error instanceof GkitFailure) { + return { envelope: toFailureEnvelope(error), secrets }; + } + if (error instanceof ProfileError) { + return { + envelope: toFailureEnvelope( + new GkitFailure({ + code: "PROFILE_ERROR", + message: error.message, + hint: "Fix the selected HubSpot profile or its referenced access-token environment variable.", + meta: selectedProfile ? doctorMeta(selectedProfile, "hubspot") : undefined, + }), + ), + secrets, + }; + } + throw error; + } +} + +function createDoctorSignal( + externalSignal: AbortSignal, + timeoutMs: number, +): { signal: AbortSignal; timedOut(): boolean; dispose(): void } { + const controller = new AbortController(); + let didTimeOut = false; + const onExternalAbort = (): void => controller.abort(externalSignal.reason); + externalSignal.addEventListener("abort", onExternalAbort, { once: true }); + const timer = setTimeout(() => { + didTimeOut = true; + controller.abort(new Error("HubSpot doctor deadline exceeded.")); + }, timeoutMs); + timer.unref(); + return { + signal: controller.signal, + timedOut: () => didTimeOut, + dispose: () => { + clearTimeout(timer); + externalSignal.removeEventListener("abort", onExternalAbort); + }, + }; +} + +function parseDoctorJson(rawBytes: Uint8Array): unknown { + try { + return JSON.parse(Buffer.from(rawBytes).toString("utf8")) as unknown; + } catch { + return null; + } +} + +function hubSpotDoctorRequestId( + response: Response, + payload: unknown, + accessToken: string, +): string | null { + const header = + response.headers.get("x-hubspot-correlation-id") ?? response.headers.get("x-request-id"); + if (header && header !== accessToken && /^[A-Za-z0-9._:-]{1,128}$/.test(header)) return header; + if (!isDoctorRecord(payload)) return null; + const value = payload.correlationId; + return typeof value === "string" && + value !== accessToken && + /^[A-Za-z0-9._:-]{1,128}$/.test(value) + ? value + : null; +} + +function hubSpotDoctorErrorDetails(payload: unknown): Record { + if (!isDoctorRecord(payload)) return {}; + const category = payload.category; + return typeof category === "string" && /^[A-Z0-9_]{1,80}$/.test(category) + ? { providerCategory: category } + : {}; +} + +function isDoctorRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + export type DoctorExecutionResult = { envelope: Envelope; secrets: SecretRegistry; diff --git a/packages/gkit/src/execute-hubspot.test.ts b/packages/gkit/src/execute-hubspot.test.ts new file mode 100644 index 0000000..111c31c --- /dev/null +++ b/packages/gkit/src/execute-hubspot.test.ts @@ -0,0 +1,221 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { ParsedCommand } from "./args"; +import { serializeEnvelope } from "./envelope"; +import { executeHubSpotCall } from "./execute-hubspot"; +import { loadExecutableManifest } from "./manifest"; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +describe("HubSpot execution boundary", () => { + it("dry-runs before secret resolution, fetch, or artifact creation", async () => { + const fixture = await createFixture(); + const fetch = vi.fn(); + const result = await executeHubSpotCall({ + command: command(fixture.outPath, true), + manifest: fixture.manifest, + signal: new AbortController().signal, + env: {}, + xdgConfigHome: fixture.xdgConfigHome, + fetch, + }); + + expect(result.envelope).toMatchObject({ + ok: true, + data: { + dryRun: true, + requestPlan: { + provider: "hubspot", + method: "GET", + endpoint: "https://api.hubapi.com/crm/objects/2026-03/contacts", + }, + }, + meta: { provider: "hubspot", artifact: null }, + }); + expect(fetch).not.toHaveBeenCalled(); + await expect(readFile(fixture.outPath)).rejects.toThrow(); + }); + + it("publishes bounded CRM facts and returns only a compact receipt", async () => { + const fixture = await createFixture(); + const result = await executeHubSpotCall({ + command: command(fixture.outPath, false), + manifest: fixture.manifest, + signal: new AbortController().signal, + env: { TEST_HUBSPOT_ACCESS_TOKEN: "private-secret" }, + xdgConfigHome: fixture.xdgConfigHome, + fetch: async (_input, init) => { + expect(new Headers(init?.headers).get("authorization")).toBe("Bearer private-secret"); + return new Response(JSON.stringify({ results: [{ id: "1", properties: { email: "a@b.test" } }] }), { + status: 200, + headers: { "x-hubspot-correlation-id": "request_123" }, + }); + }, + }); + + expect(result.envelope).toMatchObject({ + ok: true, + data: { + pages: 1, + rowCount: 1, + artifactFormat: "json-array-of-exact-hubspot-pages", + }, + meta: { + providerRequestId: "request_123", + artifact: { path: expect.stringMatching(/result\.json$/), bytes: expect.any(Number), sha256: expect.any(String) }, + }, + }); + expect(await readFile(fixture.outPath, "utf8")).toContain("a@b.test"); + const serialized = serializeEnvelope(result.envelope, result.secrets); + expect(serialized).not.toContain("private-secret"); + expect(serialized).not.toContain("a@b.test"); + }); + + it("preserves no-replace behavior before dispatch", async () => { + const fixture = await createFixture(); + await writeFile(fixture.outPath, "existing"); + const fetch = vi.fn(); + const result = await executeHubSpotCall({ + command: command(fixture.outPath, false), + manifest: fixture.manifest, + signal: new AbortController().signal, + env: { TEST_HUBSPOT_ACCESS_TOKEN: "private-secret" }, + xdgConfigHome: fixture.xdgConfigHome, + fetch, + }); + + expect(result.envelope).toMatchObject({ ok: false, error: { code: "LOCAL_IO_ERROR" } }); + expect(fetch).not.toHaveBeenCalled(); + expect(await readFile(fixture.outPath, "utf8")).toBe("existing"); + }); + + it("fails closed when provider bytes contain the access token", async () => { + const fixture = await createFixture(); + const result = await executeHubSpotCall({ + command: command(fixture.outPath, false), + manifest: fixture.manifest, + signal: new AbortController().signal, + env: { TEST_HUBSPOT_ACCESS_TOKEN: "private-secret" }, + xdgConfigHome: fixture.xdgConfigHome, + fetch: async () => + new Response(JSON.stringify({ results: [{ id: "1", value: "private-secret" }] }), { + status: 200, + }), + }); + + expect(result.envelope).toMatchObject({ + ok: false, + error: { code: "LOCAL_IO_ERROR", outcome: "confirmed" }, + meta: { artifact: null }, + }); + expect(serializeEnvelope(result.envelope, result.secrets)).not.toContain("private-secret"); + await expect(readFile(fixture.outPath)).rejects.toThrow(); + }); + + it("records a confirmed rate-limit body as an artifact without projecting it to stdout", async () => { + const fixture = await createFixture(); + const result = await executeHubSpotCall({ + command: command(fixture.outPath, false), + manifest: fixture.manifest, + signal: new AbortController().signal, + env: { TEST_HUBSPOT_ACCESS_TOKEN: "private-secret" }, + xdgConfigHome: fixture.xdgConfigHome, + fetch: async () => + new Response( + JSON.stringify({ + category: "RATE_LIMITS", + correlationId: "rate_request", + message: "provider-only diagnostic", + }), + { status: 429 }, + ), + }); + + expect(result.envelope).toMatchObject({ + ok: false, + error: { code: "RATE_LIMITED", retryable: true, outcome: "confirmed" }, + meta: { providerRequestId: "rate_request", artifact: { bytes: expect.any(Number) } }, + }); + expect(serializeEnvelope(result.envelope, result.secrets)).not.toContain( + "provider-only diagnostic", + ); + expect(await readFile(fixture.outPath, "utf8")).toContain("provider-only diagnostic"); + }); + + it("cancels before provider handoff with exit 130 and no fetch", async () => { + const fixture = await createFixture(); + const controller = new AbortController(); + controller.abort(); + const fetch = vi.fn(); + const result = await executeHubSpotCall({ + command: command(fixture.outPath, false), + manifest: fixture.manifest, + signal: controller.signal, + env: { TEST_HUBSPOT_ACCESS_TOKEN: "private-secret" }, + xdgConfigHome: fixture.xdgConfigHome, + fetch, + }); + + expect(result.exitCode).toBe(130); + expect(result.envelope).toMatchObject({ + ok: false, + error: { code: "CANCELLED", outcome: "not_dispatched" }, + }); + expect(fetch).not.toHaveBeenCalled(); + }); +}); + +function command( + outPath: string, + dryRun: boolean, +): Extract { + return { + kind: "hubspot-call", + profileFlag: "app-a", + operationId: "hubspot.crm.objects.list", + input: JSON.stringify({ objectType: "contacts", properties: ["email"], limit: 1 }), + out: outPath, + force: false, + dryRun, + }; +} + +async function createFixture() { + const root = await mkdtemp(join(tmpdir(), "gkit-hubspot-execute-")); + temporaryDirectories.push(root); + const xdgConfigHome = join(root, "config"); + const profilePath = join(xdgConfigHome, "gkit/profiles/app-a.json"); + await mkdir(dirname(profilePath), { recursive: true }); + await writeFile( + profilePath, + JSON.stringify({ + version: 1, + name: "app-a", + providers: { + hubspot: { + config: {}, + policy: {}, + secrets: { accessToken: "env:TEST_HUBSPOT_ACCESS_TOKEN" }, + }, + }, + }), + ); + return { + root, + xdgConfigHome, + outPath: join(root, "result.json"), + manifest: await loadExecutableManifest( + new URL("../generated/hubspot/manifest.json", import.meta.url).pathname, + ), + }; +} diff --git a/packages/gkit/src/execute-hubspot.ts b/packages/gkit/src/execute-hubspot.ts new file mode 100644 index 0000000..0868dd3 --- /dev/null +++ b/packages/gkit/src/execute-hubspot.ts @@ -0,0 +1,70 @@ +import type { ParsedCommand } from "./args"; +import { executeReadProviderCall } from "./execute-read-provider"; +import type { LoadedExecutableManifest } from "./manifest"; +import { ProfileError, type ProviderProfile } from "./profile"; +import { + createHubSpotOperation, + dispatchHubSpot, + planHubSpotRequest, + type HubSpotConfig, + type HubSpotCredentials, + type HubSpotFetch, + type HubSpotOperation, +} from "./providers/hubspot"; + +type HubSpotCallCommand = Extract; + +export const hubSpotAdapterKeys = new Set([ + "crm.associations.list", + "crm.objects.list", + "crm.objects.search", + "crm.owners.list", + "crm.pipelines.list", + "crm.properties.list", + "events.occurrences.list", +]); + +export async function executeHubSpotCall(options: { + command: HubSpotCallCommand; + manifest: LoadedExecutableManifest; + signal: AbortSignal; + env?: Readonly>; + xdgConfigHome?: string; + home?: string; + fetch?: HubSpotFetch; +}) { + return await executeReadProviderCall({ + ...options, + spec: { + provider: "hubspot", + adapterKeys: hubSpotAdapterKeys, + readConfig, + createOperation: createHubSpotOperation, + plan: planHubSpotRequest, + prepareCredentials: async (credentialOptions) => { + const accessToken = credentialOptions.resolvedSecrets.accessToken; + if (!accessToken) { + throw new ProfileError( + "invalid_profile", + "HubSpot requires an accessToken env reference under secrets.", + ); + } + credentialOptions.secrets.register(accessToken); + return { + credentials: Object.freeze({ accessToken }), + secretValues: [accessToken], + }; + }, + dispatch: async (dispatchOptions) => + await dispatchHubSpot({ ...dispatchOptions, fetch: options.fetch }), + artifactFormat: "json-array-of-exact-hubspot-pages", + }, + }); +} + +function readConfig(provider: ProviderProfile): HubSpotConfig { + if (Object.keys(provider.config).length > 0) { + throw new ProfileError("invalid_profile", "HubSpot config must be empty."); + } + return Object.freeze({}); +} diff --git a/packages/gkit/src/execute-read-provider.ts b/packages/gkit/src/execute-read-provider.ts index b3b7602..d4f0807 100644 --- a/packages/gkit/src/execute-read-provider.ts +++ b/packages/gkit/src/execute-read-provider.ts @@ -366,6 +366,13 @@ function normalizeArtifactError(error: unknown): ArtifactError { function normalizeExecutionError(error: unknown, context: ExecutionContext): unknown { if (error instanceof GkitFailure) return error; + if (error instanceof ArtifactError) { + return new GkitFailure({ + code: "LOCAL_IO_ERROR", + message: error.message, + meta: contextMeta(context), + }); + } if (error instanceof ProfileError) { return new GkitFailure({ code: "PROFILE_ERROR", diff --git a/packages/gkit/src/hubspot-manifest.test.ts b/packages/gkit/src/hubspot-manifest.test.ts new file mode 100644 index 0000000..81259fd --- /dev/null +++ b/packages/gkit/src/hubspot-manifest.test.ts @@ -0,0 +1,40 @@ +import { readFile } from "node:fs/promises"; + +import { describe, expect, it } from "vitest"; + +import { getManifestRecord, loadExecutableManifest } from "./manifest"; + +describe("HubSpot reviewed manifest", () => { + it("exposes only the seven reviewed cost-free read capabilities", async () => { + const manifest = await loadExecutableManifest( + new URL("../generated/hubspot/manifest.json", import.meta.url).pathname, + ); + expect(manifest.document.capabilities).toHaveLength(7); + for (const capability of manifest.document.capabilities) { + expect(capability.provider).toBe("hubspot"); + expect(capability.effects).toEqual(["read"]); + expect(capability.cost).toBeUndefined(); + } + expect(getManifestRecord(manifest, "hubspot.crm.objects.search").adapterKey).toBe( + "crm.objects.search", + ); + expect(() => getManifestRecord(manifest, "hubspot.crm.objects.create")).toThrow( + expect.objectContaining({ kind: "CAPABILITY_NOT_FOUND" }), + ); + }); + + it("keeps every mutation inventory-only", async () => { + const inventory = JSON.parse( + await readFile( + new URL("../generated/hubspot/inventory.json", import.meta.url), + "utf8", + ), + ) as { operations: Array<{ method: string; operationId: string; exposure: string }> }; + const mutations = inventory.operations.filter( + (operation) => + operation.method !== "get" && operation.operationId !== "crm.objects.search", + ); + expect(mutations.length).toBeGreaterThan(0); + expect(mutations.every((operation) => operation.exposure === "inventory")).toBe(true); + }); +}); diff --git a/packages/gkit/src/profile.test.ts b/packages/gkit/src/profile.test.ts index 42d7bf8..1576c03 100644 --- a/packages/gkit/src/profile.test.ts +++ b/packages/gkit/src/profile.test.ts @@ -349,6 +349,46 @@ describe("profile loading and secret resolution", () => { } }); + test("accepts only a static HubSpot access-token env reference with no config", async () => { + const input = profileDocument(); + input.providers = { + ...input.providers, + hubspot: { + config: {}, + policy: {}, + secrets: { accessToken: "env:APP_A_HUBSPOT_ACCESS_TOKEN" }, + }, + } as typeof input.providers; + const profile = await loadDocument(input); + + expect(profile.providers.hubspot?.config).toEqual({}); + expect( + resolveProviderSecrets(profile, "hubspot", { + APP_A_HUBSPOT_ACCESS_TOKEN: "resolved-hubspot-secret", + }), + ).toEqual({ accessToken: "resolved-hubspot-secret" }); + + for (const hubspot of [ + { + config: { portalId: "123" }, + policy: {}, + secrets: { accessToken: "env:APP_A_HUBSPOT_ACCESS_TOKEN" }, + }, + { + config: {}, + policy: {}, + secrets: { + accessToken: "env:APP_A_HUBSPOT_ACCESS_TOKEN", + apiKey: "env:APP_A_HUBSPOT_API_KEY", + }, + }, + ]) { + const invalid = profileDocument(); + invalid.providers = { ...invalid.providers, hubspot } as typeof invalid.providers; + await expect(loadDocument(invalid)).rejects.toMatchObject({ reason: "invalid_profile" }); + } + }); + test("reports a missing referenced env var without reading arbitrary secrets", async () => { const profile = await loadDocument(profileDocument()); diff --git a/packages/gkit/src/profile.ts b/packages/gkit/src/profile.ts index 70fbbd3..e91a1b8 100644 --- a/packages/gkit/src/profile.ts +++ b/packages/gkit/src/profile.ts @@ -96,6 +96,12 @@ const gscSecretsSchema = strictObject({ serviceAccountFile: pipe(string(), regex(ENV_REFERENCE_PATTERN)), }); +const hubSpotConfigSchema = strictObject({}); + +const hubSpotSecretsSchema = strictObject({ + accessToken: pipe(string(), regex(ENV_REFERENCE_PATTERN)), +}); + export type ProviderEnvironment = "production" | "sandbox"; export type ProviderPolicy = { @@ -218,6 +224,8 @@ export async function loadProfile( ? parseGscConfig(provider.config) : providerId === "posthog" ? parsePostHogConfig(provider.config) + : providerId === "hubspot" + ? parseHubSpotConfig(provider.config) : providerId === "google-ads" ? parseGoogleAdsConfig(provider.config) : freezeRecord(provider.config); @@ -230,6 +238,8 @@ export async function loadProfile( ? parseGscSecrets(provider.secrets) : providerId === "posthog" ? parsePostHogSecrets(provider.secrets) + : providerId === "hubspot" + ? parseHubSpotSecrets(provider.secrets) : providerId === "google-ads" ? parseGoogleAdsSecrets(provider.secrets) : Object.freeze(provider.secrets as Record); @@ -459,6 +469,30 @@ function parseGscSecrets( return Object.freeze(parsed.output as Record); } +function parseHubSpotConfig(config: Record): Readonly> { + const parsed = safeParse(hubSpotConfigSchema, config); + if (!parsed.success) { + throw new ProfileError( + "invalid_profile", + "HubSpot config must be empty; the account is derived only from the profile-bound token.", + ); + } + return Object.freeze(parsed.output); +} + +function parseHubSpotSecrets( + secrets: Record, +): Readonly> { + const parsed = safeParse(hubSpotSecretsSchema, secrets); + if (!parsed.success) { + throw new ProfileError( + "invalid_profile", + "HubSpot secrets must contain only an accessToken env: reference.", + ); + } + return Object.freeze(parsed.output as Record); +} + function assertNonSecretConfig(value: unknown, providerId: string): void { if (value === null || typeof value !== "object") { return; diff --git a/packages/gkit/src/providers/hubspot.bdd.md b/packages/gkit/src/providers/hubspot.bdd.md new file mode 100644 index 0000000..b8a7768 --- /dev/null +++ b/packages/gkit/src/providers/hubspot.bdd.md @@ -0,0 +1,210 @@ +# HubSpot read provider — BDD spec + +> Context: Add HubSpot behind gkit's existing profile-bound Growth Capability Runtime. +> Status: **Implemented and verified for the V1 read-only slice** + +--- + +## Scope boundaries + +**Included:** + +- One profile-bound HubSpot private-app access token. +- Offline capability discovery and describe. +- Safe account-connectivity doctor. +- Reviewed reads for CRM property metadata, object listing and search, record associations, event occurrences, pipelines, and owners. +- Bounded pagination, artifact-only provider payloads, request IDs, cancellation, timeout, and redacted errors. + +**Not included:** + +- OAuth, multiple HubSpot accounts in one invocation, arbitrary URL passthrough, sensitive-property opt-in, mutations, imports, or provider workflow orchestration. +- Claims or conclusions derived from provider facts. + +--- + +## Public seams + +The executable behavior is tested through these existing public seams: + +1. Profile loading and `env:` secret resolution. +2. Offline manifest discovery and capability describe. +3. `gkit --profile hubspot doctor`. +4. `gkit --profile hubspot api call --operation-id --input --out `. +5. The common response envelope and artifact receipt. + +--- + +## Feature 1: Profile and doctor + +### Scenario 1.1: Parse one HubSpot binding + +```gherkin +Given a profile binds provider hubspot with no config and accessToken as an env reference +When gkit loads the profile +Then the non-secret provider configuration is accepted + And the access token is resolved only for doctor or live execution +``` + +### Scenario 1.2: Reject credentials outside the secret map + +```gherkin +Given a HubSpot profile places a token or transport override in config +When gkit loads the profile +Then profile validation fails before provider dispatch +``` + +### Scenario 1.3: Verify safe account connectivity + +```gherkin +Given one valid profile-bound private-app token +When the Agent runs hubspot doctor +Then gkit calls only the fixed account details endpoint + And reports the connected portal identifier without returning the token +``` + +### Scenario 1.4: Report doctor authentication and transport failures + +```gherkin +Given HubSpot rejects or cannot complete the account details request +When the Agent runs hubspot doctor +Then gkit returns the common non-zero failure envelope + And maps authentication, permission, rate-limit, timeout, network, and provider failures without projecting provider messages or PII +``` + +--- + +## Feature 2: Discovery and read-only dispatch + +### Scenario 2.1: Discover the reviewed surface offline + +```gherkin +Given the committed HubSpot manifest +When an Agent requests schema or describes a HubSpot capability +Then gkit exposes only reviewed read capabilities + And discovery does not load a profile or resolve a secret +``` + +### Scenario 2.2: Reject unreviewed and mutating operations + +```gherkin +Given an operation ID is absent from the HubSpot manifest or its adapter key is not reviewed +When an Agent requests execution +Then gkit rejects the request before secret resolution and provider dispatch +``` + +### Scenario 2.3: Dry-run through the common call shape + +```gherkin +Given a valid HubSpot read input and profile configuration +When the Agent adds --dry-run +Then gkit returns the fixed method and endpoint plan plus an input digest and artifact path + And does not resolve the token, reserve an artifact, or send a network request +``` + +--- + +## Feature 3: Bounded CRM data access + +### Scenario 3.1: Enforce the object allowlist + +```gherkin +Given a request names a CRM object outside contacts, companies, deals, or tickets +When gkit validates or prepares the request +Then the request fails before provider dispatch +``` + +### Scenario 3.2: Enforce object-specific property allowlists + +```gherkin +Given a list or search request omits properties or includes a property outside the reviewed allowlist for its object type +When gkit prepares the request +Then the request fails before provider dispatch +``` + +### Scenario 3.3: Bound listing pagination + +```gherkin +Given a valid CRM object listing request +When HubSpot returns paging cursors +Then gkit follows cursors only until the requested total limit + And each page is at most 100 records + And the artifact contains the bounded combined result +``` + +### Scenario 3.4: Bound CRM Search POST + +```gherkin +Given a valid CRM search request +When gkit dispatches the request +Then it uses POST only to the fixed search endpoint + And each page is at most 200 records + And the query body is at most 3000 encoded characters + And no request may page beyond HubSpot's 10000-result query limit +``` + +### Scenario 3.5: Bound associations and event occurrences + +```gherkin +Given a valid association or event-occurrence request with reviewed event properties +When HubSpot returns paging cursors +Then gkit follows only provider-returned cursors until the operation limit + And it never follows provider-returned links or accepts arbitrary event property query keys +``` + +--- + +## Feature 4: Outcomes, artifacts, and secrecy + +### Scenario 4.1: Publish raw provider facts only as an artifact + +```gherkin +Given a successful HubSpot response +When live execution completes +Then stdout contains only the common compact envelope and artifact receipt + And the complete bounded provider data is written to the requested no-replace artifact +``` + +### Scenario 4.2: Preserve response evidence for confirmed provider failures + +```gherkin +Given HubSpot returns a 4xx, 429, or 5xx response body +When gkit maps the failure +Then the response body may be published to the requested artifact after secret scanning + And the envelope contains only allowlisted HTTP status, category, request ID, and retry metadata +``` + +### Scenario 4.3: Classify interrupted outcomes + +```gherkin +Given cancellation or timeout occurs before dispatch +When gkit handles the invocation +Then it reports not_dispatched + +Given cancellation, timeout, or network loss occurs after dispatch begins +When no provider response is available +Then it reports an unknown provider outcome +``` + +### Scenario 4.4: Redact the access token everywhere + +```gherkin +Given a provider response, exception, request ID, or artifact contains the access token or an encoded form +When gkit serializes the envelope or publishes the artifact +Then the token is absent from stdout and artifact metadata + And unsafe artifact publication fails closed +``` + +--- + +## Acceptance checklist + +- [x] Profile parsing and secret resolution are strict and profile-bound. +- [x] Doctor performs only a fixed safe account-details GET. +- [x] Manifest discovery and describe remain offline. +- [x] All executable capabilities have exactly one `read` effect and no cost. +- [x] Object, property, method, endpoint, pagination, and total-result bounds are enforced. +- [x] Search uses bounded POST and enforces 200/page, 3,000 characters, and 10,000/query. +- [x] HTTP/auth/rate-limit/timeout/network/cancellation outcomes use the common envelope. +- [x] Provider payloads require `--out` and preserve no-replace behavior. +- [x] Access tokens never enter source, snapshots, envelopes, provider request IDs, or artifact metadata. +- [x] Provider inventory, capability docs, and README state PII boundaries. diff --git a/packages/gkit/src/providers/hubspot.test.ts b/packages/gkit/src/providers/hubspot.test.ts new file mode 100644 index 0000000..8a3b0cc --- /dev/null +++ b/packages/gkit/src/providers/hubspot.test.ts @@ -0,0 +1,306 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + createHubSpotOperation, + dispatchHubSpot, + planHubSpotRequest, +} from "./hubspot"; + +describe("HubSpot adapter", () => { + it("keeps CRM Search as a bounded POST and aggregates provider cursors", async () => { + const requests: Array<{ url: string; body: Record }> = []; + const fetch = vi + .fn() + .mockImplementationOnce(async (input: string | URL | Request, init?: RequestInit) => { + requests.push({ url: String(input), body: JSON.parse(String(init?.body)) }); + return new Response( + JSON.stringify({ + total: 3, + results: [{ id: "1" }, { id: "2" }], + paging: { next: { after: "2", link: "https://attacker.invalid/ignored" } }, + }), + { status: 200, headers: { "x-hubspot-correlation-id": "request_1" } }, + ); + }) + .mockImplementationOnce(async (input: string | URL | Request, init?: RequestInit) => { + requests.push({ url: String(input), body: JSON.parse(String(init?.body)) }); + return new Response(JSON.stringify({ total: 3, results: [{ id: "3" }] }), { + status: 200, + headers: { "x-hubspot-correlation-id": "request_2" }, + }); + }); + + const operation = createHubSpotOperation("crm.objects.search", { + objectType: "contacts", + properties: ["email", "lifecyclestage"], + filterGroups: [ + { + filters: [{ propertyName: "lifecyclestage", operator: "EQ", value: "customer" }], + }, + ], + pageSize: 2, + limit: 3, + }); + const result = await dispatchHubSpot({ + operation, + config: {}, + credentials: { accessToken: "private-secret" }, + signal: new AbortController().signal, + fetch, + }); + + expect(requests).toEqual([ + { + url: "https://api.hubapi.com/crm/objects/2026-03/contacts/search", + body: { + properties: ["email", "lifecyclestage"], + filterGroups: [ + { + filters: [ + { propertyName: "lifecyclestage", operator: "EQ", value: "customer" }, + ], + }, + ], + limit: 2, + }, + }, + { + url: "https://api.hubapi.com/crm/objects/2026-03/contacts/search", + body: { + properties: ["email", "lifecyclestage"], + filterGroups: [ + { + filters: [ + { propertyName: "lifecyclestage", operator: "EQ", value: "customer" }, + ], + }, + ], + limit: 1, + after: "2", + }, + }, + ]); + expect(result).toMatchObject({ + ok: true, + data: { pages: 2, rowCount: 3 }, + providerRequestId: "request_2", + }); + expect(JSON.stringify(result)).not.toContain("private-secret"); + expect(new TextDecoder().decode(result.rawBytes!)).toContain("attacker.invalid/ignored"); + }); + + it("rejects unreviewed objects and object-specific properties before dispatch", async () => { + expect(() => + createHubSpotOperation("crm.objects.list", { + objectType: "products", + properties: [], + limit: 1, + }), + ).toThrow("objectType"); + expect(() => + createHubSpotOperation("crm.objects.list", { + objectType: "contacts", + properties: ["hs_sensitive_data"], + limit: 1, + }), + ).toThrow("property"); + }); + + it("enforces HubSpot Search page, query, filter, and total-result bounds", () => { + expect(() => + createHubSpotOperation("crm.objects.search", { + objectType: "contacts", + properties: ["email"], + pageSize: 201, + limit: 1, + }), + ).toThrow("pageSize"); + expect(() => + createHubSpotOperation("crm.objects.search", { + objectType: "contacts", + properties: ["email"], + pageSize: 1, + limit: 10_001, + }), + ).toThrow("10000"); + expect(() => + createHubSpotOperation("crm.objects.search", { + objectType: "contacts", + properties: ["email"], + query: "x".repeat(3_001), + pageSize: 1, + limit: 1, + }), + ).toThrow("3000"); + }); + + it("uses only fixed current-version endpoints for every reviewed operation", () => { + expect( + planHubSpotRequest( + createHubSpotOperation("crm.properties.list", { objectType: "companies" }), + {}, + ), + ).toEqual({ + method: "GET", + endpoint: "https://api.hubapi.com/crm/properties/2026-03/companies", + }); + expect( + planHubSpotRequest( + createHubSpotOperation("crm.associations.list", { + fromObjectType: "contacts", + objectId: "123", + toObjectType: "companies", + limit: 10, + }), + {}, + ), + ).toEqual({ + method: "GET", + endpoint: + "https://api.hubapi.com/crm/objects/2026-03/contacts/123/associations/companies", + }); + expect( + planHubSpotRequest( + createHubSpotOperation("events.occurrences.list", { + occurredAfter: "2026-08-01T00:00:00Z", + occurredBefore: "2026-08-02T00:00:00Z", + properties: ["hs_url"], + limit: 10, + }), + {}, + ), + ).toEqual({ + method: "GET", + endpoint: "https://api.hubapi.com/events/event-occurrences/2026-03", + }); + expect( + planHubSpotRequest( + createHubSpotOperation("crm.pipelines.list", { objectType: "deals" }), + {}, + ), + ).toEqual({ + method: "GET", + endpoint: "https://api.hubapi.com/crm/pipelines/2026-03/deals", + }); + expect(planHubSpotRequest(createHubSpotOperation("crm.owners.list", { limit: 10 }), {})).toEqual( + { + method: "GET", + endpoint: "https://api.hubapi.com/crm/owners/2026-03", + }, + ); + }); + + it("requires an allowlisted event property projection", async () => { + expect(() => + createHubSpotOperation("events.occurrences.list", { + occurredAfter: "2026-08-01T00:00:00Z", + occurredBefore: "2026-08-02T00:00:00Z", + properties: ["email"], + limit: 1, + }), + ).toThrow("event property"); + + let capturedUrl = ""; + await dispatchHubSpot({ + operation: createHubSpotOperation("events.occurrences.list", { + occurredAfter: "2026-08-01T00:00:00Z", + occurredBefore: "2026-08-02T00:00:00Z", + objectType: "contact", + objectId: "123", + properties: ["hs_url", "hs_page_title"], + limit: 1, + }), + config: {}, + credentials: { accessToken: "private-secret" }, + signal: new AbortController().signal, + fetch: async (input) => { + capturedUrl = String(input); + return new Response(JSON.stringify({ results: [] }), { status: 200 }); + }, + }); + + const url = new URL(capturedUrl); + expect(url.origin + url.pathname).toBe( + "https://api.hubapi.com/events/event-occurrences/2026-03", + ); + expect(url.searchParams.getAll("properties")).toEqual(["hs_url", "hs_page_title"]); + expect(url.searchParams.get("objectType")).toBe("contact"); + expect(url.searchParams.get("objectId")).toBe("123"); + }); + + it("maps timeout and post-handoff cancellation to unknown outcomes", async () => { + const fetch = async (_input: string | URL | Request, init?: RequestInit): Promise => + await new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(new Error("aborted")), { once: true }); + }); + const operation = createHubSpotOperation("crm.owners.list", { limit: 1 }); + const timedOut = await dispatchHubSpot({ + operation, + config: {}, + credentials: { accessToken: "private-secret" }, + signal: new AbortController().signal, + fetch, + timeoutMs: 1, + }); + expect(timedOut).toMatchObject({ + ok: false, + code: "TIMEOUT", + retryable: true, + outcome: "unknown", + }); + + const controller = new AbortController(); + const cancelledPromise = dispatchHubSpot({ + operation, + config: {}, + credentials: { accessToken: "private-secret" }, + signal: controller.signal, + fetch, + }); + controller.abort(); + await expect(cancelledPromise).resolves.toMatchObject({ + ok: false, + code: "UNKNOWN_OUTCOME", + outcome: "unknown", + }); + }); + + it.each([ + [401, "AUTH_FAILED", false], + [403, "AUTH_FAILED", false], + [429, "RATE_LIMITED", true], + [400, "PROVIDER_ERROR", false], + [500, "UNKNOWN_OUTCOME", true], + ] as const)("maps HTTP %i without projecting provider PII", async (status, code, retryable) => { + const result = await dispatchHubSpot({ + operation: createHubSpotOperation("crm.owners.list", { limit: 1 }), + config: {}, + credentials: { accessToken: "private-secret" }, + signal: new AbortController().signal, + fetch: async () => + new Response( + JSON.stringify({ + status: "error", + category: "VALIDATION_ERROR", + correlationId: "safe-correlation", + message: "contact person@example.com token private-secret", + context: { email: ["person@example.com"] }, + }), + { status }, + ), + }); + + expect(result).toMatchObject({ + ok: false, + code, + retryable, + details: { + httpStatus: status, + providerCategory: "VALIDATION_ERROR", + }, + providerRequestId: "safe-correlation", + }); + expect(JSON.stringify(result)).not.toContain("person@example.com"); + expect(JSON.stringify(result)).not.toContain("private-secret"); + }); +}); diff --git a/packages/gkit/src/providers/hubspot.ts b/packages/gkit/src/providers/hubspot.ts new file mode 100644 index 0000000..6d17224 --- /dev/null +++ b/packages/gkit/src/providers/hubspot.ts @@ -0,0 +1,698 @@ +import { Buffer } from "node:buffer"; + +import type { RawJsonDispatchResult } from "./raw-json"; + +export type HubSpotConfig = Readonly>; +export type HubSpotCredentials = Readonly<{ accessToken: string }>; +export type HubSpotFetch = (input: string | URL | Request, init?: RequestInit) => Promise; +export type HubSpotOperation = Readonly<{ + adapterKey: string; + input: Readonly>; + request: HubSpotRequestDefinition; +}>; + +type HubSpotRequestDefinition = Readonly<{ + method: "GET" | "POST"; + endpoint: string; + pageSize: number | null; + totalLimit: number | null; + query: Readonly>; + body: Readonly> | null; +}>; + +type ObjectType = "companies" | "contacts" | "deals" | "tickets"; +type EventObjectType = "company" | "contact" | "deal" | "ticket"; + +export const defaultHubSpotTimeoutMs = 30_000; +export const hubSpotApiOrigin = "https://api.hubapi.com"; + +const objectTypes = new Set(["companies", "contacts", "deals", "tickets"]); +const pipelineObjectTypes = new Set(["deals", "tickets"]); +const eventObjectTypes = new Set(["company", "contact", "deal", "ticket"]); +const eventProperties = new Set([ + "hs_browser", + "hs_city", + "hs_content_type", + "hs_country", + "hs_device_name", + "hs_device_type", + "hs_page_title", + "hs_referrer", + "hs_touchpoint_source", + "hs_url", + "hs_utm_campaign", + "hs_utm_medium", + "hs_utm_source", +]); +const searchOperators = new Set([ + "BETWEEN", + "CONTAINS_TOKEN", + "EQ", + "GT", + "GTE", + "HAS_PROPERTY", + "IN", + "LT", + "LTE", + "NEQ", + "NOT_CONTAINS_TOKEN", + "NOT_HAS_PROPERTY", + "NOT_IN", +]); + +export const hubSpotPropertyAllowlist: Readonly>> = + Object.freeze({ + contacts: new Set([ + "createdate", + "email", + "firstname", + "hs_analytics_source", + "hs_analytics_source_data_1", + "hs_analytics_source_data_2", + "hs_lead_status", + "hs_object_id", + "hubspot_owner_id", + "lastmodifieddate", + "lastname", + "lifecyclestage", + ]), + companies: new Set([ + "city", + "country", + "createdate", + "domain", + "hs_lastmodifieddate", + "hs_object_id", + "hubspot_owner_id", + "industry", + "lifecyclestage", + "name", + "numberofemployees", + "state", + ]), + deals: new Set([ + "amount", + "closedate", + "createdate", + "dealname", + "dealstage", + "hs_lastmodifieddate", + "hs_object_id", + "hubspot_owner_id", + "pipeline", + ]), + tickets: new Set([ + "content", + "createdate", + "hs_lastmodifieddate", + "hs_object_id", + "hs_pipeline", + "hs_pipeline_stage", + "hs_ticket_category", + "hs_ticket_priority", + "hubspot_owner_id", + "subject", + ]), + }); + +export function createHubSpotOperation( + adapterKey: string, + input: Readonly>, +): HubSpotOperation { + const request = buildRequestDefinition(adapterKey, input); + return Object.freeze({ adapterKey, input: Object.freeze({ ...input }), request }); +} + +export function planHubSpotRequest( + operation: HubSpotOperation, + _config: HubSpotConfig, +): { method: "GET" | "POST"; endpoint: string } { + return { method: operation.request.method, endpoint: operation.request.endpoint }; +} + +export async function dispatchHubSpot(options: { + operation: HubSpotOperation; + config: HubSpotConfig; + credentials: HubSpotCredentials; + signal: AbortSignal; + fetch?: HubSpotFetch; + timeoutMs?: number; +}): Promise { + const timeoutMs = options.timeoutMs ?? defaultHubSpotTimeoutMs; + if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) { + throw new RangeError("HubSpot timeoutMs must be a positive safe integer."); + } + + const dispatchSignal = createDispatchSignal(options.signal, timeoutMs); + const pageBytes: Uint8Array[] = []; + let rowCount = 0; + let remaining = options.operation.request.totalLimit; + let after: string | null = null; + let providerRequestId: string | null = null; + let hasMore = false; + + try { + while (true) { + const request = materializeRequest(options.operation.request, after, remaining); + let response: Response; + let rawBytes: Uint8Array; + try { + response = await (options.fetch ?? globalThis.fetch)(request.url, { + method: request.method, + headers: { + authorization: `Bearer ${options.credentials.accessToken}`, + accept: "application/json", + ...(request.body ? { "content-type": "application/json" } : {}), + }, + ...(request.body ? { body: JSON.stringify(request.body) } : {}), + signal: dispatchSignal.signal, + }); + rawBytes = new Uint8Array(await response.arrayBuffer()); + } catch { + return transportFailure(options.signal, dispatchSignal.timedOut()); + } + + const payload = parseJson(rawBytes); + providerRequestId = + safeRequestId(response, payload, options.credentials.accessToken) ?? providerRequestId; + if (!response.ok) { + return httpFailure(response, rawBytes, payload, providerRequestId); + } + if (!isRecord(payload)) { + return failure({ + code: "PROVIDER_ERROR", + message: "HubSpot returned a successful status with an invalid result shape.", + outcome: "confirmed", + rawBytes, + providerRequestId, + details: { httpStatus: response.status, contract: "hubspot_result_invalid" }, + }); + } + + const rows = payload.results; + if (!Array.isArray(rows)) { + return failure({ + code: "PROVIDER_ERROR", + message: "HubSpot returned a successful status without a result collection.", + outcome: "confirmed", + rawBytes, + providerRequestId, + details: { httpStatus: response.status, contract: "hubspot_collection_invalid" }, + }); + } + pageBytes.push(rawBytes); + rowCount += rows.length; + if (remaining !== null) remaining -= rows.length; + + const next = nextCursor(payload); + hasMore = next !== null; + if (!next || remaining === null || remaining <= 0 || rows.length === 0) break; + after = next; + } + } finally { + dispatchSignal.dispose(); + } + + return { + ok: true, + rawBytes: jsonArrayOfExactPages(pageBytes), + providerRequestId, + data: { pages: pageBytes.length, rowCount, truncated: hasMore && remaining === 0 }, + }; +} + +function buildRequestDefinition( + adapterKey: string, + input: Readonly>, +): HubSpotRequestDefinition { + if (adapterKey === "crm.properties.list") { + const objectType = requiredObjectType(input, "objectType"); + return requestDefinition( + "GET", + `${hubSpotApiOrigin}/crm/properties/2026-03/${objectType}`, + ); + } + if (adapterKey === "crm.objects.list") { + const objectType = requiredObjectType(input, "objectType"); + const properties = validatedProperties(input, objectType); + return requestDefinition( + "GET", + `${hubSpotApiOrigin}/crm/objects/2026-03/${objectType}`, + boundedInteger(input, "pageSize", 1, 100, 100), + boundedInteger(input, "limit", 1, 5_000, 100), + { + ...(properties.length > 0 ? { properties } : {}), + ...(input.archived === true ? { archived: "true" } : {}), + }, + ); + } + if (adapterKey === "crm.objects.search") { + const objectType = requiredObjectType(input, "objectType"); + const properties = validatedProperties(input, objectType); + const pageSize = boundedInteger(input, "pageSize", 1, 200, 100); + const totalLimit = boundedInteger(input, "limit", 1, 10_000, 100); + const body = searchBody(input, objectType, properties); + assertSearchBodySize({ ...body, limit: pageSize, after: "x".repeat(512) }); + return requestDefinition( + "POST", + `${hubSpotApiOrigin}/crm/objects/2026-03/${objectType}/search`, + pageSize, + totalLimit, + {}, + body, + ); + } + if (adapterKey === "crm.associations.list") { + const fromObjectType = requiredObjectType(input, "fromObjectType"); + const toObjectType = requiredObjectType(input, "toObjectType"); + const objectId = requiredIdentifier(input, "objectId"); + return requestDefinition( + "GET", + `${hubSpotApiOrigin}/crm/objects/2026-03/${fromObjectType}/${encodeURIComponent(objectId)}/associations/${toObjectType}`, + boundedInteger(input, "pageSize", 1, 100, 100), + boundedInteger(input, "limit", 1, 5_000, 100), + ); + } + if (adapterKey === "events.occurrences.list") { + const occurredAfter = requiredDateTime(input, "occurredAfter"); + const occurredBefore = requiredDateTime(input, "occurredBefore"); + if (Date.parse(occurredAfter) >= Date.parse(occurredBefore)) { + throw new TypeError("HubSpot occurredAfter must be earlier than occurredBefore."); + } + if (Date.parse(occurredBefore) - Date.parse(occurredAfter) > 366 * 24 * 60 * 60 * 1_000) { + throw new RangeError("HubSpot event occurrence windows must not exceed 366 days."); + } + const objectType = optionalEventObjectType(input, "objectType"); + const objectId = optionalIdentifier(input, "objectId"); + if (objectId && !objectType) { + throw new TypeError("HubSpot objectId requires objectType."); + } + const properties = requiredEventProperties(input); + return requestDefinition( + "GET", + `${hubSpotApiOrigin}/events/event-occurrences/2026-03`, + boundedInteger(input, "pageSize", 1, 100, 100), + boundedInteger(input, "limit", 1, 5_000, 100), + { + occurredAfter, + occurredBefore, + ...(optionalString(input, "eventType") ? { eventType: optionalString(input, "eventType")! } : {}), + ...(objectType ? { objectType } : {}), + ...(objectId ? { objectId } : {}), + properties, + }, + ); + } + if (adapterKey === "crm.pipelines.list") { + const objectType = requiredString(input, "objectType"); + if (!pipelineObjectTypes.has(objectType)) { + throw new TypeError("HubSpot pipeline objectType must be deals or tickets."); + } + return requestDefinition( + "GET", + `${hubSpotApiOrigin}/crm/pipelines/2026-03/${objectType}`, + ); + } + if (adapterKey === "crm.owners.list") { + return requestDefinition( + "GET", + `${hubSpotApiOrigin}/crm/owners/2026-03`, + boundedInteger(input, "pageSize", 1, 100, 100), + boundedInteger(input, "limit", 1, 5_000, 100), + input.archived === true ? { archived: "true" } : {}, + ); + } + throw new TypeError("HubSpot adapter key is not reviewed."); +} + +function searchBody( + input: Readonly>, + objectType: ObjectType, + properties: readonly string[], +): Readonly> { + const body: Record = {}; + if (properties.length > 0) body.properties = properties; + const rawQuery = input.query; + if (rawQuery !== undefined && (typeof rawQuery !== "string" || rawQuery.length === 0)) { + throw new TypeError("HubSpot Search query must be a non-empty string."); + } + const query = typeof rawQuery === "string" ? rawQuery : null; + if (query) { + if (query.length > 3_000) throw new RangeError("HubSpot Search query must not exceed 3000 characters."); + body.query = query; + } + if (input.sorts !== undefined) { + if (!Array.isArray(input.sorts) || input.sorts.length > 1) { + throw new TypeError("HubSpot Search accepts at most one sort."); + } + body.sorts = input.sorts.map((sort) => validateSort(sort, objectType)); + } + if (input.filterGroups !== undefined) { + body.filterGroups = validateFilterGroups(input.filterGroups, objectType); + } + return Object.freeze(body); +} + +function validateSort(value: unknown, objectType: ObjectType): Record { + if (!isRecord(value)) throw new TypeError("HubSpot Search sort must be an object."); + const propertyName = requiredString(value, "propertyName"); + assertAllowedProperty(objectType, propertyName); + const direction = requiredString(value, "direction"); + if (direction !== "ASCENDING" && direction !== "DESCENDING") { + throw new TypeError("HubSpot Search sort direction is not reviewed."); + } + return { propertyName, direction }; +} + +function validateFilterGroups(value: unknown, objectType: ObjectType): unknown[] { + if (!Array.isArray(value) || value.length > 5) { + throw new TypeError("HubSpot Search accepts at most five filter groups."); + } + let filterCount = 0; + return value.map((group) => { + if (!isRecord(group) || !Array.isArray(group.filters) || group.filters.length > 6) { + throw new TypeError("HubSpot Search filter groups accept at most six filters."); + } + filterCount += group.filters.length; + if (filterCount > 18) throw new TypeError("HubSpot Search accepts at most 18 filters."); + return { filters: group.filters.map((filter) => validateFilter(filter, objectType)) }; + }); +} + +function validateFilter(value: unknown, objectType: ObjectType): Record { + if (!isRecord(value)) throw new TypeError("HubSpot Search filter must be an object."); + const propertyName = requiredString(value, "propertyName"); + assertAllowedProperty(objectType, propertyName); + const operator = requiredString(value, "operator"); + if (!searchOperators.has(operator)) throw new TypeError("HubSpot Search operator is not reviewed."); + const filter: Record = { propertyName, operator }; + for (const key of ["value", "highValue"] as const) { + if (value[key] !== undefined) filter[key] = requiredString(value, key); + } + if (value.values !== undefined) { + if (!Array.isArray(value.values) || value.values.length > 100 || !value.values.every(isString)) { + throw new TypeError("HubSpot Search filter values must be a bounded string array."); + } + filter.values = value.values; + } + return filter; +} + +function requestDefinition( + method: "GET" | "POST", + endpoint: string, + pageSize: number | null = null, + totalLimit: number | null = null, + query: Readonly> = {}, + body: Readonly> | null = null, +): HubSpotRequestDefinition { + return Object.freeze({ method, endpoint, pageSize, totalLimit, query, body }); +} + +function materializeRequest( + definition: HubSpotRequestDefinition, + after: string | null, + remaining: number | null, +): { method: "GET" | "POST"; url: string; body: Record | null } { + const pageLimit = + definition.pageSize === null || remaining === null + ? definition.pageSize + : Math.min(definition.pageSize, remaining); + if (definition.method === "POST") { + const body = { + ...(definition.body ?? {}), + ...(pageLimit === null ? {} : { limit: pageLimit }), + ...(after ? { after } : {}), + }; + assertSearchBodySize(body); + return { method: "POST", url: definition.endpoint, body }; + } + const url = new URL(definition.endpoint); + for (const [key, value] of Object.entries(definition.query)) { + if (Array.isArray(value)) { + for (const child of value) url.searchParams.append(key, child); + } else { + url.searchParams.set(key, value as string); + } + } + if (pageLimit !== null) url.searchParams.set("limit", String(pageLimit)); + if (after) url.searchParams.set("after", after); + return { method: "GET", url: url.toString(), body: null }; +} + +function assertSearchBodySize(body: Readonly>): void { + if (JSON.stringify(body).length > 3_000) { + throw new RangeError("HubSpot Search request body must not exceed 3000 characters."); + } +} + +function validatedProperties( + input: Readonly>, + objectType: ObjectType, +): readonly string[] { + if ( + !Array.isArray(input.properties) || + input.properties.length < 1 || + input.properties.length > 50 || + !input.properties.every(isString) + ) { + throw new TypeError("HubSpot properties must be a bounded string array."); + } + for (const property of input.properties) assertAllowedProperty(objectType, property); + return [...new Set(input.properties)]; +} + +function assertAllowedProperty(objectType: ObjectType, property: string): void { + if (!hubSpotPropertyAllowlist[objectType].has(property)) { + throw new TypeError(`HubSpot property ${property} is not reviewed for ${objectType}.`); + } +} + +function requiredObjectType(input: Readonly>, key: string): ObjectType { + const value = requiredString(input, key); + if (!objectTypes.has(value as ObjectType)) { + throw new TypeError("HubSpot objectType is not in the reviewed allowlist."); + } + return value as ObjectType; +} + +function optionalEventObjectType( + input: Readonly>, + key: string, +): EventObjectType | null { + const value = optionalString(input, key); + if (!value) return null; + if (!eventObjectTypes.has(value as EventObjectType)) { + throw new TypeError("HubSpot event objectType is not in the reviewed allowlist."); + } + return value as EventObjectType; +} + +function requiredEventProperties(input: Readonly>): readonly string[] { + const value = input.properties; + if ( + !Array.isArray(value) || + value.length < 1 || + value.length > 20 || + !value.every(isString) + ) { + throw new TypeError("HubSpot event properties must be a bounded reviewed string array."); + } + for (const property of value) { + if (!eventProperties.has(property)) { + throw new TypeError(`HubSpot event property ${property} is not reviewed.`); + } + } + return [...new Set(value)]; +} + +function requiredIdentifier(input: Readonly>, key: string): string { + const value = requiredString(input, key); + if (!/^[1-9]\d{0,19}$/.test(value)) throw new TypeError(`HubSpot ${key} must be a record ID.`); + return value; +} + +function optionalIdentifier(input: Readonly>, key: string): string | null { + const value = optionalString(input, key); + if (!value) return null; + if (!/^[1-9]\d{0,19}$/.test(value)) throw new TypeError(`HubSpot ${key} must be a record ID.`); + return value; +} + +function requiredDateTime(input: Readonly>, key: string): string { + const value = requiredString(input, key); + if (!Number.isFinite(Date.parse(value)) || value.length > 64) { + throw new TypeError(`HubSpot ${key} must be an ISO 8601 date-time.`); + } + return value; +} + +function boundedInteger( + input: Readonly>, + key: string, + minimum: number, + maximum: number, + fallback: number, +): number { + const value = input[key] ?? fallback; + if (!Number.isSafeInteger(value) || (value as number) < minimum || (value as number) > maximum) { + throw new RangeError(`HubSpot ${key} must be an integer between ${minimum} and ${maximum}.`); + } + return value as number; +} + +function requiredString(input: Readonly>, key: string): string { + const value = optionalString(input, key); + if (!value) throw new TypeError(`HubSpot ${key} is required.`); + return value; +} + +function optionalString(input: Readonly>, key: string): string | null { + const value = input[key]; + return typeof value === "string" && value.length > 0 && value.length <= 3_000 ? value : null; +} + +function isString(value: unknown): value is string { + return typeof value === "string" && value.length > 0 && value.length <= 3_000; +} + +function parseJson(rawBytes: Uint8Array): unknown { + try { + return JSON.parse(Buffer.from(rawBytes).toString("utf8")) as unknown; + } catch { + return null; + } +} + +function jsonArrayOfExactPages(pages: readonly Uint8Array[]): Uint8Array { + const chunks: Uint8Array[] = [Buffer.from("[", "utf8")]; + for (const [index, page] of pages.entries()) { + if (index > 0) chunks.push(Buffer.from(",", "utf8")); + chunks.push(page); + } + chunks.push(Buffer.from("]", "utf8")); + return Buffer.concat(chunks); +} + +function nextCursor(payload: Record): string | null { + if (!isRecord(payload.paging) || !isRecord(payload.paging.next)) return null; + const after = payload.paging.next.after; + return typeof after === "string" && /^[A-Za-z0-9._:-]{1,512}$/.test(after) ? after : null; +} + +function safeRequestId(response: Response, payload: unknown, accessToken: string): string | null { + const header = + response.headers.get("x-hubspot-correlation-id") ?? response.headers.get("x-request-id"); + if (header && header !== accessToken && /^[A-Za-z0-9._:-]{1,128}$/.test(header)) return header; + if (!isRecord(payload)) return null; + const correlationId = payload.correlationId; + return typeof correlationId === "string" && + correlationId !== accessToken && + /^[A-Za-z0-9._:-]{1,128}$/.test(correlationId) + ? correlationId + : null; +} + +function safeErrorDetails(payload: unknown): Record { + if (!isRecord(payload)) return {}; + const category = payload.category; + return typeof category === "string" && /^[A-Z0-9_]{1,80}$/.test(category) + ? { providerCategory: category } + : {}; +} + +function httpFailure( + response: Response, + rawBytes: Uint8Array, + payload: unknown, + providerRequestId: string | null, +): RawJsonDispatchResult { + const status = response.status; + const code = + status === 401 || status === 403 + ? "AUTH_FAILED" + : status === 429 + ? "RATE_LIMITED" + : status === 408 || status >= 500 + ? "UNKNOWN_OUTCOME" + : "PROVIDER_ERROR"; + return failure({ + code, + message: + code === "AUTH_FAILED" + ? "HubSpot rejected the configured credentials or scopes." + : code === "RATE_LIMITED" + ? "HubSpot rejected the request because its rate limit was reached." + : code === "UNKNOWN_OUTCOME" + ? "HubSpot did not confirm the read outcome." + : "HubSpot rejected the read request.", + outcome: code === "UNKNOWN_OUTCOME" ? "unknown" : "confirmed", + rawBytes, + providerRequestId, + details: { httpStatus: status, ...safeErrorDetails(payload) }, + }); +} + +function transportFailure(signal: AbortSignal, timedOut: boolean): RawJsonDispatchResult { + return failure({ + code: timedOut ? "TIMEOUT" : signal.aborted ? "UNKNOWN_OUTCOME" : "NETWORK_ERROR", + message: timedOut + ? "The HubSpot request exceeded its deadline before the outcome was confirmed." + : signal.aborted + ? "The HubSpot request was interrupted before the outcome was confirmed." + : "The HubSpot request ended without a confirmed provider outcome.", + outcome: "unknown", + }); +} + +function failure(options: { + code: Extract["code"]; + message: string; + outcome: Extract["outcome"]; + details?: Record | null; + rawBytes?: Uint8Array | null; + providerRequestId?: string | null; +}): RawJsonDispatchResult { + return { + ok: false, + code: options.code, + message: options.message, + retryable: + options.code === "RATE_LIMITED" || + options.code === "NETWORK_ERROR" || + options.code === "TIMEOUT" || + options.code === "UNKNOWN_OUTCOME", + outcome: options.outcome, + details: options.details ?? null, + rawBytes: options.rawBytes ?? null, + providerRequestId: options.providerRequestId ?? null, + }; +} + +function createDispatchSignal( + externalSignal: AbortSignal, + timeoutMs: number, +): { signal: AbortSignal; timedOut(): boolean; dispose(): void } { + const controller = new AbortController(); + let didTimeOut = false; + const onExternalAbort = (): void => controller.abort(externalSignal.reason); + if (externalSignal.aborted) onExternalAbort(); + else externalSignal.addEventListener("abort", onExternalAbort, { once: true }); + const timer = setTimeout(() => { + didTimeOut = true; + controller.abort(new Error("HubSpot request deadline exceeded.")); + }, timeoutMs); + timer.unref(); + return { + signal: controller.signal, + timedOut: () => didTimeOut, + dispose: () => { + clearTimeout(timer); + externalSignal.removeEventListener("abort", onExternalAbort); + }, + }; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/packages/gkit/src/schema.ts b/packages/gkit/src/schema.ts index ba95077..491e42a 100644 --- a/packages/gkit/src/schema.ts +++ b/packages/gkit/src/schema.ts @@ -24,6 +24,9 @@ export function buildGkitSchema( const gscCapabilityCount = capabilities.filter( (capability) => capability.provider === "gsc", ).length; + const hubSpotCapabilityCount = capabilities.filter( + (capability) => capability.provider === "hubspot", + ).length; if (capabilities.length === 0) { throw new GkitFailure({ code: "INTERNAL_ERROR", @@ -194,6 +197,30 @@ export function buildGkitSchema( ), }, ), + hubspot: group( + { description: `${hubSpotCapabilityCount} reviewed reads.` }, + { + doctor: c + .meta({ + description: "Profile check.", + examples: ["gkit --profile app-a hubspot doctor"], + }) + .input(s(v.strictObject({}))), + api: group( + { description: "Native API." }, + { + call: c + .meta({ + description: `Call ${hubSpotCapabilityCount} reads: gkit --profile hubspot api call --operation-id --input @request.json --out --dry-run.`, + examples: [ + "gkit --profile hubspot api call --operation-id --input @request.json --out --dry-run", + ], + }) + .input(s(v.strictObject({}))), + }, + ), + }, + ), }; } @@ -262,7 +289,10 @@ function compactRootSchema(generated: string): string { return `${indent}/** ${description} */`; }, ) - .replace(/^\s*\/\*\* (?:\d+ reviewed reads?|Profile check\.|Native API\.) \*\/\n/gm, "") + .replace( + /^\s*\/\*\* (?:\d+ reviewed reads?\.|Profile check\.|Native API\.) \*\/\n/gm, + "", + ) .replace(/\n{2,}/g, "\n") ); } @@ -303,5 +333,9 @@ function rewriteArgcExamples( .replace( /gkit gsc\.api\.call "[^"]*"/g, "gkit --profile gsc api call --operation-id --input @request.json --out --dry-run", + ) + .replace( + /gkit hubspot\.api\.call "[^"]*"/g, + "gkit --profile hubspot api call --operation-id --input @request.json --out --dry-run", ); }