[ { "id": "F001", "description": "Add a required `namespace: string` first parameter to TokenBucketRateLimiter.tryConsume, getState, getBucketKey, and getBucketConfig; bucket key becomes `${prefix}${namespace}:${tenantId}[:${userId}]`.", "implemented": true, "prdRefs": ["Data / API / Integrations"] }, { "id": "F002", "description": "Widen the BucketConfigGetter signature from `(tenantId) => BucketConfig` to `(tenantId, subjectId?) => BucketConfig` so per-API-key and per-webhook overrides can be resolved.", "implemented": true, "prdRefs": ["Data / API / Integrations"] }, { "id": "F003", "description": "Change TokenBucketRateLimiter.initialize to accept a `Record` map and route lookups to the namespace-specific getter.", "implemented": true, "prdRefs": ["Data / API / Integrations"] }, { "id": "F004", "description": "Update server/src/lib/initializeApp.ts to register both the existing email getter (namespace 'email') and a placeholder 'api' getter so the singleton wires up multi-namespace at startup.", "implemented": true, "prdRefs": ["Rollout / Migration"] }, { "id": "F005", "description": "Update packages/email/src/TenantEmailService.ts:155 to pass 'email' as the first arg to tryConsume; the existing userId arg moves to second position.", "implemented": true, "prdRefs": ["Rollout / Migration"] }, { "id": "F006", "description": "Add an optional `headers?: Record` field to the ApiError interface in apiMiddleware.ts and have handleApiError merge them into the NextResponse.json options.", "implemented": true, "prdRefs": ["Data / API / Integrations"] }, { "id": "F007", "description": "Add an optional `extraHeaders?: Record` parameter to createSuccessResponse and createPaginatedResponse so callers can attach X-RateLimit-* headers to successful responses.", "implemented": true, "prdRefs": ["Functional Requirements"] }, { "id": "F008", "description": "Create the api_rate_limit_settings table via a knex .cjs migration (cols: tenant, api_key_id NULL, max_tokens, refill_per_min, timestamps; UNIQUE (tenant, api_key_id)).", "implemented": true, "prdRefs": ["Data / API / Integrations"] }, { "id": "F009", "description": "In the same create migration, gate on `pg_extension WHERE extname='citus'` and call create_distributed_table('api_rate_limit_settings', 'tenant', colocate_with => 'tenants') when present. `exports.config = { transaction: false }` so the call doesn't run inside a tx (matches the current repo pattern in 20260429133000_create_asset_facts_table.cjs).", "implemented": true, "prdRefs": ["Data / API / Integrations", "Rollout / Migration"] }, { "id": "F010", "description": "Implement a small DAL (apiRateLimitSettingsModel) with getForKey(tenant, apiKeyId), upsertForKey, upsertForTenant, and clearForKey, and a fall-back resolver: (tenant, apiKeyId) → (tenant, NULL) → hard-coded defaults.", "implemented": true, "prdRefs": ["Data / API / Integrations"] }, { "id": "F011", "description": "Implement an in-process LRU-cached apiRateLimitConfigGetter (max 1000 entries, 30s TTL) with an invalidateApiRateLimitConfig(tenant, apiKeyId?) helper that clears tenant-wide entries when apiKeyId is omitted.", "implemented": true, "prdRefs": ["Non-functional Requirements"] }, { "id": "F012", "description": "Add a shared enforceApiRateLimit(req, context) helper at server/src/lib/api/rateLimit/enforce.ts that resolves namespace 'api' bucket, applies the SSRF-style bypass list, computes header values, and either throws TooManyRequestsError or returns a RateLimitDecision with limit/remaining/resetAt.", "implemented": true, "prdRefs": ["Functional Requirements"] }, { "id": "F013", "description": "Honor the RATE_LIMIT_ENFORCE env var (default false until Stage 3): in observation mode, emit a structured WARN log + headers but do not throw on a denial.", "implemented": true, "prdRefs": ["Functional Requirements", "Rollout / Migration"] }, { "id": "F014", "description": "Use sentinel subjectId 'nm_store' for the NM Store global-key path inside withApiKeyAuth so its calls share one tenant-scoped bucket instead of being unbucketed.", "implemented": true, "prdRefs": ["Security / Permissions"] }, { "id": "F015", "description": "Implement shouldBypassRateLimit(pathname) covering health/version, internal-runner, and mobile-auth prefixes; mirror the existing shouldSkipApiKeyAuth shape.", "implemented": true, "prdRefs": ["Functional Requirements"] }, { "id": "F016", "description": "Reuse TooManyRequestsError (apiMiddleware.ts:101) for 429s; do not introduce a parallel RateLimitError class. Populate `details: { retry_after_ms, remaining }` and the `headers` field.", "implemented": true, "prdRefs": ["Functional Requirements"] }, { "id": "F017", "description": "Call enforceApiRateLimit from ApiBaseController.authenticate after `apiRequest.context = {...}`; stash decision on apiRequest.context.rateLimit.", "implemented": true, "prdRefs": ["Functional Requirements"] }, { "id": "F018", "description": "Call enforceApiRateLimit from withApiKeyAuth (both NM-Store and default branches) and withAuth in apiMiddleware.ts after `req.context = {...}`; stash decision on req.context.rateLimit.", "implemented": true, "prdRefs": ["Functional Requirements"] }, { "id": "F019", "description": "Emit X-RateLimit-Limit and X-RateLimit-Remaining headers on successful responses by reading req.context.rateLimit and passing extraHeaders to createSuccessResponse / createPaginatedResponse.", "implemented": true, "prdRefs": ["Functional Requirements"] }, { "id": "F020", "description": "Audit /api/v1/* routes that bypass all three auth surfaces (no controller, no withApiKeyAuth, no withAuth) and add enforceApiRateLimit to any that should be protected. Remove the 501-stub /api/v1/rbac/audit/route.ts from this scope (already a stub).", "implemented": true, "prdRefs": ["Functional Requirements"] }, { "id": "F021", "description": "Add server actions: getApiRateLimitForKey, setApiRateLimitForKey, setTenantDefaultApiRateLimit, clearApiRateLimitForKey. Each invalidates the LRU cache after writes.", "implemented": true, "prdRefs": ["UX / UI Notes"] }, { "id": "F022", "description": "Add a Rate Limit column + inline edit form to AdminApiKeysSetup; show effective per-key limit, override input, and current remaining tokens via TokenBucketRateLimiter.getState.", "implemented": true, "prdRefs": ["UX / UI Notes"] }, { "id": "F023", "description": "Extend webhookEventTypeSchema (server/src/lib/api/schemas/webhookSchemas.ts) with 'ticket.comment.added' so create-webhook validation accepts it.", "implemented": true, "prdRefs": ["Data / API / Integrations"] }, { "id": "F024", "description": "Create the webhooks table via a .cjs migration with: webhook_id, tenant, name, url, method, event_types text[], custom_headers jsonb, signing_secret_vault_path text NOT NULL, security_type, verify_ssl, retry_config jsonb, rate_limit_per_min, is_active, total/successful/failed_deliveries, last_*_at timestamps, auto_disabled_at, created_by_user_id, created_at, updated_at.", "implemented": true, "prdRefs": ["Data / API / Integrations"] }, { "id": "F025", "description": "Create the webhook_deliveries table via the same migration with: delivery_id, tenant, webhook_id, event_id, event_type, request/response capture, status, attempt_number, duration_ms, error_message, next_retry_at, is_test boolean, attempted_at, completed_at; plus indexes on (tenant, webhook_id, attempted_at DESC), (tenant, event_id), and a partial index on status IN ('pending','retrying') with next_retry_at.", "implemented": true, "prdRefs": ["Data / API / Integrations"] }, { "id": "F026", "description": "In the same create migration, gate on Citus and call create_distributed_table('webhooks','tenant', colocate_with => 'tenants') + create_distributed_table('webhook_deliveries','tenant', colocate_with => 'tenants') under a `transaction: false` config (matches the current repo pattern).", "implemented": true, "prdRefs": ["Data / API / Integrations", "Rollout / Migration"] }, { "id": "F027", "description": "Implement webhookModel DAL with getById(webhookId, tenant), listForEventType(tenant, eventType), insert (with vault-path resolution), update, delete, recordDelivery, updateStats, markAbandoned, plus getSigningSecret(webhookId, tenant) that resolves signing_secret_vault_path via getSecretProviderInstance().", "implemented": true, "prdRefs": ["Data / API / Integrations", "Security / Permissions"] }, { "id": "F028", "description": "Replace the mocked WebhookService.performWebhookDelivery (line 950) with a real HTTP call (undici/node:fetch), 10s timeout, capturing status/response headers/truncated body (8KB), and surfacing distinct error types for DNS/connect/TLS errors. Honor verify_ssl.", "implemented": true, "prdRefs": ["Functional Requirements"] }, { "id": "F029", "description": "Add an assertSafeWebhookTarget(url) helper that DNS-resolves the hostname and rejects RFC1918 (10/8, 172.16/12, 192.168/16), loopback (127/8, ::1), link-local (169.254/16, fe80::/10), CGNAT (100.64/10), non-http(s) schemes, and http://localhost variants. Allow override via WEBHOOK_SSRF_ALLOW_PRIVATE=true.", "implemented": true, "prdRefs": ["Security / Permissions"] }, { "id": "F030", "description": "Implement HMAC-SHA256 signing: signRequest(secret, body, ts) returns 'X-Alga-Signature: t=,v1=' over `${ts}.${body}`. Use crypto.createHmac.", "implemented": true, "prdRefs": ["Functional Requirements", "Security / Permissions"] }, { "id": "F031", "description": "Replace the broken WebhookService.checkRateLimit (line 1056, queries non-existent table) with TokenBucketRateLimiter.tryConsume('webhook-out', tenant, webhookId). Default config from webhooks.rate_limit_per_min.", "implemented": true, "prdRefs": ["Functional Requirements"] }, { "id": "F032", "description": "Implement webhookEventMap.ts with TICKET_INTERNAL_TO_PUBLIC mapping (TICKET_CREATED → ticket.created, TICKET_UPDATED → ticket.updated, TICKET_STATUS_CHANGED → ticket.status_changed, TICKET_ASSIGNED → ticket.assigned, TICKET_CLOSED → ticket.closed, TICKET_COMMENT_ADDED → ticket.comment.added) and publicEventsFor(eventType).", "implemented": true, "prdRefs": ["Data / API / Integrations"] }, { "id": "F033", "description": "Implement buildTicketWebhookPayload(internalEvent, knex) in server/src/lib/eventBus/subscribers/webhook/webhookTicketPayload.ts with the curated field set from PRD §Functional Requirements; include short-lived in-memory cache (60s TTL) keyed by ticket_id+tenant to avoid N joins on fan-out.", "implemented": true, "prdRefs": ["Functional Requirements", "Non-functional Requirements"] }, { "id": "F034", "description": "Include `previous_status_id` and `previous_status_name` in `ticket.status_changed` payloads, derived from TICKET_STATUS_CHANGED.payload.changes when available.", "implemented": true, "prdRefs": ["Open Questions", "Functional Requirements"] }, { "id": "F035", "description": "Implement webhookSubscriber.ts subscribing to TICKET_CREATED, TICKET_UPDATED, TICKET_STATUS_CHANGED, TICKET_CLOSED, TICKET_ASSIGNED, TICKET_COMMENT_ADDED. Look up active webhooks in the same tenant matching the public event type and enqueue a delivery job.", "implemented": true, "prdRefs": ["Functional Requirements"] }, { "id": "F036", "description": "Register webhookSubscriber in server/src/lib/eventBus/subscribers/index.ts alongside ticketEmail / projectEmail / etc.; provide registerWebhookSubscriber and unregisterWebhookSubscriber lifecycle hooks.", "implemented": true, "prdRefs": ["Functional Requirements"] }, { "id": "F037", "description": "Implement WebhookDeliveryQueue (Redis ZSET singleton mirrored on DelayedEmailQueue) with enqueue(job), poller (zRangeByScore + zRem to claim, in-process semaphore cap of 50 concurrent deliveries), retry re-zAdd with computeBackoff, and graceful shutdown on SIGTERM with a 30s drain.", "implemented": true, "prdRefs": ["Data / API / Integrations", "Non-functional Requirements"] }, { "id": "F038", "description": "Initialize WebhookDeliveryQueue.getInstance().initialize(getRedisClient, processFn) in server/src/lib/initializeApp.ts alongside the existing DelayedEmailQueue block.", "implemented": true, "prdRefs": ["Rollout / Migration"] }, { "id": "F039", "description": "Implement computeBackoff(attempt) returning the schedule 1m, 5m, 30m, 2h, 12h.", "implemented": true, "prdRefs": ["Functional Requirements"] }, { "id": "F040", "description": "Implement maybeAutoDisable(webhook): if all deliveries in the past 24h failed, set webhook.is_active=false and webhook.auto_disabled_at=now(); email the owning user via the existing notification path.", "implemented": true, "prdRefs": ["Functional Requirements"] }, { "id": "F052", "description": "Persist `event_filter` on webhook rows (migration + DAL mapping) so `event_filter.entity_ids` survives create/update/read and the entity-id subscriber filter has durable input data.", "implemented": true, "prdRefs": ["Open Questions", "Data / API / Integrations"] }, { "id": "F041", "description": "Implement webhook subscription filtering by entity_ids — when a webhook row has filter.entity_ids set, drop events whose entity_id is not in the set. Skip generic conditions for v1.", "implemented": true, "prdRefs": ["Open Questions", "Functional Requirements"] }, { "id": "F042", "description": "Implement controller endpoint rotateWebhookSecret (currently a TODO at ApiWebhookController.ts:1202): generate crypto.randomBytes(32).toString('base64url'), store via secret provider, replace signing_secret_vault_path, return plaintext once.", "implemented": true, "prdRefs": ["Security / Permissions"] }, { "id": "F043", "description": "Implement controller endpoint verifyWebhookSignature (TODO at line 1175): take signature header + body + secret-vault-path, recompute, return match/no-match.", "implemented": true, "prdRefs": ["Security / Permissions"] }, { "id": "F044", "description": "Implement controller endpoints getDeliveryDetails (line 575), getWebhookHealth (line 778), getWebhookSubscriptions (line 878 — return webhook.event_types), and listAvailableEvents (line 1298 — return the public enum).", "implemented": true, "prdRefs": ["UX / UI Notes", "Functional Requirements"] }, { "id": "F045", "description": "Remove the route handlers for deferred TODO endpoints rather than leaving 501s in OpenAPI: testPayloadTransformation, testEventFilter, validateWebhookConfiguration, getSystemWebhookHealth, createWebhookSubscription, bulkWebhookOperation, searchWebhooks, exportWebhooks, triggerWebhookEvent.", "implemented": true, "prdRefs": ["Non-goals", "UX / UI Notes"] }, { "id": "F046", "description": "Implement POST /api/v1/webhooks/[id]/test: send a synthetic webhook.test payload to the configured URL with the live signing secret; record in webhook_deliveries with is_test=true; skip rate-limit token consumption; apply assertSafeWebhookTarget.", "implemented": true, "prdRefs": ["Functional Requirements", "UX / UI Notes"] }, { "id": "F047", "description": "Build the Webhooks settings UI: list view (status, last delivery, success rate), create/edit form (name, URL, event types multi-select, custom headers, retry config), delivery history (paginated with response status/body/retry button), reveal-on-create + rotate secret, pause/resume/delete.", "implemented": true, "prdRefs": ["UX / UI Notes"] }, { "id": "F048", "description": "Add a 15-minute scheduled job (initializeScheduledJobs) that deletes webhook_deliveries rows older than 30 days in batches of 10000.", "implemented": true, "prdRefs": ["Rollout / Migration"] }, { "id": "F049", "description": "Emit rate-limiter metrics: api_rate_limit_consumed_total{tenant,api_key_id,outcome}, api_rate_limit_remaining{tenant,api_key_id}, api_rate_limit_redis_unavailable_total. Use whatever metric backend the codebase exposes; logger WARN counts are the fallback.", "implemented": true, "prdRefs": ["Observability"] }, { "id": "F050", "description": "Emit webhook metrics: webhook_deliveries_total{tenant,webhook_id,outcome}, webhook_delivery_duration_ms histogram, webhook_queue_depth gauge (zCard), webhook_auto_disabled_total{tenant,webhook_id}.", "implemented": true, "prdRefs": ["Observability"] }, { "id": "F051", "description": "Public-facing API documentation: 429 semantics + Retry-After + X-RateLimit-* headers, default limits, observation-vs-enforce mode; webhook event payloads with example JSON per event; HMAC verification recipes in Node and Python; idempotency/ordering guarantees; retry behavior table.", "implemented": true, "prdRefs": ["Rollout / Migration"] } ]