Skip to content

Commit ae4cbb9

Browse files
committed
feat: domain verif
1 parent 4189e76 commit ae4cbb9

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

62 files changed

+3327
-1608
lines changed

backend/package.json

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,7 @@
1111
"dev": "cross-env NODE_ENV=development tsx --watch-path=src src/main.ts",
1212
"tunnel": "cross-env NODE_ENV=tunnel tsx --watch-path=src src/main.ts",
1313
"ts": "cd .. && tsgo -p backend/tsconfig.json --pretty",
14-
"ts:old": "tsc --pretty",
1514
"tsperf": "rm -rf dist && rm -rf tsconfig.tsbuildinfo && tsgo --extendedDiagnostics",
16-
"tsperf:old": "rm -rf dist && rm -rf tsconfig.tsbuildinfo && tsc --extendedDiagnostics",
1715
"build": "cross-env NODE_ENV=production tsup",
1816
"build:dev": "tsup",
1917
"build:staging": "cross-env NODE_ENV=staging tsup",
@@ -38,14 +36,14 @@
3836
},
3937
"dependencies": {
4038
"@asteasolutions/zod-to-openapi": "^8.4.3",
41-
"@aws-sdk/client-s3": "^3.1006.0",
39+
"@aws-sdk/client-s3": "^3.1007.0",
4240
"@aws-sdk/cloudfront-signer": "^3.1005.0",
43-
"@aws-sdk/lib-storage": "^3.1006.0",
44-
"@aws-sdk/s3-request-presigner": "^3.1006.0",
41+
"@aws-sdk/lib-storage": "^3.1007.0",
42+
"@aws-sdk/s3-request-presigner": "^3.1007.0",
4543
"@blocknote/core": "^0.47.1",
4644
"@dotenv-run/core": "^1.3.8",
4745
"@electric-sql/pglite": "^0.3.16",
48-
"@getbrevo/brevo": "^4.0.1",
46+
"@getbrevo/brevo": "^5.0.1",
4947
"@hono/node-server": "^1.19.11",
5048
"@hono/otel": "^1.1.1",
5149
"@hono/zod-openapi": "^1.2.2",
@@ -71,7 +69,7 @@
7169
"@paddle/paddle-node-sdk": "^3.6.0",
7270
"@scalar/hono-api-reference": "^0.10.2",
7371
"@sendgrid/mail": "^8.1.6",
74-
"@sentry/cli": "^3.3.2",
72+
"@sentry/cli": "^3.3.3",
7573
"@sentry/node": "^10.43.0",
7674
"@sentry/profiling-node": "^10.43.0",
7775
"@t3-oss/env-core": "^0.13.10",
@@ -80,7 +78,7 @@
8078
"drizzle-orm": "1.0.0-beta.15-859cf75",
8179
"enforce-unique": "^1.3.0",
8280
"hono": "^4.12.5",
83-
"i18next": "^25.8.17",
81+
"i18next": "^25.8.18",
8482
"isbot": "^5.1.36",
8583
"jsdom": "^28.1.0",
8684
"jsonwebtoken": "^9.0.3",
@@ -97,7 +95,7 @@
9795
"rate-limiter-flexible": "^9.1.1",
9896
"react": "^19.2.4",
9997
"react-dom": "^19.2.4",
100-
"react-i18next": "^16.5.7",
98+
"react-i18next": "^16.5.8",
10199
"shared": "workspace:*",
102100
"slugify": "^1.6.6",
103101
"ua-parser-js": "^2.0.9",
@@ -127,7 +125,7 @@
127125
"tsup": "^8.5.1",
128126
"tsx": "^4.21.0",
129127
"typescript": "^5.9.3",
130-
"vite": "^7.3.1"
128+
"vite": "^8.0.0"
131129
},
132130
"exports": {
133131
"types": "./dist/src/index.d.ts"

backend/src/modules/domains/domains-handlers.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import dns from 'node:dns/promises';
12
import { OpenAPIHono } from '@hono/zod-openapi';
23
import { and, asc, eq } from 'drizzle-orm';
34
import { domainsTable } from '#/db/schema/domains';
@@ -79,6 +80,89 @@ const domainHandlers = app
7980
logEvent('info', 'Domain removed', { tenantId, domain: deleted.domain, removedBy: user.id });
8081

8182
return ctx.json(deleted);
83+
})
84+
85+
/**
86+
* Get a single domain with its verification token.
87+
*/
88+
.openapi(domainRoutes.getDomain, async (ctx) => {
89+
const db = ctx.var.db;
90+
const { tenantId, id } = ctx.req.valid('param');
91+
92+
const [domain] = await db
93+
.select()
94+
.from(domainsTable)
95+
.where(and(eq(domainsTable.id, id), eq(domainsTable.tenantId, tenantId)))
96+
.limit(1);
97+
98+
if (!domain) {
99+
throw new AppError(404, 'not_found', 'warn', { meta: { resource: 'domain' } });
100+
}
101+
102+
return ctx.json(domain);
103+
})
104+
105+
/**
106+
* Verify a domain via DNS TXT record lookup.
107+
*/
108+
.openapi(domainRoutes.verifyDomain, async (ctx) => {
109+
const db = ctx.var.db;
110+
const { tenantId, id } = ctx.req.valid('param');
111+
const user = ctx.var.user;
112+
113+
const [domain] = await db
114+
.select()
115+
.from(domainsTable)
116+
.where(and(eq(domainsTable.id, id), eq(domainsTable.tenantId, tenantId)))
117+
.limit(1);
118+
119+
if (!domain) {
120+
throw new AppError(404, 'not_found', 'warn', { meta: { resource: 'domain' } });
121+
}
122+
123+
if (!domain.verificationToken) {
124+
throw new AppError(422, 'invalid_request', 'warn', { meta: { reason: 'Domain has no verification token' } });
125+
}
126+
127+
const hostname = `_cella-verification.${domain.domain}`;
128+
let recordsFound: string[] = [];
129+
130+
try {
131+
const txtRecords = await dns.resolveTxt(hostname);
132+
// dns.resolveTxt returns string[][] — each record is an array of chunks, join them
133+
recordsFound = txtRecords.map((chunks) => chunks.join(''));
134+
} catch (err: unknown) {
135+
// ENOTFOUND / ENODATA means no TXT records exist — not an error
136+
const code = err instanceof Error && 'code' in err ? (err as NodeJS.ErrnoException).code : undefined;
137+
if (code !== 'ENOTFOUND' && code !== 'ENODATA') {
138+
logEvent('warn', 'DNS lookup failed', { domain: domain.domain, error: String(err) });
139+
}
140+
}
141+
142+
const now = new Date().toISOString();
143+
const verified = recordsFound.includes(domain.verificationToken);
144+
145+
const [updated] = await db
146+
.update(domainsTable)
147+
.set({
148+
lastCheckedAt: now,
149+
...(verified ? { verified: true, verifiedAt: now } : {}),
150+
})
151+
.where(eq(domainsTable.id, id))
152+
.returning();
153+
154+
logEvent('info', `Domain verification ${verified ? 'succeeded' : 'failed'}`, {
155+
tenantId,
156+
domain: domain.domain,
157+
verified,
158+
verifiedBy: user.id,
159+
});
160+
161+
return ctx.json({
162+
success: verified,
163+
domain: updated,
164+
...(!verified ? { diagnostics: { recordsFound, expectedToken: domain.verificationToken } } : {}),
165+
});
82166
});
83167

84168
export default domainHandlers;

backend/src/modules/domains/domains-routes.ts

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,13 @@ import { createXRoute } from '#/docs/x-routes';
22
import { authGuard, sysAdminGuard } from '#/middlewares/guard';
33
import { singlePointsLimiter } from '#/middlewares/rate-limiter/limiters';
44
import { errorResponseRefs, tenantOnlyParamSchema } from '#/schemas';
5-
import { createDomainBodySchema, domainParamSchema, domainSchema } from './domains-schema';
5+
import {
6+
createDomainBodySchema,
7+
domainParamSchema,
8+
domainSchema,
9+
domainWithTokenSchema,
10+
verifyDomainResponseSchema,
11+
} from './domains-schema';
612

713
export const domainRoutes = {
814
/**
@@ -15,14 +21,15 @@ export const domainRoutes = {
1521
xGuard: [authGuard, sysAdminGuard],
1622
tags: ['tenants'],
1723
summary: 'List domains for a tenant',
18-
description: 'Returns all domains belonging to a tenant. System admin access required.',
24+
description:
25+
'Returns all domains belonging to a tenant, including verification tokens. System admin access required.',
1926
request: { params: tenantOnlyParamSchema },
2027
responses: {
2128
200: {
2229
description: 'List of domains',
2330
content: {
2431
'application/json': {
25-
schema: domainSchema.array(),
32+
schema: domainWithTokenSchema.array(),
2633
},
2734
},
2835
},
@@ -87,6 +94,59 @@ export const domainRoutes = {
8794
...errorResponseRefs,
8895
},
8996
}),
97+
98+
/**
99+
* Get a single domain with its verification token (system admin only)
100+
*/
101+
getDomain: createXRoute({
102+
operationId: 'getDomain',
103+
method: 'get',
104+
path: '/{id}',
105+
xGuard: [authGuard, sysAdminGuard],
106+
tags: ['tenants'],
107+
summary: 'Get domain with verification token',
108+
description:
109+
'Returns a single domain including its verification token for DNS TXT setup. System admin access required.',
110+
request: { params: domainParamSchema },
111+
responses: {
112+
200: {
113+
description: 'Domain with verification token',
114+
content: {
115+
'application/json': {
116+
schema: domainWithTokenSchema,
117+
},
118+
},
119+
},
120+
...errorResponseRefs,
121+
},
122+
}),
123+
124+
/**
125+
* Verify a domain via DNS TXT record lookup (system admin only)
126+
*/
127+
verifyDomain: createXRoute({
128+
operationId: 'verifyDomain',
129+
method: 'post',
130+
path: '/{id}/verify',
131+
xGuard: [authGuard, sysAdminGuard],
132+
xRateLimiter: singlePointsLimiter,
133+
tags: ['tenants'],
134+
summary: 'Verify domain ownership via DNS',
135+
description:
136+
'Looks up DNS TXT records for the domain to verify ownership. Checks for a _cella-verification.<domain> TXT record matching the verification token.',
137+
request: { params: domainParamSchema },
138+
responses: {
139+
200: {
140+
description: 'Verification result',
141+
content: {
142+
'application/json': {
143+
schema: verifyDomainResponseSchema,
144+
},
145+
},
146+
},
147+
...errorResponseRefs,
148+
},
149+
}),
90150
};
91151

92152
export default domainRoutes;

backend/src/modules/domains/domains-schema.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,40 @@ import { createInsertSchema, createSelectSchema } from '#/db/utils/drizzle-schem
44
import { entityIdParamSchema, tenantOnlyParamSchema } from '#/schemas';
55

66
/**
7-
* Domain schema for API responses.
7+
* Domain schema for API responses (excludes verificationToken).
88
*/
99
export const domainSchema = z
1010
.object({
1111
...createSelectSchema(domainsTable).omit({ verificationToken: true }).shape,
1212
})
1313
.openapi('Domain', { description: 'A domain claimed by a tenant for email matching and verification.' });
1414

15+
/**
16+
* Domain schema including verificationToken — used for the detail/verify endpoints
17+
* so the admin can see the DNS TXT record value they need to configure.
18+
*/
19+
export const domainWithTokenSchema = z
20+
.object({
21+
...createSelectSchema(domainsTable).shape,
22+
})
23+
.openapi('DomainWithToken', { description: 'A domain with its verification token for DNS setup.' });
24+
25+
/**
26+
* Response schema for domain verification attempts.
27+
*/
28+
export const verifyDomainResponseSchema = z
29+
.object({
30+
success: z.boolean(),
31+
domain: domainWithTokenSchema,
32+
diagnostics: z
33+
.object({
34+
recordsFound: z.array(z.string()),
35+
expectedToken: z.string(),
36+
})
37+
.optional(),
38+
})
39+
.openapi('VerifyDomainResponse', { description: 'Result of a DNS TXT domain verification attempt.' });
40+
1541
/**
1642
* Schema for adding a domain to a tenant.
1743
*/

cdc/package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
"dev": "cross-env NODE_ENV=development tsx ../shared/scripts/wait-backend.ts -i 2000 -t 60000 && tsx watch --env-file=../backend/.env src/cdc-worker.ts",
1212
"build": "cross-env NODE_ENV=production tsup",
1313
"ts": "cd .. && tsgo -p cdc/tsconfig.json --pretty",
14-
"ts:old": "tsc --pretty",
1514
"test": "vitest run",
1615
"test:watch": "vitest"
1716
},
@@ -34,7 +33,7 @@
3433
"tsup": "^8.5.1",
3534
"tsx": "^4.21.0",
3635
"typescript": "^5.9.3",
37-
"vitest": "^4.0.18",
36+
"vitest": "^4.1.0",
3837
"wait-on": "^9.0.4"
3938
}
4039
}

cli/cella/package.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,7 @@
2626
"cella": "tsx index.ts",
2727
"validate": "tsx index.ts --sync-service validate",
2828
"check": "pnpm ts && pnpm biome check --write .",
29-
"check:old": "pnpm ts:old && pnpm biome check --write .",
3029
"ts": "tsgo --pretty",
31-
"ts:old": "tsc --pretty",
3230
"lint": "biome check .",
3331
"lint:fix": "biome check --write .",
3432
"test": "vitest run",

cli/create-cella/package.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,7 @@
3232
"test-build": "pnpm run build && node index.js",
3333
"prepublishOnly": "pnpm run build",
3434
"check": "pnpm ts && pnpm biome check --write .",
35-
"check:old": "pnpm ts:old && pnpm biome check --write .",
3635
"ts": "tsgo --pretty",
37-
"ts:old": "tsc --pretty",
3836
"lint": "biome check .",
3937
"lint:fix": "biome check --write .",
4038
"test": "vitest run",

0 commit comments

Comments
 (0)