Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 11 additions & 4 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand All @@ -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

Expand All @@ -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=<hex hmac of the raw body with your whsec_ secret>
X-ScopeWeave-Event: project.update
X-ScopeWeave-Signature: sha256=<hex hmac of the raw body with your whsec_ secret>
```

```js
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
29 changes: 24 additions & 5 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.
WHERE org_id = ? AND accepted_at IS NULL ORDER BY id DESC`
).all(orgId);
return c.json({ members, invites });
Expand All @@ -468,30 +474,43 @@ 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');
const role = orgRole(uid, orgId);
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);
}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
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);
logAudit(orgId, uid, 'member.invite', 'invite', email, { role: inviteRole });
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.
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.
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.
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
Loading
Loading