From ec92fc50830c5ea3e9136a561c3e5c2c4e019d4e Mon Sep 17 00:00:00 2001 From: Rotimi Best Date: Thu, 21 May 2026 14:05:37 +0100 Subject: [PATCH] fix: approximated not verifying domain, change request type for /agents route --- apps/api/src/config/env.ts | 4 + .../api/src/middlewares/agent-content-type.ts | 23 ++++ apps/api/src/routes/agent/agent.ts | 6 +- apps/api/src/services/org/domain.ts | 43 +++++-- .../ai-assistant/message-bubble.svelte | 4 +- .../src/lib/features/app/layout-setup.ts | 2 + .../features/settings/pages/domains.svelte | 18 ++- .../lib/features/ui/visit-org-site-btn.svelte | 10 +- .../src/lib/utils/services/api/index.ts | 15 +++ .../src/lib/utils/services/org/domain.ts | 120 ------------------ .../src/lib/utils/translations/da.json | 10 +- .../src/lib/utils/translations/de.json | 10 +- .../src/lib/utils/translations/en.json | 6 +- .../src/lib/utils/translations/es.json | 10 +- .../src/lib/utils/translations/fr.json | 10 +- .../src/lib/utils/translations/hi.json | 10 +- .../src/lib/utils/translations/pl.json | 10 +- .../src/lib/utils/translations/pt.json | 10 +- .../src/lib/utils/translations/ru.json | 10 +- .../src/lib/utils/translations/vi.json | 10 +- packages/db/GOOGLE_AUTH.md | 2 +- packages/utils/src/constants/agent.ts | 11 ++ packages/utils/src/constants/index.ts | 1 + 23 files changed, 179 insertions(+), 176 deletions(-) create mode 100644 apps/api/src/middlewares/agent-content-type.ts delete mode 100644 apps/dashboard/src/lib/utils/services/org/domain.ts create mode 100644 packages/utils/src/constants/agent.ts diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index f4534f71..e0366355 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -10,6 +10,10 @@ const envSchema = z.object({ CLOUDFLARE_IMAGE_BUCKET_DOMAIN: z.string().optional(), APPROXIMATED_API_KEY: z.string().optional(), APPROXIMATED_TARGET_ADDRESS: z.string().optional(), + /** Public IP customers point their A record to (Approximated edge IP). */ + APPROXIMATED_DNS_TARGET_IP: z.string().optional(), + /** Optional CNAME target customers can point to instead of A record. */ + APPROXIMATED_DNS_TARGET_CNAME: z.string().optional(), // S3-compatible storage (MinIO, AWS S3, or other S3-compatible backends) OBJECT_STORAGE_ENDPOINT: z.string().optional(), OBJECT_STORAGE_REGION: z.string().optional(), diff --git a/apps/api/src/middlewares/agent-content-type.ts b/apps/api/src/middlewares/agent-content-type.ts new file mode 100644 index 00000000..c9328a73 --- /dev/null +++ b/apps/api/src/middlewares/agent-content-type.ts @@ -0,0 +1,23 @@ +import type { Context, Next } from 'hono'; +import { AGENT_CONTENT_TYPE } from '@cio/utils/constants'; + +/** + * Render fronts services with Cloudflare's OWASP Managed Rules, which scan + * `application/json` bodies and 403 anything that looks like SQL injection. + * AI chat content trips this constantly (`SELECT … WHERE …`, `OR 1=1` etc.). + * The dashboard sends `/agent/*` writes as `application/x-cio-agent`; this + * middleware swaps the header back to `application/json` so Hono's zValidator + * (which gates parsing on a `/application\/.+?\+?json/` regex) parses the body. + */ +export const agentContentTypeRewrite = async (c: Context, next: Next) => { + const ct = c.req.header('content-type')?.toLowerCase() ?? ''; + if (!ct.startsWith(AGENT_CONTENT_TYPE)) { + return next(); + } + + const headers = new Headers(c.req.raw.headers); + headers.set('content-type', 'application/json'); + c.req.raw = new Request(c.req.raw, { headers }); + + return next(); +}; diff --git a/apps/api/src/routes/agent/agent.ts b/apps/api/src/routes/agent/agent.ts index db6b481a..ccb2a8ed 100644 --- a/apps/api/src/routes/agent/agent.ts +++ b/apps/api/src/routes/agent/agent.ts @@ -3,6 +3,7 @@ import { authMiddleware } from '@api/middlewares/auth'; import { orgMemberMiddleware } from '@api/middlewares/org-member'; import { orgAdminMiddleware } from '@api/middlewares/org-admin'; import { authOrApiKeyMiddleware } from '@api/middlewares/auth-or-api-key'; +import { agentContentTypeRewrite } from '@api/middlewares/agent-content-type'; import { handleError, AppError } from '@api/utils/errors'; import { zValidator } from '@hono/zod-validator'; import { streamText, stepCountIs, convertToModelMessages } from 'ai'; @@ -691,4 +692,7 @@ const agentCoreRouter = new Hono() } }); -export const agentRouter = new Hono().route('/', agentCoreRouter).route('/history', agentHistoryRouter); +export const agentRouter = new Hono() + .use('*', agentContentTypeRewrite) + .route('/', agentCoreRouter) + .route('/history', agentHistoryRouter); diff --git a/apps/api/src/services/org/domain.ts b/apps/api/src/services/org/domain.ts index 15851eca..92bf0123 100644 --- a/apps/api/src/services/org/domain.ts +++ b/apps/api/src/services/org/domain.ts @@ -57,6 +57,14 @@ function ensureApproximatedConfig() { 500 ); } + + if (!env.APPROXIMATED_DNS_TARGET_IP && !env.APPROXIMATED_DNS_TARGET_CNAME) { + throw new AppError( + 'Set APPROXIMATED_DNS_TARGET_IP and/or APPROXIMATED_DNS_TARGET_CNAME so customers know where to point their DNS.', + ErrorCodes.INTERNAL_ERROR, + 500 + ); + } } async function approximatedRequest(path: string, init: RequestInit = {}): Promise { @@ -92,20 +100,31 @@ async function approximatedRequest(path: string, init: RequestInit = {}): Pro ); } + if (payload && typeof payload === 'object' && 'data' in payload) { + return (payload as { data: T }).data; + } + return payload as T; } function buildDnsRecords(hostname: string, vhost?: ApproximatedVhost | null): DomainDnsRecord[] { - if (!vhost?.dns_pointed_at) return []; + const targetIp = env.APPROXIMATED_DNS_TARGET_IP; + const targetCname = env.APPROXIMATED_DNS_TARGET_CNAME; - return [ - { - type: 'A', - name: hostname, - value: vhost.dns_pointed_at, - status: vhost.is_resolving ? 'active' : 'pending' - } - ]; + const records: DomainDnsRecord[] = []; + const isResolvingToTarget = Boolean( + vhost?.is_resolving && (vhost?.dns_pointed_at === targetIp || vhost?.dns_pointed_at === targetCname) + ); + const status: 'pending' | 'active' = isResolvingToTarget ? 'active' : 'pending'; + + if (targetIp) { + records.push({ type: 'A', name: hostname, value: targetIp, status }); + } + if (targetCname) { + records.push({ type: 'CNAME', name: hostname, value: targetCname, status }); + } + + return records; } function mapDomainStatus(vhost?: ApproximatedVhost | null): DomainSetupStatus { @@ -113,7 +132,7 @@ function mapDomainStatus(vhost?: ApproximatedVhost | null): DomainSetupStatus { return 'reconnect_required'; } - if (vhost.has_ssl && vhost.is_resolving && vhost.apx_hit) { + if (vhost.has_ssl && vhost.is_resolving) { return 'verified'; } @@ -145,6 +164,7 @@ function getStatusMessage(status: DomainSetupStatus) { function toDomainSetupResult(hostname: string, vhost?: ApproximatedVhost | null): DomainSetupResult { const status = mapDomainStatus(vhost); + console.log('status', status); return { hostname, @@ -201,6 +221,8 @@ async function createVhost(hostname: string): Promise { if (!created) { throw new AppError('Approximated did not return a vhost', ErrorCodes.INTERNAL_ERROR, 500); } + + console.log('created', created); return created; } @@ -212,6 +234,7 @@ export async function connectDomain(hostname: string): Promise { const vhost = await getVhost(hostname); + console.log('existing', vhost); return toDomainSetupResult(hostname, vhost); } diff --git a/apps/dashboard/src/lib/features/ai-assistant/message-bubble.svelte b/apps/dashboard/src/lib/features/ai-assistant/message-bubble.svelte index a3dbfd2c..e3c42a61 100644 --- a/apps/dashboard/src/lib/features/ai-assistant/message-bubble.svelte +++ b/apps/dashboard/src/lib/features/ai-assistant/message-bubble.svelte @@ -113,9 +113,9 @@ return (part as { errorText?: string }).errorText; } - const inlineParts = $derived(message.parts.filter((part) => (part as { type?: string }).type === 'text')); + const inlineParts = $derived((message.parts ?? []).filter((part) => (part as { type?: string }).type === 'text')); const deferredPlanParts = $derived( - message.parts.filter((part) => isDeferredPlanPart(part as Record)) + (message.parts ?? []).filter((part) => isDeferredPlanPart(part as Record)) ); const hasBubbleContent = $derived(inlineParts.length > 0 || deferredPlanParts.length > 0 || !!messageAttachment); const showStreamingSpinner = $derived(message.role === 'assistant' && !hasBubbleContent && isStreaming && isLast); diff --git a/apps/dashboard/src/lib/features/app/layout-setup.ts b/apps/dashboard/src/lib/features/app/layout-setup.ts index 6109bb32..6d058f03 100644 --- a/apps/dashboard/src/lib/features/app/layout-setup.ts +++ b/apps/dashboard/src/lib/features/app/layout-setup.ts @@ -52,8 +52,10 @@ export async function getOrgSiteInfo(url: URL, cookies: Cookies): Promise { if ($currentOrg.customDomain) { void refreshDomainApex($currentOrg.customDomain); @@ -304,6 +306,20 @@ $effect(() => { resetErrors(siteName, customDomain); }); + + $effect(() => { + const shouldPoll = + Boolean($currentOrg.customDomain) && currentDomainStatus !== null && currentDomainStatus !== 'verified'; + + if (!shouldPoll) return; + + const interval = window.setInterval(() => { + if (isRefreshing || isCustomDomainLoading || document.hidden) return; + void handleRefreshCustomDomain(true); + }, DOMAIN_POLL_INTERVAL_MS); + + return () => window.clearInterval(interval); + }); @@ -330,7 +346,7 @@ - + diff --git a/apps/dashboard/src/lib/features/ui/visit-org-site-btn.svelte b/apps/dashboard/src/lib/features/ui/visit-org-site-btn.svelte index f55239d0..ba2f0889 100644 --- a/apps/dashboard/src/lib/features/ui/visit-org-site-btn.svelte +++ b/apps/dashboard/src/lib/features/ui/visit-org-site-btn.svelte @@ -2,6 +2,7 @@ import { browser } from '$app/environment'; import { Button, type ButtonVariant } from '@cio/ui/base/button'; import ArrowUpRightIcon from '@lucide/svelte/icons/arrow-up-right'; + import { TENANT_ROOT_DOMAIN } from '@cio/utils/constants'; import { currentOrg, currentOrgDomain } from '$lib/utils/store/org'; import { isMobile } from '$lib/utils/store/useMobile'; import { t } from '$lib/utils/functions/translations'; @@ -12,17 +13,22 @@ isLMS?: boolean; labelKey?: string; variant?: ButtonVariant; + forceSubdomain?: boolean; } let { className = '', isLMS = false, labelKey = 'settings.subheadings.view_site', - variant = 'default' + variant = 'default', + forceSubdomain = false }: Props = $props(); let href = $derived.by(() => { - const origin = $currentOrgDomain || (browser ? window.location.origin : ''); + const subdomainOrigin = $currentOrg.siteName ? `https://${$currentOrg.siteName}.${TENANT_ROOT_DOMAIN}` : ''; + const origin = forceSubdomain + ? subdomainOrigin || (browser ? window.location.origin : '') + : $currentOrgDomain || (browser ? window.location.origin : ''); if (!origin) { return ''; diff --git a/apps/dashboard/src/lib/utils/services/api/index.ts b/apps/dashboard/src/lib/utils/services/api/index.ts index 48d20b67..2ee5fd02 100644 --- a/apps/dashboard/src/lib/utils/services/api/index.ts +++ b/apps/dashboard/src/lib/utils/services/api/index.ts @@ -9,6 +9,7 @@ import { currentOrg } from '$lib/utils/store/org'; import type { Cookies } from '@sveltejs/kit'; import { env } from '$env/dynamic/public'; import { getCioCookieString } from '$lib/utils/functions/cookies'; +import { AGENT_CONTENT_TYPE } from '@cio/utils/constants'; export const getRequestBaseUrl = () => { if (typeof window === 'undefined') { @@ -31,6 +32,16 @@ export const getRequestBaseUrl = () => { return `${window.location.origin}/proxy`; }; +function isAgentRequest(input: RequestInfo | URL): boolean { + try { + const raw = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + const pathname = new URL(raw, 'http://placeholder').pathname; + return pathname.includes('/agent/'); + } catch { + return false; + } +} + function mergeAbortSignals(...signals: Array) { const activeSignals = signals.filter((signal): signal is AbortSignal => Boolean(signal)); @@ -115,6 +126,10 @@ class ApiClient { headers.delete('Content-Type'); } + if (requestBody && !isFormData && isAgentRequest(input)) { + headers.set('Content-Type', AGENT_CONTENT_TYPE); + } + // Create abort controller for timeout const timeoutController = new AbortController(); const timeoutId = setTimeout(() => timeoutController.abort(), timeout); diff --git a/apps/dashboard/src/lib/utils/services/org/domain.ts b/apps/dashboard/src/lib/utils/services/org/domain.ts deleted file mode 100644 index 521df722..00000000 --- a/apps/dashboard/src/lib/utils/services/org/domain.ts +++ /dev/null @@ -1,120 +0,0 @@ -// Code from https://github.com/vercel/platforms/blob/1ed55b13bcae97b037c69ec40b4c32df21c2412c/lib/domains.ts - -import { env } from '$env/dynamic/private'; -import type { DomainConfigResponse, DomainResponse, DomainVerificationResponse } from '$lib/utils/types/org'; - -export const addDomainToVercel = async (domain: string) => { - return await fetch( - `https://api.vercel.com/v10/projects/${env.PROJECT_ID_VERCEL}/domains${ - env.TEAM_ID_VERCEL ? `?teamId=${env.TEAM_ID_VERCEL}` : '' - }`, - { - method: 'POST', - headers: { - Authorization: `Bearer ${env.AUTH_BEARER_TOKEN}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - name: domain - // Optional: Redirect www. to root domain - // ...(domain.startsWith("www.") && { - // redirect: domain.replace("www.", ""), - // }), - }) - } - ).then((res) => res.json()); -}; - -export const removeDomainFromVercelProject = async (domain: string) => { - return await fetch( - `https://api.vercel.com/v9/projects/${env.PROJECT_ID_VERCEL}/domains/${domain}${ - env.TEAM_ID_VERCEL ? `?teamId=${env.TEAM_ID_VERCEL}` : '' - }`, - { - headers: { - Authorization: `Bearer ${env.AUTH_BEARER_TOKEN}` - }, - method: 'DELETE' - } - ).then((res) => res.json()); -}; - -export const removeDomainFromVercelTeam = async (domain: string) => { - return await fetch( - `https://api.vercel.com/v6/domains/${domain}${env.TEAM_ID_VERCEL ? `?teamId=${env.TEAM_ID_VERCEL}` : ''}`, - { - headers: { - Authorization: `Bearer ${env.AUTH_BEARER_TOKEN}` - }, - method: 'DELETE' - } - ).then((res) => res.json()); -}; - -export const getDomainResponse = async ( - domain: string -): Promise => { - return await fetch( - `https://api.vercel.com/v9/projects/${env.PROJECT_ID_VERCEL}/domains/${domain}${ - env.TEAM_ID_VERCEL ? `?teamId=${env.TEAM_ID_VERCEL}` : '' - }`, - { - method: 'GET', - headers: { - Authorization: `Bearer ${env.AUTH_BEARER_TOKEN}`, - 'Content-Type': 'application/json' - } - } - ).then((res) => { - return res.json(); - }); -}; - -export const getConfigResponse = async (domain: string): Promise => { - return await fetch( - `https://api.vercel.com/v6/domains/${domain}/config${env.TEAM_ID_VERCEL ? `?teamId=${env.TEAM_ID_VERCEL}` : ''}`, - { - method: 'GET', - headers: { - Authorization: `Bearer ${env.AUTH_BEARER_TOKEN}`, - 'Content-Type': 'application/json' - } - } - ).then((res) => res.json()); -}; - -export const verifyDomain = async (domain: string): Promise => { - return await fetch( - `https://api.vercel.com/v9/projects/${env.PROJECT_ID_VERCEL}/domains/${domain}/verify${ - env.TEAM_ID_VERCEL ? `?teamId=${env.TEAM_ID_VERCEL}` : '' - }`, - { - method: 'POST', - headers: { - Authorization: `Bearer ${env.AUTH_BEARER_TOKEN}`, - 'Content-Type': 'application/json' - } - } - ).then((res) => res.json()); -}; - -export const getSubdomain = (name: string, apexName: string) => { - if (name === apexName) return null; - return name.slice(0, name.length - apexName.length - 1); -}; - -export const getApexDomain = (url: string) => { - let domain; - try { - domain = new URL(url).hostname; - } catch (e) { - return ''; - } - const parts = domain.split('.'); - if (parts.length > 2) { - // if it's a subdomain (e.g. project.vercel.app), return the last 2 parts - return parts.slice(-2).join('.'); - } - // if it's a normal domain (e.g. training.yourdomain.com), we return the domain - return domain; -}; diff --git a/apps/dashboard/src/lib/utils/translations/da.json b/apps/dashboard/src/lib/utils/translations/da.json index 684f524e..9deea343 100644 --- a/apps/dashboard/src/lib/utils/translations/da.json +++ b/apps/dashboard/src/lib/utils/translations/da.json @@ -2164,7 +2164,9 @@ "depth": "Hvor dybt skal kurset gå?", "topic": "Emne eller niche", "expertise_level": "Mål ekspertiseniveau", - "documentation_url": "Dokumentations-URL (valgfrit)" + "documentation_url": { + "hint": "Vi læser dette websted for at basere kurset på dit rigtige produkt." + } }, "expert_on_x": { "title": "Bliv ekspert på X", @@ -2417,12 +2419,12 @@ "error_description": "Dette tilpassede domæne eksisterer, men bekræftelsen er ikke fuldført endnu.", "page_subtitle": "Run your public learning site on a web address you own.", "dns_record": "DNS-record", - "verified_snackbar": "Tilpasset domæne bekræftet", "record_active": "Opdaget", "record_pending": "Afventer", "ssl_pending_heading": "SSL-certifikat er ved at blive udstedt", - "ssl_pending_body": "Ejerskabet af dit domæne er bekræftet. Vi venter nu på, at Cloudflare udsteder og implementerer SSL-certifikatet. Dette tager normalt et par minutter, når alle nedenstående DNS-poster er på plads.", - "validation_errors_heading": "Certifikatudstedelse er blokeret" + "ssl_pending_body": "Dit domæne peger på os. Vi venter nu på, at SSL-certifikatet bliver udstedt og implementeret. Dette tager normalt et par minutter, når alle nedenstående DNS-poster er på plads.", + "validation_errors_heading": "Certifikatudstedelse er blokeret", + "verified_snackbar": "Tilpasset domæne bekræftet" } }, "quiz": { diff --git a/apps/dashboard/src/lib/utils/translations/de.json b/apps/dashboard/src/lib/utils/translations/de.json index 9fa9d5a0..8b7e7e90 100644 --- a/apps/dashboard/src/lib/utils/translations/de.json +++ b/apps/dashboard/src/lib/utils/translations/de.json @@ -2164,7 +2164,9 @@ "depth": "Wie tief soll der Kurs gehen?", "topic": "Thema oder Nische", "expertise_level": "Zielkompetenzniveau", - "documentation_url": "Dokumentations-URL (optional)" + "documentation_url": { + "hint": "Wir werden diese Website lesen, um den Kurs in Ihrem realen Produkt zu verankern." + } }, "expert_on_x": { "title": "Werden Sie Experte für X", @@ -2417,12 +2419,12 @@ "error_description": "Diese benutzerdefinierte Domäne existiert, aber die Überprüfung ist noch nicht abgeschlossen.", "page_subtitle": "Run your public learning site on a web address you own.", "dns_record": "DNS-Eintrag", - "verified_snackbar": "Benutzerdefinierte Domain verifiziert", "record_active": "Erkannt", "record_pending": "Ausstehend", "ssl_pending_heading": "SSL-Zertifikat wird ausgestellt", - "ssl_pending_body": "Der Besitz Ihrer Domain wird bestätigt. Wir warten nun darauf, dass Cloudflare das SSL-Zertifikat ausstellt und bereitstellt. Dies dauert normalerweise einige Minuten, sobald alle unten aufgeführten DNS-Einträge vorhanden sind.", - "validation_errors_heading": "Die Ausstellung des Zertifikats ist gesperrt" + "ssl_pending_body": "Ihre Domain verweist auf uns. Wir warten nun darauf, dass das SSL-Zertifikat ausgestellt und bereitgestellt wird. Dies dauert normalerweise einige Minuten, sobald alle unten aufgeführten DNS-Einträge vorhanden sind.", + "validation_errors_heading": "Die Ausstellung des Zertifikats ist gesperrt", + "verified_snackbar": "Benutzerdefinierte Domain verifiziert" } }, "quiz": { diff --git a/apps/dashboard/src/lib/utils/translations/en.json b/apps/dashboard/src/lib/utils/translations/en.json index b4451e37..673c43b2 100644 --- a/apps/dashboard/src/lib/utils/translations/en.json +++ b/apps/dashboard/src/lib/utils/translations/en.json @@ -1527,11 +1527,11 @@ "record_active": "Detected", "record_pending": "Pending", "ssl_pending_heading": "SSL certificate is being issued", - "ssl_pending_body": "Ownership of your domain is verified. We're now waiting for Cloudflare to issue and deploy the SSL certificate. This usually takes a few minutes once all DNS records below are in place.", + "ssl_pending_body": "Your domain is pointing to us. We're now waiting for the SSL certificate to be issued and deployed. This usually takes a few minutes once all DNS records below are in place.", "validation_errors_heading": "Certificate issuance is blocked", "refresh": "Refresh", "reconnect": "Reconnect domain", - "reconnect_hint": "Reconnect this domain to generate new Cloudflare setup records.", + "reconnect_hint": "Reconnect this domain to generate fresh setup records.", "remove": "Remove", "status_verified": "Verified", "verified_snackbar": "Custom domain verified", @@ -1543,7 +1543,7 @@ "verified_description": "This custom domain is active and verified.", "pending_dns_description": "Add the DNS records below, then refresh verification.", "pending_verification_description": "DNS records were found. SSL issuance or validation is still in progress.", - "reconnect_description": "This domain needs to be connected again through the new Cloudflare setup flow.", + "reconnect_description": "This domain needs to be connected again to generate new setup records.", "error_description": "This custom domain exists, but verification is not complete yet.", "custom_favicon": "Custom favicon", "custom_code": "Custom code" diff --git a/apps/dashboard/src/lib/utils/translations/es.json b/apps/dashboard/src/lib/utils/translations/es.json index 45508e79..46150d54 100644 --- a/apps/dashboard/src/lib/utils/translations/es.json +++ b/apps/dashboard/src/lib/utils/translations/es.json @@ -2164,7 +2164,9 @@ "depth": "¿Qué profundidad debe tener el curso?", "topic": "Tema o nicho", "expertise_level": "Nivel de experiencia objetivo", - "documentation_url": "URL de documentación (opcional)" + "documentation_url": { + "hint": "Leeremos este sitio para basar el curso en su producto real." + } }, "expert_on_x": { "title": "Conviértete en un experto en X", @@ -2417,12 +2419,12 @@ "error_description": "Este dominio personalizado existe, pero la verificación aún no se ha completado.", "page_subtitle": "Run your public learning site on a web address you own.", "dns_record": "registro DNS", - "verified_snackbar": "Dominio personalizado verificado", "record_active": "Detectado", "record_pending": "Pendiente", "ssl_pending_heading": "Se está emitiendo el certificado SSL", - "ssl_pending_body": "Se verifica la propiedad de su dominio. Ahora estamos esperando que Cloudflare emita e implemente el certificado SSL. Esto suele tardar unos minutos una vez que todos los registros DNS siguientes estén en su lugar.", - "validation_errors_heading": "La emisión de certificados está bloqueada" + "ssl_pending_body": "Tu dominio nos apunta. Ahora estamos esperando que se emita e implemente el certificado SSL. Esto suele tardar unos minutos una vez que todos los registros DNS siguientes estén en su lugar.", + "validation_errors_heading": "La emisión de certificados está bloqueada", + "verified_snackbar": "Dominio personalizado verificado" } }, "quiz": { diff --git a/apps/dashboard/src/lib/utils/translations/fr.json b/apps/dashboard/src/lib/utils/translations/fr.json index ac40aece..ae83cc0e 100644 --- a/apps/dashboard/src/lib/utils/translations/fr.json +++ b/apps/dashboard/src/lib/utils/translations/fr.json @@ -2164,7 +2164,9 @@ "depth": "Jusqu’où doit aller le cours ?", "topic": "Sujet ou niche", "expertise_level": "Niveau d’expertise cible", - "documentation_url": "URL de la documentation (facultatif)" + "documentation_url": { + "hint": "Nous lirons ce site pour ancrer le cours dans votre produit réel." + } }, "expert_on_x": { "title": "Devenez un expert sur X", @@ -2417,12 +2419,12 @@ "error_description": "Ce domaine personnalisé existe, mais la vérification n'est pas encore terminée.", "page_subtitle": "Run your public learning site on a web address you own.", "dns_record": "Enregistrement DNS", - "verified_snackbar": "Domaine personnalisé vérifié", "record_active": "Détecté", "record_pending": "En attente", "ssl_pending_heading": "Le certificat SSL est en cours d'émission", - "ssl_pending_body": "La propriété de votre domaine est vérifiée. Nous attendons maintenant que Cloudflare émette et déploie le certificat SSL. Cela prend généralement quelques minutes une fois que tous les enregistrements DNS ci-dessous sont en place.", - "validation_errors_heading": "La délivrance du certificat est bloquée" + "ssl_pending_body": "Votre domaine pointe vers nous. Nous attendons maintenant que le certificat SSL soit émis et déployé. Cela prend généralement quelques minutes une fois que tous les enregistrements DNS ci-dessous sont en place.", + "validation_errors_heading": "La délivrance du certificat est bloquée", + "verified_snackbar": "Domaine personnalisé vérifié" } }, "quiz": { diff --git a/apps/dashboard/src/lib/utils/translations/hi.json b/apps/dashboard/src/lib/utils/translations/hi.json index 39468a8a..89857202 100644 --- a/apps/dashboard/src/lib/utils/translations/hi.json +++ b/apps/dashboard/src/lib/utils/translations/hi.json @@ -2164,7 +2164,9 @@ "depth": "पाठ्यक्रम कितना गहरा होना चाहिए?", "topic": "विषय या आला", "expertise_level": "लक्ष्य विशेषज्ञता स्तर", - "documentation_url": "दस्तावेज़ीकरण URL (वैकल्पिक)" + "documentation_url": { + "hint": "हम आपके वास्तविक उत्पाद में पाठ्यक्रम को आधार बनाने के लिए इस साइट को पढ़ेंगे।" + } }, "expert_on_x": { "title": "एक्स पर विशेषज्ञ बनें", @@ -2417,12 +2419,12 @@ "error_description": "यह कस्टम डोमेन मौजूद है, लेकिन सत्यापन अभी तक पूरा नहीं हुआ है।", "page_subtitle": "Run your public learning site on a web address you own.", "dns_record": "डीएनएस रिकॉर्ड", - "verified_snackbar": "कस्टम डोमेन सत्यापित", "record_active": "पता चला", "record_pending": "लंबित", "ssl_pending_heading": "एसएसएल प्रमाणपत्र जारी किया जा रहा है", - "ssl_pending_body": "आपके डोमेन का स्वामित्व सत्यापित है. अब हम क्लाउडफ्लेयर द्वारा एसएसएल प्रमाणपत्र जारी करने और तैनात करने की प्रतीक्षा कर रहे हैं। नीचे दिए गए सभी DNS रिकॉर्ड सही जगह पर आ जाने पर आमतौर पर इसमें कुछ मिनट लगते हैं।", - "validation_errors_heading": "प्रमाणपत्र जारी करना अवरुद्ध है" + "ssl_pending_body": "आपका डोमेन हमें इंगित कर रहा है. अब हम एसएसएल प्रमाणपत्र जारी होने और तैनात होने की प्रतीक्षा कर रहे हैं। नीचे दिए गए सभी DNS रिकॉर्ड सही जगह पर आ जाने पर आमतौर पर इसमें कुछ मिनट लगते हैं।", + "validation_errors_heading": "प्रमाणपत्र जारी करना अवरुद्ध है", + "verified_snackbar": "कस्टम डोमेन सत्यापित" } }, "quiz": { diff --git a/apps/dashboard/src/lib/utils/translations/pl.json b/apps/dashboard/src/lib/utils/translations/pl.json index 7b51288e..43c3b122 100644 --- a/apps/dashboard/src/lib/utils/translations/pl.json +++ b/apps/dashboard/src/lib/utils/translations/pl.json @@ -2164,7 +2164,9 @@ "depth": "Jak głęboko powinien sięgać kurs?", "topic": "Temat lub nisza", "expertise_level": "Docelowy poziom wiedzy", - "documentation_url": "Adres URL dokumentacji (opcjonalnie)" + "documentation_url": { + "hint": "Przeczytamy tę witrynę, aby oprzeć kurs na Twoim prawdziwym produkcie." + } }, "expert_on_x": { "title": "Zostań ekspertem w dziedzinie X", @@ -2417,12 +2419,12 @@ "error_description": "Ta domena niestandardowa istnieje, ale weryfikacja nie została jeszcze ukończona.", "page_subtitle": "Run your public learning site on a web address you own.", "dns_record": "Rekord DNS", - "verified_snackbar": "Zweryfikowano domenę niestandardową", "record_active": "Wykryto", "record_pending": "Oczekujące", "ssl_pending_heading": "Certyfikat SSL jest wydawany", - "ssl_pending_body": "Własność Twojej domeny została zweryfikowana. Czekamy teraz, aż Cloudflare wystawi i wdroży certyfikat SSL. Zwykle zajmuje to kilka minut, gdy wszystkie poniższe rekordy DNS są już na swoim miejscu.", - "validation_errors_heading": "Wystawienie certyfikatu jest zablokowane" + "ssl_pending_body": "Twoja domena wskazuje na nas. Czekamy teraz na wystawienie i wdrożenie certyfikatu SSL. Zwykle zajmuje to kilka minut, gdy wszystkie poniższe rekordy DNS są już na swoim miejscu.", + "validation_errors_heading": "Wystawienie certyfikatu jest zablokowane", + "verified_snackbar": "Zweryfikowano domenę niestandardową" } }, "quiz": { diff --git a/apps/dashboard/src/lib/utils/translations/pt.json b/apps/dashboard/src/lib/utils/translations/pt.json index e8bfd1c7..8f447a0e 100644 --- a/apps/dashboard/src/lib/utils/translations/pt.json +++ b/apps/dashboard/src/lib/utils/translations/pt.json @@ -2164,7 +2164,9 @@ "depth": "Quão profundo o curso deve ir?", "topic": "Tópico ou nicho", "expertise_level": "Nível de especialização alvo", - "documentation_url": "URL da documentação (opcional)" + "documentation_url": { + "hint": "Leremos este site para fundamentar o curso em seu produto real." + } }, "expert_on_x": { "title": "Torne-se um especialista em X", @@ -2417,12 +2419,12 @@ "error_description": "Este domínio personalizado existe, mas a verificação ainda não foi concluída.", "page_subtitle": "Run your public learning site on a web address you own.", "dns_record": "Registro DNS", - "verified_snackbar": "Domínio personalizado verificado", "record_active": "Detectado", "record_pending": "Pendente", "ssl_pending_heading": "O certificado SSL está sendo emitido", - "ssl_pending_body": "A propriedade do seu domínio é verificada. Agora estamos aguardando a Cloudflare emitir e implantar o certificado SSL. Isso geralmente leva alguns minutos depois que todos os registros DNS abaixo estiverem em vigor.", - "validation_errors_heading": "A emissão do certificado está bloqueada" + "ssl_pending_body": "Seu domínio está apontando para nós. Agora estamos aguardando a emissão e implantação do certificado SSL. Isso geralmente leva alguns minutos depois que todos os registros DNS abaixo estiverem em vigor.", + "validation_errors_heading": "A emissão do certificado está bloqueada", + "verified_snackbar": "Domínio personalizado verificado" } }, "quiz": { diff --git a/apps/dashboard/src/lib/utils/translations/ru.json b/apps/dashboard/src/lib/utils/translations/ru.json index d7a8d517..7e0e28d7 100644 --- a/apps/dashboard/src/lib/utils/translations/ru.json +++ b/apps/dashboard/src/lib/utils/translations/ru.json @@ -2164,7 +2164,9 @@ "depth": "Насколько глубоким должен быть курс?", "topic": "Тема или ниша", "expertise_level": "Целевой уровень экспертизы", - "documentation_url": "URL-адрес документации (необязательно)" + "documentation_url": { + "hint": "Мы прочитаем этот сайт, чтобы закрепить курс на вашем реальном продукте." + } }, "expert_on_x": { "title": "Станьте экспертом по X", @@ -2417,12 +2419,12 @@ "error_description": "Этот личный домен существует, но проверка еще не завершена.", "page_subtitle": "Run your public learning site on a web address you own.", "dns_record": "DNS-запись", - "verified_snackbar": "Персональный домен подтвержден", "record_active": "Обнаружено", "record_pending": "Ожидается", "ssl_pending_heading": "SSL-сертификат выдается", - "ssl_pending_body": "Право собственности на ваш домен подтверждено. Теперь мы ждем, пока Cloudflare выпустит и развернет сертификат SSL. Обычно это занимает несколько минут после того, как все указанные ниже записи DNS будут на месте.", - "validation_errors_heading": "Выдача сертификата заблокирована" + "ssl_pending_body": "Ваш домен указывает на нас. Теперь мы ожидаем выдачи и развертывания SSL-сертификата. Обычно это занимает несколько минут после того, как все указанные ниже записи DNS будут на месте.", + "validation_errors_heading": "Выдача сертификата заблокирована", + "verified_snackbar": "Персональный домен подтвержден" } }, "quiz": { diff --git a/apps/dashboard/src/lib/utils/translations/vi.json b/apps/dashboard/src/lib/utils/translations/vi.json index 319801ac..ae0e61b9 100644 --- a/apps/dashboard/src/lib/utils/translations/vi.json +++ b/apps/dashboard/src/lib/utils/translations/vi.json @@ -2164,7 +2164,9 @@ "depth": "Khóa học nên đi sâu đến mức nào?", "topic": "Chủ đề hoặc niche", "expertise_level": "Mục tiêu trình độ chuyên môn", - "documentation_url": "URL tài liệu (tùy chọn)" + "documentation_url": { + "hint": "Chúng tôi sẽ đọc trang web này để xây dựng khóa học về sản phẩm thực của bạn." + } }, "expert_on_x": { "title": "Trở thành chuyên gia về X", @@ -2417,12 +2419,12 @@ "error_description": "Miền tùy chỉnh này tồn tại nhưng quá trình xác minh chưa hoàn tất.", "page_subtitle": "Run your public learning site on a web address you own.", "dns_record": "bản ghi DNS", - "verified_snackbar": "Đã xác minh miền tùy chỉnh", "record_active": "Đã phát hiện", "record_pending": "Đang chờ xử lý", "ssl_pending_heading": "Chứng chỉ SSL đang được cấp", - "ssl_pending_body": "Quyền sở hữu tên miền của bạn đã được xác minh. Hiện chúng tôi đang chờ Cloudflare phát hành và triển khai chứng chỉ SSL. Quá trình này thường mất vài phút sau khi tất cả bản ghi DNS bên dưới đã sẵn sàng.", - "validation_errors_heading": "Việc cấp chứng chỉ bị chặn" + "ssl_pending_body": "Tên miền của bạn đang trỏ đến chúng tôi. Hiện chúng tôi đang chờ chứng chỉ SSL được cấp và triển khai. Quá trình này thường mất vài phút sau khi tất cả bản ghi DNS bên dưới đã sẵn sàng.", + "validation_errors_heading": "Việc cấp chứng chỉ bị chặn", + "verified_snackbar": "Đã xác minh miền tùy chỉnh" } }, "quiz": { diff --git a/packages/db/GOOGLE_AUTH.md b/packages/db/GOOGLE_AUTH.md index 06e918c1..08653db4 100644 --- a/packages/db/GOOGLE_AUTH.md +++ b/packages/db/GOOGLE_AUTH.md @@ -139,7 +139,7 @@ On the **dashboard** Render service: | -------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `HOST_HEADER` | `x-forwarded-host` | SvelteKit adapter-node reads the effective host from this so `event.url.host` is the tenant/admin host the user is on. | | `PROTOCOL_HEADER` | `x-forwarded-proto` | Same, for protocol. | -| `PRIVATE_APP_HOST` | `myclassroomio.com` | Used by `getSubdomain` in `layout-setup.ts` to extract the org siteName. | +| `PRIVATE_APP_HOST` | `myclassroomio.com` | Used by `getSubdomain` in `layout-setup.ts` to extract the org siteName. | | `PUBLIC_SERVER_URL` | `https://api.classroomio.com` | Used at runtime only as a fallback / for `authClient`'s SSR-time URL constructor; browser code uses `${origin}/proxy` via `getRequestBaseUrl`. | | `PRIVATE_SERVER_URL` | API's internal Render URL | Server-side dashboard → API calls bypass the public edge. | diff --git a/packages/utils/src/constants/agent.ts b/packages/utils/src/constants/agent.ts new file mode 100644 index 00000000..8effeb7f --- /dev/null +++ b/packages/utils/src/constants/agent.ts @@ -0,0 +1,11 @@ +/** + * Custom request `Content-Type` for `/agent/*` write endpoints. + * + * Render fronts every service with Cloudflare, which runs OWASP Managed Rules + * on bodies whose content-type substring-matches `application/json` and rejects + * AI chat payloads that contain SQL-injection-shaped strings (e.g. user + * messages discussing `SELECT … WHERE … OR 1=1`). A vendor-specific type + * falls through inspection. A small server middleware rewrites it back to + * `application/json` so `zValidator` parses the body unchanged. + */ +export const AGENT_CONTENT_TYPE = 'application/x-cio-agent'; diff --git a/packages/utils/src/constants/index.ts b/packages/utils/src/constants/index.ts index 9ee97bfc..72028364 100644 --- a/packages/utils/src/constants/index.ts +++ b/packages/utils/src/constants/index.ts @@ -3,3 +3,4 @@ export * from './org'; export * from './content'; export * from './domains'; export * from './error-codes'; +export * from './agent';