diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..504b70d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: ci + +on: + pull_request: + push: + branches: [main] + +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 15 + services: + postgres: + image: postgres:16 + env: { POSTGRES_PASSWORD: postgres, POSTGRES_DB: webhooks } + ports: ["5432:5432"] + options: >- + --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 + env: + PGHOST: localhost + PGPORT: 5432 + PGUSER: postgres + PGPASSWORD: postgres + PGDATABASE: webhooks + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v4 + with: + node-version: 24 + - uses: oven-sh/setup-bun@v2 + - run: bun install --frozen-lockfile + - run: bun run check + - run: bun run test:e2e + - name: node consumer smoke + run: | + set -euo pipefail + TARBALL="$PWD/$(npm pack --silent)" + mkdir -p "$RUNNER_TEMP/c" && cd "$RUNNER_TEMP/c" + npm init -y >/dev/null && npm pkg set type=module >/dev/null + npm install "$TARBALL" + node -e 'import("@corbits/webhooks").then((m) => { for (const n of ["createHookRoutes", "createRunTriggerDeliverer", "createTenantSystemSender", "isRunTriggerUnroutable"]) if (typeof m[n] !== "function") throw new Error("missing export: " + n); })' diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml new file mode 100644 index 0000000..a03ad39 --- /dev/null +++ b/.github/workflows/cla.yml @@ -0,0 +1,53 @@ +name: CLA Assistant + +on: + issue_comment: + types: [created] + pull_request_target: + types: [opened, synchronize] + +permissions: + actions: write + contents: write + pull-requests: write + statuses: write + +concurrency: + group: cla-${{ github.event.pull_request.number || github.event.issue.number || github.run_id }} + cancel-in-progress: true + +jobs: + cla: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Maintainer fast path + if: >- + github.event_name == 'pull_request_target' && + (contains(fromJSON('["TheGreatAxios","brianjfox"]'), github.event.pull_request.user.login) || + endsWith(github.event.pull_request.user.login, '[bot]')) + run: echo "Maintainer or bot pull request; CLA not required." + - name: CLA Assistant + if: >- + ((github.event.comment.body == 'recreate-signatures' || + github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || + github.event_name == 'pull_request_target') && + !(github.event_name == 'pull_request_target' && + (contains(fromJSON('["TheGreatAxios","brianjfox"]'), github.event.pull_request.user.login) || + endsWith(github.event.pull_request.user.login, '[bot]'))) + # corbitsdev/cla-assistant-action v2.6.1-node24: upstream v2.6.1 on node24. + uses: corbitsdev/cla-assistant-action@ef6d3e51db8232fe93090810f13bde30497c1d74 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + path-to-document: "https://github.com/${{ github.repository }}/blob/main/CLA.md" + path-to-signatures: "signatures/version1/cla.json" + branch: "cla-signatures" + allowlist: TheGreatAxios,brianjfox,*[bot] + custom-notsigned-prcomment: >- + Thank you for your contribution. Before it can be merged, please read our + [Contributor License Agreement](https://github.com/${{ github.repository }}/blob/main/CLA.md) + and sign it by posting a new comment on this pull request containing + exactly the line below (nothing else): + custom-pr-sign-comment: "I have read the CLA Document and I hereby sign the CLA" + custom-allsigned-prcomment: "All contributors have signed the CLA." diff --git a/.gitignore b/.gitignore index d122555..6f17218 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,8 @@ -node_modules -dist -.tmp +node_modules/ +dist/ *.tsbuildinfo +*.tgz +coverage/ .env +.env.* .DS_Store -*.log diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a613de4 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,40 @@ +# AGENTS.md + +## Purpose + +`@corbits/webhooks` is signed webhook ingress for Interchange workflows. It +owns the hook routes, the Slack, Standard Webhooks and bearer verifiers, hook +resolution, and delivery of the body as trigger mail to a live run. It does not +own credentials (they are Interchange credentials with `metadata.webhook`), +storage of its own, or scheduling (`@corbits/cron`). + +## Layout + +- `src/routes.ts`: `createHookRoutes`, the Hono sub-app for `/api/hooks`. +- `src/verify.ts`: the three signature verifiers. +- `src/resolve.ts`: credential lookup and target run resolution. +- `src/hooks.ts`: the request handler that ties verify, resolve and deliver. +- `src/deliver.ts`: `createRunTriggerDeliverer` and the unroutable errors. +- `src/system-sender.ts`: `createTenantSystemSender`, the per-tenant signer of + trigger mail. +- `src/index.ts`: the only module consumers import from. +- `e2e/`: real-Postgres suites, gated on the libpq `PG*` variables. + +## Rules + +- The signing credential only authenticates the request. The workflow runs as + its own run principal, `deriveRunPrincipalId(tenantId, runId)`, through + Interchange's mail-triggered grant path, never as the credential owner. +- Trigger mail is signed by the durable per-tenant system sender + (`webhook@domain`), whose key is co-delivered on the run-grants barrier. +- No unsigned mode: a credential without a verifier matches no hook. +- Unknown hooks and name collisions both answer `404`. +- `exactOptionalPropertyTypes` is on: omit optional keys, never assign + `undefined` to them. + +## Local development + +```sh +bun install +bun run check +``` diff --git a/CLA.md b/CLA.md new file mode 100644 index 0000000..201eec8 --- /dev/null +++ b/CLA.md @@ -0,0 +1,64 @@ +# Contributor License Agreement (CLA) + +Version 1.0 + +This Contributor License Agreement ("Agreement") is entered into between the contributor ("Contributor") and ABK Labs, Inc. ("Maintainer"). + +By submitting any Contribution to a project maintained by Maintainer, the Contributor agrees to the following terms. + +## 1. Definitions + +"Contribution" means any source code, object code, documentation, test cases, designs, specifications, bug fixes, enhancements, comments, pull requests, commits, issues containing code, or other materials intentionally submitted to a project maintained by Maintainer. + +## 2. Copyright Ownership + +The Contributor retains ownership of the Contributor's copyrights in the Contribution. + +No transfer of copyright ownership is required by this Agreement. + +## 3. License Grant to Maintainer + +The Contributor grants Maintainer a perpetual, worldwide, non-exclusive, irrevocable, royalty-free license to: + +- use, reproduce, modify, distribute, display, perform, and sublicense the Contribution; +- combine the Contribution with other software and works; +- distribute the Contribution under the project's current license; +- distribute the Contribution under future versions of the project's license; +- distribute the Contribution under alternative open-source, source-available, commercial, proprietary, or dual-license terms. + +## 4. License Grant to the Public + +The Contributor agrees that the Contribution may be distributed as part of the applicable project under the project's then-current license terms, including LGPL-2.1 or any successor license adopted by Maintainer. + +## 5. Contributor Representations + +The Contributor represents and warrants that: + +1. The Contributor created the Contribution or otherwise has sufficient rights to submit it. +2. The Contributor has the legal authority to grant the rights described in this Agreement. +3. To the best of the Contributor's knowledge, the Contribution does not knowingly infringe the intellectual property rights of any third party. +4. Any third-party material included in the Contribution has been properly disclosed and is compatible with the rights granted under this Agreement. + +## 6. Corporate Contributions + +If a Contribution is submitted on behalf of an employer or other legal entity, the person accepting this Agreement represents that they are authorized to bind that entity to this Agreement. + +## 7. No Obligation + +Maintainer is not obligated to use, distribute, maintain, support, or accept any Contribution. + +## 8. Disclaimer + +Except as expressly stated in this Agreement, the Contribution is provided "AS IS" without warranties or conditions of any kind. + +## 9. Electronic Acceptance + +The Contributor agrees that electronic acceptance of this Agreement, including acceptance through a website, source-control platform, click-through process, pull-request workflow, or similar mechanism, shall have the same force and effect as a handwritten signature. + +## 10. Governing Law + +This Agreement shall be governed by the laws of the State of California, excluding its conflict-of-law provisions. + +--- + +By submitting a Contribution, the Contributor acknowledges that they have read and agree to this Agreement. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 55493a4..9e76c94 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,23 +1,25 @@ # Contributing -## Running the tests +## Development ```sh -git clone https://github.com/corbitsdev/webhooks.git -cd webhooks bun install -bun run typecheck -bun run test -bun run test:e2e +bun run check ``` -`bun run test` runs the `./src` units and `bun run test:e2e` the `./e2e` real-Postgres suites, which run when `PGHOST`, `PGPORT`, `PGUSER`, `PGPASSWORD` and `PGDATABASE` are all set and skip otherwise. Each suite migrates a throwaway `wh_*` schema and drops it on exit; a killed run can leave one behind. +`bun run check` runs typecheck, lint, format check and unit tests. `bun run format` rewrites the tree. -## How a hook fires +Contributors sign the [CLA](CLA.md) on their first PR; the CLA bot explains how. -The signing credential only authenticates the inbound request. The workflow runs as its own run principal, `deriveRunPrincipalId(tenantId, runId)`, through Interchange's mail-triggered grant path (`createMailTriggeredRunGrantsMaterializer`), never as the credential owner. Credentials stay Interchange's (`POST /credentials`, `credential:*`); this package only adds the hook routes. +`bun run test:e2e` runs the real-Postgres suites in `e2e/`. They run when +`PGHOST`, `PGPORT`, `PGUSER`, `PGPASSWORD` and `PGDATABASE` are all set and skip +otherwise. Each suite migrates a throwaway `wh_*` schema and drops it on exit; a +killed run can leave one behind. -The trigger mail is signed by a durable per-tenant system sender (`webhook@domain`), a `kind: "user"` principal whose key is co-delivered on the run-grants barrier so the recipient verifies the mail's `From`. +```sh +docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=postgres postgres:16 +PGHOST=localhost PGPORT=5432 PGUSER=postgres PGPASSWORD=postgres PGDATABASE=postgres bun run test:e2e +``` ## Commit messages @@ -25,3 +27,16 @@ Commit subjects and PR titles follow [Conventional Commits](https://www.conventi Add `!` only for public API breaks: removed or renamed exports, changed signatures, newly required params. Peer and dependency range changes are `build(deps):` with no `!`. Keep subjects imperative, lowercase after the colon, 72 characters or less, and free of ticket IDs. Every PR links its issue with a `Closes ` line in the PR body. + +## Releasing + +Releases are manual. On a clean, up-to-date `main`: + +```sh +npm version -m "chore(release): %s" +git push --follow-tags +gh release create "v$(node -p 'require("./package.json").version')" --generate-notes +npm publish +``` + +Bump minor only for breaking API changes; everything else is a patch. `prepack` builds `dist/` from the tagged commit. diff --git a/README.md b/README.md index 5537d2b..0f3b2f1 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,5 @@ # @corbits/webhooks -[![npm](https://img.shields.io/npm/v/@corbits/webhooks.svg)](https://www.npmjs.com/package/@corbits/webhooks) [![License: LGPL-2.1](https://img.shields.io/badge/license-LGPL--2.1-green.svg)](https://github.com/corbitsdev/webhooks/blob/main/LICENSE) - Signed webhook ingress for Interchange workflows: verifies a Slack, Standard Webhooks or bearer request against a hub credential, then delivers the body as trigger mail to a live workflow run. A Corbits hub module, mounted as Hono routes on the Interchange hub (Interchange's multi-tenant control plane, where a principal is an identity and a grant is a permission it holds) and backed by its Postgres. ## Why @corbits/webhooks? diff --git a/package.json b/package.json index b77cf56..1ffba39 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,15 @@ "name": "@corbits/webhooks", "version": "0.2.0", "description": "Receive signed webhooks (Slack, Standard Webhooks, bearer) and start a workflow run.", - "homepage": "https://github.com/corbitsdev/webhooks", + "keywords": [ + "corbits", + "hono", + "interchange", + "slack", + "standard-webhooks", + "webhooks" + ], + "homepage": "https://github.com/corbitsdev/webhooks#readme", "bugs": { "url": "https://github.com/corbitsdev/webhooks/issues" }, @@ -19,8 +27,6 @@ ], "type": "module", "sideEffects": false, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", "exports": { ".": { "types": "./dist/index.d.ts", @@ -31,14 +37,15 @@ "access": "public" }, "scripts": { - "typecheck": "tsc --noEmit", - "build": "tsc -p tsconfig.build.json", + "build": "rm -rf dist && tsc -p tsconfig.build.json", "prepack": "bun run build", - "test": "bun test ./src", - "test:e2e": "bun test ./e2e", + "typecheck": "tsc --noEmit", "lint": "oxlint", "format": "oxfmt", - "format:check": "oxfmt --check" + "format:check": "oxfmt --check", + "test": "bun test src --pass-with-no-tests", + "test:e2e": "bun test e2e", + "check": "bun run typecheck && bun run lint && bun run format:check && bun run test" }, "devDependencies": { "@intx/crypto": "0.4.0", @@ -62,5 +69,9 @@ "@intx/mime": "^0.4.0", "@intx/types": "^0.4.0", "hono": "^4.11.9" + }, + "engines": { + "bun": ">=1.2.0", + "node": ">=24" } } diff --git a/tsconfig.build.json b/tsconfig.build.json index b685cf3..af6694f 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -5,13 +5,11 @@ "declaration": true, "declarationMap": false, "sourceMap": false, - "composite": false, - "outDir": "dist", - "rootDir": "src", - "allowImportingTsExtensions": false, "module": "NodeNext", - "moduleResolution": "NodeNext" + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src" }, - "include": ["src/**/*.ts"], + "include": ["src"], "exclude": ["src/**/*.test.ts"] } diff --git a/tsconfig.json b/tsconfig.json index 1977cfa..dda942c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,19 +1,21 @@ { "compilerOptions": { + "esModuleInterop": true, + "skipLibCheck": true, "target": "ESNext", "module": "ESNext", "moduleResolution": "bundler", - "lib": ["ESNext"], + "moduleDetection": "force", + "isolatedModules": true, + "verbatimModuleSyntax": true, + "resolveJsonModule": true, "strict": true, "noUncheckedIndexedAccess": true, - "verbatimModuleSyntax": true, - "isolatedModules": true, - "skipLibCheck": true, + "noImplicitOverride": true, + "exactOptionalPropertyTypes": true, "noEmit": true, - "esModuleInterop": true, - "resolveJsonModule": true, - "allowImportingTsExtensions": true, + "lib": ["ESNext"], "types": ["bun"] }, - "include": ["src/**/*.ts", "e2e/**/*.ts"] + "include": ["src", "e2e"] }