From 63f8e6d80b6f47337162fd99a8312f9ba09d6d5e Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Sun, 25 Oct 2026 00:37:34 -0400 Subject: [PATCH 1/4] feat(docs): build and deliver versioned HubSpot documentation [skip release] --- .github/workflows/module-docs-check.yml | 25 + .github/workflows/module-docs-delivery.yml | 44 ++ .github/workflows/release.yml | 46 +- .gitignore | 2 + package.json | 2 + packages/v1-ready/hubspot/.npmignore | 6 + packages/v1-ready/hubspot/docs/README.md | 27 ++ .../v1-ready/hubspot/docs/authentication.md | 44 ++ packages/v1-ready/hubspot/docs/changes.md | 24 + packages/v1-ready/hubspot/docs/first-call.cjs | 6 + .../v1-ready/hubspot/docs/getting-started.md | 39 ++ packages/v1-ready/hubspot/docs/operations.md | 66 +++ packages/v1-ready/hubspot/docs/reference.json | 447 ++++++++++++++++++ packages/v1-ready/hubspot/module-docs.json | 17 + packages/v1-ready/hubspot/package.json | 3 +- tools/module-docs/README.md | 75 +++ tools/module-docs/auto-plugin.cjs | 24 + tools/module-docs/build.cjs | 94 ++++ tools/module-docs/build.test.cjs | 121 +++++ tools/module-docs/contract.cjs | 159 +++++++ tools/module-docs/deliver.cjs | 46 ++ tools/module-docs/descriptor.schema.json | 25 + tools/module-docs/fixtures.cjs | 53 +++ tools/module-docs/modules.json | 7 + tools/module-docs/registry.cjs | 64 +++ 25 files changed, 1464 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/module-docs-check.yml create mode 100644 .github/workflows/module-docs-delivery.yml create mode 100644 packages/v1-ready/hubspot/.npmignore create mode 100644 packages/v1-ready/hubspot/docs/README.md create mode 100644 packages/v1-ready/hubspot/docs/authentication.md create mode 100644 packages/v1-ready/hubspot/docs/changes.md create mode 100644 packages/v1-ready/hubspot/docs/first-call.cjs create mode 100644 packages/v1-ready/hubspot/docs/getting-started.md create mode 100644 packages/v1-ready/hubspot/docs/operations.md create mode 100644 packages/v1-ready/hubspot/docs/reference.json create mode 100644 packages/v1-ready/hubspot/module-docs.json create mode 100644 tools/module-docs/README.md create mode 100644 tools/module-docs/auto-plugin.cjs create mode 100644 tools/module-docs/build.cjs create mode 100644 tools/module-docs/build.test.cjs create mode 100644 tools/module-docs/contract.cjs create mode 100644 tools/module-docs/deliver.cjs create mode 100644 tools/module-docs/descriptor.schema.json create mode 100644 tools/module-docs/fixtures.cjs create mode 100644 tools/module-docs/modules.json create mode 100644 tools/module-docs/registry.cjs diff --git a/.github/workflows/module-docs-check.yml b/.github/workflows/module-docs-check.yml new file mode 100644 index 00000000..0023df31 --- /dev/null +++ b/.github/workflows/module-docs-check.yml @@ -0,0 +1,25 @@ +name: Module docs contract +on: + pull_request: + paths: + - tools/module-docs/** + - packages/v1-ready/hubspot/** + - .github/workflows/module-docs*.yml + - .github/workflows/release.yml + - package.json + workflow_dispatch: +permissions: + contents: read +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: node --test tools/module-docs/*.test.cjs + - name: Pack the pilot with its real lifecycle hook + run: npm pack --workspace @friggframework/api-module-hubspot --pack-destination /tmp diff --git a/.github/workflows/module-docs-delivery.yml b/.github/workflows/module-docs-delivery.yml new file mode 100644 index 00000000..76b7a2ae --- /dev/null +++ b/.github/workflows/module-docs-delivery.yml @@ -0,0 +1,44 @@ +name: Retry module documentation delivery +on: + workflow_dispatch: + inputs: + package: + description: Allowlisted published npm package + type: string + required: true + version: + description: Exact published version (no package publication) + type: string + required: true +permissions: + contents: read +jobs: + deliver: + if: vars.MODULE_DOCS_ENABLED == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: next + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: 22 + - uses: actions/create-github-app-token@v2 + id: app + with: + app-id: ${{ vars.MODULE_DOCS_APP_ID }} + private-key: ${{ secrets.MODULE_DOCS_APP_PRIVATE_KEY }} + owner: friggframework + repositories: frigg + - name: Verify package and retry dispatch + env: + MODULE_DOCS_TOKEN: ${{ steps.app.outputs.token }} + run: node tools/module-docs/deliver.cjs + - uses: actions/upload-artifact@v4 + if: always() + with: + name: module-docs-delivery + path: .module-docs/delivery.json + include-hidden-files: true + if-no-files-found: ignore diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2dc21e25..18ecaf23 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,7 +6,7 @@ on: jobs: release: runs-on: ubuntu-latest - if: "!contains(github.event.head_commit.message, 'ci skip') && !contains(github.event.head_commit.message, 'skip ci')" + if: "!contains(github.event.head_commit.message, 'ci skip') && !contains(github.event.head_commit.message, 'skip ci') && !contains(github.event.head_commit.message, '[skip release]')" steps: - name: Checkout uses: actions/checkout@v4 @@ -21,6 +21,8 @@ jobs: node-version: 18 cache: 'npm' registry-url: 'https://registry.npmjs.org' + - name: Check module documentation and examples + run: node --test tools/module-docs/*.test.cjs - name: Auto release env: GH_TOKEN: ${{ secrets.GH_TOKEN }} @@ -30,3 +32,45 @@ jobs: run: | npm ci npx auto shipit + - name: Save published module identities for separate delivery + if: success() && hashFiles('.module-docs/published.json') != '' + uses: actions/upload-artifact@v4 + with: + name: module-docs-published + path: .module-docs/published.json + include-hidden-files: true + retention-days: 30 + + module-docs: + needs: release + if: vars.MODULE_DOCS_ENABLED == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: 22 + - uses: actions/download-artifact@v4 + with: + name: module-docs-published + path: .module-docs + - uses: actions/create-github-app-token@v2 + id: app + with: + app-id: ${{ vars.MODULE_DOCS_APP_ID }} + private-key: ${{ secrets.MODULE_DOCS_APP_PRIVATE_KEY }} + owner: friggframework + repositories: frigg + - name: Verify published packages and dispatch docs delivery + env: + MODULE_DOCS_TOKEN: ${{ steps.app.outputs.token }} + run: node tools/module-docs/deliver.cjs + - uses: actions/upload-artifact@v4 + if: always() + with: + name: module-docs-delivery + path: .module-docs/delivery.json + include-hidden-files: true + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index ce7e46d2..8bc76f07 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,5 @@ yarn-error.log* .env .npmrc /.nx/ +/.module-docs/ +**/module-docs.bundle.json diff --git a/package.json b/package.json index f1962106..95e19382 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "description": "", "private": true, "scripts": { + "test:module-docs": "node --test tools/module-docs/*.test.cjs", "auto:info": "auto info", "test:all": "npm test --foreground-scripts --workspaces --if-present", "test:api-module-managers": "jest --testMatch='/api-module-library/**/manager.test.js' --globalSetup='/scripts/set-up-tests.js' --globalTeardown='/scripts/tear-down-tests.js' --watch", @@ -39,6 +40,7 @@ "versionBranches": true, "onlyPublishWithReleaseLabel": true, "plugins": [ + "./tools/module-docs/auto-plugin.cjs", "npm", "all-contributors", "conventional-commits", diff --git a/packages/v1-ready/hubspot/.npmignore b/packages/v1-ready/hubspot/.npmignore new file mode 100644 index 00000000..382a36e8 --- /dev/null +++ b/packages/v1-ready/hubspot/.npmignore @@ -0,0 +1,6 @@ +.env* +*.tgz +tests/ +jest-*.js +jest.config.js +coverage/ diff --git a/packages/v1-ready/hubspot/docs/README.md b/packages/v1-ready/hubspot/docs/README.md new file mode 100644 index 00000000..ce743107 --- /dev/null +++ b/packages/v1-ready/hubspot/docs/README.md @@ -0,0 +1,27 @@ +# HubSpot + +This module exports an OAuth-aware `Api`, a Frigg authorization `Definition`, +`Config`, and `extensions.webhooks`. It wraps selected CRM, list, association, +custom-object, property, owner, timeline, CMS and template operations. It is not +the complete HubSpot API or a generated replacement for the provider's SDK. + +```bash +npm install {{PACKAGE}}@{{VERSION}} +``` + +This exact release declares Frigg compatibility `{{FRIGG_RANGE}}`. Its Node +engines range is `{{NODE_RANGE}}`; unknown means the package declares no range. +Declared compatibility is separate from tested compatibility. The documentation +pilot checks wrapper calls with fixtures; it does not establish a live-account +test or a matrix of Frigg versions. Use the runtime requirements of your installed +Frigg release. + +- [First successful call](getting-started.md) +- [OAuth setup and credential lifecycle](authentication.md) +- [Public methods](reference.md) +- [Pagination, errors, webhooks and limitations](operations.md) +- [Release changes and support](changes.md) + +The package uses the MIT license and is maintained by the Frigg Framework +maintainers. HubSpot product entitlements and scopes determine which operations +an account can use; exported method names do not grant those permissions. diff --git a/packages/v1-ready/hubspot/docs/authentication.md b/packages/v1-ready/hubspot/docs/authentication.md new file mode 100644 index 00000000..df3438ed --- /dev/null +++ b/packages/v1-ready/hubspot/docs/authentication.md @@ -0,0 +1,44 @@ +# OAuth setup and credential lifecycle + +Create or configure a HubSpot OAuth app, register its redirect URI, and enable the +scopes required for the methods you will call. The first-call example requires +`crm.objects.contacts.read`; property discovery, other CRM objects, CMS features, +and webhooks can require different scopes or account subscriptions. Consult the +[HubSpot OAuth guide](https://developers.hubspot.com/docs/apps/developer-platform/build-apps/authentication/oauth/overview) +for the app generation you use. + +Configure these values before importing the module: + +| Variable | Used by the module | +|---|---| +| `HUBSPOT_CLIENT_ID` | OAuth client ID | +| `HUBSPOT_CLIENT_SECRET` | Code exchange, token introspection, and webhook signature verification | +| `HUBSPOT_SCOPE` | Space-separated scopes included in the authorization URL | +| `REDIRECT_URI` | Base callback URI; `Definition` appends `/hubspot` | + +Register the complete callback URI, including `/hubspot`, in HubSpot. Avoid a +trailing slash on `REDIRECT_URI` that would create a double slash. The definition +reads environment values at module load time. The `Api` constructor can instead +receive `client_id`, `client_secret`, `scope`, `redirect_uri`, `state`, +`access_token`, and `refresh_token` through the Frigg OAuth2Requester. + +`getAuthUri()` returns the constructed authorization URL. The application must +generate, persist and verify OAuth state and route the callback; this method does +not implement that callback. The definition exchanges the returned code through +the inherited `getTokenFromCode()` and persists `access_token` and `refresh_token` +on the credential. + +This source sets the token endpoint to `/oauth/2026-03/token` and introspects tokens +through `/oauth/2026-03/token/introspect`. `getUserDetails()` combines +`/integrations/v1/me` with token introspection and therefore requires client +credentials as well as the token. It is not the minimal read-only first-call path. +See the [HubSpot OAuth endpoint migration](https://developers.hubspot.com/changelog/v1-oauth-api-deprecation) +for provider changes. + +Refresh behavior is inherited from the installed Frigg OAuth2Requester. Use Frigg's +managed credential flow to persist refreshed credentials rather than discarding +them in an ephemeral `Api` instance. The module declares Frigg `{{FRIGG_RANGE}}`; +it does not independently promise a concurrency strategy or refresh schedule. +Reauthorize revoked or inactive grants and keep credentials out of URLs, logs, +fixtures and source control. `introspectToken()` rejects when HubSpot returns an +inactive token. diff --git a/packages/v1-ready/hubspot/docs/changes.md b/packages/v1-ready/hubspot/docs/changes.md new file mode 100644 index 00000000..e5bec6d9 --- /dev/null +++ b/packages/v1-ready/hubspot/docs/changes.md @@ -0,0 +1,24 @@ +# Changes and support + +This snapshot documents `{{PACKAGE}}@{{VERSION}}`. Its release record contains +the immutable source input commit, the exact package metadata after release +versioning, generator identity, bundle digest, and verified npm integrity. +The catalog links to the package changelog at that source input commit. + +Before upgrading, compare the target version's public-method reference and +limitations and read its package changelog. A provider API change and a wrapper +package release are separate version dimensions. The OAuth implementation in +this source uses date-based token endpoints while many CRM methods retain v3 +paths and association methods retain v4 paths. + +This first docs pilot does not backfill earlier package versions or claim that +current code describes those historical releases. It introduces no authentication +or runtime API migration. Future breaking wrapper changes must carry migration +instructions alongside their release notes. + +The package is MIT licensed. Report wrapper and documentation issues to the +[module library issue tracker](https://github.com/friggframework/api-module-library/issues) +with exact package/Frigg versions, method and redacted reproduction. Send HubSpot +account, app-entitlement or service incidents through the provider's support +channel. Never include client secrets, tokens, customer records or signed webhook +bodies in a public issue. diff --git a/packages/v1-ready/hubspot/docs/first-call.cjs b/packages/v1-ready/hubspot/docs/first-call.cjs new file mode 100644 index 00000000..e200c32a --- /dev/null +++ b/packages/v1-ready/hubspot/docs/first-call.cjs @@ -0,0 +1,6 @@ +'use strict'; + +module.exports = async function firstCall(Api, accessToken) { + const api = new Api({access_token: accessToken}); + return api.listContacts({limit: 1, properties: ['email']}); +}; diff --git a/packages/v1-ready/hubspot/docs/getting-started.md b/packages/v1-ready/hubspot/docs/getting-started.md new file mode 100644 index 00000000..a1b6012d --- /dev/null +++ b/packages/v1-ready/hubspot/docs/getting-started.md @@ -0,0 +1,39 @@ +# First successful call + +Install `{{PACKAGE}}@{{VERSION}}` with a compatible Frigg dependency +(`{{FRIGG_RANGE}}`). Obtain an OAuth access token for an account whose app has +`crm.objects.contacts.read`, following [OAuth setup](authentication.md). + +Set `HUBSPOT_ACCESS_TOKEN` in your shell or secret manager, then run this read-only +example in a Node application: + +```javascript +const {Api} = require('{{PACKAGE}}'); + +async function main() { + const api = new Api({access_token: process.env.HUBSPOT_ACCESS_TOKEN}); + const page = await api.listContacts({limit: 1, properties: ['email']}); + console.log(page.results.map(contact => contact.id)); +} + +main().catch(() => { + console.error('HubSpot contact read failed'); + process.exitCode = 1; +}); +``` + +A successful call resolves to one provider response, normally with `results` and +an optional `paging.next.after` cursor. An empty account can return an empty +`results` array. Providing `properties` avoids the module's additional property +discovery request. This example uses an already-issued token; it does not exchange +an authorization code or configure automatic refresh persistence. + +The adjacent `first-call.cjs` is used by the offline fixture test. That check +exercises this module's method with a fake Requester and verifies URL and query +selection, omitted first-page cursors, and the returned fixture. It does not test +a live HubSpot account or the installed Frigg OAuth implementation. + +For a Frigg application, register `Definition` through the application's module +binding and use the managed authorization flow so credentials can be persisted. +Treat access tokens and contact data as secrets or customer data; avoid logging +responses from production accounts merely to verify a connection. diff --git a/packages/v1-ready/hubspot/docs/operations.md b/packages/v1-ready/hubspot/docs/operations.md new file mode 100644 index 00000000..12ccee80 --- /dev/null +++ b/packages/v1-ready/hubspot/docs/operations.md @@ -0,0 +1,66 @@ +# Operating behavior and limitations + +## Pagination, rate limits and errors + +The wrapper generally returns one provider response per call. For contacts, pass +`limit`, `properties`, and then the previous page's `paging.next.after` to +`listContacts()`. The first page omits an empty cursor. Passing `properties` +avoids an extra property-discovery request. `listDeals()` merges the supplied +page object into its query and fetches all deal properties before the page request. +Some methods accept query strings rather than objects; use the exact signature in +the [reference](reference.md). There is no universal auto-pagination helper. + +This module defines no provider-specific rate-limit policy. The installed Frigg +Requester handles HTTP transport and any generic retries. Apply bounded retries +and provider-aware pacing in the application, especially around writes and batch +calls. Do not infer newer Frigg behavior from this package's declared dependency +range. HubSpot scope, product entitlement, quota and transport errors can reject +calls. Returned shapes and empty-body behavior vary by method; see the reference +and [HubSpot API usage guidance](https://developers.hubspot.com/docs/developer-tooling/platform/usage-guidelines). + +## Webhook extension + +The package exports `extensions.webhooks`, a reusable integration extension with +a DB-free receiver and queued portal-to-integration resolution. Bind it using +the Frigg extension contract: + +```javascript +extensions: { + hubspotWebhooks: { + extension: hubspot.extensions.webhooks, + handlers: {HUBSPOT_WEBHOOK: 'onHubSpotEvent'}, + }, +} +``` + +The consuming integration must provide `createFriggCommands({integrationClass})` +as its `commands`, a queue worker, a HubSpot module binding and an idempotent +`onHubSpotEvent({data})` handler. The complete source-package README includes the +integration class example. This fragment is a binding, not a standalone app. + +Register the app-level target URL in HubSpot, including the integration name and +binding key: `POST /api/-integration/hubspotWebhooks/webhooks`. +Set `HUBSPOT_CLIENT_SECRET` on the receiver. The shipped verifier requires the v3 +signature and timestamp headers, computes HMAC-SHA256 over method, reconstructed +URI, raw body and timestamp, compares the base64 signature, and allows at most +five minutes of timestamp skew in either direction. Preserve the raw request +body and correct public URI through the HTTP adapter. Do not disable validation +to accommodate a proxy mismatch. See +[HubSpot request validation](https://developers.hubspot.com/docs/apps/developer-platform/build-apps/authentication/request-validation). + +`HUBSPOT_WEBHOOK_RECEIVED` verifies the request and queues resolution per event. +The worker resolves `portalId` to the owning integration and queues +`HUBSPOT_WEBHOOK` with that integration's credentials. Missing portals or unmatched +integrations are skipped; ambiguous integration resolution throws. Delivery may +repeat: deduplicate business operations using event identifiers or a stable +composite key. This package ships no separate provider-side project artifact. + +## Known source limitations + +The pilot records the actual wrapper rather than promising every exported method +is production-ready. In this source, `updateDeal()` references a URL key that is +not defined, `createTimelineEvent()` passes positional arguments to an options +object helper, and `getCustomObjectSchema()` ignores its `query` argument. +Legacy contact-list and owner endpoints coexist with newer APIs. Review the +release-specific reference before use. Fixing these behaviors needs separate code +changes and validation; this documentation work does not silently change them. diff --git a/packages/v1-ready/hubspot/docs/reference.json b/packages/v1-ready/hubspot/docs/reference.json new file mode 100644 index 00000000..35ced538 --- /dev/null +++ b/packages/v1-ready/hubspot/docs/reference.json @@ -0,0 +1,447 @@ +{ + "getAuthUri": { + "description": "Returns the authorization URI constructed from the instance OAuth parameters. Does not validate state or handle the callback.", + "parameters": "No parameters are accepted.", + "returns": "A synchronous authorization URL string." + }, + "addJsonHeaders": { + "description": "Adds JSON Content-Type and Accept defaults, preserving explicitly provided headers. Used internally by write helpers.", + "parameters": "options: request options object to mutate.", + "returns": "Undefined; mutates options.headers in place." + }, + "createCompany": { + "description": "Create company. Delegates to `_post`.", + "parameters": "body: a property-name to value map; the wrapper sends {properties: body}.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "listCompanies": { + "description": "List companies. Delegates to `_get`.", + "parameters": "No parameters are accepted.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "updateCompany": { + "description": "Update company. Delegates to `_patch`.", + "parameters": "id: company ID. body: full provider update object, including properties; unlike createCompany, no properties wrapper is added.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "searchCompanies": { + "description": "Search companies. Delegates to `_post`.", + "parameters": "Arguments in order: body. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "archiveCompany": { + "description": "Archive company. Delegates to `_delete`.", + "parameters": "Arguments in order: compId. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "getCompanyById": { + "description": "Get company by id. Delegates to `_get`.", + "parameters": "Arguments in order: compId. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "batchGetCompaniesById": { + "description": "Batch get companies by id. Delegates to `_post`.", + "parameters": "params: inputs (required provider batch inputs) and properties (default []); archived=false is included in the query.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "createContact": { + "description": "Create contact. Delegates to `_post`.", + "parameters": "body: a property-name to value map; the wrapper sends {properties: body}.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "listContacts": { + "description": "Lists one contacts page. Discovers contact properties unless properties is supplied; omits after on the first page.", + "parameters": "params: object with limit (default 100), after (optional cursor) and properties (optional array or provider-compatible property selection).", + "returns": "A promise resolving to the contacts response, normally results and optional paging.next.after." + }, + "archiveContact": { + "description": "Archive contact. Delegates to `_delete`.", + "parameters": "Arguments in order: id. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "getContactById": { + "description": "Get contact by id. Delegates to `_get`.", + "parameters": "Arguments in order: contactId. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "updateContact": { + "description": "Update contact. Delegates to `_patch`.", + "parameters": "contactId: contact ID. properties: property-name to value map, wrapped in {properties}.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "batchGetContactsById": { + "description": "Batch get contacts by id. Delegates to `_post`.", + "parameters": "Arguments in order: body. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "createDeal": { + "description": "Create deal. Delegates to `_post`.", + "parameters": "body: a property-name to value map; the wrapper sends {properties: body}.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "archiveDeal": { + "description": "Archive deal. Delegates to `_delete`.", + "parameters": "Arguments in order: dealId. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "getDealById": { + "description": "Get deal by id. Delegates to `_get`.", + "parameters": "Arguments in order: dealId. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "getDealStageHistory": { + "description": "Get deal stage history. Delegates to `_get`.", + "parameters": "Arguments in order: dealId. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise resolving to response.properties.dealstage.versions from the legacy deals endpoint." + }, + "listDeals": { + "description": "List deals. Delegates to `_get`.", + "parameters": "pageObj: optional provider page/query object, merged into a query that requests all deal properties and contacts/companies associations.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "searchDeals": { + "description": "Search deals. Delegates to `_post`.", + "parameters": "params: allProps (default true), props (default []), limit (10), after (0), filterGroups ([]) and sorts ([]). An empty props array is filled via property discovery when allProps is true.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "updateDeal": { + "description": "Attempts to update deal properties. Known limitation: this source uses the undefined URLs.getDealById key and fails before dispatch.", + "parameters": "params: object requiring dealId and properties. Use only after verifying a release that fixes the URL key.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "createContactList": { + "description": "Create contact list. Delegates to `_post`.", + "parameters": "Arguments in order: body. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "deleteContactList": { + "description": "Delete contact list. Delegates to `_delete`.", + "parameters": "Arguments in order: listId. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "getContactListById": { + "description": "Get contact list by id. Delegates to `_get`.", + "parameters": "Arguments in order: listId. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "listContactLists": { + "description": "List contact lists. Delegates to `_get`.", + "parameters": "No parameters are accepted.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "updateContactList": { + "description": "Update contact list. Delegates to `_post`.", + "parameters": "Arguments in order: listId, body. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "createCustomObjectSchema": { + "description": "Create custom object schema. Delegates to `_post`.", + "parameters": "Arguments in order: body. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "deleteCustomObjectSchema": { + "description": "Delete custom object schema. Delegates to `_delete`.", + "parameters": "Arguments in order: objectType, hardDelete. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "getCustomObjectSchema": { + "description": "Fetches a custom object schema. Known limitation: the query argument is accepted but ignored.", + "parameters": "Arguments in order: objectType, query. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "listCustomObjectSchemas": { + "description": "List custom object schemas. Delegates to `_get`.", + "parameters": "No parameters are accepted.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "updateCustomObjectSchema": { + "description": "Update custom object schema. Delegates to `_patch`.", + "parameters": "Arguments in order: objectType, body. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "createCustomObject": { + "description": "Create custom object. Delegates to `_post`.", + "parameters": "Arguments in order: objectType, body. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "bulkCreateCustomObjects": { + "description": "Bulk create custom objects. Delegates to `_post`.", + "parameters": "Arguments in order: objectType, body. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "deleteCustomObject": { + "description": "Delete custom object. Delegates to `_delete`.", + "parameters": "Arguments in order: objectType, objId. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "bulkArchiveCustomObjects": { + "description": "Bulk archive custom objects. Delegates to `_request`.", + "parameters": "Arguments in order: objectType, body. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "getCustomObject": { + "description": "Get custom object. Delegates to `_get`.", + "parameters": "Arguments in order: objectType, objId. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "bulkReadCustomObjects": { + "description": "Bulk read custom objects. Delegates to `_post`.", + "parameters": "Arguments in order: objectType, body. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "listCustomObjects": { + "description": "List custom objects. Delegates to `_get`.", + "parameters": "Arguments in order: objectType, query = {}. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "searchCustomObjects": { + "description": "Search custom objects. Delegates to `_post`.", + "parameters": "Arguments in order: objectType, body. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "updateCustomObject": { + "description": "Update custom object. Delegates to `_patch`.", + "parameters": "Arguments in order: objectType, objId, body. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "bulkUpdateCustomObjects": { + "description": "Bulk update custom objects. Delegates to `_post`.", + "parameters": "Arguments in order: objectType, body. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "getProperties": { + "description": "Legacy alias for listProperties(objType).", + "parameters": "Arguments in order: objType. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise resolving to the same provider properties response as listProperties." + }, + "listProperties": { + "description": "List properties. Delegates to `_get`.", + "parameters": "Arguments in order: objType. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "createProperty": { + "description": "Create property. Delegates to `_post`.", + "parameters": "Arguments in order: objType, body. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "deleteProperty": { + "description": "Delete property. Delegates to `_delete`.", + "parameters": "Arguments in order: objType, propName. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "getPropertyByName": { + "description": "Get property by name. Delegates to `_get`.", + "parameters": "Arguments in order: objType, propName. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "updateProperty": { + "description": "Update property. Delegates to `_patch`.", + "parameters": "Arguments in order: objType, propName, body. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "getOwnerById": { + "description": "Get owner by id. Delegates to `_get`.", + "parameters": "Arguments in order: ownerId. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "createTimelineEvent": { + "description": "Builds an event template payload. Known limitation: passes positional arguments to an options-object _post helper; do not rely on this release for timeline event creation.", + "parameters": "Arguments in order: objId, data, eventTemplateId = process.env.HUBSPOT_TIMELINE_EVENT_TEMPLATE_ID. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "getLandingPages": { + "description": "Get landing pages. Delegates to `_get`.", + "parameters": "Arguments in order: query = ''. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "getLandingPage": { + "description": "Get landing page. Delegates to `_get`.", + "parameters": "Arguments in order: id. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "updateLandingPage": { + "description": "Update landing page. Delegates to `_patch`.", + "parameters": "Arguments in order: objId, body, isDraft = false. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "pushLandingPageDraftToLive": { + "description": "Push landing page draft to live. Delegates to `_post`.", + "parameters": "Arguments in order: objId. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "publishLandingPage": { + "description": "Publish landing page. Delegates to `_post`.", + "parameters": "Arguments in order: objId, publishDate. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "getSitePages": { + "description": "Get site pages. Delegates to `_get`.", + "parameters": "Arguments in order: query = ''. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "getSitePage": { + "description": "Get site page. Delegates to `_get`.", + "parameters": "Arguments in order: id. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "updateSitePage": { + "description": "Update site page. Delegates to `_patch`.", + "parameters": "Arguments in order: objId, body, isDraft = false. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "pushSitePageDraftToLive": { + "description": "Push site page draft to live. Delegates to `_post`.", + "parameters": "Arguments in order: objId. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "publishSitePage": { + "description": "Publish site page. Delegates to `_post`.", + "parameters": "Arguments in order: objId, publishDate. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "getBlogPosts": { + "description": "Get blog posts. Delegates to `_get`.", + "parameters": "Arguments in order: query = ''. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "getBlogPost": { + "description": "Get blog post. Delegates to `_get`.", + "parameters": "Arguments in order: id. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "updateBlogPost": { + "description": "Update blog post. Delegates to `_patch`.", + "parameters": "Arguments in order: objId, body, isDraft = false. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "pushBlogPostDraftToLive": { + "description": "Push blog post draft to live. Delegates to `_post`.", + "parameters": "Arguments in order: objId. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "publishBlogPost": { + "description": "Publish blog post. Delegates to `_post`.", + "parameters": "Arguments in order: objId, publishDate. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "getEmailTemplates": { + "description": "Get email templates. Delegates to `_get`.", + "parameters": "Arguments in order: query = ''. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "getEmailTemplate": { + "description": "Get email template. Delegates to `_get`.", + "parameters": "Arguments in order: id. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "updateEmailTemplate": { + "description": "Update email template. Delegates to `_put`.", + "parameters": "Arguments in order: objId, body. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "createEmailTemplate": { + "description": "Create email template. Delegates to `_post`.", + "parameters": "Arguments in order: body. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "deleteEmailTemplate": { + "description": "Delete email template. Delegates to `_delete`.", + "parameters": "Arguments in order: id. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "getUserDetails": { + "description": "Fetches integration account details, introspects the access token, and merges the two responses. Requires OAuth client credentials.", + "parameters": "No parameters are accepted.", + "returns": "A promise resolving to the merged account/introspection object. Rejects if the introspected token is inactive." + }, + "introspectToken": { + "description": "Posts client_id, client_secret, access_token and the access_token hint as form data to the date-versioned introspection endpoint.", + "parameters": "No parameters are accepted.", + "returns": "A promise resolving to an active introspection response; throws when active is falsy." + }, + "getPipelineDetails": { + "description": "Get pipeline details. Delegates to `_get`.", + "parameters": "Arguments in order: objType. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "getBatchAssociations": { + "description": "Get batch associations. Delegates to `_post`.", + "parameters": "Arguments in order: fromObjectType, toObjectType, inputs. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise resolving to the response results array, rather than the entire provider response." + }, + "createBatchAssociations": { + "description": "Create batch associations. Delegates to `_post`.", + "parameters": "Arguments in order: fromObjectType, toObjectType, inputs. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise resolving to the response results array, rather than the entire provider response." + }, + "createBatchAssociationsDefault": { + "description": "Create batch associations default. Delegates to `_post`.", + "parameters": "Arguments in order: fromObjectType, toObjectType, inputs. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise resolving to the response results array, rather than the entire provider response." + }, + "deleteBatchAssociations": { + "description": "Delete batch associations. Delegates to `_post`.", + "parameters": "Arguments in order: fromObjectType, toObjectType, inputs. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise resolving to the full HTTP response because this method sets returnFullRes." + }, + "deleteBatchAssociationLabels": { + "description": "Delete batch association labels. Delegates to `_post`.", + "parameters": "Arguments in order: fromObjectType, toObjectType, inputs. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise resolving to the full HTTP response because this method sets returnFullRes." + }, + "getAssociationLabels": { + "description": "Get association labels. Delegates to `_get`.", + "parameters": "Arguments in order: fromObjType, toObjType. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "createAssociationLabel": { + "description": "Create association label. Delegates to `_post`.", + "parameters": "Arguments in order: fromObjType, toObjType, label. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "deleteAssociationLabel": { + "description": "Delete association label. Delegates to `_delete`.", + "parameters": "Arguments in order: fromObjType, toObjType, associationTypeId. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "searchLists": { + "description": "Search lists. Delegates to `_post`.", + "parameters": "Arguments in order: query = \"\", offset = 0, count = 500, additionalProperties = []. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "getListById": { + "description": "Get list by id. Delegates to `_get`.", + "parameters": "Arguments in order: listId. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "createList": { + "description": "Create list. Delegates to `_post`.", + "parameters": "Arguments in order: name, objectTypeId, processingType = 'MANUAL', listFolderId = null. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "deleteList": { + "description": "Delete list. Delegates to `_delete`.", + "parameters": "Arguments in order: listId. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "removeAllListMembers": { + "description": "Remove all list members. Delegates to `_delete`.", + "parameters": "Arguments in order: listId. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "addAndRemoveFromList": { + "description": "Add and remove from list. Delegates to `_put`.", + "parameters": "Arguments in order: listId, idsToAdd, idsToRemove. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "addToList": { + "description": "Add to list.", + "parameters": "Arguments in order: listId, recordIds. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + }, + "removeFromList": { + "description": "Remove from list.", + "parameters": "Arguments in order: listId, recordIds. IDs identify HubSpot records; body/query objects follow the selected provider endpoint.", + "returns": "A promise from the Frigg Requester. Provider response fields are not normalized unless explicitly noted; consult the linked source and HubSpot API reference." + } +} diff --git a/packages/v1-ready/hubspot/module-docs.json b/packages/v1-ready/hubspot/module-docs.json new file mode 100644 index 00000000..1502ad92 --- /dev/null +++ b/packages/v1-ready/hubspot/module-docs.json @@ -0,0 +1,17 @@ +{ + "schemaVersion": 1, + "id": "hubspot", + "name": "HubSpot", + "description": "Connect HubSpot OAuth accounts to Frigg, call CRM and CMS methods, and route signed app webhooks to per-account integrations.", + "provider": "HubSpot", + "category": "CRM", + "maintainers": ["Frigg Framework maintainers"], + "status": "maintained", + "capabilities": ["CRM contacts, companies and deals", "Custom objects and properties", "Lists and associations", "CMS pages and templates", "OAuth token introspection", "Signed webhook routing"], + "extensions": ["webhooks"], + "testedCompatibility": [], + "links": { + "provider": "https://developers.hubspot.com/docs", + "support": "https://github.com/friggframework/api-module-library/issues" + } +} diff --git a/packages/v1-ready/hubspot/package.json b/packages/v1-ready/hubspot/package.json index d04b15dd..c1c122b6 100644 --- a/packages/v1-ready/hubspot/package.json +++ b/packages/v1-ready/hubspot/package.json @@ -6,7 +6,8 @@ "main": "index.js", "scripts": { "lint:fix": "prettier --write --loglevel error . && eslint . --fix", - "test": "jest" + "test": "jest", + "prepack": "node ../../../tools/module-docs/build.cjs ." }, "author": "", "license": "MIT", diff --git a/tools/module-docs/README.md b/tools/module-docs/README.md new file mode 100644 index 00000000..a71b6485 --- /dev/null +++ b/tools/module-docs/README.md @@ -0,0 +1,75 @@ +# Module documentation producer pilot + +Implements the producer half of Frigg ADR-065 for +`packages/v1-ready/hubspot` only. The matching receiver and setup instructions live +in `friggframework/frigg/tools/module-docs/README.md`. + +## Authoring + +`module-docs.json` is validated against `descriptor.schema.json`. Package name, +version, license and declared Node/Frigg compatibility come from package.json; +do not duplicate them in the descriptor. `testedCompatibility` is empty unless +specific runtime versions have actually been tested. Exported extensions must +agree with the package index. HubSpot's existing API, authorization and webhook +code is unchanged by this pilot. + +The five authored pages and `reference.json` cover setup, OAuth, first call, +operating behavior, extensions, known limitations, release changes and support. +The builder creates the sixth page from public method signatures and those +annotations. This pilot supports the package's current class-method syntax; +extend the extractor and runtime-inventory test before introducing other syntax. +Annotations are authored facts, not inferred provider schemas. The first-call +fixture checks `listContacts` with a fake Requester, including the first-page +cursor and explicit properties. It does not test live HubSpot or OAuth refresh. + +## Exact release bundles + +The package's `prepack` hook builds `module-docs.bundle.json` after versioning and +before the package is packed. `.npmignore` deliberately includes the generated +bundle even though Git ignores it. Committed package source is checked against +`GITHUB_SHA` (or HEAD locally); auto may change only the package version and +changelog. The bundle records the source input SHA and exact resolved package +metadata so temporary prerelease commits/reset do not lose version provenance. + +`contract.cjs` and `registry.cjs` are byte-identical to the receiver copies. The +published npm tarball is the durable bundle store. Its sha512 integrity is added +by the receiver after publication rather than creating a circular hash in the +package. JSON canonicalization makes bundle digests repeatable. + +The local auto plugin clears old candidate state before shipit and records only +candidates from successful non-canary runs. A no-op run emits an empty list. +The separate delivery job retrieves each exact npm version, verifies integrity +and candidate digest, then dispatches the package/version to Frigg. Canary docs +are built and validated but are not dispatched. Failed dispatches do not rerun +shipit or republish packages. + +`MODULE_DOCS_ENABLED` is off by default. When enabling, configure the destination +workflow on its default branch, App permissions, variable and secret described +in the receiver README. The stable release branch needs the same producer before +stable releases can carry bundles. Never backfill an old npm release by generating +docs from the current branch. Immutable npm versions lacking a bundle are not +eligible for this pilot's import path. + +## Verification and retry + +```bash +node --test tools/module-docs/*.test.cjs +npm pack --workspace @friggframework/api-module-hubspot --pack-destination /tmp +``` + +Packing performs source checks; commit authored sources first. No dependency +installation or live credentials are needed for these checks. It does not publish +a package. CI runs these checks on changes to the pilot and producer tooling. + +After publication, use **Retry module documentation delivery** with the exact +package/version. It verifies npm and dispatches again, never calls npm publish. +The workflow must also exist on the default branch for manual dispatch. Delivery +results are recorded in `.module-docs/delivery.json` and an Actions artifact. A +dispatch success means accepted by GitHub, not yet imported, merged or published. +After transient Actions artifacts expire, the npm-embedded bundle remains the +source for manual retry. The destination's release manifests record successful +imports durably in Git. + +Use `[skip release]` in a tooling-only commit to suppress the repository's release +job while keeping documentation checks runnable. Do not merge this pilot to a +release branch with that marker if a package publication is intended. diff --git a/tools/module-docs/auto-plugin.cjs b/tools/module-docs/auto-plugin.cjs new file mode 100644 index 00000000..cf795866 --- /dev/null +++ b/tools/module-docs/auto-plugin.cjs @@ -0,0 +1,24 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); + +// No remote writes here. A separate job verifies npm publication and dispatches +// with a short-lived App token. Successful shipit with no publication emits none. +module.exports = class ModuleDocsPlugin { + name = 'frigg-module-docs'; + apply(auto) { + const directory = path.resolve('.module-docs'); + auto.hooks.beforeShipIt.tapPromise(this.name, async () => { + fs.rmSync(directory, {recursive: true, force: true}); + fs.mkdirSync(directory, {recursive: true}); + fs.writeFileSync(path.join(directory, 'published.json'), '[]\n'); + }); + auto.hooks.afterShipIt.tapPromise(this.name, async ({dryRun, context, newVersion}) => { + if (dryRun || context === 'canary' || !newVersion) return; + const candidates = path.join(directory, 'candidates'); + const records = fs.existsSync(candidates) ? fs.readdirSync(candidates).sort().map(x => JSON.parse(fs.readFileSync(path.join(candidates, x), 'utf8'))) : []; + fs.writeFileSync(path.join(directory, 'published.json'), `${JSON.stringify(records, null, 2)}\n`); + }); + } +}; diff --git a/tools/module-docs/build.cjs b/tools/module-docs/build.cjs new file mode 100644 index 00000000..7a038b57 --- /dev/null +++ b/tools/module-docs/build.cjs @@ -0,0 +1,94 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const assert = require('node:assert/strict'); +const {execFileSync} = require('node:child_process'); +const {canonical, digest, validateBundle, validateSchema, REQUIRED_PAGES} = require('./contract.cjs'); +const schema = require('./descriptor.schema.json'); +const modules = require('./modules.json'); + +function build(packageDir, options = {}) { + const root = options.root || execFileSync('git', ['rev-parse', '--show-toplevel'], {cwd: packageDir, encoding: 'utf8'}).trim(); + const read = filename => JSON.parse(fs.readFileSync(path.join(packageDir, filename), 'utf8')); + const pkg = read('package.json'); + const allowed = modules.find(x => x.package === pkg.name); + assert(allowed && path.resolve(root, allowed.packagePath) === path.resolve(packageDir), 'Package is not enrolled'); + const descriptor = read('module-docs.json'); + validateSchema(descriptor, schema); + assert.equal(descriptor.id, allowed.id); + const commit = options.commit || process.env.GITHUB_SHA || execFileSync('git', ['rev-parse', 'HEAD'], {cwd: root, encoding: 'utf8'}).trim(); + assert(/^[a-f0-9]{40}$/.test(commit), 'Expected source commit'); + // GITHUB_SHA survives auto/lerna's temporary prerelease-version commit/reset. + // Record the exact resolved metadata in addition to this source input commit. + // Reject uncommitted changes to code/docs; package version and changelog may be + // changed by the release tool, but generated docs must match committed source. + if (!options.skipSourceCheck) { + const check = filename => { + const source = execFileSync('git', ['show', `${commit}:${allowed.packagePath}/${filename}`], {cwd: root}); + assert(source.equals(fs.readFileSync(path.join(packageDir, filename))), `Source differs from ${commit}: ${filename}`); + }; + const tracked = execFileSync('git', ['ls-tree', '-r', '--name-only', commit, allowed.packagePath], {cwd: root, encoding: 'utf8'}).trim().split('\n'); + for (const filename of tracked.map(x => x.slice(allowed.packagePath.length + 1)).filter(x => !['package.json', 'CHANGELOG.md'].includes(x))) check(filename); + const sourcePkg = JSON.parse(execFileSync('git', ['show', `${commit}:${allowed.packagePath}/package.json`], {cwd: root, encoding: 'utf8'})); + assert.deepEqual({...sourcePkg, version: pkg.version}, pkg, 'Release changed package metadata beyond its version'); + } + const metadata = {}; + for (const key of ['name', 'version', 'license', 'engines', 'dependencies', 'peerDependencies']) if (pkg[key] !== undefined) metadata[key] = pkg[key]; + const source = {repository: 'friggframework/api-module-library', commit, packagePath: allowed.packagePath}; + const sourceUrl = `https://github.com/${source.repository}/tree/${commit}/${source.packagePath}`; + const index = fs.readFileSync(path.join(packageDir, 'index.js'), 'utf8'); + const extensionBlock = index.match(/extensions:\s*\{([^}]*)\}/)?.[1] || ''; + const extensionNames = extensionBlock.split(',').map(x => x.trim()).filter(Boolean); + assert(extensionNames.every(x => /^[a-zA-Z]\w*$/.test(x)), 'Pilot supports shorthand extension exports; extend the extractor before changing export syntax'); + assert.deepEqual([...descriptor.extensions].sort(), extensionNames.sort(), 'Descriptor extensions disagree with index.js'); + const replacements = {'{{PACKAGE}}': pkg.name, '{{VERSION}}': pkg.version, '{{FRIGG_RANGE}}': pkg.peerDependencies?.['@friggframework/core'] || pkg.dependencies?.['@friggframework/core'] || 'unknown', '{{NODE_RANGE}}': pkg.engines?.node || 'unknown'}; + const files = {}; + for (const filename of REQUIRED_PAGES.filter(x => x !== 'reference.md')) { + let content = fs.readFileSync(path.join(packageDir, 'docs', filename), 'utf8'); + for (const [from, to] of Object.entries(replacements)) content = content.replaceAll(from, to); + assert(!/\{\{[^}]+\}\}/.test(content), 'Unknown documentation template field'); + files[filename] = {encoding: 'utf8', content}; + } + const api = fs.readFileSync(path.join(packageDir, 'api.js'), 'utf8'); + const publicMethods = [...api.matchAll(/^ (?:async )?([A-Za-z]\w*)\(([^)]*)\)\s*\{/gm)].filter(x => x[1] !== 'constructor'); + const reference = read('docs/reference.json'); + assert.deepEqual(Object.keys(reference).sort(), publicMethods.map(x => x[1]).sort(), 'Reference annotations must cover every public method'); + const sections = publicMethods.map(([, method, args]) => { + const entry = reference[method]; + for (const field of ['description', 'parameters', 'returns']) assert(typeof entry[field] === 'string' && entry[field].length > 10, `Missing ${method}.${field}`); + return `## ${method}\n\n\`${method}(${args.replace(/\s+/g, ' ').trim()})\`\n\n${entry.description}\n\nParameters: ${entry.parameters}\n\nReturns: ${entry.returns}\n`; + }); + files['reference.md'] = {encoding: 'utf8', content: `# API reference\n\nGenerated from public methods in [the released source](${sourceUrl}/api.js) and reviewed annotations. Most responses are provider JSON passed through the Frigg Requester; exceptions are described per method. This module does not define typed response schemas. Inherited OAuth methods are documented by the installed Frigg runtime.\n\n${sections.join('\n')}\nSee [operating behavior](operations.md) for errors and pagination.\n`}; + const navigation = REQUIRED_PAGES.map(filename => ({path: filename, title: files[filename].content.match(/^# (.+)$/m)[1]})); + const generatorSources = ['build.cjs', 'contract.cjs', 'descriptor.schema.json'].map(x => fs.readFileSync(path.join(__dirname, x), 'utf8')); + const bundle = { + schemaVersion: 1, + manifest: {id: descriptor.id, package: pkg.name, version: pkg.version, + channel: require('./contract.cjs').semver(pkg.version).pre.length ? 'prerelease' : 'stable', + source, packageMetadata: metadata, + generator: {name: 'frigg-module-docs', version: '1.0.0', digest: digest(generatorSources)}}, + catalog: {identity: {id: descriptor.id, package: pkg.name, version: pkg.version, kind: 'api-module'}, + name: descriptor.name, description: descriptor.description, provider: descriptor.provider, category: descriptor.category, + license: pkg.license, maintainers: descriptor.maintainers, status: descriptor.status, + capabilities: descriptor.capabilities, extensions: descriptor.extensions, + compatibility: {node: pkg.engines?.node || null, frigg: replacements['{{FRIGG_RANGE}}'] === 'unknown' ? null : replacements['{{FRIGG_RANGE}}'], tested: descriptor.testedCompatibility}, + links: {...descriptor.links, source: sourceUrl, releaseNotes: `${sourceUrl}/CHANGELOG.md`}}, + files, navigation, + }; + validateBundle(bundle, allowed); + return bundle; +} + +function writeCandidate(packageDir) { + const bundle = build(packageDir); + const root = execFileSync('git', ['rev-parse', '--show-toplevel'], {cwd: packageDir, encoding: 'utf8'}).trim(); + fs.writeFileSync(path.join(packageDir, 'module-docs.bundle.json'), `${canonical(bundle)}\n`); + const ledger = path.join(root, '.module-docs', 'candidates'); + fs.mkdirSync(ledger, {recursive: true}); + fs.writeFileSync(path.join(ledger, `${bundle.manifest.id}-${bundle.manifest.version}.json`), JSON.stringify({package: bundle.manifest.package, version: bundle.manifest.version, digest: digest(bundle)})); + console.log(`Built ${bundle.manifest.package}@${bundle.manifest.version} documentation`); +} + +module.exports = {build, writeCandidate}; +if (require.main === module) writeCandidate(path.resolve(process.argv[2] || process.cwd())); diff --git a/tools/module-docs/build.test.cjs b/tools/module-docs/build.test.cjs new file mode 100644 index 00000000..4efe5faf --- /dev/null +++ b/tools/module-docs/build.test.cjs @@ -0,0 +1,121 @@ +'use strict'; + +const {test} = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const vm = require('node:vm'); +const {build} = require('./build.cjs'); +const {digest} = require('./contract.cjs'); +const {deliver} = require('./deliver.cjs'); +const {registryFetch} = require('./fixtures.cjs'); +const Plugin = require('./auto-plugin.cjs'); +const sourceRoot = path.resolve(__dirname, '../..'); +const packagePath = 'packages/v1-ready/hubspot'; + +function workspace(t) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'module-docs-build-')); + t.after(() => fs.rmSync(root, {recursive: true, force: true})); + fs.cpSync(path.join(sourceRoot, packagePath), path.join(root, packagePath), {recursive: true}); + return {root, directory: path.join(root, packagePath), options: {root, skipSourceCheck: true, commit: 'a'.repeat(40)}}; +} + +test('build resolves exact metadata, includes all 89 public methods and is deterministic', t => { + const {directory, options} = workspace(t); + const first = build(directory, options), second = build(directory, options); + assert.equal(digest(first), digest(second)); + assert.equal(first.catalog.identity.package, '@friggframework/api-module-hubspot'); + assert.deepEqual(first.catalog.extensions, ['webhooks']); + assert.equal(first.catalog.compatibility.node, null); + assert.equal((first.files['reference.md'].content.match(/^## /gm) || []).length, 89); + assert(first.files['README.md'].content.includes(`@${first.manifest.version}`)); + assert.match(first.files['reference.md'].content, /Known limitation/); +}); + +test('reference matches runtime public methods and first-call example uses the real wrapper', async () => { + const requests = []; + const fixture = {results: [{id: 'fixture-contact', properties: {email: 'fixture@example.test'}}]}; + class Requester { + constructor(params) { Object.assign(this, params); } + async _get(options) { requests.push(options); return fixture; } + } + const module = {exports: {}}; + vm.runInNewContext(fs.readFileSync(path.join(sourceRoot, packagePath, 'api.js'), 'utf8'), { + module, URLSearchParams, process: {env: {}}, + require: name => { + assert.equal(name, '@friggframework/core'); + return {OAuth2Requester: Requester, get: (object, key, fallback) => object?.[key] ?? fallback}; + }, + }); + const Api = module.exports.Api; + const annotations = require(path.join(sourceRoot, packagePath, 'docs/reference.json')); + const actual = Object.getOwnPropertyNames(Api.prototype).filter(x => x !== 'constructor' && !x.startsWith('_')).sort(); + assert.deepEqual(actual, Object.keys(annotations).sort()); + const firstCall = require(path.join(sourceRoot, packagePath, 'docs/first-call.cjs')); + assert.equal(await firstCall(Api, 'fixture-token'), fixture); + assert.equal(requests.length, 1, 'Explicit properties must avoid discovery request'); + assert.equal(requests[0].url, 'https://api.hubapi.com/crm/v3/objects/contacts'); + assert.equal(JSON.stringify(requests[0].query), JSON.stringify({limit: 1, properties: ['email']})); + assert(!Object.hasOwn(requests[0].query, 'after')); + await new Api({access_token: 'fixture-token'}).listContacts({limit: 1, properties: ['email'], after: 'next-cursor'}); + assert.equal(requests[1].query.after, 'next-cursor'); +}); + +test('schema, coverage, required content and extension mismatch block generation', t => { + const {directory, options} = workspace(t); + const descriptorPath = path.join(directory, 'module-docs.json'); + const descriptor = JSON.parse(fs.readFileSync(descriptorPath)); + fs.writeFileSync(descriptorPath, JSON.stringify({...descriptor, version: '9.9.9'})); + assert.throws(() => build(directory, options), /Unknown descriptor.version/); + fs.writeFileSync(descriptorPath, JSON.stringify({...descriptor, extensions: []})); + assert.throws(() => build(directory, options), /extensions disagree/); + fs.writeFileSync(descriptorPath, JSON.stringify(descriptor)); + const reference = path.join(directory, 'docs/reference.json'); + const records = JSON.parse(fs.readFileSync(reference)); delete records.listContacts; + fs.writeFileSync(reference, JSON.stringify(records)); + assert.throws(() => build(directory, options), /cover every public method/); +}); + +test('source verification cannot accept a fabricated commit', t => { + const {directory, root} = workspace(t); + assert.throws(() => build(directory, {root, commit: 'b'.repeat(40)})); +}); + +test('auto plugin emits only candidate identities from a successful non-canary publication', async t => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'module-docs-plugin-')); + t.after(() => fs.rmSync(root, {recursive: true, force: true})); + const before = process.cwd(); process.chdir(root); + try { + const callbacks = {}; + new Plugin().apply({hooks: { + beforeShipIt: {tapPromise: (_, fn) => { callbacks.before = fn; }}, + afterShipIt: {tapPromise: (_, fn) => { callbacks.after = fn; }}, + }}); + await callbacks.before(); + const ledger = path.join(root, '.module-docs'); + assert.deepEqual(JSON.parse(fs.readFileSync(path.join(ledger, 'published.json'))), []); + fs.mkdirSync(path.join(ledger, 'candidates')); + const candidate = {package: '@friggframework/api-module-hubspot', version: '2.0.0', digest: 'c'.repeat(64)}; + fs.writeFileSync(path.join(ledger, 'candidates/hubspot-2.0.0.json'), JSON.stringify(candidate)); + for (const args of [{context: 'canary', newVersion: '2.0.0'}, {context: 'latest', dryRun: true, newVersion: '2.0.0'}, {context: 'next'}]) { + await callbacks.after(args); + assert.deepEqual(JSON.parse(fs.readFileSync(path.join(ledger, 'published.json'))), []); + } + await callbacks.after({context: 'next', newVersion: 'module@2.0.0'}); + assert.deepEqual(JSON.parse(fs.readFileSync(path.join(ledger, 'published.json'))), [candidate]); + await callbacks.before(); + assert(!fs.existsSync(path.join(ledger, 'candidates')), 'Next run must not replay stale candidates'); + } finally { process.chdir(before); } +}); + +test('delivery verifies npm, detects candidate mismatch, and retries independently of release', async t => { + const {directory, options} = workspace(t), value = build(directory, options); + const record = {package: value.manifest.package, version: value.manifest.version, digest: digest(value)}; + let requests = 0; + const fetch = registryFetch(value, {dispatch: () => { requests++; return new Response(null, {status: requests === 1 ? 503 : 204}); }}); + await assert.rejects(deliver(record, {fetch, dispatch: true, token: 'test'}), /dispatch failed/); + assert.equal((await deliver(record, {fetch, dispatch: true, token: 'test'})).digest, record.digest); + assert.equal(requests, 2); + await assert.rejects(deliver({...record, digest: 'd'.repeat(64)}, {fetch}), /differs from release candidate/); +}); diff --git a/tools/module-docs/contract.cjs b/tools/module-docs/contract.cjs new file mode 100644 index 00000000..e11aa0a1 --- /dev/null +++ b/tools/module-docs/contract.cjs @@ -0,0 +1,159 @@ +'use strict'; + +// Wire contract v1. Keep this file byte-identical in api-module-library. +const {createHash} = require('node:crypto'); +const path = require('node:path').posix; +const assert = require('node:assert/strict'); + +const VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/; +const ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const MAX_BUNDLE_BYTES = 2 * 1024 * 1024; +const REQUIRED_PAGES = ['README.md', 'getting-started.md', 'authentication.md', 'reference.md', 'operations.md', 'changes.md']; + +function canonical(value) { + if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`; + if (value && typeof value === 'object') { + return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`; + } + return JSON.stringify(value); +} +const digest = value => createHash('sha256').update(canonical(value)).digest('hex'); + +function semver(value) { + assert.equal(typeof value, 'string', 'Version must be a string'); + const match = VERSION.exec(value); + assert(match, `Invalid semantic version: ${value}`); + const pre = match[4]?.split('.') || []; + assert(!pre.some(x => /^\d+$/.test(x) && x.length > 1 && x.startsWith('0')), 'Leading zero in prerelease'); + return {numbers: match.slice(1, 4).map(BigInt), pre}; +} + +function compareVersions(a, b) { + const x = semver(a), y = semver(b); + for (let i = 0; i < 3; i++) if (x.numbers[i] !== y.numbers[i]) return x.numbers[i] > y.numbers[i] ? 1 : -1; + if (!x.pre.length || !y.pre.length) return Number(!x.pre.length) - Number(!y.pre.length); + for (let i = 0; i < Math.max(x.pre.length, y.pre.length); i++) { + if (x.pre[i] === undefined) return -1; + if (y.pre[i] === undefined) return 1; + if (x.pre[i] === y.pre[i]) continue; + const xn = /^\d+$/.test(x.pre[i]), yn = /^\d+$/.test(y.pre[i]); + if (xn && yn) return BigInt(x.pre[i]) > BigInt(y.pre[i]) ? 1 : -1; + if (xn !== yn) return xn ? -1 : 1; + return x.pre[i] > y.pre[i] ? 1 : -1; + } + return 0; +} + +function safePath(value) { + assert.equal(typeof value, 'string'); + assert(value.length < 200 && /^[a-zA-Z0-9][a-zA-Z0-9._/-]*$/.test(value), `Unsafe path: ${value}`); + assert(!value.split('/').some(p => p === '..' || p === '.' || !p), `Unsafe path: ${value}`); + return value; +} + +function text(value, label) { + assert(typeof value === 'string' && value.trim().length > 0 && value.length <= 1000, `Missing/invalid ${label}`); + assert(!/[\r\n<>\[\]`]/.test(value), `Unsafe display text: ${label}`); +} + +function https(value) { + const url = new URL(value); + assert(url.protocol === 'https:' && !url.username && !url.password, 'Expected public HTTPS URL'); +} + +// Validate the JSON Schema subset used by descriptor.schema.json; unknown keywords +// are rejected so adding schema features cannot silently weaken validation. +function validateSchema(value, schema, label = 'descriptor') { + const supported = new Set(['$schema', 'title', 'type', 'required', 'properties', 'additionalProperties', 'items', 'enum', 'pattern', 'minLength', 'minItems']); + for (const key of Object.keys(schema)) assert(supported.has(key), `Unsupported schema keyword ${key}`); + if (schema.type === 'object') { + assert(value && typeof value === 'object' && !Array.isArray(value), `${label} must be an object`); + for (const key of schema.required || []) assert(Object.hasOwn(value, key), `${label}.${key} is required`); + for (const [key, item] of Object.entries(value)) { + const child = schema.properties?.[key]; + if (child) validateSchema(item, child, `${label}.${key}`); + else assert(schema.additionalProperties !== false, `Unknown ${label}.${key}`); + } + } else if (schema.type === 'array') { + assert(Array.isArray(value), `${label} must be an array`); + assert(value.length >= (schema.minItems || 0), `${label} is empty`); + value.forEach((item, i) => validateSchema(item, schema.items, `${label}[${i}]`)); + } else if (schema.type === 'string') { + assert.equal(typeof value, 'string', `${label} must be a string`); + assert(value.length >= (schema.minLength || 0), `${label} is empty`); + if (schema.pattern) assert(new RegExp(schema.pattern).test(value), `Invalid ${label}`); + } else if (schema.type === 'integer') assert(Number.isInteger(value), `${label} must be an integer`); + else assert.fail(`Unsupported schema type ${schema.type}`); + if (schema.enum) assert(schema.enum.includes(value), `Invalid ${label}`); +} + +function validateBundle(bundle, allowed) { + assert(Buffer.byteLength(JSON.stringify(bundle)) <= MAX_BUNDLE_BYTES, 'Bundle too large'); + assert.equal(bundle.schemaVersion, 1); + const {manifest: m, catalog: c, files, navigation} = bundle; + assert(m && c && files && Array.isArray(navigation), 'Incomplete bundle'); + assert(ID.test(m.id)); + semver(m.version); + assert.equal(m.package, allowed.package); + assert.equal(m.id, allowed.id); + assert.equal(m.source.repository, 'friggframework/api-module-library'); + assert.equal(m.source.packagePath, allowed.packagePath); + assert(/^[a-f0-9]{40}$/.test(m.source.commit), 'Expected immutable source commit'); + assert.equal(m.channel, semver(m.version).pre.length ? 'prerelease' : 'stable'); + assert.equal(m.generator.name, 'frigg-module-docs'); + assert.equal(m.generator.version, '1.0.0'); + assert(/^[a-f0-9]{64}$/.test(m.generator.digest), 'Missing generator provenance'); + assert.deepEqual(c.identity, {id: m.id, package: m.package, version: m.version, kind: 'api-module'}); + assert.equal(c.license, m.packageMetadata.license); + assert.equal(m.packageMetadata.name, m.package); + assert.equal(m.packageMetadata.version, m.version); + assert.deepEqual(c.compatibility, { + node: m.packageMetadata.engines?.node || null, + frigg: m.packageMetadata.peerDependencies?.['@friggframework/core'] || m.packageMetadata.dependencies?.['@friggframework/core'] || null, + tested: c.compatibility.tested, + }); + for (const value of [c.compatibility.node, c.compatibility.frigg]) if (value !== null) text(value, 'compatibility range'); + for (const key of ['name', 'description', 'provider', 'category', 'license', 'status']) text(c[key], key); + assert(['experimental', 'maintained', 'deprecated'].includes(c.status)); + assert(Array.isArray(c.maintainers) && c.maintainers.length > 0); + c.maintainers.forEach(x => text(x, 'maintainer')); + assert(Array.isArray(c.capabilities) && Array.isArray(c.extensions) && Array.isArray(c.compatibility.tested)); + [...c.capabilities, ...c.extensions, ...c.compatibility.tested].forEach(x => text(x, 'catalog value')); + https(c.links.provider); https(c.links.support); https(c.links.releaseNotes); + assert.equal(c.links.source, `https://github.com/${m.source.repository}/tree/${m.source.commit}/${m.source.packagePath}`); + const names = Object.keys(files); + assert(names.length <= 80, 'Too many bundle files'); + for (const name of REQUIRED_PAGES) assert(Object.hasOwn(files, name), `Missing ${name}`); + for (const name of names) { + safePath(name); + const file = files[name]; + if (name.endsWith('.md')) { + assert.equal(file.encoding, 'utf8'); + assert(typeof file.content === 'string' && file.content.trim().length >= 80, `Empty page ${name}`); + const prose = file.content.replace(/```[^]*?```/g, ''); + assert(!/\b(TODO|TBD|lorem ipsum)\b/i.test(prose), `Placeholder in ${name}`); + assert(!/<\s*\/?\s*(script|iframe|object|embed|style)\b|\bon\w+\s*=|javascript:|data:/i.test(file.content), `Unsafe Markdown in ${name}`); + // Inline and reference-style links; anchors resolve within the target + // document at publication time, while target file existence is checked here. + const destinations = [...prose.matchAll(/\]\(([^\s)]+)(?:\s+"[^"]*")?\)|^\[[^\]]+\]:\s*(\S+)/gm)]; + for (const match of destinations) { + const href = match[1] || match[2]; + if (href.startsWith('#')) continue; + if (/^https:\/\//.test(href)) { https(href); continue; } + assert(!/^[a-z]+:|^\/|\\|%/i.test(href), `Unsafe link ${href}`); + const target = path.normalize(path.join(path.dirname(name), href.split('#')[0])); + assert(Object.hasOwn(files, target), `Broken link ${name} -> ${href}`); + } + } else { + assert(/\.(png|jpg|jpeg|webp)$/.test(name), 'Unsupported asset type'); + assert.equal(file.encoding, 'base64'); + assert(typeof file.content === 'string' && Buffer.from(file.content, 'base64').toString('base64') === file.content, 'Invalid asset encoding'); + } + } + assert.equal(new Set(navigation.map(x => x.path)).size, navigation.length, 'Duplicate navigation entry'); + assert.deepEqual([...navigation.map(x => x.path)].sort(), names.filter(x => x.endsWith('.md')).sort(), 'Navigation must cover every page'); + navigation.forEach(x => { text(x.title, 'page title'); assert(x.title.length <= 100); }); + return bundle; +} + +module.exports = {canonical, digest, semver, compareVersions, safePath, validateBundle, validateSchema, MAX_BUNDLE_BYTES, REQUIRED_PAGES}; diff --git a/tools/module-docs/deliver.cjs b/tools/module-docs/deliver.cjs new file mode 100644 index 00000000..8e8eb3e9 --- /dev/null +++ b/tools/module-docs/deliver.cjs @@ -0,0 +1,46 @@ +'use strict'; + +const fs = require('node:fs'); +const assert = require('node:assert/strict'); +const {fetchRelease} = require('./registry.cjs'); +const {semver} = require('./contract.cjs'); +const modules = require('./modules.json'); + +async function deliver(record, options = {}) { + const allowed = modules.find(x => x.package === record.package); + assert(allowed, 'Package is not enrolled'); semver(record.version); + const fetchImpl = options.fetch || fetch; + const release = await fetchRelease(allowed, record.version, fetchImpl); + if (record.digest) assert.equal(release.digest, record.digest, 'Published bundle differs from release candidate'); + if (!options.dispatch) return release; + assert(typeof options.token === 'string' && options.token.length > 0, 'Missing docs delivery App token'); + const response = await fetchImpl('https://api.github.com/repos/friggframework/frigg/dispatches', { + method: 'POST', redirect: 'error', signal: AbortSignal.timeout(30000), + headers: {Authorization: `Bearer ${options.token}`, Accept: 'application/vnd.github+json', 'Content-Type': 'application/json', 'X-GitHub-Api-Version': '2022-11-28'}, + body: JSON.stringify({event_type: 'module-docs-published-v1', client_payload: {package: record.package, version: record.version}}), + }); + assert.equal(response.status, 204, `Docs dispatch failed: ${response.status}`); + return release; +} + +module.exports = {deliver}; +if (require.main === module) { + (async () => { + const event = process.env.GITHUB_EVENT_PATH ? JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8')) : {}; + const records = event.inputs?.package ? [{package: event.inputs.package, version: event.inputs.version}] : JSON.parse(fs.readFileSync(process.argv[2] || '.module-docs/published.json', 'utf8')); + const outcomes = []; + for (const record of records) { + try { + const release = await deliver(record, {dispatch: true, token: process.env.MODULE_DOCS_TOKEN}); + outcomes.push({package: record.package, version: record.version, digest: release.digest, status: 'dispatched'}); + } catch (error) { + outcomes.push({package: record.package, version: record.version, status: 'failed', error: error.message}); + process.exitCode = 1; + } + } + fs.mkdirSync('.module-docs', {recursive: true}); + fs.writeFileSync('.module-docs/delivery.json', `${JSON.stringify(outcomes, null, 2)}\n`); + for (const outcome of outcomes) console.log(`${outcome.package}@${outcome.version}: ${outcome.status}`); + if (process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `\nModule docs delivery: ${outcomes.filter(x => x.status === 'dispatched').length} dispatched, ${outcomes.filter(x => x.status === 'failed').length} failed. Dispatch acceptance is not confirmation of import or publication. Retry using the module-docs-delivery workflow; do not rerun shipit.\n`); + })().catch(error => { console.error(error.message); process.exitCode = 1; }); +} diff --git a/tools/module-docs/descriptor.schema.json b/tools/module-docs/descriptor.schema.json new file mode 100644 index 00000000..fa51fa54 --- /dev/null +++ b/tools/module-docs/descriptor.schema.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Frigg module catalog descriptor v1", + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "id", "name", "description", "provider", "category", "maintainers", "status", "capabilities", "extensions", "testedCompatibility", "links"], + "properties": { + "schemaVersion": {"type": "integer", "enum": [1]}, + "id": {"type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$"}, + "name": {"type": "string", "minLength": 1}, + "description": {"type": "string", "minLength": 1}, + "provider": {"type": "string", "minLength": 1}, + "category": {"type": "string", "minLength": 1}, + "maintainers": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1}}, + "status": {"type": "string", "enum": ["experimental", "maintained", "deprecated"]}, + "capabilities": {"type": "array", "items": {"type": "string", "minLength": 1}}, + "extensions": {"type": "array", "items": {"type": "string", "minLength": 1}}, + "testedCompatibility": {"type": "array", "items": {"type": "string", "minLength": 1}}, + "links": { + "type": "object", "additionalProperties": false, + "required": ["provider", "support"], + "properties": {"provider": {"type": "string"}, "support": {"type": "string"}} + } + } +} diff --git a/tools/module-docs/fixtures.cjs b/tools/module-docs/fixtures.cjs new file mode 100644 index 00000000..a52ac45c --- /dev/null +++ b/tools/module-docs/fixtures.cjs @@ -0,0 +1,53 @@ +'use strict'; + +// Synthetic provider data for transport/import tests; never published to the site. +const {gzipSync} = require('node:zlib'); +const {createHash} = require('node:crypto'); +const {digest, REQUIRED_PAGES, semver} = require('./contract.cjs'); + +function bundle(version = '1.0.0', id = 'hubspot') { + const pkg = {name: `@friggframework/api-module-${id}`, version, license: 'MIT', dependencies: {'@friggframework/core': '^1.1.2'}}; + const commit = 'a'.repeat(40); + const source = {repository: 'friggframework/api-module-library', commit, packagePath: `packages/v1-ready/${id}`}; + return {schemaVersion: 1, + manifest: {id, package: pkg.name, version, channel: semver(version).pre.length ? 'prerelease' : 'stable', source, packageMetadata: pkg, generator: {name: 'frigg-module-docs', version: '1.0.0', digest: 'b'.repeat(64)}}, + catalog: {identity: {id, package: pkg.name, version, kind: 'api-module'}, name: id, description: 'Fixture module for testing release delivery without provider credentials.', provider: id, category: 'Testing', license: 'MIT', maintainers: ['Test maintainer'], status: 'experimental', capabilities: [], extensions: [], compatibility: {node: null, frigg: '^1.1.2', tested: []}, links: {provider: 'https://example.com/docs', support: 'https://example.com/support', source: `https://github.com/${source.repository}/tree/${commit}/${source.packagePath}`, releaseNotes: 'https://example.com/releases'}}, + files: Object.fromEntries(REQUIRED_PAGES.map(name => [name, {encoding: 'utf8', content: `# ${name}\n\nFixture content covering the release-specific module behavior, requirements and support instructions.\n\n[Overview](README.md)\n`}])), + navigation: REQUIRED_PAGES.map(name => ({path: name, title: name})), + }; +} + +const envelope = value => ({schemaVersion: 1, bundle: value, digest: digest(value), packageIntegrity: `sha512-${Buffer.alloc(64).toString('base64')}`}); + +function tarball(value, extraEntries = []) { + const entries = [ + ['package/package.json', JSON.stringify(value.manifest.packageMetadata)], + ['package/module-docs.bundle.json', JSON.stringify(value)], + ...extraEntries, + ]; + const chunks = entries.flatMap(([name, content, type = '0']) => { + const data = Buffer.from(content); + const header = Buffer.alloc(512); + header.write(name); header.write('0000644\0', 100); header.write('0000000\0', 108); header.write('0000000\0', 116); + header.write(`${data.length.toString(8).padStart(11, '0')}\0`, 124); + header.write('00000000000\0', 136); header.fill(32, 148, 156); header.write(type, 156); header.write('ustar\0', 257); + const sum = header.reduce((a, b) => a + b, 0); + header.write(`${sum.toString(8).padStart(6, '0')}\0 `, 148); + return [header, data, Buffer.alloc((512 - data.length % 512) % 512)]; + }); + const tgz = gzipSync(Buffer.concat([...chunks, Buffer.alloc(1024)])); + return {tgz, integrity: `sha512-${createHash('sha512').update(tgz).digest('base64')}`}; +} + +function registryFetch(value, options = {}) { + const {tgz, integrity} = tarball(value); + const pkg = value.manifest.packageMetadata; + return async url => { + if (url === 'https://registry.npmjs.org/fixture.tgz') return new Response(tgz); + if (url.startsWith('https://registry.npmjs.org/')) return new Response(JSON.stringify({...pkg, dist: {tarball: 'https://registry.npmjs.org/fixture.tgz', integrity: options.badIntegrity || integrity}})); + if (url.startsWith('https://api.github.com/') && options.dispatch) return options.dispatch(url); + throw new Error(`Unexpected request ${url}`); + }; +} + +module.exports = {bundle, envelope, tarball, registryFetch}; diff --git a/tools/module-docs/modules.json b/tools/module-docs/modules.json new file mode 100644 index 00000000..e9c8b70b --- /dev/null +++ b/tools/module-docs/modules.json @@ -0,0 +1,7 @@ +[ + { + "id": "hubspot", + "package": "@friggframework/api-module-hubspot", + "packagePath": "packages/v1-ready/hubspot" + } +] diff --git a/tools/module-docs/registry.cjs b/tools/module-docs/registry.cjs new file mode 100644 index 00000000..73068b4e --- /dev/null +++ b/tools/module-docs/registry.cjs @@ -0,0 +1,64 @@ +'use strict'; + +// Registry transport v1. Keep this file byte-identical in api-module-library. +const assert = require('node:assert/strict'); +const {createHash} = require('node:crypto'); +const {gunzipSync} = require('node:zlib'); +const {semver, validateBundle, digest, MAX_BUNDLE_BYTES} = require('./contract.cjs'); + +async function download(url, maxBytes, fetchImpl = fetch) { + assert.equal(new URL(url).origin, 'https://registry.npmjs.org'); + const response = await fetchImpl(url, {redirect: 'error', signal: AbortSignal.timeout(30000)}); + assert(response.ok, `Registry request failed: ${response.status}`); + const chunks = []; let length = 0; + for await (const chunk of response.body) { + length += chunk.length; + assert(length <= maxBytes, 'Registry response too large'); + chunks.push(chunk); + } + return Buffer.concat(chunks); +} + +function readPackage(tgz, integrity) { + assert(/^sha512-[A-Za-z0-9+/]+={0,2}$/.test(integrity), 'Expected sha512 npm integrity'); + assert.equal(`sha512-${createHash('sha512').update(tgz).digest('base64')}`, integrity, 'npm integrity mismatch'); + const tar = gunzipSync(tgz, {maxOutputLength: 32 * 1024 * 1024}); + const wanted = new Set(['package/package.json', 'package/module-docs.bundle.json']); + const found = {}; + for (let offset = 0; offset + 512 <= tar.length;) { + const header = tar.subarray(offset, offset + 512); + if (header.every(x => x === 0)) break; + const str = (a, b) => header.subarray(a, b).toString().split('\0')[0]; + const name = [str(345, 500), str(0, 100)].filter(Boolean).join('/'); + const sizeText = str(124, 136).trim(); + assert(/^[0-7]+$/.test(sizeText), 'Invalid tar entry size'); + const size = parseInt(sizeText, 8); + assert(Number.isSafeInteger(size) && offset + 512 + size <= tar.length, 'Truncated tar'); + if (wanted.has(name)) { + assert(!Object.hasOwn(found, name), 'Duplicate package entry'); + assert([0, 48].includes(header[156]), 'Package entry must be a regular file'); + assert(size <= MAX_BUNDLE_BYTES, 'Package metadata/bundle too large'); + found[name] = JSON.parse(tar.subarray(offset + 512, offset + 512 + size).toString('utf8')); + } + offset += 512 + Math.ceil(size / 512) * 512; + } + assert.equal(Object.keys(found).length, 2, 'Published package is missing documentation bundle'); + return {bundle: found['package/module-docs.bundle.json'], pkg: found['package/package.json']}; +} + +async function fetchRelease(allowed, version, fetchImpl = fetch) { + semver(version); + const metadata = JSON.parse(await download(`https://registry.npmjs.org/${encodeURIComponent(allowed.package)}/${encodeURIComponent(version)}`, 2 * 1024 * 1024, fetchImpl)); + assert.equal(metadata.name, allowed.package); assert.equal(metadata.version, version); + const tgz = await download(metadata.dist.tarball, 16 * 1024 * 1024, fetchImpl); + const {bundle, pkg} = readPackage(tgz, metadata.dist.integrity); + assert.equal(pkg.name, allowed.package); assert.equal(pkg.version, version); + assert.equal(bundle.manifest.version, version); + for (const key of ['name', 'version', 'license', 'engines', 'dependencies', 'peerDependencies']) { + assert.deepEqual(bundle.manifest.packageMetadata[key], pkg[key], `Published package ${key} disagrees with bundle`); + } + validateBundle(bundle, allowed); + return {schemaVersion: 1, digest: digest(bundle), packageIntegrity: metadata.dist.integrity, bundle}; +} + +module.exports = {download, readPackage, fetchRelease}; From e31f4cf58fafefc8177b02a1f4fc1927ca7342cd Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Sun, 25 Oct 2026 00:38:04 -0400 Subject: [PATCH 2/4] docs(hubspot): use current authentication guide [skip release] --- packages/v1-ready/hubspot/docs/authentication.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/v1-ready/hubspot/docs/authentication.md b/packages/v1-ready/hubspot/docs/authentication.md index df3438ed..3b600c18 100644 --- a/packages/v1-ready/hubspot/docs/authentication.md +++ b/packages/v1-ready/hubspot/docs/authentication.md @@ -4,7 +4,7 @@ Create or configure a HubSpot OAuth app, register its redirect URI, and enable t scopes required for the methods you will call. The first-call example requires `crm.objects.contacts.read`; property discovery, other CRM objects, CMS features, and webhooks can require different scopes or account subscriptions. Consult the -[HubSpot OAuth guide](https://developers.hubspot.com/docs/apps/developer-platform/build-apps/authentication/oauth/overview) +[HubSpot OAuth guide](https://developers.hubspot.com/docs/apps/developer-platform/build-apps/authentication/overview) for the app generation you use. Configure these values before importing the module: From b3bd70fd4befb3ac89a82e4f33040ea02d3ca71f Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Sun, 25 Oct 2026 00:38:16 -0400 Subject: [PATCH 3/4] fix(docs): explicitly include the release bundle in npm packs [skip release] --- packages/v1-ready/hubspot/package.json | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/v1-ready/hubspot/package.json b/packages/v1-ready/hubspot/package.json index c1c122b6..6b27112a 100644 --- a/packages/v1-ready/hubspot/package.json +++ b/packages/v1-ready/hubspot/package.json @@ -23,5 +23,16 @@ }, "dependencies": { "@friggframework/core": "^2.0.0-next.89" - } + }, + "files": [ + "*.js", + "defaultConfig.json", + "README.md", + "CHANGELOG.md", + "LICENSE.md", + "extensions/", + "docs/", + "module-docs.json", + "module-docs.bundle.json" + ] } From 4788d9954aa68e20834cefc3550e4a6616bef561 Mon Sep 17 00:00:00 2001 From: Sean Matthews Date: Sun, 25 Oct 2026 00:42:12 -0400 Subject: [PATCH 4/4] fix(docs): handle Lerna release metadata and verify packed bundles [skip release] --- .github/workflows/module-docs-check.yml | 1 + packages/v1-ready/hubspot/package.json | 4 +++- tools/module-docs/README.md | 9 +++++---- tools/module-docs/build.cjs | 8 ++++++-- tools/module-docs/build.test.cjs | 20 ++++++++++++++++++++ tools/module-docs/check-pack.cjs | 22 ++++++++++++++++++++++ tools/module-docs/registry.cjs | 7 +++++-- 7 files changed, 62 insertions(+), 9 deletions(-) create mode 100644 tools/module-docs/check-pack.cjs diff --git a/.github/workflows/module-docs-check.yml b/.github/workflows/module-docs-check.yml index 0023df31..b865d245 100644 --- a/.github/workflows/module-docs-check.yml +++ b/.github/workflows/module-docs-check.yml @@ -23,3 +23,4 @@ jobs: - run: node --test tools/module-docs/*.test.cjs - name: Pack the pilot with its real lifecycle hook run: npm pack --workspace @friggframework/api-module-hubspot --pack-destination /tmp + - run: node tools/module-docs/check-pack.cjs /tmp diff --git a/packages/v1-ready/hubspot/package.json b/packages/v1-ready/hubspot/package.json index 6b27112a..5defdd7e 100644 --- a/packages/v1-ready/hubspot/package.json +++ b/packages/v1-ready/hubspot/package.json @@ -25,7 +25,9 @@ "@friggframework/core": "^2.0.0-next.89" }, "files": [ - "*.js", + "api.js", + "definition.js", + "index.js", "defaultConfig.json", "README.md", "CHANGELOG.md", diff --git a/tools/module-docs/README.md b/tools/module-docs/README.md index a71b6485..f2195285 100644 --- a/tools/module-docs/README.md +++ b/tools/module-docs/README.md @@ -25,10 +25,10 @@ cursor and explicit properties. It does not test live HubSpot or OAuth refresh. ## Exact release bundles The package's `prepack` hook builds `module-docs.bundle.json` after versioning and -before the package is packed. `.npmignore` deliberately includes the generated -bundle even though Git ignores it. Committed package source is checked against -`GITHUB_SHA` (or HEAD locally); auto may change only the package version and -changelog. The bundle records the source input SHA and exact resolved package +before the package is packed. The explicit package `files` list includes the generated bundle even though Git +ignores it; `.npmignore` keeps local secrets and test scaffolding out. Committed package source is checked against +`GITHUB_SHA` (or HEAD locally); auto/lerna may change the package version, +changelog and temporary `gitHead`. The bundle records the source input SHA and exact resolved package metadata so temporary prerelease commits/reset do not lose version provenance. `contract.cjs` and `registry.cjs` are byte-identical to the receiver copies. The @@ -55,6 +55,7 @@ eligible for this pilot's import path. ```bash node --test tools/module-docs/*.test.cjs npm pack --workspace @friggframework/api-module-hubspot --pack-destination /tmp +node tools/module-docs/check-pack.cjs /tmp ``` Packing performs source checks; commit authored sources first. No dependency diff --git a/tools/module-docs/build.cjs b/tools/module-docs/build.cjs index 7a038b57..440bdc90 100644 --- a/tools/module-docs/build.cjs +++ b/tools/module-docs/build.cjs @@ -31,10 +31,14 @@ function build(packageDir, options = {}) { const tracked = execFileSync('git', ['ls-tree', '-r', '--name-only', commit, allowed.packagePath], {cwd: root, encoding: 'utf8'}).trim().split('\n'); for (const filename of tracked.map(x => x.slice(allowed.packagePath.length + 1)).filter(x => !['package.json', 'CHANGELOG.md'].includes(x))) check(filename); const sourcePkg = JSON.parse(execFileSync('git', ['show', `${commit}:${allowed.packagePath}/package.json`], {cwd: root, encoding: 'utf8'})); - assert.deepEqual({...sourcePkg, version: pkg.version}, pkg, 'Release changed package metadata beyond its version'); + // Lerna 8 annotates gitHead on disk before prepack. This is release + // metadata, not a change to the module's declared runtime contract. + const resolvedPkg = {...pkg}; delete resolvedPkg.gitHead; delete sourcePkg.gitHead; + if (pkg.gitHead !== undefined) assert(/^[a-f0-9]{40}$/.test(pkg.gitHead), 'Invalid Lerna gitHead'); + assert.deepEqual({...sourcePkg, version: pkg.version}, resolvedPkg, 'Release changed package metadata beyond version/gitHead'); } const metadata = {}; - for (const key of ['name', 'version', 'license', 'engines', 'dependencies', 'peerDependencies']) if (pkg[key] !== undefined) metadata[key] = pkg[key]; + for (const key of ['name', 'version', 'license', 'engines', 'dependencies', 'peerDependencies', 'gitHead']) if (pkg[key] !== undefined) metadata[key] = pkg[key]; const source = {repository: 'friggframework/api-module-library', commit, packagePath: allowed.packagePath}; const sourceUrl = `https://github.com/${source.repository}/tree/${commit}/${source.packagePath}`; const index = fs.readFileSync(path.join(packageDir, 'index.js'), 'utf8'); diff --git a/tools/module-docs/build.test.cjs b/tools/module-docs/build.test.cjs index 4efe5faf..b8eca497 100644 --- a/tools/module-docs/build.test.cjs +++ b/tools/module-docs/build.test.cjs @@ -6,6 +6,7 @@ const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); const vm = require('node:vm'); +const {execFileSync} = require('node:child_process'); const {build} = require('./build.cjs'); const {digest} = require('./contract.cjs'); const {deliver} = require('./deliver.cjs'); @@ -82,6 +83,25 @@ test('source verification cannot accept a fabricated commit', t => { assert.throws(() => build(directory, {root, commit: 'b'.repeat(40)})); }); +test('prepack accepts Lerna version/gitHead annotations but rejects runtime metadata changes', t => { + const {root, directory} = workspace(t); + const git = args => execFileSync('git', args, {cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe']}).trim(); + git(['init']); git(['add', '.']); + git(['-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.test', '-c', 'commit.gpgsign=false', 'commit', '-m', 'fixture']); + const commit = git(['rev-parse', 'HEAD']); + const filename = path.join(directory, 'package.json'); + const pkg = JSON.parse(fs.readFileSync(filename)); + pkg.version = '2.0.0-next.1'; pkg.gitHead = commit; + fs.writeFileSync(filename, JSON.stringify(pkg)); + const result = build(directory, {root, commit}); + assert.equal(result.manifest.version, pkg.version); + assert.equal(result.manifest.packageMetadata.gitHead, commit); + assert.equal(result.manifest.source.commit, commit); + pkg.dependencies['@friggframework/core'] = '*'; + fs.writeFileSync(filename, JSON.stringify(pkg)); + assert.throws(() => build(directory, {root, commit}), /beyond version/); +}); + test('auto plugin emits only candidate identities from a successful non-canary publication', async t => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'module-docs-plugin-')); t.after(() => fs.rmSync(root, {recursive: true, force: true})); diff --git a/tools/module-docs/check-pack.cjs b/tools/module-docs/check-pack.cjs new file mode 100644 index 00000000..7638fb47 --- /dev/null +++ b/tools/module-docs/check-pack.cjs @@ -0,0 +1,22 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const assert = require('node:assert/strict'); +const {createHash} = require('node:crypto'); +const {readPackage} = require('./registry.cjs'); +const {digest, validateBundle} = require('./contract.cjs'); + +for (const allowed of require('./modules.json')) { + const directory = path.resolve(allowed.packagePath); + const pkg = JSON.parse(fs.readFileSync(path.join(directory, 'package.json'))); + const filename = `${pkg.name.replace('@', '').replace('/', '-')}-${pkg.version}.tgz`; + const bytes = fs.readFileSync(path.join(process.argv[2] || require('node:os').tmpdir(), filename)); + const integrity = `sha512-${createHash('sha512').update(bytes).digest('base64')}`; + const packed = readPackage(bytes, integrity); + validateBundle(packed.bundle, allowed); + assert.equal(packed.pkg.version, pkg.version); + const candidate = JSON.parse(fs.readFileSync(path.join(directory, 'module-docs.bundle.json'))); + assert.equal(digest(packed.bundle), digest(candidate), 'Packed bundle differs from prepack candidate'); + console.log(`Verified ${filename}: release bundle included, validated and identical to candidate.`); +} diff --git a/tools/module-docs/registry.cjs b/tools/module-docs/registry.cjs index 73068b4e..88c05c7e 100644 --- a/tools/module-docs/registry.cjs +++ b/tools/module-docs/registry.cjs @@ -29,7 +29,10 @@ function readPackage(tgz, integrity) { const header = tar.subarray(offset, offset + 512); if (header.every(x => x === 0)) break; const str = (a, b) => header.subarray(a, b).toString().split('\0')[0]; - const name = [str(345, 500), str(0, 100)].filter(Boolean).join('/'); + const rawName = [str(345, 500), str(0, 100)].filter(Boolean).join('/'); + // Lerna's packer prepends ./ to neutralize node-tar's special handling. + // Normalize only harmless dot components, never parent traversal. + const name = rawName.split('/').includes('..') ? rawName : rawName.split('/').filter(x => x !== '.').join('/'); const sizeText = str(124, 136).trim(); assert(/^[0-7]+$/.test(sizeText), 'Invalid tar entry size'); const size = parseInt(sizeText, 8); @@ -54,7 +57,7 @@ async function fetchRelease(allowed, version, fetchImpl = fetch) { const {bundle, pkg} = readPackage(tgz, metadata.dist.integrity); assert.equal(pkg.name, allowed.package); assert.equal(pkg.version, version); assert.equal(bundle.manifest.version, version); - for (const key of ['name', 'version', 'license', 'engines', 'dependencies', 'peerDependencies']) { + for (const key of ['name', 'version', 'license', 'engines', 'dependencies', 'peerDependencies', 'gitHead']) { assert.deepEqual(bundle.manifest.packageMetadata[key], pkg[key], `Published package ${key} disagrees with bundle`); } validateBundle(bundle, allowed);