From 88f26e1470a7cd088e3fef6a35c72b6df5a02cf9 Mon Sep 17 00:00:00 2001 From: LamsOfJos Date: Fri, 16 Oct 2026 18:58:25 +0100 Subject: [PATCH] agenticpay 1258-1261 --- PR_DESCRIPTION.md | 97 ++++++------------- README.md | 2 +- .../services/__tests__/issues-456-459.test.ts | 2 +- .../__tests__/withdrawal-allowlist.test.ts | 28 ++++++ .../payments/__tests__/payment-router.test.ts | 17 ++++ .../src/services/payments/payment-router.ts | 4 + .../payouts/__tests__/payout-engine.test.ts | 28 ++++++ backend/src/services/payouts/payout-engine.ts | 18 +++- backend/src/services/withdrawal-allowlist.ts | 16 ++- docs/PAYMENT_ROUTING.md | 13 +++ docs/PAYOUT_APPROVALS.md | 6 ++ docs/README.md | 42 ++++++++ docs/WITHDRAWAL_SECURITY.md | 20 ++++ package-lock.json | 88 +++++++---------- package.json | 5 +- scripts/docs-index.test.mjs | 24 +++++ 16 files changed, 281 insertions(+), 129 deletions(-) create mode 100644 docs/PAYMENT_ROUTING.md create mode 100644 docs/README.md create mode 100644 docs/WITHDRAWAL_SECURITY.md create mode 100644 scripts/docs-index.test.mjs diff --git a/PR_DESCRIPTION.md b/PR_DESCRIPTION.md index 35df3dd4..150a26a1 100644 --- a/PR_DESCRIPTION.md +++ b/PR_DESCRIPTION.md @@ -1,77 +1,44 @@ -# PR: Implement funnel, ML forecasting, A/B testing, customer health (#852 #853 #854 #855) +# Docs, payment correctness, payout reliability, and withdrawal security (#1258–#1261) -**Branch:** `feat/issues-852-853-854-855` -**Base:** `main` -**Commits:** -- `bfc5bb0` feat: add funnel conversion tracking, ML forecasting, A/B testing, customer health (#852 #853 #854 #855) -- `05ac260` docs: add integration docs for funnel, ML forecast, AB testing, health +Closes #1258 +Closes #1259 +Closes #1260 +Closes #1261 -Closes #852 -Closes #853 -Closes #854 -Closes #855 +## Scope -## Summary -This PR implements all four requested features: +This PR delivers one focused increment for each issue, in issue order: -### #853 Add funnel conversion tracking -- Service `backend/src/services/funnel-tracking.ts` with `FunnelTrackingService` -- Custom funnel definitions, step ordering, conversion window, per-step stats (entered, conversionRate, stepConversionRate, dropOff, avg/median/p95 time to next) -- User journey tracking with completion and conversion time -- Routes `backend/src/routes/funnel-tracking.ts` mounted at `/api/v1/funnels` -- Tests `backend/src/services/__tests__/funnel-tracking.test.ts` (9 cases) -- Docs `backend/docs/FUNNEL_TRACKING.md` +### #1258 — Documentation discovery -### #854 Implement revenue forecasting with ML -- Service `backend/src/services/ml-forecast.ts` with `MLForecastService` -- Ensemble: linear regression, polynomial (degree 2 via 3x3 solve), exponential smoothing, Holt-Winters seasonal, moving average -- Cross-validated model selection (80/20 holdout, RMSE), confidence intervals (1.96*RMSE), seasonality detection via autocorrelation, trend analysis -- Routes `backend/src/routes/ml-forecast.ts` mounted at `/api/v1/forecast/ml` -- Tests `backend/src/services/__tests__/ml-forecast.test.ts` -- Docs `backend/docs/ML_FORECAST.md` +- Adds `docs/README.md` as a curated entry point for contributor, payment, SDK, operations, and security guides. +- Links the root README to the index. +- Adds a dependency-free Node test that verifies every local link in the index exists, and runs it from the root `test` command. -### #852 Build A/B testing framework -- Service `backend/src/services/ab-testing.ts` with `ABTestingService` -- Deterministic MD5 bucket assignment, weighted variants, traffic allocation, lifecycle (draft/running/paused/completed/archived) -- Statistical: two-proportion z-test, Wilson interval, Bayesian prob, sample size calculator -- Routes `backend/src/routes/ab-testing.ts` mounted at `/api/v1/ab-tests` -- Tests `backend/src/services/__tests__/ab-testing.test.ts` -- Docs `backend/docs/AB_TESTING.md` +### #1259 — Payment amount correctness -### #855 Build customer health score system -- Service `backend/src/services/customer-health.ts` with `CustomerHealthService` -- Composite 0-100 score: payment_health 30% + frequency 20% + recency 20% + engagement 15% + support 15%, with penalties for churn signals -- Levels champion/healthy/at_risk/critical, trend, riskReasons, recommendations, history, distribution, at-risk listing -- Routes `backend/src/routes/customer-health.ts` mounted at `/api/v1/customer-health` -- Tests `backend/src/services/__tests__/customer-health.test.ts` -- Docs `backend/docs/CUSTOMER_HEALTH.md` +- Rejects non-finite, zero, and negative amounts at the shared payment-provider router boundary. +- Returns `INVALID_PAYMENT_AMOUNT` with status `400` before invoking or recording metrics for any provider. +- Documents amount validation and provider selection/fallback behavior. -## API Registration -Updated `backend/src/index.ts` to mount all new routers and fix missing `allowancesRouter` import. +### #1260 — Payout state reliability -## How to create PR (when GitHub auth available) -```bash -git push -u origin feat/issues-852-853-854-855 -gh pr create --title "feat: add funnel conversion tracking, ML forecasting, A/B testing, customer health (#852 #853 #854 #855)" \ - --body "Closes #852, Closes #853, Closes #854, Closes #855" \ - --base main --head feat/issues-852-853-854-855 -``` -Or fork first: -```bash -gh repo fork Smartdevs17/agenticpay --clone=false -git remote add fork https://github.com//agenticpay.git -git push -u fork feat/issues-852-853-854-855 -gh pr create --repo Smartdevs17/agenticpay --head :feat/issues-852-853-854-855 -``` +- Rejects negative and non-finite pending payout balances. +- Returns defensive copies from payout evaluation, history, and audit APIs, including cloned `Date` values. +- Prevents callers from corrupting authoritative payout status, amount, or audit state by mutating returned objects. +- Updates the payout approval guide with the new state-integrity guarantees. -## Testing -Tests are vitest-based, run with: -```bash -cd backend && npm test src/services/__tests__/funnel-tracking.test.ts src/services/__tests__/ml-forecast.test.ts src/services/__tests__/ab-testing.test.ts src/services/__tests__/customer-health.test.ts -``` +### #1261 — Withdrawal decision security -All services expose `resetForTests()` for isolation. +- Applies the configured approver allowlist to rejection as well as approval. +- Fails closed when a non-allowlisted withdrawal has no configured approvers. +- Documents approver configuration, the API identity trust boundary, and velocity controls. -## Verification -- `git log --oneline bfc5bb0..HEAD` -- `git diff main..HEAD --stat` +## Tests + +- `npm run test:docs` +- `npm --workspace backend test -- src/services/payments/__tests__/payment-router.test.ts src/services/payouts/__tests__/payout-engine.test.ts src/services/__tests__/issues-456-459.test.ts src/services/__tests__/withdrawal-allowlist.test.ts` +- `npm run lint` +- `npm run build` + +New regression coverage includes invalid payment amounts, invalid payout balances, mutation isolation for payout/audit snapshots, missing withdrawal approvers, unauthorized withdrawal rejection, and documentation link integrity. diff --git a/README.md b/README.md index 2cf41a65..1f38ade2 100644 --- a/README.md +++ b/README.md @@ -504,7 +504,7 @@ For issues, questions, or suggestions: - **Issues**: GitHub Issues - **Discussions**: GitHub Discussions -- **Documentation**: See [docs/](./docs) folder +- **Documentation**: Start with the [documentation index](docs/README.md) --- diff --git a/backend/src/services/__tests__/issues-456-459.test.ts b/backend/src/services/__tests__/issues-456-459.test.ts index 4af063a6..d1d564f6 100644 --- a/backend/src/services/__tests__/issues-456-459.test.ts +++ b/backend/src/services/__tests__/issues-456-459.test.ts @@ -81,7 +81,7 @@ describe('Payout Engine (Issue 457)', () => { const approved = await engine.approveManualPayout(batches[0].id, { id: 'fin_1', role: 'finance_approver' }); expect(approved).toBe(true); - expect(batches[0].status).toBe('completed'); + expect(engine.getPayoutHistory()[0].status).toBe('completed'); }); }); diff --git a/backend/src/services/__tests__/withdrawal-allowlist.test.ts b/backend/src/services/__tests__/withdrawal-allowlist.test.ts index 1aa24071..42de8e5e 100644 --- a/backend/src/services/__tests__/withdrawal-allowlist.test.ts +++ b/backend/src/services/__tests__/withdrawal-allowlist.test.ts @@ -5,6 +5,7 @@ import { removeFromAllowlist, isAllowlisted, createWithdrawalRequest, + getWithdrawalRequest, approveWithdrawal, rejectWithdrawal, markExecuted, @@ -65,6 +66,33 @@ describe('withdrawal allowlist service', () => { expect(() => approveWithdrawal(request.id, 'eve')).toThrow(WithdrawalApprovalError); }); + it('fails closed when no withdrawal approvers are configured', () => { + const request = createWithdrawalRequest({ + walletId: 'wallet-unconfigured', + toAddress: 'GADDR_UNKNOWN', + amount: 50, + currency: 'XLM', + requestedBy: 'owner', + }); + + expect(() => approveWithdrawal(request.id, 'owner')).toThrow(/No approvers are configured/); + expect(() => rejectWithdrawal(request.id, 'owner')).toThrow(/No approvers are configured/); + }); + + it('rejects rejection attempts from non-authorized approvers', () => { + configureWallet('wallet-reject', { approvalThreshold: 1, approvers: ['alice'] }); + const request = createWithdrawalRequest({ + walletId: 'wallet-reject', + toAddress: 'GADDR_UNKNOWN', + amount: 50, + currency: 'XLM', + requestedBy: 'owner', + }); + + expect(() => rejectWithdrawal(request.id, 'eve')).toThrow(WithdrawalApprovalError); + expect(getWithdrawalRequest(request.id)?.status).toBe('pending_approval'); + }); + it('rejects a withdrawal request and prevents further approval', () => { configureWallet('wallet-4', { approvalThreshold: 1, approvers: ['alice'] }); const request = createWithdrawalRequest({ diff --git a/backend/src/services/payments/__tests__/payment-router.test.ts b/backend/src/services/payments/__tests__/payment-router.test.ts index af7e76a8..9ea66704 100644 --- a/backend/src/services/payments/__tests__/payment-router.test.ts +++ b/backend/src/services/payments/__tests__/payment-router.test.ts @@ -53,6 +53,23 @@ describe('ProviderPaymentRouter', () => { if (result.ok) expect(result.value.providerId).toBe('soroban'); }); + it.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY])( + 'rejects invalid amount %s before invoking a provider', + async (amount) => { + const provider = new MockPaymentProvider('soroban'); + registry.register(provider); + + const result = await router.route({ ...baseInput, amount }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.code).toBe('INVALID_PAYMENT_AMOUNT'); + expect(result.error.statusCode).toBe(400); + } + expect(registry.getMetrics('soroban')).toMatchObject({ successCount: 0, errorCount: 0 }); + }, + ); + it('falls back to another healthy strategy when the primary one fails', async () => { registry.register(new MockPaymentProvider('soroban', { shouldFail: true })); registry.register(new MockPaymentProvider('evm')); diff --git a/backend/src/services/payments/payment-router.ts b/backend/src/services/payments/payment-router.ts index 73a5aadd..e6ed4152 100644 --- a/backend/src/services/payments/payment-router.ts +++ b/backend/src/services/payments/payment-router.ts @@ -25,6 +25,10 @@ export class ProviderPaymentRouter extends BaseService { } async route(input: PaymentInput, preferredProviderId?: string): Promise> { + if (!Number.isFinite(input.amount) || input.amount <= 0) { + return this.fail('Payment amount must be a finite number greater than zero', 400, 'INVALID_PAYMENT_AMOUNT'); + } + const primaryId = selectProviderId(input, preferredProviderId, this.registry); const candidateIds = [primaryId, ...this.registry.list().filter((id) => id !== primaryId)]; diff --git a/backend/src/services/payouts/__tests__/payout-engine.test.ts b/backend/src/services/payouts/__tests__/payout-engine.test.ts index edf4d7dc..4561ad99 100644 --- a/backend/src/services/payouts/__tests__/payout-engine.test.ts +++ b/backend/src/services/payouts/__tests__/payout-engine.test.ts @@ -54,6 +54,28 @@ describe('PayoutEngine approval workflow', () => { expect(engine.getPayoutHistory()).toHaveLength(1); }); + it.each([-1, Number.NaN, Number.POSITIVE_INFINITY])('rejects invalid pending amount %s', (amount) => { + const engine = engineWithSchedule(); + + expect(() => engine.setPendingAmount('merch_1', amount)).toThrow('INVALID_PENDING_PAYOUT_AMOUNT'); + expect(engine.getPendingAmount('merch_1')).toBe(0); + }); + + it('returns snapshots that cannot mutate stored payout state', async () => { + const engine = engineWithSchedule({ autoApproveLimit: 500 }); + engine.setPendingAmount('merch_1', 600); + const [created] = await engine.evaluatePayouts(); + + created.status = 'completed'; + created.amount = 1; + created.createdAt.setUTCFullYear(1999); + + const [stored] = engine.getPayoutHistory(); + expect(stored.status).toBe('pending'); + expect(stored.amount).toBe(600); + expect(stored.createdAt.getUTCFullYear()).not.toBe(1999); + }); + it('rejects approval from callers without an approver role', async () => { const engine = engineWithSchedule({ autoApproveLimit: 500 }); engine.setPendingAmount('merch_1', 600); @@ -92,5 +114,11 @@ describe('PayoutEngine approval workflow', () => { const trail = engine.getAuditTrail(batch.id); expect(trail.map((entry) => entry.action)).toEqual(['created', 'approved']); expect(trail[1].actorId).toBe('fin_1'); + + trail[0].actorId = 'tampered'; + trail[0].timestamp.setUTCFullYear(1999); + const stored = engine.getAuditTrail(batch.id)[0]; + expect(stored.actorId).toBe('system'); + expect(stored.timestamp.getUTCFullYear()).not.toBe(1999); }); }); diff --git a/backend/src/services/payouts/payout-engine.ts b/backend/src/services/payouts/payout-engine.ts index 297fe9df..4879a7ca 100644 --- a/backend/src/services/payouts/payout-engine.ts +++ b/backend/src/services/payouts/payout-engine.ts @@ -45,6 +45,14 @@ export interface PayoutEngineOptions { const APPROVER_ROLES: readonly PayoutApproverRole[] = ['finance_approver', 'admin']; +function snapshotBatch(batch: PayoutBatch): PayoutBatch { + return { ...batch, createdAt: new Date(batch.createdAt) }; +} + +function snapshotAuditEntry(entry: PayoutAuditEntry): PayoutAuditEntry { + return { ...entry, timestamp: new Date(entry.timestamp) }; +} + function resolveApprovalThreshold(explicit?: number): number { const fromEnv = Number(process.env.PAYOUT_APPROVAL_THRESHOLD); const threshold = explicit ?? (fromEnv > 0 ? fromEnv : DEFAULT_PAYOUT_APPROVAL_THRESHOLD); @@ -66,6 +74,9 @@ export class PayoutEngine { } setPendingAmount(merchantId: string, amount: number) { + if (!Number.isFinite(amount) || amount < 0) { + throw new Error('INVALID_PENDING_PAYOUT_AMOUNT'); + } this.pendingAmounts.set(merchantId, amount); } @@ -107,7 +118,7 @@ export class PayoutEngine { this.pendingAmounts.set(schedule.merchantId, 0); this.batches.push(batch); this.record(batch.id, 'created', 'system', requiresApproval ? 'Awaiting approval' : 'Auto-approved'); - executedBatches.push(batch); + executedBatches.push(snapshotBatch(batch)); } } @@ -154,11 +165,12 @@ export class PayoutEngine { } getPayoutHistory(): PayoutBatch[] { - return this.batches; + return this.batches.map(snapshotBatch); } getAuditTrail(batchId?: string): PayoutAuditEntry[] { - return batchId ? this.auditTrail.filter(entry => entry.batchId === batchId) : [...this.auditTrail]; + const entries = batchId ? this.auditTrail.filter(entry => entry.batchId === batchId) : this.auditTrail; + return entries.map(snapshotAuditEntry); } private assertApprover(approver: PayoutApprover): void { diff --git a/backend/src/services/withdrawal-allowlist.ts b/backend/src/services/withdrawal-allowlist.ts index d8c81109..5d130ecf 100644 --- a/backend/src/services/withdrawal-allowlist.ts +++ b/backend/src/services/withdrawal-allowlist.ts @@ -212,6 +212,15 @@ export function listWithdrawalRequests(walletId?: string): WithdrawalRequest[] { export class WithdrawalApprovalError extends Error {} +function assertAuthorizedApprover(config: WalletWithdrawalConfig, approver: string): void { + if (config.approvers.length === 0) { + throw new WithdrawalApprovalError('No approvers are configured for this wallet'); + } + if (!config.approvers.includes(approver)) { + throw new WithdrawalApprovalError(`'${approver}' is not an authorized approver for this wallet`); + } +} + export function approveWithdrawal(id: string, approver: string): WithdrawalRequest { const request = withdrawalRequests.get(id); if (!request) throw new WithdrawalApprovalError('Withdrawal request not found'); @@ -220,9 +229,7 @@ export function approveWithdrawal(id: string, approver: string): WithdrawalReque } const cfg = getOrCreateConfig(request.walletId); - if (cfg.approvers.length > 0 && !cfg.approvers.includes(approver)) { - throw new WithdrawalApprovalError(`'${approver}' is not an authorized approver for this wallet`); - } + assertAuthorizedApprover(cfg, approver); if (!request.approvals.includes(approver)) { request.approvals.push(approver); } @@ -242,6 +249,9 @@ export function rejectWithdrawal(id: string, approver: string): WithdrawalReques throw new WithdrawalApprovalError(`Cannot reject a request in status '${request.status}'`); } + const cfg = getOrCreateConfig(request.walletId); + assertAuthorizedApprover(cfg, approver); + request.rejections.push(approver); request.status = 'rejected'; request.updatedAt = Date.now(); diff --git a/docs/PAYMENT_ROUTING.md b/docs/PAYMENT_ROUTING.md new file mode 100644 index 00000000..7f6cb539 --- /dev/null +++ b/docs/PAYMENT_ROUTING.md @@ -0,0 +1,13 @@ +# Payment Provider Routing + +The provider router selects a payment rail from the requested network first, then from the token or currency. A valid explicit provider preference takes precedence. + +## Amount contract + +`PaymentInput.amount` must be a finite number greater than zero. The router rejects zero, negative amounts, `NaN`, and positive or negative infinity with HTTP status `400` and code `INVALID_PAYMENT_AMOUNT` before any provider is invoked. + +Callers should still format amounts to the precision supported by the selected asset. Provider-specific conversion to on-chain or fiat minor units happens after this shared validation boundary. + +## Provider fallback + +When a provider returns a failure, the router tries the remaining registered providers in registration order. If every provider fails, it returns `PROVIDER_UNAVAILABLE` with HTTP status `502`. diff --git a/docs/PAYOUT_APPROVALS.md b/docs/PAYOUT_APPROVALS.md index 61b875ea..52f9fd63 100644 --- a/docs/PAYOUT_APPROVALS.md +++ b/docs/PAYOUT_APPROVALS.md @@ -11,6 +11,9 @@ A payout batch is created with status `pending` when either condition is true: Otherwise the batch is auto-approved and marked `completed`. +Pending balances must be finite and non-negative. The engine rejects invalid balance updates with +`INVALID_PENDING_PAYOUT_AMOUNT` so a negative value, `NaN`, or infinity cannot poison future evaluations. + ## Configuring the threshold Set `PAYOUT_APPROVAL_THRESHOLD` (see [ENV_VARS.md](../ENV_VARS.md)). If it is unset, the default is `10000`. @@ -43,6 +46,9 @@ so the rejection cannot be bypassed. Every decision is recorded with the batch ID, action (`created`, `approved`, `rejected`, `retried`), actor ID, timestamp, and optional details. Query it with `PayoutEngine.getAuditTrail(batchId?)`. +History and audit APIs return defensive snapshots. Changing a returned object or its `Date` values +does not alter the engine's stored payout state. + ## Reserved funds When a batch is created, its amount is moved out of the merchant's pending balance, so repeated diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..66f01377 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,42 @@ +# AgenticPay Documentation + +Use this page as the entry point for the repository's maintained guides. API-specific implementation notes live in [`backend/docs`](../backend/docs/). + +## Start here + +- [Local development with the dev container](DEVCONTAINER.md) +- [Architecture overview](architecture.md) +- [API overview](api.md) +- [Deployment guide](deployment.md) +- [Troubleshooting](troubleshooting.md) +- [Contributing](../CONTRIBUTING.md) + +## Payments and billing + +- [Instant transfers](INSTANT_TRANSFERS.md) +- [Marketplace escrow](MARKETPLACE_ESCROW.md) +- [Payment links](PAYMENT_LINKS.md) +- [Payment provider routing](PAYMENT_ROUTING.md) +- [Payout approvals](PAYOUT_APPROVALS.md) +- [Subscription usage billing](SUBSCRIPTION_USAGE_BILLING.md) +- [Wallet payments](WALLET_PAYMENTS.md) +- [Withdrawal approval security](WITHDRAWAL_SECURITY.md) + +## Integrations and SDKs + +- [API documentation](API-DOCS.md) +- [SDK overview](sdk/README.md) +- [SDK error handling](sdk/ERROR-HANDLING.md) +- [Webhook signature verification](security/webhook-signatures.md) + +## Operations and security + +- [Disaster recovery](DISASTER_RECOVERY.md) +- [Logging](logging.md) +- [Security policy](security-policy.md) +- [Audit logging](security/audit-logging.md) +- [Configuration reference](config/CONFIGURATION.md) + +## Keeping this index healthy + +Run `npm run test:docs` from the repository root after moving or renaming a linked guide. The test checks every local Markdown link on this page and fails when its target does not exist. diff --git a/docs/WITHDRAWAL_SECURITY.md b/docs/WITHDRAWAL_SECURITY.md new file mode 100644 index 00000000..10f690b1 --- /dev/null +++ b/docs/WITHDRAWAL_SECURITY.md @@ -0,0 +1,20 @@ +# Withdrawal Approval Security + +Withdrawals to destinations outside a wallet's allowlist remain in `pending_approval` until the configured approval threshold is met. + +## Approver configuration + +Configure an explicit, non-empty list of approver IDs for every wallet that can create non-allowlisted withdrawals. Approval and rejection both fail closed when the list is empty, and both reject identities that are not present in the list. + +```ts +configureWallet('wallet-1', { + approvalThreshold: 2, + approvers: ['finance-user-1', 'finance-user-2'], +}); +``` + +Do not use request-body identity as the authentication mechanism at the API edge. The caller must be authenticated and authorized before its trusted identity is passed to the withdrawal service. + +## Destination and velocity controls + +Allowlisted destinations may be approved automatically when the configured threshold permits it. Hourly count, hourly amount, and daily amount velocity limits still apply to every destination, including allowlisted ones. diff --git a/package-lock.json b/package-lock.json index 1226539a..2279f7dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1676,6 +1676,23 @@ "node": ">=18" } }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "dev": true, @@ -4324,9 +4341,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4347,9 +4361,6 @@ "cpu": [ "arm" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4370,9 +4381,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4393,9 +4401,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4416,9 +4421,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4439,9 +4441,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -7085,9 +7084,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7101,9 +7097,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -8693,9 +8686,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -8712,9 +8702,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -8731,9 +8718,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -8750,9 +8734,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -8769,9 +8750,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -8788,9 +8766,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -8935,9 +8910,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -8954,9 +8926,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -18880,9 +18849,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -18903,9 +18869,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -24903,6 +24866,23 @@ "node": ">=18" } }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/vite/node_modules/esbuild": { "version": "0.25.12", "dev": true, diff --git a/package.json b/package.json index ead1655b..ce081856 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ ], "scripts": { "build": "turbo run build", - "test": "turbo run test", + "test": "npm run test:docs && turbo run test", + "test:docs": "node --test scripts/docs-index.test.mjs", "lint": "turbo run lint", "dev": "turbo run dev", "sandbox:up": "docker compose -f docker-compose.yml -f docker-compose.sandbox.yml up -d && npm run sandbox:seed", @@ -56,4 +57,4 @@ "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0" } -} \ No newline at end of file +} diff --git a/scripts/docs-index.test.mjs b/scripts/docs-index.test.mjs new file mode 100644 index 00000000..66325c5c --- /dev/null +++ b/scripts/docs-index.test.mjs @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const indexPath = resolve(repositoryRoot, 'docs/README.md'); + +function localMarkdownTargets(markdown) { + return [...markdown.matchAll(/\[[^\]]+\]\(([^)]+)\)/g)] + .map((match) => match[1].trim()) + .filter((target) => !target.startsWith('#') && !/^[a-z][a-z\d+.-]*:/i.test(target)) + .map((target) => decodeURIComponent(target.split('#', 1)[0])); +} + +test('the documentation index only links to existing local files', () => { + const markdown = readFileSync(indexPath, 'utf8'); + const missing = localMarkdownTargets(markdown) + .map((target) => resolve(dirname(indexPath), target)) + .filter((target) => !existsSync(target)); + + assert.deepEqual(missing, []); +});