-
-
Notifications
You must be signed in to change notification settings - Fork 0
feat(sync): standardize 2-tier hybrid sync and webhook verification #7
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -28,7 +28,10 @@ export class DosOrgSyncWebhookController { | |||||||||||||||||||
| ) {} | ||||||||||||||||||||
|
|
||||||||||||||||||||
| private verifySignature(rawBody: string, signatureHeader?: string): boolean { | ||||||||||||||||||||
| const secret = process.env.DOS_WEBHOOK_SECRET || process.env.JWT_SECRET; | ||||||||||||||||||||
| const secret = | ||||||||||||||||||||
| process.env.DOS_SYNC_WEBHOOK_SECRET || | ||||||||||||||||||||
| process.env.DOS_WEBHOOK_SECRET || | ||||||||||||||||||||
| process.env.JWT_SECRET; | ||||||||||||||||||||
| if (!secret) { | ||||||||||||||||||||
| return true; | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
@@ -37,16 +40,19 @@ export class DosOrgSyncWebhookController { | |||||||||||||||||||
| return false; | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| const cleanSignature = signatureHeader.replace(/^sha256=/, '').trim(); | ||||||||||||||||||||
| const expected = createHmac('sha256', secret) | ||||||||||||||||||||
| .update(rawBody) | ||||||||||||||||||||
| .digest('hex'); | ||||||||||||||||||||
|
|
||||||||||||||||||||
| const provided = signatureHeader.replace(/^sha256=/, ''); | ||||||||||||||||||||
| if (provided.length !== expected.length) { | ||||||||||||||||||||
| if (cleanSignature.length !== expected.length) { | ||||||||||||||||||||
| return false; | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| return timingSafeEqual(Buffer.from(provided), Buffer.from(expected)); | ||||||||||||||||||||
| return timingSafeEqual( | ||||||||||||||||||||
| Buffer.from(cleanSignature, 'hex'), | ||||||||||||||||||||
| Buffer.from(expected, 'hex') | ||||||||||||||||||||
| ); | ||||||||||||||||||||
|
Comment on lines
+52
to
+55
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Using To prevent this, compare the hex strings directly as UTF-8 buffers. Since both are hex strings of equal length (verified by the length check above), their UTF-8 byte lengths are guaranteed to be identical and safe for
Suggested change
|
||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| @Post('/dos-org-sync') | ||||||||||||||||||||
|
|
@@ -62,9 +68,9 @@ export class DosOrgSyncWebhookController { | |||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| const { event, data } = payload; | ||||||||||||||||||||
| const { org_id, org_name, user_id, user_email, user_name, role } = data; | ||||||||||||||||||||
| const { org_id, org_name, user_id, user_email, user_name, role } = data || {}; | ||||||||||||||||||||
|
|
||||||||||||||||||||
| let targetOrg = await this._orgService.getOrgById(org_id); | ||||||||||||||||||||
| let targetOrg = org_id ? await this._orgService.getOrgById(org_id) : null; | ||||||||||||||||||||
| if (!targetOrg && org_name) { | ||||||||||||||||||||
| targetOrg = await this._orgService.findOrgByName(org_name); | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
@@ -78,13 +84,15 @@ export class DosOrgSyncWebhookController { | |||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| switch (event) { | ||||||||||||||||||||
| case DosSyncEvent.ORG_CREATED: { | ||||||||||||||||||||
| case DosSyncEvent.ORG_CREATED: | ||||||||||||||||||||
| case DosSyncEvent.ORGANIZATION_CREATED: { | ||||||||||||||||||||
| if (!targetOrg) { | ||||||||||||||||||||
| if (targetUser) { | ||||||||||||||||||||
| await this._orgService.createOrgForExistingUser( | ||||||||||||||||||||
| targetUser.id, | ||||||||||||||||||||
| org_name || 'Organization', | ||||||||||||||||||||
| 'SUPERADMIN' | ||||||||||||||||||||
| 'SUPERADMIN', | ||||||||||||||||||||
| org_id | ||||||||||||||||||||
| ); | ||||||||||||||||||||
| } else if (user_email) { | ||||||||||||||||||||
| const created = await this._orgService.createOrgAndUser( | ||||||||||||||||||||
|
|
@@ -110,21 +118,24 @@ export class DosOrgSyncWebhookController { | |||||||||||||||||||
| return { success: true, event, status: 'processed' }; | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| case DosSyncEvent.ORG_UPDATED: { | ||||||||||||||||||||
| case DosSyncEvent.ORG_UPDATED: | ||||||||||||||||||||
| case DosSyncEvent.ORGANIZATION_UPDATED: { | ||||||||||||||||||||
| if (targetOrg && org_name) { | ||||||||||||||||||||
| await this._orgService.updateOrganizationName(targetOrg.id, org_name); | ||||||||||||||||||||
| } | ||||||||||||||||||||
| return { success: true, event, status: 'processed' }; | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| case DosSyncEvent.ORG_DELETED: { | ||||||||||||||||||||
| case DosSyncEvent.ORG_DELETED: | ||||||||||||||||||||
| case DosSyncEvent.ORGANIZATION_DELETED: { | ||||||||||||||||||||
| if (targetOrg) { | ||||||||||||||||||||
| await this._orgService.deleteOrganization(targetOrg.id); | ||||||||||||||||||||
| } | ||||||||||||||||||||
| return { success: true, event, status: 'processed' }; | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| case DosSyncEvent.ORG_MEMBER_ADDED: { | ||||||||||||||||||||
| case DosSyncEvent.ORG_MEMBER_ADDED: | ||||||||||||||||||||
| case DosSyncEvent.ORGANIZATION_MEMBER_ADDED: { | ||||||||||||||||||||
| if (targetOrg && targetUser) { | ||||||||||||||||||||
| const appRole = role === 'MEMBER' ? 'USER' : 'ADMIN'; | ||||||||||||||||||||
| await this._orgService.addUserToOrg( | ||||||||||||||||||||
|
|
@@ -148,6 +159,12 @@ export class DosOrgSyncWebhookController { | |||||||||||||||||||
| '127.0.0.1', | ||||||||||||||||||||
| 'dos-webhook-sync' | ||||||||||||||||||||
| ); | ||||||||||||||||||||
| if (user_name) { | ||||||||||||||||||||
| await this._userService.changePersonal( | ||||||||||||||||||||
| created.users[0].user.id, | ||||||||||||||||||||
| { fullname: user_name, bio: '' } | ||||||||||||||||||||
| ); | ||||||||||||||||||||
| } | ||||||||||||||||||||
| const appRole = role === 'MEMBER' ? 'USER' : 'ADMIN'; | ||||||||||||||||||||
| await this._orgService.addUserToOrg( | ||||||||||||||||||||
| created.users[0].user.id, | ||||||||||||||||||||
|
|
@@ -160,10 +177,11 @@ export class DosOrgSyncWebhookController { | |||||||||||||||||||
| return { success: true, event, status: 'processed' }; | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| case DosSyncEvent.ORG_MEMBER_REMOVED: { | ||||||||||||||||||||
| case DosSyncEvent.ORG_MEMBER_REMOVED: | ||||||||||||||||||||
| case DosSyncEvent.ORGANIZATION_MEMBER_REMOVED: { | ||||||||||||||||||||
| if (targetOrg && targetUser) { | ||||||||||||||||||||
| await this._orgService.deleteTeamMember( | ||||||||||||||||||||
| targetOrg as any, | ||||||||||||||||||||
| targetOrg.id, | ||||||||||||||||||||
| targetUser.id | ||||||||||||||||||||
| ); | ||||||||||||||||||||
|
Comment on lines
183
to
186
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The
Suggested change
|
||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -242,3 +242,55 @@ X-DOS-Signature: sha256=<hex_hmac_sha256_signature> | |||||||||||||||||
| | `organizations.id` | `Organization.id` | `workspace.id` | `Organisation.id` | `Team.id` | | ||||||||||||||||||
| | `organizations.name` | `Organization.name` | `workspace.name` | `Organisation.name` | `Team.name` | | ||||||||||||||||||
| | `org_members.role` | `UserOrganization.role` | `workspaceMember.role` | `OrganisationMember.role` | `Membership.role` | | ||||||||||||||||||
|
|
||||||||||||||||||
| --- | ||||||||||||||||||
|
|
||||||||||||||||||
| ## 8. 🏗️ 2-Tier Hybrid Architecture Standard | ||||||||||||||||||
|
|
||||||||||||||||||
| The Crove OS ecosystem adopts a strict **2-Tier Separation of Concerns** between high-performance local database mirrors and deep agentic tool calling: | ||||||||||||||||||
|
|
||||||||||||||||||
| ``` | ||||||||||||||||||
| ┌─────────────────────────────────────────────────────────────────────────────────────────┐ | ||||||||||||||||||
| │ CROVE OS 2-TIER HYBRID ARCHITECTURE │ | ||||||||||||||||||
| ├──────────────────────────────────────────┬──────────────────────────────────────────────┤ | ||||||||||||||||||
| │ TIER 1: Identity & Entity Data Sync │ TIER 2: Deep Agentic Business Actions │ | ||||||||||||||||||
| │ (Companies, Customers, Organizations) │ (Create Deals, Renewals, Assign Tasks) │ | ||||||||||||||||||
| ├──────────────────────────────────────────┼──────────────────────────────────────────────┤ | ||||||||||||||||||
| │ DATABASE SYNCHRONIZATION │ MCP PROTOCOL │ | ||||||||||||||||||
| │ (PostgreSQL Mirror / < 5ms Latency) │ (Model Context Protocol Tool Calling) │ | ||||||||||||||||||
| │ │ │ │ │ | ||||||||||||||||||
| │ • Master SSOT: DOS.Me & Twenty CRM │ • twenty_crm.create_opportunity(...) │ | ||||||||||||||||||
| │ • Local Mirrors: post.* / desk.* │ • twenty_crm.get_subscription_status(...) │ | ||||||||||||||||||
| │ • Bi-directional Webhook Dispatch │ • twenty_crm.create_task(...) │ | ||||||||||||||||||
| │ • JIT (Just-In-Time) Sync on Login │ • crove_sign.get_contracts(...) │ | ||||||||||||||||||
| │ • Event Ingress: api.dos.me/internal/ │ • Deep business validation & side-effects │ | ||||||||||||||||||
| │ events/publish │ │ | ||||||||||||||||||
| └──────────────────────────────────────────┴──────────────────────────────────────────────┘ | ||||||||||||||||||
| ``` | ||||||||||||||||||
|
|
||||||||||||||||||
| ### 8.1. Webhook Signature Verification Standard | ||||||||||||||||||
|
|
||||||||||||||||||
| All satellite applications verify inbound events using constant-time HMAC-SHA256 comparison: | ||||||||||||||||||
|
|
||||||||||||||||||
| ```typescript | ||||||||||||||||||
| import * as crypto from 'crypto'; | ||||||||||||||||||
|
|
||||||||||||||||||
| export function verifyEcosystemWebhook( | ||||||||||||||||||
| rawBody: string | Buffer, | ||||||||||||||||||
| signatureHeader: string, | ||||||||||||||||||
| secret: string | ||||||||||||||||||
| ): boolean { | ||||||||||||||||||
| if (!signatureHeader || !secret) return false; | ||||||||||||||||||
| const cleanSignature = signatureHeader.replace(/^sha256=/, '').trim(); | ||||||||||||||||||
| const expected = crypto | ||||||||||||||||||
| .createHmac('sha256', secret) | ||||||||||||||||||
| .update(rawBody) | ||||||||||||||||||
| .digest('hex'); | ||||||||||||||||||
| if (cleanSignature.length !== expected.length) return false; | ||||||||||||||||||
| return crypto.timingSafeEqual( | ||||||||||||||||||
| Buffer.from(cleanSignature, 'hex'), | ||||||||||||||||||
| Buffer.from(expected, 'hex') | ||||||||||||||||||
| ); | ||||||||||||||||||
|
Comment on lines
+290
to
+293
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Using To prevent this, compare the hex strings directly as UTF-8 buffers. Since both are hex strings of equal length, their UTF-8 byte lengths are guaranteed to be identical and safe for
Suggested change
|
||||||||||||||||||
| } | ||||||||||||||||||
| ``` | ||||||||||||||||||
|
|
||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Returning
truewhensecretis not configured completely disables signature verification. If the environment variables are misconfigured or fail to load in production, the webhook endpoint will be left entirely unprotected, allowing unauthorized clients to trigger sync events.Consider only bypassing signature verification in non-production environments.