diff --git a/CHANGELOG.md b/CHANGELOG.md index e434fa01..d05899c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +- Hardened tenant-configured outbound webhooks against SSRF and DNS rebinding by + requiring production HTTPS destinations, rejecting credentials, fragments, + local/private/link-local/special-use address space, revalidating every A/AAAA + answer before each delivery attempt, pinning the actual HTTPS socket to the + validated address while preserving hostname/TLS authority, refusing redirects, + and exposing stable non-secret resolver and transport failures. - Made contextual-orchestrator briefing requests fail closed unless an authenticated endpoint is configured. Deterministic generated text is restricted to explicit `SCOPEWEAVE_DEV=1`, message/provider responses are bounded and validated, and non-loopback HTTP transport is rejected. - Made `SCOPEWEAVE_JWT_SECRET` mandatory at startup and rejected weak or unexpanded placeholder values so production deployments fail closed. diff --git a/docs/doctoring/outbound-webhook-ssrf.md b/docs/doctoring/outbound-webhook-ssrf.md new file mode 100644 index 00000000..8b56017e --- /dev/null +++ b/docs/doctoring/outbound-webhook-ssrf.md @@ -0,0 +1,153 @@ +# Outbound webhook SSRF boundary: evidence and design record + +## Status boundary + +As of this active repair, protected `develop@1fadec04195805722829b386475a09a15f8cd926` +still contains the historical webhook implementation that accepts arbitrary +`http(s)` destinations and later supplies the persisted URL to server-side +`fetch()`. That is protected-shipped truth until this pull request is integrated. + +The active `fix/webhook-ssrf-551` pull request introduces the candidate repair +described below. Nothing in this record represents a certification claim or a +statement that protected `develop` already contains the repair. + +## Buyer-visible risk + +A custom webhook is intentionally an outbound server-side request whose target +is supplied by a tenant administrator. Without a destination authority boundary, +that feature can become an SSRF primitive against the ScopeWeave runtime, +neighboring private services, link-local cloud metadata endpoints, or other +addresses that are not Internet-facing webhook authorities. Redirect following +and DNS rebinding can invalidate an otherwise correct one-time URL or DNS check. + +The commercial requirement is therefore stronger than syntactic URL validation: +ScopeWeave must prove that the address used by the actual socket is an allowed +public destination immediately before every attempt. + +## Candidate design + +The active repair applies the following fail-closed contract: + +1. webhook registration is parsed with the WHATWG `URL` implementation and + production accepts HTTPS only; URL credentials, fragments, localhost-like + names, and literal denied addresses are rejected; +2. every delivery attempt performs a fresh A/AAAA lookup and accepts the attempt + only when **every** returned address passes the same public-address policy; +3. private, loopback, link-local, shared, unspecified, mapped, multicast, + documentation, benchmark, reserved, ULA, and other non-public/special-use + ranges are rejected conservatively using the current IANA special-purpose + registries plus multicast boundaries; +4. the HTTPS socket receives a custom `lookup` callback containing the + just-validated address, while the original DNS hostname remains the HTTP/TLS + authority and SNI name. `agent: false` prevents an older pooled connection + from bypassing the fresh per-attempt authorization; +5. Node's native `https.request()` is used directly; redirects are never + followed, so a 3xx response is a failed delivery rather than authority + transplantation of the signed body and HMAC header; +6. the existing three-second abort budget and bounded retry behavior remain in + the application core. Each retry re-enters the transport and therefore + resolves, validates, and pins again; +7. policy, resolver, TLS, and transport failures expose stable non-secret error + classes rather than internal addresses or resolver/socket details; and +8. the legacy development-only loopback HTTP registration fixture remains + isolated behind `SCOPEWEAVE_DEV=1` solely so the existing failure/retry smoke + path remains deterministic. The outbound transport itself still rejects HTTP, + so that fixture cannot make a loopback connection and production never + inherits it. + +The existing Hono route graph is temporarily retained in `server/app_core.mjs`. +`server/app.mjs` is the sole exported server facade and interposes the registration +policy plus the signed-webhook egress transport while delegating unrelated OIDC, +Clearfolio, billing, tenant, and authentication fetches to native `fetch`. This +keeps the security slice bounded and reviewable rather than rewriting unrelated +application behavior inside the same repair. + +## Verification contract + +Deterministic regression evidence must cover at least: + +- production registration rejection for plaintext HTTP, localhost and + `.localhost`, IPv4 loopback in dotted/decimal/hex forms, RFC 1918, IPv4 + link-local/metadata-style destinations, IPv6 loopback, ULA, IPv4-mapped IPv6, + URL credentials, and fragments; +- direct public IPv4 and IPv6 literals plus a public-hostname-shaped success seam + without depending on the Internet; +- empty/malformed/private DNS responses and mixed public+private answer sets, + with zero connector calls after a denied resolution; +- a DNS-rebinding sequence where a first public answer can be used but a later + private answer is rejected before the retry socket is created; +- the custom connector lookup returning only the address validated for that + attempt while preserving the original hostname as TLS `servername`; +- redirects treated as failures without a second request; +- stable error text for resolver, synchronous request, asynchronous socket, and + pre-aborted-signal failures; and +- canonical `test:unit`, `test:api`, and c8 registration for every new production + module, while continuing to measure the moved application core rather than + creating a false coverage improvement through a filename split. + +A green successor head does not erase the deliberately preserved RED predecessor: +`006cfabda2f9e1b36221215a481b9475a07164c4` registered the real production API +regression and the hosted `unit-and-api` lane failed because protected behavior +returned HTTP 200 for the first denied plaintext-HTTP destination. Exact-current- +head gates must be regenerated after every production or evidence change. + +## Standards and primary-source rationale + +OWASP identifies custom webhooks as an SSRF use case, recommends validating both +A and AAAA answers when arbitrary external targets are allowed, calls out DNS +pinning/rebinding, and recommends disabling redirects. ScopeWeave therefore does +not rely on registration-time DNS or a second independent resolver decision at +connection time. + +IANA's IPv4 and IPv6 Special-Purpose Address Registries are the authoritative +machine-readable inventory for address blocks whose routing or protocol semantics +are exceptional. The registries were last updated October 9, 2025 when this +record was prepared. ScopeWeave uses a conservative deny policy for ranges not +suitable as ordinary public webhook authorities; this includes the IPv6 dummy +prefix `100:0:0:1::/64` added to the registry in 2025. + +RFC 6890 establishes the special-purpose address registries and their +`Globally Reachable` semantics. RFC 4291 defines IPv6 unspecified, loopback, +IPv4-mapped, link-local, and multicast semantics. RFC 1918, RFC 3927, and RFC +4193 define private IPv4, IPv4 link-local, and IPv6 unique-local space, +respectively. + +Node.js 22 documents that `https.request()` accepts HTTP request options plus TLS +options such as `servername`; its underlying connection options support a custom +DNS `lookup` function. The active repair uses that supported seam so address +validation and socket selection are one authorization decision while TLS still +authenticates the original webhook hostname. + +## References + +Cotton, M., Vegoda, L., Bonica, R., & Haberman, B. (2013). *Special-purpose IP +address registries* (BCP 153; RFC 6890). Internet Engineering Task Force. +https://doi.org/10.17487/RFC6890 + +Cheshire, S., Aboba, B., & Guttman, E. (2005). *Dynamic configuration of IPv4 +link-local addresses* (RFC 3927). Internet Engineering Task Force. +https://doi.org/10.17487/RFC3927 + +Hinden, R., & Deering, S. (2006). *IP version 6 addressing architecture* (RFC +4291). Internet Engineering Task Force. https://doi.org/10.17487/RFC4291 + +Hinden, R., & Haberman, B. (2005). *Unique local IPv6 unicast addresses* (RFC +4193). Internet Engineering Task Force. https://doi.org/10.17487/RFC4193 + +Internet Assigned Numbers Authority. (2025, October 9). *IPv4 special-purpose +address space*. https://www.iana.org/assignments/iana-ipv4-special-registry/ + +Internet Assigned Numbers Authority. (2025, October 9). *IPv6 special-purpose +address space*. https://www.iana.org/assignments/iana-ipv6-special-registry/ + +Open Worldwide Application Security Project Foundation. (n.d.). *Server side +request forgery prevention cheat sheet*. OWASP Cheat Sheet Series. Retrieved +August 18, 2026, from +https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html + +OpenJS Foundation. (2026). *HTTPS*. Node.js v22 documentation. +https://nodejs.org/docs/latest-v22.x/api/https.html + +Rekhter, Y., Moskowitz, B., Karrenberg, D., de Groot, G. J., & Lear, E. (1996). +*Address allocation for private Internets* (BCP 5; RFC 1918). Internet +Engineering Task Force. https://doi.org/10.17487/RFC1918 diff --git a/package.json b/package.json index 8cefdc74..37d15841 100644 --- a/package.json +++ b/package.json @@ -12,10 +12,10 @@ "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: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", + "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 && node tests/api/static-prototype-pollution.test.mjs && node tests/api/webhook-destination-policy.test.mjs && node tests/api/webhook-legacy-migration.test.mjs && node tests/api/webhook-legacy-private-destination-migration.test.mjs && node tests/api/fetch-boundary-ownership.test.mjs && node tests/api/review-regressions.test.mjs && node tests/api/console-observability-boundary.test.mjs && node tests/api/email-identity-compat.test.mjs && node tests/api/oidc-production-boundary.test.mjs && node tests/api/oidc-state-capacity.test.mjs && node tests/api/oidc-mock-dev-only.test.mjs && node tests/api/oidc-email-verification.test.mjs && node tests/api/oidc-timeout.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 && node tests/unit/webhook-transport.test.mjs && node tests/unit/public-https-transport.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/app_core.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/oidc_identity.mjs --include=server/orchestrator.mjs --include=server/webhook_transport.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 && node tests/unit/webhook-transport.test.mjs && node tests/unit/public-https-transport.test.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", "test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js tests/e2e/toast-accessibility.spec.js", diff --git a/server/app.mjs b/server/app.mjs index c432a84f..29270bdc 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -1,1410 +1,801 @@ -// ScopeWeave SaaS API. Multi-tenant (org-scoped), optimistic concurrency on -// project docs, SSE realtime fan-out per project. The existing static client -// (index.html/app.js) becomes the frontend that talks to these routes. -import { Hono } from 'hono'; -import { readFile } from 'node:fs/promises'; -import { randomBytes, createHmac, createHash } from 'node:crypto'; -import { db, rowid } from './db.mjs'; -import { hashPassword, verifyPassword, signToken, verifyToken, generateApiToken, hashApiToken } from './auth.mjs'; -import { PLANS, planOf, orgUsage, wouldExceed, createCheckout } from './billing.mjs'; -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 { computeEvm } from '../analytics.js'; // pure math, shared with the client - -const getOrg = (id) => db.prepare('SELECT * FROM orgs WHERE id = ?').get(id); +// ScopeWeave API security facade. +// +// The historical Hono route graph remains in app_core.mjs so bounded security +// policy can be added without rewriting unrelated tenant, auth, billing, or +// Clearfolio behavior. Every production server and in-process caller imports +// this facade; app_core.mjs is an implementation module, not a public entrypoint. +import { createPublicKey, randomBytes, verify as verifySignature } from 'node:crypto'; +import { app as coreApp, configureSecureOutboundFetch } from './app_core.mjs'; +import { db } from './db.mjs'; +import { + finalizeOidcIdentity, + OidcIdentityConflictError, + prepareOidcIdentity, +} from './oidc_identity.mjs'; +import { + WebhookDestinationError, + fetchPublicHttps, + postWebhook, + validateWebhookRegistrationUrl, +} from './webhook_transport.mjs'; + +const nativeFetch = globalThis.fetch.bind(globalThis); +const authorizationProbeObservabilityKey = Symbol('scopeweave.authorization-probe-observability'); +const WEBHOOK_REGISTRATION_PATH = /^\/api\/orgs\/[^/]+\/webhooks$/; +const WEBHOOK_REGISTRATION_BODY_MAX_BYTES = 16 * 1024; +const AUTH_REQUEST_BODY_MAX_BYTES = 16 * 1024; +const AUDIT_PATH = /^\/api\/orgs\/[^/]+\/audit$/; +const AUTH_EMAIL_PATH = /^\/api\/auth\/(?:signup|login)$/; +const METRICS_PATH = '/api/metrics'; +const LEGACY_WEBHOOK_URL_REQUIRED_ERROR = 'valid http(s) url required'; +const OIDC_ISSUER = process.env.OIDC_ISSUER + ? process.env.OIDC_ISSUER.replace(/\/$/, '') + : null; +const OIDC_CLIENT_ID = process.env.OIDC_CLIENT_ID || ''; +const OIDC_TOKEN_URL = OIDC_ISSUER ? `${OIDC_ISSUER}/token` : null; +const OIDC_TOKEN_TIMEOUT_MS = 3000; +const OIDC_DISCOVERY_TTL_MS = 60 * 1000; +const OIDC_JWKS_TTL_MS = 60 * 1000; +const OIDC_SIGNING_KEY_MAX_ENTRIES = 8; +const OIDC_STATE_TTL_MS = 5 * 60 * 1000; +const OIDC_STATE_MAX_ENTRIES = 256; +const OIDC_CLOCK_SKEW_SECONDS = 60; +const oidcNonceByState = new Map(); +const oidcNonceByCode = new Map(); +const facadeMetrics = { + requests: 0, + signups: 0, + s2xx: 0, + s4xx: 0, + s5xx: 0, +}; +const suppressedCoreMetrics = { + requests: 0, + s2xx: 0, + s4xx: 0, + s5xx: 0, +}; +const quietFacadeLogs = String(process.env.SCOPEWEAVE_DB || '').includes(':memory:'); +let oidcDiscoveryCache = null; +const oidcSigningKeyCache = new Map(); -// Append-only audit trail. Never throws into the request path. -function logAudit(orgId, userId, action, targetType, targetId, meta) { - try { - db.prepare('INSERT INTO audit_log(org_id,user_id,action,target_type,target_id,meta) VALUES(?,?,?,?,?,?)') - .run(orgId, userId ?? null, action, targetType ?? null, targetId != null ? String(targetId) : null, meta ? JSON.stringify(meta) : null); - } catch { /* audit must not break the operation */ } +function matchingStoredEmails(email) { + return db.prepare( + 'SELECT email FROM users WHERE email = ? COLLATE NOCASE ORDER BY id LIMIT 2', + ).all(email); } -// --- RBAC. Roles (highest→lowest): owner > admin > member > viewer. -const orgRole = (userId, orgId) => - db.prepare('SELECT role FROM memberships WHERE user_id = ? AND org_id = ?').get(userId, orgId)?.role || null; -const canManage = (role) => role === 'owner' || role === 'admin'; -const canWrite = (role) => role === 'owner' || role === 'admin' || role === 'member'; +function observeFacadeResponse(request, response, startedAt = Date.now()) { + facadeMetrics.requests += 1; + if (response.status >= 500) facadeMetrics.s5xx += 1; + else if (response.status >= 400) facadeMetrics.s4xx += 1; + else if (response.status >= 200) facadeMetrics.s2xx += 1; + if (!quietFacadeLogs) { + console.log(JSON.stringify({ + ts: new Date().toISOString(), + method: request.method, + path: new URL(request.url).pathname, + status: response.status, + ms: Date.now() - startedAt, + })); + } + return response; +} -export const app = new Hono(); +function mergePrometheusFacadeMetrics(payload) { + let merged = payload; + for (const [key, value] of Object.entries(facadeMetrics)) { + const metric = `scopeweave_${key}`; + merged = merged.replace( + new RegExp(`^(${metric}\\s+)(-?\\d+(?:\\.\\d+)?)$`, 'm'), + (_, prefix, current) => `${prefix}${Number(current) + value}`, + ); + } + return merged; +} -async function requireAuth(c, next) { - const header = c.req.header('authorization') || ''; - const token = header.startsWith('Bearer ') ? header.slice(7) : ''; - // Personal Access Token path (swk_...): look up by hash, act as its user. - if (token.startsWith('swk_')) { - const row = db.prepare('SELECT * FROM api_tokens WHERE token_hash = ?').get(hashApiToken(token)); - if (!row) return c.json({ error: 'unauthorized' }, 401); - db.prepare("UPDATE api_tokens SET last_used = datetime('now') WHERE id = ?").run(row.id); - c.set('user', { sub: row.user_id, viaPat: true }); - return next(); +async function mergeFacadeMetricsResponse(request, response) { + const url = new URL(request.url); + if ( + request.method !== 'GET' + || url.pathname !== METRICS_PATH + || !response.ok + ) return response; + + const headers = new Headers(response.headers); + headers.delete('content-length'); + if (url.searchParams.get('format') === 'prometheus') { + return new Response( + mergePrometheusFacadeMetrics(await response.clone().text()), + { + status: response.status, + statusText: response.statusText, + headers, + }, + ); } - try { - const payload = verifyToken(token); - // Session revocation: a bumped token_version invalidates all older JWTs. - const u = db.prepare('SELECT token_version FROM users WHERE id = ?').get(payload.sub); - if (!u || (payload.tv || 0) !== u.token_version) return c.json({ error: 'unauthorized' }, 401); - c.set('user', payload); - } catch { - return c.json({ error: 'unauthorized' }, 401); + + const payload = await response.clone().json().catch(() => null); + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return response; + const merged = { ...payload }; + for (const key of Object.keys(facadeMetrics)) { + merged[key] = (Number(merged[key]) || 0) + facadeMetrics[key]; } - await next(); + return new Response(JSON.stringify(merged), { + status: response.status, + statusText: response.statusText, + headers, + }); +} + +function recordSuppressedCoreResponse(response) { + suppressedCoreMetrics.requests += 1; + if (response.status >= 500) suppressedCoreMetrics.s5xx += 1; + else if (response.status >= 400) suppressedCoreMetrics.s4xx += 1; + else if (response.status >= 200) suppressedCoreMetrics.s2xx += 1; } -// --- realtime: projectId -> Set -const streams = new Map(); -function broadcast(projectId, data) { - const subs = streams.get(String(projectId)); - if (!subs) return; - const chunk = new TextEncoder().encode(`data: ${JSON.stringify(data)}\n\n`); - for (const ctrl of subs) { - try { ctrl.enqueue(chunk); } catch { /* dropped subscriber */ } +function subtractPrometheusCoreMetrics(payload) { + let adjusted = payload; + for (const [key, value] of Object.entries(suppressedCoreMetrics)) { + const metric = `scopeweave_${key}`; + adjusted = adjusted.replace( + new RegExp(`^(${metric}\\s+)(-?\\d+(?:\\.\\d+)?)$`, 'm'), + (_, prefix, current) => `${prefix}${Math.max(0, Number(current) - value)}`, + ); } + return adjusted; } -// Membership-scoped project fetch — the tenant isolation boundary. -function projectAccess(userId, projectId) { - return db.prepare( - `SELECT p.*, m.role AS memberRole FROM projects p - JOIN memberships m ON m.org_id = p.org_id - WHERE p.id = ? AND m.user_id = ?` - ).get(projectId, userId); +async function subtractSuppressedCoreMetricsResponse(request, response) { + const url = new URL(request.url); + if ( + request.method !== 'GET' + || url.pathname !== METRICS_PATH + || !response.ok + ) return response; + + const headers = new Headers(response.headers); + headers.delete('content-length'); + if (url.searchParams.get('format') === 'prometheus') { + return new Response( + subtractPrometheusCoreMetrics(await response.clone().text()), + { + status: response.status, + statusText: response.statusText, + headers, + }, + ); + } + + const payload = await response.clone().json().catch(() => null); + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return response; + const adjusted = { ...payload }; + for (const [key, value] of Object.entries(suppressedCoreMetrics)) { + adjusted[key] = Math.max(0, (Number(adjusted[key]) || 0) - value); + } + return new Response(JSON.stringify(adjusted), { + status: response.status, + statusText: response.statusText, + headers, + }); } -// --- observability: in-process counters + structured request log. -const metrics = { - startedAt: new Date().toISOString(), - requests: 0, - s2xx: 0, - s4xx: 0, - s5xx: 0, - signups: 0, - projectsCreated: 0, - webhookDeliveries: 0, - attachmentStatusRefreshAttempted: 0, - attachmentStatusRefreshChanged: 0, - attachmentStatusRefreshFailed: 0, - attachmentStatusRefreshDeferred: 0, +const nativeCoreFetch = coreApp.fetch.bind(coreApp); +coreApp.fetch = async (request, ...rest) => { + const internalAuthorizationProbe = request?.[authorizationProbeObservabilityKey] === true; + const response = await nativeCoreFetch(request, ...rest); + if (internalAuthorizationProbe) recordSuppressedCoreResponse(response); + return subtractSuppressedCoreMetricsResponse(request, response); }; -// Outbound webhooks: POST signed JSON to each active hook subscribed to `event`. -// Fire-and-forget with a timeout, one retry on failure, and a recorded outcome -// per attempt — never blocks or fails the triggering request. -function recordDelivery(webhookId, event, status, ok, attempt) { - try { - db.prepare('INSERT INTO webhook_deliveries(webhook_id,event,status_code,ok,attempt) VALUES(?,?,?,?,?)') - .run(webhookId, event, status ?? null, ok ? 1 : 0, attempt); - } catch { /* recording must not break delivery */ } +function isSignedWebhookRequest(request) { + if (request.method.toUpperCase() !== 'POST') return false; + return Boolean( + request.headers.get('x-scopeweave-event') + && /^sha256=[0-9a-f]{64}$/i.test( + request.headers.get('x-scopeweave-signature') || '', + ), + ); } -function sendWebhook(webhookId, url, sig, event, body, attempt) { - metrics.webhookDeliveries++; - const ctrl = new AbortController(); - const to = setTimeout(() => ctrl.abort(), 3000); - fetch(url, { - method: 'POST', - headers: { 'content-type': 'application/json', 'x-scopeweave-event': event, 'x-scopeweave-signature': `sha256=${sig}` }, - body, - signal: ctrl.signal, - }).then((res) => { - recordDelivery(webhookId, event, res.status, res.ok, attempt); - if (!res.ok && attempt < 2) setTimeout(() => sendWebhook(webhookId, url, sig, event, body, attempt + 1), 500); - }).catch(() => { - recordDelivery(webhookId, event, null, false, attempt); - if (attempt < 2) setTimeout(() => sendWebhook(webhookId, url, sig, event, body, attempt + 1), 500); - }).finally(() => clearTimeout(to)); +function isOidcTokenRequest(request) { + return OIDC_TOKEN_URL !== null + && request.method.toUpperCase() === 'POST' + && request.url === OIDC_TOKEN_URL; } -function deliver(orgId, event, payload) { - let hooks; - try { - hooks = db.prepare('SELECT id, url, secret, events FROM webhooks WHERE org_id = ? AND active = 1').all(orgId); - } catch { return; } - for (const h of hooks) { - const subs = String(h.events || '').split(',').map((s) => s.trim()); - if (!(subs.includes('*') || subs.includes(event))) continue; - const body = JSON.stringify({ event, orgId: Number(orgId), payload, ts: new Date().toISOString() }); - const sig = createHmac('sha256', h.secret).update(body).digest('hex'); - sendWebhook(h.id, h.url, sig, event, body, 1); +async function protectedWebhookFetch(request) { + const body = request.body + ? new Uint8Array(await request.clone().arrayBuffer()) + : ''; + const result = await postWebhook(request.url, { + headers: Object.fromEntries(request.headers.entries()), + body, + signal: request.signal, + }); + // Preserve fetch's Response contract for callers. Informational/invalid + // status codes cannot construct a standard Response and are represented as a + // network-style error response instead of leaking a transport-only object. + if (result.status >= 200 && result.status <= 599) { + return new Response(null, { status: result.status }); } + return Response.error(); } -const quietLogs = String(process.env.SCOPEWEAVE_DB || '').includes(':memory:'); // silence during tests -app.use('*', async (c, next) => { - const t = Date.now(); - await next(); - try { - metrics.requests++; - const s = c.res.status; - 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 })); - } - } catch { /* metrics/logging must never break a request */ } -}); -// Rate limiting (opt-in via SCOPEWEAVE_RATE_LIMIT_MAX, per client IP, fixed -// window). Protects against brute-force/abuse. Off by default so it never -// surprises tests/dev. Ceiling: per-instance in-memory → use Redis for multi-node. -const RL_MAX = Number(process.env.SCOPEWEAVE_RATE_LIMIT_MAX) || 0; -const RL_WINDOW_MS = Number(process.env.SCOPEWEAVE_RATE_LIMIT_WINDOW_MS) || 60000; -const rlBuckets = new Map(); -if (RL_MAX > 0) { - app.use('*', async (c, next) => { - const key = (c.req.header('x-forwarded-for') || '').split(',')[0].trim() || 'local'; - const now = Date.now(); - let b = rlBuckets.get(key); - if (!b || b.resetAt <= now) { b = { count: 0, resetAt: now + RL_WINDOW_MS }; rlBuckets.set(key, b); } - b.count++; - if (b.count > RL_MAX) { - const retry = Math.ceil((b.resetAt - now) / 1000); - return c.json({ error: 'rate limit exceeded' }, 429, { 'Retry-After': String(retry) }); - } - await next(); - }); +function parseJwtObject(segment) { + const value = JSON.parse(Buffer.from(String(segment || ''), 'base64url').toString('utf8')); + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('invalid token'); + return value; } -app.post('/api/auth/signup', async (c) => { - const { email, password, name } = await c.req.json().catch(() => ({})); - if (!email || typeof password !== 'string' || password.length < 8) { - return c.json({ error: 'email and password (min 8 chars) required' }, 400); - } - if (db.prepare('SELECT id FROM users WHERE email = ?').get(email)) { - return c.json({ error: 'email already registered' }, 409); - } - // user + personal workspace + owner membership, atomically. - let uid; - const tx = () => { - uid = rowid(db.prepare('INSERT INTO users(email,password_hash,name) VALUES(?,?,?)') - .run(email, hashPassword(password), name || '')); - const oid = rowid(db.prepare('INSERT INTO orgs(name,owner_id) VALUES(?,?)') - .run(`${name || email}'s workspace`, uid)); - db.prepare('INSERT INTO memberships(org_id,user_id,role) VALUES(?,?,?)').run(oid, uid, 'owner'); - }; - db.exec('BEGIN'); - try { tx(); db.exec('COMMIT'); } catch (e) { db.exec('ROLLBACK'); throw e; } - metrics.signups++; - return c.json({ token: signToken({ sub: uid, email, tv: 0 }) }); -}); - -app.post('/api/auth/login', async (c) => { - const { email, password } = await c.req.json().catch(() => ({})); - const u = db.prepare('SELECT * FROM users WHERE email = ?').get(email || ''); - // Pass password through only when it is a string — verifyPassword rejects - // non-strings (objects/arrays) so they never match an empty-password hash. - if (!u || typeof password !== 'string' || !verifyPassword(password, u.password_hash)) { - return c.json({ error: 'invalid credentials' }, 401); - } - return c.json({ token: signToken({ sub: u.id, email: u.email, tv: u.token_version }) }); -}); - -app.get('/api/me', requireAuth, (c) => { - const uid = c.get('user').sub; - const user = db.prepare('SELECT id,email,name FROM users WHERE id = ?').get(uid); - const orgs = db.prepare( - `SELECT o.id,o.name,o.plan,m.role FROM orgs o - JOIN memberships m ON m.org_id = o.id WHERE m.user_id = ?` - ).all(uid); - return c.json({ user, orgs }); -}); +function audienceMatches(claims) { + if (typeof claims.aud === 'string') return claims.aud === OIDC_CLIENT_ID; + if (!Array.isArray(claims.aud) || !claims.aud.includes(OIDC_CLIENT_ID)) return false; + return claims.aud.length === 1 + ? (!claims.azp || claims.azp === OIDC_CLIENT_ID) + : claims.azp === OIDC_CLIENT_ID; +} -// Create an additional workspace (org); the creator becomes its owner. -app.post('/api/orgs', requireAuth, async (c) => { - const uid = c.get('user').sub; - const { name } = await c.req.json().catch(() => ({})); - if (!name || !String(name).trim()) return c.json({ error: 'name required' }, 400); - let oid; - db.exec('BEGIN'); +function validateOidcEndpoint(value, label) { + let endpoint; try { - oid = rowid(db.prepare('INSERT INTO orgs(name,owner_id) VALUES(?,?)').run(String(name).trim().slice(0, 120), uid)); - db.prepare('INSERT INTO memberships(org_id,user_id,role) VALUES(?,?,?)').run(oid, uid, 'owner'); - db.exec('COMMIT'); - } catch (e) { db.exec('ROLLBACK'); throw e; } - logAudit(oid, uid, 'org.create', 'org', oid, { name }); - return c.json({ id: oid, name: String(name).trim(), role: 'owner' }); -}); - -app.get('/api/projects', requireAuth, (c) => { - const uid = c.get('user').sub; - const projects = db.prepare( - `SELECT p.id,p.name,p.base_date AS baseDate,p.version,p.org_id AS orgId,p.updated_at AS updatedAt,p.archived - FROM projects p JOIN memberships m ON m.org_id = p.org_id - WHERE m.user_id = ? ORDER BY p.archived ASC, p.updated_at DESC` - ).all(uid); - return c.json({ projects }); -}); - -app.post('/api/projects', requireAuth, async (c) => { - const uid = c.get('user').sub; - const { name, orgId } = await c.req.json().catch(() => ({})); - if (!name) return c.json({ error: 'name required' }, 400); - const org = orgId - ? db.prepare('SELECT o.id FROM orgs o JOIN memberships m ON m.org_id = o.id WHERE o.id = ? AND m.user_id = ?').get(orgId, uid) - : db.prepare('SELECT o.id FROM orgs o JOIN memberships m ON m.org_id = o.id WHERE m.user_id = ? ORDER BY o.id LIMIT 1').get(uid); - if (!org) return c.json({ error: 'no accessible org' }, 400); - if (wouldExceed(db, getOrg(org.id), 'projects')) { - return c.json({ error: 'project limit reached on the Free plan', upgrade: true, limit: PLANS.free.limits.projects }, 402); + endpoint = new URL(String(value || '')); + } catch { + throw new Error(`invalid ${label}`); } - const id = rowid(db.prepare('INSERT INTO projects(org_id,name,created_by) VALUES(?,?,?)').run(org.id, name, uid)); - metrics.projectsCreated++; - logAudit(org.id, uid, 'project.create', 'project', id, { name }); - return c.json({ id, name, version: 1 }); -}); - -app.get('/api/projects/:id', requireAuth, (c) => { - const p = projectAccess(c.get('user').sub, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - return c.json({ id: p.id, name: p.name, orgId: p.org_id, baseDate: p.base_date, methodology: p.methodology || 'waterfall', tasks: JSON.parse(p.tasks_json), version: p.version }); -}); - -app.put('/api/projects/:id', requireAuth, async (c) => { - const uid = c.get('user').sub; - const id = c.req.param('id'); - const p = projectAccess(uid, id); - if (!p) return c.json({ error: 'not found' }, 404); - if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden: viewer role is read-only' }, 403); - const body = await c.req.json().catch(() => ({})); - if (typeof body.version === 'number' && body.version !== p.version) { - return c.json({ error: 'version conflict', current: p.version }, 409); + if (endpoint.username || endpoint.password || endpoint.hash || !endpoint.hostname) { + throw new Error(`invalid ${label}`); } - const tasks = Array.isArray(body.tasks) ? body.tasks : JSON.parse(p.tasks_json); - const version = p.version + 1; - const methodology = ['waterfall', 'agile', 'hybrid'].includes(body.methodology) ? body.methodology : (p.methodology || 'waterfall'); - db.prepare( - "UPDATE projects SET name=?, base_date=?, tasks_json=?, version=?, methodology=?, updated_at=datetime('now') WHERE id=?" - ).run(body.name ?? p.name, body.baseDate ?? p.base_date, JSON.stringify(tasks), version, methodology, id); - logAudit(p.org_id, uid, 'project.update', 'project', id, { version, tasks: tasks.length }); - // Revision history: snapshot every save, keep the last 20 per project. - try { - db.prepare('INSERT OR REPLACE INTO project_revisions(project_id,version,name,base_date,tasks_json,saved_by) VALUES(?,?,?,?,?,?)') - .run(id, version, body.name ?? p.name, body.baseDate ?? p.base_date, JSON.stringify(tasks), uid); - db.prepare('DELETE FROM project_revisions WHERE project_id = ? AND version <= ?').run(id, version - 20); - } catch { /* history must not break saves */ } - deliver(p.org_id, 'project.update', { projectId: Number(id), version, tasks: tasks.length, by: uid }); - broadcast(id, { type: 'update', version, by: uid }); - return c.json({ version }); -}); - -// Task comments: discussion bound to a project (optionally a task). All roles -// can read; write roles can post; author or manage can delete. -app.get('/api/projects/:id/comments', requireAuth, (c) => { - const p = projectAccess(c.get('user').sub, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - const taskId = c.req.query('taskId'); - const comments = (taskId - ? db.prepare(`SELECT cm.id, cm.task_id AS taskId, cm.body, cm.created_at AS createdAt, cm.user_id AS userId, u.email - FROM comments cm LEFT JOIN users u ON u.id = cm.user_id - WHERE cm.project_id = ? AND cm.task_id = ? ORDER BY cm.id DESC LIMIT 100`).all(p.id, taskId) - : db.prepare(`SELECT cm.id, cm.task_id AS taskId, cm.body, cm.created_at AS createdAt, cm.user_id AS userId, u.email - FROM comments cm LEFT JOIN users u ON u.id = cm.user_id - WHERE cm.project_id = ? ORDER BY cm.id DESC LIMIT 100`).all(p.id)); - return c.json({ comments }); -}); - -app.post('/api/projects/:id/comments', requireAuth, async (c) => { - const uid = c.get('user').sub; - const p = projectAccess(uid, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); - const { taskId, body } = await c.req.json().catch(() => ({})); - const text = String(body || '').trim(); - if (!text) return c.json({ error: 'body required' }, 400); - if (text.length > 2000) return c.json({ error: 'comment too long (max 2000)' }, 400); - const cid = rowid(db.prepare('INSERT INTO comments(project_id,task_id,user_id,body) VALUES(?,?,?,?)') - .run(p.id, String(taskId || ''), uid, text)); - logAudit(p.org_id, uid, 'comment.create', 'project', p.id, { commentId: cid, taskId: taskId || null }); - broadcast(p.id, { type: 'comment', commentId: cid, by: uid }); - return c.json({ id: cid }); -}); - -app.delete('/api/projects/:id/comments/:cid', requireAuth, (c) => { - const uid = c.get('user').sub; - const p = projectAccess(uid, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - const cm = db.prepare('SELECT user_id FROM comments WHERE id = ? AND project_id = ?').get(c.req.param('cid'), p.id); - if (!cm) return c.json({ error: 'not found' }, 404); - if (cm.user_id !== uid && !canManage(p.memberRole)) return c.json({ error: 'forbidden' }, 403); - db.prepare('DELETE FROM comments WHERE id = ?').run(c.req.param('cid')); - return c.json({ ok: true }); -}); - -// Revision history: list, inspect, restore. -app.get('/api/projects/:id/revisions', requireAuth, (c) => { - const p = projectAccess(c.get('user').sub, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - const revisions = db.prepare( - `SELECT r.version, r.created_at AS savedAt, u.email AS savedBy FROM project_revisions r - LEFT JOIN users u ON u.id = r.saved_by WHERE r.project_id = ? ORDER BY r.version DESC` - ).all(p.id); - return c.json({ revisions }); -}); - -app.get('/api/projects/:id/revisions/:version', requireAuth, (c) => { - const p = projectAccess(c.get('user').sub, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - const r = db.prepare('SELECT version, name, base_date AS baseDate, tasks_json FROM project_revisions WHERE project_id = ? AND version = ?') - .get(p.id, c.req.param('version')); - if (!r) return c.json({ error: 'not found' }, 404); - return c.json({ version: r.version, name: r.name, baseDate: r.baseDate, tasks: JSON.parse(r.tasks_json) }); -}); - -// Restore = write the old snapshot as a NEW version (history stays linear). -app.post('/api/projects/:id/revisions/:version/restore', requireAuth, (c) => { - const uid = c.get('user').sub; - const id = c.req.param('id'); - const p = projectAccess(uid, id); - if (!p) return c.json({ error: 'not found' }, 404); - if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); - const r = db.prepare('SELECT name, base_date, tasks_json FROM project_revisions WHERE project_id = ? AND version = ?') - .get(id, c.req.param('version')); - if (!r) return c.json({ error: 'not found' }, 404); - const version = p.version + 1; - db.prepare("UPDATE projects SET name=?, base_date=?, tasks_json=?, version=?, updated_at=datetime('now') WHERE id=?") - .run(r.name, r.base_date, r.tasks_json, version, id); + if (isDevelopmentLoopbackHttp(endpoint)) return endpoint.toString(); + if (endpoint.protocol !== 'https:') throw new Error(`invalid ${label}`); try { - db.prepare('INSERT OR REPLACE INTO project_revisions(project_id,version,name,base_date,tasks_json,saved_by) VALUES(?,?,?,?,?,?)') - .run(id, version, r.name, r.base_date, r.tasks_json, uid); - } catch { /* history must not break restore */ } - logAudit(p.org_id, uid, 'project.restore', 'project', id, { from: Number(c.req.param('version')), version }); - broadcast(id, { type: 'update', version, by: uid }); - return c.json({ version }); -}); - -// iCalendar feed: planned tasks as all-day VEVENTs — subscribable from -// Google/Outlook. Calendar apps can't send headers, so accept ?token= (same -// pattern + ceiling as /stream). PATs work via the Authorization header. -app.get('/api/projects/:id/calendar.ics', (c) => { - const header = c.req.header('authorization') || ''; - const raw = header.startsWith('Bearer ') ? header.slice(7) : (c.req.query('token') || ''); - let uid; - if (raw.startsWith('swk_')) { - const row = db.prepare('SELECT user_id FROM api_tokens WHERE token_hash = ?').get(hashApiToken(raw)); - if (!row) return c.json({ error: 'unauthorized' }, 401); - uid = row.user_id; - } else { - try { uid = verifyToken(raw).sub; } catch { return c.json({ error: 'unauthorized' }, 401); } + return validateWebhookRegistrationUrl(endpoint.toString()); + } catch { + throw new Error(`invalid ${label}`); } - const p = projectAccess(uid, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - let tasks = []; - try { tasks = JSON.parse(p.tasks_json); } catch { /* empty */ } - const day = (s) => String(s).replaceAll('-', ''); - const nextDay = (s) => { const d = new Date(s); d.setDate(d.getDate() + 1); return d.toISOString().slice(0, 10).replaceAll('-', ''); }; - const esc = (s) => String(s).replace(/\\/g, '\\\\').replace(/[,;]/g, (m) => `\\${m}`).replace(/\n/g, '\\n'); - const lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//ScopeWeave//KO', 'CALSCALE:GREGORIAN', `X-WR-CALNAME:${esc(p.name)}`]; - for (const t of tasks) { - if (!/^\d{4}-\d{2}-\d{2}$/.test(t.plannedStartDate || '') || !/^\d{4}-\d{2}-\d{2}$/.test(t.plannedEndDate || '')) continue; - lines.push( - 'BEGIN:VEVENT', - `UID:scopeweave-${p.id}-${esc(t.id)}`, - `DTSTART;VALUE=DATE:${day(t.plannedStartDate)}`, - `DTEND;VALUE=DATE:${nextDay(t.plannedEndDate)}`, // DTEND is exclusive - `SUMMARY:${esc(t.name || t.task || t.id)}`, - 'END:VEVENT' - ); +} + +async function fetchOidcEndpoint( + value, + label, + { method = 'GET', headers = {}, body, signal } = {}, +) { + const endpoint = validateOidcEndpoint(value, label); + if (isDevelopmentLoopbackHttp(endpoint)) { + return nativeFetch(new Request(endpoint, { + method, + headers, + body, + redirect: 'error', + signal, + })); } - lines.push('END:VCALENDAR'); - return c.text(lines.join('\r\n') + '\r\n', 200, { - 'content-type': 'text/calendar; charset=utf-8', - 'content-disposition': `attachment; filename="scopeweave-${p.id}.ics"`, + return fetchPublicHttps(endpoint, { + method, + headers, + body, + signal, }); -}); +} -app.get('/api/projects/:id/stream', (c) => { - // EventSource can't send an Authorization header, so accept a query token - // here only. Ceiling: issue a short-lived stream-scoped token before prod so - // full JWTs don't land in URLs / access logs. - const header = c.req.header('authorization') || ''; - const token = header.startsWith('Bearer ') ? header.slice(7) : (c.req.query('token') || ''); - let user; - try { user = verifyToken(token); } catch { return c.json({ error: 'unauthorized' }, 401); } - const id = c.req.param('id'); - if (!projectAccess(user.sub, id)) return c.json({ error: 'not found' }, 404); - const key = String(id); - const stream = new ReadableStream({ - start(controller) { - if (!streams.has(key)) streams.set(key, new Set()); - streams.get(key).add(controller); - controller.enqueue(new TextEncoder().encode(': connected\n\n')); - c.req.raw.signal?.addEventListener('abort', () => { - streams.get(key)?.delete(controller); - try { controller.close(); } catch { /* already closed */ } - }); - }, - }); - return new Response(stream, { - headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' }, +async function oidcProviderJson(url) { + const response = await fetchOidcEndpoint(url, 'provider endpoint', { + signal: AbortSignal.timeout(OIDC_TOKEN_TIMEOUT_MS), }); -}); - -// --------------------------------------------------------------- teams / RBAC -// List members of an org (any member may view the roster). -app.get('/api/orgs/:id/members', requireAuth, (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (!orgRole(uid, orgId)) return c.json({ error: 'not found' }, 404); - const members = db.prepare( - `SELECT u.id, u.email, u.name, m.role FROM memberships m - 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 - WHERE org_id = ? AND accepted_at IS NULL ORDER BY id DESC` - ).all(orgId); - return c.json({ members, invites }); -}); - -// Revoke a pending invite (owner/admin). The token stops working immediately. -app.delete('/api/orgs/:id/invites/:inviteId', requireAuth, (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); - const info = db.prepare('DELETE FROM invites WHERE id = ? AND org_id = ? AND accepted_at IS NULL') - .run(c.req.param('inviteId'), orgId); - if (!info.changes) return c.json({ error: 'not found' }, 404); - logAudit(orgId, uid, 'invite.revoke', 'invite', c.req.param('inviteId'), {}); - return c.json({ ok: true }); -}); - -// Invite 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 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 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 }); -}); + if (!response.ok) throw new Error('provider unavailable'); + const payload = await response.json(); + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) throw new Error('invalid provider response'); + return payload; +} -// Accept an invite (any authenticated user holding the token). Idempotent. -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 existing = orgRole(uid, inv.org_id); - if (!existing) { - if (wouldExceed(db, getOrg(inv.org_id), 'members')) { - return c.json({ error: 'member limit reached on the Free plan', upgrade: true, limit: PLANS.free.limits.members }, 402); - } - db.prepare('INSERT INTO memberships(org_id,user_id,role) VALUES(?,?,?)').run(inv.org_id, uid, inv.role); - logAudit(inv.org_id, uid, 'member.join', 'user', uid, { role: inv.role }); - deliver(inv.org_id, 'member.join', { userId: uid, role: inv.role }); +async function loadOidcDiscovery(now = Date.now()) { + if (oidcDiscoveryCache && oidcDiscoveryCache.expiresAt > now) { + return oidcDiscoveryCache.value; } - db.prepare("UPDATE invites SET accepted_at = datetime('now') WHERE id = ?").run(inv.id); - return c.json({ orgId: inv.org_id, role: existing || inv.role }); -}); - -// Change a member's role (owner/admin). Cannot touch an owner or set owner. -app.patch('/api/orgs/:id/members/:userId', requireAuth, async (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - const targetId = c.req.param('userId'); - if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); - const body = await c.req.json().catch(() => ({})); - const newRole = body.role; - if (!['admin', 'member', 'viewer'].includes(newRole)) return c.json({ error: 'invalid role' }, 400); - const target = db.prepare('SELECT role FROM memberships WHERE org_id = ? AND user_id = ?').get(orgId, targetId); - if (!target) return c.json({ error: 'not found' }, 404); - if (target.role === 'owner') return c.json({ error: 'cannot change owner role' }, 403); - db.prepare('UPDATE memberships SET role = ? WHERE org_id = ? AND user_id = ?').run(newRole, orgId, targetId); - logAudit(orgId, uid, 'member.role_change', 'user', targetId, { from: target.role, to: newRole }); - return c.json({ userId: Number(targetId), role: newRole }); -}); - -// Remove a member (owner/admin). Cannot remove an owner. -app.delete('/api/orgs/:id/members/:userId', requireAuth, (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - const targetId = c.req.param('userId'); - if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); - const target = db.prepare('SELECT role FROM memberships WHERE org_id = ? AND user_id = ?').get(orgId, targetId); - if (!target) return c.json({ error: 'not found' }, 404); - if (target.role === 'owner') return c.json({ error: 'cannot remove owner' }, 403); - db.prepare('DELETE FROM memberships WHERE org_id = ? AND user_id = ?').run(orgId, targetId); - logAudit(orgId, uid, 'member.remove', 'user', targetId, { role: target.role }); - return c.json({ ok: true }); -}); - -// Leave a workspace voluntarily (any non-owner member). Owners must transfer or -// delete the org instead — an org can never be left ownerless. -app.post('/api/orgs/:id/leave', requireAuth, (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 (role === 'owner') return c.json({ error: 'owner cannot leave; delete the workspace or transfer ownership' }, 403); - db.prepare('DELETE FROM memberships WHERE org_id = ? AND user_id = ?').run(orgId, uid); - logAudit(orgId, uid, 'member.leave', 'user', uid, { role }); - return c.json({ ok: true }); -}); - -// Transfer workspace ownership to an existing member (owner only). The old -// owner becomes an admin; orgs.owner_id follows. Transactional. -app.post('/api/orgs/:id/transfer', requireAuth, async (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (orgRole(uid, orgId) !== 'owner') return c.json({ error: 'forbidden' }, 403); - const { userId } = await c.req.json().catch(() => ({})); - if (!userId || Number(userId) === Number(uid)) return c.json({ error: 'target member userId required' }, 400); - const target = db.prepare('SELECT role FROM memberships WHERE org_id = ? AND user_id = ?').get(orgId, userId); - if (!target) return c.json({ error: 'target is not a member' }, 404); - db.exec('BEGIN'); - try { - db.prepare("UPDATE memberships SET role = 'owner' WHERE org_id = ? AND user_id = ?").run(orgId, userId); - db.prepare("UPDATE memberships SET role = 'admin' WHERE org_id = ? AND user_id = ?").run(orgId, uid); - db.prepare('UPDATE orgs SET owner_id = ? WHERE id = ?').run(userId, orgId); - db.exec('COMMIT'); - } catch (e) { db.exec('ROLLBACK'); throw e; } - logAudit(orgId, uid, 'org.transfer', 'user', userId, { from: uid }); - return c.json({ ok: true, newOwnerId: Number(userId) }); -}); - -// Rename a workspace (owner only). -app.patch('/api/orgs/:id', requireAuth, async (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (orgRole(uid, orgId) !== 'owner') return c.json({ error: 'forbidden' }, 403); - const { name } = await c.req.json().catch(() => ({})); - if (!name || !String(name).trim()) return c.json({ error: 'name required' }, 400); - db.prepare('UPDATE orgs SET name = ? WHERE id = ?').run(String(name).trim().slice(0, 120), orgId); - logAudit(orgId, uid, 'org.rename', 'org', orgId, { name }); - return c.json({ id: Number(orgId), name: String(name).trim() }); -}); - -// ------------------------------------------------------------------- billing -app.get('/api/orgs/:id/billing', requireAuth, (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (!orgRole(uid, orgId)) return c.json({ error: 'not found' }, 404); - const org = getOrg(orgId); - const plan = planOf(org); - return c.json({ plan: org.plan, planName: plan.name, priceKrw: plan.priceKrw, limits: plan.limits, usage: orgUsage(db, orgId) }); -}); - -app.post('/api/orgs/:id/checkout', requireAuth, async (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (orgRole(uid, orgId) !== 'owner') return c.json({ error: 'only the owner can upgrade' }, 403); - const origin = new URL(c.req.url).origin; - const session = await createCheckout({ orgId, origin }); - return c.json(session); -}); + const discovery = await oidcProviderJson(`${OIDC_ISSUER}/.well-known/openid-configuration`); + if (discovery.issuer !== OIDC_ISSUER) throw new Error('invalid discovery'); + const value = Object.freeze({ + ...discovery, + authorization_endpoint: validateOidcEndpoint(discovery.authorization_endpoint, 'authorization endpoint'), + token_endpoint: validateOidcEndpoint(discovery.token_endpoint, 'token endpoint'), + jwks_uri: validateOidcEndpoint(discovery.jwks_uri, 'jwks url'), + }); + oidcDiscoveryCache = { value, expiresAt: now + OIDC_DISCOVERY_TTL_MS }; + return value; +} -// Stripe webhook (stub). Live mode should verify the signature with -// STRIPE_WEBHOOK_SECRET before trusting the event — named ceiling. -app.post('/api/stripe/webhook', async (c) => { - const event = await c.req.json().catch(() => ({})); - if (event?.type === 'checkout.session.completed') { - const orgId = event.data?.object?.client_reference_id || event.data?.object?.metadata?.orgId; - if (orgId) db.prepare("UPDATE orgs SET plan = 'pro' WHERE id = ?").run(orgId); +async function loadOidcSigningKey(discovery, kid, now = Date.now()) { + const cacheKey = JSON.stringify([discovery.jwks_uri, kid]); + const cached = oidcSigningKeyCache.get(cacheKey); + if (cached && cached.expiresAt > now) return cached.key; + + const jwks = await oidcProviderJson(discovery.jwks_uri); + const keyData = Array.isArray(jwks.keys) + ? jwks.keys.find((candidate) => ( + candidate + && candidate.kid === kid + && candidate.kty === 'RSA' + && (!candidate.use || candidate.use === 'sig') + && (!candidate.alg || candidate.alg === 'RS256') + )) + : null; + if (!keyData) throw new Error('signing key unavailable'); + const key = createPublicKey({ key: keyData, format: 'jwk' }); + oidcSigningKeyCache.delete(cacheKey); + if (oidcSigningKeyCache.size >= OIDC_SIGNING_KEY_MAX_ENTRIES) { + oidcSigningKeyCache.delete(oidcSigningKeyCache.keys().next().value); } - return c.json({ received: true }); -}); - -// Dev-only: simulate a successful checkout upgrading the org to Pro. -// Disabled unless SCOPEWEAVE_DEV=1 (never reachable in production). -app.post('/api/orgs/:id/_dev/activate-pro', requireAuth, (c) => { - if (process.env.SCOPEWEAVE_DEV !== '1') return c.json({ error: 'not found' }, 404); - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (orgRole(uid, orgId) !== 'owner') return c.json({ error: 'forbidden' }, 403); - db.prepare("UPDATE orgs SET plan = 'pro' WHERE id = ?").run(orgId); - logAudit(orgId, uid, 'billing.upgrade', 'org', orgId, { plan: 'pro', via: 'dev' }); - deliver(orgId, 'billing.upgrade', { plan: 'pro' }); - return c.json({ plan: 'pro' }); -}); - -// ------------------------------------------------- personal access tokens (PAT) -app.get('/api/tokens', requireAuth, (c) => { - const uid = c.get('user').sub; - const tokens = db.prepare( - 'SELECT id, name, prefix, last_used AS lastUsed, created_at AS createdAt FROM api_tokens WHERE user_id = ? ORDER BY id DESC' - ).all(uid); - return c.json({ tokens }); // never the secret or hash -}); - -app.post('/api/tokens', requireAuth, async (c) => { - const uid = c.get('user').sub; - const { name } = await c.req.json().catch(() => ({})); - const t = generateApiToken(); - const id = rowid(db.prepare('INSERT INTO api_tokens(user_id,name,token_hash,prefix) VALUES(?,?,?,?)') - .run(uid, String(name || 'token').slice(0, 60), t.hash, t.prefix)); - // Full secret returned ONCE — never retrievable again. - return c.json({ id, name: name || 'token', prefix: t.prefix, token: t.full }); -}); + oidcSigningKeyCache.set(cacheKey, { + key, + expiresAt: now + OIDC_JWKS_TTL_MS, + }); + return key; +} -app.delete('/api/tokens/:id', requireAuth, (c) => { - const uid = c.get('user').sub; - const info = db.prepare('DELETE FROM api_tokens WHERE id = ? AND user_id = ?').run(c.req.param('id'), uid); - if (!info.changes) return c.json({ error: 'not found' }, 404); - return c.json({ ok: true }); -}); +async function verifyOidcIdToken(idToken, expectedNonce, discovery) { + const parts = String(idToken || '').split('.'); + if (parts.length !== 3) throw new Error('invalid token'); + const [encodedHeader, encodedClaims, encodedSignature] = parts; + const header = parseJwtObject(encodedHeader); + const claims = parseJwtObject(encodedClaims); + if (header.alg !== 'RS256' || typeof header.kid !== 'string' || !header.kid) throw new Error('invalid algorithm'); + + const key = await loadOidcSigningKey(discovery, header.kid); + if (!verifySignature( + 'RSA-SHA256', + Buffer.from(`${encodedHeader}.${encodedClaims}`), + key, + Buffer.from(encodedSignature, 'base64url'), + )) throw new Error('invalid signature'); + + const now = Math.floor(Date.now() / 1000); + if (claims.iss !== OIDC_ISSUER || !audienceMatches(claims)) throw new Error('invalid token binding'); + if (!Number.isInteger(claims.exp) || claims.exp <= now - OIDC_CLOCK_SKEW_SECONDS) throw new Error('expired token'); + if ( + claims.nbf !== undefined + && (!Number.isInteger(claims.nbf) || claims.nbf > now + OIDC_CLOCK_SKEW_SECONDS) + ) throw new Error('token not active'); + if (!Number.isInteger(claims.iat) || claims.iat > now + OIDC_CLOCK_SKEW_SECONDS) throw new Error('invalid issued-at'); + if (typeof claims.sub !== 'string' || !claims.sub || claims.nonce !== expectedNonce) throw new Error('invalid subject or nonce'); + if (typeof claims.email !== 'string' || !claims.email.trim()) throw new Error('missing email'); + if (claims.email !== claims.email.trim()) throw new Error('invalid email claim'); + if (claims.email_verified !== true) throw new Error('unverified email'); + return claims; +} -// Audit trail — owner/admin only. Enterprise requirement. -app.get('/api/orgs/:id/audit', requireAuth, (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); - const limit = Math.min(Number(c.req.query('limit')) || 100, 500); - const rows = db.prepare( - `SELECT a.id, a.action, a.target_type AS targetType, a.target_id AS targetId, a.meta, - a.created_at AS createdAt, u.email AS actorEmail - FROM audit_log a LEFT JOIN users u ON u.id = a.user_id - WHERE a.org_id = ? ORDER BY a.id DESC LIMIT ?` - ).all(orgId, limit); - const events = rows.map((r) => ({ ...r, meta: r.meta ? JSON.parse(r.meta) : null })); - if (c.req.query('format') === 'csv') { - // Compliance deliverable. Formula-injection-safe: values that (after optional - // leading whitespace) start with = + - @ | are prefixed with ' so - // spreadsheets treat them as text. Leading whitespace alone used to bypass - // /^[=+\-@|]/ — match the client-side CSV_FORMULA_PREFIX_PATTERN. - const csvCell = (v) => { - let s = v == null ? '' : String(v); - if (/^\s*[=+\-@|]/.test(s)) s = `'${s}`; - return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; +async function boundedOidcFetch(request) { + const body = await request.clone().arrayBuffer(); + const form = new URLSearchParams(new TextDecoder().decode(body)); + const code = form.get('code'); + const expectedNonce = code ? oidcNonceByCode.get(code) : null; + if (!expectedNonce || expectedNonce.exp <= Date.now()) throw new Error('OIDC flow binding unavailable'); + const discovery = await loadOidcDiscovery(); + const signal = AbortSignal.any([ + request.signal, + expectedNonce.callbackSignal, + AbortSignal.timeout(OIDC_TOKEN_TIMEOUT_MS), + ]); + const headers = new Headers(request.headers); + headers.delete('content-length'); + const response = await fetchOidcEndpoint(discovery.token_endpoint, 'token endpoint', { + method: request.method, + headers: Object.fromEntries(headers.entries()), + body: new Uint8Array(body), + signal, + }); + if (!response.ok) return response; + const tokenPayload = await response.clone().json(); + const claims = await verifyOidcIdToken(tokenPayload.id_token, expectedNonce.nonce, discovery); + const identity = { + issuer: claims.iss, + subject: claims.sub, + email: claims.email.trim(), + }; + try { + const prepared = prepareOidcIdentity(identity); + expectedNonce.verifiedIdentity = { + ...identity, + created: prepared.created, + needsFinalization: prepared.needsFinalization, }; - const header = ['id', 'createdAt', 'actorEmail', 'action', 'targetType', 'targetId', 'meta']; - const lines = [header.join(',')]; - for (const e of events) { - lines.push([e.id, e.createdAt, e.actorEmail, e.action, e.targetType, e.targetId, e.meta ? JSON.stringify(e.meta) : ''].map(csvCell).join(',')); + } catch (error) { + if (error instanceof OidcIdentityConflictError) { + expectedNonce.identityConflict = true; } - return c.text(lines.join('\r\n') + '\r\n', 200, { - 'content-type': 'text/csv; charset=utf-8', - 'content-disposition': `attachment; filename="scopeweave-audit-${orgId}.csv"`, - }); + throw error; } - return c.json({ events }); -}); - -// Full workspace export (owner only) — data portability / GDPR. Everything the -// org holds, as one JSON document. -app.get('/api/orgs/:id/export', requireAuth, (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (orgRole(uid, orgId) !== 'owner') return c.json({ error: 'only the owner can export' }, 403); - const org = getOrg(orgId); - const members = db.prepare( - `SELECT u.email, u.name, m.role FROM memberships m JOIN users u ON u.id = m.user_id WHERE m.org_id = ?` - ).all(orgId); - const projects = db.prepare( - 'SELECT id, name, base_date AS baseDate, tasks_json, version, created_at AS createdAt, updated_at AS updatedAt FROM projects WHERE org_id = ?' - ).all(orgId).map((p) => ({ ...p, tasks: JSON.parse(p.tasks_json), tasks_json: undefined })); - const audit = db.prepare( - 'SELECT action, target_type AS targetType, target_id AS targetId, meta, created_at AS createdAt FROM audit_log WHERE org_id = ? ORDER BY id' - ).all(orgId).map((a) => ({ ...a, meta: a.meta ? JSON.parse(a.meta) : null })); - logAudit(orgId, uid, 'org.export', 'org', orgId, { projects: projects.length }); - return c.json({ - exportedAt: new Date().toISOString(), - org: { id: org.id, name: org.name, plan: org.plan }, - members, projects, audit, - }, 200, { 'Content-Disposition': `attachment; filename="scopeweave-org-${orgId}.json"` }); -}); + return response; +} -// Operational metrics (JSON). Ceiling: expose Prometheus text format + gate -// behind an internal token before prod if scraped externally. -app.get('/api/metrics', (c) => { - const sseActive = [...streams.values()].reduce((n, s) => n + s.size, 0); - const all = { ...metrics, sseActive, uptimeSec: Math.round(process.uptime()) }; - if (c.req.query('format') !== 'prometheus') return c.json(all); - // Prometheus text exposition format (0.0.4) — scrape-ready for Grafana/Alerting. - const gauge = new Set(['sseActive', 'uptimeSec']); - const lines = []; - for (const [k, v] of Object.entries(all)) { - if (typeof v !== 'number') continue; // startedAt etc. - const name = `scopeweave_${k.replace(/([A-Z])/g, '_$1').toLowerCase()}`; - lines.push(`# TYPE ${name} ${gauge.has(k) ? 'gauge' : 'counter'}`, `${name} ${v}`); +// app_core delegates its only security-sensitive outbound call sites here. +// Unknown core egress fails closed; unrelated process-wide fetch users retain +// the caller-owned implementation and are never classified by ScopeWeave. +configureSecureOutboundFetch(async (input, init) => { + const effectiveRequest = new Request(input, init); + if (isSignedWebhookRequest(effectiveRequest)) { + return protectedWebhookFetch(effectiveRequest); } - return c.text(lines.join('\n') + '\n', 200, { 'content-type': 'text/plain; version=0.0.4; charset=utf-8' }); -}); - -// ------------------------------------------------------------------- webhooks -app.get('/api/orgs/:id/webhooks', requireAuth, (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); - const webhooks = db.prepare( - `SELECT w.id, w.url, w.events, w.active, w.created_at AS createdAt, - (SELECT ok FROM webhook_deliveries d WHERE d.webhook_id = w.id ORDER BY d.id DESC LIMIT 1) AS lastOk, - (SELECT created_at FROM webhook_deliveries d WHERE d.webhook_id = w.id ORDER BY d.id DESC LIMIT 1) AS lastAt - FROM webhooks w WHERE w.org_id = ? ORDER BY w.id DESC` - ).all(orgId); // secret never returned - return c.json({ webhooks }); -}); - -app.post('/api/orgs/:id/webhooks', requireAuth, async (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); - const { url, events } = await c.req.json().catch(() => ({})); - if (!/^https?:\/\//.test(String(url || ''))) return c.json({ error: 'valid http(s) url required' }, 400); - const secret = `whsec_${randomBytes(24).toString('base64url')}`; - const evs = Array.isArray(events) ? events.join(',') : (events || '*'); - const id = rowid(db.prepare('INSERT INTO webhooks(org_id,url,secret,events) VALUES(?,?,?,?)').run(orgId, url, secret, evs)); - logAudit(orgId, uid, 'webhook.create', 'webhook', id, { url, events: evs }); - return c.json({ id, url, events: evs, secret }); // secret shown once for signature verification -}); - -app.get('/api/orgs/:id/webhooks/:whId/deliveries', requireAuth, (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); - const wh = db.prepare('SELECT id FROM webhooks WHERE id = ? AND org_id = ?').get(c.req.param('whId'), orgId); - if (!wh) return c.json({ error: 'not found' }, 404); - const deliveries = db.prepare( - 'SELECT event, status_code AS statusCode, ok, attempt, created_at AS createdAt FROM webhook_deliveries WHERE webhook_id = ? ORDER BY id DESC LIMIT 50' - ).all(wh.id); - return c.json({ deliveries }); + if (isOidcTokenRequest(effectiveRequest)) { + return boundedOidcFetch(effectiveRequest); + } + throw new Error('unclassified core outbound request'); }); -// Rotate a webhook's signing secret (leak response / periodic hygiene). The new -// secret is returned ONCE; old signatures stop validating immediately. -app.post('/api/orgs/:id/webhooks/:whId/rotate', requireAuth, (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); - const secret = `whsec_${randomBytes(24).toString('base64url')}`; - const info = db.prepare('UPDATE webhooks SET secret = ? WHERE id = ? AND org_id = ?').run(secret, c.req.param('whId'), orgId); - if (!info.changes) return c.json({ error: 'not found' }, 404); - logAudit(orgId, uid, 'webhook.rotate', 'webhook', c.req.param('whId'), {}); - return c.json({ id: Number(c.req.param('whId')), secret }); // shown once -}); +function isDevelopmentLoopbackHttp(value) { + if (process.env.SCOPEWEAVE_DEV !== '1') return false; + try { + const url = new URL(String(value || '')); + const host = url.hostname.replace(/^\[|\]$/g, '').toLowerCase(); + return url.protocol === 'http:' + && !url.username + && !url.password + && !url.hash + && (host === 'localhost' || host === '127.0.0.1' || host === '::1'); + } catch { + return false; + } +} -app.delete('/api/orgs/:id/webhooks/:whId', requireAuth, (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); - const info = db.prepare('DELETE FROM webhooks WHERE id = ? AND org_id = ?').run(c.req.param('whId'), orgId); - if (!info.changes) return c.json({ error: 'not found' }, 404); - return c.json({ ok: true }); -}); +function requestWithJson(request, payload) { + const headers = new Headers(request.headers); + headers.delete('content-length'); + headers.set('content-type', 'application/json'); + return new Request(request.url, { + method: request.method, + headers, + body: JSON.stringify(payload), + signal: request.signal, + }); +} -// ------------------------------------------------------------ SSO (OIDC) -// Real IdP via env (OIDC_ISSUER/CLIENT_ID/CLIENT_SECRET/REDIRECT_URI). When -// unset, a built-in mock provider makes the whole flow self-contained + testable. -const OIDC = { - issuer: process.env.OIDC_ISSUER, - clientId: process.env.OIDC_CLIENT_ID, - clientSecret: process.env.OIDC_CLIENT_SECRET, - redirectUri: process.env.OIDC_REDIRECT_URI, -}; -const oidcMock = !OIDC.issuer; -const oidcStates = new Map(); // state -> { verifier, exp } -const oidcCodes = new Map(); // mock only: code -> email +async function canonicalInboundRequest(request) { + const url = new URL(request.url); + + if (request.method === 'GET' && AUDIT_PATH.test(url.pathname)) { + const rawLimit = url.searchParams.get('limit'); + if (rawLimit !== null) { + const requested = Number(rawLimit); + const limit = Number.isFinite(requested) && requested > 0 + ? Math.min(Math.floor(requested), 500) + : 100; + if (String(limit) !== rawLimit) { + url.searchParams.set('limit', String(limit)); + return new Request(url, request); + } + } + } -function upsertSsoUser(email) { - let user = db.prepare('SELECT id, email, token_version FROM users WHERE email = ?').get(email); - if (user) return user; - db.exec('BEGIN'); - try { - const uid = rowid(db.prepare('INSERT INTO users(email,password_hash,name) VALUES(?,?,?)') - .run(email, hashPassword(randomBytes(24).toString('hex')), '')); - const oid = rowid(db.prepare('INSERT INTO orgs(name,owner_id) VALUES(?,?)').run(`${email}'s workspace`, uid)); - db.prepare('INSERT INTO memberships(org_id,user_id,role) VALUES(?,?,?)').run(oid, uid, 'owner'); - db.exec('COMMIT'); - metrics.signups++; - return { id: uid, email }; - } catch (e) { db.exec('ROLLBACK'); throw e; } + return request; } -app.get('/api/auth/oidc/start', (c) => { - const origin = new URL(c.req.url).origin; - const state = randomBytes(16).toString('hex'); - const verifier = randomBytes(32).toString('base64url'); - const challenge = createHash('sha256').update(verifier).digest('base64url'); - oidcStates.set(state, { verifier, exp: Date.now() + 5 * 60 * 1000 }); - const redirectUri = OIDC.redirectUri || `${origin}/api/auth/oidc/callback`; - if (oidcMock) { - const email = c.req.query('email') || 'sso-user@example.com'; - const u = new URL(`${origin}/api/auth/oidc/mock/authorize`); - u.searchParams.set('state', state); - u.searchParams.set('email', email); - u.searchParams.set('redirect_uri', redirectUri); - return c.redirect(u.toString()); +function cleanupOidcNonces(now = Date.now()) { + for (const [state, record] of oidcNonceByState.entries()) { + if (record.exp <= now) oidcNonceByState.delete(state); } - const u = new URL(`${OIDC.issuer.replace(/\/$/, '')}/authorize`); - u.searchParams.set('client_id', OIDC.clientId); - u.searchParams.set('redirect_uri', redirectUri); - u.searchParams.set('response_type', 'code'); - u.searchParams.set('scope', 'openid email profile'); - u.searchParams.set('state', state); - u.searchParams.set('code_challenge', challenge); - u.searchParams.set('code_challenge_method', 'S256'); - return c.redirect(u.toString()); -}); - -// Built-in mock IdP authorize — instantly issues a code (dev/test only). -app.get('/api/auth/oidc/mock/authorize', (c) => { - if (!oidcMock) return c.json({ error: 'mock disabled' }, 404); - const state = c.req.query('state'); - const email = c.req.query('email'); - const redirectUri = c.req.query('redirect_uri'); - const code = randomBytes(16).toString('hex'); - oidcCodes.set(code, email); - const u = new URL(redirectUri); - u.searchParams.set('code', code); - u.searchParams.set('state', state); - return c.redirect(u.toString()); -}); +} -app.get('/api/auth/oidc/callback', async (c) => { - const state = c.req.query('state'); - const code = c.req.query('code'); - const s = oidcStates.get(state); - if (!s || s.exp < Date.now()) return c.json({ error: 'invalid or expired state' }, 400); - oidcStates.delete(state); - let email; - if (oidcMock) { - email = oidcCodes.get(code); - oidcCodes.delete(code); - if (!email) return c.json({ error: 'invalid code' }, 400); - } else { - const redirectUri = OIDC.redirectUri || `${new URL(c.req.url).origin}/api/auth/oidc/callback`; - const tokenRes = await fetch(`${OIDC.issuer.replace(/\/$/, '')}/token`, { - method: 'POST', - headers: { 'content-type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: redirectUri, client_id: OIDC.clientId, client_secret: OIDC.clientSecret, code_verifier: s.verifier }), - }).catch(() => null); - const tok = tokenRes ? await tokenRes.json().catch(() => ({})) : {}; - if (!tok.id_token) return c.json({ error: 'token exchange failed' }, 400); - // Ceiling: verify the id_token signature via the issuer JWKS before prod. - const claims = JSON.parse(Buffer.from(String(tok.id_token).split('.')[1] || '', 'base64url').toString() || '{}'); - email = claims.email; - if (!email) return c.json({ error: 'no email claim' }, 400); +function bindOidcStartNonce(request, response, discovery) { + if (!OIDC_ISSUER || request.method !== 'GET' || new URL(request.url).pathname !== '/api/auth/oidc/start') return response; + if (response.status !== 302) return response; + const location = response.headers.get('location'); + if (!location) return response; + const generatedAuthorization = new URL(location); + const authorization = new URL(discovery.authorization_endpoint); + for (const [name, value] of generatedAuthorization.searchParams.entries()) { + authorization.searchParams.set(name, value); } - const user = upsertSsoUser(email); - const token = signToken({ sub: user.id, email, tv: user.token_version || 0 }); - // Return the token in the URL fragment (not query → not logged); the client - // stores it and cleans the URL. - return c.redirect(`/#token=${token}`); -}); + const state = authorization.searchParams.get('state'); + if (!state) return response; + const nonce = randomBytes(16).toString('base64url'); + oidcNonceByState.set(state, { nonce, exp: Date.now() + OIDC_STATE_TTL_MS }); + authorization.searchParams.set('nonce', nonce); + const headers = new Headers(response.headers); + headers.set('location', authorization.toString()); + return new Response(response.body, { status: response.status, statusText: response.statusText, headers }); +} -// Cross-project search: project names + task names, membership-scoped (tenant -// isolation via the same JOIN as projectAccess). -// ponytail: LIKE over tasks_json text; move to FTS5 if search gets heavy. -app.get('/api/search', requireAuth, (c) => { - const uid = c.get('user').sub; - const q = String(c.req.query('q') || '').trim(); - if (q.length < 2) return c.json({ error: 'query too short (min 2)' }, 400); - const rows = db.prepare( - `SELECT DISTINCT p.id, p.name, p.tasks_json FROM projects p - JOIN memberships m ON m.org_id = p.org_id - WHERE m.user_id = ? AND (p.name LIKE ? OR p.tasks_json LIKE ?) LIMIT 100` - ).all(uid, `%${q}%`, `%${q}%`); - const needle = q.toLowerCase(); - const results = []; - for (const p of rows) { - const hit = { projectId: p.id, projectName: p.name, tasks: [] }; - if (p.name.toLowerCase().includes(needle)) hit.nameMatch = true; - let tasks = []; - try { tasks = JSON.parse(p.tasks_json); } catch { /* skip bad json */ } - for (const t of tasks) { - if (String(t.name || '').toLowerCase().includes(needle)) { - hit.tasks.push({ id: t.id, name: t.name }); - if (hit.tasks.length >= 5) break; - } +async function coreFetchWithOidcBinding(request, rest) { + const startedAt = Date.now(); + const requestUrl = new URL(request.url); + if (!OIDC_ISSUER) { + if ( + process.env.SCOPEWEAVE_DEV !== '1' + && requestUrl.pathname.startsWith('/api/auth/oidc/') + ) { + return observeFacadeResponse( + request, + Response.json({ error: 'not found' }, { status: 404 }), + startedAt, + ); } - if (hit.nameMatch || hit.tasks.length) results.push(hit); - if (results.length >= 20) break; + return coreApp.fetch(request, ...rest); } - return c.json({ query: q, results }); -}); - -// Portfolio dashboard: executive rollup across every project in a workspace — -// weighted planned/actual progress, SPI + status, overdue-task counts. -app.get('/api/orgs/:id/portfolio', requireAuth, (c) => { - const uid = c.get('user').sub; - const orgId = c.req.param('id'); - if (!orgRole(uid, orgId)) return c.json({ error: 'not found' }, 404); - const today = new Date().toISOString().slice(0, 10); - const rows = db.prepare( - 'SELECT id, name, base_date AS baseDate, tasks_json, archived, updated_at AS updatedAt FROM projects WHERE org_id = ? ORDER BY archived ASC, updated_at DESC' - ).all(orgId); - const projects = rows.map((p) => { - let tasks = []; - try { tasks = JSON.parse(p.tasks_json); } catch { /* empty */ } - let wSum = 0, pv = 0, ev = 0, overdue = 0; - for (const t of tasks) { - const w = Number(t.weight) || 1; - wSum += w; - pv += w * ((Number(t.plannedProgress) || 0) / 100); - ev += w * ((Number(t.actualProgress) || 0) / 100); - if (t.plannedEndDate && t.plannedEndDate < today && (Number(t.actualProgress) || 0) < 100) overdue++; + if (request.method !== 'GET') { + return coreApp.fetch(request, ...rest); + } + if (requestUrl.pathname === '/api/auth/oidc/start') { + cleanupOidcNonces(); + if (oidcNonceByState.size >= OIDC_STATE_MAX_ENTRIES) { + return observeFacadeResponse( + request, + Response.json( + { error: 'OIDC temporarily unavailable' }, + { status: 503 }, + ), + startedAt, + ); } - const evm = computeEvm({ pv: wSum ? pv / wSum : 0, ev: wSum ? ev / wSum : 0 }); - return { - id: p.id, - name: p.name, - archived: Boolean(p.archived), - tasks: tasks.length, - planned: Math.round(evm.pv * 1000) / 10, // % - actual: Math.round(evm.ev * 1000) / 10, // % - spi: evm.spi === null ? null : Math.round(evm.spi * 100) / 100, - status: evm.status, - label: evm.label, - overdue, - updatedAt: p.updatedAt, - }; - }); - return c.json({ projects }); -}); - -// AI 브리핑: 프로젝트 스냅샷(요약 지표 + 지연/차주 작업)을 contextual- -// orchestrator(LLM)로 보내 경영진용 리스크 분석을 생성. 원문 데이터는 서버가 -// 요약해 전송하며, LLM 자격은 서버 환경변수에만 존재. -app.post('/api/projects/:id/ai/brief', requireAuth, async (c) => { - const uid = c.get('user').sub; - const p = projectAccess(uid, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - let tasks = []; - try { tasks = JSON.parse(p.tasks_json); } catch { /* empty */ } - const today = new Date().toISOString().slice(0, 10); - let wSum = 0, pv = 0, ev = 0; - const late = [], upcoming = []; - for (const t of tasks) { - const w = Number(t.weight) || 1; - wSum += w; - pv += w * ((Number(t.plannedProgress) || 0) / 100); - ev += w * ((Number(t.actualProgress) || 0) / 100); - const name = t.name || t.task || t.activity || t.phase || t.id; - if (t.plannedEndDate && t.plannedEndDate < today && (Number(t.actualProgress) || 0) < 100) { - late.push(`${name}(계획종료 ${t.plannedEndDate}, 실적 ${Number(t.actualProgress) || 0}%${t.owner ? `, ${t.owner}` : ''})`); - } else if (t.plannedStartDate && t.plannedStartDate >= today) { - upcoming.push(`${name}(${t.plannedStartDate} 시작)`); + let discovery; + try { + discovery = await loadOidcDiscovery(); + } catch { + return observeFacadeResponse( + request, + Response.json({ error: 'OIDC provider unavailable' }, { status: 502 }), + startedAt, + ); } + const response = await coreApp.fetch(request, ...rest); + return bindOidcStartNonce(request, response, discovery); } - const pvPct = wSum ? ((pv / wSum) * 100).toFixed(1) : '0'; - const evPct = wSum ? ((ev / wSum) * 100).toFixed(1) : '0'; - const context = [ - `프로젝트: ${p.name}`, - `작업 수: ${tasks.length} · 계획진척 ${pvPct}% · 실적진척 ${evPct}%`, - `지연 작업(${late.length}): ${late.slice(0, 8).join(' / ') || '없음'}`, - `예정 작업(${upcoming.length}): ${upcoming.slice(0, 5).join(' / ') || '없음'}`, - ].join('\n'); - try { - const analysis = await orchestratorChat([ - { role: 'system', content: '너는 공정관리(schedule control) 전문가다. 주어진 프로젝트 지표를 근거로 한국어 경영진 브리핑을 작성하라: ①일정 상태 한 줄 판정 ②핵심 리스크 2~3개(근거 지표 인용) ③실행 권고 2~3개. 지표에 없는 사실은 만들지 마라.' }, - { role: 'user', content: context }, - ], { - service: 'scopeweave', - account: String(p.org_id), - }); - logAudit(p.org_id, uid, 'ai.brief', 'project', p.id, { tasks: tasks.length }); - return c.json({ analysis }); - } catch (e) { - return c.json({ error: `AI 분석 실패: ${e.message}` }, 502); + if (requestUrl.pathname !== '/api/auth/oidc/callback') { + return coreApp.fetch(request, ...rest); } -}); - -// 산출물 첨부(Clearfolio 통합 문서 뷰어 프록시): 업로드→변환 잡, 목록(+상태 -// 갱신), 서명 아티팩트 열람(302), 삭제. 테넌트 = 조직, 브라우저에는 Clearfolio -// 자격이 절대 노출되지 않음. -const ATTACH_MAX_BYTES = 10 * 1024 * 1024; -const ATTACH_STATUS_CONCURRENCY = normalizeAttachmentStatusConcurrency( - process.env.SCOPEWEAVE_ATTACHMENT_STATUS_CONCURRENCY, -); -const ATTACH_STATUS_TIMEOUT_MS = normalizeAttachmentStatusTimeoutMs( - process.env.SCOPEWEAVE_ATTACHMENT_STATUS_TIMEOUT_MS, -); -const ATTACH_STATUS_BUDGET_MS = normalizeAttachmentStatusBudgetMs( - process.env.SCOPEWEAVE_ATTACHMENT_STATUS_BUDGET_MS, -); -const ATTACHMENT_LIST_COLUMNS = `a.id, a.task_id AS taskId, a.name, a.mime, a.size, - a.job_id AS jobId, a.status, a.created_at AS createdAt, u.email AS uploadedBy`; -const ATTACHMENT_LIST_FROM = - 'FROM attachments a LEFT JOIN users u ON u.id = a.created_by'; -const listAttachmentsStatement = db.prepare( - `SELECT ${ATTACHMENT_LIST_COLUMNS} ${ATTACHMENT_LIST_FROM} - WHERE a.project_id = ? ORDER BY a.id DESC`, -); -const listTaskAttachmentsStatement = db.prepare( - `SELECT ${ATTACHMENT_LIST_COLUMNS} ${ATTACHMENT_LIST_FROM} - WHERE a.project_id = ? AND a.task_id = ? ORDER BY a.id DESC`, -); -const updateAttachmentStatusStatement = db.prepare( - 'UPDATE attachments SET status = ? WHERE id = ?', -); -app.post('/api/projects/:id/attachments', requireAuth, async (c) => { - const uid = c.get('user').sub; - const p = projectAccess(uid, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); - const form = await c.req.formData().catch(() => null); - const file = form?.get('file'); - if (!file || typeof file === 'string') return c.json({ error: 'multipart file required' }, 400); - const taskId = String(form.get('taskId') || ''); - if (/\.(hwp|hwpx)$/i.test(file.name || '')) return c.json({ error: 'HWP/HWPX는 지원되지 않습니다 (Clearfolio 정책)' }, 400); - if (file.size > ATTACH_MAX_BYTES) return c.json({ error: 'file too large (max 10MB)' }, 400); - const bytes = Buffer.from(await file.arrayBuffer()); - let job; + const state = requestUrl.searchParams.get('state'); + const code = requestUrl.searchParams.get('code'); + const record = state ? oidcNonceByState.get(state) : null; + if (state) oidcNonceByState.delete(state); + let codeRecord = null; + if (code && record && record.exp > Date.now()) { + codeRecord = { ...record, callbackSignal: request.signal }; + oidcNonceByCode.set(code, codeRecord); + } try { - job = await submitJob(p.org_id, uid, { name: file.name || 'document', mime: file.type || '', bytes }); - } catch (e) { - return c.json({ error: `문서 변환 제출 실패: ${e.message}` }, 502); + const response = await coreApp.fetch(request, ...rest); + if (codeRecord?.identityConflict) { + return Response.json( + { error: 'federated identity conflicts with an existing account' }, + { status: 409 }, + ); + } + if ( + response.status === 302 + && codeRecord?.verifiedIdentity?.needsFinalization + ) { + try { + finalizeOidcIdentity(codeRecord.verifiedIdentity); + } catch (error) { + if (error instanceof OidcIdentityConflictError) { + return Response.json( + { error: 'federated identity conflicts with an existing account' }, + { status: 409 }, + ); + } + throw error; + } + } + if (response.status === 302 && codeRecord?.verifiedIdentity?.created) { + facadeMetrics.signups += 1; + } + return response; + } finally { + if (code) oidcNonceByCode.delete(code); } - const aid = rowid(db.prepare( - 'INSERT INTO attachments(project_id,task_id,name,mime,size,job_id,status,created_by) VALUES(?,?,?,?,?,?,?,?)' - ).run(p.id, taskId, file.name || 'document', file.type || '', file.size, job.jobId, job.status, uid)); - logAudit(p.org_id, uid, 'attachment.upload', 'project', p.id, { attachmentId: aid, name: file.name, taskId: taskId || null }); - return c.json({ id: aid, status: job.status }); -}); - -app.get('/api/projects/:id/attachments', requireAuth, async (c) => { - const uid = c.get('user').sub; - const p = projectAccess(uid, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); +} - const taskId = c.req.query('taskId'); - const rows = taskId - ? listTaskAttachmentsStatement.all(p.id, taskId) - : listAttachmentsStatement.all(p.id); - await refreshAttachmentStatuses(rows, { - orgId: p.org_id, - userId: uid, - jobStatus, - updateStatus: (status, attachmentId) => - updateAttachmentStatusStatement.run(status, attachmentId), - concurrency: ATTACH_STATUS_CONCURRENCY, - timeoutMs: ATTACH_STATUS_TIMEOUT_MS, - budgetMs: ATTACH_STATUS_BUDGET_MS, - metrics, +function authorizationProbeRequest(request) { + const headers = new Headers(request.headers); + headers.delete('content-length'); + headers.set('content-type', 'application/json'); + const probe = new Request(request.url, { + method: 'POST', + headers, + body: JSON.stringify({ url: '', events: [] }), + signal: request.signal, }); - const attachments = rows.map(({ jobId: _internalJobId, ...publicRow }) => publicRow); - return c.json({ attachments }); -}); - -// 열람: 서명 아티팩트 URL로 302. 새 탭 열기용으로 ?token=도 허용(ics/stream 패턴). -app.get('/api/projects/:id/attachments/:aid/view', (c) => { - const header = c.req.header('authorization') || ''; - const raw = header.startsWith('Bearer ') ? header.slice(7) : (c.req.query('token') || ''); - let uid; - if (raw.startsWith('swk_')) { - const row = db.prepare('SELECT user_id FROM api_tokens WHERE token_hash = ?').get(hashApiToken(raw)); - if (!row) return c.json({ error: 'unauthorized' }, 401); - uid = row.user_id; - } else { - try { - const payload = verifyToken(raw); - const u = db.prepare('SELECT token_version FROM users WHERE id = ?').get(payload.sub); - if (!u || (payload.tv || 0) !== u.token_version) return c.json({ error: 'unauthorized' }, 401); - uid = payload.sub; - } catch { return c.json({ error: 'unauthorized' }, 401); } - } - const p = projectAccess(uid, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - const a = db.prepare('SELECT job_id, status FROM attachments WHERE id = ? AND project_id = ?').get(c.req.param('aid'), p.id); - if (!a) return c.json({ error: 'not found' }, 404); - if (a.status !== 'SUCCEEDED') return c.json({ error: `문서가 아직 준비되지 않았습니다 (${a.status})` }, 409); - return artifactUrl(p.org_id, uid, a.job_id) - .then((url) => c.redirect(url)) - .catch((e) => c.json({ error: `열람 링크 발급 실패: ${e.message}` }, 502)); -}); - -app.delete('/api/projects/:id/attachments/:aid', requireAuth, (c) => { - const uid = c.get('user').sub; - const p = projectAccess(uid, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - const a = db.prepare('SELECT created_by FROM attachments WHERE id = ? AND project_id = ?').get(c.req.param('aid'), p.id); - if (!a) return c.json({ error: 'not found' }, 404); - if (a.created_by !== uid && !canManage(p.memberRole)) return c.json({ error: 'forbidden' }, 403); - db.prepare('DELETE FROM attachments WHERE id = ?').run(c.req.param('aid')); - logAudit(p.org_id, uid, 'attachment.delete', 'project', p.id, { attachmentId: Number(c.req.param('aid')) }); - return c.json({ ok: true }); -}); - -// mock Clearfolio 아티팩트 서빙(dev/test 전용) -if (clearfolioMock) { - app.get('/api/mock-clearfolio/:jobId', (c) => { - const doc = mockArtifact(c.req.param('jobId')); - if (!doc) return c.json({ error: 'not found' }, 404); - return c.body(doc.bytes, 200, { - 'content-type': doc.mime || 'application/octet-stream', - 'content-disposition': `inline; filename="${encodeURIComponent(doc.name)}"`, - }); + Object.defineProperty(probe, authorizationProbeObservabilityKey, { + value: true, + configurable: false, + enumerable: false, + writable: false, }); + return probe; } -// Public read-only share links: a random token grants VIEW access to one -// project (no account needed) — revocable. Never exposes org/member data. -app.post('/api/projects/:id/shares', requireAuth, (c) => { - const uid = c.get('user').sub; - const p = projectAccess(uid, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - if (!canManage(p.memberRole)) return c.json({ error: 'forbidden' }, 403); - const token = randomBytes(18).toString('base64url'); - db.prepare('INSERT INTO share_tokens(project_id, token, created_by) VALUES(?,?,?)').run(p.id, token, uid); - logAudit(p.org_id, uid, 'share.create', 'project', p.id, {}); - return c.json({ token, url: `/?share=${token}` }); -}); - -app.get('/api/projects/:id/shares', requireAuth, (c) => { - const p = projectAccess(c.get('user').sub, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - if (!canManage(p.memberRole)) return c.json({ error: 'forbidden' }, 403); - const shares = db.prepare( - 'SELECT id, token, created_at AS createdAt FROM share_tokens WHERE project_id = ? AND revoked = 0 ORDER BY id DESC' - ).all(p.id); - return c.json({ shares }); -}); - -app.delete('/api/projects/:id/shares/:sid', requireAuth, (c) => { - const uid = c.get('user').sub; - const p = projectAccess(uid, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - if (!canManage(p.memberRole)) return c.json({ error: 'forbidden' }, 403); - const info = db.prepare('UPDATE share_tokens SET revoked = 1 WHERE id = ? AND project_id = ? AND revoked = 0') - .run(c.req.param('sid'), p.id); - if (!info.changes) return c.json({ error: 'not found' }, 404); - logAudit(p.org_id, uid, 'share.revoke', 'project', p.id, { shareId: Number(c.req.param('sid')) }); - return c.json({ ok: true }); -}); - -// Anonymous read via share token — project content only. -app.get('/api/shared/:token', (c) => { - const row = db.prepare( - `SELECT p.name, p.base_date AS baseDate, p.tasks_json FROM share_tokens s - JOIN projects p ON p.id = s.project_id WHERE s.token = ? AND s.revoked = 0` - ).get(c.req.param('token')); - if (!row) return c.json({ error: 'not found' }, 404); - return c.json({ name: row.name, baseDate: row.baseDate, tasks: JSON.parse(row.tasks_json), readOnly: true }); -}); - -// Unseen-activity notifications: per project, count others' saves + comments -// newer than my last-seen mark. Opening a project marks it seen. -app.get('/api/notifications', requireAuth, (c) => { - const uid = c.get('user').sub; - const rows = db.prepare( - `SELECT p.id AS projectId, - (SELECT COUNT(*) FROM project_revisions r WHERE r.project_id = p.id - AND r.saved_by IS NOT NULL AND r.saved_by != ? - AND r.created_at > COALESCE(s.seen_at, '')) AS revisions, - (SELECT COUNT(*) FROM comments cm WHERE cm.project_id = p.id - AND cm.user_id IS NOT NULL AND cm.user_id != ? - AND cm.created_at > COALESCE(s.seen_at, '')) AS comments - FROM projects p - JOIN memberships m ON m.org_id = p.org_id AND m.user_id = ? - LEFT JOIN project_seen s ON s.project_id = p.id AND s.user_id = ?` - ).all(uid, uid, uid, uid); - const notifications = rows - .map((r) => ({ projectId: r.projectId, unseen: r.revisions + r.comments })) - .filter((r) => r.unseen > 0); - return c.json({ notifications }); -}); - -app.post('/api/projects/:id/seen', requireAuth, (c) => { - const uid = c.get('user').sub; - const p = projectAccess(uid, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - db.prepare(`INSERT INTO project_seen(project_id, user_id, seen_at) VALUES(?, ?, datetime('now')) - ON CONFLICT(project_id, user_id) DO UPDATE SET seen_at = datetime('now')`).run(p.id, uid); - return c.json({ ok: true }); -}); +/** + * Run a controlled, side-effect-free webhook registration through the real + * authentication, rate-limit, and tenant-role chain before this facade returns + * a destination-policy error. The synthetic payload is valid JSON but has an + * empty URL, so an authorized manager deterministically reaches the legacy + * pre-insert URL guard and receives 400. Every denial, rate limit, or internal + * failure is propagated unchanged. The internal probe is omitted from customer + * metrics; its core request log remains visible rather than mutating process-wide + * console behavior. secureFetch records the actual customer-visible policy + * outcome after the authorization decision completes. + */ +async function deniedRegistrationAuthorization(request, rest) { + const response = await coreApp.fetch(authorizationProbeRequest(request), ...rest); + if (response.status !== 400) return response; + const payload = await response.clone().json().catch(() => null); + return payload?.error === LEGACY_WEBHOOK_URL_REQUIRED_ERROR ? null : response; +} -// Archive / restore a project (write roles): declutter without deleting. -app.post('/api/projects/:id/archive', requireAuth, async (c) => { - const uid = c.get('user').sub; - const p = projectAccess(uid, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); - const { archived } = await c.req.json().catch(() => ({})); - const flag = archived === false ? 0 : 1; - db.prepare('UPDATE projects SET archived = ? WHERE id = ?').run(flag, p.id); - logAudit(p.org_id, uid, flag ? 'project.archive' : 'project.unarchive', 'project', p.id, {}); - return c.json({ id: p.id, archived: Boolean(flag) }); -}); +function declaredRegistrationBodyTooLarge(request) { + const rawLength = request.headers.get('content-length'); + if (rawLength === null) return false; + const declaredLength = Number(rawLength); + return Number.isFinite(declaredLength) + && declaredLength > WEBHOOK_REGISTRATION_BODY_MAX_BYTES; +} -// Duplicate a project (template use: copy tasks + base date into a new project -// in the same org). Plan caps apply like any create. -app.post('/api/projects/:id/duplicate', requireAuth, async (c) => { - const uid = c.get('user').sub; - const p = projectAccess(uid, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); - if (wouldExceed(db, getOrg(p.org_id), 'projects')) { - return c.json({ error: 'project limit reached on the Free plan', upgrade: true, limit: PLANS.free.limits.projects }, 402); +/** + * Read one JSON request payload with an explicit memory budget. + * + * The original request stream is consumed directly instead of cloning it: a + * cloned stream can let the unread tee branch buffer attacker-controlled data. + * Callers reconstruct the small JSON request only after this bounded read. + */ +async function readBoundedJsonBody(request, maxBytes) { + if (!request.body) return { payload: {}, tooLarge: false }; + const reader = request.body.getReader(); + const chunks = []; + let totalBytes = 0; + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + const chunk = value instanceof Uint8Array ? value : new Uint8Array(value); + totalBytes += chunk.byteLength; + if (totalBytes > maxBytes) { + await reader.cancel().catch(() => {}); + return { payload: {}, tooLarge: true }; + } + chunks.push(chunk); + } + } catch { + return { payload: {}, tooLarge: false }; + } finally { + try { reader.releaseLock(); } catch { /* already released/cancelled */ } } - const { name } = await c.req.json().catch(() => ({})); - const newName = String(name || `${p.name} (복사본)`).slice(0, 120); - const nid = rowid(db.prepare('INSERT INTO projects(org_id,name,base_date,tasks_json,created_by) VALUES(?,?,?,?,?)') - .run(p.org_id, newName, p.base_date, p.tasks_json, uid)); - metrics.projectsCreated++; - logAudit(p.org_id, uid, 'project.duplicate', 'project', nid, { from: p.id, name: newName }); - return c.json({ id: nid, name: newName, version: 1 }); -}); - -// -------------------------------------------------------------- sprints -// Agile/Hybrid: 시간상자(스프린트) CRUD. 작업은 task.sprint(이름)로 배정되고 -// task.storyPoints로 추정된다 — 지표(커밋/완료 포인트, 벨로시티)는 클라이언트 -// 순수 함수(computeSprintStats)가 계산한다. -app.post('/api/projects/:id/sprints', requireAuth, async (c) => { - const uid = c.get('user').sub; - const p = projectAccess(uid, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); - const { name, startDate, endDate, goal } = await c.req.json().catch(() => ({})); - if (!name || !String(name).trim()) return c.json({ error: 'name required' }, 400); - const day = (v) => (/^\d{4}-\d{2}-\d{2}$/.test(String(v || '')) ? v : ''); - const sid = rowid(db.prepare('INSERT INTO sprints(project_id,name,start_date,end_date,goal) VALUES(?,?,?,?,?)') - .run(p.id, String(name).trim().slice(0, 80), day(startDate), day(endDate), String(goal || '').slice(0, 300))); - logAudit(p.org_id, uid, 'sprint.create', 'project', p.id, { sprintId: sid, name }); - return c.json({ id: sid, name: String(name).trim() }); -}); - -app.get('/api/projects/:id/sprints', requireAuth, (c) => { - const p = projectAccess(c.get('user').sub, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - const sprints = db.prepare( - 'SELECT id, name, start_date AS startDate, end_date AS endDate, goal FROM sprints WHERE project_id = ? ORDER BY start_date, id' - ).all(p.id); - return c.json({ sprints, methodology: p.methodology || 'waterfall' }); -}); - -app.delete('/api/projects/:id/sprints/:sid', requireAuth, (c) => { - const uid = c.get('user').sub; - const p = projectAccess(uid, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); - const info = db.prepare('DELETE FROM sprints WHERE id = ? AND project_id = ?').run(c.req.param('sid'), p.id); - if (!info.changes) return c.json({ error: 'not found' }, 404); - return c.json({ ok: true }); -}); -// ------------------------------------------------------------- baselines -// Snapshot a project's current plan as a named baseline (schedule-control: -// compare actuals against the frozen plan later). -app.post('/api/projects/:id/baselines', requireAuth, async (c) => { - const uid = c.get('user').sub; - const id = c.req.param('id'); - const p = projectAccess(uid, id); - if (!p) return c.json({ error: 'not found' }, 404); - if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); - const { name } = await c.req.json().catch(() => ({})); - const bid = rowid(db.prepare('INSERT INTO baselines(project_id,name,base_date,tasks_json,created_by) VALUES(?,?,?,?,?)') - .run(id, String(name || 'Baseline').slice(0, 80), p.base_date, p.tasks_json, uid)); - logAudit(p.org_id, uid, 'baseline.create', 'project', id, { baselineId: bid, name }); - return c.json({ id: bid, name: name || 'Baseline' }); -}); - -app.get('/api/projects/:id/baselines', requireAuth, (c) => { - const p = projectAccess(c.get('user').sub, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - const baselines = db.prepare( - 'SELECT id, name, base_date AS baseDate, created_at AS createdAt FROM baselines WHERE project_id = ? ORDER BY id DESC' - ).all(p.id); - return c.json({ baselines }); -}); + const bytes = new Uint8Array(totalBytes); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + try { + return { + payload: JSON.parse(new TextDecoder().decode(bytes)), + tooLarge: false, + }; + } catch { + return { payload: {}, tooLarge: false }; + } +} -app.get('/api/projects/:id/baselines/:bid', requireAuth, (c) => { - const p = projectAccess(c.get('user').sub, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - const b = db.prepare('SELECT id, name, base_date AS baseDate, tasks_json, created_at AS createdAt FROM baselines WHERE id = ? AND project_id = ?').get(c.req.param('bid'), p.id); - if (!b) return c.json({ error: 'not found' }, 404); - return c.json({ id: b.id, name: b.name, baseDate: b.baseDate, tasks: JSON.parse(b.tasks_json), createdAt: b.createdAt }); -}); +async function authenticationPolicyResult(request) { + const url = new URL(request.url); + if (request.method !== 'POST' || !AUTH_EMAIL_PATH.test(url.pathname)) return null; -app.delete('/api/projects/:id/baselines/:bid', requireAuth, (c) => { - const uid = c.get('user').sub; - const p = projectAccess(uid, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); - const info = db.prepare('DELETE FROM baselines WHERE id = ? AND project_id = ?').run(c.req.param('bid'), p.id); - if (!info.changes) return c.json({ error: 'not found' }, 404); - return c.json({ ok: true }); -}); + const { payload: parsedPayload, tooLarge } = await readBoundedJsonBody( + request, + AUTH_REQUEST_BODY_MAX_BYTES, + ); + if (tooLarge) { + return { + response: Response.json( + { error: 'authentication request body too large' }, + { status: 413 }, + ), + }; + } -// ------------------------------------------------------ account & lifecycle -// Delete a project (write roles). tasks live in the row, so this fully removes it. -app.delete('/api/projects/:id', requireAuth, (c) => { - const uid = c.get('user').sub; - const id = c.req.param('id'); - const p = projectAccess(uid, id); - if (!p) return c.json({ error: 'not found' }, 404); - if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); - db.prepare('DELETE FROM projects WHERE id = ?').run(id); - logAudit(p.org_id, uid, 'project.delete', 'project', id, { name: p.name }); - deliver(p.org_id, 'project.delete', { projectId: Number(id) }); - return c.json({ ok: true }); -}); + let payload = parsedPayload; + if ( + payload + && typeof payload === 'object' + && !Array.isArray(payload) + && typeof payload.email === 'string' + ) { + const trimmedEmail = payload.email.trim(); + const storedMatches = matchingStoredEmails(trimmedEmail); + const email = url.pathname.endsWith('/signup') + ? (storedMatches[0]?.email || trimmedEmail.toLowerCase()) + : (storedMatches.length === 1 ? storedMatches[0].email : trimmedEmail); + if (email !== payload.email) payload = { ...payload, email }; + } + return { request: requestWithJson(request, payload) }; +} -// Log out everywhere: bump token_version → every existing JWT dies. Returns a -// fresh token so THIS device stays signed in. PATs are unaffected. -app.post('/api/auth/logout-all', requireAuth, (c) => { - const uid = c.get('user').sub; - db.prepare('UPDATE users SET token_version = token_version + 1 WHERE id = ?').run(uid); - const u = db.prepare('SELECT email, token_version FROM users WHERE id = ?').get(uid); - return c.json({ ok: true, token: signToken({ sub: uid, email: u.email, tv: u.token_version }) }); -}); +function canonicalRegistrationRequest(request, payload, canonicalUrl) { + return requestWithJson(request, { ...payload, url: canonicalUrl }); +} -// Change password (verifies the current one). -app.post('/api/auth/change-password', requireAuth, async (c) => { - const uid = c.get('user').sub; - const { oldPassword, newPassword } = await c.req.json().catch(() => ({})); - if (typeof newPassword !== 'string' || newPassword.length < 8) return c.json({ error: 'new password (min 8) required' }, 400); - const u = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(uid); - if (!u || typeof oldPassword !== 'string' || !verifyPassword(oldPassword, u.password_hash)) { - return c.json({ error: 'current password incorrect' }, 403); +async function registrationPolicyResult(request, rest) { + const url = new URL(request.url); + if (request.method !== 'POST' || !WEBHOOK_REGISTRATION_PATH.test(url.pathname)) { + return null; } - db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hashPassword(newPassword), uid); - return c.json({ ok: true }); -}); -// Delete account (GDPR). Removes owned workspaces (cascading their data) and the -// user. Requires the current password to confirm. -app.delete('/api/account', requireAuth, async (c) => { - const uid = c.get('user').sub; - const { password } = await c.req.json().catch(() => ({})); - const u = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(uid); - if (!u || typeof password !== 'string' || !verifyPassword(password, u.password_hash)) { - return c.json({ error: 'password required to delete account' }, 403); + // Requests with no credential material can be rejected by the core + // requireAuth middleware without touching their body at all. Credential-bearing + // requests may still be forged, so any pre-auth policy read remains bounded. + if (!request.headers.get('authorization')) return { request }; + + let payload; + let tooLarge = declaredRegistrationBodyTooLarge(request); + if (!tooLarge) { + const parsed = await readBoundedJsonBody(request, WEBHOOK_REGISTRATION_BODY_MAX_BYTES); + payload = parsed.payload; + tooLarge = parsed.tooLarge; } - db.exec('BEGIN'); - try { - db.prepare('DELETE FROM orgs WHERE owner_id = ?').run(uid); // cascades projects/members/webhooks/invites/audit - db.prepare('DELETE FROM users WHERE id = ?').run(uid); // cascades memberships/tokens - db.exec('COMMIT'); - } catch (e) { db.exec('ROLLBACK'); throw e; } - return c.json({ ok: true }); -}); -app.get('/api/health', (c) => c.json({ ok: true })); + if (tooLarge) { + const authorization = await deniedRegistrationAuthorization(request, rest); + if (authorization) return { response: authorization }; + return { + response: Response.json( + { error: 'webhook registration body too large' }, + { status: 413 }, + ), + }; + } -// Static client — strict allowlist so server/, data.db, package.json etc. are -// never served. Anything not listed → 404. -const STATIC = { - '/': ['index.html', 'text/html; charset=utf-8'], - '/index.html': ['index.html', 'text/html; charset=utf-8'], - '/404.html': ['404.html', 'text/html; charset=utf-8'], - '/landing.html': ['landing.html', 'text/html; charset=utf-8'], - '/landing.en.html': ['landing.en.html', 'text/html; charset=utf-8'], - '/docs/api.md': ['docs/api.md', 'text/markdown; charset=utf-8'], - '/robots.txt': ['robots.txt', 'text/plain; charset=utf-8'], - '/sitemap.xml': ['sitemap.xml', 'application/xml; charset=utf-8'], - '/pricing': ['landing.html', 'text/html; charset=utf-8'], - '/app.js': ['app.js', 'text/javascript; charset=utf-8'], - '/cloud-sync.js': ['cloud-sync.js', 'text/javascript; charset=utf-8'], - '/analytics.js': ['analytics.js', 'text/javascript; charset=utf-8'], - '/styles.css': ['styles.css', 'text/css; charset=utf-8'], - '/toast-state.css': ['toast-state.css', 'text/css; charset=utf-8'], - '/wbs.json': ['wbs.json', 'application/json; charset=utf-8'], -}; -app.get('*', async (c) => { - const entry = STATIC[c.req.path]; - if (!entry) return c.notFound(); try { - const buf = await readFile(new URL(`../${entry[0]}`, import.meta.url)); - return c.body(buf, 200, { 'Content-Type': entry[1] }); - } catch { - return c.notFound(); + const canonicalUrl = validateWebhookRegistrationUrl(payload?.url); + return { + request: canonicalRegistrationRequest(request, payload, canonicalUrl), + }; + } catch (error) { + // Preserve the existing dev-only localhost failure-path smoke fixture. The + // outbound transport still refuses HTTP, so this exception cannot create a + // server-side connection and production never inherits it. + if ( + error instanceof WebhookDestinationError + && isDevelopmentLoopbackHttp(payload?.url) + ) { + return { request: requestWithJson(request, payload) }; + } + const authorization = await deniedRegistrationAuthorization(request, rest); + if (authorization) return { response: authorization }; + return { + response: Response.json( + { error: 'valid public https webhook URL required' }, + { status: 400 }, + ), + }; } -}); +} + +async function secureFetch(request, ...rest) { + const authPolicy = await authenticationPolicyResult(request); + if (authPolicy?.response) return observeFacadeResponse(request, authPolicy.response); + const canonicalRequest = await canonicalInboundRequest(authPolicy?.request || request); + const policy = await registrationPolicyResult(canonicalRequest, rest); + if (policy?.response) return observeFacadeResponse(canonicalRequest, policy.response); + const effectiveRequest = policy?.request || canonicalRequest; + const response = await coreFetchWithOidcBinding(effectiveRequest, rest); + return mergeFacadeMetricsResponse(effectiveRequest, response); +} + +async function secureRequest(input, init, ...rest) { + const request = input instanceof Request + ? (init === undefined ? input : new Request(input, init)) + : new Request(new URL(String(input), 'http://localhost'), init); + return secureFetch(request, ...rest); +} + +// Proxying preserves Hono route/introspection properties for existing callers +// while forcing both server fetches and in-process app.request tests through the +// registration, identity, and request-boundary policies above. +export const app = new Proxy(coreApp, { + get(target, property) { + if (property === 'fetch') return secureFetch; + if (property === 'request') return secureRequest; + const value = Reflect.get(target, property, target); + return typeof value === 'function' ? value.bind(target) : value; + }, +}); \ No newline at end of file diff --git a/server/app_core.mjs b/server/app_core.mjs new file mode 100644 index 00000000..b2d8714e --- /dev/null +++ b/server/app_core.mjs @@ -0,0 +1,1370 @@ +// ScopeWeave SaaS API. Multi-tenant (org-scoped), optimistic concurrency on +// project docs, SSE realtime fan-out per project. The existing static client +// (index.html/app.js) becomes the frontend that talks to these routes. +import { Hono } from 'hono'; +import { readFile } from 'node:fs/promises'; +import { randomBytes, createHmac, createHash } from 'node:crypto'; +import { db, rowid } from './db.mjs'; +import { hashPassword, verifyPassword, signToken, verifyToken, generateApiToken, hashApiToken } from './auth.mjs'; +import { PLANS, planOf, orgUsage, wouldExceed, createCheckout } from './billing.mjs'; +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 { computeEvm } from '../analytics.js'; // pure math, shared with the client + +const getOrg = (id) => db.prepare('SELECT * FROM orgs WHERE id = ?').get(id); + +// Append-only audit trail. Never throws into the request path. +function logAudit(orgId, userId, action, targetType, targetId, meta) { + try { + db.prepare('INSERT INTO audit_log(org_id,user_id,action,target_type,target_id,meta) VALUES(?,?,?,?,?,?)') + .run(orgId, userId ?? null, action, targetType ?? null, targetId != null ? String(targetId) : null, meta ? JSON.stringify(meta) : null); + } catch { /* audit must not break the operation */ } +} + +// --- RBAC. Roles (highest→lowest): owner > admin > member > viewer. +const orgRole = (userId, orgId) => + db.prepare('SELECT role FROM memberships WHERE user_id = ? AND org_id = ?').get(userId, orgId)?.role || null; +const canManage = (role) => role === 'owner' || role === 'admin'; +const canWrite = (role) => role === 'owner' || role === 'admin' || role === 'member'; + +export const app = new Hono(); + +async function requireAuth(c, next) { + const header = c.req.header('authorization') || ''; + const token = header.startsWith('Bearer ') ? header.slice(7) : ''; + // Personal Access Token path (swk_...): look up by hash, act as its user. + if (token.startsWith('swk_')) { + const row = db.prepare('SELECT * FROM api_tokens WHERE token_hash = ?').get(hashApiToken(token)); + if (!row) return c.json({ error: 'unauthorized' }, 401); + db.prepare("UPDATE api_tokens SET last_used = datetime('now') WHERE id = ?").run(row.id); + c.set('user', { sub: row.user_id, viaPat: true }); + return next(); + } + try { + const payload = verifyToken(token); + // Session revocation: a bumped token_version invalidates all older JWTs. + const u = db.prepare('SELECT token_version FROM users WHERE id = ?').get(payload.sub); + if (!u || (payload.tv || 0) !== u.token_version) return c.json({ error: 'unauthorized' }, 401); + c.set('user', payload); + } catch { + return c.json({ error: 'unauthorized' }, 401); + } + await next(); +} + +// --- realtime: projectId -> Set +const streams = new Map(); +function broadcast(projectId, data) { + const subs = streams.get(String(projectId)); + if (!subs) return; + const chunk = new TextEncoder().encode(`data: ${JSON.stringify(data)}\n\n`); + for (const ctrl of subs) { + try { ctrl.enqueue(chunk); } catch { /* dropped subscriber */ } + } +} + +// Membership-scoped project fetch — the tenant isolation boundary. +function projectAccess(userId, projectId) { + return db.prepare( + `SELECT p.*, m.role AS memberRole FROM projects p + JOIN memberships m ON m.org_id = p.org_id + WHERE p.id = ? AND m.user_id = ?` + ).get(projectId, userId); +} + +// --- observability: in-process counters + structured request log. +const metrics = { + startedAt: new Date().toISOString(), + requests: 0, + s2xx: 0, + s4xx: 0, + s5xx: 0, + signups: 0, + projectsCreated: 0, + webhookDeliveries: 0, + attachmentStatusRefreshAttempted: 0, + attachmentStatusRefreshChanged: 0, + attachmentStatusRefreshFailed: 0, + attachmentStatusRefreshDeferred: 0, +}; + +// Outbound webhooks: POST signed JSON to each active hook subscribed to `event`. +// Fire-and-forget with a timeout, one retry on failure, and a recorded outcome +// per attempt — never blocks or fails the triggering request. +function recordDelivery(webhookId, event, status, ok, attempt) { + try { + db.prepare('INSERT INTO webhook_deliveries(webhook_id,event,status_code,ok,attempt) VALUES(?,?,?,?,?)') + .run(webhookId, event, status ?? null, ok ? 1 : 0, attempt); + } catch { /* recording must not break delivery */ } +} + +function sendWebhook(webhookId, url, sig, event, body, attempt) { + metrics.webhookDeliveries++; + const ctrl = new AbortController(); + const to = setTimeout(() => ctrl.abort(), 3000); + fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-scopeweave-event': event, 'x-scopeweave-signature': `sha256=${sig}` }, + body, + signal: ctrl.signal, + }).then((res) => { + recordDelivery(webhookId, event, res.status, res.ok, attempt); + if (!res.ok && attempt < 2) setTimeout(() => sendWebhook(webhookId, url, sig, event, body, attempt + 1), 500); + }).catch(() => { + recordDelivery(webhookId, event, null, false, attempt); + if (attempt < 2) setTimeout(() => sendWebhook(webhookId, url, sig, event, body, attempt + 1), 500); + }).finally(() => clearTimeout(to)); +} + +function deliver(orgId, event, payload) { + let hooks; + try { + hooks = db.prepare('SELECT id, url, secret, events FROM webhooks WHERE org_id = ? AND active = 1').all(orgId); + } catch { return; } + for (const h of hooks) { + const subs = String(h.events || '').split(',').map((s) => s.trim()); + if (!(subs.includes('*') || subs.includes(event))) continue; + const body = JSON.stringify({ event, orgId: Number(orgId), payload, ts: new Date().toISOString() }); + const sig = createHmac('sha256', h.secret).update(body).digest('hex'); + sendWebhook(h.id, h.url, sig, event, body, 1); + } +} +const quietLogs = String(process.env.SCOPEWEAVE_DB || '').includes(':memory:'); // silence during tests +app.use('*', async (c, next) => { + const t = Date.now(); + await next(); + try { + metrics.requests++; + const s = c.res.status; + 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 })); + } + } catch { /* metrics/logging must never break a request */ } +}); + +// Rate limiting (opt-in via SCOPEWEAVE_RATE_LIMIT_MAX, per client IP, fixed +// window). Protects against brute-force/abuse. Off by default so it never +// surprises tests/dev. Ceiling: per-instance in-memory → use Redis for multi-node. +const RL_MAX = Number(process.env.SCOPEWEAVE_RATE_LIMIT_MAX) || 0; +const RL_WINDOW_MS = Number(process.env.SCOPEWEAVE_RATE_LIMIT_WINDOW_MS) || 60000; +const rlBuckets = new Map(); +if (RL_MAX > 0) { + app.use('*', async (c, next) => { + const key = (c.req.header('x-forwarded-for') || '').split(',')[0].trim() || 'local'; + const now = Date.now(); + let b = rlBuckets.get(key); + if (!b || b.resetAt <= now) { b = { count: 0, resetAt: now + RL_WINDOW_MS }; rlBuckets.set(key, b); } + b.count++; + if (b.count > RL_MAX) { + const retry = Math.ceil((b.resetAt - now) / 1000); + return c.json({ error: 'rate limit exceeded' }, 429, { 'Retry-After': String(retry) }); + } + await next(); + }); +} + +app.post('/api/auth/signup', async (c) => { + const { email, password, name } = await c.req.json().catch(() => ({})); + if (!email || typeof password !== 'string' || password.length < 8) { + return c.json({ error: 'email and password (min 8 chars) required' }, 400); + } + if (db.prepare('SELECT id FROM users WHERE email = ?').get(email)) { + return c.json({ error: 'email already registered' }, 409); + } + // user + personal workspace + owner membership, atomically. + let uid; + const tx = () => { + uid = rowid(db.prepare('INSERT INTO users(email,password_hash,name) VALUES(?,?,?)') + .run(email, hashPassword(password), name || '')); + const oid = rowid(db.prepare('INSERT INTO orgs(name,owner_id) VALUES(?,?)') + .run(`${name || email}'s workspace`, uid)); + db.prepare('INSERT INTO memberships(org_id,user_id,role) VALUES(?,?,?)').run(oid, uid, 'owner'); + }; + db.exec('BEGIN'); + try { tx(); db.exec('COMMIT'); } catch (e) { db.exec('ROLLBACK'); throw e; } + metrics.signups++; + return c.json({ token: signToken({ sub: uid, email, tv: 0 }) }); +}); + +app.post('/api/auth/login', async (c) => { + const { email, password } = await c.req.json().catch(() => ({})); + const u = db.prepare('SELECT * FROM users WHERE email = ?').get(email || ''); + // Pass password through only when it is a string — verifyPassword rejects + // non-strings (objects/arrays) so they never match an empty-password hash. + if (!u || typeof password !== 'string' || !verifyPassword(password, u.password_hash)) { + return c.json({ error: 'invalid credentials' }, 401); + } + return c.json({ token: signToken({ sub: u.id, email: u.email, tv: u.token_version }) }); +}); + +app.get('/api/me', requireAuth, (c) => { + const uid = c.get('user').sub; + const user = db.prepare('SELECT id,email,name FROM users WHERE id = ?').get(uid); + const orgs = db.prepare( + `SELECT o.id,o.name,o.plan,m.role FROM orgs o + JOIN memberships m ON m.org_id = o.id WHERE m.user_id = ?` + ).all(uid); + return c.json({ user, orgs }); +}); + +// Create an additional workspace (org); the creator becomes its owner. +app.post('/api/orgs', requireAuth, async (c) => { + const uid = c.get('user').sub; + const { name } = await c.req.json().catch(() => ({})); + if (!name || !String(name).trim()) return c.json({ error: 'name required' }, 400); + let oid; + db.exec('BEGIN'); + try { + oid = rowid(db.prepare('INSERT INTO orgs(name,owner_id) VALUES(?,?)').run(String(name).trim().slice(0, 120), uid)); + db.prepare('INSERT INTO memberships(org_id,user_id,role) VALUES(?,?,?)').run(oid, uid, 'owner'); + db.exec('COMMIT'); + } catch (e) { db.exec('ROLLBACK'); throw e; } + logAudit(oid, uid, 'org.create', 'org', oid, { name }); + return c.json({ id: oid, name: String(name).trim(), role: 'owner' }); +}); + +app.get('/api/projects', requireAuth, (c) => { + const uid = c.get('user').sub; + const projects = db.prepare( + `SELECT p.id,p.name,p.base_date AS baseDate,p.version,p.org_id AS orgId,p.updated_at AS updatedAt,p.archived + FROM projects p JOIN memberships m ON m.org_id = p.org_id + WHERE m.user_id = ? ORDER BY p.archived ASC, p.updated_at DESC` + ).all(uid); + return c.json({ projects }); +}); + +app.post('/api/projects', requireAuth, async (c) => { + const uid = c.get('user').sub; + const { name, orgId } = await c.req.json().catch(() => ({})); + if (!name) return c.json({ error: 'name required' }, 400); + const org = orgId + ? db.prepare('SELECT o.id FROM orgs o JOIN memberships m ON m.org_id = o.id WHERE o.id = ? AND m.user_id = ?').get(orgId, uid) + : db.prepare('SELECT o.id FROM orgs o JOIN memberships m ON m.org_id = o.id WHERE m.user_id = ? ORDER BY o.id LIMIT 1').get(uid); + if (!org) return c.json({ error: 'no accessible org' }, 400); + if (wouldExceed(db, getOrg(org.id), 'projects')) { + return c.json({ error: 'project limit reached on the Free plan', upgrade: true, limit: PLANS.free.limits.projects }, 402); + } + const id = rowid(db.prepare('INSERT INTO projects(org_id,name,created_by) VALUES(?,?,?)').run(org.id, name, uid)); + metrics.projectsCreated++; + logAudit(org.id, uid, 'project.create', 'project', id, { name }); + return c.json({ id, name, version: 1 }); +}); + +app.get('/api/projects/:id', requireAuth, (c) => { + const p = projectAccess(c.get('user').sub, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + return c.json({ id: p.id, name: p.name, orgId: p.org_id, baseDate: p.base_date, methodology: p.methodology || 'waterfall', tasks: JSON.parse(p.tasks_json), version: p.version }); +}); + +app.put('/api/projects/:id', requireAuth, async (c) => { + const uid = c.get('user').sub; + const id = c.req.param('id'); + const p = projectAccess(uid, id); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden: viewer role is read-only' }, 403); + const body = await c.req.json().catch(() => ({})); + if (typeof body.version === 'number' && body.version !== p.version) { + return c.json({ error: 'version conflict', current: p.version }, 409); + } + const tasks = Array.isArray(body.tasks) ? body.tasks : JSON.parse(p.tasks_json); + const version = p.version + 1; + const methodology = ['waterfall', 'agile', 'hybrid'].includes(body.methodology) ? body.methodology : (p.methodology || 'waterfall'); + db.prepare( + "UPDATE projects SET name=?, base_date=?, tasks_json=?, version=?, methodology=?, updated_at=datetime('now') WHERE id=?" + ).run(body.name ?? p.name, body.baseDate ?? p.base_date, JSON.stringify(tasks), version, methodology, id); + logAudit(p.org_id, uid, 'project.update', 'project', id, { version, tasks: tasks.length }); + // Revision history: snapshot every save, keep the last 20 per project. + try { + db.prepare('INSERT OR REPLACE INTO project_revisions(project_id,version,name,base_date,tasks_json,saved_by) VALUES(?,?,?,?,?,?)') + .run(id, version, body.name ?? p.name, body.baseDate ?? p.base_date, JSON.stringify(tasks), uid); + db.prepare('DELETE FROM project_revisions WHERE project_id = ? AND version <= ?').run(id, version - 20); + } catch { /* history must not break saves */ } + deliver(p.org_id, 'project.update', { projectId: Number(id), version, tasks: tasks.length, by: uid }); + broadcast(id, { type: 'update', version, by: uid }); + return c.json({ version }); +}); + +// Task comments: discussion bound to a project (optionally a task). All roles +// can read; write roles can post; author or manage can delete. +app.get('/api/projects/:id/comments', requireAuth, (c) => { + const p = projectAccess(c.get('user').sub, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + const taskId = c.req.query('taskId'); + const comments = (taskId + ? db.prepare(`SELECT cm.id, cm.task_id AS taskId, cm.body, cm.created_at AS createdAt, cm.user_id AS userId, u.email + FROM comments cm LEFT JOIN users u ON u.id = cm.user_id + WHERE cm.project_id = ? AND cm.task_id = ? ORDER BY cm.id DESC LIMIT 100`).all(p.id, taskId) + : db.prepare(`SELECT cm.id, cm.task_id AS taskId, cm.body, cm.created_at AS createdAt, cm.user_id AS userId, u.email + FROM comments cm LEFT JOIN users u ON u.id = cm.user_id + WHERE cm.project_id = ? ORDER BY cm.id DESC LIMIT 100`).all(p.id)); + return c.json({ comments }); +}); + +app.post('/api/projects/:id/comments', requireAuth, async (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const { taskId, body } = await c.req.json().catch(() => ({})); + const text = String(body || '').trim(); + if (!text) return c.json({ error: 'body required' }, 400); + if (text.length > 2000) return c.json({ error: 'comment too long (max 2000)' }, 400); + const cid = rowid(db.prepare('INSERT INTO comments(project_id,task_id,user_id,body) VALUES(?,?,?,?)') + .run(p.id, String(taskId || ''), uid, text)); + logAudit(p.org_id, uid, 'comment.create', 'project', p.id, { commentId: cid, taskId: taskId || null }); + broadcast(p.id, { type: 'comment', commentId: cid, by: uid }); + return c.json({ id: cid }); +}); + +app.delete('/api/projects/:id/comments/:cid', requireAuth, (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + const cm = db.prepare('SELECT user_id FROM comments WHERE id = ? AND project_id = ?').get(c.req.param('cid'), p.id); + if (!cm) return c.json({ error: 'not found' }, 404); + if (cm.user_id !== uid && !canManage(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + db.prepare('DELETE FROM comments WHERE id = ?').run(c.req.param('cid')); + return c.json({ ok: true }); +}); + +// Revision history: list, inspect, restore. +app.get('/api/projects/:id/revisions', requireAuth, (c) => { + const p = projectAccess(c.get('user').sub, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + const revisions = db.prepare( + `SELECT r.version, r.created_at AS savedAt, u.email AS savedBy FROM project_revisions r + LEFT JOIN users u ON u.id = r.saved_by WHERE r.project_id = ? ORDER BY r.version DESC` + ).all(p.id); + return c.json({ revisions }); +}); + +app.get('/api/projects/:id/revisions/:version', requireAuth, (c) => { + const p = projectAccess(c.get('user').sub, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + const r = db.prepare('SELECT version, name, base_date AS baseDate, tasks_json FROM project_revisions WHERE project_id = ? AND version = ?') + .get(p.id, c.req.param('version')); + if (!r) return c.json({ error: 'not found' }, 404); + return c.json({ version: r.version, name: r.name, baseDate: r.baseDate, tasks: JSON.parse(r.tasks_json) }); +}); + +// Restore = write the old snapshot as a NEW version (history stays linear). +app.post('/api/projects/:id/revisions/:version/restore', requireAuth, (c) => { + const uid = c.get('user').sub; + const id = c.req.param('id'); + const p = projectAccess(uid, id); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const r = db.prepare('SELECT name, base_date, tasks_json FROM project_revisions WHERE project_id = ? AND version = ?') + .get(id, c.req.param('version')); + if (!r) return c.json({ error: 'not found' }, 404); + const version = p.version + 1; + db.prepare("UPDATE projects SET name=?, base_date=?, tasks_json=?, version=?, updated_at=datetime('now') WHERE id=?") + .run(r.name, r.base_date, r.tasks_json, version, id); + try { + db.prepare('INSERT OR REPLACE INTO project_revisions(project_id,version,name,base_date,tasks_json,saved_by) VALUES(?,?,?,?,?,?)') + .run(id, version, r.name, r.base_date, r.tasks_json, uid); + } catch { /* history must not break restore */ } + logAudit(p.org_id, uid, 'project.restore', 'project', id, { from: Number(c.req.param('version')), version }); + broadcast(id, { type: 'update', version, by: uid }); + return c.json({ version }); +}); + +// iCalendar feed: planned tasks as all-day VEVENTs — subscribable from +// Google/Outlook. Calendar apps can't send headers, so accept ?token= (same +// pattern + ceiling as /stream). PATs work via the Authorization header. +app.get('/api/projects/:id/calendar.ics', (c) => { + const header = c.req.header('authorization') || ''; + const raw = header.startsWith('Bearer ') ? header.slice(7) : (c.req.query('token') || ''); + let uid; + if (raw.startsWith('swk_')) { + const row = db.prepare('SELECT user_id FROM api_tokens WHERE token_hash = ?').get(hashApiToken(raw)); + if (!row) return c.json({ error: 'unauthorized' }, 401); + uid = row.user_id; + } else { + try { uid = verifyToken(raw).sub; } catch { return c.json({ error: 'unauthorized' }, 401); } + } + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + let tasks = []; + try { tasks = JSON.parse(p.tasks_json); } catch { /* empty */ } + const day = (s) => String(s).replaceAll('-', ''); + const nextDay = (s) => { const d = new Date(s); d.setDate(d.getDate() + 1); return d.toISOString().slice(0, 10).replaceAll('-', ''); }; + const esc = (s) => String(s).replace(/\\/g, '\\\\').replace(/[,;]/g, (m) => `\\${m}`).replace(/\n/g, '\\n'); + const lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//ScopeWeave//KO', 'CALSCALE:GREGORIAN', `X-WR-CALNAME:${esc(p.name)}`]; + for (const t of tasks) { + if (!/^\d{4}-\d{2}-\d{2}$/.test(t.plannedStartDate || '') || !/^\d{4}-\d{2}-\d{2}$/.test(t.plannedEndDate || '')) continue; + lines.push( + 'BEGIN:VEVENT', + `UID:scopeweave-${p.id}-${esc(t.id)}`, + `DTSTART;VALUE=DATE:${day(t.plannedStartDate)}`, + `DTEND;VALUE=DATE:${nextDay(t.plannedEndDate)}`, + `SUMMARY:${esc(t.name || t.task || t.id)}`, + 'END:VEVENT' + ); + } + lines.push('END:VCALENDAR'); + return c.text(lines.join('\r\n') + '\r\n', 200, { + 'content-type': 'text/calendar; charset=utf-8', + 'content-disposition': `attachment; filename="scopeweave-${p.id}.ics"`, + }); +}); + +app.get('/api/projects/:id/stream', (c) => { + const header = c.req.header('authorization') || ''; + const token = header.startsWith('Bearer ') ? header.slice(7) : (c.req.query('token') || ''); + let user; + try { user = verifyToken(token); } catch { return c.json({ error: 'unauthorized' }, 401); } + const id = c.req.param('id'); + if (!projectAccess(user.sub, id)) return c.json({ error: 'not found' }, 404); + const key = String(id); + const stream = new ReadableStream({ + start(controller) { + if (!streams.has(key)) streams.set(key, new Set()); + streams.get(key).add(controller); + controller.enqueue(new TextEncoder().encode(': connected\n\n')); + c.req.raw.signal?.addEventListener('abort', () => { + streams.get(key)?.delete(controller); + try { controller.close(); } catch { /* already closed */ } + }); + }, + }); + return new Response(stream, { + headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' }, + }); +}); + +app.get('/api/orgs/:id/members', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (!orgRole(uid, orgId)) return c.json({ error: 'not found' }, 404); + const members = db.prepare( + `SELECT u.id, u.email, u.name, m.role FROM memberships m + 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 + WHERE org_id = ? AND accepted_at IS NULL ORDER BY id DESC` + ).all(orgId); + return c.json({ members, invites }); +}); + +app.delete('/api/orgs/:id/invites/:inviteId', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); + const info = db.prepare('DELETE FROM invites WHERE id = ? AND org_id = ? AND accepted_at IS NULL') + .run(c.req.param('inviteId'), orgId); + if (!info.changes) return c.json({ error: 'not found' }, 404); + logAudit(orgId, uid, 'invite.revoke', 'invite', c.req.param('inviteId'), {}); + return c.json({ ok: true }); +}); + +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 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 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 }); +}); + +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 existing = orgRole(uid, inv.org_id); + if (!existing) { + if (wouldExceed(db, getOrg(inv.org_id), 'members')) { + return c.json({ error: 'member limit reached on the Free plan', upgrade: true, limit: PLANS.free.limits.members }, 402); + } + db.prepare('INSERT INTO memberships(org_id,user_id,role) VALUES(?,?,?)').run(inv.org_id, uid, inv.role); + logAudit(inv.org_id, uid, 'member.join', 'user', uid, { role: inv.role }); + deliver(inv.org_id, 'member.join', { userId: uid, role: inv.role }); + } + db.prepare("UPDATE invites SET accepted_at = datetime('now') WHERE id = ?").run(inv.id); + return c.json({ orgId: inv.org_id, role: existing || inv.role }); +}); + +app.patch('/api/orgs/:id/members/:userId', requireAuth, async (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + const targetId = c.req.param('userId'); + if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); + const body = await c.req.json().catch(() => ({})); + const newRole = body.role; + if (!['admin', 'member', 'viewer'].includes(newRole)) return c.json({ error: 'invalid role' }, 400); + const target = db.prepare('SELECT role FROM memberships WHERE org_id = ? AND user_id = ?').get(orgId, targetId); + if (!target) return c.json({ error: 'not found' }, 404); + if (target.role === 'owner') return c.json({ error: 'cannot change owner role' }, 403); + db.prepare('UPDATE memberships SET role = ? WHERE org_id = ? AND user_id = ?').run(newRole, orgId, targetId); + logAudit(orgId, uid, 'member.role_change', 'user', targetId, { from: target.role, to: newRole }); + return c.json({ userId: Number(targetId), role: newRole }); +}); + +app.delete('/api/orgs/:id/members/:userId', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + const targetId = c.req.param('userId'); + if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); + const target = db.prepare('SELECT role FROM memberships WHERE org_id = ? AND user_id = ?').get(orgId, targetId); + if (!target) return c.json({ error: 'not found' }, 404); + if (target.role === 'owner') return c.json({ error: 'cannot remove owner' }, 403); + db.prepare('DELETE FROM memberships WHERE org_id = ? AND user_id = ?').run(orgId, targetId); + logAudit(orgId, uid, 'member.remove', 'user', targetId, { role: target.role }); + return c.json({ ok: true }); +}); + +app.post('/api/orgs/:id/leave', requireAuth, (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 (role === 'owner') return c.json({ error: 'owner cannot leave; delete the workspace or transfer ownership' }, 403); + db.prepare('DELETE FROM memberships WHERE org_id = ? AND user_id = ?').run(orgId, uid); + logAudit(orgId, uid, 'member.leave', 'user', uid, { role }); + return c.json({ ok: true }); +}); + +app.post('/api/orgs/:id/transfer', requireAuth, async (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (orgRole(uid, orgId) !== 'owner') return c.json({ error: 'forbidden' }, 403); + const { userId } = await c.req.json().catch(() => ({})); + if (!userId || Number(userId) === Number(uid)) return c.json({ error: 'target member userId required' }, 400); + const target = db.prepare('SELECT role FROM memberships WHERE org_id = ? AND user_id = ?').get(orgId, userId); + if (!target) return c.json({ error: 'target is not a member' }, 404); + db.exec('BEGIN'); + try { + db.prepare("UPDATE memberships SET role = 'owner' WHERE org_id = ? AND user_id = ?").run(orgId, userId); + db.prepare("UPDATE memberships SET role = 'admin' WHERE org_id = ? AND user_id = ?").run(orgId, uid); + db.prepare('UPDATE orgs SET owner_id = ? WHERE id = ?').run(userId, orgId); + db.exec('COMMIT'); + } catch (e) { db.exec('ROLLBACK'); throw e; } + logAudit(orgId, uid, 'org.transfer', 'user', userId, { from: uid }); + return c.json({ ok: true, newOwnerId: Number(userId) }); +}); + +app.patch('/api/orgs/:id', requireAuth, async (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (orgRole(uid, orgId) !== 'owner') return c.json({ error: 'forbidden' }, 403); + const { name } = await c.req.json().catch(() => ({})); + if (!name || !String(name).trim()) return c.json({ error: 'name required' }, 400); + db.prepare('UPDATE orgs SET name = ? WHERE id = ?').run(String(name).trim().slice(0, 120), orgId); + logAudit(orgId, uid, 'org.rename', 'org', orgId, { name }); + return c.json({ id: Number(orgId), name: String(name).trim() }); +}); + +app.get('/api/orgs/:id/billing', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (!orgRole(uid, orgId)) return c.json({ error: 'not found' }, 404); + const org = getOrg(orgId); + const plan = planOf(org); + return c.json({ plan: org.plan, planName: plan.name, priceKrw: plan.priceKrw, limits: plan.limits, usage: orgUsage(db, orgId) }); +}); + +app.post('/api/orgs/:id/checkout', requireAuth, async (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (orgRole(uid, orgId) !== 'owner') return c.json({ error: 'only the owner can upgrade' }, 403); + const origin = new URL(c.req.url).origin; + const session = await createCheckout({ orgId, origin }); + return c.json(session); +}); + +app.post('/api/stripe/webhook', async (c) => { + const event = await c.req.json().catch(() => ({})); + if (event?.type === 'checkout.session.completed') { + const orgId = event.data?.object?.client_reference_id || event.data?.object?.metadata?.orgId; + if (orgId) db.prepare("UPDATE orgs SET plan = 'pro' WHERE id = ?").run(orgId); + } + return c.json({ received: true }); +}); + +app.post('/api/orgs/:id/_dev/activate-pro', requireAuth, (c) => { + if (process.env.SCOPEWEAVE_DEV !== '1') return c.json({ error: 'not found' }, 404); + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (orgRole(uid, orgId) !== 'owner') return c.json({ error: 'forbidden' }, 403); + db.prepare("UPDATE orgs SET plan = 'pro' WHERE id = ?").run(orgId); + logAudit(orgId, uid, 'billing.upgrade', 'org', orgId, { plan: 'pro', via: 'dev' }); + deliver(orgId, 'billing.upgrade', { plan: 'pro' }); + return c.json({ plan: 'pro' }); +}); + +app.get('/api/tokens', requireAuth, (c) => { + const uid = c.get('user').sub; + const tokens = db.prepare( + 'SELECT id, name, prefix, last_used AS lastUsed, created_at AS createdAt FROM api_tokens WHERE user_id = ? ORDER BY id DESC' + ).all(uid); + return c.json({ tokens }); +}); + +app.post('/api/tokens', requireAuth, async (c) => { + const uid = c.get('user').sub; + const { name } = await c.req.json().catch(() => ({})); + const t = generateApiToken(); + const id = rowid(db.prepare('INSERT INTO api_tokens(user_id,name,token_hash,prefix) VALUES(?,?,?,?)') + .run(uid, String(name || 'token').slice(0, 60), t.hash, t.prefix)); + return c.json({ id, name: name || 'token', prefix: t.prefix, token: t.full }); +}); + +app.delete('/api/tokens/:id', requireAuth, (c) => { + const uid = c.get('user').sub; + const info = db.prepare('DELETE FROM api_tokens WHERE id = ? AND user_id = ?').run(c.req.param('id'), uid); + if (!info.changes) return c.json({ error: 'not found' }, 404); + return c.json({ ok: true }); +}); + +app.get('/api/orgs/:id/audit', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); + const limit = Math.min(Number(c.req.query('limit')) || 100, 500); + const rows = db.prepare( + `SELECT a.id, a.action, a.target_type AS targetType, a.target_id AS targetId, a.meta, + a.created_at AS createdAt, u.email AS actorEmail + FROM audit_log a LEFT JOIN users u ON u.id = a.user_id + WHERE a.org_id = ? ORDER BY a.id DESC LIMIT ?` + ).all(orgId, limit); + const events = rows.map((r) => ({ ...r, meta: r.meta ? JSON.parse(r.meta) : null })); + if (c.req.query('format') === 'csv') { + const csvCell = (v) => { + let s = v == null ? '' : String(v); + if (/^\s*[=+\-@|]/.test(s)) s = `'${s}`; + return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; + }; + const header = ['id', 'createdAt', 'actorEmail', 'action', 'targetType', 'targetId', 'meta']; + const lines = [header.join(',')]; + for (const e of events) { + lines.push([e.id, e.createdAt, e.actorEmail, e.action, e.targetType, e.targetId, e.meta ? JSON.stringify(e.meta) : ''].map(csvCell).join(',')); + } + return c.text(lines.join('\r\n') + '\r\n', 200, { + 'content-type': 'text/csv; charset=utf-8', + 'content-disposition': `attachment; filename="scopeweave-audit-${orgId}.csv"`, + }); + } + return c.json({ events }); +}); + +app.get('/api/orgs/:id/export', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (orgRole(uid, orgId) !== 'owner') return c.json({ error: 'only the owner can export' }, 403); + const org = getOrg(orgId); + const members = db.prepare( + `SELECT u.email, u.name, m.role FROM memberships m JOIN users u ON u.id = m.user_id WHERE m.org_id = ?` + ).all(orgId); + const projects = db.prepare( + 'SELECT id, name, base_date AS baseDate, tasks_json, version, created_at AS createdAt, updated_at AS updatedAt FROM projects WHERE org_id = ?' + ).all(orgId).map((p) => ({ ...p, tasks: JSON.parse(p.tasks_json), tasks_json: undefined })); + const audit = db.prepare( + 'SELECT action, target_type AS targetType, target_id AS targetId, meta, created_at AS createdAt FROM audit_log WHERE org_id = ? ORDER BY id' + ).all(orgId).map((a) => ({ ...a, meta: a.meta ? JSON.parse(a.meta) : null })); + logAudit(orgId, uid, 'org.export', 'org', orgId, { projects: projects.length }); + return c.json({ + exportedAt: new Date().toISOString(), + org: { id: org.id, name: org.name, plan: org.plan }, + members, projects, audit, + }, 200, { 'Content-Disposition': `attachment; filename="scopeweave-org-${orgId}.json"` }); +}); + +app.get('/api/metrics', (c) => { + const sseActive = [...streams.values()].reduce((n, s) => n + s.size, 0); + const all = { ...metrics, sseActive, uptimeSec: Math.round(process.uptime()) }; + if (c.req.query('format') !== 'prometheus') return c.json(all); + const gauge = new Set(['sseActive', 'uptimeSec']); + const lines = []; + for (const [k, v] of Object.entries(all)) { + if (typeof v !== 'number') continue; + const name = `scopeweave_${k.replace(/([A-Z])/g, '_$1').toLowerCase()}`; + lines.push(`# TYPE ${name} ${gauge.has(k) ? 'gauge' : 'counter'}`, `${name} ${v}`); + } + return c.text(lines.join('\n') + '\n', 200, { 'content-type': 'text/plain; version=0.0.4; charset=utf-8' }); +}); + +app.get('/api/orgs/:id/webhooks', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); + const webhooks = db.prepare( + `SELECT w.id, w.url, w.events, w.active, w.created_at AS createdAt, + (SELECT ok FROM webhook_deliveries d WHERE d.webhook_id = w.id ORDER BY d.id DESC LIMIT 1) AS lastOk, + (SELECT created_at FROM webhook_deliveries d WHERE d.webhook_id = w.id ORDER BY d.id DESC LIMIT 1) AS lastAt + FROM webhooks w WHERE w.org_id = ? ORDER BY w.id DESC` + ).all(orgId); + return c.json({ webhooks }); +}); + +app.post('/api/orgs/:id/webhooks', requireAuth, async (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); + const { url, events } = await c.req.json().catch(() => ({})); + if (!/^https?:\/\//.test(String(url || ''))) return c.json({ error: 'valid http(s) url required' }, 400); + const secret = `whsec_${randomBytes(24).toString('base64url')}`; + const evs = Array.isArray(events) ? events.join(',') : (events || '*'); + const id = rowid(db.prepare('INSERT INTO webhooks(org_id,url,secret,events) VALUES(?,?,?,?)').run(orgId, url, secret, evs)); + logAudit(orgId, uid, 'webhook.create', 'webhook', id, { url, events: evs }); + return c.json({ id, url, events: evs, secret }); +}); + +app.get('/api/orgs/:id/webhooks/:whId/deliveries', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); + const wh = db.prepare('SELECT id FROM webhooks WHERE id = ? AND org_id = ?').get(c.req.param('whId'), orgId); + if (!wh) return c.json({ error: 'not found' }, 404); + const deliveries = db.prepare( + 'SELECT event, status_code AS statusCode, ok, attempt, created_at AS createdAt FROM webhook_deliveries WHERE webhook_id = ? ORDER BY id DESC LIMIT 50' + ).all(wh.id); + return c.json({ deliveries }); +}); + +app.post('/api/orgs/:id/webhooks/:whId/rotate', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); + const secret = `whsec_${randomBytes(24).toString('base64url')}`; + const info = db.prepare('UPDATE webhooks SET secret = ? WHERE id = ? AND org_id = ?').run(secret, c.req.param('whId'), orgId); + if (!info.changes) return c.json({ error: 'not found' }, 404); + logAudit(orgId, uid, 'webhook.rotate', 'webhook', c.req.param('whId'), {}); + return c.json({ id: Number(c.req.param('whId')), secret }); +}); + +app.delete('/api/orgs/:id/webhooks/:whId', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (!canManage(orgRole(uid, orgId))) return c.json({ error: 'forbidden' }, 403); + const info = db.prepare('DELETE FROM webhooks WHERE id = ? AND org_id = ?').run(c.req.param('whId'), orgId); + if (!info.changes) return c.json({ error: 'not found' }, 404); + return c.json({ ok: true }); +}); + +const OIDC = { + issuer: process.env.OIDC_ISSUER, + clientId: process.env.OIDC_CLIENT_ID, + clientSecret: process.env.OIDC_CLIENT_SECRET, + redirectUri: process.env.OIDC_REDIRECT_URI, +}; +const oidcMock = !OIDC.issuer; +const OIDC_STATE_TTL_MS = 5 * 60 * 1000; +const OIDC_STATE_MAX_ENTRIES = 256; +const oidcStates = new Map(); +const oidcCodes = new Map(); + +function cleanupOidcStates(now = Date.now()) { + for (const [state, record] of oidcStates.entries()) { + if (record.exp <= now) oidcStates.delete(state); + } +} + +function upsertSsoUser(email) { + let user = db.prepare('SELECT id, email, token_version FROM users WHERE email = ?').get(email); + if (user) return user; + db.exec('BEGIN'); + try { + const uid = rowid(db.prepare('INSERT INTO users(email,password_hash,name) VALUES(?,?,?)') + .run(email, hashPassword(randomBytes(24).toString('hex')), '')); + const oid = rowid(db.prepare('INSERT INTO orgs(name,owner_id) VALUES(?,?)').run(`${email}'s workspace`, uid)); + db.prepare('INSERT INTO memberships(org_id,user_id,role) VALUES(?,?,?)').run(oid, uid, 'owner'); + db.exec('COMMIT'); + metrics.signups++; + return { id: uid, email }; + } catch (e) { db.exec('ROLLBACK'); throw e; } +} + +app.get('/api/auth/oidc/start', (c) => { + const now = Date.now(); + cleanupOidcStates(now); + if (oidcStates.size >= OIDC_STATE_MAX_ENTRIES) { + return c.json({ error: 'OIDC temporarily unavailable' }, 503); + } + const origin = new URL(c.req.url).origin; + const state = randomBytes(16).toString('hex'); + const verifier = randomBytes(32).toString('base64url'); + const challenge = createHash('sha256').update(verifier).digest('base64url'); + oidcStates.set(state, { verifier, exp: now + OIDC_STATE_TTL_MS }); + const redirectUri = OIDC.redirectUri || `${origin}/api/auth/oidc/callback`; + if (oidcMock) { + const email = c.req.query('email') || 'sso-user@example.com'; + const u = new URL(`${origin}/api/auth/oidc/mock/authorize`); + u.searchParams.set('state', state); + u.searchParams.set('email', email); + u.searchParams.set('redirect_uri', redirectUri); + return c.redirect(u.toString()); + } + const u = new URL(`${OIDC.issuer.replace(/\/$/, '')}/authorize`); + u.searchParams.set('client_id', OIDC.clientId); + u.searchParams.set('redirect_uri', redirectUri); + u.searchParams.set('response_type', 'code'); + u.searchParams.set('scope', 'openid email profile'); + u.searchParams.set('state', state); + u.searchParams.set('code_challenge', challenge); + u.searchParams.set('code_challenge_method', 'S256'); + return c.redirect(u.toString()); +}); + +app.get('/api/auth/oidc/mock/authorize', (c) => { + if (!oidcMock) return c.json({ error: 'mock disabled' }, 404); + const state = c.req.query('state'); + const email = c.req.query('email'); + const redirectUri = c.req.query('redirect_uri'); + const code = randomBytes(16).toString('hex'); + oidcCodes.set(code, email); + const u = new URL(redirectUri); + u.searchParams.set('code', code); + u.searchParams.set('state', state); + return c.redirect(u.toString()); +}); + +app.get('/api/auth/oidc/callback', async (c) => { + const state = c.req.query('state'); + const code = c.req.query('code'); + const s = oidcStates.get(state); + if (!s || s.exp <= Date.now()) { + if (s) oidcStates.delete(state); + return c.json({ error: 'invalid or expired state' }, 400); + } + oidcStates.delete(state); + let email; + if (oidcMock) { + email = oidcCodes.get(code); + oidcCodes.delete(code); + if (!email) return c.json({ error: 'invalid code' }, 400); + } else { + const redirectUri = OIDC.redirectUri || `${new URL(c.req.url).origin}/api/auth/oidc/callback`; + const tokenRes = await fetch(`${OIDC.issuer.replace(/\/$/, '')}/token`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: redirectUri, client_id: OIDC.clientId, client_secret: OIDC.clientSecret, code_verifier: s.verifier }), + }).catch(() => null); + const tok = tokenRes ? await tokenRes.json().catch(() => ({})) : {}; + if (!tok.id_token) return c.json({ error: 'token exchange failed' }, 400); + const claims = JSON.parse(Buffer.from(String(tok.id_token).split('.')[1] || '', 'base64url').toString() || '{}'); + email = claims.email; + if (!email) return c.json({ error: 'no email claim' }, 400); + } + const user = upsertSsoUser(email); + const token = signToken({ sub: user.id, email, tv: user.token_version || 0 }); + return c.redirect(`/#token=${token}`); +}); + +app.get('/api/search', requireAuth, (c) => { + const uid = c.get('user').sub; + const q = String(c.req.query('q') || '').trim(); + if (q.length < 2) return c.json({ error: 'query too short (min 2)' }, 400); + const rows = db.prepare( + `SELECT DISTINCT p.id, p.name, p.tasks_json FROM projects p + JOIN memberships m ON m.org_id = p.org_id + WHERE m.user_id = ? AND (p.name LIKE ? OR p.tasks_json LIKE ?) LIMIT 100` + ).all(uid, `%${q}%`, `%${q}%`); + const needle = q.toLowerCase(); + const results = []; + for (const p of rows) { + const hit = { projectId: p.id, projectName: p.name, tasks: [] }; + if (p.name.toLowerCase().includes(needle)) hit.nameMatch = true; + let tasks = []; + try { tasks = JSON.parse(p.tasks_json); } catch { /* skip bad json */ } + for (const t of tasks) { + if (String(t.name || '').toLowerCase().includes(needle)) { + hit.tasks.push({ id: t.id, name: t.name }); + if (hit.tasks.length >= 5) break; + } + } + if (hit.nameMatch || hit.tasks.length) results.push(hit); + if (results.length >= 20) break; + } + return c.json({ query: q, results }); +}); + +app.get('/api/orgs/:id/portfolio', requireAuth, (c) => { + const uid = c.get('user').sub; + const orgId = c.req.param('id'); + if (!orgRole(uid, orgId)) return c.json({ error: 'not found' }, 404); + const today = new Date().toISOString().slice(0, 10); + const rows = db.prepare( + 'SELECT id, name, base_date AS baseDate, tasks_json, archived, updated_at AS updatedAt FROM projects WHERE org_id = ? ORDER BY archived ASC, updated_at DESC' + ).all(orgId); + const projects = rows.map((p) => { + let tasks = []; + try { tasks = JSON.parse(p.tasks_json); } catch { /* empty */ } + let wSum = 0, pv = 0, ev = 0, overdue = 0; + for (const t of tasks) { + const w = Number(t.weight) || 1; + wSum += w; + pv += w * ((Number(t.plannedProgress) || 0) / 100); + ev += w * ((Number(t.actualProgress) || 0) / 100); + if (t.plannedEndDate && t.plannedEndDate < today && (Number(t.actualProgress) || 0) < 100) overdue++; + } + const evm = computeEvm({ pv: wSum ? pv / wSum : 0, ev: wSum ? ev / wSum : 0 }); + return { + id: p.id, + name: p.name, + archived: Boolean(p.archived), + tasks: tasks.length, + planned: Math.round(evm.pv * 1000) / 10, + actual: Math.round(evm.ev * 1000) / 10, + spi: evm.spi === null ? null : Math.round(evm.spi * 100) / 100, + status: evm.status, + label: evm.label, + overdue, + updatedAt: p.updatedAt, + }; + }); + return c.json({ projects }); +}); + +app.post('/api/projects/:id/ai/brief', requireAuth, async (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + let tasks = []; + try { tasks = JSON.parse(p.tasks_json); } catch { /* empty */ } + const today = new Date().toISOString().slice(0, 10); + let wSum = 0, pv = 0, ev = 0; + const late = [], upcoming = []; + for (const t of tasks) { + const w = Number(t.weight) || 1; + wSum += w; + pv += w * ((Number(t.plannedProgress) || 0) / 100); + ev += w * ((Number(t.actualProgress) || 0) / 100); + const name = t.name || t.task || t.activity || t.phase || t.id; + if (t.plannedEndDate && t.plannedEndDate < today && (Number(t.actualProgress) || 0) < 100) { + late.push(`${name}(계획종료 ${t.plannedEndDate}, 실적 ${Number(t.actualProgress) || 0}%${t.owner ? `, ${t.owner}` : ''})`); + } else if (t.plannedStartDate && t.plannedStartDate >= today) { + upcoming.push(`${name}(${t.plannedStartDate} 시작)`); + } + } + const pvPct = wSum ? ((pv / wSum) * 100).toFixed(1) : '0'; + const evPct = wSum ? ((ev / wSum) * 100).toFixed(1) : '0'; + const context = [ + `프로젝트: ${p.name}`, + `작업 수: ${tasks.length} · 계획진척 ${pvPct}% · 실적진척 ${evPct}%`, + `지연 작업(${late.length}): ${late.slice(0, 8).join(' / ') || '없음'}`, + `예정 작업(${upcoming.length}): ${upcoming.slice(0, 5).join(' / ') || '없음'}`, + ].join('\n'); + try { + const analysis = await orchestratorChat([ + { role: 'system', content: '너는 공정관리(schedule control) 전문가다. 주어진 프로젝트 지표를 근거로 한국어 경영진 브리핑을 작성하라: ①일정 상태 한 줄 판정 ②핵심 리스크 2~3개(근거 지표 인용) ③실행 권고 2~3개. 지표에 없는 사실은 만들지 마라.' }, + { role: 'user', content: context }, + ], { + service: 'scopeweave', + account: String(p.org_id), + }); + logAudit(p.org_id, uid, 'ai.brief', 'project', p.id, { tasks: tasks.length }); + return c.json({ analysis }); + } catch (e) { + return c.json({ error: `AI 분석 실패: ${e.message}` }, 502); + } +}); + +const ATTACH_MAX_BYTES = 10 * 1024 * 1024; + +const ATTACH_STATUS_CONCURRENCY = normalizeAttachmentStatusConcurrency( + process.env.SCOPEWEAVE_ATTACHMENT_STATUS_CONCURRENCY, +); +const ATTACH_STATUS_TIMEOUT_MS = normalizeAttachmentStatusTimeoutMs( + process.env.SCOPEWEAVE_ATTACHMENT_STATUS_TIMEOUT_MS, +); +const ATTACH_STATUS_BUDGET_MS = normalizeAttachmentStatusBudgetMs( + process.env.SCOPEWEAVE_ATTACHMENT_STATUS_BUDGET_MS, +); +const ATTACHMENT_LIST_COLUMNS = `a.id, a.task_id AS taskId, a.name, a.mime, a.size, + a.job_id AS jobId, a.status, a.created_at AS createdAt, u.email AS uploadedBy`; +const ATTACHMENT_LIST_FROM = + 'FROM attachments a LEFT JOIN users u ON u.id = a.created_by'; +const listAttachmentsStatement = db.prepare( + `SELECT ${ATTACHMENT_LIST_COLUMNS} ${ATTACHMENT_LIST_FROM} + WHERE a.project_id = ? ORDER BY a.id DESC`, +); +const listTaskAttachmentsStatement = db.prepare( + `SELECT ${ATTACHMENT_LIST_COLUMNS} ${ATTACHMENT_LIST_FROM} + WHERE a.project_id = ? AND a.task_id = ? ORDER BY a.id DESC`, +); +const updateAttachmentStatusStatement = db.prepare( + 'UPDATE attachments SET status = ? WHERE id = ?', +); +app.post('/api/projects/:id/attachments', requireAuth, async (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const form = await c.req.formData().catch(() => null); + const file = form?.get('file'); + if (!file || typeof file === 'string') return c.json({ error: 'multipart file required' }, 400); + const taskId = String(form.get('taskId') || ''); + if (/\.(hwp|hwpx)$/i.test(file.name || '')) return c.json({ error: 'HWP/HWPX는 지원되지 않습니다 (Clearfolio 정책)' }, 400); + if (file.size > ATTACH_MAX_BYTES) return c.json({ error: 'file too large (max 10MB)' }, 400); + const bytes = Buffer.from(await file.arrayBuffer()); + let job; + try { + job = await submitJob(p.org_id, uid, { name: file.name || 'document', mime: file.type || '', bytes }); + } catch (e) { + return c.json({ error: `문서 변환 제출 실패: ${e.message}` }, 502); + } + const aid = rowid(db.prepare( + 'INSERT INTO attachments(project_id,task_id,name,mime,size,job_id,status,created_by) VALUES(?,?,?,?,?,?,?,?)' + ).run(p.id, taskId, file.name || 'document', file.type || '', file.size, job.jobId, job.status, uid)); + logAudit(p.org_id, uid, 'attachment.upload', 'project', p.id, { attachmentId: aid, name: file.name, taskId: taskId || null }); + return c.json({ id: aid, status: job.status }); +}); + +app.get('/api/projects/:id/attachments', requireAuth, async (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + + const taskId = c.req.query('taskId'); + const rows = taskId + ? listTaskAttachmentsStatement.all(p.id, taskId) + : listAttachmentsStatement.all(p.id); + await refreshAttachmentStatuses(rows, { + orgId: p.org_id, + userId: uid, + jobStatus, + updateStatus: (status, attachmentId) => + updateAttachmentStatusStatement.run(status, attachmentId), + concurrency: ATTACH_STATUS_CONCURRENCY, + timeoutMs: ATTACH_STATUS_TIMEOUT_MS, + budgetMs: ATTACH_STATUS_BUDGET_MS, + metrics, + }); + const attachments = rows.map(({ jobId: _internalJobId, ...publicRow }) => publicRow); + return c.json({ attachments }); +}); + +app.get('/api/projects/:id/attachments/:aid/view', (c) => { + const header = c.req.header('authorization') || ''; + const raw = header.startsWith('Bearer ') ? header.slice(7) : (c.req.query('token') || ''); + let uid; + if (raw.startsWith('swk_')) { + const row = db.prepare('SELECT user_id FROM api_tokens WHERE token_hash = ?').get(hashApiToken(raw)); + if (!row) return c.json({ error: 'unauthorized' }, 401); + uid = row.user_id; + } else { + try { + const payload = verifyToken(raw); + const u = db.prepare('SELECT token_version FROM users WHERE id = ?').get(payload.sub); + if (!u || (payload.tv || 0) !== u.token_version) return c.json({ error: 'unauthorized' }, 401); + uid = payload.sub; + } catch { return c.json({ error: 'unauthorized' }, 401); } + } + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + const a = db.prepare('SELECT job_id, status FROM attachments WHERE id = ? AND project_id = ?').get(c.req.param('aid'), p.id); + if (!a) return c.json({ error: 'not found' }, 404); + if (a.status !== 'SUCCEEDED') return c.json({ error: `문서가 아직 준비되지 않았습니다 (${a.status})` }, 409); + return artifactUrl(p.org_id, uid, a.job_id) + .then((url) => c.redirect(url)) + .catch((e) => c.json({ error: `열람 링크 발급 실패: ${e.message}` }, 502)); +}); + +app.delete('/api/projects/:id/attachments/:aid', requireAuth, (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + const a = db.prepare('SELECT created_by FROM attachments WHERE id = ? AND project_id = ?').get(c.req.param('aid'), p.id); + if (!a) return c.json({ error: 'not found' }, 404); + if (a.created_by !== uid && !canManage(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + db.prepare('DELETE FROM attachments WHERE id = ?').run(c.req.param('aid')); + logAudit(p.org_id, uid, 'attachment.delete', 'project', p.id, { attachmentId: Number(c.req.param('aid')) }); + return c.json({ ok: true }); +}); + +if (clearfolioMock) { + app.get('/api/mock-clearfolio/:jobId', (c) => { + const doc = mockArtifact(c.req.param('jobId')); + if (!doc) return c.json({ error: 'not found' }, 404); + return c.body(doc.bytes, 200, { + 'content-type': doc.mime || 'application/octet-stream', + 'content-disposition': `inline; filename="${encodeURIComponent(doc.name)}"`, + }); + }); +} + +app.post('/api/projects/:id/shares', requireAuth, (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canManage(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const token = randomBytes(18).toString('base64url'); + db.prepare('INSERT INTO share_tokens(project_id, token, created_by) VALUES(?,?,?)').run(p.id, token, uid); + logAudit(p.org_id, uid, 'share.create', 'project', p.id, {}); + return c.json({ token, url: `/?share=${token}` }); +}); + +app.get('/api/projects/:id/shares', requireAuth, (c) => { + const p = projectAccess(c.get('user').sub, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canManage(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const shares = db.prepare( + 'SELECT id, token, created_at AS createdAt FROM share_tokens WHERE project_id = ? AND revoked = 0 ORDER BY id DESC' + ).all(p.id); + return c.json({ shares }); +}); + +app.delete('/api/projects/:id/shares/:sid', requireAuth, (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canManage(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const info = db.prepare('UPDATE share_tokens SET revoked = 1 WHERE id = ? AND project_id = ? AND revoked = 0') + .run(c.req.param('sid'), p.id); + if (!info.changes) return c.json({ error: 'not found' }, 404); + logAudit(p.org_id, uid, 'share.revoke', 'project', p.id, { shareId: Number(c.req.param('sid')) }); + return c.json({ ok: true }); +}); + +app.get('/api/shared/:token', (c) => { + const row = db.prepare( + `SELECT p.name, p.base_date AS baseDate, p.tasks_json FROM share_tokens s + JOIN projects p ON p.id = s.project_id WHERE s.token = ? AND s.revoked = 0` + ).get(c.req.param('token')); + if (!row) return c.json({ error: 'not found' }, 404); + return c.json({ name: row.name, baseDate: row.baseDate, tasks: JSON.parse(row.tasks_json), readOnly: true }); +}); + +app.get('/api/notifications', requireAuth, (c) => { + const uid = c.get('user').sub; + const rows = db.prepare( + `SELECT p.id AS projectId, + (SELECT COUNT(*) FROM project_revisions r WHERE r.project_id = p.id + AND r.saved_by IS NOT NULL AND r.saved_by != ? + AND r.created_at > COALESCE(s.seen_at, '')) AS revisions, + (SELECT COUNT(*) FROM comments cm WHERE cm.project_id = p.id + AND cm.user_id IS NOT NULL AND cm.user_id != ? + AND cm.created_at > COALESCE(s.seen_at, '')) AS comments + FROM projects p + JOIN memberships m ON m.org_id = p.org_id AND m.user_id = ? + LEFT JOIN project_seen s ON s.project_id = p.id AND s.user_id = ?` + ).all(uid, uid, uid, uid); + const notifications = rows + .map((r) => ({ projectId: r.projectId, unseen: r.revisions + r.comments })) + .filter((r) => r.unseen > 0); + return c.json({ notifications }); +}); + +app.post('/api/projects/:id/seen', requireAuth, (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + db.prepare(`INSERT INTO project_seen(project_id, user_id, seen_at) VALUES(?, ?, datetime('now')) + ON CONFLICT(project_id, user_id) DO UPDATE SET seen_at = datetime('now')`).run(p.id, uid); + return c.json({ ok: true }); +}); + +app.post('/api/projects/:id/archive', requireAuth, async (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const { archived } = await c.req.json().catch(() => ({})); + const flag = archived === false ? 0 : 1; + db.prepare('UPDATE projects SET archived = ? WHERE id = ?').run(flag, p.id); + logAudit(p.org_id, uid, flag ? 'project.archive' : 'project.unarchive', 'project', p.id, {}); + return c.json({ id: p.id, archived: Boolean(flag) }); +}); + +app.post('/api/projects/:id/duplicate', requireAuth, async (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + if (wouldExceed(db, getOrg(p.org_id), 'projects')) { + return c.json({ error: 'project limit reached on the Free plan', upgrade: true, limit: PLANS.free.limits.projects }, 402); + } + const { name } = await c.req.json().catch(() => ({})); + const newName = String(name || `${p.name} (복사본)`).slice(0, 120); + const nid = rowid(db.prepare('INSERT INTO projects(org_id,name,base_date,tasks_json,created_by) VALUES(?,?,?,?,?)') + .run(p.org_id, newName, p.base_date, p.tasks_json, uid)); + metrics.projectsCreated++; + logAudit(p.org_id, uid, 'project.duplicate', 'project', nid, { from: p.id, name: newName }); + return c.json({ id: nid, name: newName, version: 1 }); +}); + +app.post('/api/projects/:id/sprints', requireAuth, async (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const { name, startDate, endDate, goal } = await c.req.json().catch(() => ({})); + if (!name || !String(name).trim()) return c.json({ error: 'name required' }, 400); + const day = (v) => (/^\d{4}-\d{2}-\d{2}$/.test(String(v || '')) ? v : ''); + const sid = rowid(db.prepare('INSERT INTO sprints(project_id,name,start_date,end_date,goal) VALUES(?,?,?,?,?)') + .run(p.id, String(name).trim().slice(0, 80), day(startDate), day(endDate), String(goal || '').slice(0, 300))); + logAudit(p.org_id, uid, 'sprint.create', 'project', p.id, { sprintId: sid, name }); + return c.json({ id: sid, name: String(name).trim() }); +}); + +app.get('/api/projects/:id/sprints', requireAuth, (c) => { + const p = projectAccess(c.get('user').sub, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + const sprints = db.prepare( + 'SELECT id, name, start_date AS startDate, end_date AS endDate, goal FROM sprints WHERE project_id = ? ORDER BY start_date, id' + ).all(p.id); + return c.json({ sprints, methodology: p.methodology || 'waterfall' }); +}); + +app.delete('/api/projects/:id/sprints/:sid', requireAuth, (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const info = db.prepare('DELETE FROM sprints WHERE id = ? AND project_id = ?').run(c.req.param('sid'), p.id); + if (!info.changes) return c.json({ error: 'not found' }, 404); + return c.json({ ok: true }); +}); + +app.post('/api/projects/:id/baselines', requireAuth, async (c) => { + const uid = c.get('user').sub; + const id = c.req.param('id'); + const p = projectAccess(uid, id); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const { name } = await c.req.json().catch(() => ({})); + const bid = rowid(db.prepare('INSERT INTO baselines(project_id,name,base_date,tasks_json,created_by) VALUES(?,?,?,?,?)') + .run(id, String(name || 'Baseline').slice(0, 80), p.base_date, p.tasks_json, uid)); + logAudit(p.org_id, uid, 'baseline.create', 'project', id, { baselineId: bid, name }); + return c.json({ id: bid, name: name || 'Baseline' }); +}); + +app.get('/api/projects/:id/baselines', requireAuth, (c) => { + const p = projectAccess(c.get('user').sub, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + const baselines = db.prepare( + 'SELECT id, name, base_date AS baseDate, created_at AS createdAt FROM baselines WHERE project_id = ? ORDER BY id DESC' + ).all(p.id); + return c.json({ baselines }); +}); + +app.get('/api/projects/:id/baselines/:bid', requireAuth, (c) => { + const p = projectAccess(c.get('user').sub, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + const b = db.prepare('SELECT id, name, base_date AS baseDate, tasks_json, created_at AS createdAt FROM baselines WHERE id = ? AND project_id = ?').get(c.req.param('bid'), p.id); + if (!b) return c.json({ error: 'not found' }, 404); + return c.json({ id: b.id, name: b.name, baseDate: b.baseDate, tasks: JSON.parse(b.tasks_json), createdAt: b.createdAt }); +}); + +app.delete('/api/projects/:id/baselines/:bid', requireAuth, (c) => { + const uid = c.get('user').sub; + const p = projectAccess(uid, c.req.param('id')); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + const info = db.prepare('DELETE FROM baselines WHERE id = ? AND project_id = ?').run(c.req.param('bid'), p.id); + if (!info.changes) return c.json({ error: 'not found' }, 404); + return c.json({ ok: true }); +}); + +app.delete('/api/projects/:id', requireAuth, (c) => { + const uid = c.get('user').sub; + const id = c.req.param('id'); + const p = projectAccess(uid, id); + if (!p) return c.json({ error: 'not found' }, 404); + if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); + db.prepare('DELETE FROM projects WHERE id = ?').run(id); + logAudit(p.org_id, uid, 'project.delete', 'project', id, { name: p.name }); + deliver(p.org_id, 'project.delete', { projectId: Number(id) }); + return c.json({ ok: true }); +}); + +app.post('/api/auth/logout-all', requireAuth, (c) => { + const uid = c.get('user').sub; + db.prepare('UPDATE users SET token_version = token_version + 1 WHERE id = ?').run(uid); + const u = db.prepare('SELECT email, token_version FROM users WHERE id = ?').get(uid); + return c.json({ ok: true, token: signToken({ sub: uid, email: u.email, tv: u.token_version }) }); +}); + +app.post('/api/auth/change-password', requireAuth, async (c) => { + const uid = c.get('user').sub; + const { oldPassword, newPassword } = await c.req.json().catch(() => ({})); + if (typeof newPassword !== 'string' || newPassword.length < 8) return c.json({ error: 'new password (min 8) required' }, 400); + const u = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(uid); + if (!u || typeof oldPassword !== 'string' || !verifyPassword(oldPassword, u.password_hash)) { + return c.json({ error: 'current password incorrect' }, 403); + } + db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hashPassword(newPassword), uid); + return c.json({ ok: true }); +}); + +app.delete('/api/account', requireAuth, async (c) => { + const uid = c.get('user').sub; + const { password } = await c.req.json().catch(() => ({})); + const u = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(uid); + if (!u || typeof password !== 'string' || !verifyPassword(password, u.password_hash)) { + return c.json({ error: 'password required to delete account' }, 403); + } + db.exec('BEGIN'); + try { + db.prepare('DELETE FROM orgs WHERE owner_id = ?').run(uid); + db.prepare('DELETE FROM users WHERE id = ?').run(uid); + db.exec('COMMIT'); + } catch (e) { db.exec('ROLLBACK'); throw e; } + return c.json({ ok: true }); +}); + +app.get('/api/health', (c) => c.json({ ok: true })); + +const STATIC = Object.assign(Object.create(null), { + '/': ['index.html', 'text/html; charset=utf-8'], + '/index.html': ['index.html', 'text/html; charset=utf-8'], + '/404.html': ['404.html', 'text/html; charset=utf-8'], + '/landing.html': ['landing.html', 'text/html; charset=utf-8'], + '/landing.en.html': ['landing.en.html', 'text/html; charset=utf-8'], + '/docs/api.md': ['docs/api.md', 'text/markdown; charset=utf-8'], + '/robots.txt': ['robots.txt', 'text/plain; charset=utf-8'], + '/sitemap.xml': ['sitemap.xml', 'application/xml; charset=utf-8'], + '/pricing': ['landing.html', 'text/html; charset=utf-8'], + '/app.js': ['app.js', 'text/javascript; charset=utf-8'], + '/cloud-sync.js': ['cloud-sync.js', 'text/javascript; charset=utf-8'], + '/analytics.js': ['analytics.js', 'text/javascript; charset=utf-8'], + '/styles.css': ['styles.css', 'text/css; charset=utf-8'], + '/toast-state.css': ['toast-state.css', 'text/css; charset=utf-8'], + '/wbs.json': ['wbs.json', 'application/json; charset=utf-8'], +}); +app.get('*', async (c) => { + const entry = STATIC[c.req.path]; + if (!entry) return c.notFound(); + try { + const buf = await readFile(new URL(`../${entry[0]}`, import.meta.url)); + return c.body(buf, 200, { 'Content-Type': entry[1] }); + } catch { + return c.notFound(); + } +}); + +let secureOutboundFetch = async () => { + throw new Error('secure outbound transport is not configured'); +}; + +/** + * Configure the module-local transport used by security-sensitive outbound requests. + * + * The core API intentionally never falls back to the process-wide `globalThis.fetch`. + * The public app facade must provide the SSRF-hardened webhook and OIDC transport + * before serving requests. Direct core imports therefore fail closed instead of + * bypassing destination validation. + */ +export function configureSecureOutboundFetch(nextFetch) { + if (typeof nextFetch !== 'function') { + throw new TypeError('secure outbound transport must be a function'); + } + secureOutboundFetch = nextFetch; +} + +// This lexical binding is resolved by the webhook and OIDC call sites above. +// Keeping it module-local preserves caller-owned process fetch implementations. +const fetch = (input, init) => secureOutboundFetch(input, init); diff --git a/server/db.mjs b/server/db.mjs index 122b70d6..5cdf7dcd 100644 --- a/server/db.mjs +++ b/server/db.mjs @@ -4,10 +4,13 @@ import { DatabaseSync } from 'node:sqlite'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; +import { migrateLegacyWebhookDestinations } from './webhook_legacy_migration.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const dbPath = process.env.SCOPEWEAVE_DB || join(__dirname, '..', 'data.db'); export const db = new DatabaseSync(dbPath); +// Wait briefly for an existing SQLite writer instead of failing startup on a transient lock. +db.exec("PRAGMA busy_timeout = 5000"); db.exec("PRAGMA journal_mode = WAL"); db.exec("PRAGMA foreign_keys = ON"); @@ -64,6 +67,7 @@ CREATE TABLE IF NOT EXISTS webhooks ( secret TEXT NOT NULL, events TEXT NOT NULL DEFAULT '*', active INTEGER NOT NULL DEFAULT 1, + blocked_reason TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_webhooks_org ON webhooks(org_id); @@ -172,10 +176,19 @@ CREATE INDEX IF NOT EXISTS idx_projects_org ON projects(org_id); CREATE INDEX IF NOT EXISTS idx_invites_token ON invites(token); `); -// Migration for pre-existing DBs: add token_version if missing (idempotent). +// Migrations for pre-existing DBs. These ALTERs are intentionally idempotent. try { db.exec('ALTER TABLE users ADD COLUMN token_version INTEGER NOT NULL DEFAULT 0'); } catch { /* already there */ } try { db.exec('ALTER TABLE projects ADD COLUMN archived INTEGER NOT NULL DEFAULT 0'); } catch { /* already there */ } try { db.exec("ALTER TABLE projects ADD COLUMN methodology TEXT NOT NULL DEFAULT 'waterfall'"); } catch { /* already there */ } +try { db.exec('ALTER TABLE webhooks ADD COLUMN blocked_reason TEXT'); } catch { /* already there */ } + +// Reconcile active historical webhook rows against the current deterministic +// registration policy. The helper performs a read-only preflight, so a compliant +// database does not reserve SQLite's single writer during ordinary startup. If a +// row must be disabled, it re-reads under one immediate transaction, records the +// tenant-visible reason/next action, and leaves DNS-backed delivery checks to the +// per-attempt transport boundary. +migrateLegacyWebhookDestinations(db); // node:sqlite returns lastInsertRowid as number|bigint; normalize to Number. -export const rowid = (r) => Number(r.lastInsertRowid); +export const rowid = (r) => Number(r.lastInsertRowid); \ No newline at end of file diff --git a/server/oidc_identity.mjs b/server/oidc_identity.mjs new file mode 100644 index 00000000..378c3632 --- /dev/null +++ b/server/oidc_identity.mjs @@ -0,0 +1,171 @@ +// Durable OpenID Connect identity binding for production authentication. +// +// OpenID Connect only guarantees the pair (issuer, subject) as a stable user +// identifier. Verified email remains useful profile data, but it must not become +// the long-lived account key or implicitly authorize cross-method account linking. +import { randomBytes } from 'node:crypto'; +import { hashPassword } from './auth.mjs'; +import { db, rowid } from './db.mjs'; + +db.exec(` +CREATE TABLE IF NOT EXISTS oidc_identity_links ( + id INTEGER PRIMARY KEY, + issuer_url TEXT NOT NULL, + subject_identifier TEXT NOT NULL, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + email_at_link TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(issuer_url, subject_identifier), + UNIQUE(issuer_url, user_id) +); +CREATE INDEX IF NOT EXISTS idx_oidc_identity_user + ON oidc_identity_links(user_id, issuer_url); +`); + +/** Error raised when a verified federated identity conflicts with a prior binding. */ +export class OidcIdentityConflictError extends Error { + constructor(message = 'federated identity conflicts with an existing account') { + super(message); + this.name = 'OidcIdentityConflictError'; + } +} + +function validatedIdentity(identity) { + const issuer = String(identity?.issuer || '').trim(); + const subject = String(identity?.subject || '').trim(); + const email = String(identity?.email || '').trim(); + if (!issuer || !subject || !email) throw new Error('invalid verified OIDC identity'); + return { issuer, subject, email }; +} + +function matchingUsers(email) { + return db.prepare( + 'SELECT id, email, token_version FROM users WHERE email = ? COLLATE NOCASE ORDER BY id LIMIT 2', + ).all(email); +} + +function linkedUser(issuer, subject) { + return db.prepare( + `SELECT u.id, u.email, u.token_version + FROM oidc_identity_links l + JOIN users u ON u.id = l.user_id + WHERE l.issuer_url = ? AND l.subject_identifier = ?`, + ).get(issuer, subject); +} + +/** + * Bind a provider-verified OIDC identity before the legacy callback consumes it. + * + * Existing issuer/subject links remain authoritative even when the provider's + * verified email changes. An unlinked local row with the same email is rejected + * rather than silently adopted: verified email proves the provider's assertion, + * not authorization to merge a password account or an unverifiable legacy SSO + * account. + * + * A first-time federated login provisions its local user, personal workspace, + * owner membership, and durable issuer/subject link in one SQLite write + * transaction. That transaction is intentionally synchronous and contains no + * provider/network work. The password hash used only as an inaccessible local + * fallback is prepared before taking the database write lock on the normal + * first-login path. All identity and email collision checks are repeated while + * the write lock is held, closing the check-then-create race with another auth + * request or process. + * + * @param {{issuer:string,subject:string,email:string}} identity - Cryptographically verified OIDC identity. + * @returns {{userId:number,needsFinalization:false,created:boolean}} Bound local identity metadata. + */ +export function prepareOidcIdentity(identity) { + const { issuer, subject, email } = validatedIdentity(identity); + + // Avoid paying the scrypt cost on established logins. If the link disappears + // before the write lock is acquired, the rare fallback below prepares the hash + // inside the transaction rather than creating an unbound account. + const linkedBeforeLock = linkedUser(issuer, subject); + let passwordHash = linkedBeforeLock + ? null + : hashPassword(randomBytes(24).toString('hex')); + + db.exec('BEGIN IMMEDIATE'); + try { + const linked = linkedUser(issuer, subject); + if (linked) { + const collision = db.prepare( + 'SELECT id FROM users WHERE email = ? COLLATE NOCASE AND id <> ? LIMIT 1', + ).get(email, linked.id); + if (collision) throw new OidcIdentityConflictError(); + if (linked.email !== email) { + db.prepare('UPDATE users SET email = ? WHERE id = ?').run(email, linked.id); + } + db.prepare( + `UPDATE oidc_identity_links + SET email_at_link = ?, updated_at = datetime('now') + WHERE issuer_url = ? AND subject_identifier = ?`, + ).run(email, issuer, subject); + db.exec('COMMIT'); + return { userId: linked.id, needsFinalization: false, created: false }; + } + + const users = matchingUsers(email); + if (users.length > 0) throw new OidcIdentityConflictError(); + + // This path is only possible when a previously observed link was removed + // before BEGIN IMMEDIATE. Keep the transaction safe rather than depending on + // the optimistic pre-lock observation. + if (!passwordHash) passwordHash = hashPassword(randomBytes(24).toString('hex')); + + const userId = rowid( + db.prepare('INSERT INTO users(email,password_hash,name) VALUES(?,?,?)') + .run(email, passwordHash, ''), + ); + const orgId = rowid( + db.prepare('INSERT INTO orgs(name,owner_id) VALUES(?,?)') + .run(`${email}'s workspace`, userId), + ); + db.prepare('INSERT INTO memberships(org_id,user_id,role) VALUES(?,?,?)') + .run(orgId, userId, 'owner'); + db.prepare( + `INSERT INTO oidc_identity_links( + issuer_url, subject_identifier, user_id, email_at_link + ) VALUES(?,?,?,?)`, + ).run(issuer, subject, userId, email); + + db.exec('COMMIT'); + return { userId, needsFinalization: false, created: true }; + } catch (error) { + db.exec('ROLLBACK'); + throw error; + } +} + +/** + * Compatibility finalizer for callers that still carry the historical two-step + * contract. New production OIDC flows bind during prepareOidcIdentity and do not + * require this function. + * + * @param {{issuer:string,subject:string,email:string}} identity - Verified OIDC identity. + * @returns {number} Bound local user identifier. + */ +export function finalizeOidcIdentity(identity) { + const { issuer, subject, email } = validatedIdentity(identity); + const existingLink = linkedUser(issuer, subject); + if (existingLink) return existingLink.id; + + const users = matchingUsers(email); + if (users.length !== 1) throw new OidcIdentityConflictError(); + const user = users[0]; + const priorFederatedLink = db.prepare( + `SELECT issuer_url, subject_identifier + FROM oidc_identity_links + WHERE user_id = ? + LIMIT 1`, + ).get(user.id); + if (priorFederatedLink) throw new OidcIdentityConflictError(); + + db.prepare( + `INSERT INTO oidc_identity_links( + issuer_url, subject_identifier, user_id, email_at_link + ) VALUES(?,?,?,?)`, + ).run(issuer, subject, user.id, email); + return user.id; +} diff --git a/server/webhook_legacy_migration.mjs b/server/webhook_legacy_migration.mjs new file mode 100644 index 00000000..4db8babc --- /dev/null +++ b/server/webhook_legacy_migration.mjs @@ -0,0 +1,114 @@ +import { validateWebhookRegistrationUrl } from './webhook_transport.mjs'; + +const SECURITY_ACTION = 'webhook.security_block'; +const NEXT_ACTION = 'register_public_https_replacement'; + +function isCurrentDestinationAllowed(url) { + try { + validateWebhookRegistrationUrl(url); + return true; + } catch { + return false; + } +} + +function blockedReasonFor(url) { + try { + return new URL(String(url ?? '')).protocol === 'http:' + ? 'insecure_scheme' + : 'destination_policy'; + } catch { + return 'destination_policy'; + } +} + +function activeWebhookDestinations(database) { + return database.prepare( + `SELECT id, org_id AS orgId, url + FROM webhooks + WHERE active = 1 + ORDER BY id`, + ).all(); +} + +function hasPolicyIncompatibleDestination(candidates) { + return candidates.some((candidate) => !isCurrentDestinationAllowed(candidate.url)); +} + +/** + * Disable active legacy webhook destinations rejected by current registration policy. + * + * Historical ScopeWeave releases accepted broader HTTP(S) webhook URLs. Current + * production registration requires public HTTPS, so leaving an incompatible row + * active would repeatedly attempt a delivery that the transport must reject. This + * migration reconciles every active row against the same synchronous registration + * policy, disables rejected destinations, records why they were blocked, and emits + * one tenant-visible audit event with a concrete replacement action. It never reads + * or copies webhook signing secrets. + * + * The initial scan is deliberately read-only. A database whose active webhook rows + * already satisfy policy therefore does not reserve SQLite's single writer during + * ordinary startup. If mutation is needed, the migration then acquires an immediate + * transaction and re-reads the candidate set while holding that writer reservation, + * so concurrent changes cannot make the preflight result authoritative by accident. + * DNS-backed hostnames remain subject to per-attempt address authorization and + * socket pinning at delivery time; startup intentionally performs no network I/O. + * + * @param {import('node:sqlite').DatabaseSync} database Open ScopeWeave database. + * @returns {number} Number of webhook rows newly disabled during this run. + */ +export function migrateLegacyWebhookDestinations(database) { + const preflightCandidates = activeWebhookDestinations(database); + if (!hasPolicyIncompatibleDestination(preflightCandidates)) return 0; + + database.exec('BEGIN IMMEDIATE'); + try { + const candidates = activeWebhookDestinations(database); + const disable = database.prepare( + `UPDATE webhooks + SET active = 0, + blocked_reason = ? + WHERE id = ? AND org_id = ? AND active = 1`, + ); + const audit = database.prepare( + `INSERT INTO audit_log(org_id, user_id, action, target_type, target_id, meta) + SELECT ?, NULL, ?, 'webhook', ?, ? + WHERE changes() > 0 + AND NOT EXISTS ( + SELECT 1 + FROM audit_log + WHERE org_id = ? + AND action = ? + AND target_type = 'webhook' + AND target_id = ? + )`, + ); + + let disabled = 0; + for (const candidate of candidates) { + if (isCurrentDestinationAllowed(candidate.url)) continue; + const reason = blockedReasonFor(candidate.url); + const targetId = String(candidate.id); + const result = disable.run(reason, candidate.id, candidate.orgId); + disabled += Number(result.changes); + audit.run( + candidate.orgId, + SECURITY_ACTION, + targetId, + JSON.stringify({ reason, nextAction: NEXT_ACTION }), + candidate.orgId, + SECURITY_ACTION, + targetId, + ); + } + database.exec('COMMIT'); + return disabled; + } catch (error) { + try { + database.exec('ROLLBACK'); + } catch { + // Preserve the causal migration failure if rollback itself also fails. + } + throw error; + } +} diff --git a/server/webhook_transport.mjs b/server/webhook_transport.mjs new file mode 100644 index 00000000..a70fe49f --- /dev/null +++ b/server/webhook_transport.mjs @@ -0,0 +1,459 @@ +import { lookup as dnsLookup } from 'node:dns/promises'; +import { request as httpsRequest } from 'node:https'; +import { BlockList, isIP } from 'node:net'; + +const DENIED_IPV4_BLOCKS = new BlockList(); +const DENIED_IPV6_BLOCKS = new BlockList(); +const PUBLIC_IPV6_UNICAST = new BlockList(); +const PUBLIC_HTTPS_RESPONSE_MAX_BYTES = 1024 * 1024; +PUBLIC_IPV6_UNICAST.addSubnet('2000::', 3, 'ipv6'); +for (const [address, prefix, family] of [ + ['0.0.0.0', 8, 'ipv4'], + ['10.0.0.0', 8, 'ipv4'], + ['100.64.0.0', 10, 'ipv4'], + ['127.0.0.0', 8, 'ipv4'], + ['169.254.0.0', 16, 'ipv4'], + ['172.16.0.0', 12, 'ipv4'], + ['192.0.0.0', 24, 'ipv4'], + ['192.0.2.0', 24, 'ipv4'], + ['192.31.196.0', 24, 'ipv4'], + ['192.52.193.0', 24, 'ipv4'], + ['192.88.99.0', 24, 'ipv4'], + ['192.168.0.0', 16, 'ipv4'], + ['192.175.48.0', 24, 'ipv4'], + ['198.18.0.0', 15, 'ipv4'], + ['198.51.100.0', 24, 'ipv4'], + ['203.0.113.0', 24, 'ipv4'], + ['224.0.0.0', 4, 'ipv4'], + ['240.0.0.0', 4, 'ipv4'], + ['::', 128, 'ipv6'], + ['::1', 128, 'ipv6'], + ['::ffff:0:0', 96, 'ipv6'], + ['64:ff9b::', 96, 'ipv6'], + ['64:ff9b:1::', 48, 'ipv6'], + ['100::', 64, 'ipv6'], + ['100:0:0:1::', 64, 'ipv6'], + ['2001::', 23, 'ipv6'], + ['2001:db8::', 32, 'ipv6'], + ['2002::', 16, 'ipv6'], + ['2620:4f:8000::', 48, 'ipv6'], + ['3ffe::', 16, 'ipv6'], + ['3fff::', 20, 'ipv6'], + ['5f00::', 16, 'ipv6'], + ['fc00::', 7, 'ipv6'], + ['fe80::', 10, 'ipv6'], + ['ff00::', 8, 'ipv6'], +]) { + (family === 'ipv4' ? DENIED_IPV4_BLOCKS : DENIED_IPV6_BLOCKS) + .addSubnet(address, prefix, family); +} + +const SAFE_ERROR = 'webhook destination unavailable'; +const POLICY_ERROR = 'webhook destination is not permitted'; + +/** A stable, non-secret webhook destination policy failure. */ +export class WebhookDestinationError extends Error { + constructor() { + super(POLICY_ERROR); + this.name = 'WebhookDestinationError'; + } +} + +/** A stable, non-secret resolver/TLS/transport failure. */ +export class WebhookTransportError extends Error { + constructor() { + super(SAFE_ERROR); + this.name = 'WebhookTransportError'; + } +} + +function hostAddress(hostname) { + return hostname.startsWith('[') && hostname.endsWith(']') + ? hostname.slice(1, -1) + : hostname; +} + +function isLocalHostname(hostname) { + const host = hostname.toLowerCase().replace(/\.$/, ''); + return host === 'localhost' + || host.endsWith('.localhost') + || host.endsWith('.local') + || host === 'home.arpa' + || host.endsWith('.home.arpa'); +} + +/** + * Return whether an IP address is safe for an Internet-facing webhook target. + * IPv4 special-purpose space is denied. IPv6 must be in IANA's ordinary + * 2000::/3 global-unicast envelope and outside every denied special-use block. + * Unknown strings and exceptional/reserved address space fail closed. + */ +export function isPublicWebhookAddress(address) { + const family = isIP(address); + if (!family) return false; + if (family === 4) return !DENIED_IPV4_BLOCKS.check(address, 'ipv4'); + return PUBLIC_IPV6_UNICAST.check(address, 'ipv6') + && !DENIED_IPV6_BLOCKS.check(address, 'ipv6'); +} + +/** + * Parse and canonicalize a webhook registration URL without performing DNS. + * DNS authorization happens again immediately before every network attempt. + */ +export function validateWebhookRegistrationUrl(value) { + let destination; + try { + destination = new URL(String(value ?? '')); + } catch { + throw new WebhookDestinationError(); + } + if (destination.protocol !== 'https:' + || destination.username + || destination.password + || destination.hash + || !destination.hostname + || isLocalHostname(destination.hostname)) { + throw new WebhookDestinationError(); + } + const literal = hostAddress(destination.hostname); + if (isIP(literal) && !isPublicWebhookAddress(literal)) { + throw new WebhookDestinationError(); + } + return destination.href; +} + +async function withAbort(promise, signal) { + if (!signal) return promise; + if (signal.aborted) throw new WebhookTransportError(); + let onAbort; + const aborted = new Promise((_, reject) => { + onAbort = () => reject(new WebhookTransportError()); + signal.addEventListener('abort', onAbort, { once: true }); + }); + try { + return await Promise.race([promise, aborted]); + } finally { + signal.removeEventListener('abort', onAbort); + } +} + +async function resolvePublicAddresses(destination, lookup, signal) { + const literal = hostAddress(destination.hostname); + if (isIP(literal)) { + if (!isPublicWebhookAddress(literal)) throw new WebhookDestinationError(); + return [{ address: literal, family: isIP(literal) }]; + } + + let answers; + try { + answers = await withAbort( + Promise.resolve(lookup(destination.hostname, { all: true, verbatim: true })), + signal, + ); + } catch (error) { + if (error instanceof WebhookDestinationError || error instanceof WebhookTransportError) throw error; + throw new WebhookTransportError(); + } + if (!Array.isArray(answers) || answers.length === 0) throw new WebhookTransportError(); + + const normalized = []; + const seen = new Set(); + for (const answer of answers) { + const address = String(answer?.address || ''); + const family = Number(answer?.family) || isIP(address); + if ((family !== 4 && family !== 6) || !isPublicWebhookAddress(address)) { + throw new WebhookDestinationError(); + } + const key = `${family}:${address}`; + if (!seen.has(key)) { + seen.add(key); + normalized.push({ address, family }); + } + } + if (!normalized.length) throw new WebhookTransportError(); + return normalized; +} + +function pinnedLookup(address, family) { + return (_hostname, options, callback) => { + if (options?.all) { + callback(null, [{ address, family }]); + return; + } + callback(null, address, family); + }; +} + +function pinnedRequestOptions(destination, candidate, options = {}) { + const tlsHost = hostAddress(destination.hostname); + return { + ...options, + agent: false, + lookup: pinnedLookup(candidate.address, candidate.family), + ...(isIP(tlsHost) ? {} : { servername: tlsHost }), + }; +} + +function trackSecureConnect(request, attempt) { + if (!attempt || typeof request?.once !== 'function') return; + request.once('socket', (socket) => { + socket?.once?.('secureConnect', () => { + attempt.secureConnected = true; + }); + }); +} + +async function postToCandidate(destination, candidate, { headers, body, signal, attempt }, request) { + if (signal?.aborted) throw new WebhookTransportError(); + try { + return await withAbort(new Promise((resolve, reject) => { + let req; + try { + req = request(destination, pinnedRequestOptions(destination, candidate, { + method: 'POST', + headers, + signal, + }), (response) => { + response.resume?.(); + const status = Number(response.statusCode) || 0; + resolve({ status, ok: status >= 200 && status < 300 }); + }); + } catch { + reject(new WebhookTransportError()); + return; + } + trackSecureConnect(req, attempt); + req.once?.('error', () => reject(new WebhookTransportError())); + req.end(body); + }), signal); + } catch (error) { + if (error instanceof WebhookTransportError) throw error; + throw new WebhookTransportError(); + } +} + +function appendResponseHeaders(target, source) { + for (const [name, value] of Object.entries(source || {})) { + if (Array.isArray(value)) { + for (const item of value) target.append(name, String(item)); + } else if (value !== undefined) { + target.append(name, String(value)); + } + } +} + +function identityEncodedHeaders(headers) { + const normalized = Object.fromEntries(new Headers(headers).entries()); + delete normalized['content-length']; + normalized['accept-encoding'] = 'identity'; + return normalized; +} + +async function fetchFromCandidate( + destination, + candidate, + { method, headers, body, signal, maxResponseBytes, attempt }, + request, +) { + if (signal?.aborted) throw new WebhookTransportError(); + try { + return await withAbort(new Promise((resolve, reject) => { + let settled = false; + const fail = () => { + if (settled) return; + settled = true; + reject(new WebhookTransportError()); + }; + let req; + try { + req = request(destination, pinnedRequestOptions(destination, candidate, { + method, + headers, + signal, + }), (response) => { + if (attempt) attempt.responseStarted = true; + const chunks = []; + let totalBytes = 0; + response.on?.('data', (chunk) => { + if (settled) return; + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + totalBytes += bytes.byteLength; + if (totalBytes > maxResponseBytes) { + response.destroy?.(); + fail(); + return; + } + chunks.push(bytes); + }); + response.once?.('error', fail); + response.once?.('end', () => { + if (settled) return; + const status = Number(response.statusCode) || 0; + if (status < 200 || status > 599) { + fail(); + return; + } + const responseHeaders = new Headers(); + appendResponseHeaders(responseHeaders, response.headers); + const responseBody = status === 204 || status === 205 || status === 304 + ? null + : (chunks.length ? Buffer.concat(chunks) : null); + try { + const builtResponse = new Response(responseBody, { status, headers: responseHeaders }); + settled = true; + resolve(builtResponse); + } catch { + fail(); + } + }); + }); + } catch { + fail(); + return; + } + trackSecureConnect(req, attempt); + req.once?.('error', fail); + if (body === undefined || body === null) { + req.end(); + } else if (typeof body === 'string' || Buffer.isBuffer(body) || body instanceof Uint8Array) { + req.end(body); + } else if (body instanceof ArrayBuffer) { + req.end(new Uint8Array(body)); + } else { + fail(); + } + }), signal); + } catch (error) { + if (error instanceof WebhookTransportError) throw error; + throw new WebhookTransportError(); + } +} + +function methodMayReplay(method) { + return method === 'GET' || method === 'HEAD'; +} + +/** + * Build a bounded public-HTTPS fetch transport for server-side metadata flows. + * Each request resolves DNS afresh, fails closed if any answer is non-public, + * pins every socket to a validated candidate, preserves the original TLS SNI, + * disables pooling, never follows redirects, and bounds response buffering. + * GET/HEAD may fail over after a post-handshake transport error; mutating + * requests stop after TLS establishment because their delivery is ambiguous. + */ +export function createPublicHttpsTransport({ lookup = dnsLookup, request = httpsRequest } = {}) { + if (typeof lookup !== 'function' || typeof request !== 'function') { + throw new TypeError('public HTTPS transport dependencies must be functions'); + } + + return Object.freeze({ + async fetch(url, { + method = 'GET', + headers = {}, + body, + signal, + maxResponseBytes = PUBLIC_HTTPS_RESPONSE_MAX_BYTES, + } = {}) { + let destination; + try { + destination = new URL(validateWebhookRegistrationUrl(url)); + } catch (error) { + if (error instanceof WebhookDestinationError) throw error; + throw new WebhookDestinationError(); + } + if (!Number.isSafeInteger(maxResponseBytes) || maxResponseBytes <= 0) { + throw new TypeError('maxResponseBytes must be a positive safe integer'); + } + + const candidates = await resolvePublicAddresses(destination, lookup, signal); + const requestHeaders = identityEncodedHeaders(headers); + const requestMethod = String(method || 'GET').toUpperCase(); + let lastError; + for (const candidate of candidates) { + const attempt = { responseStarted: false, secureConnected: false }; + try { + return await fetchFromCandidate( + destination, + candidate, + { + method: requestMethod, + headers: requestHeaders, + body, + signal, + maxResponseBytes, + attempt, + }, + request, + ); + } catch (error) { + if (!(error instanceof WebhookTransportError)) throw error; + lastError = error; + if ( + signal?.aborted + || attempt.responseStarted + || (attempt.secureConnected && !methodMayReplay(requestMethod)) + ) throw error; + } + } + throw lastError || new WebhookTransportError(); + }, + }); +} + +/** + * Build the outbound webhook transport around injectable DNS and HTTPS seams. + * Every post resolves afresh, rejects mixed/private answers, pins each socket to + * a validated public candidate, preserves the original hostname for Host/TLS, + * and never follows redirects because Node's native HTTPS client does not do so. + * Pre-handshake connect failure may fall through to another address from the + * same fully validated DNS answer set. Once TLS succeeds, delivery is ambiguous + * and the same signed body is never replayed to another candidate; a later + * application retry performs fresh DNS again. + */ +export function createWebhookTransport({ lookup = dnsLookup, request = httpsRequest } = {}) { + if (typeof lookup !== 'function' || typeof request !== 'function') { + throw new TypeError('webhook transport dependencies must be functions'); + } + + return Object.freeze({ + async post(url, { headers = {}, body = '', signal } = {}) { + let destination; + try { + destination = new URL(validateWebhookRegistrationUrl(url)); + } catch (error) { + if (error instanceof WebhookDestinationError) throw error; + throw new WebhookDestinationError(); + } + + const candidates = await resolvePublicAddresses(destination, lookup, signal); + const requestHeaders = Object.fromEntries(new Headers(headers).entries()); + delete requestHeaders['content-length']; + let lastError; + for (const candidate of candidates) { + const attempt = { secureConnected: false }; + try { + return await postToCandidate( + destination, + candidate, + { headers: requestHeaders, body, signal, attempt }, + request, + ); + } catch (error) { + if (!(error instanceof WebhookTransportError)) throw error; + lastError = error; + if (signal?.aborted || attempt.secureConnected) throw error; + } + } + throw lastError || new WebhookTransportError(); + }, + }); +} + +const publicHttpsTransport = createPublicHttpsTransport(); +const webhookTransport = createWebhookTransport(); + +/** Fetch one bounded response through the production public-HTTPS transport. */ +export const fetchPublicHttps = (url, options) => publicHttpsTransport.fetch(url, options); + +/** Send one signed webhook attempt through the production SSRF-safe transport. */ +export const postWebhook = (url, options) => webhookTransport.post(url, options); \ No newline at end of file diff --git a/tests/api/console-observability-boundary.test.mjs b/tests/api/console-observability-boundary.test.mjs new file mode 100644 index 00000000..70f613ef --- /dev/null +++ b/tests/api/console-observability-boundary.test.mjs @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; + +const consoleLogBeforeImport = console.log; + +await import('../../server/app.mjs'); + +assert.strictEqual( + console.log, + consoleLogBeforeImport, + 'importing the ScopeWeave facade must not replace process-wide console.log', +); + +console.log('console observability boundary regression passed'); diff --git a/tests/api/email-identity-compat.test.mjs b/tests/api/email-identity-compat.test.mjs new file mode 100644 index 00000000..0863a875 --- /dev/null +++ b/tests/api/email-identity-compat.test.mjs @@ -0,0 +1,60 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; + +const { app } = await import('../../server/app.mjs'); +const { app: coreApp } = await import('../../server/app_core.mjs'); + +const jsonHeaders = { 'content-type': 'application/json' }; +const payload = (value) => JSON.stringify(value); + +async function legacySignup(email, password = 'password123') { + return coreApp.request('/api/auth/signup', { + method: 'POST', + headers: jsonHeaders, + body: payload({ email, password, name: 'Legacy Owner' }), + }); +} + +async function publicAuth(path, email, password = 'password123') { + return app.request(path, { + method: 'POST', + headers: jsonHeaders, + body: payload({ email, password, name: 'Canonical Owner' }), + }); +} + +test('legacy mixed-case password identities remain reachable through the public login boundary', async () => { + const legacyEmail = 'Legacy.Owner@ScopeWeave.Test'; + const created = await legacySignup(legacyEmail); + assert.equal(created.status, 200, 'pre-canonical mixed-case account exists'); + + const sameSpelling = await publicAuth('/api/auth/login', legacyEmail); + assert.equal( + sameSpelling.status, + 200, + 'the public facade must not lock out an account that previously authenticated with this exact spelling', + ); + + const canonicalSpelling = await publicAuth('/api/auth/login', legacyEmail.toLowerCase()); + assert.equal( + canonicalSpelling.status, + 200, + 'canonical login remains compatible when exactly one legacy identity matches case-insensitively', + ); +}); + +test('canonical signup cannot create a case-only duplicate of a legacy account', async () => { + const legacyEmail = 'Existing.Owner@ScopeWeave.Test'; + const created = await legacySignup(legacyEmail, 'password456'); + assert.equal(created.status, 200, 'pre-canonical mixed-case account exists'); + + const duplicate = await publicAuth('/api/auth/signup', legacyEmail.toLowerCase(), 'password789'); + assert.equal( + duplicate.status, + 409, + 'case-only duplicates must be rejected instead of creating a second login identity', + ); +}); diff --git a/tests/api/fetch-boundary-ownership.test.mjs b/tests/api/fetch-boundary-ownership.test.mjs new file mode 100644 index 00000000..9654b01f --- /dev/null +++ b/tests/api/fetch-boundary-ownership.test.mjs @@ -0,0 +1,21 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; + +const callerFetch = async (input, init) => { + const request = new Request(input, init); + return new Response(request.url, { status: 200 }); +}; +globalThis.fetch = callerFetch; + +await import('../../server/app.mjs'); + +test('importing the ScopeWeave app preserves the caller-owned process fetch implementation', () => { + assert.equal( + globalThis.fetch, + callerFetch, + 'ScopeWeave security boundaries must be explicit collaborators rather than a process-wide fetch monkey patch', + ); +}); diff --git a/tests/api/oidc-email-verification.test.mjs b/tests/api/oidc-email-verification.test.mjs new file mode 100644 index 00000000..b07216b5 --- /dev/null +++ b/tests/api/oidc-email-verification.test.mjs @@ -0,0 +1,299 @@ +import assert from 'node:assert/strict'; +import { createSign, generateKeyPairSync } from 'node:crypto'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; +process.env.SCOPEWEAVE_DEV = '1'; +process.env.OIDC_ISSUER = 'http://127.0.0.1:19101'; +process.env.OIDC_CLIENT_ID = 'scopeweave-email-verification-test'; +process.env.OIDC_CLIENT_SECRET = 'scopeweave-secret'; +process.env.OIDC_REDIRECT_URI = 'http://localhost/api/auth/oidc/callback'; + +const issuer = process.env.OIDC_ISSUER; +const clientId = process.env.OIDC_CLIENT_ID; +const authorizationEndpoint = 'http://127.0.0.1:19102/oauth2/authorize'; +const tokenEndpoint = 'http://127.0.0.1:19103/oauth2/token'; +const jwksEndpoint = 'http://127.0.0.1:19104/jwks'; +const primaryEmail = 'verified@scopeweave.test'; +const renamedEmail = 'renamed@scopeweave.test'; +const passwordEmail = 'password-account@scopeweave.test'; +const linkFailureEmail = 'link-failure@scopeweave.test'; +const primarySubject = 'oidc-subject-verified'; +const linkFailureSubject = 'oidc-subject-link-failure'; +const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); +const publicJwk = { + ...publicKey.export({ format: 'jwk' }), + alg: 'RS256', + kid: 'scopeweave-email-verification-key', + use: 'sig', +}; +let expectedNonce = null; +const originalFetch = globalThis.fetch; + +const encoded = (value) => Buffer.from(JSON.stringify(value)).toString('base64url'); +const signIdToken = (claims) => { + const header = encoded({ alg: 'RS256', kid: publicJwk.kid, typ: 'JWT' }); + const payload = encoded(claims); + const input = `${header}.${payload}`; + const signature = createSign('RSA-SHA256') + .update(input) + .end() + .sign(privateKey) + .toString('base64url'); + return `${input}.${signature}`; +}; + +const claimCases = { + 'verified-email-code': { + sub: primarySubject, + email: primaryEmail, + email_verified: true, + }, + 'renamed-email-code': { + sub: primarySubject, + email: renamedEmail, + email_verified: true, + }, + 'whitespace-email-code': { + sub: primarySubject, + email: ` ${renamedEmail} `, + email_verified: true, + }, + 'reassigned-email-code': { + sub: 'oidc-subject-reassigned', + email: primaryEmail, + email_verified: true, + }, + 'password-email-code': { + sub: 'oidc-subject-password-collision', + email: passwordEmail, + email_verified: true, + }, + 'link-failure-code': { + sub: linkFailureSubject, + email: linkFailureEmail, + email_verified: true, + }, + 'unverified-email-code': { + sub: 'oidc-subject-unverified', + email: 'unverified@scopeweave.test', + email_verified: false, + }, +}; + +globalThis.fetch = async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init); + if (request.url === `${issuer}/.well-known/openid-configuration`) { + return Response.json({ + issuer, + authorization_endpoint: authorizationEndpoint, + token_endpoint: tokenEndpoint, + jwks_uri: jwksEndpoint, + id_token_signing_alg_values_supported: ['RS256'], + }); + } + if (request.url === jwksEndpoint) { + return Response.json({ keys: [publicJwk] }); + } + if (request.url !== tokenEndpoint) { + throw new Error(`unexpected outbound fetch: ${request.url}`); + } + const form = new URLSearchParams(await request.clone().text()); + const code = form.get('code'); + const claimCase = claimCases[code]; + if (!claimCase) throw new Error(`unexpected authorization code: ${code}`); + const now = Math.floor(Date.now() / 1000); + return Response.json({ + id_token: signIdToken({ + iss: issuer, + aud: clientId, + ...claimCase, + nonce: expectedNonce, + iat: now, + exp: now + 300, + }), + }); +}; + +const sessionClaims = (response) => { + const location = response.headers.get('location') || ''; + const token = location.split('#token=')[1] || ''; + const payload = token.split('.')[1] || ''; + return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); +}; +const sessionSubject = (response) => sessionClaims(response).sub; + +try { + const { app } = await import('../../server/app.mjs'); + const { db } = await import('../../server/db.mjs'); + + const callback = async (code) => { + const start = await app.request('/api/auth/oidc/start'); + assert.equal(start.status, 302, 'OIDC authorization flow starts'); + const authorization = new URL(start.headers.get('location')); + expectedNonce = authorization.searchParams.get('nonce'); + const state = authorization.searchParams.get('state'); + assert.ok(expectedNonce, 'authorization redirect binds a nonce'); + assert.ok(state, 'authorization redirect binds a state'); + return app.request( + `/api/auth/oidc/callback?state=${encodeURIComponent(state)}&code=${encodeURIComponent(code)}`, + ); + }; + + const verified = await callback('verified-email-code'); + assert.equal( + verified.status, + 302, + 'an ID token with a provider-verified email can create the federated session', + ); + const initialUserId = sessionSubject(verified); + const metricsAfterVerified = await (await app.request('/api/metrics')).json(); + assert.equal( + metricsAfterVerified.signups, + 1, + 'a first-time federated account increments the same signup counter as password registration', + ); + + const reassigned = await callback('reassigned-email-code'); + assert.equal( + reassigned.status, + 409, + 'a different subject cannot take over an existing federated account by reusing its current verified email', + ); + + const renamed = await callback('renamed-email-code'); + assert.equal( + renamed.status, + 302, + 'the same issuer/subject remains the same local identity after its email claim changes', + ); + assert.equal( + sessionSubject(renamed), + initialUserId, + 'federated identity follows stable issuer/subject rather than a mutable email claim', + ); + + const whitespaceEmail = await callback('whitespace-email-code'); + assert.equal( + whitespaceEmail.status, + 400, + 'a verified email claim outside canonical addr-spec form is rejected before account mutation', + ); + assert.equal( + whitespaceEmail.headers.get('location'), + null, + 'a non-canonical verified email claim must not return a session redirect', + ); + assert.equal( + db.prepare('SELECT COUNT(*) AS count FROM users').get().count, + 1, + 'whitespace in a verified email claim does not create a duplicate local user', + ); + const metricsAfterWhitespace = await (await app.request('/api/metrics')).json(); + assert.equal( + metricsAfterWhitespace.signups, + 1, + 'a rejected non-canonical federated claim does not count as a new signup', + ); + + const unverified = await callback('unverified-email-code'); + assert.equal( + unverified.status, + 400, + 'an ID token with email_verified=false must not be trusted to create or link an account by email', + ); + + assert.equal( + db.prepare('SELECT COUNT(*) AS count FROM users').get().count, + 1, + 'email changes and reassignment attempts do not create shadow federated users', + ); + + const passwordSignup = await app.request('/api/auth/signup', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email: passwordEmail, password: 'a-secure-password' }), + }); + assert.equal(passwordSignup.status, 200, 'password account fixture is created through the public boundary'); + + const passwordCollision = await callback('password-email-code'); + assert.equal( + passwordCollision.status, + 409, + 'a verified OIDC email must not silently link to a pre-existing password account', + ); + assert.equal( + db.prepare('SELECT COUNT(*) AS count FROM users').get().count, + 2, + 'the password account remains distinct after the rejected federated linking attempt', + ); + + const identityLinks = db.prepare( + `SELECT issuer_url AS issuer, subject_identifier AS subject, user_id AS userId + FROM oidc_identity_links ORDER BY id`, + ).all().map((row) => ({ ...row })); + assert.deepEqual( + identityLinks, + [{ issuer, subject: primarySubject, userId: Number(initialUserId) }], + 'the durable federated identity key is the verified issuer/subject pair', + ); + + const beforeLinkFailure = { + users: db.prepare('SELECT COUNT(*) AS count FROM users').get().count, + orgs: db.prepare('SELECT COUNT(*) AS count FROM orgs').get().count, + memberships: db.prepare('SELECT COUNT(*) AS count FROM memberships').get().count, + }; + db.exec(` + CREATE TRIGGER fail_oidc_identity_link + BEFORE INSERT ON oidc_identity_links + WHEN NEW.subject_identifier = '${linkFailureSubject}' + BEGIN + SELECT RAISE(ABORT, 'simulated OIDC identity-link persistence failure'); + END; + `); + const failedLink = await callback('link-failure-code'); + assert.equal( + failedLink.status, + 400, + 'identity-link persistence failure must fail closed before any federated session is returned', + ); + assert.equal( + failedLink.headers.get('location'), + null, + 'identity-link persistence failure must not return a session redirect', + ); + assert.equal( + db.prepare('SELECT COUNT(*) AS count FROM users').get().count, + beforeLinkFailure.users, + 'failed identity-link persistence must not leave an orphan local user', + ); + assert.equal( + db.prepare('SELECT COUNT(*) AS count FROM orgs').get().count, + beforeLinkFailure.orgs, + 'failed identity-link persistence must not leave an orphan personal workspace', + ); + assert.equal( + db.prepare('SELECT COUNT(*) AS count FROM memberships').get().count, + beforeLinkFailure.memberships, + 'failed identity-link persistence must not leave an orphan owner membership', + ); + db.exec('DROP TRIGGER fail_oidc_identity_link'); + + const retryAfterLinkFailure = await callback('link-failure-code'); + assert.equal( + retryAfterLinkFailure.status, + 302, + 'a transient identity-link persistence failure remains safely retryable', + ); + const metricsAfterRetry = await (await app.request('/api/metrics')).json(); + assert.equal( + metricsAfterRetry.signups, + 3, + 'only committed password or federated account creation increments signup metrics', + ); +} finally { + globalThis.fetch = originalFetch; + delete process.env.SCOPEWEAVE_DEV; +} + +console.log('OIDC stable-subject account-linking regression passed'); diff --git a/tests/api/oidc-mock-dev-only.test.mjs b/tests/api/oidc-mock-dev-only.test.mjs new file mode 100644 index 00000000..55d0917d --- /dev/null +++ b/tests/api/oidc-mock-dev-only.test.mjs @@ -0,0 +1,22 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; +delete process.env.SCOPEWEAVE_DEV; +delete process.env.OIDC_ISSUER; +delete process.env.OIDC_CLIENT_ID; +delete process.env.OIDC_CLIENT_SECRET; +delete process.env.OIDC_REDIRECT_URI; + +const { app } = await import('../../server/app.mjs'); + +test('built-in OIDC mock cannot authenticate when development mode is disabled', async () => { + let response = await app.request('http://localhost/api/auth/oidc/start?email=attacker@scopeweave.test'); + assert.equal(response.status, 404, 'production-like deployment must not expose mock OIDC start'); + + response = await app.request( + 'http://localhost/api/auth/oidc/mock/authorize?state=fake&email=attacker%40scopeweave.test&redirect_uri=http%3A%2F%2Flocalhost%2Fapi%2Fauth%2Foidc%2Fcallback', + ); + assert.equal(response.status, 404, 'production-like deployment must not expose mock OIDC authorize'); +}); diff --git a/tests/api/oidc-production-boundary.test.mjs b/tests/api/oidc-production-boundary.test.mjs new file mode 100644 index 00000000..b3814cf6 --- /dev/null +++ b/tests/api/oidc-production-boundary.test.mjs @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; +delete process.env.SCOPEWEAVE_DEV; +delete process.env.OIDC_ISSUER; +delete process.env.OIDC_CLIENT_ID; +delete process.env.OIDC_CLIENT_SECRET; +delete process.env.OIDC_REDIRECT_URI; + +const { app } = await import('../../server/app.mjs'); + +const start = await app.request('/api/auth/oidc/start?email=attacker@example.test'); +assert.equal( + start.status, + 404, + 'an unconfigured production deployment must not expose the built-in mock identity provider', +); + +const mock = await app.request( + '/api/auth/oidc/mock/authorize?state=attacker&email=attacker@example.test&redirect_uri=http://localhost/api/auth/oidc/callback', +); +assert.equal( + mock.status, + 404, + 'the mock authorization endpoint is inaccessible unless explicit development mode is enabled', +); + +const callback = await app.request('/api/auth/oidc/callback?state=attacker&code=attacker'); +assert.equal( + callback.status, + 404, + 'an unconfigured production callback cannot enter the mock-session path', +); + +console.log('production OIDC fail-closed boundary regression passed'); diff --git a/tests/api/oidc-state-capacity.test.mjs b/tests/api/oidc-state-capacity.test.mjs new file mode 100644 index 00000000..fa61fdde --- /dev/null +++ b/tests/api/oidc-state-capacity.test.mjs @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; +process.env.OIDC_ISSUER = 'https://idp.example.test'; +process.env.OIDC_CLIENT_ID = 'scopeweave-capacity-test'; +process.env.OIDC_CLIENT_SECRET = 'scopeweave-capacity-secret'; +process.env.OIDC_REDIRECT_URI = 'http://localhost/api/auth/oidc/callback'; +delete process.env.SCOPEWEAVE_DEV; + +const originalNow = Date.now; +let now = 1_800_000_000_000; +Date.now = () => now; + +try { + const { app } = await import('../../server/app_core.mjs'); + + for (let index = 0; index < 256; index += 1) { + const response = await app.request('/api/auth/oidc/start'); + assert.equal( + response.status, + 302, + 'the core OIDC state store admits flows until its bounded capacity is full', + ); + } + + const saturated = await app.request('/api/auth/oidc/start'); + assert.equal( + saturated.status, + 503, + 'the core OIDC state store fails closed instead of growing without bound', + ); + assert.deepEqual( + await saturated.json(), + { error: 'OIDC temporarily unavailable' }, + 'capacity exhaustion returns a stable non-secret response', + ); + + now += 5 * 60 * 1000; + const atExpiry = await app.request('/api/auth/oidc/start'); + assert.equal( + atExpiry.status, + 302, + 'state entries expiring at the current instant are reclaimed before applying the capacity limit', + ); + + const replacementState = new URL(atExpiry.headers.get('location')).searchParams.get('state'); + assert.ok(replacementState, 'the replacement flow exposes a state value through the authorization redirect'); + + now += 5 * 60 * 1000; + const expiredCallback = await app.request( + `/api/auth/oidc/callback?state=${encodeURIComponent(replacementState)}&code=unused`, + ); + assert.equal( + expiredCallback.status, + 400, + 'a state expiring at the current instant is rejected before token exchange', + ); + assert.deepEqual( + await expiredCallback.json(), + { error: 'invalid or expired state' }, + 'inclusive callback expiry returns the stable fail-closed response', + ); +} finally { + Date.now = originalNow; +} + +console.log('core OIDC state capacity and inclusive expiry reclamation regression passed'); diff --git a/tests/api/oidc-timeout.test.mjs b/tests/api/oidc-timeout.test.mjs new file mode 100644 index 00000000..f576afbf --- /dev/null +++ b/tests/api/oidc-timeout.test.mjs @@ -0,0 +1,331 @@ +import assert from 'node:assert/strict'; +import { createSign, generateKeyPairSync } from 'node:crypto'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; +process.env.SCOPEWEAVE_DEV = '1'; +process.env.OIDC_ISSUER = 'http://127.0.0.1:19001'; +process.env.OIDC_CLIENT_ID = 'scopeweave-test'; +process.env.OIDC_CLIENT_SECRET = 'scopeweave-secret'; +process.env.OIDC_REDIRECT_URI = 'http://localhost/api/auth/oidc/callback'; + +const issuer = process.env.OIDC_ISSUER; +const clientId = process.env.OIDC_CLIENT_ID; +const authorizationEndpoint = 'http://127.0.0.1:19002/oauth2/authorize'; +const tokenEndpoint = 'http://127.0.0.1:19003/oauth2/token'; +const jwksEndpoint = 'http://127.0.0.1:19004/jwks'; +const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); +const publicJwk = { + ...publicKey.export({ format: 'jwk' }), + alg: 'RS256', + kid: 'scopeweave-test-key-1', + use: 'sig', +}; +const signingJwks = Array.from({ length: 9 }, (_, index) => ({ + ...publicJwk, + kid: `scopeweave-test-key-${index + 1}`, +})); +const expectedNonceByCode = new Map(); +let discoveryMode = 'private-metadata'; +let observedTimeout = null; +let callbackAbortController = null; +let observedUpstreamAbort = null; +let jwksFetches = 0; +let exactExpiryTokenExchanges = 0; +const originalTimeout = AbortSignal.timeout; +const originalFetch = globalThis.fetch; +const originalDateNow = Date.now; + +const encoded = (value) => Buffer.from(JSON.stringify(value)).toString('base64url'); +const signIdToken = (claims, kid = publicJwk.kid) => { + const header = encoded({ alg: 'RS256', kid, typ: 'JWT' }); + const payload = encoded(claims); + const input = `${header}.${payload}`; + const signature = createSign('RSA-SHA256').update(input).end().sign(privateKey).toString('base64url'); + return `${input}.${signature}`; +}; + +AbortSignal.timeout = (milliseconds) => { + observedTimeout = milliseconds; + return new AbortController().signal; +}; + +globalThis.fetch = async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init); + const url = request.url; + if (url === `${issuer}/.well-known/openid-configuration`) { + assert.equal( + request.redirect, + 'error', + 'OIDC discovery must reject redirects instead of following provider-controlled locations', + ); + if (discoveryMode === 'private-metadata') { + return Response.json({ + issuer, + authorization_endpoint: authorizationEndpoint, + token_endpoint: 'https://127.0.0.1/internal-token', + jwks_uri: 'https://[::1]/internal-jwks', + id_token_signing_alg_values_supported: ['RS256'], + }); + } + return Response.json({ + issuer, + authorization_endpoint: authorizationEndpoint, + token_endpoint: tokenEndpoint, + jwks_uri: jwksEndpoint, + id_token_signing_alg_values_supported: ['RS256'], + }); + } + if (url === jwksEndpoint) { + jwksFetches += 1; + assert.equal( + request.redirect, + 'error', + 'OIDC JWKS retrieval must reject redirects before trusting signing-key bytes', + ); + return Response.json({ keys: signingJwks }); + } + if (url !== tokenEndpoint) { + throw new Error(`unexpected outbound fetch: ${url}`); + } + assert.equal( + request.redirect, + 'error', + 'OIDC token exchange must not forward authorization code or client credentials across redirects', + ); + + const form = new URLSearchParams(await request.clone().text()); + const code = form.get('code'); + if (code === 'exact-expiry-code') exactExpiryTokenExchanges += 1; + const expectedNonce = expectedNonceByCode.get(code); + const now = Math.floor(Date.now() / 1000); + const baseClaims = { + iss: issuer, + aud: clientId, + sub: 'oidc-subject-123', + email: 'oidc-timeout@scopeweave.test', + email_verified: true, + nonce: expectedNonce, + iat: now, + exp: now + 300, + }; + + if (code === 'valid-code' || code === 'valid-code-cache' || code === 'exact-expiry-code') { + return Response.json({ id_token: signIdToken(baseClaims) }); + } + const signingKeyMatch = /^valid-code-key-(\d+)$/.exec(code || ''); + if (signingKeyMatch) { + const keyIndex = Number(signingKeyMatch[1]); + if (keyIndex >= 1 && keyIndex <= signingJwks.length) { + return Response.json({ + id_token: signIdToken(baseClaims, signingJwks[keyIndex - 1].kid), + }); + } + } + if (code === 'forged-code') { + const valid = signIdToken(baseClaims).split('.'); + valid[2] = Buffer.from('forged-signature').toString('base64url'); + return Response.json({ id_token: valid.join('.') }); + } + if (code === 'wrong-audience-code') { + return Response.json({ id_token: signIdToken({ ...baseClaims, aud: 'attacker-client' }) }); + } + if (code === 'wrong-issuer-code') { + return Response.json({ id_token: signIdToken({ ...baseClaims, iss: 'https://evil.example.test' }) }); + } + if (code === 'wrong-nonce-code') { + return Response.json({ id_token: signIdToken({ ...baseClaims, nonce: 'attacker-nonce' }) }); + } + if (code === 'future-not-before-code') { + return Response.json({ + id_token: signIdToken({ + ...baseClaims, + nbf: now + 120, + }), + }); + } + if (code === 'cancelled-code') { + callbackAbortController.abort(); + observedUpstreamAbort = request.signal.aborted; + throw new Error('simulated cancelled identity-provider request'); + } + throw new Error(`unexpected authorization code: ${code}`); +}; + +try { + const { app } = await import('../../server/app.mjs'); + + const unsafeMetadata = await app.request('/api/auth/oidc/start'); + assert.equal( + unsafeMetadata.status, + 502, + 'OIDC discovery metadata cannot redirect server-side token or JWKS requests to private HTTPS addresses', + ); + discoveryMode = 'valid'; + + const startFlow = async (code) => { + const start = await app.request('/api/auth/oidc/start'); + assert.equal(start.status, 302, 'OIDC authorization flow starts'); + const location = start.headers.get('location'); + assert.ok(location, 'authorization redirect is present'); + const authorization = new URL(location); + assert.equal( + `${authorization.origin}${authorization.pathname}`, + authorizationEndpoint, + 'OIDC authorization uses the provider-discovered authorization endpoint rather than guessing an issuer-relative path', + ); + const state = authorization.searchParams.get('state'); + const nonce = authorization.searchParams.get('nonce'); + assert.ok(state, 'authorization redirect carries state'); + assert.ok(nonce, 'authorization redirect carries an OIDC nonce bound to this flow'); + expectedNonceByCode.set(code, nonce); + return state; + }; + + const callback = async (code) => { + const state = await startFlow(code); + return app.request( + `/api/auth/oidc/callback?state=${encodeURIComponent(state)}&code=${encodeURIComponent(code)}`, + ); + }; + + const valid = await callback('valid-code'); + assert.equal(valid.status, 302, 'a correctly signed and bound ID token creates the session'); + assert.equal( + observedTimeout, + 3000, + 'OIDC provider calls use the bounded three-second provider budget', + ); + assert.equal(jwksFetches, 1, 'first verified login retrieves signing-key evidence once'); + + const cachedKeyLogin = await callback('valid-code-cache'); + assert.equal(cachedKeyLogin.status, 302, 'a second correctly signed login remains valid'); + assert.equal( + jwksFetches, + 1, + 'repeated logins with the same signing key reuse bounded JWKS evidence instead of amplifying provider traffic', + ); + + const secondKeyLogin = await callback('valid-code-key-2'); + assert.equal(secondKeyLogin.status, 302, 'a concurrently published second signing key is accepted'); + assert.equal(jwksFetches, 2, 'a new kid requires one bounded JWKS refresh'); + + const firstKeyAgain = await callback('valid-code-key-1'); + assert.equal(firstKeyAgain.status, 302, 'the first signing key remains usable during provider key overlap'); + assert.equal( + jwksFetches, + 2, + 'alternating between two active kids reuses per-kid signing evidence instead of refetching JWKS', + ); + + for (let keyIndex = 3; keyIndex <= signingJwks.length; keyIndex += 1) { + const rotated = await callback(`valid-code-key-${keyIndex}`); + assert.equal(rotated.status, 302, `signing key ${keyIndex} is accepted during bounded rotation`); + } + assert.equal( + jwksFetches, + signingJwks.length, + 'each previously unseen kid causes at most one JWKS refresh while the cache fills', + ); + + const evictedFirstKey = await callback('valid-code-key-1'); + assert.equal(evictedFirstKey.status, 302, 'an evicted signing key can be revalidated from current JWKS'); + assert.equal( + jwksFetches, + signingJwks.length + 1, + 'the fixed-size signing-key cache evicts old evidence instead of growing without bound', + ); + + const forged = await callback('forged-code'); + assert.equal(forged.status, 400, 'a forged ID-token signature is rejected'); + + const wrongAudience = await callback('wrong-audience-code'); + assert.equal(wrongAudience.status, 400, 'an ID token for another client is rejected'); + + const wrongIssuer = await callback('wrong-issuer-code'); + assert.equal(wrongIssuer.status, 400, 'an ID token from another issuer is rejected'); + + const wrongNonce = await callback('wrong-nonce-code'); + assert.equal(wrongNonce.status, 400, 'an ID token from another authorization flow is rejected'); + + const futureNotBefore = await callback('future-not-before-code'); + assert.equal( + futureNotBefore.status, + 400, + 'a signed ID token must not be accepted before its nbf time, beyond the allowed clock skew', + ); + + const cancelledState = await startFlow('cancelled-code'); + callbackAbortController = new AbortController(); + const cancelled = await app.request(new Request( + `http://localhost/api/auth/oidc/callback?state=${encodeURIComponent(cancelledState)}&code=cancelled-code`, + { signal: callbackAbortController.signal }, + )); + assert.equal(cancelled.status, 400, 'an upstream-cancelled provider exchange does not create a session'); + assert.equal( + observedUpstreamAbort, + true, + 'OIDC token exchange preserves callback cancellation while retaining its timeout budget', + ); + + const anchoredNow = originalDateNow(); + let exactExpiryState; + try { + // Keep the historical core OIDC state valid one second longer than the + // facade nonce. This isolates the facade boundary: on the vulnerable + // predecessor, equality at the facade expiry reaches the provider and + // succeeds; on the fixed code it is rejected before any token exchange. + const startMoments = [ + anchoredNow, + anchoredNow + 1000, + anchoredNow, + anchoredNow, + ]; + let startMomentIndex = 0; + Date.now = () => startMoments[Math.min(startMomentIndex++, startMoments.length - 1)]; + exactExpiryState = await startFlow('exact-expiry-code'); + Date.now = () => anchoredNow + (5 * 60 * 1000); + const exactExpiry = await app.request( + `/api/auth/oidc/callback?state=${encodeURIComponent(exactExpiryState)}&code=exact-expiry-code`, + ); + assert.equal( + exactExpiry.status, + 400, + 'the facade OIDC binding is unusable at its exact configured expiration instant', + ); + assert.equal( + exactExpiryTokenExchanges, + 0, + 'an exact-expired facade binding is rejected before any provider token exchange', + ); + } finally { + Date.now = originalDateNow; + } + + for (let index = 0; index < 256; index += 1) { + const pending = await app.request('/api/auth/oidc/start'); + assert.equal( + pending.status, + 302, + 'OIDC state capacity must admit flows until the bounded in-memory state budget is full', + ); + } + const saturated = await app.request('/api/auth/oidc/start'); + assert.equal( + saturated.status, + 503, + 'OIDC state capacity must fail closed instead of allowing unbounded in-memory growth', + ); + assert.deepEqual( + await saturated.json(), + { error: 'OIDC temporarily unavailable' }, + 'capacity exhaustion returns a stable non-secret degraded-mode response', + ); +} finally { + Date.now = originalDateNow; + AbortSignal.timeout = originalTimeout; + globalThis.fetch = originalFetch; + delete process.env.SCOPEWEAVE_DEV; +} + +console.log('oidc discovery, private-endpoint, validation, not-before, cancellation, inclusive facade expiry, redirect, timeout, bounded per-kid JWKS reuse, and state-capacity regression passed'); \ No newline at end of file diff --git a/tests/api/orchestrator-attribution.test.mjs b/tests/api/orchestrator-attribution.test.mjs index d07460a3..15b47dae 100644 --- a/tests/api/orchestrator-attribution.test.mjs +++ b/tests/api/orchestrator-attribution.test.mjs @@ -8,8 +8,13 @@ process.env.ORCHESTRATOR_TOKEN = 'secret-token'; process.env.ORCHESTRATOR_MODEL = 'nvidia/nemotron-3-super-120b-a12b'; const providerCalls = []; -globalThis.fetch = async (url, init) => { - providerCalls.push({ url: String(url), init }); +globalThis.fetch = async (input, init) => { + const request = new Request(input, init); + providerCalls.push({ + url: request.url, + method: request.method, + body: request.body ? await request.clone().text() : '', + }); return new Response(JSON.stringify({ choices: [{ message: { content: 'Grounded production response' } }], }), { @@ -62,7 +67,8 @@ response = await jsonRequest(`/api/projects/${projectId}/ai/brief`, { assert.equal(response.status, 200, 'authorized owner receives AI briefing'); assert.equal(providerCalls.length, 1, 'authorized briefing performs one provider call'); assert.equal(providerCalls[0].url, 'https://orchestrator.example/v1/chat/completions'); -const providerBody = JSON.parse(providerCalls[0].init.body); +assert.equal(providerCalls[0].method, 'POST', 'orchestrator transport preserves POST semantics'); +const providerBody = JSON.parse(providerCalls[0].body); assert.deepEqual( providerBody.attribution, { service: 'scopeweave', account: String(owner.orgId) }, diff --git a/tests/api/review-regressions.test.mjs b/tests/api/review-regressions.test.mjs new file mode 100644 index 00000000..da7aedc3 --- /dev/null +++ b/tests/api/review-regressions.test.mjs @@ -0,0 +1,437 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; + +const forwardedFetches = []; +globalThis.fetch = async (input, init) => { + const forwarded = new Request(input, init); + const forwardedBody = forwarded.body ? await forwarded.text() : ''; + forwardedFetches.push({ + url: forwarded.url, + method: forwarded.method, + body: forwardedBody, + }); + return new Response(forwardedBody, { status: 200 }); +}; + +const { app } = await import('../../server/app.mjs'); +const { app: coreApp } = await import('../../server/app_core.mjs'); +const { db } = await import('../../server/db.mjs'); + +const body = (value) => JSON.stringify(value); +const request = (target, options = {}) => app.request(target, { + ...options, + headers: { + 'content-type': 'application/json', + ...(options.headers || {}), + }, +}); + +function prometheusMetric(text, name) { + const match = text.match(new RegExp(`^${name}\\s+(-?\\d+(?:\\.\\d+)?)$`, 'm')); + assert.ok(match, `Prometheus output includes ${name}`); + return Number(match[1]); +} + +async function createOwner(email) { + const response = await request('/api/auth/signup', { + method: 'POST', + body: body({ email, password: 'password123', name: 'Review Regression' }), + }); + assert.equal(response.status, 200, `signup succeeds for ${email}`); + const { token } = await response.json(); + const me = await request('/api/me', { + headers: { authorization: `Bearer ${token}` }, + }); + assert.equal(me.status, 200, 'owner session resolves'); + const payload = await me.json(); + return { token, user: payload.user, org: payload.orgs[0] }; +} + +test('unauthenticated webhook registration rejects before consuming the request body', async () => { + let bodyPulls = 0; + const requestBody = new ReadableStream({ + pull(controller) { + bodyPulls += 1; + controller.enqueue(new TextEncoder().encode(body({ + url: 'http://127.0.0.1/private', + events: ['project.update'], + }))); + controller.close(); + }, + }, { highWaterMark: 0 }); + const unauthenticated = new Request( + 'http://localhost/api/orgs/not-authorized/webhooks', + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: requestBody, + duplex: 'half', + }, + ); + + const response = await app.request(unauthenticated); + assert.equal(response.status, 401, 'authentication rejects the request'); + assert.equal( + bodyPulls, + 0, + 'the webhook payload is not parsed or buffered before authentication succeeds', + ); +}); + +test('invalid webhook credentials cannot force unbounded pre-auth body buffering', async () => { + let bodyPulls = 0; + const chunk = new Uint8Array(8 * 1024).fill(0x20); + const requestBody = new ReadableStream({ + pull(controller) { + bodyPulls += 1; + if (bodyPulls > 20) { + controller.close(); + return; + } + controller.enqueue(chunk); + }, + }, { highWaterMark: 0 }); + const invalidCredentialRequest = new Request( + 'http://localhost/api/orgs/not-authorized/webhooks', + { + method: 'POST', + headers: { + authorization: 'Bearer definitely-not-a-valid-session', + 'content-type': 'application/json', + }, + body: requestBody, + duplex: 'half', + }, + ); + + const response = await app.request(invalidCredentialRequest); + assert.equal(response.status, 401, 'invalid credentials remain unauthorized'); + assert.ok( + bodyPulls <= 3, + `pre-auth webhook parsing must stop at the bounded request budget; observed ${bodyPulls} pulls`, + ); +}); + +test('public auth rejects an oversized streaming body before unbounded buffering', async () => { + let bodyPulls = 0; + const chunk = new Uint8Array(8 * 1024).fill(0x20); + const requestBody = new ReadableStream({ + pull(controller) { + bodyPulls += 1; + if (bodyPulls > 20) { + controller.close(); + return; + } + controller.enqueue(chunk); + }, + }, { highWaterMark: 0 }); + const oversizedLogin = new Request( + 'http://localhost/api/auth/login', + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: requestBody, + duplex: 'half', + }, + ); + + const response = await app.request(oversizedLogin); + assert.equal( + response.status, + 413, + 'public login rejects a body that exceeds the bounded authentication budget', + ); + assert.deepEqual( + await response.json(), + { error: 'authentication request body too large' }, + 'oversized public authentication uses a stable non-secret rejection contract', + ); + assert.ok( + bodyPulls <= 3, + `authentication parsing must stop at the bounded request budget; observed ${bodyPulls} pulls`, + ); +}); + +test('actual webhook deliveries stay behind the SSRF destination policy without replacing global fetch', async () => { + const { token, org } = await createOwner('webhook-delivery-boundary@scopeweave.test'); + const created = await request('/api/projects', { + method: 'POST', + headers: { authorization: `Bearer ${token}` }, + body: body({ name: 'Webhook Boundary', orgId: org.id }), + }); + assert.equal(created.status, 200, 'project creation succeeds'); + const project = await created.json(); + + const inserted = db.prepare( + 'INSERT INTO webhooks(org_id,url,secret,events) VALUES(?,?,?,?)', + ).run(org.id, 'https://127.0.0.1/internal', 'whsec_review_regression', 'project.update'); + const webhookId = Number(inserted.lastInsertRowid); + const forwardedBefore = forwardedFetches.length; + + const updated = await request(`/api/projects/${project.id}`, { + method: 'PUT', + headers: { authorization: `Bearer ${token}` }, + body: body({ name: 'Webhook Boundary', version: 1, tasks: [] }), + }); + assert.equal(updated.status, 200, 'customer save succeeds while webhook delivery is isolated'); + + const deliveryQuery = db.prepare( + 'SELECT status_code AS statusCode, ok, attempt FROM webhook_deliveries WHERE webhook_id = ? ORDER BY attempt', + ); + let deliveries = []; + const deadline = Date.now() + 1200; + while (Date.now() < deadline) { + deliveries = deliveryQuery.all(webhookId); + if (deliveries.length >= 2) break; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + + assert.deepEqual( + deliveries.map(({ statusCode, ok, attempt }) => ({ statusCode, ok, attempt })), + [ + { statusCode: null, ok: 0, attempt: 1 }, + { statusCode: null, ok: 0, attempt: 2 }, + ], + 'private webhook destinations fail closed on both bounded attempts', + ); + assert.equal( + forwardedFetches.length, + forwardedBefore, + 'webhook delivery never reaches the caller-owned process fetch implementation', + ); +}); + +test('non-webhook Request inputs preserve their body through the fetch facade', async () => { + const upstream = new Request('https://api.example.test/echo', { + method: 'POST', + headers: { 'content-type': 'text/plain' }, + body: 'request-body-must-survive', + }); + + const response = await globalThis.fetch(upstream); + assert.equal(response.status, 200, 'the underlying fetch receives a usable Request'); + assert.equal(await response.text(), 'request-body-must-survive'); + assert.deepEqual( + forwardedFetches.at(-1), + { + url: 'https://api.example.test/echo', + method: 'POST', + body: 'request-body-must-survive', + }, + 'fallback fetch receives the effective Request instead of the already-consumed original', + ); +}); + +test('signup and login use one canonical email identity', async () => { + const response = await request('/api/auth/signup', { + method: 'POST', + body: body({ + email: ' Mixed.Case@ScopeWeave.Test ', + password: 'password123', + name: 'Mixed Case', + }), + }); + assert.equal(response.status, 200, 'mixed-case signup succeeds'); + const { token } = await response.json(); + + const me = await request('/api/me', { + headers: { authorization: `Bearer ${token}` }, + }); + assert.equal(me.status, 200); + assert.equal((await me.json()).user.email, 'mixed.case@scopeweave.test'); + + const login = await request('/api/auth/login', { + method: 'POST', + body: body({ email: 'MIXED.CASE@SCOPEWEAVE.TEST', password: 'password123' }), + }); + assert.equal(login.status, 200, 'case-insensitive canonical login succeeds'); + + const duplicate = await request('/api/auth/signup', { + method: 'POST', + body: body({ + email: 'mixed.case@scopeweave.test', + password: 'password456', + name: 'Duplicate', + }), + }); + assert.equal(duplicate.status, 409, 'canonical duplicate identity is rejected'); +}); + +test('audit pagination rejects non-positive limits instead of expanding to the full tenant history', async () => { + const { token, user, org } = await createOwner('audit-limit@scopeweave.test'); + const insert = db.prepare( + 'INSERT INTO audit_log(org_id,user_id,action,target_type,target_id,meta) VALUES(?,?,?,?,?,?)', + ); + for (let index = 0; index < 125; index += 1) { + insert.run(org.id, user.id, 'review.regression', 'test_event', String(index), null); + } + + const response = await request(`/api/orgs/${org.id}/audit?limit=-1`, { + headers: { authorization: `Bearer ${token}` }, + }); + assert.equal(response.status, 200); + const { events } = await response.json(); + assert.equal(events.length, 100, 'invalid negative limit falls back to the bounded default'); +}); + +test('webhook authorization probing never treats an arbitrary 400 as authorization success', async () => { + const { token, org } = await createOwner('webhook-probe@scopeweave.test'); + const target = `/api/orgs/${org.id}/webhooks`; + + const denied = await request(target, { + method: 'POST', + body: body({ url: 'http://127.0.0.1/private', events: ['project.update'] }), + }); + assert.equal(denied.status, 401, 'destination validation never bypasses authentication'); + + const originalCoreFetch = coreApp.fetch; + let facadeResponse; + coreApp.fetch = async () => Response.json( + { error: 'unrelated controlled-probe failure' }, + { status: 400 }, + ); + try { + facadeResponse = await request(target, { + method: 'POST', + headers: { authorization: `Bearer ${token}` }, + body: body({ url: 'http://127.0.0.1/private', events: ['project.update'] }), + }); + } finally { + coreApp.fetch = originalCoreFetch; + } + + assert.equal(facadeResponse.status, 400); + assert.deepEqual( + await facadeResponse.json(), + { error: 'unrelated controlled-probe failure' }, + 'only an explicit successful authorization probe may be replaced by the public destination-policy error', + ); +}); + +test('facade webhook rejection is observed as the real POST exactly once', async () => { + const { token, org } = await createOwner('facade-observability@scopeweave.test'); + const before = await (await request('/api/metrics')).json(); + + const rejected = await request(`/api/orgs/${org.id}/webhooks`, { + method: 'POST', + headers: { authorization: `Bearer ${token}` }, + body: body({ url: 'http://127.0.0.1/private', events: ['project.update'] }), + }); + assert.equal(rejected.status, 400, 'authorized private destination is rejected by the facade'); + + const after = await (await request('/api/metrics')).json(); + assert.equal( + after.requests, + before.requests + 2, + 'metrics include the baseline metrics GET and one customer-visible rejected POST, not an internal probe', + ); + assert.equal( + after.s2xx, + before.s2xx + 1, + 'the internal authorization probe is not counted as a successful customer request', + ); + assert.equal( + after.s4xx, + before.s4xx + 1, + 'the facade-generated 400 is counted as the customer-visible request outcome', + ); +}); + +test('oversized webhook authorization probe is not observed as a synthetic core request', async () => { + const { token, org } = await createOwner('oversized-webhook-observability@scopeweave.test'); + const coreBefore = await (await coreApp.request('/api/metrics')).json(); + + const rejected = await request(`/api/orgs/${org.id}/webhooks`, { + method: 'POST', + headers: { + authorization: `Bearer ${token}`, + 'content-length': String(17 * 1024), + }, + body: body({ url: 'https://hooks.example.test', events: ['project.update'] }), + }); + assert.equal(rejected.status, 413, 'authorized oversized registration is rejected by the facade'); + + const coreAfter = await (await coreApp.request('/api/metrics')).json(); + assert.equal( + coreAfter.requests, + coreBefore.requests + 1, + 'the authorization probe must not appear as a second customer request in core metrics', + ); + assert.equal( + coreAfter.s2xx, + coreBefore.s2xx + 1, + 'only the baseline core metrics request is observed between snapshots', + ); + assert.equal( + coreAfter.s4xx, + coreBefore.s4xx, + 'the probe 400 must not be recorded in place of the customer-visible 413', + ); + + const combined = await (await request('/api/metrics')).json(); + assert.ok( + combined.s4xx >= coreAfter.s4xx + 1, + 'the customer-visible facade rejection remains represented in combined metrics', + ); +}); + +test('facade OIDC rejection is observed as the real request exactly once', async () => { + const before = await (await request('/api/metrics')).json(); + + const rejected = await request('/api/auth/oidc/start'); + assert.equal(rejected.status, 404, 'unconfigured production OIDC remains hidden as not found'); + + const after = await (await request('/api/metrics')).json(); + assert.equal( + after.requests, + before.requests + 2, + 'metrics include the baseline metrics GET and one facade-rejected OIDC request', + ); + assert.equal( + after.s2xx, + before.s2xx + 1, + 'only the follow-up metrics request increments the success class', + ); + assert.equal( + after.s4xx, + before.s4xx + 1, + 'the facade-generated OIDC 404 is counted as the customer-visible outcome', + ); +}); + +test('Prometheus metrics include facade-only request outcomes', async () => { + const beforeText = await (await request('/api/metrics?format=prometheus')).text(); + const before = { + requests: prometheusMetric(beforeText, 'scopeweave_requests'), + s2xx: prometheusMetric(beforeText, 'scopeweave_s2xx'), + s4xx: prometheusMetric(beforeText, 'scopeweave_s4xx'), + }; + + const rejected = await request('/api/auth/oidc/start'); + assert.equal(rejected.status, 404, 'facade-only OIDC rejection is reproduced'); + + const afterText = await (await request('/api/metrics?format=prometheus')).text(); + const after = { + requests: prometheusMetric(afterText, 'scopeweave_requests'), + s2xx: prometheusMetric(afterText, 'scopeweave_s2xx'), + s4xx: prometheusMetric(afterText, 'scopeweave_s4xx'), + }; + assert.equal( + after.requests, + before.requests + 2, + 'Prometheus request totals include the baseline scrape and the facade-only rejection', + ); + assert.equal( + after.s2xx, + before.s2xx + 1, + 'Prometheus success totals include only the baseline scrape', + ); + assert.equal( + after.s4xx, + before.s4xx + 1, + 'Prometheus client-error totals include the facade-only rejection', + ); +}); diff --git a/tests/api/static-prototype-pollution.test.mjs b/tests/api/static-prototype-pollution.test.mjs new file mode 100644 index 00000000..a0e91931 --- /dev/null +++ b/tests/api/static-prototype-pollution.test.mjs @@ -0,0 +1,38 @@ +// Regression: static-file allowlists must not inherit attacker-controlled Object.prototype entries. +// Run: node tests/api/static-prototype-pollution.test.mjs +import assert from 'node:assert/strict'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; + +const probePath = '/prototype-pollution-probe'; +// Keep this inherited property non-enumerable so the regression isolates the +// static allowlist lookup itself. An enumerable slash-prefixed Object.prototype +// key is consumed by Hono/Undici's header-object machinery before routing and +// fails there as an invalid HTTP header name, which does not exercise the +// ScopeWeave static-map boundary this test is designed to protect. +Object.defineProperty(Object.prototype, probePath, { + configurable: true, + value: ['package.json', 'application/json; charset=utf-8'], +}); + +try { + const { app } = await import('../../server/app.mjs'); + const response = await app.request(probePath); + const responseBody = await response.text(); + + assert.equal( + response.status, + 404, + 'prototype-polluted static lookup must not resolve inherited allowlist entries', + ); + assert.doesNotMatch( + responseBody, + /"name"\s*:\s*"scopeweave"/, + 'prototype pollution must never expose package metadata through the static route', + ); +} finally { + delete Object.prototype[probePath]; +} + +console.log('✓ static prototype-pollution regression passed'); diff --git a/tests/api/webhook-destination-policy.test.mjs b/tests/api/webhook-destination-policy.test.mjs new file mode 100644 index 00000000..a959dddd --- /dev/null +++ b/tests/api/webhook-destination-policy.test.mjs @@ -0,0 +1,110 @@ +import assert from 'node:assert/strict'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +delete process.env.SCOPEWEAVE_DEV; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; + +const { app } = await import('../../server/app.mjs'); + +const request = (path, options = {}) => app.request(path, { + ...options, + headers: { + 'content-type': 'application/json', + ...(options.headers || {}), + }, +}); +const json = (value) => JSON.stringify(value); + +let response = await request('/api/auth/signup', { + method: 'POST', + body: json({ email: 'webhook-owner@example.test', password: 'password123', name: 'Webhook Owner' }), +}); +assert.equal(response.status, 200, 'fixture owner signup succeeds'); +const signup = await response.json(); +const authorization = { authorization: `Bearer ${signup.token}` }; + +response = await request('/api/me', { headers: authorization }); +assert.equal(response.status, 200, 'fixture owner can resolve organization'); +const me = await response.json(); +const organizationId = me.orgs[0].id; + +for (const headers of [{}, { authorization: 'Bearer invalid-token' }]) { + response = await request(`/api/orgs/${organizationId}/webhooks`, { + method: 'POST', + headers, + body: json({ url: 'http://127.0.0.1/private', events: ['project.updated'] }), + }); + assert.equal(response.status, 401, 'destination policy never preempts authentication'); + assert.deepEqual(await response.json(), { error: 'unauthorized' }); +} + +const deniedDestinations = [ + 'http://example.com/hook', + 'https://localhost/hook', + 'https://api.localhost/hook', + 'https://127.0.0.1/hook', + 'https://2130706433/hook', + 'https://0x7f000001/hook', + 'https://169.254.169.254/latest/meta-data', + 'https://10.0.0.8/hook', + 'https://192.168.50.12/hook', + 'https://[::1]/hook', + 'https://[fc00::1]/hook', + 'https://[::ffff:127.0.0.1]/hook', + 'https://user:password@example.com/hook', + 'https://example.com/hook#fragment', +]; + +for (const url of deniedDestinations) { + response = await request(`/api/orgs/${organizationId}/webhooks`, { + method: 'POST', + headers: authorization, + body: json({ url, events: ['project.updated'] }), + }); + assert.equal(response.status, 400, `production webhook registration rejects unsafe destination ${url}`); + assert.deepEqual( + await response.json(), + { error: 'valid public https webhook URL required' }, + 'registration failure stays stable and does not disclose resolver or address details', + ); +} + +response = await request(`/api/orgs/${organizationId}/webhooks`, { + method: 'POST', + headers: authorization, + body: json({ url: 'https://hooks.example.com/scopeweave?tenant=buyer', events: ['project.updated'] }), +}); +assert.equal(response.status, 200, 'canonical public HTTPS webhook registration remains supported'); +const created = await response.json(); +assert.equal(created.url, 'https://hooks.example.com/scopeweave?tenant=buyer'); +assert.equal(created.events, 'project.updated'); +assert.match(created.secret, /^whsec_[A-Za-z0-9_-]+$/, 'secret is returned only at creation'); + +response = await request(`/api/orgs/${organizationId}/webhooks`, { + method: 'POST', + headers: authorization, + body: json({ + url: 'HTTPS://HOOKS.EXAMPLE.COM:443/staging/../scopeweave?tenant=buyer', + events: ['project.updated'], + }), +}); +assert.equal(response.status, 200, 'equivalent public HTTPS spelling remains accepted'); +const canonicalized = await response.json(); +assert.equal( + canonicalized.url, + 'https://hooks.example.com/scopeweave?tenant=buyer', + 'registration persists and returns the canonical authority/path rather than attacker-controlled spelling', +); + +response = await request(`/api/orgs/${organizationId}/webhooks`, { + headers: authorization, +}); +assert.equal(response.status, 200, 'owner can inspect registered webhook destinations'); +const listing = await response.json(); +assert.equal( + listing.webhooks.find((webhook) => webhook.id === canonicalized.id)?.url, + 'https://hooks.example.com/scopeweave?tenant=buyer', + 'canonical destination is durable in storage and therefore reused by later delivery attempts', +); + +console.log('webhook destination registration policy tests passed'); diff --git a/tests/api/webhook-legacy-migration.test.mjs b/tests/api/webhook-legacy-migration.test.mjs new file mode 100644 index 00000000..9755e1c0 --- /dev/null +++ b/tests/api/webhook-legacy-migration.test.mjs @@ -0,0 +1,172 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { DatabaseSync } from 'node:sqlite'; + +const directory = mkdtempSync(join(tmpdir(), 'scopeweave-webhook-migration-')); +const databasePath = join(directory, 'legacy.sqlite'); +const legacy = new DatabaseSync(databasePath); +legacy.exec(` +CREATE TABLE users ( + id INTEGER PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + name TEXT NOT NULL DEFAULT '', + token_version INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE TABLE orgs ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + owner_id INTEGER NOT NULL REFERENCES users(id), + plan TEXT NOT NULL DEFAULT 'free', + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE TABLE webhooks ( + id INTEGER PRIMARY KEY, + org_id INTEGER NOT NULL REFERENCES orgs(id) ON DELETE CASCADE, + url TEXT NOT NULL, + secret TEXT NOT NULL, + events TEXT NOT NULL DEFAULT '*', + active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +INSERT INTO users(id,email,password_hash,name) VALUES(1,'legacy-owner@example.test','unused','Legacy Owner'); +INSERT INTO orgs(id,name,owner_id) VALUES(1,'Legacy Buyer',1); +INSERT INTO webhooks(id,org_id,url,secret,events,active) +VALUES(41,1,'http://legacy-webhook.example.test/callback','whsec_legacy','project.update',1); +`); +legacy.close(); + +process.env.SCOPEWEAVE_DB = databasePath; +let locker = null; + +function waitForMarker(child, marker) { + return new Promise((resolve, reject) => { + let output = ''; + let stderr = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stderr.on('data', (chunk) => { + stderr += chunk; + }); + const onData = (chunk) => { + output += chunk; + if (output.includes(marker)) { + child.stdout.off('data', onData); + resolve(); + } + }; + child.stdout.on('data', onData); + child.once('error', reject); + child.once('exit', (code) => { + if (!output.includes(marker)) { + reject(new Error(`lock helper exited before ${marker.trim()}: ${code}; ${stderr}`)); + } + }); + }); +} + +try { + const moduleUrl = pathToFileURL(join(process.cwd(), 'server', 'db.mjs')).href; + const first = await import(`${moduleUrl}?legacy-http-migration=first`); + const migrated = { + ...first.db.prepare( + 'SELECT active, blocked_reason AS blockedReason FROM webhooks WHERE id = 41', + ).get(), + }; + assert.deepEqual( + migrated, + { active: 0, blockedReason: 'insecure_scheme' }, + 'legacy HTTP webhook rows are disabled and explicitly marked instead of silently failing on every delivery', + ); + + const firstAudit = first.db.prepare( + `SELECT action, target_type AS targetType, target_id AS targetId, meta + FROM audit_log + WHERE org_id = 1 AND action = 'webhook.security_block' AND target_id = '41'`, + ).all(); + assert.equal(firstAudit.length, 1, 'migration emits one durable security audit event'); + assert.equal(firstAudit[0].targetType, 'webhook'); + assert.deepEqual( + JSON.parse(firstAudit[0].meta), + { + reason: 'insecure_scheme', + nextAction: 'register_public_https_replacement', + }, + 'audit evidence records the policy reason and remediation contract without exposing the webhook secret', + ); + first.db.close(); + + const second = await import(`${moduleUrl}?legacy-http-migration=second`); + const secondAudit = second.db.prepare( + `SELECT COUNT(*) AS count + FROM audit_log + WHERE org_id = 1 AND action = 'webhook.security_block' AND target_id = '41'`, + ).get(); + assert.equal(secondAudit.count, 1, 'restarting after migration does not duplicate buyer audit evidence'); + assert.deepEqual( + { + ...second.db.prepare( + 'SELECT active, blocked_reason AS blockedReason FROM webhooks WHERE id = 41', + ).get(), + }, + { active: 0, blockedReason: 'insecure_scheme' }, + 'migration remains fail-closed and idempotent on subsequent starts', + ); + second.db.close(); + + const lockedDatabasePath = join(directory, 'transient-lock.sqlite'); + const bootstrap = new DatabaseSync(lockedDatabasePath); + bootstrap.exec(` + PRAGMA journal_mode = WAL; + CREATE TABLE lock_probe ( + probe_id INTEGER PRIMARY KEY, + probe_value TEXT NOT NULL + ); + `); + bootstrap.close(); + + const lockScript = String.raw` + const { DatabaseSync } = require('node:sqlite'); + const db = new DatabaseSync(process.env.SCOPEWEAVE_LOCK_DB); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('BEGIN IMMEDIATE'); + db.prepare('INSERT INTO lock_probe(probe_value) VALUES (?)').run('held'); + process.stdout.write('locked\n'); + setTimeout(() => { + db.exec('COMMIT'); + db.close(); + }, 300); + `; + locker = spawn(process.execPath, ['-e', lockScript], { + env: { ...process.env, SCOPEWEAVE_LOCK_DB: lockedDatabasePath }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + await waitForMarker(locker, 'locked\n'); + + process.env.SCOPEWEAVE_DB = lockedDatabasePath; + const concurrent = await import(`${moduleUrl}?legacy-http-migration=transient-lock`); + concurrent.db.close(); + + if (locker.exitCode === null) { + const [exitCode] = await once(locker, 'exit'); + assert.equal(exitCode, 0, 'transient lock helper exits cleanly after releasing its writer lock'); + } else { + assert.equal(locker.exitCode, 0, 'transient lock helper exits cleanly after releasing its writer lock'); + } + locker = null; +} finally { + if (locker && locker.exitCode === null) { + locker.kill(); + await once(locker, 'exit').catch(() => {}); + } + delete process.env.SCOPEWEAVE_DB; + rmSync(directory, { recursive: true, force: true }); +} + +console.log('legacy HTTP webhook migration regression passed'); \ No newline at end of file diff --git a/tests/api/webhook-legacy-private-destination-migration.test.mjs b/tests/api/webhook-legacy-private-destination-migration.test.mjs new file mode 100644 index 00000000..dea8d2c6 --- /dev/null +++ b/tests/api/webhook-legacy-private-destination-migration.test.mjs @@ -0,0 +1,164 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { DatabaseSync } from 'node:sqlite'; +import { migrateLegacyWebhookDestinations } from '../../server/webhook_legacy_migration.mjs'; + +const directory = mkdtempSync(join(tmpdir(), 'scopeweave-webhook-private-migration-')); +const databasePath = join(directory, 'legacy-private.sqlite'); +const legacy = new DatabaseSync(databasePath); + +legacy.exec(` +CREATE TABLE users ( + id INTEGER PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + name TEXT NOT NULL DEFAULT '', + token_version INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE TABLE orgs ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + owner_id INTEGER NOT NULL REFERENCES users(id), + plan TEXT NOT NULL DEFAULT 'free', + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE TABLE webhooks ( + id INTEGER PRIMARY KEY, + org_id INTEGER NOT NULL REFERENCES orgs(id) ON DELETE CASCADE, + url TEXT NOT NULL, + secret TEXT NOT NULL, + events TEXT NOT NULL DEFAULT '*', + active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +INSERT INTO users(id,email,password_hash,name) +VALUES(1,'legacy-owner@example.test','unused','Legacy Owner'); +INSERT INTO orgs(id,name,owner_id) VALUES(1,'Legacy Buyer',1); +INSERT INTO webhooks(id,org_id,url,secret,events,active) VALUES + (42,1,'https://127.0.0.1/private','whsec_private','project.update',1), + (43,1,'https://hooks.example.test/callback','whsec_public','project.update',1); +`); +legacy.close(); + +process.env.SCOPEWEAVE_DB = databasePath; + +try { + const moduleUrl = pathToFileURL(join(process.cwd(), 'server', 'db.mjs')).href; + const first = await import(`${moduleUrl}?legacy-private-migration=first`); + + assert.deepEqual( + { + ...first.db.prepare( + 'SELECT active, blocked_reason AS blockedReason FROM webhooks WHERE id = 42', + ).get(), + }, + { active: 0, blockedReason: 'destination_policy' }, + 'legacy HTTPS destinations rejected by the current registration policy are disabled before delivery retries begin', + ); + assert.deepEqual( + { + ...first.db.prepare( + 'SELECT active, blocked_reason AS blockedReason FROM webhooks WHERE id = 43', + ).get(), + }, + { active: 1, blockedReason: null }, + 'legacy public HTTPS destinations remain enabled', + ); + + const firstAudit = first.db.prepare( + `SELECT action, target_type AS targetType, target_id AS targetId, meta + FROM audit_log + WHERE org_id = 1 AND action = 'webhook.security_block' AND target_id = '42'`, + ).all(); + assert.equal(firstAudit.length, 1, 'policy-incompatible legacy HTTPS rows emit one durable security audit event'); + assert.equal(firstAudit[0].targetType, 'webhook'); + assert.deepEqual( + JSON.parse(firstAudit[0].meta), + { + reason: 'destination_policy', + nextAction: 'register_public_https_replacement', + }, + 'audit evidence explains why delivery was blocked and gives the tenant a concrete replacement action', + ); + first.db.close(); + + const second = await import(`${moduleUrl}?legacy-private-migration=second`); + assert.equal( + second.db.prepare( + `SELECT COUNT(*) AS count + FROM audit_log + WHERE org_id = 1 AND action = 'webhook.security_block' AND target_id = '42'`, + ).get().count, + 1, + 'restarting after migration does not duplicate tenant audit evidence', + ); + assert.deepEqual( + { + ...second.db.prepare( + 'SELECT active, blocked_reason AS blockedReason FROM webhooks WHERE id = 42', + ).get(), + }, + { active: 0, blockedReason: 'destination_policy' }, + 'policy-incompatible legacy destinations remain fail-closed on later starts', + ); + assert.deepEqual( + { + ...second.db.prepare( + 'SELECT active, blocked_reason AS blockedReason FROM webhooks WHERE id = 43', + ).get(), + }, + { active: 1, blockedReason: null }, + 'policy-compliant legacy destinations remain active on later starts', + ); + second.db.close(); +} finally { + delete process.env.SCOPEWEAVE_DB; + rmSync(directory, { recursive: true, force: true }); +} + +const contentionDirectory = mkdtempSync( + join(tmpdir(), 'scopeweave-webhook-private-migration-contention-'), +); +const contentionPath = join(contentionDirectory, 'scopeweave.sqlite'); +const contended = new DatabaseSync(contentionPath); +contended.exec(` + PRAGMA busy_timeout = 0; + CREATE TABLE webhooks ( + id INTEGER PRIMARY KEY, + org_id INTEGER NOT NULL, + url TEXT NOT NULL, + active INTEGER NOT NULL DEFAULT 1, + blocked_reason TEXT + ); + CREATE TABLE audit_log ( + id INTEGER PRIMARY KEY, + org_id INTEGER NOT NULL, + user_id INTEGER, + action TEXT NOT NULL, + target_type TEXT, + target_id TEXT, + meta TEXT + ); + INSERT INTO webhooks(id,org_id,url,active,blocked_reason) + VALUES(50,11,'https://hooks.example.test/callback',1,NULL); +`); +const writer = new DatabaseSync(contentionPath); + +try { + writer.exec('PRAGMA busy_timeout = 0; BEGIN IMMEDIATE;'); + assert.doesNotThrow( + () => assert.equal(migrateLegacyWebhookDestinations(contended), 0), + 'a compliant no-op startup migration must not reserve the SQLite writer', + ); +} finally { + writer.exec('ROLLBACK'); + writer.close(); + contended.close(); + rmSync(contentionDirectory, { recursive: true, force: true }); +} + +console.log('legacy private HTTPS webhook migration regression passed'); diff --git a/tests/fuzz/webhookTransport.fuzz.mjs b/tests/fuzz/webhookTransport.fuzz.mjs new file mode 100644 index 00000000..108afb92 --- /dev/null +++ b/tests/fuzz/webhookTransport.fuzz.mjs @@ -0,0 +1,62 @@ +// Property regression: the outbound webhook transport owns HTTP framing for +// the exact body bytes it writes. Caller-supplied Content-Length is untrusted +// metadata and must never be forwarded when it can disagree with that body. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import fc from 'fast-check'; +import { createWebhookTransport } from '../../server/webhook_transport.mjs'; + +const DEFAULT_RUNS = 3000; +const MAX_RUNS = 200000; + +const requestedRuns = (value) => { + if (value === undefined || value === '') return DEFAULT_RUNS; + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) return DEFAULT_RUNS; + return Math.min(parsed, MAX_RUNS); +}; +const RUNS = requestedRuns(process.env.FUZZ_RUNS); + +test('fuzz iteration budget preserves the documented default and workflow budgets', () => { + assert.equal(requestedRuns(undefined), 3000, 'local default stays at 3000 property cases'); + assert.equal(requestedRuns('20000'), 20000, 'pull-request workflow budget is honored'); + assert.equal(requestedRuns('200000'), 200000, 'scheduled workflow budget is honored'); + assert.equal(requestedRuns('200001'), 200000, 'operator input is bounded by the scheduled budget ceiling'); + assert.equal(requestedRuns('0'), 3000, 'zero falls back to the safe local default'); + assert.equal(requestedRuns('-1'), 3000, 'negative values fall back to the safe local default'); + assert.equal(requestedRuns('not-a-number'), 3000, 'invalid values fall back to the safe local default'); +}); + +test('webhook transport strips caller-supplied Content-Length before writing the body', async () => { + await fc.assert( + fc.asyncProperty(fc.string({ maxLength: 128 }), async (body) => { + let capturedOptions; + const transport = createWebhookTransport({ + lookup: async () => [{ address: '8.8.8.8', family: 4 }], + request: (_url, options, callback) => { + capturedOptions = options; + const req = new EventEmitter(); + req.end = (sentBody) => { + assert.equal(sentBody, body); + callback({ statusCode: 204, resume() {} }); + }; + return req; + }, + }); + + const result = await transport.post('https://hooks.example.com/events', { + headers: { + 'content-length': String(Buffer.byteLength(body) + 1), + 'x-scopeweave-test': 'framing-owner', + }, + body, + }); + + assert.equal(result.status, 204); + assert.equal(capturedOptions.headers['content-length'], undefined); + assert.equal(capturedOptions.headers['x-scopeweave-test'], 'framing-owner'); + }), + { numRuns: RUNS }, + ); +}); diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index 149440e5..325df689 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -34,11 +34,31 @@ assert.match( /--include=server\/clearfolio\.mjs/, 'the abortable Clearfolio adapter is instrumented', ); +assert.match( + scripts['test:coverage'], + /--include=server\/app_core\.mjs/, + 'the moved SaaS route graph remains instrumented after the security facade split', +); +assert.match( + scripts['test:coverage'], + /--include=server\/webhook_transport\.mjs/, + 'the outbound webhook SSRF transport is instrumented', +); assert.match( scripts['test:coverage:cases'], /tests\/unit\/clearfolio-status-signal\.test\.mjs/, 'the Clearfolio signal and HTTP failure regression executes under c8', ); +assert.match( + scripts['test:coverage:cases'], + /tests\/unit\/webhook-transport\.test\.mjs/, + 'the webhook DNS/pinning/redirect regression executes under c8', +); +assert.match( + scripts['test:api'], + /tests\/api\/webhook-destination-policy\.test\.mjs/, + 'the production webhook registration regression executes in the canonical API suite', +); assert.doesNotMatch( scripts['test:coverage:cases'], /npm run (?:coverage|test:coverage)(?:\s|$)/, diff --git a/tests/unit/public-https-transport.test.mjs b/tests/unit/public-https-transport.test.mjs new file mode 100644 index 00000000..0f784f6d --- /dev/null +++ b/tests/unit/public-https-transport.test.mjs @@ -0,0 +1,225 @@ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { + WebhookDestinationError, + WebhookTransportError, + createPublicHttpsTransport, +} from '../../server/webhook_transport.mjs'; + +const PUBLIC_A = { address: '93.184.216.34', family: 4 }; +const PUBLIC_B = { address: '93.184.216.35', family: 4 }; + +await assert.rejects( + createPublicHttpsTransport({ + lookup: async () => [PUBLIC_A, { address: '10.0.0.8', family: 4 }], + request: () => { + throw new Error('request must not run after mixed public/private DNS'); + }, + }).fetch('https://idp.example.test/.well-known/openid-configuration'), + WebhookDestinationError, + 'metadata transport fails closed when any current DNS answer is private', +); + +const attempts = []; +const transport = createPublicHttpsTransport({ + lookup: async () => [PUBLIC_A, PUBLIC_B], + request: (_url, options, callback) => { + const req = new EventEmitter(); + req.end = () => { + options.lookup('ignored.example', {}, (error, address, family) => { + assert.ifError(error); + attempts.push({ + address, + family, + agent: options.agent, + servername: options.servername, + method: options.method, + acceptEncoding: options.headers?.get?.('accept-encoding') + ?? options.headers?.['accept-encoding'], + }); + if (address === PUBLIC_A.address) { + queueMicrotask(() => req.emit('error', new Error('simulated first-address failure'))); + return; + } + const response = new EventEmitter(); + response.statusCode = 200; + response.headers = { 'content-type': 'application/json' }; + response.destroy = () => {}; + callback(response); + queueMicrotask(() => { + response.emit('data', Buffer.from('{"issuer":"https://idp.example.test"}')); + response.emit('end'); + }); + }); + }; + return req; + }, +}); + +const response = await transport.fetch( + 'https://idp.example.test/.well-known/openid-configuration', +); +assert.equal(response.status, 200); +assert.deepEqual(await response.json(), { issuer: 'https://idp.example.test' }); +assert.deepEqual( + attempts, + [ + { + ...PUBLIC_A, + agent: false, + servername: 'idp.example.test', + method: 'GET', + acceptEncoding: 'identity', + }, + { + ...PUBLIC_B, + agent: false, + servername: 'idp.example.test', + method: 'GET', + acceptEncoding: 'identity', + }, + ], + 'every fallback attempt is pinned, disables pooling, preserves SNI, and requests identity encoding', +); + +const nullBodyStatusTransport = createPublicHttpsTransport({ + lookup: async () => [PUBLIC_A], + request: (_url, _options, callback) => { + const req = new EventEmitter(); + req.end = () => { + const response = new EventEmitter(); + response.statusCode = 204; + response.headers = { 'content-type': 'application/json' }; + response.destroy = () => {}; + callback(response); + queueMicrotask(() => { + response.emit('data', Buffer.from('unexpected upstream bytes')); + response.emit('end'); + }); + }; + return req; + }, +}); +const nullBodyResponse = await nullBodyStatusTransport.fetch( + 'https://idp.example.test/no-content', + { signal: AbortSignal.timeout(250) }, +); +assert.equal(nullBodyResponse.status, 204); +assert.equal(await nullBodyResponse.text(), ''); + +const oversized = createPublicHttpsTransport({ + lookup: async () => [PUBLIC_A], + request: (_url, _options, callback) => { + const req = new EventEmitter(); + req.end = () => { + const response = new EventEmitter(); + response.statusCode = 200; + response.headers = {}; + response.destroy = () => {}; + callback(response); + queueMicrotask(() => response.emit('data', Buffer.alloc(9))); + }; + return req; + }, +}); +await assert.rejects( + oversized.fetch('https://idp.example.test/jwks', { maxResponseBytes: 8 }), + WebhookTransportError, + 'provider responses larger than the configured memory budget fail closed', +); + +let responseStartedAttempts = 0; +const noReplayAfterResponse = createPublicHttpsTransport({ + lookup: async () => [PUBLIC_A, PUBLIC_B], + request: (_url, options, callback) => { + responseStartedAttempts += 1; + const req = new EventEmitter(); + req.end = () => { + options.lookup('ignored.example', {}, (error, address) => { + assert.ifError(error); + const response = new EventEmitter(); + response.statusCode = 200; + response.headers = { 'content-type': 'application/json' }; + response.destroy = () => {}; + callback(response); + queueMicrotask(() => { + if (address === PUBLIC_A.address) { + response.emit('data', Buffer.alloc(9)); + return; + } + response.emit('data', Buffer.from('{}')); + response.emit('end'); + }); + }); + }; + return req; + }, +}); +await assert.rejects( + noReplayAfterResponse.fetch('https://idp.example.test/token', { + method: 'POST', + body: 'grant_type=authorization_code', + maxResponseBytes: 8, + }), + WebhookTransportError, + 'a response-stream failure must not replay an already-sent POST to another address', +); +assert.equal( + responseStartedAttempts, + 1, + 'only connection-establishment failures may advance to another validated address', +); + +let postTlsAttempts = 0; +let forwardedContentLength; +const noReplayAfterSecureConnect = createPublicHttpsTransport({ + lookup: async () => [PUBLIC_A, PUBLIC_B], + request: (_url, options) => { + postTlsAttempts += 1; + forwardedContentLength = options.headers?.get?.('content-length') + ?? options.headers?.['content-length']; + const req = new EventEmitter(); + req.end = () => { + options.lookup('ignored.example', {}, (error) => { + assert.ifError(error); + const socket = new EventEmitter(); + req.emit('socket', socket); + queueMicrotask(() => { + socket.emit('secureConnect'); + queueMicrotask(() => req.emit('error', new Error('peer closed after TLS handshake'))); + }); + }); + }; + return req; + }, +}); +await assert.rejects( + noReplayAfterSecureConnect.fetch('https://idp.example.test/token', { + method: 'POST', + headers: { 'content-length': '9999' }, + body: 'grant_type=authorization_code', + }), + WebhookTransportError, + 'a non-idempotent request must not replay after TLS is established even without response headers', +); +assert.equal( + postTlsAttempts, + 1, + 'a post-handshake failure is ambiguous and must not consume an authorization code twice', +); +assert.equal( + forwardedContentLength, + undefined, + 'public HTTPS transport owns body framing and strips stale caller content-length', +); + +await assert.rejects( + transport.fetch('https://idp.example.test/jwks', { maxResponseBytes: 0 }), + /positive safe integer/, +); +assert.throws( + () => createPublicHttpsTransport({ lookup: null }), + /dependencies must be functions/, +); + +console.log('public HTTPS DNS pinning, identity encoding, fallback, and response-bound regressions passed'); diff --git a/tests/unit/toast-accessibility.test.mjs b/tests/unit/toast-accessibility.test.mjs index c0aa79a0..5bff32e9 100644 --- a/tests/unit/toast-accessibility.test.mjs +++ b/tests/unit/toast-accessibility.test.mjs @@ -2,6 +2,9 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; + const indexHtml = readFileSync(new URL('../../index.html', import.meta.url), 'utf8'); const toastStateCss = readFileSync(new URL('../../toast-state.css', import.meta.url), 'utf8'); const cloudSyncJs = readFileSync(new URL('../../cloud-sync.js', import.meta.url), 'utf8'); @@ -34,12 +37,31 @@ test('sync status uses the same explicit advisory status semantics', () => { assert.doesNotMatch(syncStatus, /\btabindex\s*=/i, 'sync feedback does not become a synthetic keyboard stop'); }); -test('cloud toast stylesheet is on every production serve path', () => { - const serverApp = readFileSync(new URL('../../server/app.mjs', import.meta.url), 'utf8'); +test('cloud toast stylesheet is on every production serve path', async () => { + const serverFacade = readFileSync(new URL('../../server/app.mjs', import.meta.url), 'utf8'); + const serverCore = readFileSync(new URL('../../server/app_core.mjs', import.meta.url), 'utf8'); const pagesWorkflow = readFileSync(new URL('../../.github/workflows/pages.yml', import.meta.url), 'utf8'); const staticDockerfile = readFileSync(new URL('../../Dockerfile', import.meta.url), 'utf8'); const serverDockerfile = readFileSync(new URL('../../Dockerfile.server', import.meta.url), 'utf8'); - assert.match(serverApp, /['"]\/toast-state\.css['"]/, 'SaaS allowlist serves the cloud toast stylesheet'); + assert.match( + serverFacade, + /import\s+\{[^}]*\bapp\s+as\s+coreApp\b[^}]*\}\s+from\s+['"]\.\/app_core\.mjs['"]/, + 'SaaS security facade delegates to the route graph that owns static assets', + ); + const { app } = await import('../../server/app.mjs'); + const stylesheetResponse = await app.request('/toast-state.css'); + assert.equal(stylesheetResponse.status, 200, 'SaaS security facade serves the toast stylesheet'); + assert.match( + stylesheetResponse.headers.get('content-type') || '', + /^text\/css\b/i, + 'SaaS security facade preserves the stylesheet media type', + ); + assert.equal( + await stylesheetResponse.text(), + toastStateCss, + 'SaaS security facade preserves the exact core static-asset response', + ); + assert.match(serverCore, /['"]\/toast-state\.css['"]/, 'SaaS allowlist serves the cloud toast stylesheet'); assert.match(pagesWorkflow, /\btoast-state\.css\b/, 'GitHub Pages stages the cloud toast stylesheet'); assert.match(staticDockerfile, /\btoast-state\.css\b/, 'static image copies the cloud toast stylesheet'); assert.match(serverDockerfile, /\btoast-state\.css\b/, 'SaaS image copies the cloud toast stylesheet'); diff --git a/tests/unit/webhook-transport.test.mjs b/tests/unit/webhook-transport.test.mjs new file mode 100644 index 00000000..0916f306 --- /dev/null +++ b/tests/unit/webhook-transport.test.mjs @@ -0,0 +1,386 @@ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { + WebhookDestinationError, + WebhookTransportError, + createWebhookTransport, + isPublicWebhookAddress, + validateWebhookRegistrationUrl, +} from '../../server/webhook_transport.mjs'; + +assert.equal(isPublicWebhookAddress('8.8.8.8'), true); +assert.equal(isPublicWebhookAddress('2606:4700:4700::1111'), true); +for (const address of [ + 'not-an-ip', '0.0.0.0', '10.0.0.1', '100.64.0.1', '127.0.0.1', + '169.254.169.254', '172.16.0.1', '192.0.2.10', '192.31.196.1', + '192.52.193.1', '192.168.1.1', '192.175.48.1', '198.18.0.1', + '198.51.100.2', '203.0.113.9', '224.0.0.1', '255.255.255.255', + '::', '::1', '::ffff:127.0.0.1', '64:ff9b::1', '100::1', + '100:0:0:1::1', '2001::1', '2001:2::1', '2001:db8::1', '2002::1', + '2620:4f:8000::1', '3ffe::1', '3fff::1', '400::1', '4000::1', + '5f00::1', 'fc00::1', 'fec0::1', 'fe80::1', 'ff00::1', +]) { + assert.equal(isPublicWebhookAddress(address), false, `${address} is denied`); +} + +assert.equal( + validateWebhookRegistrationUrl('https://hooks.example.com/scopeweave?tenant=buyer'), + 'https://hooks.example.com/scopeweave?tenant=buyer', +); +assert.equal(validateWebhookRegistrationUrl('https://8.8.8.8/hook'), 'https://8.8.8.8/hook'); +assert.equal( + validateWebhookRegistrationUrl('https://[2606:4700:4700::1111]/hook'), + 'https://[2606:4700:4700::1111]/hook', +); +for (const url of [ + '', 'not a url', 'http://example.com/hook', + 'https://user:pass@example.com/hook', 'https://example.com/hook#fragment', + 'https://localhost/hook', 'https://api.localhost/hook', 'https://printer.local/hook', + 'https://home.arpa/hook', 'https://svc.home.arpa/hook', 'https://127.0.0.1/hook', + 'https://2130706433/hook', 'https://0x7f000001/hook', + 'https://[::1]/hook', 'https://[::ffff:127.0.0.1]/hook', +]) { + assert.throws( + () => validateWebhookRegistrationUrl(url), + WebhookDestinationError, + `${url} is rejected`, + ); +} +assert.throws(() => createWebhookTransport({ lookup: null }), TypeError); +assert.throws(() => createWebhookTransport({ request: null }), TypeError); + +function responseRequest(statusCode, capture = {}) { + return (url, options, callback) => { + capture.url = url; + capture.options = options; + capture.calls = (capture.calls || 0) + 1; + const req = new EventEmitter(); + req.end = (body) => { + capture.body = body; + queueMicrotask(() => callback({ + statusCode, + resume() { capture.resumed = true; }, + })); + }; + return req; + }; +} + +const capture = {}; +const publicTransport = createWebhookTransport({ + lookup: async (hostname, options) => { + assert.equal(hostname, 'hooks.example.com'); + assert.deepEqual(options, { all: true, verbatim: true }); + return [ + { address: '93.184.216.34', family: 4 }, + { address: '93.184.216.34', family: 4 }, + { address: '2606:4700:4700::1111', family: 6 }, + ]; + }, + request: responseRequest(204, capture), +}); +const sent = await publicTransport.post('https://hooks.example.com/a?x=1', { + headers: { 'x-test': 'yes' }, + body: '{"ok":true}', +}); +assert.deepEqual(sent, { status: 204, ok: true }); +assert.equal(capture.url.hostname, 'hooks.example.com'); +assert.equal(capture.options.method, 'POST'); +assert.equal(capture.options.agent, false); +assert.equal(capture.options.servername, 'hooks.example.com'); +assert.deepEqual(capture.options.headers, { 'x-test': 'yes' }); +assert.equal(capture.body, '{"ok":true}'); +assert.equal(capture.resumed, true); +await new Promise((resolve, reject) => { + capture.options.lookup('ignored.example', {}, (error, address, family) => { + try { + assert.equal(error, null); + assert.equal(address, '93.184.216.34'); + assert.equal(family, 4); + resolve(); + } catch (e) { reject(e); } + }); +}); +await new Promise((resolve, reject) => { + capture.options.lookup('ignored.example', { all: true }, (error, addresses) => { + try { + assert.equal(error, null); + assert.deepEqual(addresses, [{ address: '93.184.216.34', family: 4 }]); + resolve(); + } catch (e) { reject(e); } + }); +}); + +const redirectCapture = {}; +const redirectTransport = createWebhookTransport({ + lookup: async () => [{ address: '93.184.216.34', family: 4 }], + request: responseRequest(302, redirectCapture), +}); +assert.deepEqual( + await redirectTransport.post('https://hooks.example.com/redirect'), + { status: 302, ok: false }, +); +assert.equal(redirectCapture.calls, 1, 'native HTTPS does not follow redirects'); + +for (const answers of [ + [{ address: '127.0.0.1', family: 4 }], + [{ address: '93.184.216.34', family: 4 }, { address: '10.0.0.4', family: 4 }], + [{ address: 'bad-address', family: 4 }], + [{ address: '93.184.216.34', family: 7 }], +]) { + let requestCalls = 0; + const transport = createWebhookTransport({ + lookup: async () => answers, + request: (...args) => { + requestCalls++; + return responseRequest(200)(...args); + }, + }); + await assert.rejects( + () => transport.post('https://hooks.example.com/hook'), + WebhookDestinationError, + ); + assert.equal(requestCalls, 0, 'denied DNS answers never reach the connector'); +} + +for (const answers of [[], null]) { + const transport = createWebhookTransport({ + lookup: async () => answers, + request: responseRequest(200), + }); + await assert.rejects( + () => transport.post('https://hooks.example.com/hook'), + WebhookTransportError, + ); +} +const dnsFailure = createWebhookTransport({ + lookup: async () => { throw new Error('lookup 10.0.0.1 failed'); }, + request: responseRequest(200), +}); +await assert.rejects( + () => dnsFailure.post('https://hooks.example.com/hook'), + (error) => error instanceof WebhookTransportError + && error.message === 'webhook destination unavailable' + && !error.message.includes('10.0.0.1'), +); + +let generation = 0; +let reboundRequests = 0; +const rebindingTransport = createWebhookTransport({ + lookup: async () => (++generation === 1 + ? [{ address: '93.184.216.34', family: 4 }] + : [{ address: '127.0.0.1', family: 4 }]), + request: (...args) => { + reboundRequests++; + return responseRequest(503)(...args); + }, +}); +assert.deepEqual( + await rebindingTransport.post('https://hooks.example.com/hook'), + { status: 503, ok: false }, +); +await assert.rejects( + () => rebindingTransport.post('https://hooks.example.com/hook'), + WebhookDestinationError, +); +assert.equal( + reboundRequests, + 1, + 'a later private DNS answer is rejected before a retry connection', +); + +let literalLookupCalls = 0; +const literalCapture = {}; +const literalTransport = createWebhookTransport({ + lookup: async () => { + literalLookupCalls++; + return []; + }, + request: responseRequest(200, literalCapture), +}); +assert.deepEqual( + await literalTransport.post('https://8.8.8.8/hook'), + { status: 200, ok: true }, +); +assert.equal(literalLookupCalls, 0); +assert.equal( + 'servername' in literalCapture.options, + false, + 'IP literals do not inject an SNI hostname', +); + +const candidateAnswers = [ + { address: '93.184.216.34', family: 4 }, + { address: '2606:4700:4700::1111', family: 6 }, +]; +const candidateAttempts = []; +const candidateOptions = []; +const fallbackTransport = createWebhookTransport({ + lookup: async () => candidateAnswers, + request: (_url, options, callback) => { + const req = new EventEmitter(); + req.end = () => { + candidateOptions.push({ agent: options.agent, servername: options.servername }); + options.lookup('ignored.example', {}, (error, address, family) => { + assert.equal(error, null); + candidateAttempts.push({ address, family }); + if (candidateAttempts.length === 1) { + queueMicrotask(() => req.emit('error', new Error('first public address unreachable'))); + return; + } + queueMicrotask(() => callback({ statusCode: 204, resume() {} })); + }); + }; + return req; + }, +}); +assert.deepEqual( + await fallbackTransport.post('https://hooks.example.com/hook'), + { status: 204, ok: true }, + 'a later policy-validated address is attempted when the first address cannot connect', +); +assert.deepEqual(candidateAttempts, candidateAnswers); +assert.deepEqual( + candidateOptions, + [ + { agent: false, servername: 'hooks.example.com' }, + { agent: false, servername: 'hooks.example.com' }, + ], + 'every fallback attempt disables pooling and preserves the original TLS authority', +); + +const protocolCapture = {}; +const protocolFailureTransport = createWebhookTransport({ + lookup: async () => candidateAnswers, + request: responseRequest(503, protocolCapture), +}); +assert.deepEqual( + await protocolFailureTransport.post('https://hooks.example.com/hook'), + { status: 503, ok: false }, +); +assert.equal( + protocolCapture.calls, + 1, + 'an HTTP response is authoritative and must not replay the signed body to another address', +); + +let postHandshakeAttempts = 0; +const noReplayAfterSecureConnect = createWebhookTransport({ + lookup: async () => candidateAnswers, + request: (_url, options) => { + postHandshakeAttempts += 1; + const req = new EventEmitter(); + req.end = () => { + options.lookup('ignored.example', {}, (error) => { + assert.equal(error, null); + const socket = new EventEmitter(); + req.emit('socket', socket); + queueMicrotask(() => { + socket.emit('secureConnect'); + queueMicrotask(() => req.emit('error', new Error('peer closed after TLS handshake'))); + }); + }); + }; + return req; + }, +}); +await assert.rejects( + () => noReplayAfterSecureConnect.post('https://hooks.example.com/hook', { + body: '{"signed":"payload"}', + }), + WebhookTransportError, + 'a signed webhook must not replay after TLS is established even without response headers', +); +assert.equal( + postHandshakeAttempts, + 1, + 'post-handshake delivery is ambiguous and must stop within the current webhook attempt', +); + +const exhaustedAttempts = []; +const exhaustedTransport = createWebhookTransport({ + lookup: async () => candidateAnswers, + request: (_url, options) => { + const req = new EventEmitter(); + req.end = () => { + options.lookup('ignored.example', {}, (error, address, family) => { + assert.equal(error, null); + exhaustedAttempts.push({ address, family }); + queueMicrotask(() => req.emit('error', new Error('candidate unavailable'))); + }); + }; + return req; + }, +}); +await assert.rejects( + () => exhaustedTransport.post('https://hooks.example.com/hook'), + WebhookTransportError, +); +assert.deepEqual( + exhaustedAttempts, + candidateAnswers, + 'all already-validated candidates are exhausted before the attempt fails', +); + +const fallbackAbort = new AbortController(); +const abortAttempts = []; +const abortingFallbackTransport = createWebhookTransport({ + lookup: async () => candidateAnswers, + request: (_url, options) => { + const req = new EventEmitter(); + req.end = () => { + options.lookup('ignored.example', {}, (error, address, family) => { + assert.equal(error, null); + abortAttempts.push({ address, family }); + queueMicrotask(() => fallbackAbort.abort()); + }); + }; + return req; + }, +}); +await assert.rejects( + () => abortingFallbackTransport.post('https://hooks.example.com/hook', { + signal: fallbackAbort.signal, + }), + WebhookTransportError, +); +assert.deepEqual( + abortAttempts, + [candidateAnswers[0]], + 'an aborted delivery never falls through to another validated address', +); + +const syncFailure = createWebhookTransport({ + lookup: async () => [{ address: '93.184.216.34', family: 4 }], + request: () => { throw new Error('secret network detail'); }, +}); +await assert.rejects( + () => syncFailure.post('https://hooks.example.com/hook'), + WebhookTransportError, +); + +const emittedFailure = createWebhookTransport({ + lookup: async () => [{ address: '93.184.216.34', family: 4 }], + request: () => { + const req = new EventEmitter(); + req.end = () => queueMicrotask(() => req.emit('error', new Error('socket 10.0.0.1'))); + return req; + }, +}); +await assert.rejects( + () => emittedFailure.post('https://hooks.example.com/hook'), + WebhookTransportError, +); + +const controller = new AbortController(); +controller.abort(); +const aborted = createWebhookTransport({ + lookup: async () => [{ address: '93.184.216.34', family: 4 }], + request: responseRequest(200), +}); +await assert.rejects( + () => aborted.post('https://hooks.example.com/hook', { signal: controller.signal }), + WebhookTransportError, +); + +console.log('webhook transport policy tests passed');