diff --git a/frontend/nginx.conf b/frontend/nginx.conf
index baedc043a3..6c5ee4d5d2 100644
--- a/frontend/nginx.conf
+++ b/frontend/nginx.conf
@@ -56,7 +56,7 @@ server {
# backend/CLI, not typed by people: /cli/authorize (device-code login,
# backend/routes/auth.ts verifyUrl) and /settings/devices (the CLI's
# manage-devices line). V2ConnectHonesty.test.ts asserts both stay here.
- location ~ ^/(?:v2(?:/|$)|login/?$|register(?:/|$)|verify-email/?$|cli/authorize/?$|settings/devices/?$|discord/(?:callback|success|error)/?$|feed/?$|thread/|dashboard/?$|digest/?$|agents/?$|skills/?$|activity/?$|apps/?$|profile(?:/|$)|admin/(?:users|integrations/global)/?$|dev/(?:api|pod-context)/?$|pods(?:/|$)|chat/) {
+ location ~ ^/(?:v2(?:/|$)|privacy/?$|terms/?$|login/?$|register(?:/|$)|verify-email/?$|cli/authorize/?$|settings/devices/?$|discord/(?:callback|success|error)/?$|feed/?$|thread/|dashboard/?$|digest/?$|agents/?$|skills/?$|activity/?$|apps/?$|profile(?:/|$)|admin/(?:users|integrations/global)/?$|dev/(?:api|pod-context)/?$|pods(?:/|$)|chat/) {
try_files $uri $uri/ /index.html;
}
diff --git a/frontend/scripts/nginx-routing.test.mjs b/frontend/scripts/nginx-routing.test.mjs
index 7056a80674..c85c41e0ee 100644
--- a/frontend/scripts/nginx-routing.test.mjs
+++ b/frontend/scripts/nginx-routing.test.mjs
@@ -16,6 +16,7 @@ test('unknown public routes are real 404s while documented app routes retain SPA
assert.match(config, /location = \/404\.html\s*\{\s*internal;/s);
assert.doesNotMatch(config, /error_page\s+404\s+\/index\.html/);
assert.match(config, /location ~ \^\/\(\?:v2\(\?:\/\|\$\).*try_files \$uri \$uri\/ \/index\.html;/s);
+ assert.match(config, /privacy\/\?\$\|terms\/\?\$/);
assert.match(config, /login\/\?\$/);
assert.match(config, /pods\(\?:\/\|\$\)/);
assert.match(notFoundPage, /
Page not found \| Commonly AI<\/title>/);
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 89138f23fe..8d476b3d5b 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -20,6 +20,8 @@ import '@fontsource/ibm-plex-sans/600.css';
import '@fontsource/ibm-plex-mono/500.css';
import './v2/v2.css';
import './App.css';
+// Public legal pages linked from Google OAuth consent and signup (TASK-530).
+import { V2PrivacyPage, V2TermsPage } from './v2/legal/V2LegalPage';
// TASK-145 — route-level split. One 4.18 MB (1.16 MB gzip) entry chunk used to
// be the whole product for every visitor: the landing carried the authenticated
@@ -316,6 +318,8 @@ function App(): React.ReactElement {
} />
} />
} />
+ } />
+ } />
} />
} />
} />
diff --git a/frontend/src/v2/__tests__/V2LegalPage.test.tsx b/frontend/src/v2/__tests__/V2LegalPage.test.tsx
new file mode 100644
index 0000000000..95529c74c6
--- /dev/null
+++ b/frontend/src/v2/__tests__/V2LegalPage.test.tsx
@@ -0,0 +1,25 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { V2PrivacyPage, V2TermsPage } from '../legal/V2LegalPage';
+
+describe('public legal pages (TASK-530)', () => {
+ it('renders the privacy page with its required sections', () => {
+ render();
+ expect(screen.getByRole('heading', { level: 1, name: 'Privacy' })).toBeInTheDocument();
+ ['What we store', 'How long we keep data', 'Who processes your data', 'Analytics', 'Deleting your account']
+ .forEach((heading) => expect(screen.getByRole('heading', { level: 2, name: heading })).toBeInTheDocument());
+ expect(screen.queryByText(/Draft for review/)).not.toBeInTheDocument();
+ });
+
+ it('links the addresses by mailto and links home', () => {
+ render();
+ expect(screen.getByRole('link', { name: 'privacy@commonly.me' })).toHaveAttribute('href', 'mailto:privacy@commonly.me');
+ expect(screen.getByRole('link', { name: 'Commonly' })).toHaveAttribute('href', '/');
+ });
+
+ it('renders the terms page', () => {
+ render();
+ expect(screen.getByRole('heading', { level: 1, name: 'Terms' })).toBeInTheDocument();
+ expect(screen.getByRole('heading', { level: 2, name: 'The service' })).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/v2/legal/V2LegalPage.tsx b/frontend/src/v2/legal/V2LegalPage.tsx
new file mode 100644
index 0000000000..3aa8f6752d
--- /dev/null
+++ b/frontend/src/v2/legal/V2LegalPage.tsx
@@ -0,0 +1,141 @@
+import React from 'react';
+
+/*
+ * TASK-530 — public Privacy and Terms pages, linked from Google OAuth consent
+ * and signup. Every factual claim cites its source in the PR body. Legal
+ * wording is Sam's to approve; Cofounder reads it before the gate.
+ */
+
+interface LegalSection {
+ heading: string;
+ body: string[];
+}
+
+// Turns each commonly.me address in a paragraph into a mailto link.
+// String.split with a capture group puts the matches at odd indexes.
+const EMAIL_PATTERN = /([a-z]+@commonly\.me)/;
+const linkEmails = (text: string): React.ReactNode[] =>
+ text.split(EMAIL_PATTERN).map((part, index) => (index % 2 === 1
+ ? {part}
+ : part));
+
+const PRIVACY_SECTIONS: LegalSection[] = [
+ {
+ heading: 'Who we are',
+ body: [
+ 'Commonly Software LLC, a California limited liability company, runs commonly.me. On this page, "we" and "us" mean Commonly Software LLC.',
+ ],
+ },
+ {
+ heading: 'What we store',
+ body: [
+ 'Account details: username, display name, email, profile picture, role, timezone, billing identifiers, linked Google and GitHub identities, and contact details. Accounts created with a password store a bcrypt hash, never the password itself. Accounts created through Google or GitHub sign-in are created without a password.',
+ 'You can sign in with a password, Google, GitHub, or a CLI device code. After you sign in, your session token is kept in your browser\'s local storage under the key "token".',
+ 'Pods, the chat messages you and your agents post, and the tasks and posts you create.',
+ 'Agent memory: notes an agent keeps between sessions. Its owners and admins can read it and edit its sections. The agent reads and writes its own memory. There is no way in the app to delete an agent\'s whole memory.',
+ 'Files you upload, such as images attached to posts and messages. They are stored in our MongoDB database.',
+ 'Connector credentials, such as tokens for integrations you connect. These are encrypted at rest with AES-256-GCM.',
+ ],
+ },
+ {
+ heading: 'How long we keep data',
+ body: [
+ 'Chat messages are deleted from our chat database after 30 days. This applies to every plan.',
+ 'Uploaded files have no expiry date.',
+ 'The chat retention job can shorten the 30-day window when storage runs low, down to a one-day floor.',
+ ],
+ },
+ {
+ heading: 'Who processes your data',
+ body: [
+ 'Hosting: Google Cloud (Kubernetes on GKE). Traffic reaches our servers through Cloudflare.',
+ 'Email delivery: SMTP2GO.',
+ 'Error monitoring: Sentry. Personal data is not sent by default.',
+ 'AI models for hosted agents: DeepSeek is the default model. OpenRouter serves some hosted routes, including free community models and two paid models. Cloudflare Workers AI is the fallback.',
+ 'When you talk to a hosted agent, your messages and the agent\'s context are sent to that model provider to generate the reply.',
+ 'Agents you run on your own machine use your own model provider. We do not process those model calls.',
+ ],
+ },
+ {
+ heading: 'Analytics',
+ body: [
+ 'Campaign links: when you first arrive with a ref code in the link (?ref=), your browser stores that code in local storage and sends one visit record. The record holds the code and the UTC day. It has no person, address or user agent.',
+ 'If you sign up through a ref link, the code is saved on your account.',
+ ],
+ },
+ {
+ heading: 'Deleting your account',
+ body: [
+ 'To delete your account and your uploads, email support@commonly.me.',
+ 'Uploaded files cannot be deleted in the app.',
+ 'Privacy questions: privacy@commonly.me.',
+ ],
+ },
+];
+
+const TERMS_SECTIONS: LegalSection[] = [
+ {
+ heading: 'Who we are',
+ body: [
+ 'Commonly Software LLC, a California limited liability company, runs commonly.me. In these terms, "we" and "us" mean Commonly Software LLC.',
+ ],
+ },
+ {
+ heading: 'The service',
+ body: [
+ 'Commonly is a hosted workspace at commonly.me where people and software agents work together in pods, chat and tasks. Features can change while the product is in beta.',
+ ],
+ },
+ {
+ heading: 'Your account',
+ body: [
+ 'You need to be at least 16 to use Commonly. Keep your sign-in secure. You are responsible for activity on your account, including actions taken by agents you install or connect.',
+ ],
+ },
+ {
+ heading: 'Content and agents',
+ body: [
+ 'You keep ownership of what you and your agents post. You give us permission to store, process and display that content only to run the service for you and the people you share it with. Don\'t use Commonly to break the law, to harass anyone, to access accounts or data that aren\'t yours, to send spam, or to interfere with the service. We may remove content or agents that break these rules.',
+ ],
+ },
+ {
+ heading: 'Ending',
+ body: [
+ 'You can stop using Commonly at any time and ask us to delete your account. We may suspend or close accounts that break these terms or put the service or other users at risk. The service is provided as is, without warranties. To the extent the law allows, our total liability to you is limited to the amount you paid us in the 12 months before the claim.',
+ ],
+ },
+ {
+ heading: 'Contact',
+ body: [
+ 'Questions about these terms: support@commonly.me.',
+ ],
+ },
+];
+
+const LegalDocument: React.FC<{ title: string; sections: LegalSection[] }> = ({ title, sections }) => (
+
+
+ Commonly
+ {title}
+ {sections.map((section) => (
+
+ {section.heading}
+ {section.body.map((paragraph, index) => (
+ // eslint-disable-next-line react/no-array-index-key
+ {linkEmails(paragraph)}
+ ))}
+
+ ))}
+
+
+);
+
+export const V2PrivacyPage: React.FC = () => (
+
+);
+
+export const V2TermsPage: React.FC = () => (
+
+);
+
+export default V2PrivacyPage;
diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css
index 00c4572b86..5e17832cf3 100644
--- a/frontend/src/v2/v2.css
+++ b/frontend/src/v2/v2.css
@@ -13389,3 +13389,15 @@ body.modern-ui.v2-canvas {
.v2-root button.v2-board__new:hover, .v2-root button.v2-board__create-submit:not(:disabled):hover { background: var(--v2-text-secondary); border-color: var(--v2-text-secondary); }
.v2-board__owner-time { color: var(--v2-text-muted); }
+
+/* Public Privacy and Terms pages (TASK-530). Outside .v2-root, so no root-scoped selectors. */
+/* .v2-root sets height:100vh and overflow:hidden; the legal pages must scroll, so override both (same as the landing). */
+.v2-root.v2-legal { display: block; height: 100vh; overflow-y: auto; overflow-x: hidden; box-sizing: border-box; padding: 48px 24px; background: var(--v2-bg-subtle); color: var(--v2-text-primary); }
+.v2-legal__doc { max-width: 680px; margin: 0 auto; }
+.v2-legal__home { display: inline-block; margin: 0 0 24px; font-size: 14px; color: var(--v2-text-secondary); text-decoration: none; }
+.v2-legal__home:hover { text-decoration: underline; }
+.v2-legal__section a { color: var(--v2-text-primary); text-decoration: underline; }
+.v2-legal__title { font-size: 32px; font-weight: 600; line-height: 1.2; margin: 0 0 32px; }
+.v2-legal__section { margin-bottom: 28px; }
+.v2-legal__section h2 { font-size: 18px; font-weight: 600; margin: 0 0 8px; }
+.v2-legal__section p { font-size: 15px; line-height: 1.6; color: var(--v2-text-secondary); margin: 0 0 8px; }