Skip to content
Draft
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
6e0c49f
test(security): reproduce invite identity takeover
seonghobae Aug 23, 2026
80b2682
test(security): register invite identity regression
seonghobae Aug 23, 2026
58c5c30
fix(security): bind invite redemption to identity
seonghobae Aug 23, 2026
bebc207
test: reject ambiguous invite identities
seonghobae Aug 25, 2026
7b89a4a
fix: fail closed on ambiguous invite identities
seonghobae Aug 25, 2026
2333d32
test(security): reproduce invite token access-log leak
seonghobae Aug 25, 2026
d0e1a73
test(security): cover share-token log redaction
seonghobae Aug 25, 2026
0064af3
fix(security): redact bearer secrets from request logs
seonghobae Aug 25, 2026
8981c8d
test: redact secret path variants from request logs
seonghobae Aug 28, 2026
a7bc39d
fix: redact bearer tokens on malformed paths
seonghobae Aug 28, 2026
f8baae8
fix(security): preserve Unicode invite identities
seonghobae Aug 29, 2026
2ce9f1d
fix(security): canonicalize Unicode invite identities
seonghobae Aug 29, 2026
11493bb
test(coverage): require invite identity instrumentation
seonghobae Aug 29, 2026
c0c4cb2
fix(coverage): instrument invite identity module
seonghobae Aug 29, 2026
27e581a
fix(invites): use complete Unicode mailbox folding
seonghobae Aug 29, 2026
d3937e7
test(invites): close current-head coverage gaps
seonghobae Aug 29, 2026
4850e56
test(invites): reject invitations for unregistered identities
seonghobae Aug 29, 2026
0a9d7c0
fix(invites): bind invitations to registered accounts
seonghobae Aug 29, 2026
1ece078
docs(api): document account-bound invitation contract
seonghobae Aug 29, 2026
c98304e
test(api): register invitee in smoke fixture
seonghobae Aug 29, 2026
1aec93d
test(api): preserve revoked invite identity
seonghobae Aug 29, 2026
6afb53e
fix(identity): preserve Cherokee case folding
seonghobae Aug 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@
"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/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",
Expand Down
21 changes: 17 additions & 4 deletions server/app.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand All @@ -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 */ }
});
Expand Down Expand Up @@ -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
Comment thread
seonghobae marked this conversation as resolved.
Resolved
WHERE org_id = ? AND accepted_at IS NULL ORDER BY id DESC`
).all(orgId);
return c.json({ members, invites });
Expand All @@ -476,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);
Expand All @@ -487,11 +493,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);
Comment thread
seonghobae marked this conversation as resolved.
Resolved
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) {
Comment thread
seonghobae marked this conversation as resolved.
Resolved
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')) {
Expand Down
3 changes: 3 additions & 0 deletions server/db.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
db.exec("PRAGMA journal_mode = WAL");
db.exec("PRAGMA foreign_keys = ON");

Expand Down Expand Up @@ -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 */ }
Expand Down
17 changes: 17 additions & 0 deletions server/email_identity.mjs
Original file line number Diff line number Diff line change
@@ -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');
Comment thread
github-actions[bot] marked this conversation as resolved.
Resolved
}
Loading
Loading