diff --git a/README.md b/README.md index 6340c1f4..b20bdfa9 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ Standalone: python3 -m http.server 4173 # open http://127.0.0.1:4173 ``` -Cloud (Node 22.13+ or 23.4+): +Cloud (Node 22.13+ or 23.5+): ```bash npm install diff --git a/docs/api.md b/docs/api.md index 1c668425..1bbf96c0 100644 --- a/docs/api.md +++ b/docs/api.md @@ -123,6 +123,13 @@ snapshot as a **new** version — history stays linear. ## Workspaces & members +Invitations are **account-bound**, not registration claims. The invited email +must already identify a ScopeWeave account; otherwise invite creation returns +`409 { "error": "invitee must already have a ScopeWeave account" }` before a +bearer token is minted. Possessing an invite token is not sufficient to accept +it: the signed-in account must be the single persisted account whose +canonicalized email matches the invitation. + | Method | Path | Purpose | | --- | --- | --- | | `POST` | `/api/orgs` | `{ name }` — create an additional workspace (creator = owner) | @@ -132,9 +139,9 @@ snapshot as a **new** version — history stays linear. | `GET` | `/api/orgs/:id/members` | Roster + pending invites | | `PATCH` | `/api/orgs/:id/members/:userId` | `{ role }` — change a member's role (manage; owner immutable) | | `DELETE` | `/api/orgs/:id/members/:userId` | Remove a member (manage; owner immune) | -| `POST` | `/api/orgs/:id/invites` | `{ email, role? }` → `{ token }` invite link token (manage) | +| `POST` | `/api/orgs/:id/invites` | `{ email, role? }` → `{ token, email, role }` (manage; invitee account must already exist; `409` otherwise) | | `DELETE` | `/api/orgs/:id/invites/:inviteId` | Revoke a pending invite (manage) — the token dies immediately | -| `POST` | `/api/invites/:token/accept` | Accept an invite (any authenticated user holding the token) | +| `POST` | `/api/invites/:token/accept` | Accept an invite as the unique authenticated account matching the invited canonical email; other identities get generic `404` | ## Billing @@ -161,8 +168,8 @@ attempt is recorded. **Verify a delivery** — the body is signed with HMAC-SHA256: ``` -X-Scopeweave-Event: project.update -X-Scopeweave-Signature: sha256= +X-ScopeWeave-Event: project.update +X-ScopeWeave-Signature: sha256= ``` ```js diff --git a/package-lock.json b/package-lock.json index 00a99254..41e96002 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "fast-check": "4.9.0" }, "engines": { - "node": "^22.13.0 || >=23.4.0" + "node": "^22.13.0 || >=23.5.0" } }, "node_modules/@bcoe/v8-coverage": { diff --git a/package.json b/package.json index 8cefdc74..5eb6d665 100644 --- a/package.json +++ b/package.json @@ -6,15 +6,15 @@ "packageManager": "npm@10.9.2", "description": "Production-grade pure HTML/CSS/JS WBS planner", "engines": { - "node": "^22.13.0 || >=23.4.0" + "node": "^22.13.0 || >=23.5.0" }, "scripts": { "check:python-docstrings": "node scripts/ci/static_coverage_evidence.mjs docstrings", "coverage": "npm run test:coverage", "server": "node server/server.mjs", - "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", + "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/invite-identity-security.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/toast-accessibility.test.mjs", - "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", + "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/db.mjs --include=server/email_identity.mjs --include=server/orchestrator.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", diff --git a/server/app.mjs b/server/app.mjs index c432a84f..da3b30e5 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -10,6 +10,7 @@ import { PLANS, planOf, orgUsage, wouldExceed, createCheckout } from './billing. import { clearfolioMock, mockArtifact, submitJob, jobStatus, artifactUrl } from './clearfolio.mjs'; import { normalizeAttachmentStatusBudgetMs, normalizeAttachmentStatusConcurrency, normalizeAttachmentStatusTimeoutMs, refreshAttachmentStatuses } from './attachment_status.mjs'; import { chat as orchestratorChat } from './orchestrator.mjs'; +import { canonicalizeMailbox } from './email_identity.mjs'; import { computeEvm } from '../analytics.js'; // pure math, shared with the client const getOrg = (id) => db.prepare('SELECT * FROM orgs WHERE id = ?').get(id); @@ -131,6 +132,11 @@ function deliver(orgId, event, payload) { } } const quietLogs = String(process.env.SCOPEWEAVE_DB || '').includes(':memory:'); // silence during tests +// Bearer secrets that are part of a route path must never be persisted in logs. +// Keep the route shape for incident triage while replacing only secret segments. +const redactRequestLogPath = (path) => String(path) + .replace(/^\/api\/invites\/[^/]+(?=\/|$)/, '/api/invites/:token') + .replace(/^\/api\/shared\/[^/]+(?=\/|$)/, '/api/shared/:token'); app.use('*', async (c, next) => { const t = Date.now(); await next(); @@ -140,7 +146,7 @@ app.use('*', async (c, next) => { if (s >= 500) metrics.s5xx++; else if (s >= 400) metrics.s4xx++; else if (s >= 200) metrics.s2xx++; if (!quietLogs) { // structured; never logs bodies, tokens, or secrets - console.log(JSON.stringify({ ts: new Date().toISOString(), method: c.req.method, path: c.req.path, status: s, ms: Date.now() - t })); + console.log(JSON.stringify({ ts: new Date().toISOString(), method: c.req.method, path: redactRequestLogPath(c.req.path), status: s, ms: Date.now() - t })); } } catch { /* metrics/logging must never break a request */ } }); @@ -450,7 +456,7 @@ app.get('/api/orgs/:id/members', requireAuth, (c) => { JOIN users u ON u.id = m.user_id WHERE m.org_id = ? ORDER BY m.id` ).all(orgId); const invites = db.prepare( - `SELECT id, email, role, token, created_at AS createdAt FROM invites + `SELECT id, email, role, created_at AS createdAt FROM invites WHERE org_id = ? AND accepted_at IS NULL ORDER BY id DESC` ).all(orgId); return c.json({ members, invites }); @@ -468,7 +474,7 @@ app.delete('/api/orgs/:id/invites/:inviteId', requireAuth, (c) => { return c.json({ ok: true }); }); -// Invite by email (owner/admin only). Returns the token (prod: email a link). +// Invite an existing account by email (owner/admin only). Returns the token (prod: email a link). app.post('/api/orgs/:id/invites', requireAuth, async (c) => { const uid = c.get('user').sub; const orgId = c.req.param('id'); @@ -476,10 +482,16 @@ app.post('/api/orgs/:id/invites', requireAuth, async (c) => { if (!role) return c.json({ error: 'not found' }, 404); if (!canManage(role)) return c.json({ error: 'forbidden' }, 403); const body = await c.req.json().catch(() => ({})); - const email = String(body.email || '').trim().toLowerCase(); + const email = canonicalizeMailbox(body.email); const inviteRole = body.role || 'member'; if (!email) return c.json({ error: 'email required' }, 400); if (!['admin', 'member', 'viewer'].includes(inviteRole)) return c.json({ error: 'invalid role' }, 400); + const registeredInvitee = db.prepare( + 'SELECT id FROM users WHERE scopeweave_canonical_email(email) = ? ORDER BY id LIMIT 1' + ).get(email); + if (!registeredInvitee) { + return c.json({ error: 'invitee must already have a ScopeWeave account' }, 409); + } const token = randomBytes(24).toString('base64url'); db.prepare('INSERT INTO invites(org_id,email,role,token,invited_by) VALUES(?,?,?,?,?)') .run(orgId, email, inviteRole, token, uid); @@ -487,11 +499,18 @@ app.post('/api/orgs/:id/invites', requireAuth, async (c) => { return c.json({ token, email, role: inviteRole }); }); -// Accept an invite (any authenticated user holding the token). Idempotent. +// Accept an invite only for the authenticated identity named by the invite. app.post('/api/invites/:token/accept', requireAuth, (c) => { const uid = c.get('user').sub; const inv = db.prepare('SELECT * FROM invites WHERE token = ?').get(c.req.param('token')); if (!inv || inv.accepted_at) return c.json({ error: 'invalid or used invite' }, 404); + const canonicalInviteEmail = canonicalizeMailbox(inv.email); + const identityMatches = db.prepare( + 'SELECT id FROM users WHERE scopeweave_canonical_email(email) = ? ORDER BY id LIMIT 2' + ).all(canonicalInviteEmail); + if (identityMatches.length !== 1 || identityMatches[0].id !== uid) { + return c.json({ error: 'invalid or used invite' }, 404); + } const existing = orgRole(uid, inv.org_id); if (!existing) { if (wouldExceed(db, getOrg(inv.org_id), 'members')) { diff --git a/server/db.mjs b/server/db.mjs index 122b70d6..bab6611c 100644 --- a/server/db.mjs +++ b/server/db.mjs @@ -4,10 +4,12 @@ import { DatabaseSync } from 'node:sqlite'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; +import { canonicalizeMailbox } from './email_identity.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const dbPath = process.env.SCOPEWEAVE_DB || join(__dirname, '..', 'data.db'); export const db = new DatabaseSync(dbPath); +db.function('scopeweave_canonical_email', { deterministic: true }, canonicalizeMailbox); db.exec("PRAGMA journal_mode = WAL"); db.exec("PRAGMA foreign_keys = ON"); @@ -171,6 +173,7 @@ CREATE INDEX IF NOT EXISTS idx_memberships_user ON memberships(user_id); CREATE INDEX IF NOT EXISTS idx_projects_org ON projects(org_id); CREATE INDEX IF NOT EXISTS idx_invites_token ON invites(token); `); +db.exec('CREATE INDEX IF NOT EXISTS idx_users_canonical_email ON users(scopeweave_canonical_email(email))'); // Migration for pre-existing DBs: add token_version if missing (idempotent). try { db.exec('ALTER TABLE users ADD COLUMN token_version INTEGER NOT NULL DEFAULT 0'); } catch { /* already there */ } diff --git a/server/email_identity.mjs b/server/email_identity.mjs new file mode 100644 index 00000000..2efab645 --- /dev/null +++ b/server/email_identity.mjs @@ -0,0 +1,242 @@ +/** + * Unicode CaseFolding-17.0.0 C+F overrides for code points where JavaScript's + * per-character lowercasing is not the default full case-fold mapping. The + * Turkic-only T mappings are intentionally excluded; default folding uses C+F. + * + * @see https://www.unicode.org/Public/17.0.0/ucd/CaseFolding.txt + */ +const UNICODE_CASE_FOLD_OVERRIDES = new Map([ + [0xb5, "\u{3bc}"], + [0xdf, "\u{73}\u{73}"], + [0x149, "\u{2bc}\u{6e}"], + [0x17f, "\u{73}"], + [0x1f0, "\u{6a}\u{30c}"], + [0x345, "\u{3b9}"], + [0x390, "\u{3b9}\u{308}\u{301}"], + [0x3b0, "\u{3c5}\u{308}\u{301}"], + [0x3c2, "\u{3c3}"], + [0x3d0, "\u{3b2}"], + [0x3d1, "\u{3b8}"], + [0x3d5, "\u{3c6}"], + [0x3d6, "\u{3c0}"], + [0x3f0, "\u{3ba}"], + [0x3f1, "\u{3c1}"], + [0x3f5, "\u{3b5}"], + [0x587, "\u{565}\u{582}"], + [0x13f8, "\u{13f0}"], + [0x13f9, "\u{13f1}"], + [0x13fa, "\u{13f2}"], + [0x13fb, "\u{13f3}"], + [0x13fc, "\u{13f4}"], + [0x13fd, "\u{13f5}"], + [0x1c80, "\u{432}"], + [0x1c81, "\u{434}"], + [0x1c82, "\u{43e}"], + [0x1c83, "\u{441}"], + [0x1c84, "\u{442}"], + [0x1c85, "\u{442}"], + [0x1c86, "\u{44a}"], + [0x1c87, "\u{463}"], + [0x1c88, "\u{a64b}"], + [0x1e96, "\u{68}\u{331}"], + [0x1e97, "\u{74}\u{308}"], + [0x1e98, "\u{77}\u{30a}"], + [0x1e99, "\u{79}\u{30a}"], + [0x1e9a, "\u{61}\u{2be}"], + [0x1e9b, "\u{1e61}"], + [0x1e9e, "\u{73}\u{73}"], + [0x1f50, "\u{3c5}\u{313}"], + [0x1f52, "\u{3c5}\u{313}\u{300}"], + [0x1f54, "\u{3c5}\u{313}\u{301}"], + [0x1f56, "\u{3c5}\u{313}\u{342}"], + [0x1f80, "\u{1f00}\u{3b9}"], + [0x1f81, "\u{1f01}\u{3b9}"], + [0x1f82, "\u{1f02}\u{3b9}"], + [0x1f83, "\u{1f03}\u{3b9}"], + [0x1f84, "\u{1f04}\u{3b9}"], + [0x1f85, "\u{1f05}\u{3b9}"], + [0x1f86, "\u{1f06}\u{3b9}"], + [0x1f87, "\u{1f07}\u{3b9}"], + [0x1f88, "\u{1f00}\u{3b9}"], + [0x1f89, "\u{1f01}\u{3b9}"], + [0x1f8a, "\u{1f02}\u{3b9}"], + [0x1f8b, "\u{1f03}\u{3b9}"], + [0x1f8c, "\u{1f04}\u{3b9}"], + [0x1f8d, "\u{1f05}\u{3b9}"], + [0x1f8e, "\u{1f06}\u{3b9}"], + [0x1f8f, "\u{1f07}\u{3b9}"], + [0x1f90, "\u{1f20}\u{3b9}"], + [0x1f91, "\u{1f21}\u{3b9}"], + [0x1f92, "\u{1f22}\u{3b9}"], + [0x1f93, "\u{1f23}\u{3b9}"], + [0x1f94, "\u{1f24}\u{3b9}"], + [0x1f95, "\u{1f25}\u{3b9}"], + [0x1f96, "\u{1f26}\u{3b9}"], + [0x1f97, "\u{1f27}\u{3b9}"], + [0x1f98, "\u{1f20}\u{3b9}"], + [0x1f99, "\u{1f21}\u{3b9}"], + [0x1f9a, "\u{1f22}\u{3b9}"], + [0x1f9b, "\u{1f23}\u{3b9}"], + [0x1f9c, "\u{1f24}\u{3b9}"], + [0x1f9d, "\u{1f25}\u{3b9}"], + [0x1f9e, "\u{1f26}\u{3b9}"], + [0x1f9f, "\u{1f27}\u{3b9}"], + [0x1fa0, "\u{1f60}\u{3b9}"], + [0x1fa1, "\u{1f61}\u{3b9}"], + [0x1fa2, "\u{1f62}\u{3b9}"], + [0x1fa3, "\u{1f63}\u{3b9}"], + [0x1fa4, "\u{1f64}\u{3b9}"], + [0x1fa5, "\u{1f65}\u{3b9}"], + [0x1fa6, "\u{1f66}\u{3b9}"], + [0x1fa7, "\u{1f67}\u{3b9}"], + [0x1fa8, "\u{1f60}\u{3b9}"], + [0x1fa9, "\u{1f61}\u{3b9}"], + [0x1faa, "\u{1f62}\u{3b9}"], + [0x1fab, "\u{1f63}\u{3b9}"], + [0x1fac, "\u{1f64}\u{3b9}"], + [0x1fad, "\u{1f65}\u{3b9}"], + [0x1fae, "\u{1f66}\u{3b9}"], + [0x1faf, "\u{1f67}\u{3b9}"], + [0x1fb2, "\u{1f70}\u{3b9}"], + [0x1fb3, "\u{3b1}\u{3b9}"], + [0x1fb4, "\u{3ac}\u{3b9}"], + [0x1fb6, "\u{3b1}\u{342}"], + [0x1fb7, "\u{3b1}\u{342}\u{3b9}"], + [0x1fbc, "\u{3b1}\u{3b9}"], + [0x1fbe, "\u{3b9}"], + [0x1fc2, "\u{1f74}\u{3b9}"], + [0x1fc3, "\u{3b7}\u{3b9}"], + [0x1fc4, "\u{3ae}\u{3b9}"], + [0x1fc6, "\u{3b7}\u{342}"], + [0x1fc7, "\u{3b7}\u{342}\u{3b9}"], + [0x1fcc, "\u{3b7}\u{3b9}"], + [0x1fd2, "\u{3b9}\u{308}\u{300}"], + [0x1fd3, "\u{3b9}\u{308}\u{301}"], + [0x1fd6, "\u{3b9}\u{342}"], + [0x1fd7, "\u{3b9}\u{308}\u{342}"], + [0x1fe2, "\u{3c5}\u{308}\u{300}"], + [0x1fe3, "\u{3c5}\u{308}\u{301}"], + [0x1fe4, "\u{3c1}\u{313}"], + [0x1fe6, "\u{3c5}\u{342}"], + [0x1fe7, "\u{3c5}\u{308}\u{342}"], + [0x1ff2, "\u{1f7c}\u{3b9}"], + [0x1ff3, "\u{3c9}\u{3b9}"], + [0x1ff4, "\u{3ce}\u{3b9}"], + [0x1ff6, "\u{3c9}\u{342}"], + [0x1ff7, "\u{3c9}\u{342}\u{3b9}"], + [0x1ffc, "\u{3c9}\u{3b9}"], + [0xab70, "\u{13a0}"], + [0xab71, "\u{13a1}"], + [0xab72, "\u{13a2}"], + [0xab73, "\u{13a3}"], + [0xab74, "\u{13a4}"], + [0xab75, "\u{13a5}"], + [0xab76, "\u{13a6}"], + [0xab77, "\u{13a7}"], + [0xab78, "\u{13a8}"], + [0xab79, "\u{13a9}"], + [0xab7a, "\u{13aa}"], + [0xab7b, "\u{13ab}"], + [0xab7c, "\u{13ac}"], + [0xab7d, "\u{13ad}"], + [0xab7e, "\u{13ae}"], + [0xab7f, "\u{13af}"], + [0xab80, "\u{13b0}"], + [0xab81, "\u{13b1}"], + [0xab82, "\u{13b2}"], + [0xab83, "\u{13b3}"], + [0xab84, "\u{13b4}"], + [0xab85, "\u{13b5}"], + [0xab86, "\u{13b6}"], + [0xab87, "\u{13b7}"], + [0xab88, "\u{13b8}"], + [0xab89, "\u{13b9}"], + [0xab8a, "\u{13ba}"], + [0xab8b, "\u{13bb}"], + [0xab8c, "\u{13bc}"], + [0xab8d, "\u{13bd}"], + [0xab8e, "\u{13be}"], + [0xab8f, "\u{13bf}"], + [0xab90, "\u{13c0}"], + [0xab91, "\u{13c1}"], + [0xab92, "\u{13c2}"], + [0xab93, "\u{13c3}"], + [0xab94, "\u{13c4}"], + [0xab95, "\u{13c5}"], + [0xab96, "\u{13c6}"], + [0xab97, "\u{13c7}"], + [0xab98, "\u{13c8}"], + [0xab99, "\u{13c9}"], + [0xab9a, "\u{13ca}"], + [0xab9b, "\u{13cb}"], + [0xab9c, "\u{13cc}"], + [0xab9d, "\u{13cd}"], + [0xab9e, "\u{13ce}"], + [0xab9f, "\u{13cf}"], + [0xaba0, "\u{13d0}"], + [0xaba1, "\u{13d1}"], + [0xaba2, "\u{13d2}"], + [0xaba3, "\u{13d3}"], + [0xaba4, "\u{13d4}"], + [0xaba5, "\u{13d5}"], + [0xaba6, "\u{13d6}"], + [0xaba7, "\u{13d7}"], + [0xaba8, "\u{13d8}"], + [0xaba9, "\u{13d9}"], + [0xabaa, "\u{13da}"], + [0xabab, "\u{13db}"], + [0xabac, "\u{13dc}"], + [0xabad, "\u{13dd}"], + [0xabae, "\u{13de}"], + [0xabaf, "\u{13df}"], + [0xabb0, "\u{13e0}"], + [0xabb1, "\u{13e1}"], + [0xabb2, "\u{13e2}"], + [0xabb3, "\u{13e3}"], + [0xabb4, "\u{13e4}"], + [0xabb5, "\u{13e5}"], + [0xabb6, "\u{13e6}"], + [0xabb7, "\u{13e7}"], + [0xabb8, "\u{13e8}"], + [0xabb9, "\u{13e9}"], + [0xabba, "\u{13ea}"], + [0xabbb, "\u{13eb}"], + [0xabbc, "\u{13ec}"], + [0xabbd, "\u{13ed}"], + [0xabbe, "\u{13ee}"], + [0xabbf, "\u{13ef}"], + [0xfb00, "\u{66}\u{66}"], + [0xfb01, "\u{66}\u{69}"], + [0xfb02, "\u{66}\u{6c}"], + [0xfb03, "\u{66}\u{66}\u{69}"], + [0xfb04, "\u{66}\u{66}\u{6c}"], + [0xfb05, "\u{73}\u{74}"], + [0xfb06, "\u{73}\u{74}"], + [0xfb13, "\u{574}\u{576}"], + [0xfb14, "\u{574}\u{565}"], + [0xfb15, "\u{574}\u{56b}"], + [0xfb16, "\u{57e}\u{576}"], + [0xfb17, "\u{574}\u{56d}"], +]); + +const isCherokeeUppercase = (codePoint) => ( + (codePoint >= 0x13a0 && codePoint <= 0x13ef) + || (codePoint >= 0x13f0 && codePoint <= 0x13f5) +); + +/** + * Canonicalize an email mailbox for the identity comparisons used by invites. + * NFKC plus Unicode's full default case-fold mapping keeps equivalent mailbox + * spellings on one indexed identity key without a runtime dependency. + * + * @param {unknown} value persisted or user-supplied email address + * @returns {string} deterministic Unicode-aware identity key + */ +export function canonicalizeMailbox(value) { + const normalized = String(value ?? '').trim().normalize('NFKC'); + return Array.from(normalized, (character) => { + const codePoint = character.codePointAt(0); + return UNICODE_CASE_FOLD_OVERRIDES.get(codePoint) + ?? (isCherokeeUppercase(codePoint) ? character : character.toLowerCase()); + }).join('').normalize('NFKC'); +} diff --git a/tests/api/invite-identity-security.test.mjs b/tests/api/invite-identity-security.test.mjs new file mode 100644 index 00000000..ef3a9620 --- /dev/null +++ b/tests/api/invite-identity-security.test.mjs @@ -0,0 +1,308 @@ +// Security regression: pending invitation secrets must stay private and only +// the account uniquely named by an invitation may redeem it. +import assert from 'node:assert/strict'; +import { rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const dbPath = join(tmpdir(), `scopeweave-invite-security-${process.pid}-${Date.now()}.db`); +process.env.SCOPEWEAVE_DB = dbPath; +process.env.SCOPEWEAVE_DEV = '1'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; +delete process.env.ORCHESTRATOR_URL; + +const observedLogs = []; +const originalConsoleLog = console.log; +console.log = (...args) => observedLogs.push(args.join(' ')); + +const { canonicalizeMailbox } = await import('../../server/email_identity.mjs'); +const { app } = await import('../../server/app.mjs'); + +for (const [upperCodePoint, lowerCodePoint] of [[0x13a0, 0xab70], [0x13ef, 0xabbf], [0x13f0, 0x13f8], [0x13f5, 0x13fd]]) { + assert.equal( + canonicalizeMailbox(`${String.fromCodePoint(upperCodePoint)}@example.com`), + canonicalizeMailbox(`${String.fromCodePoint(lowerCodePoint)}@example.com`), + 'Cherokee uppercase and lowercase spellings share one identity key', + ); +} + +const body = (value) => JSON.stringify(value); +const req = (path, options = {}) => app.request(path, { + ...options, + headers: { 'content-type': 'application/json', ...(options.headers || {}) }, +}); +const authFor = (token) => ({ authorization: `Bearer ${token}` }); + +async function signup(email, name = email) { + const response = await req('/api/auth/signup', { + method: 'POST', + body: body({ email, password: 'password123', name }), + }); + assert.equal(response.status, 200, `signup succeeds for ${email}`); + return (await response.json()).token; +} + +const ownerToken = await signup('owner@example.com', 'Owner'); +const viewerToken = await signup('viewer@example.com', 'Viewer'); +const intendedToken = await signup('Invitee@Example.com', 'Invitee'); +const unicodeToken = await signup('ÄDMIN@EXAMPLE.COM', 'Unicode invitee'); +const greekUpperToken = await signup('ΟΣ@UPPER.EXAMPLE.COM', 'Greek uppercase'); +const greekLowerToken = await signup('οσ@LOWER.EXAMPLE.COM', 'Greek lowercase'); +const combiningIotaToken = await signup('a\u0345@example.com', 'Combining iota'); +const attackerToken = await signup('attacker@example.com', 'Attacker'); +const ambiguousPrimaryToken = await signup('CaseVictim@example.com', 'Case victim'); +const ambiguousCollisionToken = await signup('casevictim@example.com', 'Case collision'); +const ownerAuth = authFor(ownerToken); +const viewerAuth = authFor(viewerToken); +const intendedAuth = authFor(intendedToken); +const unicodeAuth = authFor(unicodeToken); +const greekUpperAuth = authFor(greekUpperToken); +const greekLowerAuth = authFor(greekLowerToken); +const combiningIotaAuth = authFor(combiningIotaToken); +const attackerAuth = authFor(attackerToken); +const ambiguousPrimaryAuth = authFor(ambiguousPrimaryToken); +const ambiguousCollisionAuth = authFor(ambiguousCollisionToken); + +let response = await req('/api/me', { headers: ownerAuth }); +assert.equal(response.status, 200); +const ownerMe = await response.json(); +const orgId = ownerMe.orgs[0].id; + +// Missing input must exercise the canonicalizer's empty-value branch before +// the invite is rejected at the request boundary. +response = await req(`/api/orgs/${orgId}/invites`, { + method: 'POST', + headers: ownerAuth, + body: body({ role: 'viewer' }), +}); +assert.equal(response.status, 400, 'missing invite email is rejected'); + +// Without independent email verification, minting a bearer invitation for an +// address that has no registered account lets whoever holds the token create +// that address later and become the apparent invitee. Fail before creating a +// secret so invitations are account-bound rather than registration claims. +response = await req(`/api/orgs/${orgId}/invites`, { + method: 'POST', + headers: ownerAuth, + body: body({ email: 'future-member@example.com', role: 'admin' }), +}); +assert.equal(response.status, 409, 'unregistered invite target is rejected before a bearer token is minted'); +assert.deepEqual(await response.json(), { error: 'invitee must already have a ScopeWeave account' }); + +// Give the low-privilege account legitimate roster visibility. +response = await req(`/api/orgs/${orgId}/invites`, { + method: 'POST', + headers: ownerAuth, + body: body({ email: 'viewer@example.com', role: 'viewer' }), +}); +assert.equal(response.status, 200); +const viewerInvite = await response.json(); +response = await req(`/api/invites/${viewerInvite.token}/accept`, { + method: 'POST', + headers: viewerAuth, +}); +assert.equal(response.status, 200); +assert.equal((await response.json()).role, 'viewer'); + +// A case-insensitive address can exist in more than one historical account +// because the legacy users.email uniqueness rule is case-sensitive. An invite +// must fail closed for that ambiguous canonical identity rather than letting +// either account win possession of the role. +response = await req(`/api/orgs/${orgId}/invites`, { + method: 'POST', + headers: ownerAuth, + body: body({ email: 'CASEVICTIM@example.com', role: 'admin' }), +}); +assert.equal(response.status, 200); +const ambiguousInvite = await response.json(); +for (const candidateAuth of [ambiguousPrimaryAuth, ambiguousCollisionAuth]) { + response = await req(`/api/invites/${ambiguousInvite.token}/accept`, { + method: 'POST', + headers: candidateAuth, + }); + assert.equal(response.status, 404, 'ambiguous canonical identity cannot redeem invite'); + assert.deepEqual(await response.json(), { error: 'invalid or used invite' }); + response = await req(`/api/orgs/${orgId}/members`, { headers: candidateAuth }); + assert.equal(response.status, 404, 'ambiguous account gains no organization membership'); +} + +// Create a higher-privilege pending invite for a different, unique identity. +// Invite creation canonicalizes the address, while the existing account +// intentionally retains mixed case to cover historical identity data. +response = await req(`/api/orgs/${orgId}/invites`, { + method: 'POST', + headers: ownerAuth, + body: body({ email: 'INVITEE@example.com', role: 'admin' }), +}); +assert.equal(response.status, 200); +const adminInvite = await response.json(); +assert.ok(adminInvite.token, 'inviter receives the one-time invitation secret'); +assert.equal(adminInvite.email, 'invitee@example.com'); + +// A viewer may inspect the roster, but pending invitation secrets must never be +// projected into that response. +response = await req(`/api/orgs/${orgId}/members`, { headers: viewerAuth }); +assert.equal(response.status, 200); +const roster = await response.json(); +const pendingAdmin = roster.invites.find((invite) => invite.email === 'invitee@example.com'); +assert.ok(pendingAdmin?.id, 'pending invite remains manageable by stable id'); +assert.equal('token' in pendingAdmin, false, 'roster never discloses invitation bearer tokens'); + +// The request logger is a second secret boundary: an unauthenticated request +// must not copy a still-live invite bearer token from the URL into access logs. +// Non-sensitive paths should remain useful for operations and incident triage. +observedLogs.length = 0; +response = await req(`/api/invites/${adminInvite.token}/accept`, { method: 'POST' }); +assert.equal(response.status, 401, 'unauthenticated invite acceptance is rejected before consumption'); +const inviteLogLines = observedLogs.filter((line) => line.includes('/api/invites/')); +assert.equal(inviteLogLines.length, 1, 'invite acceptance emits one structured request log'); +assert.doesNotMatch(inviteLogLines[0], new RegExp(adminInvite.token), 'access log must never contain the live invite token'); +assert.equal(JSON.parse(inviteLogLines[0]).path, '/api/invites/:token/accept', 'secret path segment is represented by its route name'); + +// Redaction must follow the secret-bearing path boundary, not only an exact +// successful route match. A typo/trailing segment must not turn an otherwise +// valid bearer token into durable log data on the resulting 404/405 path. +for (const secretPath of [ + `/api/invites/${adminInvite.token}/accept/`, + `/api/invites/${adminInvite.token}/accept/extra`, +]) { + observedLogs.length = 0; + response = await req(secretPath, { method: 'POST' }); + assert.ok(response.status >= 400, 'malformed invite path is rejected'); + const malformedInviteLog = observedLogs.at(-1); + assert.doesNotMatch(malformedInviteLog, new RegExp(adminInvite.token), 'malformed invite path must not leak its bearer token'); + assert.match(JSON.parse(malformedInviteLog).path, /^\/api\/invites\/:token\//, 'malformed invite path keeps a redacted route prefix'); +} + +// Public-share bearer secrets live in a path segment too. Even a missing share +// token is treated as secret-shaped input so operational logs cannot become a +// durable disclosure channel for valid share links. +const shareTokenSentinel = 'share-secret-sentinel-123456'; +observedLogs.length = 0; +response = await req(`/api/shared/${shareTokenSentinel}`); +assert.equal(response.status, 404); +const shareLogLine = observedLogs.at(-1); +assert.doesNotMatch(shareLogLine, new RegExp(shareTokenSentinel), 'access log must never contain a share bearer token'); +assert.equal(JSON.parse(shareLogLine).path, '/api/shared/:token', 'share secret path segment is represented by its route name'); + +observedLogs.length = 0; +response = await req(`/api/shared/${shareTokenSentinel}/extra`); +assert.equal(response.status, 404); +const malformedShareLogLine = observedLogs.at(-1); +assert.doesNotMatch(malformedShareLogLine, new RegExp(shareTokenSentinel), 'malformed share path must not leak its bearer token'); +assert.equal(JSON.parse(malformedShareLogLine).path, '/api/shared/:token/extra', 'malformed share path keeps a redacted route prefix'); + +observedLogs.length = 0; +response = await req('/api/me'); +assert.equal(response.status, 401); +assert.equal(JSON.parse(observedLogs.at(-1)).path, '/api/me', 'ordinary request paths stay intact'); + +// Possession of a leaked/copied invite token is insufficient: the authenticated +// identity must match the invited address, and rejection must not consume the +// invitation or create membership. +response = await req(`/api/invites/${adminInvite.token}/accept`, { + method: 'POST', + headers: attackerAuth, +}); +assert.equal(response.status, 404, 'wrong authenticated identity receives generic invalid-invite response'); +assert.deepEqual(await response.json(), { error: 'invalid or used invite' }); +response = await req(`/api/orgs/${orgId}/members`, { headers: attackerAuth }); +assert.equal(response.status, 404, 'rejected account gains no organization membership'); + +// The intended historical mixed-case account can still accept exactly once and +// receives the role selected by the inviter. +response = await req(`/api/invites/${adminInvite.token}/accept`, { + method: 'POST', + headers: intendedAuth, +}); +assert.equal(response.status, 200, 'matching invited identity can accept'); +assert.equal((await response.json()).role, 'admin'); + +// SQLite's built-in lower() only handles ASCII. Invitation identity matching +// must still accept the uniquely matching account for a Unicode mailbox. +response = await req('/api/orgs', { + method: 'POST', + headers: ownerAuth, + body: body({ name: 'Unicode invite workspace' }), +}); +assert.equal(response.status, 200); +const unicodeOrgId = (await response.json()).id; +response = await req(`/api/orgs/${unicodeOrgId}/invites`, { + method: 'POST', + headers: ownerAuth, + body: body({ email: 'ÄdMiN@example.com', role: 'member' }), +}); +assert.equal(response.status, 200); +const unicodeInvite = await response.json(); +assert.equal(unicodeInvite.email, 'ädmin@example.com'); +response = await req(`/api/invites/${unicodeInvite.token}/accept`, { + method: 'POST', + headers: unicodeAuth, +}); +assert.equal(response.status, 200, 'Unicode case-insensitive identity can accept its invite'); +assert.equal((await response.json()).role, 'member'); + +// JavaScript lowercasing keeps Greek final sigma (ς) distinct from sigma (σ), +// so the mailbox key must apply Unicode case-folding for both casing directions. +for (const [accountAuth, invitedEmail, workspaceName] of [ + [greekUpperAuth, 'οσ@UPPER.EXAMPLE.COM', 'Greek uppercase workspace'], + [greekLowerAuth, 'ΟΣ@LOWER.EXAMPLE.COM', 'Greek lowercase workspace'], +]) { + response = await req('/api/orgs', { + method: 'POST', + headers: ownerAuth, + body: body({ name: workspaceName }), + }); + assert.equal(response.status, 200); + const greekOrgId = (await response.json()).id; + response = await req(`/api/orgs/${greekOrgId}/invites`, { + method: 'POST', + headers: ownerAuth, + body: body({ email: invitedEmail, role: 'member' }), + }); + assert.equal(response.status, 200); + const greekInvite = await response.json(); + response = await req(`/api/invites/${greekInvite.token}/accept`, { + method: 'POST', + headers: accountAuth, + }); + assert.equal(response.status, 200, 'Unicode case-folded identity can accept its invite'); +} + +response = await req('/api/orgs', { + method: 'POST', + headers: ownerAuth, + body: body({ name: 'Combining iota workspace' }), +}); +assert.equal(response.status, 200); +const combiningIotaOrgId = (await response.json()).id; +response = await req(`/api/orgs/${combiningIotaOrgId}/invites`, { + method: 'POST', + headers: ownerAuth, + body: body({ email: 'a\u03b9@example.com', role: 'member' }), +}); +assert.equal(response.status, 200); +const combiningIotaInvite = await response.json(); +response = await req(`/api/invites/${combiningIotaInvite.token}/accept`, { + method: 'POST', + headers: combiningIotaAuth, +}); +assert.equal(response.status, 200, 'Unicode full case-folded identity can accept its invite'); +assert.equal((await response.json()).role, 'member'); + +response = await req(`/api/invites/${adminInvite.token}/accept`, { + method: 'POST', + headers: intendedAuth, +}); +assert.equal(response.status, 404, 'used invite remains fail-closed'); +assert.deepEqual(await response.json(), { error: 'invalid or used invite' }); +response = await req('/api/invites/definitely-missing-token/accept', { + method: 'POST', + headers: intendedAuth, +}); +assert.equal(response.status, 404, 'missing invite remains fail-closed'); +assert.deepEqual(await response.json(), { error: 'invalid or used invite' }); + +console.log = originalConsoleLog; +await rm(dbPath, { force: true }); +originalConsoleLog('invite identity security: ok'); diff --git a/tests/api/smoke.mjs b/tests/api/smoke.mjs index e536b908..a6af7bd3 100644 --- a/tests/api/smoke.mjs +++ b/tests/api/smoke.mjs @@ -118,6 +118,9 @@ assert.equal((await r.json()).role, 'viewer'); r = await req(`/api/invites/${invite.token}/accept`, { method: 'POST', headers: vauth }); assert.equal(r.status, 404, 'used invite → 404'); // invite revocation: pending list has ids; revoked token stops working +r = await req('/api/auth/signup', { method: 'POST', body: body({ email: 'revoke-me@x.com', password: 'password123' }) }); +assert.equal(r.status, 200, 'revoke invitee signup'); +const revokeAuth = { authorization: `Bearer ${(await r.json()).token}` }; r = await req(`/api/orgs/${orgAId}/invites`, { method: 'POST', headers: auth, body: body({ email: 'revoke-me@x.com' }) }); const revInvite = await r.json(); r = await req(`/api/orgs/${orgAId}/members`, { headers: auth }); @@ -127,7 +130,7 @@ r = await req(`/api/orgs/${orgAId}/invites/${pend.id}`, { method: 'DELETE', head assert.equal(r.status, 403, 'viewer cannot revoke'); r = await req(`/api/orgs/${orgAId}/invites/${pend.id}`, { method: 'DELETE', headers: auth }); assert.equal(r.status, 200, 'owner revokes invite'); -r = await req(`/api/invites/${revInvite.token}/accept`, { method: 'POST', headers: vauth }); +r = await req(`/api/invites/${revInvite.token}/accept`, { method: 'POST', headers: revokeAuth }); assert.equal(r.status, 404, 'revoked invite token is dead'); r = await req(`/api/orgs/${orgAId}/invites/${pend.id}`, { method: 'DELETE', headers: auth }); assert.equal(r.status, 404, 'double revoke → 404'); @@ -748,4 +751,4 @@ assert.equal((await r.json()).orgs.find((o) => o.id === orgAId)?.role, 'admin', r = await req(`/api/orgs/${orgAId}/leave`, { method: 'POST', headers: auth }); assert.equal(r.status, 200, 'former owner can now leave'); -console.log('✓ API smoke tests passed'); \ No newline at end of file +console.log('✓ API smoke tests passed'); diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index 149440e5..28a7718e 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -34,6 +34,16 @@ assert.match( /--include=server\/clearfolio\.mjs/, 'the abortable Clearfolio adapter is instrumented', ); +assert.match( + scripts['test:coverage'], + /--include=server\/db\.mjs/, + 'the database schema and function registration module is instrumented', +); +assert.match( + scripts['test:coverage'], + /--include=server\/email_identity\.mjs/, + 'the invite identity canonicalization module is instrumented', +); assert.match( scripts['test:coverage:cases'], /tests\/unit\/clearfolio-status-signal\.test\.mjs/,