fix(api): register the v1 stamp endpoint scope and derive the webhook event catalogue from one source (#1930)
POST /api/v1/companies/{companyId}/inbox-items/{id}/stamp registered itself
with scope documents:write but had no V1_ENDPOINT_SCOPES entry, and the
wrapper resolves the required scope from that map before it validates the
bearer token, so the route answered NOT_FOUND to every caller. Add the entry,
drop the three phantom entries that had no route (GET openapi.yaml, GET
companies/:companyId, GET companies/:companyId/events), and add a parity test
that pins the scope map to the endpoint registry in both directions, checks
every pattern against an existing route file, and checks every v1 route file
is imported by load-routes.ts.
The webhook event catalogue was hand-copied in three places and had drifted:
the fan-out handler delivered 28 events while the v1 create enum, the OpenAPI
spec, the generated agent skill and the docs page listed 24, so the four
reconciliation.* events could not be subscribed to. lib/webhooks/public-events.ts
is now the single source; the handler set, the Zod enum and the docs section
derive from it, with tests that pin each surface to the catalogue. The PATCH
webhook docs no longer tell agents to delete and recreate a webhook to rotate
its secret: POST .../rotate-secret exists.
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
f08fc2c274
commit
d3869e6694
@@ -16,8 +16,20 @@ describe('resolveRequiredScope', () => {
|
||||
|
||||
it('resolves :param patterns to a single scope', () => {
|
||||
expect(
|
||||
resolveRequiredScope('GET', '/api/v1/companies/8fd5b1f4-1111-2222-3333-444455556666'),
|
||||
).toBe('companies:read')
|
||||
resolveRequiredScope(
|
||||
'GET',
|
||||
'/api/v1/companies/8fd5b1f4-1111-2222-3333-444455556666/customers/0b6c7d8e-1111-2222-3333-444455556666',
|
||||
),
|
||||
).toBe('customers:read')
|
||||
})
|
||||
|
||||
it('resolves the inbox-items stamp verb (previously unregistered: 404 with a valid key)', () => {
|
||||
expect(
|
||||
resolveRequiredScope(
|
||||
'POST',
|
||||
'/api/v1/companies/8fd5b1f4-1111-2222-3333-444455556666/inbox-items/0b6c7d8e-1111-2222-3333-444455556666/stamp',
|
||||
),
|
||||
).toBe('documents:write')
|
||||
})
|
||||
|
||||
it('returns null for unknown paths', () => {
|
||||
|
||||
+12
-6
@@ -10,7 +10,15 @@
|
||||
* Endpoints not listed here are public (no auth): only the discovery routes
|
||||
* (`/llms.txt`, `/.well-known/skills`, `/api/v1/health`, `/api/v1/openapi.json`)
|
||||
* fall into that bucket. Everything else under `/api/v1/` MUST be in this map
|
||||
* or the wrapper will refuse the request with INSUFFICIENT_SCOPE.
|
||||
* or the wrapper answers NOT_FOUND before it even looks at the bearer token
|
||||
* (`resolveRequiredScope` returns null for an unknown path).
|
||||
*
|
||||
* This map and the endpoint registry (`lib/api/v1/registry.ts`, populated by
|
||||
* `load-routes.ts`) are kept in lock-step by
|
||||
* `lib/api/v1/__tests__/scope-registry-parity.test.ts`: every registered
|
||||
* endpoint needs an entry with the same scope, and every entry needs a
|
||||
* registered endpoint. The inbox-items stamp route shipped without an entry
|
||||
* and answered 404 to valid keys until that test existed.
|
||||
*/
|
||||
|
||||
import type { ApiKeyScope } from './api-keys'
|
||||
@@ -22,7 +30,6 @@ import type { ApiKeyScope } from './api-keys'
|
||||
export const V1_PUBLIC_ENDPOINTS: ReadonlyArray<string> = [
|
||||
'GET /api/v1/health',
|
||||
'GET /api/v1/openapi.json',
|
||||
'GET /api/v1/openapi.yaml',
|
||||
]
|
||||
|
||||
/**
|
||||
@@ -40,7 +47,6 @@ export const V1_ENDPOINT_SCOPES: Record<string, ApiKeyScope> = {
|
||||
'GET /api/v1/companies': 'companies:read',
|
||||
// Issue #1814: programmatic company creation (partner provisioning, agents).
|
||||
'POST /api/v1/companies': 'companies:write',
|
||||
'GET /api/v1/companies/:companyId': 'companies:read',
|
||||
// Issue #1348: company-settings write (same field set as the MCP tool
|
||||
// gnubok_update_company_settings; direct write, no staging).
|
||||
'PATCH /api/v1/companies/:companyId/settings': 'companies:write',
|
||||
@@ -48,9 +54,6 @@ export const V1_ENDPOINT_SCOPES: Record<string, ApiKeyScope> = {
|
||||
// Operations (async long-running tasks)
|
||||
'GET /api/v1/operations/:id': 'operations:read',
|
||||
|
||||
// Events (webhook fallback / event log polling)
|
||||
'GET /api/v1/companies/:companyId/events': 'events:read',
|
||||
|
||||
// Customers (Phase 2 PR-A: reads; Phase 2 PR-B-1: writes)
|
||||
'GET /api/v1/companies/:companyId/customers': 'customers:read',
|
||||
'GET /api/v1/companies/:companyId/customers/:id': 'customers:read',
|
||||
@@ -117,6 +120,9 @@ export const V1_ENDPOINT_SCOPES: Record<string, ApiKeyScope> = {
|
||||
'POST /api/v1/companies/:companyId/documents': 'documents:write',
|
||||
'GET /api/v1/companies/:companyId/documents/:id/download': 'documents:read',
|
||||
'POST /api/v1/companies/:companyId/documents/:id/link': 'documents:write',
|
||||
// Inbox item stamp: closes an invoice_inbox_items row against the JE it
|
||||
// was booked to. Rides documents:write like the link verb it complements.
|
||||
'POST /api/v1/companies/:companyId/inbox-items/:id/stamp': 'documents:write',
|
||||
|
||||
// Phase 3: transactions + reconciliation vertical.
|
||||
// Reads
|
||||
|
||||
Reference in New Issue
Block a user