From 6e0c49ffe43f93c1fa16ba08111f3896e2d1496f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 08:41:07 -0700 Subject: [PATCH 01/22] test(security): reproduce invite identity takeover --- tests/api/invite-identity-security.test.mjs | 112 ++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 tests/api/invite-identity-security.test.mjs diff --git a/tests/api/invite-identity-security.test.mjs b/tests/api/invite-identity-security.test.mjs new file mode 100644 index 00000000..b39715ee --- /dev/null +++ b/tests/api/invite-identity-security.test.mjs @@ -0,0 +1,112 @@ +// Security regression: pending invitation secrets must stay private and only +// the account named by an invitation may redeem it. +import assert from 'node:assert/strict'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_DEV = '1'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; +delete process.env.ORCHESTRATOR_URL; + +const { app } = await import('../../server/app.mjs'); + +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 attackerToken = await signup('attacker@example.com', 'Attacker'); +const ownerAuth = authFor(ownerToken); +const viewerAuth = authFor(viewerToken); +const intendedAuth = authFor(intendedToken); +const attackerAuth = authFor(attackerToken); + +let response = await req('/api/me', { headers: ownerAuth }); +assert.equal(response.status, 200); +const ownerMe = await response.json(); +const orgId = ownerMe.orgs[0].id; + +// 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'); + +// Create a higher-privilege pending invite for a different 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'); + +// 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'); +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('invite identity security: ok'); From 80b2682579be06c1ead56840aee9521a9a8dae3a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 08:41:28 -0700 Subject: [PATCH 02/22] test(security): register invite identity regression --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8cefdc74..dbe22c23 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "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: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", From 58c5c3035293ccf9869b9e8fcb0a7919f8fc0a6a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 08:51:24 -0700 Subject: [PATCH 03/22] fix(security): bind invite redemption to identity --- server/app.mjs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index c432a84f..f4ad8bc2 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -450,7 +450,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 }); @@ -487,11 +487,15 @@ 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 user = db.prepare('SELECT email FROM users WHERE id = ?').get(uid); + if (user.email.trim().toLowerCase() !== inv.email.trim().toLowerCase()) { + 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')) { From bebc2074a276eafc62de6e36134ae91d7abe2d0c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 08:16:02 -0700 Subject: [PATCH 04/22] test: reject ambiguous invite identities --- tests/api/invite-identity-security.test.mjs | 34 ++++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/tests/api/invite-identity-security.test.mjs b/tests/api/invite-identity-security.test.mjs index b39715ee..f99bb5ee 100644 --- a/tests/api/invite-identity-security.test.mjs +++ b/tests/api/invite-identity-security.test.mjs @@ -1,5 +1,5 @@ // Security regression: pending invitation secrets must stay private and only -// the account named by an invitation may redeem it. +// the account uniquely named by an invitation may redeem it. import assert from 'node:assert/strict'; process.env.SCOPEWEAVE_DB = ':memory:'; @@ -29,10 +29,14 @@ 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 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 attackerAuth = authFor(attackerToken); +const ambiguousPrimaryAuth = authFor(ambiguousPrimaryToken); +const ambiguousCollisionAuth = authFor(ambiguousCollisionToken); let response = await req('/api/me', { headers: ownerAuth }); assert.equal(response.status, 200); @@ -54,9 +58,31 @@ response = await req(`/api/invites/${viewerInvite.token}/accept`, { assert.equal(response.status, 200); assert.equal((await response.json()).role, 'viewer'); -// Create a higher-privilege pending invite for a different identity. Invite -// creation canonicalizes the address, while the existing account intentionally -// retains mixed case to cover historical identity data. +// 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, From 7b89a4a971c343530672b9d70ed68585c3e46149 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 08:24:53 -0700 Subject: [PATCH 05/22] fix: fail closed on ambiguous invite identities --- server/app.mjs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index f4ad8bc2..e074b176 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -492,8 +492,11 @@ 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 user = db.prepare('SELECT email FROM users WHERE id = ?').get(uid); - if (user.email.trim().toLowerCase() !== inv.email.trim().toLowerCase()) { + const canonicalInviteEmail = inv.email.trim().toLowerCase(); + const identityMatches = db.prepare( + 'SELECT id FROM users WHERE lower(trim(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); From 2333d32874d3ab10a83071e075d4e39f2daf6b60 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 14:46:18 -0700 Subject: [PATCH 06/22] test(security): reproduce invite token access-log leak --- tests/api/invite-identity-security.test.mjs | 30 +++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/tests/api/invite-identity-security.test.mjs b/tests/api/invite-identity-security.test.mjs index f99bb5ee..f182822a 100644 --- a/tests/api/invite-identity-security.test.mjs +++ b/tests/api/invite-identity-security.test.mjs @@ -1,12 +1,20 @@ // 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'; -process.env.SCOPEWEAVE_DB = ':memory:'; +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 { app } = await import('../../server/app.mjs'); const body = (value) => JSON.stringify(value); @@ -102,6 +110,22 @@ const pendingAdmin = roster.invites.find((invite) => invite.email === 'invitee@e 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'); + +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. @@ -135,4 +159,6 @@ response = await req('/api/invites/definitely-missing-token/accept', { assert.equal(response.status, 404, 'missing invite remains fail-closed'); assert.deepEqual(await response.json(), { error: 'invalid or used invite' }); -console.log('invite identity security: ok'); +console.log = originalConsoleLog; +await rm(dbPath, { force: true }); +originalConsoleLog('invite identity security: ok'); From d0e1a732d63d2da0c9a2b987ea00a556b029bafc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 14:50:45 -0700 Subject: [PATCH 07/22] test(security): cover share-token log redaction --- tests/api/invite-identity-security.test.mjs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/api/invite-identity-security.test.mjs b/tests/api/invite-identity-security.test.mjs index f182822a..69ec59c4 100644 --- a/tests/api/invite-identity-security.test.mjs +++ b/tests/api/invite-identity-security.test.mjs @@ -121,6 +121,17 @@ assert.equal(inviteLogLines.length, 1, 'invite acceptance emits one structured r 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'); +// 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/me'); assert.equal(response.status, 401); From 0064af31cba25242075f24f25222e6b490c4891a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 14:57:03 -0700 Subject: [PATCH 08/22] fix(security): redact bearer secrets from request logs --- server/app.mjs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/server/app.mjs b/server/app.mjs index e074b176..70aeb95f 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -131,6 +131,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\/[^/]+\/accept$/, '/api/invites/:token/accept') + .replace(/^\/api\/shared\/[^/]+$/, '/api/shared/:token'); app.use('*', async (c, next) => { const t = Date.now(); await next(); @@ -140,7 +145,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 */ } }); From 8981c8dc107e1c35e0fa7772b88cba3c96d511fc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 12:23:51 -0700 Subject: [PATCH 09/22] test: redact secret path variants from request logs --- tests/api/invite-identity-security.test.mjs | 22 +++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/api/invite-identity-security.test.mjs b/tests/api/invite-identity-security.test.mjs index 69ec59c4..bcb6e6fc 100644 --- a/tests/api/invite-identity-security.test.mjs +++ b/tests/api/invite-identity-security.test.mjs @@ -121,6 +121,21 @@ assert.equal(inviteLogLines.length, 1, 'invite acceptance emits one structured r 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. @@ -132,6 +147,13 @@ 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); From a7bc39d16fe5240832ef08f0ee80e9c64dea7181 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 12:32:10 -0700 Subject: [PATCH 10/22] fix: redact bearer tokens on malformed paths --- server/app.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index 70aeb95f..11ef3afd 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -134,8 +134,8 @@ const quietLogs = String(process.env.SCOPEWEAVE_DB || '').includes(':memory:'); // 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\/[^/]+\/accept$/, '/api/invites/:token/accept') - .replace(/^\/api\/shared\/[^/]+$/, '/api/shared/:token'); + .replace(/^\/api\/invites\/[^/]+(?=\/|$)/, '/api/invites/:token') + .replace(/^\/api\/shared\/[^/]+(?=\/|$)/, '/api/shared/:token'); app.use('*', async (c, next) => { const t = Date.now(); await next(); From f8baae8b9c3a453bd5796401ad95c1601227127e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 11:08:48 +0900 Subject: [PATCH 11/22] fix(security): preserve Unicode invite identities --- server/app.mjs | 8 +++--- tests/api/invite-identity-security.test.mjs | 27 +++++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/server/app.mjs b/server/app.mjs index 11ef3afd..b77b7c2a 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -498,9 +498,11 @@ app.post('/api/invites/:token/accept', requireAuth, (c) => { 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 = inv.email.trim().toLowerCase(); - const identityMatches = db.prepare( - 'SELECT id FROM users WHERE lower(trim(email)) = ? ORDER BY id LIMIT 2' - ).all(canonicalInviteEmail); + // SQLite's built-in lower() is ASCII-only; canonicalize in JavaScript so + // Unicode mailbox casing cannot reject the intended account. + const identityMatches = db.prepare('SELECT id, email FROM users ORDER BY id').all() + .filter((user) => String(user.email ?? '').trim().toLowerCase() === canonicalInviteEmail) + .slice(0, 2); if (identityMatches.length !== 1 || identityMatches[0].id !== uid) { return c.json({ error: 'invalid or used invite' }, 404); } diff --git a/tests/api/invite-identity-security.test.mjs b/tests/api/invite-identity-security.test.mjs index bcb6e6fc..fa9ea5a4 100644 --- a/tests/api/invite-identity-security.test.mjs +++ b/tests/api/invite-identity-security.test.mjs @@ -36,12 +36,14 @@ async function signup(email, name = email) { 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 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 attackerAuth = authFor(attackerToken); const ambiguousPrimaryAuth = authFor(ambiguousPrimaryToken); const ambiguousCollisionAuth = authFor(ambiguousCollisionToken); @@ -179,6 +181,31 @@ response = await req(`/api/invites/${adminInvite.token}/accept`, { }); 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'); + response = await req(`/api/invites/${adminInvite.token}/accept`, { method: 'POST', headers: intendedAuth, From 2ce9f1dab362ef208d1fc68e8c7b6df815f1b3b7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 11:24:23 +0900 Subject: [PATCH 12/22] fix(security): canonicalize Unicode invite identities --- server/app.mjs | 13 ++++----- server/db.mjs | 3 ++ server/email_identity.mjs | 17 +++++++++++ tests/api/invite-identity-security.test.mjs | 31 +++++++++++++++++++++ 4 files changed, 57 insertions(+), 7 deletions(-) create mode 100644 server/email_identity.mjs diff --git a/server/app.mjs b/server/app.mjs index b77b7c2a..85b3e583 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); @@ -481,7 +482,7 @@ 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); @@ -497,12 +498,10 @@ 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 = inv.email.trim().toLowerCase(); - // SQLite's built-in lower() is ASCII-only; canonicalize in JavaScript so - // Unicode mailbox casing cannot reject the intended account. - const identityMatches = db.prepare('SELECT id, email FROM users ORDER BY id').all() - .filter((user) => String(user.email ?? '').trim().toLowerCase() === canonicalInviteEmail) - .slice(0, 2); + 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); } 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..aee7e8c4 --- /dev/null +++ b/server/email_identity.mjs @@ -0,0 +1,17 @@ +/** + * Canonicalize an email mailbox for the identity comparisons used by invites. + * NFKC handles compatibility forms; lowercasing plus final-sigma and sharp-s + * folds covers the Unicode case-fold mappings that JavaScript does not expose + * as a native operation. + * + * @param {unknown} value persisted or user-supplied email address + * @returns {string} deterministic Unicode-aware identity key + */ +export function canonicalizeMailbox(value) { + return String(value ?? '') + .trim() + .normalize('NFKC') + .toLowerCase() + .replace(/\u03c2/g, '\u03c3') + .replace(/\u00df/g, 'ss'); +} diff --git a/tests/api/invite-identity-security.test.mjs b/tests/api/invite-identity-security.test.mjs index fa9ea5a4..950fd48e 100644 --- a/tests/api/invite-identity-security.test.mjs +++ b/tests/api/invite-identity-security.test.mjs @@ -37,6 +37,8 @@ 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 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'); @@ -44,6 +46,8 @@ 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 attackerAuth = authFor(attackerToken); const ambiguousPrimaryAuth = authFor(ambiguousPrimaryToken); const ambiguousCollisionAuth = authFor(ambiguousCollisionToken); @@ -206,6 +210,33 @@ response = await req(`/api/invites/${unicodeInvite.token}/accept`, { 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/invites/${adminInvite.token}/accept`, { method: 'POST', headers: intendedAuth, From 11493bb06beae2c02aef48d979c4195c9ee50632 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 20:10:35 -0700 Subject: [PATCH 13/22] test(coverage): require invite identity instrumentation --- tests/unit/coverage-script-contract.test.mjs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index 149440e5..afa57185 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -34,6 +34,11 @@ assert.match( /--include=server\/clearfolio\.mjs/, 'the abortable Clearfolio adapter 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/, From c0c4cb297c5d5c2779db0956cf340d82e6746a41 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 28 Aug 2026 20:11:07 -0700 Subject: [PATCH 14/22] fix(coverage): instrument invite identity module --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index dbe22c23..afb2dad0 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "server": "node server/server.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/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", From 27e581a7298b9faa309d9c838e1f5f05d3c0f355 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 12:37:56 +0900 Subject: [PATCH 15/22] fix(invites): use complete Unicode mailbox folding --- README.md | 2 +- package-lock.json | 2 +- package.json | 2 +- server/email_identity.mjs | 236 +++++++++++++++++++- tests/api/invite-identity-security.test.mjs | 23 ++ 5 files changed, 253 insertions(+), 12 deletions(-) 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/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 afb2dad0..36ab1846 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "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", diff --git a/server/email_identity.mjs b/server/email_identity.mjs index aee7e8c4..4a623823 100644 --- a/server/email_identity.mjs +++ b/server/email_identity.mjs @@ -1,17 +1,235 @@ +/** + * 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}"], +]); + /** * Canonicalize an email mailbox for the identity comparisons used by invites. - * NFKC handles compatibility forms; lowercasing plus final-sigma and sharp-s - * folds covers the Unicode case-fold mappings that JavaScript does not expose - * as a native operation. + * 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) { - return String(value ?? '') - .trim() - .normalize('NFKC') - .toLowerCase() - .replace(/\u03c2/g, '\u03c3') - .replace(/\u00df/g, 'ss'); + const normalized = String(value ?? '').trim().normalize('NFKC'); + return Array.from(normalized, (character) => ( + UNICODE_CASE_FOLD_OVERRIDES.get(character.codePointAt(0)) ?? character.toLowerCase() + )).join('').normalize('NFKC'); } diff --git a/tests/api/invite-identity-security.test.mjs b/tests/api/invite-identity-security.test.mjs index 950fd48e..dfa53e7d 100644 --- a/tests/api/invite-identity-security.test.mjs +++ b/tests/api/invite-identity-security.test.mjs @@ -39,6 +39,7 @@ 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'); @@ -48,6 +49,7 @@ 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); @@ -237,6 +239,27 @@ for (const [accountAuth, invitedEmail, workspaceName] of [ 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, From d3937e7d8f15806bda2d98694d24ebd559c708ad Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 13:05:54 +0900 Subject: [PATCH 16/22] test(invites): close current-head coverage gaps --- package.json | 2 +- tests/api/invite-identity-security.test.mjs | 9 +++++++++ tests/unit/coverage-script-contract.test.mjs | 5 +++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 36ab1846..5eb6d665 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "server": "node server/server.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/email_identity.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/tests/api/invite-identity-security.test.mjs b/tests/api/invite-identity-security.test.mjs index dfa53e7d..9e2bd15d 100644 --- a/tests/api/invite-identity-security.test.mjs +++ b/tests/api/invite-identity-security.test.mjs @@ -59,6 +59,15 @@ 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'); + // Give the low-privilege account legitimate roster visibility. response = await req(`/api/orgs/${orgId}/invites`, { method: 'POST', diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index afa57185..28a7718e 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -34,6 +34,11 @@ 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/, From 4850e5608d62ef8aef2c360839f76d5ff7aca7c5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 00:14:42 -0700 Subject: [PATCH 17/22] test(invites): reject invitations for unregistered identities --- tests/api/invite-identity-security.test.mjs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/api/invite-identity-security.test.mjs b/tests/api/invite-identity-security.test.mjs index 9e2bd15d..e32d557d 100644 --- a/tests/api/invite-identity-security.test.mjs +++ b/tests/api/invite-identity-security.test.mjs @@ -68,6 +68,18 @@ response = await req(`/api/orgs/${orgId}/invites`, { }); 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', From 0a9d7c076782cfba033490f14384e24316faeef8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 00:19:46 -0700 Subject: [PATCH 18/22] fix(invites): bind invitations to registered accounts --- server/app.mjs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/server/app.mjs b/server/app.mjs index 85b3e583..da3b30e5 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -474,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'); @@ -486,6 +486,12 @@ app.post('/api/orgs/:id/invites', requireAuth, async (c) => { 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); From 1ece078af63aa623ce4f9a38be3860ee3bf3ea94 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 00:22:48 -0700 Subject: [PATCH 19/22] docs(api): document account-bound invitation contract --- docs/api.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) 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 From c98304eea9dc9de360426908dcbab13de79f7a56 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 16:44:17 +0900 Subject: [PATCH 20/22] test(api): register invitee in smoke fixture --- tests/api/smoke.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/api/smoke.mjs b/tests/api/smoke.mjs index e536b908..a9af33f6 100644 --- a/tests/api/smoke.mjs +++ b/tests/api/smoke.mjs @@ -118,6 +118,8 @@ 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'); 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 }); @@ -748,4 +750,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'); From 1aec93dba927f4b3e5d95c3366de12340830f3a1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 17:01:40 +0900 Subject: [PATCH 21/22] test(api): preserve revoked invite identity --- tests/api/smoke.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/api/smoke.mjs b/tests/api/smoke.mjs index a9af33f6..a6af7bd3 100644 --- a/tests/api/smoke.mjs +++ b/tests/api/smoke.mjs @@ -120,6 +120,7 @@ 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 }); @@ -129,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'); From 6afb53e045fe216d837ccb905a1da46d3787f599 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 29 Aug 2026 17:15:58 +0900 Subject: [PATCH 22/22] fix(identity): preserve Cherokee case folding --- server/email_identity.mjs | 13 ++++++++++--- tests/api/invite-identity-security.test.mjs | 9 +++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/server/email_identity.mjs b/server/email_identity.mjs index 4a623823..2efab645 100644 --- a/server/email_identity.mjs +++ b/server/email_identity.mjs @@ -219,6 +219,11 @@ const UNICODE_CASE_FOLD_OVERRIDES = new Map([ [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 @@ -229,7 +234,9 @@ const UNICODE_CASE_FOLD_OVERRIDES = new Map([ */ export function canonicalizeMailbox(value) { const normalized = String(value ?? '').trim().normalize('NFKC'); - return Array.from(normalized, (character) => ( - UNICODE_CASE_FOLD_OVERRIDES.get(character.codePointAt(0)) ?? character.toLowerCase() - )).join('').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 index e32d557d..ef3a9620 100644 --- a/tests/api/invite-identity-security.test.mjs +++ b/tests/api/invite-identity-security.test.mjs @@ -15,8 +15,17 @@ 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,