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
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ text it shows is a gist that must match `CLA.md`: change both together, as a new
| `apps/worker` | `@coss/worker` | Bun background worker: email dispatch (pg-boss), scheduled sends, broadcast fan-out, webhook deliveries, domain re-checks, dedicated IP pools, stale-claim/unclaimed sweeps, data retention purges, organization deletions, SES events and received emails from SQS (`EMAIL_PROVIDER=ses`). `scripts/ses-bootstrap.ts`. Safe to run several. |
| `packages/db` | `@coss/db` | Drizzle schema (generated Better Auth tables + domain tables), pg client, TypeIDs, migrations, test guard; `./jobs` (pg-boss); `./idempotency` (`withIdempotencyKey`, request hashes); `./partitions` (daily range partitions: create ahead, drop expired). |
| `packages/signals` | `@coss/signals` | Stateless abuse signals shared by coss.com's sign-up and risk: `./email` (syntax, disposable domains, MX/null-MX/NXDOMAIN with a cache, canonical mailbox, role accounts, free providers, typo suggestions), `./ip` (bogons, subnets, CIDR range sets, `IpIntelligence`: Tor, cloud/hosting networks, ASN, country), `./datasets` (parsers for the public IP feeds and IPinfo Lite's MMDB), `./testing` (`buildTestMmdb`). No workspace deps, no database; server-only. |
| `packages/auth` | `@coss/auth` | Better Auth options/instance (organization, admin, api-key, two-factor, passkey, sso, scim, captcha plugins), org permissions, slug rules, API key permissions (`./api-key-permissions`: the labelled catalog, access presets, validation; client-safe), the sign-up email guard (`src/email-guard.ts`, built on `@coss/signals/email`); `./social`: Google/GitHub sign-in (server-only); `./risk`: the injected sign-up risk assessor (coss.com Risk on coss.com's own sign-ups); `./sso`: SSO trust rules; `./scim`: directory sync rules (both server-only); `./emails`: the account email outbox; `./mobile`: the iOS app's sign-in (PKCE codes, bearer sessions; server-only). See `packages/auth/AGENTS.md`. |
| `packages/auth` | `@coss/auth` | Better Auth options/instance (organization, admin, api-key, two-factor, passkey, sso, scim, captcha plugins), org permissions, slug rules, API key permissions (`./api-key-permissions`: the labelled catalog, access presets, validation; client-safe), the sign-up email guard (`src/email-guard.ts`, built on `@coss/signals/email`); `./social`: Google/GitHub sign-in (server-only); `./risk`: the injected sign-up risk assessor (coss.com Risk on coss.com's own sign-ups); `./sso`: SSO trust rules; `./scim`: directory sync rules (both server-only); `./emails`: the account email outbox; `./mobile`: the iOS app's sign-in (PKCE codes, bearer sessions; server-only); `./first-touch`: where a visitor first came from, kept in the browser until sign-up (client-safe). See `packages/auth/AGENTS.md`. |
| `packages/tenancy` | `@coss/tenancy` | Projects (`createFirstProject`: a new organization's, made by the dashboard's onboarding), environments, environment-bound API keys (create/authenticate), memberships, organization SSO settings, directory sync (SCIM) connections and group roles, seed/admin scripts, test fixtures; `./logos`: organization logos found on the organization's own website (verified sending domains, then members' work email domains; SSRF-safe HTTPS, sniffed and size-checked images, kept in `organization_logo` and served by the dashboard at `/<org>/settings/logo` under a sandboxing CSP), run by the worker's `organization-logos` loop; `./admin`: suspensions, SSO/SCIM recovery, organization deletion, audit log and cross-org reads; `./deletion`: organization teardown (imports only `@coss/db`). See `packages/tenancy/AGENTS.md`. |
| `packages/email` | `@coss/email` | Email product: wire schemas, providers (fake, SES under `./ses`), send/dispatch/read, recipients + event ingestion, domains/DNS (with the BIMI check), custom tracking domains (CloudFront distribution tenants), dedicated IP pools (SES managed dedicated IPs, `dedicated-ips.ts`), receiving (received emails, receiving domains, managed inbound addresses, the content store), inboxes for agents (`inboxes.ts`, `inbox-send.ts`: threads, sends and replies; wire schemas in `./inbox-schemas`), DMARC reports (ingestion, guidance to `p=reject`, owner notifications), templates (drafts, versions, locales, rendering at send), tenants, sweeps, contacts (custom properties, audiences, segments, topics, marketing settings; wire schemas in `./contact-schemas`), broadcasts (wire schemas in `./broadcast-schemas`), sub-accounts (email for platforms: a customer's end users with their own domains and SES tenant, suppressions, quotas, reputation and screening; wire schemas in `./subaccount-schemas`); `./templates` (the template language: parse, check, render, variable validation; client-safe, no imports), `./domain-connect` (one-click DNS setup: discovery, the signed apply link, `domain-connect/coss.com.email.json`), `./dispatch` (pg-boss queue + worker handler), `./runtime` builds providers from env, `./admin` (the admin console's cross-org reads), `./env` (zod only), `./testing`. See `packages/email/AGENTS.md`. |
| `packages/ratelimit` | `@coss/ratelimit` | Rate limits: bucket catalog, GCRA in Valkey (Lua) or memory, per-org overrides (`rate_limit_override`), client IP resolution; `./env` (zod only), `./testing`. `scripts/valkey-bootstrap.ts`, `scripts/set-override.ts`. |
Expand Down Expand Up @@ -95,7 +95,7 @@ billing enqueues `billing.*` events through it — and email imports only `./met
`db ← logs ← webhooks ← verify` (`verify` also `→ sms, risk, reputation, ratelimit, signals, retention, versioning` and only `@coss/billing/{metering,gate,catalog}`; `email` imports only `@coss/verify/schemas`, for `WEBHOOK_EVENT_TYPES`);
`db ← logs ← webhooks ← sign` (`sign` also `→ versioning, retention` and only `@coss/billing/metering`; `email` imports only `@coss/sign/schemas`, for `WEBHOOK_EVENT_TYPES`); `db ← logs ← webhooks ← scheduling` (`scheduling` also `→ notifications, dashboard-routes, email-layout, versioning, retention` and only `@coss/billing/{metering,gate}`; never `@coss/email` or `@coss/sms`: sending is injected; `email` imports only `@coss/scheduling/schemas`, for `WEBHOOK_EVENT_TYPES`); `db ← notifications ← support` (`support` also `→ signals, email-layout, retention` and only `@coss/billing/catalog`; never `@coss/email`: mail, reply text and the plan lookup are injected); `db ← logs ← webhooks ← risk ← forms` (`forms` also `→ versioning, retention` and only `@coss/billing/metering`; never `@coss/email`: mail and contacts are injected; `email` imports only `@coss/forms/schemas`, for `WEBHOOK_EVENT_TYPES`); `api → coss-mcp (its `./http`; `coss` comes with it), tenancy, email, sms, sign, verify, forms, hooks, reputation, webhooks, ratelimit, versioning, logs, billing, risk, screening (`./env` only), db`;
`smtp → tenancy, email, ratelimit, logs, billing, observability, db`;
`web → auth, tenancy, email, sms, sign, forms, support, hooks, reputation, screening, webhooks, ratelimit, versioning, retention, logs, billing, risk, push, migrate, companion, db, ui`; `mobile → companion` (types only; a dev dependency), `retention`, `observability`, `dashboard-routes` (bundled by Metro: keep them dependency-free); `portal → hooks, webhooks, ratelimit, logs, observability, db, ui`; `sign-app → sign, ratelimit, observability, db, ui`; `site → ui, auth, versioning, retention, billing, risk, reputation, ratelimit, sms, sdkgen` (only the client-safe `@coss/auth/{slugs,constants,api-key-permissions}`, the changelog, the retention policy, `@coss/billing/{catalog,money,engine}`, `@coss/risk/schemas`, `@coss/reputation/schemas` (the default policy), `@coss/ratelimit/buckets` (the bucket catalog, no imports), `@coss/sms/segments` (the segment calculator) and `@coss/sdkgen`'s `snippets.json` + `./methods`) +
`web → auth, tenancy, email, sms, sign, forms, support, hooks, reputation, screening, webhooks, ratelimit, versioning, retention, logs, billing, risk, push, migrate, companion, db, ui`; `mobile → companion` (types only; a dev dependency), `retention`, `observability`, `dashboard-routes` (bundled by Metro: keep them dependency-free); `portal → hooks, webhooks, ratelimit, logs, observability, db, ui`; `sign-app → sign, ratelimit, observability, db, ui`; `site → ui, auth, versioning, retention, billing, risk, reputation, ratelimit, sms, sdkgen` (only the client-safe `@coss/auth/{slugs,constants,api-key-permissions,first-touch}`, the changelog, the retention policy, `@coss/billing/{catalog,money,engine}`, `@coss/risk/schemas`, `@coss/reputation/schemas` (the default policy), `@coss/ratelimit/buckets` (the bucket catalog, no imports), `@coss/sms/segments` (the segment calculator) and `@coss/sdkgen`'s `snippets.json` + `./methods`) +
`@coss/api/openapi.json` (data only, never api code); `sdkgen → @coss/api/openapi.json` (data only); `infra → auth, email, sms, sign, hooks, ratelimit, screening, billing` (`slugs`, `env` only);
`worker → auth (`./emails` only), email, sms, sign, verify, forms, support, hooks, reputation, screening, webhooks, ratelimit, logs, risk, push, migrate, tenancy, retention, billing, db` (from tenancy only `./retention`, `./deletion` and `./logos`, which import nothing but `db` and `signals`); `sdk`, `ui`, `kloq` and `companion` depend on no workspace (`site → kloq`, `expo-kloq → kloq`, `expo-kloq-example → expo-kloq`); `mcp` and `cli` depend only on the published `coss` (a
semver range, not `workspace:`).
Expand Down Expand Up @@ -539,7 +539,7 @@ notification emails) and, if there is useful advice, to `ADVICE` (`packages/repu
(and the dispatch jobs, from the job's `organizationId`); `@coss/sms/admin` is platform-level like `@coss/email/admin`. Server actions take only slugs +
target ids and derive the org from `requireOrgPermission`; every Better Auth organization call passes `organizationId`
explicitly (never rely on `session.activeOrganizationId`).
- **Client-safe modules** never import `@coss/db`: `@coss/compliance/catalog`, `@coss/dashboard-routes`, `@coss/risk/{schemas,env}`, `@coss/observability/*`, `@coss/auth/{permissions,slugs,constants,api-key-permissions,org-access}`, `@coss/push/schemas`, `@coss/forms/{schemas,env}`, `@coss/scheduling/{branding,holiday-countries}`, `@coss/email/schemas`,
- **Client-safe modules** never import `@coss/db`: `@coss/compliance/catalog`, `@coss/dashboard-routes`, `@coss/risk/{schemas,env}`, `@coss/observability/*`, `@coss/auth/{permissions,slugs,constants,api-key-permissions,org-access,first-touch}`, `@coss/push/schemas`, `@coss/forms/{schemas,env}`, `@coss/scheduling/{branding,holiday-countries}`, `@coss/email/schemas`,
`@coss/email/{broadcast-schemas,contact-schemas,inbox-schemas,subaccount-schemas,test-event-schemas,env,templates}`, `@coss/sms/{schemas,segments,env}`, `@coss/verify/schemas`, `@coss/hooks/{schemas,env}`, `@coss/reputation/schemas`, `@coss/screening/env`, `@coss/webhooks/{schemas,env}`, `@coss/ratelimit/buckets`, `@coss/versioning`, `@coss/retention`, `@coss/logs/{schemas,query}`, `@coss/billing/{catalog,schemas,engine,money,env,gate,wire}` (`gate` is types only), `@coss/ui/*`, `@coss/ui/theme.css`, `@coss/companion`, `@coss/migrate/templates`, `@coss/email-layout`. `@coss/webhooks`, `@coss/db/jobs`, `@coss/screening/{ai,runtime}`, `@coss/email/{runtime,ses,dispatch,domain-connect}`, `@coss/risk/{runtime,datasets}`, `@coss/signals/*`, `@coss/sms` (root, `./twilio`, `./runtime`, `./dispatch`, `./admin`), `@coss/push` (root, `./apns`), `@coss/notifications` and `@coss/billing` (root, `./metering`, `./runtime`, `./stripe`) are server-only; only `./stripe` (and `./runtime`) import the Stripe SDK. Server-only web
modules start with `import "server-only"`.
- **Rate limits** (`@coss/ratelimit`): buckets are defined only in `BUCKETS` (`src/buckets.ts`); GCRA state is one
Expand Down
2 changes: 2 additions & 0 deletions apps/site/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import "./global.css";
import { fontMono, fontSans } from "@coss/ui/fonts";
import { RootProvider } from "fumadocs-ui/provider/next";
import type { Metadata, Viewport } from "next";
import { FirstTouchCapture } from "@/components/first-touch-capture";
import { SITE_DESCRIPTION, SITE_NAME, SITE_URL } from "@/lib/site";

export const metadata: Metadata = {
Expand Down Expand Up @@ -37,6 +38,7 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
<body className="relative bg-sidebar font-sans text-foreground antialiased">
{/* Fumadocs' own search dialog stays off: the header's command menu searches instead. */}
<RootProvider search={{ enabled: false }}>{children}</RootProvider>
<FirstTouchCapture />
</body>
</html>
);
Expand Down
12 changes: 12 additions & 0 deletions apps/site/src/components/first-touch-capture.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"use client";

import { rememberFirstTouch } from "@coss/auth/first-touch";
import { useEffect } from "react";

/** Keeps where a visitor first came from, in their browser, for their sign-up on the dashboard. */
export function FirstTouchCapture() {
useEffect(() => {
rememberFirstTouch();
}, []);
return null;
}
1 change: 1 addition & 0 deletions apps/web/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ directory unless they name a workspace.
`PUBLIC_PATH_PREFIXES` (`src/proxy.ts`). The entry page is `/dashboard` (`DASHBOARD_PATH`), never `/` (the site's
landing page): use it for every "go to the dashboard" link and as the post-sign-in default (`safeRedirectPath`).
The dashboard has no root files (icon, robots, sitemap): the site serves them. Its pages are noindex (the root layout) except `/sign-in` and `/sign-up` (`publicPageMetadata`, `src/lib/public-metadata.ts`), whose canonical folds the `?next=` links of every other signed-out path into one URL. Signed out, `src/proxy.ts` redirects only the dashboard's own top-level pages and API; any other document request answers 404 with a refresh to sign-in (`src/lib/signed-out.ts`), the same for a real organization and a made-up one, so dead URLs are 404s to search engines and slugs can't be enumerated.
- Sign-up sources: the site and `/sign-up` keep a visitor's first touch in `localStorage` (`@coss/auth/first-touch`: path, referrer host and campaign parameters only; nothing with Global Privacy Control on), `/onboarding` reports it once (`recordSignupAttributionAction`, `signup_attribution`), and `/admin/signups` shows the counts. Invited users never reach onboarding, so they aren't counted.
- Domain management uses `emailRuntime().domainDeps` (`src/lib/email-runtime.ts`) and webhook management
`webhookManagementDeps()` (`src/lib/webhooks.ts`); domain/webhook errors carry user-facing messages. Revealing a
webhook secret needs `webhook:update` (members can read endpoints but never see secrets).
Expand Down
26 changes: 26 additions & 0 deletions apps/web/e2e/smoke.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,32 @@ test("a new customer goes from sign-up to an API key and a domain", async ({ pag
await expect(page.getByText(domain).first()).toBeVisible();
});

test("a new account remembers where it first came from", async ({ page }) => {
const id = randomUUID().slice(0, 8);
const email = `e2e-${id}@gmail.com`;

await page.goto(`/sign-up?utm_source=e2e-${id}`);
await signUp(page, email);
await markEmailVerified(email);
await signIn(page, email);
await page.waitForURL("**/onboarding**");

// Reported once, then forgotten.
await expect.poll(() => page.evaluate(() => localStorage.getItem("coss.first-touch"))).toBeNull();
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
await client.connect();
try {
const { rows } = await client.query(
`select a.utm_source as source, a.landing_path as path from signup_attribution a
join "user" u on u.id = a.user_id where u.email = $1`,
[email],
);
expect(rows).toEqual([{ source: `e2e-${id}`, path: "/sign-up" }]);
} finally {
await client.end();
}
});

test("signed-out visitors are sent to sign in", async ({ page }) => {
await page.goto("/dashboard");
await page.waitForURL("**/sign-in**");
Expand Down
7 changes: 6 additions & 1 deletion apps/web/src/app/(auth)/sign-up/sign-up-form.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
"use client";

import { DEVICE_TOKEN_HEADER, type SocialProvider } from "@coss/auth/constants";
import { rememberFirstTouch } from "@coss/auth/first-touch";
import { Button } from "@coss/ui/components/button";
import { Card, CardDescription, CardHeader, CardPanel, CardTitle } from "@coss/ui/components/card";
import { Field, FieldDescription, FieldLabel } from "@coss/ui/components/field";
import { Input } from "@coss/ui/components/input";
import { Separator } from "@coss/ui/components/separator";
import { collectDevice } from "coss/device";
import Link from "next/link";
import { type FormEvent, useState } from "react";
import { type FormEvent, useEffect, useState } from "react";
import { FormError } from "@/components/form-error";
import { SocialSignInButtons } from "@/components/social-sign-in";
import { useTurnstile } from "@/components/turnstile";
Expand All @@ -30,6 +31,10 @@ export function SignUpForm({
initialError: string | null;
}) {
const turnstile = useTurnstile(turnstileSiteKey, "sign-up");
// For visitors whose first page is this one; the site's pages do the same for the rest.
useEffect(() => {
rememberFirstTouch();
}, []);
const [error, setError] = useState<string | null>(initialError);
const [pending, setPending] = useState(false);
const [createdEmail, setCreatedEmail] = useState<string | null>(null);
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/app/admin/signups/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type { Metadata } from "next";
import Link from "next/link";
import { AdminActionDialog } from "@/components/admin/admin-action-dialog";
import { listHref, stringParam } from "@/components/admin/list-controls";
import { SignupSources } from "@/components/admin/signup-sources";
import { UserStatusBadge } from "@/components/admin/status-badges";
import { CursorPager } from "@/components/cursor-pager";
import { EmptyState } from "@/components/empty-state";
Expand Down Expand Up @@ -77,6 +78,7 @@ export default async function AdminSignUpsPage({ searchParams }: PageProps<"/adm
return (
<div className="flex flex-col gap-6">
<PageHeader description="The newest accounts." title="Sign-ups" />
<SignupSources />
<Alert variant="warning">
<AlertTitle>Sign-ups aren't assessed</AlertTitle>
<AlertDescription>
Expand Down Expand Up @@ -148,6 +150,7 @@ export default async function AdminSignUpsPage({ searchParams }: PageProps<"/adm
description="Every sign-up as coss.com Risk assessed it, newest first. Blocked sign-ups were refused and have no account; the address, IP and user agent are kept for 90 days. Report abuse here (or take the account down with it) so Risk flags the same mailbox, device and network next time; mark false positives legitimate."
title="Sign-ups"
/>
<SignupSources />
<FilterLinks
options={VIEWS.map((option) => ({
value: option.value,
Expand Down
Loading
Loading