Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
0d0f1c2
feat: add legal version constants for cloud consent flow
akifbayram May 9, 2026
27c4253
feat: add MARKETING_OPT_IN_VISIBLE config flag
akifbayram May 9, 2026
7975515
feat: add user_consents table and consent columns to schema
akifbayram May 9, 2026
fcc2d58
feat: add 0013_user_consent migration with backfill
akifbayram May 9, 2026
f144dc5
feat: add recordConsent helper for ToS + Privacy audit trail
akifbayram May 9, 2026
420ca5b
feat: add requireCurrentConsent middleware for cloud mutation gating
akifbayram May 9, 2026
5aacd8c
feat: surface tos/privacy versions and marketing flag on auth status
akifbayram May 9, 2026
75fe012
feat: include consent fields in /api/auth/me response
akifbayram May 9, 2026
ae6e6c9
feat: gate cloud register on ToS + Privacy acceptance
akifbayram May 9, 2026
8f5a96e
feat: add POST /api/auth/complete-consent endpoint
akifbayram May 9, 2026
43a6429
feat: mount requireCurrentConsent on /api (skip /api/auth)
akifbayram May 9, 2026
1d22ff0
feat: extend User and AuthStatusConfig with consent fields
akifbayram May 9, 2026
c74f018
feat: add ConsentRequiredError thrown from apiFetch on 403 CONSENT_RE…
akifbayram May 9, 2026
52f7fcd
feat: add CompleteSignupPage for OAuth + re-acceptance
akifbayram May 9, 2026
f239993
feat: redirect stale-consent users to /auth/complete-signup
akifbayram May 9, 2026
6db15b6
feat: clickwrap consent checkbox on cloud RegisterPage
akifbayram May 9, 2026
cc70e93
test: mock useAuthStatusConfig in AuthGuard tests after T17 redirect …
akifbayram May 9, 2026
6daba2d
feat: derive legal effective date from auth status tosVersion
akifbayram May 9, 2026
6df9a5b
refactor: simplify consent flow per code review
akifbayram May 9, 2026
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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
# MAX_PHOTO_SIZE_MB=5 # 1–50 MB per photo upload
# MAX_PHOTOS_PER_BIN=1 # Max photos per bin (1–100)
# ATTACHMENTS_ENABLED=true # Non-image file attachments on bins (PDFs, docs, archives — 5 MB cap). Set to false to disable.
# MARKETING_OPT_IN_VISIBLE=false # Cloud only. Show the optional "send me product updates" checkbox at signup. Set to true once OpenBin actually sends marketing email.

# ── AI Provider (server-wide fallback) ────────
# When set, all users get AI features without configuring their own keys.
Expand Down
19 changes: 19 additions & 0 deletions server/schema.pg.sql
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ CREATE TABLE IF NOT EXISTS users (
deletion_requested_at TEXT,
deletion_scheduled_at TEXT,
deletion_reason TEXT,
current_tos_version TEXT,
current_privacy_version TEXT,
marketing_opt_in BOOLEAN NOT NULL DEFAULT FALSE,
marketing_opt_in_at TEXT,
marketing_opt_out_at TEXT,
created_at TEXT NOT NULL DEFAULT (NOW()),
updated_at TEXT NOT NULL DEFAULT (NOW())
);
Expand Down Expand Up @@ -534,3 +539,17 @@ CREATE TABLE IF NOT EXISTS bin_usage_days (
);
CREATE INDEX IF NOT EXISTS idx_bin_usage_days_date ON bin_usage_days(date);
CREATE INDEX IF NOT EXISTS idx_bin_usage_days_bin ON bin_usage_days(bin_id, date DESC);

-- User consent audit trail (cloud only). See SQLite schema for full notes.
CREATE TABLE IF NOT EXISTS user_consents (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
document TEXT NOT NULL,
version TEXT NOT NULL,
accepted_at TEXT NOT NULL,
ip TEXT,
user_agent TEXT,
source TEXT NOT NULL,
UNIQUE(user_id, document, version)
);
CREATE INDEX IF NOT EXISTS idx_user_consents_user_id ON user_consents(user_id);
21 changes: 21 additions & 0 deletions server/schema.sqlite.sql
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ CREATE TABLE IF NOT EXISTS users (
deletion_requested_at TEXT,
deletion_scheduled_at TEXT,
deletion_reason TEXT,
current_tos_version TEXT,
current_privacy_version TEXT,
marketing_opt_in INTEGER NOT NULL DEFAULT 0,
marketing_opt_in_at TEXT,
marketing_opt_out_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
Expand Down Expand Up @@ -517,3 +522,19 @@ CREATE TABLE IF NOT EXISTS bin_usage_days (
);
CREATE INDEX IF NOT EXISTS idx_bin_usage_days_date ON bin_usage_days(date);
CREATE INDEX IF NOT EXISTS idx_bin_usage_days_bin ON bin_usage_days(bin_id, date DESC);

-- User consent audit trail (cloud only). One row per (user, document, version)
-- written by recordConsent() in server/src/lib/consent.ts. Source values:
-- 'signup' | 'oauth_completion' | 'reaccept_modal' | 'backfill'.
CREATE TABLE IF NOT EXISTS user_consents (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
document TEXT NOT NULL,
version TEXT NOT NULL,
accepted_at TEXT NOT NULL,
ip TEXT,
user_agent TEXT,
source TEXT NOT NULL,
UNIQUE(user_id, document, version)
);
CREATE INDEX IF NOT EXISTS idx_user_consents_user_id ON user_consents(user_id);
20 changes: 20 additions & 0 deletions server/src/__tests__/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1061,3 +1061,23 @@ describe('POST /api/auth/register (deletion lifecycle)', () => {
expect(res.body.error).toBe('CONFLICT');
});
});

describe('GET /api/auth/status — consent version fields', () => {
it('returns tosVersion, privacyVersion, marketingOptInVisible', async () => {
const res = await request(app).get('/api/auth/status');

expect(res.status).toBe(200);
expect(res.body).toHaveProperty('tosVersion');
expect(res.body).toHaveProperty('privacyVersion');
expect(res.body).toHaveProperty('marketingOptInVisible');
expect(typeof res.body.marketingOptInVisible).toBe('boolean');
});

it('returns null versions on self-hosted', async () => {
const res = await request(app).get('/api/auth/status');

// Default test environment has SELF_HOSTED=true.
expect(res.body.tosVersion).toBeNull();
expect(res.body.privacyVersion).toBeNull();
});
});
82 changes: 82 additions & 0 deletions server/src/__tests__/completeConsent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import type { Express } from 'express';
import request from 'supertest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { query } from '../db.js';
import { createApp } from '../index.js';
import { CURRENT_PRIVACY_VERSION, CURRENT_TOS_VERSION } from '../lib/legalVersions.js';
import * as planGate from '../lib/planGate.js';

let app: Express;

beforeEach(() => {
app = createApp();
vi.spyOn(planGate, 'isSelfHosted').mockReturnValue(false);
});

afterEach(() => {
vi.restoreAllMocks();
});

async function registerCloudUser(email: string): Promise<{ accessCookie: string; csrfCookie: string; userId: string }> {
const res = await request(app)
.post('/api/auth/register')
.send({ email, password: 'StrongPass1!', displayName: 'CC User', acceptedTos: true, acceptedPrivacy: true });
expect(res.status).toBe(201);
const cookies = (res.headers['set-cookie'] as unknown as string[]) ?? [];
const get = (name: string) => {
for (const c of cookies) {
const [pair] = c.split(';');
const [k, v] = pair.split('=');
if (k.trim() === name) return v;
}
return '';
};
const { rows } = await query<{ id: string }>('SELECT id FROM users WHERE email = $1', [email]);
return {
accessCookie: get('openbin-access'),
csrfCookie: get('openbin-csrf'),
userId: rows[0].id,
};
}

describe('POST /api/auth/complete-consent', () => {
it('rejects unauthenticated requests', async () => {
const res = await request(app)
.post('/api/auth/complete-consent')
.send({ acceptedTos: true, acceptedPrivacy: true });
expect(res.status).toBe(401);
});

it('rejects when acceptedTos is false', async () => {
const { accessCookie, csrfCookie } = await registerCloudUser('cc1@test.local');
const res = await request(app)
.post('/api/auth/complete-consent')
.set('Cookie', [`openbin-access=${accessCookie}`, `openbin-csrf=${csrfCookie}`])
.set('X-CSRF-Token', csrfCookie)
.send({ acceptedTos: false, acceptedPrivacy: true });
expect(res.status).toBe(422);
});

it('records consent with the requested source', async () => {
const { userId, accessCookie, csrfCookie } = await registerCloudUser('cc2@test.local');
await query('UPDATE users SET current_tos_version = NULL, current_privacy_version = NULL WHERE id = $1', [userId]);
await query('DELETE FROM user_consents WHERE user_id = $1', [userId]);

const res = await request(app)
.post('/api/auth/complete-consent?source=oauth_completion')
.set('Cookie', [`openbin-access=${accessCookie}`, `openbin-csrf=${csrfCookie}`])
.set('X-CSRF-Token', csrfCookie)
.send({ acceptedTos: true, acceptedPrivacy: true });

expect(res.status).toBe(200);
expect(res.body.currentTosVersion).toBe(CURRENT_TOS_VERSION);
expect(res.body.currentPrivacyVersion).toBe(CURRENT_PRIVACY_VERSION);

const consents = await query(
'SELECT source FROM user_consents WHERE user_id = $1 ORDER BY document',
[userId],
);
expect(consents.rows).toHaveLength(2);
expect(consents.rows[0].source).toBe('oauth_completion');
});
});
6 changes: 5 additions & 1 deletion server/src/__tests__/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,13 @@ export async function createTestUser(app: Express, overrides?: { email?: string;
const password = overrides?.password ?? 'TestPass123!';
const displayName = overrides?.displayName ?? `Test User ${userCounter}`;

// Always send consent flags. In the default self-hosted test environment
// these are ignored; in tests that mock isSelfHosted() to false (plan
// gating, downgrade flows, etc.) they satisfy the cloud-only ToS/Privacy
// gate added in the consent flow.
const res = await request(app)
.post('/api/auth/register')
.send({ email, password, displayName });
.send({ email, password, displayName, acceptedTos: true, acceptedPrivacy: true });

return {
token: extractAccessToken(res),
Expand Down
99 changes: 99 additions & 0 deletions server/src/__tests__/registerConsent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import type { Express } from 'express';
import request from 'supertest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { query } from '../db.js';
import { createApp } from '../index.js';
import { CURRENT_PRIVACY_VERSION, CURRENT_TOS_VERSION } from '../lib/legalVersions.js';
import * as planGate from '../lib/planGate.js';

// `config` is Object.freeze'd; spy on isSelfHosted() instead of mutating it.

let app: Express;

beforeEach(() => {
app = createApp();
});

afterEach(() => {
vi.restoreAllMocks();
});

describe('POST /api/auth/register — consent', () => {
describe('cloud mode (selfHosted=false)', () => {
beforeEach(() => {
vi.spyOn(planGate, 'isSelfHosted').mockReturnValue(false);
});

it('rejects when acceptedTos is missing', async () => {
const res = await request(app)
.post('/api/auth/register')
.send({
email: 'cloud1@test.local',
password: 'StrongPass1!',
displayName: 'Cloud One',
acceptedPrivacy: true,
});
expect(res.status).toBe(422);
expect(res.body.message).toMatch(/Terms of Service/i);
});

it('rejects when acceptedPrivacy is missing', async () => {
const res = await request(app)
.post('/api/auth/register')
.send({
email: 'cloud2@test.local',
password: 'StrongPass1!',
displayName: 'Cloud Two',
acceptedTos: true,
});
expect(res.status).toBe(422);
expect(res.body.message).toMatch(/Privacy Policy/i);
});

it('records both consent rows + sets current_*_version on success', async () => {
const res = await request(app)
.post('/api/auth/register')
.send({
email: 'cloud3@test.local',
password: 'StrongPass1!',
displayName: 'Cloud Three',
acceptedTos: true,
acceptedPrivacy: true,
});
expect(res.status).toBe(201);

const consents = await query(
`SELECT document, version, source FROM user_consents
WHERE user_id = (SELECT id FROM users WHERE email = $1)
ORDER BY document`,
['cloud3@test.local'],
);
expect(consents.rows).toHaveLength(2);
expect(consents.rows[0].source).toBe('signup');
expect(consents.rows[0].version).toBe(CURRENT_PRIVACY_VERSION);
expect(consents.rows[1].version).toBe(CURRENT_TOS_VERSION);

const u = await query(
'SELECT current_tos_version, current_privacy_version FROM users WHERE email = $1',
['cloud3@test.local'],
);
expect(u.rows[0].current_tos_version).toBe(CURRENT_TOS_VERSION);
expect(u.rows[0].current_privacy_version).toBe(CURRENT_PRIVACY_VERSION);
});
});

describe('self-hosted mode', () => {
it('does not require acceptedTos/acceptedPrivacy', async () => {
// Test setup defaults to SELF_HOSTED=true; planGate.isSelfHosted()
// returns true here without any spy.
const res = await request(app)
.post('/api/auth/register')
.send({
email: 'selfhost@test.local',
password: 'StrongPass1!',
displayName: 'Self Host',
});
expect(res.status).toBe(201);
});
});
});
1 change: 1 addition & 0 deletions server/src/__tests__/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ const TABLES = [
'login_history',
'announcements',
'user_oauth_links',
'user_consents',
];

afterEach(() => {
Expand Down
70 changes: 70 additions & 0 deletions server/src/db/__tests__/userConsentMigration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import Database from 'better-sqlite3';
import { describe, expect, it } from 'vitest';
import { userConsent } from '../migrations/0013_user_consent.js';

function freshSqlite(): Database.Database {
const db = new Database(':memory:');
// Minimal users table that mirrors what exists pre-migration.
// Use prepare(...).run() per-statement to follow this codebase's pattern.
const setupStmts = [
`CREATE TABLE users (
id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
];
for (const sql of setupStmts) db.prepare(sql).run();

db.prepare('INSERT INTO users (id, display_name, email, created_at) VALUES (?, ?, ?, ?)')
.run('u1', 'Alice', 'alice@test.local', '2026-04-01T00:00:00.000Z');
db.prepare('INSERT INTO users (id, display_name, email, created_at) VALUES (?, ?, ?, ?)')
.run('u2', 'Bob', 'bob@test.local', '2026-04-15T00:00:00.000Z');
return db;
}

describe('migration 0013_user_consent', () => {
it('adds 5 columns to users and creates user_consents table', () => {
const db = freshSqlite();
userConsent.sqlite!(db);

const cols = db.prepare("PRAGMA table_info('users')").all() as { name: string }[];
const colNames = cols.map((c) => c.name);
expect(colNames).toContain('current_tos_version');
expect(colNames).toContain('current_privacy_version');
expect(colNames).toContain('marketing_opt_in');
expect(colNames).toContain('marketing_opt_in_at');
expect(colNames).toContain('marketing_opt_out_at');

const ucCols = db.prepare("PRAGMA table_info('user_consents')").all() as { name: string }[];
expect(ucCols.length).toBeGreaterThan(0);
});

it('backfills existing users with current versions and writes audit rows', () => {
const db = freshSqlite();
userConsent.sqlite!(db);

const u1 = db.prepare('SELECT current_tos_version, current_privacy_version, marketing_opt_in FROM users WHERE id = ?').get('u1') as Record<string, unknown>;
expect(u1.current_tos_version).toBe('2026-03-31');
expect(u1.current_privacy_version).toBe('2026-03-31');
expect(u1.marketing_opt_in).toBe(0);

const consents = db.prepare("SELECT document, source, accepted_at, version FROM user_consents WHERE user_id = ? ORDER BY document").all('u1') as { document: string; source: string; accepted_at: string; version: string }[];
expect(consents).toHaveLength(2);
expect(consents[0].document).toBe('privacy');
expect(consents[0].source).toBe('backfill');
expect(consents[0].version).toBe('2026-03-31');
expect(consents[0].accepted_at).toBe('2026-04-01T00:00:00.000Z');
expect(consents[1].document).toBe('tos');
});

it('is idempotent on re-run', () => {
const db = freshSqlite();
userConsent.sqlite!(db);
userConsent.sqlite!(db);

const consents = db.prepare('SELECT COUNT(*) as n FROM user_consents').get() as { n: number };
expect(consents.n).toBe(4); // 2 users × 2 documents — no duplicates from the second run
});
});
Loading
Loading