Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* text=auto eol=lf
26 changes: 26 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: Publish

on:
push:
tags:
- "v*"

permissions:
id-token: write
contents: read
Comment on lines +8 to +10

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Files:\n'
git ls-files .github/workflows | sed -n '1,80p'

printf '\npublish.yml:\n'
cat -n .github/workflows/publish.yml 2>/dev/null || true

printf '\nrelease.yml:\n'
cat -n .github/workflows/release.yml 2>/dev/null || true

printf '\nSearch npm publish/auth config:\n'
rg -n "npm publish|NODE_AUTH_TOKEN|NPM_TOKEN|npm_config_token|id-token|setup-node|actions/setup-node|NPM_CONFIG_PROVENANCE|registry-url" .github/workflows package.json packages 2>/dev/null || true

Repository: getpaykit/paykit

Length of output: 4133


🌐 Web query:

npm trusted publishing workflow actions setup-node id-token write npm CLI version Node version

💡 Result:

To use npm trusted publishing in GitHub Actions, you must configure a trusted publisher on npmjs.com [1][2] and update your workflow to use OIDC authentication [1][3]. Requirements: - npm CLI version: 11.5.1 or later is required [3][4][5]. - Node.js version: Node.js 24 or newer is recommended, as it includes a compatible version of npm by default [3][4][6]. Workflow Setup: 1. Permissions: You must grant the workflow permission to generate an OIDC token by setting id-token: write in your job or workflow definition [1][3][2]. 2. setup-node Configuration: When using actions/setup-node, you should include registry-url: 'https://registry.npmjs.org' [1][3]. 3. Avoiding Authentication Conflicts: A common issue is that actions/setup-node may write an _authToken placeholder to the .npmrc file if registry-url is specified, which can cause npm to fall back to legacy token-based authentication and fail [7][8]. To ensure the workflow correctly initiates the OIDC exchange, you may need to ensure NODE_AUTH_TOKEN is unset or empty during the publish step [9][8]. Alternatively, using recent versions of actions/setup-node or adding an explicit step to clear environment tokens before npm publish is recommended [9][8]. Example Workflow Snippet: permissions: contents: read id-token: write # Required for OIDC jobs: publish: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: node-version: '24' registry-url: 'https://registry.npmjs.org' - run: npm ci - run: npm publish # npm 11.5.1+ will automatically use OIDC [3][9] If you are unable to upgrade to Node.js 24, you can manually install the required npm version in your workflow before publishing: run: npm install -g npm@latest [4][10][6].

Citations:


🌐 Web query:

npmjs.com trusted publishing setup publish npm publish authentication setup-node id-token node-version 22 npm 11.5.1

💡 Result:

To set up npm Trusted Publishing, which allows you to publish packages without storing long-lived npm tokens, follow these steps. ### Prerequisites * npm version 11.5.1 or higher [1]. * Node.js 22 or higher (Node.js 24 recommended) [1]. * Two-factor authentication (2FA) must be enabled on your npm account [2]. * Your package must already exist on the npm registry [2][3]. ### Step 1: Configure Trusted Publisher on npmjs.com 1. Log in to npmjs.com and navigate to your package settings [4][5]. 2. Locate the "Trusted Publisher" section and select your CI/CD provider (e.g., GitHub Actions) [4][5]. 3. Provide the required details: * GitHub: Owner/Organization, Repository name, and Workflow filename (e.g., publish.yml, without the file path) [4][5]. * Select the allowed actions: npm publish and/or npm stage publish [4][5]. 4. Save the configuration [6]. ### Step 2: Configure your GitHub Actions Workflow Update your workflow file (.github/workflows/publish.yml) to grant the necessary OIDC permissions [4][1]: yaml jobs: publish: runs-on: ubuntu-latest permissions: contents: read id-token: write # Required for OIDC authentication steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: node-version: '24' registry-url: 'https://registry.npmjs.org' - run: npm ci - run: npm publish ### Important Authentication Notes * No Long-Lived Tokens: Do not set or provide an NPM_TOKEN or NODE_AUTH_TOKEN secret when using Trusted Publishing [3][7]. * setup-node Behavior: When registry-url is set, actions/setup-node may attempt to write an _authToken placeholder to your .npmrc [8]. If your publishing job fails with ENEEDAUTH or E404, you may need to explicitly prevent this by adding auth-token-line: false to your setup-node step [8]: yaml - uses: actions/setup-node@v6 with: node-version: '24' registry-url: 'https://registry.npmjs.org' auth-token-line: false * Provenance: When using Trusted Publishing, the npm CLI automatically generates provenance attestations for your package, so the --provenance flag is not required [6].

Citations:


🌐 Web query:

npmjs.com trusted publishing Node.js 22 npm CLI compatible version default installed by Node.js 22.14.0

💡 Result:

Trusted publishing on npm requires npm CLI version 11.5.1 or later and Node.js version 22.14.0 or higher [1][2]. While Node.js 22.14.0 is the minimum required Node.js version for this feature, it is not bundled with the necessary npm CLI version by default [1][2]. Node.js 22.14.0 comes with npm 10.9.2 [3], which is older than the required npm CLI version 11.5.1 [1][2]. To use trusted publishing, users must manually upgrade their npm CLI to version 11.5.1 or later [1][2].

Citations:


Configure npm authentication for npm publish.

This workflow does not set NODE_AUTH_TOKEN, and trusted publishing is not ready from the workflow alone: Node 22 installs npm < 11.5.1 by default, so npm CLI 11.5.1+ must be installed explicitly with registry-url configured, or use the token path. For the existing token path, set NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} and NPM_CONFIG_PROVENANCE: true around npm publish.

[security And_privacy]

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/publish.yml around lines 8 - 10, Update the npm publishing
workflow around the publish step to configure authentication by setting
NODE_AUTH_TOKEN from secrets.NPM_TOKEN and enabling NPM_CONFIG_PROVENANCE.
Ensure these environment variables apply to npm publish while preserving the
existing permissions configuration.


jobs:
publish:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== workflow files =="
git ls-files '.github/workflows/*' || true

echo "== publish.yml excerpt =="
if [ -f .github/workflows/publish.yml ]; then
  nl -ba .github/workflows/publish.yml | sed -n '1,220p'
fi

echo "== checkout usages in publish.yml with context =="
if [ -f .github/workflows/publish.yml ]; then
  rg -n -C 6 'actions/checkout|persist-credentials|npm|pnpm|yarn|git ' .github/workflows/publish.yml || true
fi

echo "== deterministic docs check from installed action metadata not available; inspect local if present =="
(
  tmp="$(mktemp -d)"
  cd "$tmp"
  if command -v git >/dev/null 2>&1; then
    git clone --depth 1 --filter=blob:none --sparse https://github.com/actions/checkout.git 2>/dev/null || true
    if [ -d checkout ]; then
      cd checkout
      rg -n "persist-credentials|token|auth" README.md dist/index.js src/ 2>/dev/null | head -n 80 || true
    fi
  fi
)

Repository: getpaykit/paykit

Length of output: 387


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== publish.yml relevant lines =="
awk '{printf "%6d\t%s\n", NR, $0}' .github/workflows/publish.yml | sed -n '1,220p'

echo "== checkout usages in publish.yml =="
grep -n -C 6 'actions/checkout\|persist-credentials\|npm\|yarn\|pnpm\|git ' .github/workflows/publish.yml || true

echo "== all workflow checkout occurrences with inputs =="
grep -n -C 3 'actions/checkout\|persist-credentials' .github/workflows/*.yml || true

echo "== package script definitions mentioning publish/build =="
python3 - <<'PY'
import json
from pathlib import Path
p=Path('package.json')
if p.exists():
    data=json.loads(p.read_text())
    for k,v in data.get('scripts',{}).items():
        if any(s in v.lower() for s in ['npm publish','publish','build','npm pack','dist-tag','ci']):
            print(f"{k}: {v}")
PY

Repository: getpaykit/paykit

Length of output: 6584


🌐 Web query:

actions/checkout v4 persist-credentials default true README

💡 Result:

In actions/checkout v4, the persist-credentials input parameter defaults to true [1][2]. When this option is enabled (the default behavior), the authentication token or SSH key used for the checkout is persisted in the local git configuration [3][4]. This allows subsequent steps in your workflow job to execute authenticated git commands using those same credentials [3][4]. The token is automatically removed during the post-job cleanup phase [3][4]. To opt out of this behavior and prevent the credentials from being stored in the local git config, you must explicitly set persist-credentials to false in your workflow file [3][5]: - name: Checkout uses: actions/checkout@v4 with: persist-credentials: false While there has been ongoing community discussion regarding the security implications of this default [6][5], the official documentation and action configuration maintain true as the default setting for v4 [1][2].

Citations:


Disable checkout credential persistence.

actions/checkout@v4 persists the GitHub token by default. The publish job only needs checkout, setup, install, build, and publish steps, so explicit Git auth is unnecessary.

Proposed fix
       - uses: actions/checkout@v4
+        with:
+          persist-credentials: false
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- uses: actions/checkout@v4
- uses: actions/checkout@v4
with:
persist-credentials: false
🧰 Tools
🪛 zizmor (1.28.0)

[warning] 17-17: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false

(artipacked)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/publish.yml at line 17, Update the actions/checkout@v4
step in the publish workflow to disable credential persistence by setting
persist-credentials to false, without changing the remaining checkout behavior.

Source: Linters/SAST tools

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: actions/checkout@v4 persists the GitHub token in the local git config by default. Since this job only checks out, builds, and publishes, add persist-credentials: false to avoid leaving credentials accessible to later steps.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At .github/workflows/publish.yml, line 17:

<comment>`actions/checkout@v4` persists the GitHub token in the local git config by default. Since this job only checks out, builds, and publishes, add `persist-credentials: false` to avoid leaving credentials accessible to later steps.</comment>

<file context>
@@ -0,0 +1,26 @@
+    runs-on: ubuntu-latest
+
+    steps:
+      - uses: actions/checkout@v4
+
+      - uses: actions/setup-node@v4
</file context>
Suggested change
- uses: actions/checkout@v4
- uses: actions/checkout@v4
with:
persist-credentials: false


- uses: actions/setup-node@v4
Comment on lines +17 to +19

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== workflow files =="
git ls-files '.github/workflows/*' | sort

echo
echo "== publish workflow relevant lines =="
cat -n .github/workflows/publish.yml | sed -n '1,80p'

echo
echo "== release workflow relevant action lines =="
if [ -f .github/workflows/release.yml ]; then
  cat -n .github/workflows/release.yml | rg -n "uses:" -C 2
else
  echo "release.yml not found"
fi

echo
echo "== all uses tags/shas in workflows =="
rg -n 'uses:\s*[^#]+' .github/workflows || true

Repository: getpaykit/paykit

Length of output: 4865


🌐 Web query:

zizmor audit hash pins sensitive workflows .github/workflows docs.zizmor.sh audits

💡 Result:

Zizmor is a static analysis tool for GitHub Actions workflows that includes specific audit rules to enforce secure dependency pinning [1][2]. A primary audit rule related to this is unpinned-uses, which checks for workflows that do not use immutable SHA-based references for their actions [3][4]. Key aspects of zizmor auditing and hash pinning include: Hash-Pinning Enforcement: The unpinned-uses audit detects when actions are referenced by tags, branches, or other non-immutable references [3]. Since version 1.20.0, zizmor defaults to a strict policy requiring all actions to be pinned by SHA reference (hash-pinning) [3][4]. This prevents supply chain attacks where a malicious actor could overwrite a tag or branch reference with compromised code [5]. Sensitive Workflows and Audits: Zizmor audits help identify risks in workflows, such as excessive permissions or potential credential leakage [6]. Because hash-pinning creates a permanent link to a specific commit, it is a recommended practice for all sensitive or critical workflows to ensure dependency integrity [3][5]. Related Audit Checks: - Version Comment Mismatch: Zizmor audits hash-pinned actions to ensure their associated version comments (often used by tools like Dependabot) are accurate and match the pinned commit [3][7]. - Stale Action Refs: The stale-action-refs audit flags SHA references that do not point to a known Git tag, as these can obscure the version history and release notes associated with the action [7]. Configuration: While the default policy is strict, users can adjust these behaviors in their zizmor.yml configuration file [3][4]. For example, users can restore a more permissive policy for specific action namespaces (like actions/* or github/*) if they prefer ref-pinning for those repositories [4]. Online vs. Offline Mode: While the audit rules themselves can often be run offline, zizmor's fixing capabilities—such as automatically generating the correct SHA-pin for a reference—require online access to fetch commit history from the GitHub API [8][9]. When handling sensitive or private repositories, users should be aware that zizmor caches HTTP responses locally, and they should exercise caution when sharing these caches [8].

Citations:


🌐 Web query:

actions/checkout v4 commit SHA 34e114876b0b11c390a56381ad16ebd13914f8d5

💡 Result:

The commit SHA 34e114876b0b11c390a56381ad16ebd13914f8d5 corresponds to version v4.3.1 of the actions/checkout GitHub Action [1]. Pinning actions to specific commit SHAs is a security best practice to prevent unexpected behavior or malicious code execution if a tag (such as @v4) is updated by the repository maintainers [1]. This specific SHA has been widely used by developers to lock their workflows to the v4.3.1 release [2][1]. As with all versions of actions/checkout, it is used to check out your repository under the GITHUB_WORKSPACE environment, allowing your workflow to access the source code [3][4].

Citations:


🌐 Web query:

actions/setup-node v4 commit SHA 49933ea5288caeca8642d1e84afbd3f7d6820020

💡 Result:

The commit SHA 49933ea5288caeca8642d1e84afbd3f7d6820020 corresponds to version v4.4.0 of the actions/setup-node GitHub Action [1][2]. This version introduced support for private mirrors [3][4]. Pinning to this specific commit is a common practice in GitHub Actions workflows to ensure consistent, immutable behavior [1][2].

Citations:


Pin publishing actions to immutable commit SHAs.

This workflow grants id-token: write before publishing, so use permanent action pins instead of movable major-version tags. Use the SHAs already used by other workflows for these same action versions.

Proposed fix
-      - uses: actions/checkout@v4
+      - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
...
-      - uses: actions/setup-node@v4
+      - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
🧰 Tools
🪛 zizmor (1.28.0)

[warning] 17-17: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false

(artipacked)


[error] 19-19: runtime artifacts potentially vulnerable to a cache poisoning attack (cache-poisoning): enables caching by default

(cache-poisoning)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/publish.yml around lines 17 - 19, Update the
actions/checkout@v4 and actions/setup-node@v4 entries in the publishing workflow
to immutable commit SHA pins, reusing the corresponding SHA values already used
by other workflows for these action versions while preserving their current
action versions and order.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This workflow grants id-token: write and runs npm publish, but pins actions/checkout and actions/setup-node to mutable major-version tags (@v4) rather than immutable commit SHAs. Pin these actions to specific commit SHAs to reduce supply-chain risk during publishing.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At .github/workflows/publish.yml, line 19:

<comment>This workflow grants `id-token: write` and runs `npm publish`, but pins `actions/checkout` and `actions/setup-node` to mutable major-version tags (`@v4`) rather than immutable commit SHAs. Pin these actions to specific commit SHAs to reduce supply-chain risk during publishing.</comment>

<file context>
@@ -0,0 +1,26 @@
+    steps:
+      - uses: actions/checkout@v4
+
+      - uses: actions/setup-node@v4
+        with:
+          node-version: 22
</file context>
Suggested change
- uses: actions/setup-node@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4

with:
node-version: 22
registry-url: https://registry.npmjs.org

- run: npm ci

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0: The npm ci step will fail on this repository: it is a pnpm workspace (packageManager pnpm@11.1.1) and there is a pnpm-lock.yaml but no package-lock.json, which npm ci requires. This workflow will error out before it ever reaches the build or publish steps. Use the pnpm toolchain, consistent with the rest of the repo (ci.yml / release.yml): pnpm/action-setup followed by pnpm install --frozen-lockfile.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At .github/workflows/publish.yml, line 24:

<comment>The `npm ci` step will fail on this repository: it is a pnpm workspace (packageManager pnpm@11.1.1) and there is a pnpm-lock.yaml but no package-lock.json, which `npm ci` requires. This workflow will error out before it ever reaches the build or publish steps. Use the pnpm toolchain, consistent with the rest of the repo (ci.yml / release.yml): `pnpm/action-setup` followed by `pnpm install --frozen-lockfile`.</comment>

<file context>
@@ -0,0 +1,26 @@
+          node-version: 22
+          registry-url: https://registry.npmjs.org
+
+      - run: npm ci
+      - run: npm run build --if-present
+      - run: npm publish
</file context>

- run: npm run build --if-present
- run: npm publish
Comment on lines +24 to +26

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Tracked workflow/package files:"
git ls-files | rg '(^|/)(package\.json|pnpm-lock\.yaml|package-lock\.json|npm-shrinkwrap\.json|yarn\.lock|\.github/workflows/publish\.yml|\.github/workflows/release\.yml)$|package\.json$|pnpm-lock\.yaml$' | sort

echo
echo "publish.yml:"
if [ -f .github/workflows/publish.yml ]; then
  cat -n .github/workflows/publish.yml
fi

echo
echo "release.yml:"
if [ -f .github/workflows/release.yml ]; then
  cat -n .github/workflows/release.yml
fi

echo
echo "Root package.json first 220 lines:"
if [ -f package.json ]; then
  sed -n '1,220p' package.json | cat -n
fi

echo
echo "workspace members and paykit package.json:"
if [ -f packages/paykit/package.json ]; then
  sed -n '1,220p' packages/paykit/package.json | cat -n
fi

echo
echo "package manager declarations:"
python3 - <<'PY'
import json, pathlib
for p in [pathlib.Path('package.json'), pathlib.Path('packages/paykit/package.json')]:
    if p.exists():
        data=json.loads(p.read_text())
        print(f'{p}:')
        for k in ['scripts','packageManager','private','publishConfig','workspaces','scripts']:
            if k in data:
                print(f'  {k}={data[k]!r}')
PY

Repository: getpaykit/paykit

Length of output: 8778


Do not publish from the root package using npm.

Run npm ci and npm publish from packages/paykit, or use the existing pnpm/Changesets release command. The root package is private, the repository uses pnpm, and this workflow can install from no npm lockfile or try to publish the wrong package.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/publish.yml around lines 24 - 26, Update the publish
workflow steps around npm ci, npm run build, and npm publish so release commands
execute from packages/paykit rather than the private repository root. Prefer the
repository’s existing pnpm/Changesets release command; otherwise configure the
package-specific install and publish steps to use the pnpm-managed lockfile and
publish the paykit package only.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0: Running npm publish from the repo root will be rejected because the root package.json is marked private: true (npm errors with "This package has been marked as private"). This workflow should follow the repo's established publish flow rather than publishing the root package: build the packages and invoke scripts/publish-dist.mjs (or run the ci:release script), which publishes packages/paykit/dist with --access public. As written, the publish step cannot succeed.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At .github/workflows/publish.yml, line 26:

<comment>Running `npm publish` from the repo root will be rejected because the root package.json is marked `private: true` (npm errors with "This package has been marked as private"). This workflow should follow the repo's established publish flow rather than publishing the root package: build the packages and invoke `scripts/publish-dist.mjs` (or run the `ci:release` script), which publishes `packages/paykit/dist` with `--access public`. As written, the publish step cannot succeed.</comment>

<file context>
@@ -0,0 +1,26 @@
+
+      - run: npm ci
+      - run: npm run build --if-present
+      - run: npm publish
</file context>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: The npm publish step has no NODE_AUTH_TOKEN set in its environment, so npm cannot authenticate to the registry (setup-node's registry-url writes an .npmrc that expects that variable) and publish will fail with a 401. Add env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} to the publish step, matching the existing release.yml and npm-dist-tag.yml workflows.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At .github/workflows/publish.yml, line 26:

<comment>The `npm publish` step has no `NODE_AUTH_TOKEN` set in its environment, so npm cannot authenticate to the registry (setup-node's registry-url writes an .npmrc that expects that variable) and publish will fail with a 401. Add `env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}` to the publish step, matching the existing release.yml and npm-dist-tag.yml workflows.</comment>

<file context>
@@ -0,0 +1,26 @@
+
+      - run: npm ci
+      - run: npm run build --if-present
+      - run: npm publish
</file context>

2 changes: 2 additions & 0 deletions apps/web/content/docs/database.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ PayKit creates tables prefixed with `paykit_`. The key ones are:
PayKit owns these tables. Don't write to them directly. Use the PayKit API.
</Callout>

`paykit_subscription` rows can share one provider subscription: [combined checkout and add-ons](/docs/subscriptions#combined-checkout-add-ons) put a base plan and its add-ons on a single provider subscription, tracked as one row per plan. `paykit_product` records whether a plan is billed as a flat recurring price or [metered via Stripe usage-based billing](/docs/metered-usage#stripe-usage-based-billing).

## Migrations

`paykitjs push` applies any pending migrations. Run it on initial setup and whenever you update your plan configuration.
Expand Down
4 changes: 4 additions & 0 deletions apps/web/content/docs/entitlements.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,7 @@ export async function POST(request: Request) {
```

This pattern ensures you don't charge usage for failed requests, and you don't serve responses to customers who've hit their limit.

<Callout type="info">
`report()` decrements a local quota — it never involves your payment provider. If you need Stripe to actually calculate a bill from usage, see [Stripe usage-based billing](/docs/metered-usage#stripe-usage-based-billing).
</Callout>
65 changes: 65 additions & 0 deletions apps/web/content/docs/metered-usage.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,68 @@ export async function POST(request: Request) {
<Callout type="info">
If you just need on/off access without tracking usage, use `boolean` type instead. `check` still returns `allowed`, but there's no balance to track or reset.
</Callout>

## Stripe usage-based billing

Everything above is a **local quota**: PayKit tracks a balance in your database and gates access with `check()`/`report()`. Stripe is never involved, and there's nothing to bill — the plan's price, if any, is a flat recurring fee.

Stripe usage-based billing is a different thing: Stripe itself calculates a charge from usage you report, with no cap. Define it with a `meteredBy` price instead of a flat `amount`:

```ts title="products.ts"
const apiCalls = feature({ id: "api_calls", type: "metered" });

export const usagePlan = plan({
id: "usage",
name: "API Usage",
group: "usage",
price: { meteredBy: apiCalls, unitAmount: 0.01, interval: "month" },
});
```

When you sync this plan, PayKit creates a Stripe [Billing Meter](https://docs.stripe.com/billing/subscriptions/usage-based) and a metered Price for it automatically. Report usage with `reportUsage()` instead of `report()`:

```ts
await paykit.reportUsage({
customerId: userId,
featureId: "api_calls",
quantity: 1,
});
```

Stripe aggregates reported usage and bills it on the customer's next invoice at `unitAmount` per unit. There's no local balance to check beforehand — `reportUsage()` fails only if the customer has no active subscription for a plan metered by that feature.

<Callout type="warn">
Unlike `report()`, `reportUsage()` doesn't swallow failures — its entire purpose is the billing side effect, so a failed call throws. If you need at-least-once delivery, pass a stable `eventId`; PayKit forwards it as Stripe's `identifier`, which Stripe deduplicates on.
</Callout>

### Combining a quota with billed overage

A single price can't be both a flat fee and metered overage in Stripe. To offer "some usage included, then billed per unit beyond that," compose two plans and combine them into one checkout with `addOnPlanIds`:

```ts title="products.ts"
export const pro = plan({
id: "pro",
name: "Pro",
group: "base",
price: { amount: 19, interval: "month" },
includes: [messages({ limit: 2_000, reset: "month" })], // included quota
});

export const overage = plan({
id: "overage",
name: "Extra Usage",
group: "usage",
price: { meteredBy: apiCalls, unitAmount: 0.01, interval: "month" }, // billed beyond it
});
```

```ts
await paykit.subscribe({
customerId: "user_123",
planId: "pro",
addOnPlanIds: ["overage"],
successUrl: "https://myapp.com/billing/success",
});
```

Gate the included quota with `check()`/`report()` as usual. Once a customer exceeds it, call `reportUsage()` for the extra units instead — Stripe bills those separately at `unitAmount`.
51 changes: 51 additions & 0 deletions apps/web/content/docs/plans-and-features.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,38 @@ Groups let you model common billing patterns:

Every plan that has `default: true` must belong to a group. Without a group, PayKit doesn't know which set of plans a customer is being placed into.

### Combining groups in one checkout

Groups are mutually exclusive within themselves, but a customer can hold one active plan per group at the same time — that's how a `base` plan and an `addons` plan coexist. Pass `addOnPlanIds` to `subscribe()` to combine a plan from each group into a single checkout, instead of running a separate checkout per group:

```ts
export const pro = plan({
id: "pro",
name: "Pro",
group: "base",
price: { amount: 19, interval: "month" },
includes: [messages({ limit: 2_000, reset: "month" })],
});

export const extraMessages = plan({
id: "extra_messages",
name: "Extra Messages",
group: "addons",
price: { amount: 5, interval: "month" },
includes: [messages({ limit: 500, reset: "month" })],
});

// One checkout, one payment, both plans active afterward.
await paykit.subscribe({
customerId: "user_123",
planId: "pro",
addOnPlanIds: ["extra_messages"],
successUrl: "https://myapp.com/billing/success",
});
```

The customer's `messages` balance pools across both plans — 2,000 from `pro` plus 500 from `extra_messages`, 2,500 total — since [entitlements](/docs/entitlements) aggregate every active subscription for a feature, not just one. See [Combined checkout](/docs/subscriptions#combined-checkout-add-ons) for the full behavior, constraints, and how to add or remove a single add-on later.

## Default plans

The default plan is the fallback for a group. It's typically your free tier.
Expand Down Expand Up @@ -125,6 +157,25 @@ price: { amount: 19, interval: "month" }
- `interval` can be `month` or `year`
- `amount` is in dollars, max $999,999.99

### Metered Stripe pricing

For pay-as-you-go pricing where Stripe calculates the bill from reported usage, use `meteredBy` and `unitAmount` instead of `amount`:

```ts
const apiCalls = feature({ id: "api_calls", type: "metered" });

export const usagePlan = plan({
id: "usage",
name: "API Usage",
group: "usage",
price: { meteredBy: apiCalls, unitAmount: 0.01, interval: "month" },
});
```

`unitAmount` is the price per unit, charged for whatever quantity you report via `paykit.reportUsage()`. See [Stripe usage-based billing](/docs/metered-usage#stripe-usage-based-billing) for how reporting works and how this differs from the local metered quotas above.

This is a separate concept from a `metered`-type feature's `limit`/`reset` — that's a local quota PayKit tracks and gates on for you, with no Stripe involvement. `meteredBy` prices are for genuine usage-based billing, where Stripe itself computes the charge. You can combine both: a licensed base plan with a local quota, plus a `meteredBy` add-on for usage beyond it, joined into one checkout via `addOnPlanIds`.

## Passing products to PayKit

Pass your products array to `createPayKit`. You can import them directly or re-export them from a module object.
Expand Down
66 changes: 66 additions & 0 deletions apps/web/content/docs/subscriptions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,71 @@ await paykit.subscribe({ customerId: "user_123", planId: "pro" });
// Scheduled target changes from "free" to "pro".
```

## Combined checkout (add-ons)

Pass `addOnPlanIds` alongside `planId` to combine a base plan with one or more add-ons into a single checkout. The customer sees every line item on the same provider-hosted page and confirms one payment for all of them.

<Tabs items={["Server", "Client"]}>
<Tab value="Server">
```ts
const result = await paykit.subscribe({
customerId: "user_123",
planId: "pro",
addOnPlanIds: ["extra_messages"],
successUrl: "https://myapp.com/billing/success",
cancelUrl: "https://myapp.com/billing",
});

if (result.paymentUrl) {
// Redirect user to provider checkout — one page, both line items
}
```
</Tab>
<Tab value="Client">
```tsx
const { paymentUrl } = await paykitClient.subscribe({
planId: "pro",
addOnPlanIds: ["extra_messages"],
successUrl: "/billing/success",
cancelUrl: "/billing",
});

if (paymentUrl) {
window.location.href = paymentUrl;
}
```
</Tab>
</Tabs>

Behind the scenes, this creates one provider subscription with one line item per plan. Every plan in the list — the base plan and every add-on — is tracked as its own subscription record, so [entitlements](/docs/entitlements) and renewals stay correct for each of them independently.

A few constraints apply to combined checkout:

- Every plan must be paid. Free plans can't be combined this way.
- Each plan must belong to a different [group](/docs/plans-and-features#plan-groups). You can't combine two plans from the same group in one call.
- None of the plans can already have an active subscription for that customer. To add or remove a single plan on an already-active subscription, use `addAddOn`/`removeAddOn` below instead.

<Callout type="info">
Combined checkout is for new subscriptions. Upgrading or downgrading the base plan afterward works exactly like a normal `subscribe()` call — see [Upgrades](#upgrades) and [Downgrades](#downgrades) above — and leaves any add-ons on the subscription untouched.
</Callout>

## Managing add-ons

Once a customer has an active subscription, `addAddOn` and `removeAddOn` attach or detach a single add-on plan without touching the rest of the subscription.

```ts
// Attach extra_messages to the customer's existing subscription.
// Charges their saved card immediately, the same way an upgrade does.
await paykit.addAddOn({ customerId: "user_123", planId: "extra_messages" });

// Detach it again. Ends immediately — no need to wait for a webhook.
await paykit.removeAddOn({ customerId: "user_123", planId: "extra_messages" });
```

`addAddOn` requires the customer to already have an active provider-backed subscription to attach to. If they have more than one — for example, they subscribed to two groups separately instead of combining them — pass `targetSubscriptionId` to say which one to attach to.

`removeAddOn` fails with a clear error if the plan is the subscription's only remaining item — Stripe doesn't allow removing the last item from a subscription. Cancel the whole subscription with `subscribe()` to the group's default plan instead.

## Behavior summary

| Scenario | Behavior |
Expand All @@ -114,6 +179,7 @@ await paykit.subscribe({ customerId: "user_123", planId: "pro" });
|---|---|---|
| `planId` | Yes | The target plan ID. Must be a valid plan from your config. |
| `customerId` | Server only | The customer to subscribe. Not needed on the client (resolved from `identify`). |
| `addOnPlanIds` | No | Additional plan IDs to combine with `planId` into one checkout. See [Combined checkout](#combined-checkout-add-ons). |
| `successUrl` | Recommended | Where to redirect after successful checkout. |
| `cancelUrl` | Recommended | Where to redirect if the customer cancels checkout. |
| `forceCheckout` | No | Forces a checkout flow even if the customer already has a payment method. |
4 changes: 4 additions & 0 deletions apps/web/content/docs/webhook-events.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ Payload shape:
| `customerId` | `string` | Your internal customer identifier |
| `subscriptions` | `Subscription[]` | The customer's updated subscriptions |

<Callout type="info">
If the customer used [combined checkout or add-ons](/docs/subscriptions#combined-checkout-add-ons), `subscriptions` contains one entry per plan — the base plan and every add-on — even though they're billed together on a single provider subscription.
</Callout>

## Wildcard handler

`"*"` catches all PayKit events. Useful for logging or debugging.
Expand Down
86 changes: 86 additions & 0 deletions e2e/core/addon/add-addon.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it } from "vitest";

import { subscription } from "../../../packages/paykit/src/database/schema";
import {
createTestCustomerWithPM,
createTestPayKit,
dumpStateOnFailure,
expectExactMeteredBalance,
expectProduct,
subscribeCustomer,
type TestPayKit,
} from "../../test-utils";

describe("add-addon: attaching an add-on to an already-active subscription", () => {
let t: TestPayKit;
let customerId: string;

beforeAll(async () => {
t = await createTestPayKit();
const customer = await createTestCustomerWithPM({
t,
customer: {
id: "test_add_addon",
email: "add-addon@test.com",
name: "Add Addon Test",
},
});
customerId = customer.customerId;

await subscribeCustomer({ t, customerId, planId: "pro" });
});

afterAll(async () => {
await t?.cleanup();
});

it("adding extra_messages attaches a second item to the same Stripe subscription", async () => {
try {
const result = await t.paykit.addAddOn({ customerId, planId: "extra_messages" });
expect(result.paymentUrl).toBeNull();

await expectProduct({
database: t.database,
customerId,
planId: "pro",
expected: { status: "active" },
});
await expectProduct({
database: t.database,
customerId,
planId: "extra_messages",
expected: { status: "active" },
});

const rows = await t.database
.select({
stripeSubscriptionId: subscription.stripeSubscriptionId,
stripeSubscriptionItemId: subscription.stripeSubscriptionItemId,
})
.from(subscription)
.where(eq(subscription.customerId, customerId));
const activeRows = rows.filter((row) => row.stripeSubscriptionId != null);

const distinctSubscriptionIds = new Set(activeRows.map((row) => row.stripeSubscriptionId));
expect(distinctSubscriptionIds.size).toBe(1);

const itemIds = activeRows
.map((row) => row.stripeSubscriptionItemId)
.filter((id): id is string => id != null);
expect(new Set(itemIds).size).toBe(itemIds.length);
expect(itemIds.length).toBeGreaterThanOrEqual(2);

await expectExactMeteredBalance({
paykit: t.paykit,
customerId,
featureId: "messages",
limit: 700,
remaining: 700,
});
} catch (error) {
await dumpStateOnFailure(t.database, t.dbPath);
throw error;
}
});
});
Loading