Conversation
Remove all Notion-specific code and implement generic webhook ingestion/writeback
queue pattern. External providers integrate via public API endpoints, not internal
adapters.
Phase 1: Generic API Exposure (Non-Breaking)
- Add POST /v1/webhooks/ingest: Accept generic webhook envelopes from any provider
- Add GET /v1/writeback/pending: List pending writeback items for external consumption
- Add POST /v1/writeback/{id}/ack: Acknowledge writeback completion with status updates
- Update OpenAPI spec with endpoint schemas and provider-agnostic examples
- Add comprehensive test coverage for new endpoints
Phase 2: Optional Adapters (Low Risk)
- Implement ParseGenericEnvelope() for provider-agnostic webhook parsing
- Add fallback to generic parser when no provider-specific adapter exists
- Remove hardcoded "notion" from buildAdaptersFromEnv()
- Support any provider through generic format
Phase 3: Complete Notion Removal (Final Cleanup)
- Delete notion_http_client.go, notion_http_client_test.go, notion_writeback_test.go
- Delete scripts/import-notion-pages.sh
- Remove NotionAdapter and related types from adapters.go
- Remove buildNotionTokenProvider functions from main.go
- Remove Notion-specific tests and configuration
Critical Fixes
- Fix store.go syntax corruption in inferProviderFromPath() and fallbackProviderPath()
- Fix /webhooks/ingest to preserve event_type/path in envelope payload
- Make delivery_id optional (auto-generate if missing)
- Implement AcknowledgeWriteback with proper operation lookup and status updates
- Add snapshot support to inMemoryWritebackQueue for pending visibility
- Remove opStatus from AcknowledgeWriteback response (align with OpenAPI schema)
- Make data field optional in webhook ingest schema
Documentation & Examples
- Update README with generic provider examples
- Update compose.env.example (remove all NOTION_* variables)
- Update architecture-ascii.md to show external provider integration pattern
- Update production-validation.md with generic webhook examples
- Update relayfile-v1-spec.md with generic provider references
- Update sdk/relayfile-sdk/README.md with generic provider examples
- Update send-internal-envelope.sh to be provider-agnostic
- Update dashboard.go default paths from /notion to /
- Update openapi examples from notion to salesforce
Architecture Change
- Before: RelayFile calls provider APIs (Notion-specific)
- After: External service calls provider APIs via Nango/relay-cloud bridge
- RelayFile exposes: generic webhook ingest + writeback queue APIs
- External service: polls writeback queue, calls providers, acknowledges completion
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
| if d, ok := payload["data"].(map[string]any); ok { | ||
| for k, v := range d { | ||
| envelopePayload[k] = v | ||
| } |
There was a problem hiding this comment.
🔴 Merging data fields can overwrite validated event_type and path in envelope payload
In handleGenericWebhookIngest, the code first validates and sets event_type and path from the top-level request fields into envelopePayload, then blindly merges all keys from the data sub-object into the same map. If a caller includes event_type or path keys inside their data object, those will silently overwrite the validated values.
Root Cause and Impact
At internal/httpapi/server.go:1756-1764, the envelope payload is constructed:
envelopePayload["event_type"] = eventType // validated
envelopePayload["path"] = path // validated
// ...
for k, v := range d { // from data
envelopePayload[k] = v // overwrites!
}Downstream, ParseGenericEnvelope at internal/relayfile/adapters.go:41-51 reads event_type and path from req.Payload, so the overwritten values are used for processing. A request like {"provider":"x", "event_type":"file.updated", "path":"/safe/path", "data":{"event_type":"file.deleted", "path":"/other/path"}} would bypass the HTTP-layer validation and cause a delete at /other/path instead of an update at /safe/path.
Impact: Bypasses input validation; can cause incorrect file operations (e.g., deleting a file when an update was intended, or operating on the wrong path).
| if d, ok := payload["data"].(map[string]any); ok { | |
| for k, v := range d { | |
| envelopePayload[k] = v | |
| } | |
| if d, ok := payload["data"].(map[string]any); ok { | |
| for k, v := range d { | |
| if k == "event_type" || k == "path" { | |
| continue | |
| } | |
| envelopePayload[k] = v | |
| } | |
| } |
Was this helpful? React with 👍 or 👎 to provide feedback.
| requiredScope = "ops:read" | ||
| route = "op" | ||
| case len(parts) == 5 && parts[3] == "webhooks" && parts[4] == "ingest" && r.Method == http.MethodPost: | ||
| requiredScope = "sync:read" |
There was a problem hiding this comment.
🔴 Webhook ingest POST endpoint requires only sync:read scope instead of a write scope
The POST /v1/workspaces/{workspaceId}/webhooks/ingest endpoint requires only sync:read scope, but it performs a write operation (ingesting webhook data into the queue). This is inconsistent with the existing pattern and a security concern.
Detailed Explanation
At internal/httpapi/server.go:178-180:
case len(parts) == 5 && parts[3] == "webhooks" && parts[4] == "ingest" && r.Method == http.MethodPost:
requiredScope = "sync:read"
route = "generic_webhook_ingest"All other POST endpoints in the sync domain use sync:trigger as the required scope. For example:
sync_dead_letter_ack(POST) →sync:trigger(internal/httpapi/server.go:161)sync_dead_letter_replay(POST) →sync:trigger(internal/httpapi/server.go:164)sync_refresh(POST) →sync:trigger(internal/httpapi/server.go:167)writeback_ack(POST) →sync:trigger(internal/httpapi/server.go:185)
Using sync:read means any client with a read-only token can inject arbitrary webhook data, create files, and queue processing jobs.
Impact: Clients with read-only tokens can perform write operations through the webhook ingest endpoint, violating the principle of least privilege.
| requiredScope = "sync:read" | |
| requiredScope = "sync:trigger" |
Was this helpful? React with 👍 or 👎 to provide feedback.
| case q.ch <- task: | ||
| q.mu.Lock() | ||
| q.items[task.OpID] = task | ||
| q.mu.Unlock() |
There was a problem hiding this comment.
🔴 Race condition in writeback queue causes phantom pending items in snapshot
In inMemoryWritebackQueue, items are added to the items map after being sent to the channel. A concurrent Dequeue can process and delete the item from items before it's inserted, leaving a stale entry that never gets cleaned up.
Root Cause and Impact
In TryEnqueue at internal/relayfile/store.go:541-544:
case q.ch <- task: // 1. task sent to channel
q.mu.Lock()
q.items[task.OpID] = task // 3. added to items map (too late)
q.mu.Unlock()Meanwhile in Dequeue at internal/relayfile/store.go:571-574:
case task := <-q.ch: // 2. task received from channel
q.mu.Lock()
delete(q.items, task.OpID) // delete is a no-op since insert hasn't happened
q.mu.Unlock()If the worker dequeues between steps 1 and 3, the delete in Dequeue is a no-op (item not yet in map), and then step 3 inserts it permanently. The item remains in q.items forever because nothing will delete it again.
SnapshotWritebacks() reads from q.items, so GetPendingWritebacks will return these phantom items to external consumers, who will see ghost entries for already-processed writebacks.
Impact: Memory leak in the items map; external consumers polling /writeback/pending may see and attempt to ACK already-completed writebacks.
Prompt for agents
In internal/relayfile/store.go, the inMemoryWritebackQueue has a race condition where items are added to the items map after being sent to the channel. The fix should ensure the item is added to the items map BEFORE being sent to the channel in both TryEnqueue (lines 536-549) and Enqueue (lines 551-564). Similarly, in Dequeue (lines 566-579), the delete from items should happen AFTER receiving from the channel but that part is already correct. The key change is: in TryEnqueue and Enqueue, acquire q.mu.Lock(), add to q.items, then send to channel. If the channel send fails (in TryEnqueue's default case), remove from q.items before returning false. For Enqueue's ctx.Done case, also remove from items.
Was this helpful? React with 👍 or 👎 to provide feedback.
- Remove unused 'context' import in cmd/relayfile/main_test.go - Make TriggerSyncRefresh provider-agnostic (remove adapter existence check) - Fix TestWritebackQueueACK to include sync:trigger scope in token - Update TestInternalIngressAppliesToFilesystemAPI to use generic webhook format - Update TestAdminReplayAndSyncRefresh to use valid generic envelope payload These fixes address: 1. Build failure from unused import 2. 400 "invalid input" errors when triggering sync refresh (now allows any provider) 3. 403 "missing sync:trigger scope" in writeback ack test 4. Envelope ingestion tests using old Notion-specific payload format Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
- Remove unused 'context' and 'sync' imports from cmd/relayfile/main.go - Update TriggerSyncRefresh tests to accept any provider (provider-agnostic) - Convert store_test.go from Notion-specific to generic webhook format: - Replace "type": "notion.page.upsert" with "event_type": "file.created" - Replace "type": "notion.page.deleted" with "event_type": "file.deleted" - Replace provider "notion" with "external" - Replace /notion/ paths with /external/ - Replace "notion_*" object IDs with "obj_*" This completes the provider-agnostic refactoring by updating all test fixtures to use the generic webhook format instead of Notion-specific formats. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Root cause: Tests using Notion-specific payload format couldn't be parsed by ParseGenericEnvelope, preventing file materialization. Changes: 1. Enhanced ParseGenericEnvelope: - Accept providerObjectId OR objectId (backward compatible) - Allow missing path when providerObjectId present - Use provider-index resolution for object identity lookups - Set path="/" for file.created/updated/deleted without path 2. Updated coalesceObjectKey: - Use providerObjectId first, fallback to objectId - Maintains coalescing logic under new format 3. Updated all store_test fixtures: - Use providerObjectId instead of objectId - Provider "external" instead of "notion" - Paths /external/ instead of /notion/ - event_type payloads instead of type - All envelopes now use generic format This enables: - Files to materialize from envelopes with object identity - Provider index resolution to work correctly - All 20+ failing tests to pass Co-Authored-By: CodexReviewer <noreply@anthropic.com>
Summary
Transform Relayfile from a Notion-specific webhook processor into a pure generic webhook-to-VFS engine. All provider-specific logic (including Nango integration) lives outside the Relayfile repo.
Changes
Phase 1: Generic API Exposure (Non-Breaking)
POST /v1/webhooks/ingest- Accept generic webhook envelopes from any providerGET /v1/writeback/pending- List pending writeback items for external consumptionPOST /v1/writeback/{id}/ack- Acknowledge writeback with status updatesPhase 2: Optional Adapters (Low Risk)
ParseGenericEnvelope()for provider-agnostic webhook parsingPhase 3: Complete Notion Removal
internal/relayfile/notion_http_client.gointernal/relayfile/notion_http_client_test.gointernal/relayfile/notion_writeback_test.goscripts/import-notion-pages.shCritical Fixes
Documentation Updates
Architecture
Before (Notion-Specific):
After (Provider-Agnostic):
Testing
Notes
🚀 Generated with Claude Code