Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 31 additions & 13 deletions apps/backend/src/api/routes/dos-org-sync.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Comment on lines 35 to 37

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

Returning true when secret is 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.

Suggested change
if (!secret) {
return true;
}
if (!secret) {
return process.env.NODE_ENV !== 'production';
}

Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Using Buffer.from(cleanSignature, 'hex') can result in a buffer of a different length than Buffer.from(expected, 'hex') if cleanSignature contains any non-hex characters (since invalid characters are skipped or ignored during hex decoding). If the buffer lengths differ, crypto.timingSafeEqual will throw a TypeError: Inputs must have the same length and crash the request.

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 timingSafeEqual.

Suggested change
return timingSafeEqual(
Buffer.from(cleanSignature, 'hex'),
Buffer.from(expected, 'hex')
);
return timingSafeEqual(
Buffer.from(cleanSignature, 'utf-8'),
Buffer.from(expected, 'utf-8')
);

}

@Post('/dos-org-sync')
Expand All @@ -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);
}
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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,
Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The deleteTeamMember method on OrganizationService expects an Organization object as its first argument, not an organization ID string. Passing targetOrg.id will cause a runtime crash (e.g., when it tries to access org.users[0].role inside deleteTeamMember). Please pass targetOrg (or targetOrg as any if there is a type mismatch) instead.

Suggested change
await this._orgService.deleteTeamMember(
targetOrg as any,
targetOrg.id,
targetUser.id
);
await this._orgService.deleteTeamMember(
targetOrg as any,
targetUser.id
);

}
Expand Down
52 changes: 52 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using Buffer.from(cleanSignature, 'hex') can result in a buffer of a different length than Buffer.from(expected, 'hex') if cleanSignature contains any non-hex characters. If the buffer lengths differ, crypto.timingSafeEqual will throw a TypeError: Inputs must have the same length and crash the request.

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 timingSafeEqual.

Suggested change
return crypto.timingSafeEqual(
Buffer.from(cleanSignature, 'hex'),
Buffer.from(expected, 'hex')
);
return crypto.timingSafeEqual(
Buffer.from(cleanSignature, 'utf-8'),
Buffer.from(expected, 'utf-8')
);

}
```

Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@ export enum DosSyncEvent {
ORG_DELETED = 'org.deleted',
ORG_MEMBER_ADDED = 'org.member_added',
ORG_MEMBER_REMOVED = 'org.member_removed',
ORGANIZATION_CREATED = 'organization.created',
ORGANIZATION_UPDATED = 'organization.updated',
ORGANIZATION_DELETED = 'organization.deleted',
ORGANIZATION_MEMBER_ADDED = 'organization.member.added',
ORGANIZATION_MEMBER_REMOVED = 'organization.member.removed',
CUSTOMER_CREATED = 'customer.created',
CUSTOMER_UPDATED = 'customer.updated',
COMPANY_CREATED = 'company.created',
COMPANY_UPDATED = 'company.updated',
}

export class DosOrgSyncDataDto {
Expand Down
Loading