diff --git a/CHANGELOG.md b/CHANGELOG.md index e434fa01..69bb399a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +- Constrained outbound webhook registration and delivery to public HTTPS with + per-attempt DNS/IP authorization, validated-address pinning, redirect + non-following, and replay-safe fallback; the explicit development exception + is limited to loopback HTTP. Active legacy webhook rows rejected by the + current synchronous destination policy, including HTTP and local/private + HTTPS literals, are transactionally disabled on startup with a tenant-visible + replacement action instead of silently retrying forever; DNS-backed names + remain re-authorized immediately before each delivery attempt. - 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. @@ -106,4 +114,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.0.1] - 2026-06-25 ### 성능 개선 (Performance) -- 드래그 앤 드롭 동작 중 `dragover` 이벤트에서 발생하는 O(N) 작업 리스트 검색 성능 병목 문제를, O(1) 해시맵(Map) 기반의 캐싱 조회 로직으로 개선하여 큰 크기의 WBS 리스트에서의 버벅임 현상을 해결했습니다. +- 드래그 앤 드롭 동작 중 `dragover` 이벤트에서 발생하는 O(N) 작업 리스트 검색 성능 병목 문제를, O(1) 해시맵(Map) 기반의 캐싱 조회 로직으로 개선하여 큰 크기의 WBS 리스트에서의 버벅임 현상을 해결했습니다. \ No newline at end of file diff --git a/docs/doctoring/outbound-webhook-ssrf.md b/docs/doctoring/outbound-webhook-ssrf.md new file mode 100644 index 00000000..df34dbd2 --- /dev/null +++ b/docs/doctoring/outbound-webhook-ssrf.md @@ -0,0 +1,53 @@ +# Outbound webhook SSRF and DNS-rebinding boundary + +Status: **active pull request only** (`#588`). This document does not claim that the repair is shipped on protected `develop` or released. Protected `develop` remains the source of shipped truth until the reviewed exact contributor head is integrated through live repository and organization gates. + +## Customer decision this control supports + +A ScopeWeave organization administrator may configure a webhook destination that causes ScopeWeave to send signed event data from the server. Because the administrator controls the destination URL, the product must distinguish an ordinary public webhook endpoint from a destination that could reach the ScopeWeave host, cloud metadata, a private network, or another special-use address. + +The active repair therefore makes the network destination an authorization boundary rather than trusting a syntactically valid URL. A customer can use public HTTPS webhook endpoints; production ScopeWeave will reject destinations that are local, private, special-use, ambiguous after DNS resolution, or otherwise outside the public-unicast authority admitted by the transport. + +## Threat and control traceability + +| Threat / requirement | Active-PR control | Regression evidence | +| --- | --- | --- | +| Direct loopback, private, link-local, reserved, documentation, multicast, or other special-use IP destination | `server/webhook_transport.mjs` parses the URL and rejects non-public destination authorities before network I/O. IPv4-mapped IPv6 forms are normalized into the same decision. | `tests/unit/webhook-transport.test.mjs` exercises representative denied IPv4/IPv6 and mapped forms. | +| Hostname resolves to one denied answer or a mixed public+denied answer set | Every A/AAAA result must pass the public-destination policy; a mixed answer set fails closed. | DNS policy cases in `tests/unit/webhook-transport.test.mjs`. | +| DNS validation and connection use different resolver answers (rebinding/TOCTOU) | Resolution is performed before connection; the HTTPS request receives a custom `lookup` result pinned to an address from the just-validated answer set while the original hostname remains the TLS authority/SNI identity. | Rebinding and pinned-lookup cases in `tests/unit/webhook-transport.test.mjs`. | +| Redirect moves a signed body/secret to a second authority | The bounded transport uses Node HTTPS directly and does not implement redirect following. A redirect response is an application response, not a new destination request. | Redirect/non-replay cases in `tests/unit/webhook-transport.test.mjs`. | +| Retry reuses stale DNS authority | The existing application retry calls the protected transport again, so the outer delivery retry performs a new resolution/validation/pinning decision. Pre-connect failure may try another address only from the same already-validated answer set; once TLS has connected, no candidate replay occurs for that attempt. | Pre-connect fallback, post-connect replay, and rebinding-across-attempts tests. | +| Credential or fragment-bearing registration URL | Production registration accepts canonical public `https:` destinations only and rejects credentials/fragments. | `tests/api/webhook-destination-policy.test.mjs`. | +| Development compatibility accidentally weakens production | HTTP is admitted only when `SCOPEWEAVE_DEV=1` and only for explicit loopback development destinations. | Development/production registration policy tests. | +| Transport or resolver details expose internal information | Customer-visible transport errors are stable and do not include resolver answers, credentials, or lower-layer exception text. | Sanitized-failure regressions in `tests/unit/webhook-transport.test.mjs`. | +| Security wrapper changes unrelated outbound integrations | The fetch facade classifies a signed ScopeWeave webhook from method and signature/event headers without constructing or consuming an unrelated `Request`; all unrelated calls retain their original native-fetch input/init semantics. | Existing `tests/api/orchestrator-attribution.test.mjs` plus `tests/api/webhook-fetch-contract.test.mjs`. | + +## Design boundary + +`server/app_core.mjs` is the protected-develop application moved without behavioral editing for this slice. `server/app.mjs` is a bounded facade for webhook registration and signed webhook delivery. `server/webhook_transport.mjs` owns destination policy, resolution, address authorization, HTTPS connection pinning, and transport-level replay safety. + +This structure is intentional: tenant/auth, billing, attachment, Clearfolio, project-planning, event filtering, webhook signing, attempt accounting, and the existing three-second per-attempt abort budget remain in their prior owning code. The security slice does not make those concerns subordinate to model judgment and does not alter central `.github` policy. + +## Evidence state and merge boundary + +The preserved RED history is followed by production implementation and two additional compatibility repairs. On contributor head `e4766272b3d5ae47e187431dd60cef7251d2086b`, the repository's existing unit/API/cloud suites are green, including the webhook transport and unrelated orchestrator attribution regressions. That hosted Server Tests run checked out GitHub's synthetic pull-request merge revision, however, so it is useful behavioral evidence but is not accepted here as immutable contributor-head merge authority. + +Exact-head repository CI is being repaired independently in ScopeWeave PR `#523`; the centrally owned reusable SAST/Security exact-head defect is tracked through `ContextualWisdomLab/.github#1222`. Before `#588` can integrate, the unchanged final contributor head must receive authoritative exact-head owned coverage, required security/dependency/supply-chain evidence, zero valid unresolved findings, and qualifying independent current-head approval. Pending, synthetic-only, stale, predecessor, status-only, or model-only evidence is non-passing. + +## Standards and primary technical basis + +OWASP's SSRF guidance explicitly treats custom webhooks as an SSRF risk, recommends disabling redirect following, and for arbitrary external destinations recommends resolving A and AAAA records and applying the same public-address validation to every result. The implementation additionally binds that validation result to the actual socket lookup so the network destination cannot silently diverge from the authorization decision. Node's `https.request()` supports the HTTP request options needed for a custom `lookup` seam while retaining TLS hostname handling. RFC and IANA registries provide the authority for private, link-local, unique-local, and other special-purpose address classifications. + +## References (APA 7) + +Cheshire, S., Aboba, B., & Guttman, E. (2005). *Dynamic configuration of IPv4 link-local addresses* (RFC 3927). RFC Editor. https://doi.org/10.17487/RFC3927 + +Hinden, R., & Haberman, B. (2005). *Unique local IPv6 unicast addresses* (RFC 4193). RFC Editor. https://doi.org/10.17487/RFC4193 + +Internet Assigned Numbers Authority. (n.d.). *Number-related registries*. Retrieved August 23, 2026, from https://www.iana.org/numbers/registries + +Node.js contributors. (n.d.). *HTTPS*. Node.js documentation. Retrieved August 23, 2026, from https://nodejs.org/api/https.html + +OWASP Foundation. (n.d.). *Server-side request forgery prevention cheat sheet*. OWASP Cheat Sheet Series. Retrieved August 23, 2026, from https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html + +Rekhter, Y., Moskowitz, B., Karrenberg, D., de Groot, G. J., & Lear, E. (1996). *Address allocation for private internets* (RFC 1918). RFC Editor. https://doi.org/10.17487/RFC1918 diff --git a/docs/doctoring/webhook-destination-security.md b/docs/doctoring/webhook-destination-security.md new file mode 100644 index 00000000..8bd92a9b --- /dev/null +++ b/docs/doctoring/webhook-destination-security.md @@ -0,0 +1,78 @@ +# Webhook destination security — active PR trace + +> **Lifecycle:** active-PR evidence only. This document describes the repair lane in PR #588 and must not be read as protected-`develop` shipment or certification evidence until that PR is integrated from an exact gated head. + +## Buyer and operator outcome + +ScopeWeave accepts buyer-configured webhook destinations, so the outbound HTTP client is an SSRF trust boundary. The active repair makes registration and delivery use one destination policy instead of validating a URL once and later allowing the platform resolver/network stack to choose a different address. + +Production webhook destinations are limited to canonical public HTTPS URLs. Immediately before each delivery attempt, ScopeWeave resolves all returned A/AAAA candidates, rejects the entire result if any candidate is malformed or special-use/non-public, and pins the connection to a validated address while preserving the original HTTPS hostname for TLS authority. Redirects are not followed. This closes the validation-versus-connect gap used by DNS-rebinding/pinning attacks and avoids redirect-based policy escape. + +Historical ScopeWeave releases accepted arbitrary HTTP(S) webhook URLs, including HTTP endpoints and HTTPS local/private literals that the current registration policy rejects. Leaving those rows active after tightening the transport would make an existing customer integration fail silently on every delivery and retry. The active repair therefore performs a transactional, idempotent startup state migration: every active stored destination is checked against the current synchronous registration policy, policy-incompatible rows are disabled, and one tenant-visible `webhook.security_block` audit event tells the operator to `register_public_https_replacement`. The fixed non-secret audit metadata uses `reason: "destination_policy"`; the migration never reads or copies the signing secret. Public HTTPS rows and already inactive rows remain unchanged. + +The migration deliberately performs no DNS/network I/O at startup. A syntactically admissible public hostname that later resolves to a private or special-use address remains subject to the delivery-time A/AAAA authorization boundary and fails closed there. This avoids making database startup availability depend on external DNS while preserving per-attempt rebinding protection. + +`SCOPEWEAVE_DEV=1` is an explicit non-production exception. It may admit only HTTP `localhost`, IPv4 `127.0.0.0/8`, or IPv6 `::1` destinations. Registration, delivery, and legacy-row migration reuse that same policy: a stored development loopback HTTP webhook remains active, while public HTTP and HTTPS-local/private destinations are disabled. `localhost` DNS answers must all be loopback addresses before any connector is called. The exception does not admit arbitrary RFC 1918, link-local, metadata-service, `.local`, `.localhost` subdomain, or other special-use destinations. + +## Control design + +| Boundary | Active-PR behavior | Acceptance evidence | +| --- | --- | --- | +| URL parsing | WHATWG `URL`; credentials and fragments rejected | `tests/api/webhook-destination-policy.test.mjs`, `tests/unit/webhook-transport.test.mjs` | +| Production scheme | HTTPS only | destination-policy and transport unit tests | +| Special-use IPs | IPv4/IPv6 special-purpose ranges denied; IPv6 public acceptance is limited to the ordinary `2000::/3` global-unicast envelope and excludes registered special-purpose blocks | transport policy tests; IANA registry trace below | +| DNS authorization | Every returned A/AAAA address must pass policy; mixed public/private answers fail closed | transport unit tests | +| DNS rebinding | Resolution occurs per outbound attempt and the socket lookup is pinned to the validated candidate | transport unit tests | +| TLS authority | Original hostname remains TLS `servername` for HTTPS hostnames even while address selection is pinned | transport unit tests | +| Redirects | Transport returns 3xx without following it; delivery is recorded unsuccessful by existing webhook logic | transport/API regression coverage | +| Replay safety | Another validated address may be tried only before a connection becomes established; after connect/TLS secure-connect, the signed body is not replayed within the same attempt | transport unit tests | +| Development loopback | Registration, delivery, and startup migration share the same explicit `SCOPEWEAVE_DEV=1` loopback exception; `localhost` must resolve exclusively to loopback | `tests/unit/webhook-development-transport.test.mjs`, `tests/unit/webhook-legacy-migration.test.mjs`, API smoke test | +| Legacy destination state | Every active historical row is checked against the current synchronous registration policy; HTTP, local-name, and private/special-use literal destinations are disabled atomically; public HTTPS and already inactive rows are preserved | `tests/api/webhook-legacy-migration.test.mjs`, `tests/unit/webhook-legacy-migration.test.mjs` | +| DNS-backed legacy hostname | Startup does not resolve external names; delivery still resolves afresh and rejects any non-public A/AAAA result | migration docstring plus transport unit tests | +| Migration failure | Mutation and audit persistence share one `BEGIN IMMEDIATE` transaction; failure to write durable audit evidence rolls the row mutation back | `tests/unit/webhook-legacy-migration.test.mjs` | +| Secret handling | Migration queries only webhook id, tenant id, URL, and active state; audit metadata is fixed non-secret remediation data | migration unit/API regressions | +| Error disclosure | Destination-policy and transport failures expose stable non-secret errors rather than resolver/socket details | destination-policy and transport tests | + +## Standards and primary-source rationale + +OWASP identifies custom webhooks as a direct SSRF use case and recommends resolving all A/AAAA results, applying the same IP policy to every result, and disabling redirect following for outbound requests. The ScopeWeave boundary implements those deterministic controls rather than delegating the decision to model judgment. + +IANA's live IPv4 and IPv6 Special-Purpose Address Registries are the source of truth for ranges that have special semantics and are not ordinary globally reachable destinations. RFC 6890 defines those registries; RFC 8190 updates their registry metadata model. The code uses explicit denied ranges so addresses such as loopback, private-use, link-local, documentation, multicast, IPv4-mapped IPv6, and other special-purpose space cannot become production webhook targets. + +RFC 6761 defines `localhost.` names as special-use and states that address queries for localhost names are expected to yield loopback addresses. ScopeWeave therefore treats bare `localhost` as a development-only spelling and still validates its actual resolver answers as loopback before connection. Literal `127.0.0.0/8` and `::1` follow their IANA/RFC loopback semantics. + +Node.js `https.request()` accepts the HTTP request options plus TLS options including `servername`; the active transport uses an injected `lookup` function to pin the validated address while retaining the URL hostname as TLS authority. `agent: false` prevents connection pooling from silently reusing a socket whose address was authorized under a prior resolution. + +The legacy-row transition is a product compatibility control rather than a new network policy: once the production registration/transport boundary legitimately refuses a stored destination, continuing to mark that destination active would create misleading operability state. The migration therefore makes persisted state match the enforceable synchronous registration policy and records the customer next action in the existing tenant audit trail. + +## TDD and current verification trace + +The review finding that exposed registration/delivery drift is preserved by `tests/unit/webhook-development-transport.test.mjs`. On contributor head `dd1893ea870bec9ddbd03fbe2c24f084641f72de`, hosted Server Tests run `32626294458` failed at the new development-loopback delivery assertion with `WebhookDestinationError`; that is the realistic RED reproduction. The root-cause transport repair then made explicit development loopback registration and delivery use the same policy. + +A later review identified the legacy-state compatibility problem. The first migration repaired active HTTP rows, but exact-current review of contributor `c7f299a480dc89873fc08807f460c7d248134a83` found that historical HTTPS-local/private rows such as `https://localhost`, `https://127.0.0.1`, and `https://10.0.0.x` would remain active even though the current policy rejects them. The same head's Server Tests run `32627872784` also exposed a separate test-harness defect: Node 22.13 SQLite row objects have a null prototype, so strict deep equality against plain object literals failed before the migration assertions could provide reliable evidence. + +The regression-first successor `da062b9b9b82f84ae805a5ed31365e15e63d43a5` normalizes SQLite result rows only at the assertion boundary and adds explicit legacy HTTPS-local/private cases plus correct operator audit semantics. The root-cause implementation `7dbc5a8a1d43ebe6de6326d017af5b210cda25e9` changes the startup migration from an HTTP-prefix query to evaluating every active row with the same synchronous current registration validator; it preserves admitted development loopback destinations, never performs startup DNS, and records `reason: "destination_policy"`. The realistic on-disk API regression was then aligned at `584d7b0527f93165b5b6a97f12eaf6dfcf7a96d0`, including both an active legacy HTTP row and an active private-HTTPS row, public-HTTPS preservation, durable audit evidence, secret non-disclosure, and restart idempotence. + +The workflows associated with these rapidly advancing repair heads are revision-sensitive and must not be transferred between heads. The latest exact contributor head after this documentation commit must obtain fresh terminal evidence before any finding is considered closed. Hosted results on this PR remain **behavioral regression evidence, not merge authority** until the exact unchanged contributor head has been regenerated under corrected checkout controls. The repository's protected Server Tests control remains owned by #523, and centrally reusable SAST/Security exact-head repair remains owned by `ContextualWisdomLab/.github#1222`. + +## Rollback and residual risk + +The startup transition adds no schema object, but it is a real persisted-data state migration: rows rejected by the current synchronous registration policy become inactive. The mutation and its audit evidence are committed together or rolled back together. Re-running startup is idempotent. + +Reverting the code does not safely reactivate migrated rows and must not be used as an implicit downgrade path. An operator who needs to restore delivery should register a new public-HTTPS webhook through the normal authenticated API. Reactivating an old policy-incompatible destination would require an explicit security exception and is outside the supported production recovery path. The existing audit record remains durable evidence of why the row was disabled and what action the tenant should take. + +Residual limits are intentional and visible: this is an outbound destination authorization layer, not a general egress firewall. Production environments should still apply network egress controls and metadata-service protections as defense in depth. A public service that intentionally redirects or resolves through special-purpose/private addresses is incompatible with the production webhook policy and must expose a stable public HTTPS endpoint instead of requesting an allowlist bypass. + +## References (APA 7) + +Cheshire, S., & Krochmal, M. (2013). *Special-use domain names* (RFC 6761). Internet Engineering Task Force. https://doi.org/10.17487/RFC6761 + +Cotton, M., Vegoda, L., Bonica, R., & Haberman, B. (2013). *Special-purpose IP address registries* (RFC 6890). Internet Engineering Task Force. https://doi.org/10.17487/RFC6890 + +Internet Assigned Numbers Authority. (2025, October 9). *IPv6 special-purpose address space*. https://www.iana.org/assignments/iana-ipv6-special-registry/ + +Internet Assigned Numbers Authority. (n.d.). *IPv4 special-purpose address space*. Retrieved August 23, 2026, from https://www.iana.org/assignments/iana-ipv4-special-registry/ + +Node.js contributors. (2025). *HTTPS: Node.js v22 documentation*. Node.js. https://nodejs.org/docs/v22.13.0/api/https.html + +OWASP Foundation. (n.d.). *Server side request forgery prevention cheat sheet*. Retrieved August 23, 2026, from https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html diff --git a/package.json b/package.json index 8cefdc74..a0337b7f 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/webhook-destination-policy.test.mjs && node tests/api/webhook-fetch-contract.test.mjs && node tests/api/webhook-legacy-migration.test.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 && node tests/unit/webhook-transport.test.mjs && node tests/unit/webhook-transport-timeout.test.mjs && node tests/unit/webhook-development-transport.test.mjs && node tests/unit/webhook-legacy-migration.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/orchestrator.mjs --include=server/webhook_transport.mjs --include=server/webhook_legacy_migration.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/webhook-transport-timeout.test.mjs && node tests/unit/webhook-development-transport.test.mjs && node tests/unit/webhook-legacy-migration.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..a3c779a7 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -1,1410 +1,205 @@ -// 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. +// ScopeWeave API security facade for outbound webhook registration. +// The protected-develop route graph lives in app_core.mjs; this module adds +// bounded fail-closed policies without rewriting tenant, auth, billing, +// attachment, Clearfolio, or project-planning behavior. 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 */ } +import { app as coreApp } from './app_core.mjs'; +import { validateWebhookRegistrationUrl } from './webhook_transport.mjs'; + +const WEBHOOK_REGISTRATION_PATH = '/api/orgs/:id/webhooks'; +const WEBHOOK_REGISTRATION_BODY_MAX_BYTES = 16 * 1024; +const webhookRegistrationEvidence = new WeakMap(); + +// The public app below owns the core's global logger/rate-limit middleware. The +// private replay app therefore contains only the route-specific registration +// chain (auth/RBAC + handler). That makes global accounting run exactly once and +// lets the public logger observe the facade's final response status instead of +// the side-effect-free probe used after an authorized body exceeds its budget. +// Route handlers are wrapped at their existing position so the bounded-body +// result is translated before the private route chain unwinds. Evidence remains +// process-local in a WeakMap keyed by the forwarded Request, so clients cannot +// spoof it. +const registrationCoreApp = new Hono(); +let registrationRouteInstalled = false; +for (const route of coreApp.routes) { + if (route.path !== WEBHOOK_REGISTRATION_PATH) continue; + registrationRouteInstalled = true; + registrationCoreApp.on(route.method, route.path, async (c, next) => { + const response = await route.handler(c, next); + if (webhookRegistrationEvidence.get(c.req.raw)?.tooLarge === true) { + return c.json({ error: 'webhook registration body too large' }, 413); + } + return response; + }); } - -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)); +if (!registrationRouteInstalled) { + throw new Error('ScopeWeave webhook registration core route is unavailable'); } -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(); +function canonicalRegistrationUrl(value) { + return validateWebhookRegistrationUrl(value, { + allowDevelopmentLoopback: process.env.SCOPEWEAVE_DEV === '1', }); } -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'); +function canonicalRegistrationPayload(text) { + let payload = {}; 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 }); -}); + const value = JSON.parse(text); + if (value && typeof value === 'object' && !Array.isArray(value)) payload = value; + } catch { /* malformed JSON follows the core route's stable 400 path */ } -// 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); + let canonicalUrl = ''; 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 }); -}); + canonicalUrl = canonicalRegistrationUrl(payload.url); + } catch { /* the core registration route owns the stable destination error */ } -// 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)}`, // DTEND is exclusive - `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"`, + return JSON.stringify({ + ...payload, + url: canonicalUrl, }); -}); +} -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 */ } - }); +function canonicalRegistrationBody(original) { + const state = { tooLarge: false }; + if (!original.body) return { body: JSON.stringify({ url: '' }), state }; + + const source = original.body.getReader(); + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + let text = ''; + let totalBytes = 0; + let finished = false; + + // A zero-sized queue is deliberate: creating the forwarding Request must not + // pull attacker-controlled bytes. Public global middleware has already run; + // only the private route's auth/RBAC chain can reach c.req.json(), so body + // consumption starts only for an authorized registration request. Once that + // read begins, the facade enforces a small explicit memory budget before + // decoding. + const body = new ReadableStream({ + async pull(controller) { + if (finished) return; + try { + while (true) { + const { done, value } = await source.read(); + if (done) { + text += decoder.decode(); + controller.enqueue(encoder.encode(canonicalRegistrationPayload(text))); + controller.close(); + finished = true; + source.releaseLock(); + return; + } + + const chunk = value instanceof Uint8Array ? value : new Uint8Array(value); + totalBytes += chunk.byteLength; + if (totalBytes > WEBHOOK_REGISTRATION_BODY_MAX_BYTES) { + state.tooLarge = true; + finished = true; + try { await source.cancel('webhook registration body too large'); } catch { /* best effort */ } + try { source.releaseLock(); } catch { /* cancellation may release it */ } + // Feed the private core route a side-effect-free invalid registration + // after auth/RBAC has already admitted this request. The wrapped route + // translates that probe into the stable 413; the public logger then + // records the same final status returned to the customer. + controller.enqueue(encoder.encode(JSON.stringify({ url: '' }))); + controller.close(); + return; + } + text += decoder.decode(chunk, { stream: true }); + } + } catch (error) { + finished = true; + try { source.releaseLock(); } catch { /* already released/cancelled */ } + controller.error(error); + } }, - }); - return new Response(stream, { - headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' }, - }); -}); - -// --------------------------------------------------------------- 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 }); -}); - -// 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 }); - } - 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 }); -}); + async cancel(reason) { + if (finished) return; + finished = true; + try { + await source.cancel(reason); + } finally { + try { source.releaseLock(); } catch { /* cancellation can release it */ } + } + }, + }, { highWaterMark: 0 }); -// 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 }); -}); + return { body, state }; +} -// 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 }); -}); +function requestWithCanonicalRegistration(original) { + const headers = new Headers(original.headers); + headers.delete('content-length'); + headers.set('content-type', 'application/json'); + const { body, state } = canonicalRegistrationBody(original); + return { + request: new Request(original.url, { + method: original.method, + headers, + body, + signal: original.signal, + ...(body instanceof ReadableStream ? { duplex: 'half' } : {}), + }), + state, + }; +} -// 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'); +async function registrationPolicyResponse(c) { + // Public global middleware has already run exactly once. The zero-queue + // forwarding stream then enters only the route-specific core chain, where + // auth/RBAC can reject the request without draining attacker-controlled body + // bytes. Bounded-body evidence is keyed to this Request in process memory, not + // in an HTTP header. + const { request, state } = requestWithCanonicalRegistration(c.req.raw); + webhookRegistrationEvidence.set(request, state); + let response; 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); -}); - -// 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); - } - 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 }); -}); - -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 }); -}); - -// 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; - }; - 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(',')); + response = await registrationCoreApp.fetch(request); + } finally { + webhookRegistrationEvidence.delete(request); + // Authentication/RBAC rejection can return without consuming the forwarding + // body. Cancel that unread stream so its reader releases the original network + // body instead of keeping the connection resource locked. + if (request.body && !request.bodyUsed && !request.body.locked) { + try { await request.body.cancel('webhook registration request completed'); } catch { /* best effort */ } } - 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 }); -}); - -// 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"` }); -}); - -// 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}`); } - 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 }); -}); - -// 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 -}); - -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 }); -}); - -// ------------------------------------------------------------ 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 - -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 response; } -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()); - } - 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); - } - 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}`); -}); - -// 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; - } - } - if (hit.nameMatch || hit.tasks.length) results.push(hit); - if (results.length >= 20) break; - } - 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++; - } - 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} 시작)`); - } - } - 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); - } -}); - -// 산출물 첨부(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; - 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 }); -}); - -// 열람: 서명 아티팩트 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 }); -}); +/** + * Public ScopeWeave HTTP application with a fail-closed outbound-webhook facade. + * + * The protected core route graph remains authoritative for every unrelated + * product surface. Only outbound webhook registration is intercepted here. + * Core global middleware is mounted first so logging and rate limiting still + * wrap the facade exactly once. + */ +export const app = new Hono(); -// 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)}"`, - }); - }); +// Hono normalizes root `*` registrations to `/*` in `routes`. Identify only the +// method-ALL records produced by core `app.use('*', ...)`; the final GET `/*` +// static fallback is a route, not middleware, and must keep its original tail +// position. Re-registering these records before the facade makes the core logger +// and optional rate limiter wrap registration POSTs instead of being replayed +// after the short-circuiting facade. +const isGlobalCoreMiddleware = ({ method, path }) => method === 'ALL' && path === '/*'; +for (const route of coreApp.routes.filter(isGlobalCoreMiddleware)) { + app.use(route.path, route.handler); } -// 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 }); -}); - -// 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) }); -}); - -// 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); - } - 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.use(WEBHOOK_REGISTRATION_PATH, async (c, next) => { + if (c.req.method !== 'POST') return next(); + const response = await registrationPolicyResponse(c); + // Assign the facade's final response before outer global middleware resumes. + // Hono's access logger reads c.res after await next(), so merely returning the + // replacement Response can leave it observing the private probe's 400 status. + c.res = response; + return response; }); -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 }); -}); - -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 }); -}); - -// ------------------------------------------------------ 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 }); -}); - -// 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 }) }); -}); - -// 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); - } - 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); - } - 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 })); - -// 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(); - } -}); +for (const route of coreApp.routes.filter((route) => !isGlobalCoreMiddleware(route))) { + app.on(route.method, route.path, route.handler); +} diff --git a/server/app_core.mjs b/server/app_core.mjs new file mode 100644 index 00000000..49b5ce03 --- /dev/null +++ b/server/app_core.mjs @@ -0,0 +1,1420 @@ +// 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 { postWebhook, validateWebhookRegistrationUrl, WebhookDestinationError } from './webhook_transport.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); + postWebhook(url, { + 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)}`, // DTEND is exclusive + `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) => { + // 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' }, + }); +}); + +// --------------------------------------------------------------- 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 }); +}); + +// 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 }); + } + 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); +}); + +// 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); + } + 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 }); +}); + +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 }); +}); + +// 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; + }; + 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 }); +}); + +// 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"` }); +}); + +// 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}`); + } + 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(() => ({})); + let canonicalUrl; + try { + canonicalUrl = validateWebhookRegistrationUrl(url, { + allowDevelopmentLoopback: process.env.SCOPEWEAVE_DEV === '1', + }); + } catch (error) { + if (error instanceof WebhookDestinationError) { + return c.json({ error: 'valid public https webhook URL required' }, 400); + } + throw error; + } + 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, canonicalUrl, secret, evs)); + logAudit(orgId, uid, 'webhook.create', 'webhook', id, { url: canonicalUrl, events: evs }); + return c.json({ id, url: canonicalUrl, 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 }); +}); + +// 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 +}); + +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 }); +}); + +// ------------------------------------------------------------ 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 + +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 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()); + } + 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); + } + 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}`); +}); + +// 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; + } + } + if (hit.nameMatch || hit.tasks.length) results.push(hit); + if (results.length >= 20) break; + } + 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++; + } + 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} 시작)`); + } + } + 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); + } +}); + +// 산출물 첨부(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; + 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 }); +}); + +// 열람: 서명 아티팩트 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)}"`, + }); + }); +} + +// 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 }); +}); + +// 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) }); +}); + +// 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); + } + 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 }); +}); + +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 }); +}); + +// ------------------------------------------------------ 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 }); +}); + +// 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 }) }); +}); + +// 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); + } + 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); + } + 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 })); + +// 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(); + } +}); diff --git a/server/db.mjs b/server/db.mjs index 122b70d6..d61b53d6 100644 --- a/server/db.mjs +++ b/server/db.mjs @@ -4,6 +4,7 @@ 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'); @@ -177,5 +178,9 @@ try { db.exec('ALTER TABLE users ADD COLUMN token_version INTEGER NOT NULL DEFAU 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 */ } +migrateLegacyWebhookDestinations(db, { + allowDevelopmentLoopback: process.env.SCOPEWEAVE_DEV === '1', +}); + // 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/webhook_legacy_migration.mjs b/server/webhook_legacy_migration.mjs new file mode 100644 index 00000000..d9ba94a9 --- /dev/null +++ b/server/webhook_legacy_migration.mjs @@ -0,0 +1,115 @@ +import { validateWebhookRegistrationUrl } from './webhook_transport.mjs'; + +const SECURITY_ACTION = 'webhook.security_block'; +const SECURITY_META = JSON.stringify({ + reason: 'destination_policy', + nextAction: 'register_public_https_replacement', +}); + +function isCurrentDestinationAllowed(url, allowDevelopmentLoopback) { + try { + validateWebhookRegistrationUrl(url, { allowDevelopmentLoopback }); + return true; + } catch { + return false; + } +} + +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, allowDevelopmentLoopback) { + return candidates.some( + (candidate) => !isCurrentDestinationAllowed(candidate.url, allowDevelopmentLoopback), + ); +} + +/** + * Disable active legacy webhook destinations rejected by current registration policy. + * + * Historical ScopeWeave releases accepted arbitrary HTTP(S) webhook URLs, + * including local/private HTTPS literals and names. Current production + * registration requires public HTTPS, so leaving policy-incompatible rows active + * would create an endless silent retry loop. This migration examines every active + * row, disables only destinations rejected by the current synchronous registration + * policy, writes one tenant-visible audit event with a concrete replacement action, + * and never reads or copies the webhook signing secret. Explicit development mode + * preserves only destinations that the same current development registration + * policy still permits, including loopback HTTP. + * + * A read-only preflight avoids reserving the SQLite writer when every active row + * already satisfies current policy. If a write is required, the migration acquires + * `BEGIN IMMEDIATE` and re-reads the active rows inside that transaction before any + * mutation, preserving the existing atomic fail-closed migration boundary. + * + * DNS-backed hostnames remain subject to per-attempt address authorization at + * delivery time; this startup migration deliberately does not perform network I/O. + * + * @param {import('node:sqlite').DatabaseSync} database Open ScopeWeave database. + * @param {{allowDevelopmentLoopback?: boolean}} [options] Migration policy. + * @returns {number} Number of webhook rows newly disabled during this run. + */ +export function migrateLegacyWebhookDestinations( + database, + { allowDevelopmentLoopback = false } = {}, +) { + const preflightCandidates = activeWebhookDestinations(database); + if (!hasPolicyIncompatibleDestination(preflightCandidates, allowDevelopmentLoopback)) { + return 0; + } + + database.exec('BEGIN IMMEDIATE'); + try { + const candidates = activeWebhookDestinations(database); + const disable = database.prepare( + 'UPDATE webhooks SET active = 0 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 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, allowDevelopmentLoopback)) { + continue; + } + const targetId = String(candidate.id); + const result = disable.run(candidate.id, candidate.orgId); + if (!result.changes) continue; + disabled += Number(result.changes); + audit.run( + candidate.orgId, + SECURITY_ACTION, + targetId, + SECURITY_META, + 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..ef01b7ad --- /dev/null +++ b/server/webhook_transport.mjs @@ -0,0 +1,347 @@ +import { lookup as dnsLookup } from 'node:dns/promises'; +import { request as httpRequest } from 'node:http'; +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 DEFAULT_WEBHOOK_TRANSPORT_TIMEOUT_MS = 3000; +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'; + +/** Stable, non-secret webhook destination policy failure. */ +export class WebhookDestinationError extends Error { + constructor() { + super(POLICY_ERROR); + this.name = 'WebhookDestinationError'; + } +} + +/** 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'); +} + +function isLoopbackAddress(address) { + const family = isIP(address); + if (family === 6) return address === '::1'; + if (family !== 4) return false; + const [first] = address.split('.').map(Number); + return first === 127; +} + +function isDevelopmentLoopbackUrl(destination) { + if (destination.protocol !== 'http:' + || destination.username + || destination.password + || destination.hash + || !destination.hostname) return false; + const host = destination.hostname.toLowerCase().replace(/\.$/, ''); + if (host === 'localhost') return true; + const literal = hostAddress(host); + return isLoopbackAddress(literal); +} + +/** + * Return whether an address is an ordinary Internet-routable webhook target. + * IPv4 special-purpose ranges are denied. IPv6 must be within the ordinary + * 2000::/3 global-unicast envelope and outside denied special-use blocks. + */ +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 URL without performing DNS. Production + * destinations are public HTTPS only. Explicit development mode may admit + * HTTP only for localhost or literal loopback addresses; the transport still + * revalidates every resolved address immediately before each connection. + */ +export function validateWebhookRegistrationUrl(value, { allowDevelopmentLoopback = false } = {}) { + let destination; + try { + destination = new URL(String(value ?? '')); + } catch { + throw new WebhookDestinationError(); + } + if (allowDevelopmentLoopback && isDevelopmentLoopbackUrl(destination)) { + return destination.href; + } + 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 lookupAddresses(destination, lookup, signal) { + const literal = hostAddress(destination.hostname); + if (isIP(literal)) 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 actualFamily = isIP(address); + const family = Number(answer?.family) || actualFamily; + if ((family !== 4 && family !== 6) || actualFamily !== family) { + 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; +} + +async function resolveAuthorizedAddresses(destination, lookup, signal, allowDevelopmentLoopback) { + const candidates = await lookupAddresses(destination, lookup, signal); + const developmentLoopback = allowDevelopmentLoopback && isDevelopmentLoopbackUrl(destination); + for (const candidate of candidates) { + const allowed = developmentLoopback + ? isLoopbackAddress(candidate.address) + : isPublicWebhookAddress(candidate.address); + if (!allowed) throw new WebhookDestinationError(); + } + return candidates; +} + +function pinnedLookup(address, family) { + return (_hostname, options, callback) => { + if (options?.all) { + callback(null, [{ address, family }]); + return; + } + callback(null, address, family); + }; +} + +function requestOptions(destination, candidate, headers, signal) { + const tlsHost = hostAddress(destination.hostname); + return { + method: 'POST', + headers, + signal, + timeout: DEFAULT_WEBHOOK_TRANSPORT_TIMEOUT_MS, + agent: false, + lookup: pinnedLookup(candidate.address, candidate.family), + ...(destination.protocol === 'https:' && !isIP(tlsHost) ? { servername: tlsHost } : {}), + }; +} + +function trackConnection(request, attempt, secure) { + request.once?.('socket', (socket) => { + const event = secure ? 'secureConnect' : 'connect'; + socket?.once?.(event, () => { + attempt.connected = 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, + requestOptions(destination, candidate, headers, signal), + (response) => { + response.resume?.(); + const status = Number(response.statusCode) || 0; + resolve({ status, ok: status >= 200 && status < 300 }); + }, + ); + } catch { + reject(new WebhookTransportError()); + return; + } + trackConnection(req, attempt, destination.protocol === 'https:'); + req.once?.('error', () => reject(new WebhookTransportError())); + req.once?.('timeout', () => { + try { + req.destroy?.(); + } finally { + reject(new WebhookTransportError()); + } + }); + req.end(body); + }), signal); + } catch (error) { + if (error instanceof WebhookTransportError) throw error; + throw new WebhookTransportError(); + } +} + +/** + * Build the outbound webhook transport around injectable DNS and network seams. + * Every POST resolves afresh, rejects unauthorized mixed answers, pins the socket + * to a validated candidate, preserves HTTPS Host/TLS authority, disables pooling, + * and bounds stalled peers with a transport-owned timeout. A pre-connect failure + * may fall through to another already-validated candidate; after a connection is + * established delivery is ambiguous and the signed body is never replayed within + * the same attempt. + */ +export function createWebhookTransport({ + lookup = dnsLookup, + request = httpsRequest, + httpRequest: developmentHttpRequest = httpRequest, + allowDevelopmentLoopback = false, +} = {}) { + if (typeof lookup !== 'function' + || typeof request !== 'function' + || typeof developmentHttpRequest !== '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, { allowDevelopmentLoopback })); + } catch (error) { + if (error instanceof WebhookDestinationError) throw error; + throw new WebhookDestinationError(); + } + + const candidates = await resolveAuthorizedAddresses( + destination, + lookup, + signal, + allowDevelopmentLoopback, + ); + const requestHeaders = Object.fromEntries(new Headers(headers).entries()); + delete requestHeaders['content-length']; + const connector = destination.protocol === 'http:' ? developmentHttpRequest : request; + let lastError; + for (const candidate of candidates) { + const attempt = { connected: false }; + try { + return await postToCandidate( + destination, + candidate, + { headers: requestHeaders, body, signal, attempt }, + connector, + ); + } catch (error) { + if (!(error instanceof WebhookTransportError)) throw error; + lastError = error; + if (signal?.aborted || attempt.connected) throw error; + } + } + throw lastError || new WebhookTransportError(); + }, + }); +} + +const webhookTransport = createWebhookTransport({ + allowDevelopmentLoopback: process.env.SCOPEWEAVE_DEV === '1', +}); + +/** Send one signed webhook attempt through the SSRF-safe transport policy. */ +export const postWebhook = (url, options) => webhookTransport.post(url, options); diff --git a/tests/api/webhook-destination-policy.test.mjs b/tests/api/webhook-destination-policy.test.mjs new file mode 100644 index 00000000..5a28f41e --- /dev/null +++ b/tests/api/webhook-destination-policy.test.mjs @@ -0,0 +1,249 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { readFileSync } from 'node:fs'; + +const tempDirectory = mkdtempSync(join(tmpdir(), 'scopeweave-webhook-policy-')); +process.env.SCOPEWEAVE_DB = join(tempDirectory, 'webhook-policy.sqlite'); +delete process.env.SCOPEWEAVE_DEV; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; + +const requestLogs = []; +const originalConsoleLog = console.log; +console.log = (...args) => { + if (args.length !== 1 || typeof args[0] !== 'string') return; + try { + const record = JSON.parse(args[0]); + if (record && typeof record === 'object' && typeof record.path === 'string') { + requestLogs.push(record); + } + } catch { + // Test progress output is intentionally ignored while structured request logs are captured. + } +}; + +const facadeSource = readFileSync(new URL('../../server/app.mjs', import.meta.url), 'utf8'); +assert.doesNotMatch( + facadeSource, + /valid http\(s\) url required/, + 'registration facade does not retain the superseded core error contract', +); + +const { app } = await import('../../server/app.mjs'); +const { app: coreApp } = await import('../../server/app_core.mjs'); +const { db } = await import('../../server/db.mjs'); + +const request = (path, options = {}) => app.request(path, { + ...options, + headers: { + 'content-type': 'application/json', + ...(options.headers || {}), + }, +}); +const json = (value) => JSON.stringify(value); +const registrationAccessLogs = (organizationId) => requestLogs.filter((record) => ( + record.method === 'POST' + && record.path === `/api/orgs/${organizationId}/webhooks` +)); + +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; + +response = await coreApp.request(`/api/orgs/${organizationId}/webhooks`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...authorization, + }, + body: json({ url: 'https://127.0.0.1/private', events: ['project.updated'] }), +}); +assert.equal(response.status, 400, 'core webhook registration cannot bypass the destination policy'); +assert.deepEqual( + await response.json(), + { error: 'valid public https webhook URL required' }, + 'core registration rejects private destinations without resolver details', +); + +let unauthenticatedBodyPulls = 0; +let unauthenticatedBodyCancels = 0; +let unauthenticatedBodyCancelReason = ''; +const unauthenticatedBody = new ReadableStream({ + pull(controller) { + unauthenticatedBodyPulls += 1; + controller.enqueue(new TextEncoder().encode('x'.repeat(8192))); + if (unauthenticatedBodyPulls >= 8) controller.close(); + }, + cancel(reason) { + unauthenticatedBodyCancels += 1; + unauthenticatedBodyCancelReason = String(reason); + }, +}); +response = await request(`/api/orgs/${organizationId}/webhooks`, { + method: 'POST', + body: unauthenticatedBody, + duplex: 'half', +}); +assert.equal(response.status, 401, 'webhook registration authenticates before reading an untrusted request body'); +assert.ok( + unauthenticatedBodyPulls <= 1, + `unauthenticated webhook body must not be drained before auth; observed ${unauthenticatedBodyPulls} stream pulls`, +); +assert.equal( + unauthenticatedBodyCancels, + 1, + 'early authentication rejection cancels the unread upstream registration body exactly once', +); +assert.equal( + unauthenticatedBodyCancelReason, + 'webhook registration request completed', + 'early rejection releases the upstream body with a stable non-secret cancellation reason', +); + +let authorizedBodyPulls = 0; +let authorizedBodyCancels = 0; +let authorizedBodyCancelReason = ''; +const oversizedAuthorizedBody = new ReadableStream({ + pull(controller) { + authorizedBodyPulls += 1; + controller.enqueue(new TextEncoder().encode('x'.repeat(8192))); + if (authorizedBodyPulls >= 8) controller.close(); + }, + cancel(reason) { + authorizedBodyCancels += 1; + authorizedBodyCancelReason = String(reason); + }, +}); +const accessLogCountBeforeOversized = registrationAccessLogs(organizationId).length; +response = await request(`/api/orgs/${organizationId}/webhooks`, { + method: 'POST', + headers: authorization, + body: oversizedAuthorizedBody, + duplex: 'half', +}); +assert.equal(response.status, 413, 'authorized webhook registration bodies have a bounded memory budget'); +assert.deepEqual( + await response.json(), + { error: 'webhook registration body too large' }, + 'oversized registration returns a stable buyer-actionable error', +); +// Constructing a Fetch Request may prefetch one upstream chunk before the +// facade acquires the body reader. Detecting an unknown-length body above the +// exact 16 KiB ceiling then requires reading the first over-budget chunk; the +// facade must cancel immediately instead of draining the remaining stream. +assert.ok( + authorizedBodyPulls <= 4, + `oversized webhook body must stop at the Request prefetch plus first over-budget chunk; observed ${authorizedBodyPulls} stream pulls`, +); +assert.equal(authorizedBodyCancels, 1, 'oversized webhook registration cancels its upstream body exactly once'); +assert.equal( + authorizedBodyCancelReason, + 'webhook registration body too large', + 'oversized webhook cancellation records the bounded-body reason', +); +const oversizedAccessLogs = registrationAccessLogs(organizationId); +assert.equal( + oversizedAccessLogs.length, + accessLogCountBeforeOversized + 1, + 'every public webhook registration must append exactly one structured access record', +); +const oversizedAccessLog = oversizedAccessLogs.at(-1); +assert.equal( + oversizedAccessLog?.status, + 413, + 'structured access evidence must record the same 413 status returned to the customer', +); + +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', +); + +db.close(); +console.log = originalConsoleLog; +rmSync(tempDirectory, { recursive: true, force: true }); +console.log('webhook destination registration policy tests passed'); diff --git a/tests/api/webhook-fetch-contract.test.mjs b/tests/api/webhook-fetch-contract.test.mjs new file mode 100644 index 00000000..e887229b --- /dev/null +++ b/tests/api/webhook-fetch-contract.test.mjs @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +delete process.env.SCOPEWEAVE_DEV; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; + +const nativeCalls = []; +globalThis.fetch = async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init); + const body = request.body ? await request.text() : ''; + nativeCalls.push({ method: request.method, url: request.url, body }); + return new Response(body, { status: 200, headers: { 'content-type': 'text/plain' } }); +}; +const configuredFetch = globalThis.fetch; + +await import('../../server/app.mjs'); + +assert.strictEqual( + globalThis.fetch, + configuredFetch, + 'importing the ScopeWeave app must not replace the process-wide fetch implementation', +); + +const unrelated = new Request('https://unrelated.example.test/echo', { + method: 'POST', + headers: { 'content-type': 'text/plain' }, + body: 'preserve-this-body', +}); +const response = await globalThis.fetch(unrelated); + +assert.equal(response.status, 200, 'unrelated native fetch result is preserved'); +assert.equal(await response.text(), 'preserve-this-body'); +assert.deepEqual(nativeCalls, [{ + method: 'POST', + url: 'https://unrelated.example.test/echo', + body: 'preserve-this-body', +}], 'the facade must not consume a non-webhook Request before native fetch receives it'); + +console.log('webhook transport leaves the process-wide fetch boundary untouched'); diff --git a/tests/api/webhook-legacy-migration.test.mjs b/tests/api/webhook-legacy-migration.test.mjs new file mode 100644 index 00000000..a233feda --- /dev/null +++ b/tests/api/webhook-legacy-migration.test.mjs @@ -0,0 +1,132 @@ +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'; + +function plainRows(rows) { + return rows.map((row) => ({ ...row })); +} + +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')) +); +CREATE TABLE audit_log ( + id INTEGER PRIMARY KEY, + org_id INTEGER NOT NULL REFERENCES orgs(id) ON DELETE CASCADE, + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + action TEXT NOT NULL, + target_type TEXT, + target_id TEXT, + meta TEXT, + 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_active','project.update',1), + (42,1,'https://webhook.example.test/callback','whsec_public_https','project.update',1), + (43,1,'http://retired-webhook.example.test/callback','whsec_legacy_inactive','project.update',0), + (44,1,'https://127.0.0.1/callback','whsec_legacy_private_https','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-destination-migration=first`); + + assert.deepEqual( + plainRows(first.db.prepare('SELECT id, active FROM webhooks ORDER BY id').all()), + [ + { id: 41, active: 0 }, + { id: 42, active: 1 }, + { id: 43, active: 0 }, + { id: 44, active: 0 }, + ], + 'startup disables active legacy destinations rejected by current policy and preserves public HTTPS/inactive rows', + ); + + 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' + ORDER BY id`, + ).all(); + assert.equal(firstAudit.length, 2, 'migration emits one durable buyer-visible security audit event per disabled destination'); + assert.deepEqual( + firstAudit.map((row) => row.targetId), + ['41', '44'], + 'audit events identify both legacy HTTP and private-HTTPS rows', + ); + for (const audit of firstAudit) { + assert.equal(audit.targetType, 'webhook'); + assert.deepEqual( + JSON.parse(audit.meta), + { + reason: 'destination_policy', + nextAction: 'register_public_https_replacement', + }, + 'audit evidence gives the operator a concrete remediation action without misclassifying the scheme', + ); + assert.equal( + audit.meta.includes('whsec_'), + false, + 'buyer-visible audit evidence never includes the webhook signing secret', + ); + } + first.db.close(); + + const second = await import(`${moduleUrl}?legacy-destination-migration=second`); + assert.equal( + second.db.prepare( + `SELECT COUNT(*) AS count + FROM audit_log + WHERE org_id = 1 AND action = 'webhook.security_block'`, + ).get().count, + 2, + 'restart is idempotent and does not duplicate security audit events', + ); + assert.deepEqual( + plainRows(second.db.prepare('SELECT id, active FROM webhooks WHERE id IN (41, 44) ORDER BY id').all()), + [ + { id: 41, active: 0 }, + { id: 44, active: 0 }, + ], + 'restart remains fail-closed for every migrated policy-incompatible destination', + ); + second.db.close(); +} finally { + delete process.env.SCOPEWEAVE_DB; + rmSync(directory, { recursive: true, force: true }); +} + +console.log('legacy webhook destination migration regression passed'); diff --git a/tests/unit/toast-accessibility.test.mjs b/tests/unit/toast-accessibility.test.mjs index c0aa79a0..c970e2a6 100644 --- a/tests/unit/toast-accessibility.test.mjs +++ b/tests/unit/toast-accessibility.test.mjs @@ -35,11 +35,22 @@ test('sync status uses the same explicit advisory status semantics', () => { }); test('cloud toast stylesheet is on every production serve path', () => { - const serverApp = readFileSync(new URL('../../server/app.mjs', import.meta.url), 'utf8'); + 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+\{\s*app\s+as\s+coreApp\s*\}\s+from\s+['"]\.\/app_core\.mjs['"]/, + 'SaaS facade imports the core application that owns static asset routes', + ); + assert.match( + serverFacade, + /for\s*\(const\s+route\s+of\s+coreApp\.routes\.filter\([\s\S]*?\)\)\s*\{\s*app\.on\(route\.method,\s*route\.path,\s*route\.handler\);\s*\}/, + 'SaaS facade replays the inherited core route graph after applying its bounded route exclusions', + ); + assert.match(serverCore, /['"]\/toast-state\.css['"]/, 'SaaS core 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'); @@ -61,4 +72,4 @@ test('cloud toast state is visibly rendered by a shipped stylesheet', () => { /\.toast\.visible\s*\{[^}]*\bopacity\s*:\s*1\s*;[^}]*\btransform\s*:\s*translateY\(0\)\s*;/s, 'the shipped cloud toast state becomes visually observable', ); -}); +}); \ No newline at end of file diff --git a/tests/unit/webhook-development-transport.test.mjs b/tests/unit/webhook-development-transport.test.mjs new file mode 100644 index 00000000..48fef972 --- /dev/null +++ b/tests/unit/webhook-development-transport.test.mjs @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { + WebhookDestinationError, + createWebhookTransport, +} from '../../server/webhook_transport.mjs'; + +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 devCapture = {}; +let httpsCalls = 0; +const devTransport = createWebhookTransport({ + allowDevelopmentLoopback: true, + lookup: async () => [{ address: '127.0.0.1', family: 4 }], + request: () => { + httpsCalls += 1; + throw new Error('HTTPS connector must not receive a development HTTP loopback'); + }, + httpRequest: responseRequest(204, devCapture), +}); + +assert.deepEqual( + await devTransport.post('http://127.0.0.1:8788/hook', { + headers: { 'x-scopeweave-event': 'project.update' }, + body: '{"ok":true}', + }), + { status: 204, ok: true }, + 'a loopback URL admitted in development mode is also deliverable', +); +assert.equal(httpsCalls, 0, 'development HTTP loopback never uses the HTTPS connector'); +assert.equal(devCapture.url.protocol, 'http:'); +assert.equal(devCapture.url.hostname, '127.0.0.1'); +assert.equal(devCapture.options.method, 'POST'); +assert.equal(devCapture.options.agent, false); +assert.equal('servername' in devCapture.options, false, 'development HTTP does not configure TLS SNI'); +assert.equal(devCapture.body, '{"ok":true}'); + +const productionTransport = createWebhookTransport({ + allowDevelopmentLoopback: false, + lookup: async () => [{ address: '127.0.0.1', family: 4 }], + request: responseRequest(204), + httpRequest: responseRequest(204), +}); +await assert.rejects( + () => productionTransport.post('http://127.0.0.1:8788/hook'), + WebhookDestinationError, + 'the loopback exception remains unavailable outside explicit development mode', +); + +let privateConnectorCalls = 0; +const privateHostnameTransport = createWebhookTransport({ + allowDevelopmentLoopback: true, + lookup: async () => [{ address: '10.0.0.5', family: 4 }], + request: responseRequest(204), + httpRequest: (...args) => { + privateConnectorCalls += 1; + return responseRequest(204)(...args); + }, +}); +await assert.rejects( + () => privateHostnameTransport.post('http://localhost:8788/hook'), + WebhookDestinationError, + 'development localhost may resolve only to loopback addresses', +); +assert.equal(privateConnectorCalls, 0, 'a non-loopback localhost answer never reaches a connector'); + +console.log('webhook development loopback transport tests passed'); diff --git a/tests/unit/webhook-legacy-migration.test.mjs b/tests/unit/webhook-legacy-migration.test.mjs new file mode 100644 index 00000000..f4b19277 --- /dev/null +++ b/tests/unit/webhook-legacy-migration.test.mjs @@ -0,0 +1,167 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { migrateLegacyWebhookDestinations } from '../../server/webhook_legacy_migration.mjs'; + +function createDatabase(path = ':memory:') { + const database = new DatabaseSync(path); + database.exec(` + CREATE TABLE webhooks ( + id INTEGER PRIMARY KEY, + org_id INTEGER NOT NULL, + url TEXT NOT NULL, + active INTEGER NOT NULL DEFAULT 1 + ); + 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 + ); + `); + return database; +} + +function plainRows(rows) { + return rows.map((row) => ({ ...row })); +} + +const production = createDatabase(); +production.exec(` + INSERT INTO webhooks(id,org_id,url,active) VALUES + (1,7,'http://legacy.example.test/hook',1), + (2,7,'http://localhost:8080/hook',1), + (3,7,'https://public.example.test/hook',1), + (4,7,'http://retired.example.test/hook',0), + (5,7,'https://localhost/hook',1), + (6,7,'https://127.0.0.1/hook',1), + (7,7,'https://10.0.0.8/hook',1); +`); +assert.equal( + migrateLegacyWebhookDestinations(production), + 5, + 'production disables every active historical destination rejected by the current registration policy', +); +assert.deepEqual( + plainRows(production.prepare('SELECT id, active FROM webhooks ORDER BY id').all()), + [ + { id: 1, active: 0 }, + { id: 2, active: 0 }, + { id: 3, active: 1 }, + { id: 4, active: 0 }, + { id: 5, active: 0 }, + { id: 6, active: 0 }, + { id: 7, active: 0 }, + ], +); +assert.equal( + production.prepare("SELECT COUNT(*) AS count FROM audit_log WHERE action = 'webhook.security_block'").get().count, + 5, + 'each newly disabled production row gets one tenant-visible security audit event', +); +assert.deepEqual( + plainRows(production.prepare("SELECT DISTINCT meta FROM audit_log WHERE action = 'webhook.security_block'").all()), + [{ meta: JSON.stringify({ reason: 'destination_policy', nextAction: 'register_public_https_replacement' }) }], + 'audit evidence explains the current destination-policy incompatibility rather than assuming every row used HTTP', +); +assert.equal( + migrateLegacyWebhookDestinations(production), + 0, + 'rerunning the migration is idempotent once incompatible rows are inactive', +); +assert.equal( + production.prepare("SELECT COUNT(*) AS count FROM audit_log WHERE action = 'webhook.security_block'").get().count, + 5, + 'idempotent restart does not duplicate audit evidence', +); +production.close(); + +const development = createDatabase(); +development.exec(` + INSERT INTO webhooks(id,org_id,url,active) VALUES + (10,8,'http://localhost:8080/hook',1), + (11,8,'http://127.0.0.8:8080/hook',1), + (12,8,'http://[::1]:8080/hook',1), + (13,8,'http://public.example.test/hook',1), + (14,8,'http://localhost.evil.example/hook',1), + (15,8,'https://localhost/hook',1), + (16,8,'https://public.example.test/hook',1); +`); +assert.equal( + migrateLegacyWebhookDestinations(development, { allowDevelopmentLoopback: true }), + 3, + 'explicit development mode preserves only admitted loopback HTTP and public HTTPS destinations', +); +assert.deepEqual( + plainRows(development.prepare('SELECT id, active FROM webhooks ORDER BY id').all()), + [ + { id: 10, active: 1 }, + { id: 11, active: 1 }, + { id: 12, active: 1 }, + { id: 13, active: 0 }, + { id: 14, active: 0 }, + { id: 15, active: 0 }, + { id: 16, active: 1 }, + ], +); +development.close(); + +const rollback = createDatabase(); +rollback.exec(` + INSERT INTO webhooks(id,org_id,url,active) + VALUES(20,9,'https://127.0.0.1/hook',1); + CREATE TRIGGER reject_security_audit + BEFORE INSERT ON audit_log + BEGIN + SELECT RAISE(ABORT, 'audit write rejected'); + END; +`); +assert.throws( + () => migrateLegacyWebhookDestinations(rollback), + /audit write rejected/, + 'migration fails closed when durable audit evidence cannot be written', +); +assert.equal( + rollback.prepare('SELECT active FROM webhooks WHERE id = 20').get().active, + 1, + 'failed audit persistence rolls back the webhook mutation atomically', +); +assert.equal( + rollback.prepare('SELECT COUNT(*) AS count FROM audit_log').get().count, + 0, + 'failed migration leaves no partial audit record', +); +assert.doesNotThrow( + () => rollback.exec('BEGIN IMMEDIATE; COMMIT;'), + 'rollback releases the write transaction for subsequent startup work', +); +rollback.close(); + +const contentionDirectory = mkdtempSync(join(tmpdir(), 'scopeweave-webhook-migration-')); +const contentionPath = join(contentionDirectory, 'scopeweave.sqlite'); +const contended = createDatabase(contentionPath); +const writer = new DatabaseSync(contentionPath); +try { + contended.exec(` + PRAGMA busy_timeout = 0; + INSERT INTO webhooks(id,org_id,url,active) + VALUES(30,10,'https://public.example.test/hook',1); + `); + writer.exec('PRAGMA busy_timeout = 0; BEGIN IMMEDIATE;'); + assert.doesNotThrow( + () => assert.equal(migrateLegacyWebhookDestinations(contended), 0), + 'a compliant no-op startup migration must not require the database write reservation', + ); +} finally { + writer.exec('ROLLBACK'); + writer.close(); + contended.close(); + rmSync(contentionDirectory, { recursive: true, force: true }); +} + +console.log('legacy webhook migration unit tests passed'); diff --git a/tests/unit/webhook-transport-timeout.test.mjs b/tests/unit/webhook-transport-timeout.test.mjs new file mode 100644 index 00000000..cf1c88c0 --- /dev/null +++ b/tests/unit/webhook-transport-timeout.test.mjs @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { + WebhookTransportError, + createWebhookTransport, +} from '../../server/webhook_transport.mjs'; + +let capturedTimeout; +let destroyCalls = 0; +const transport = createWebhookTransport({ + lookup: async () => [{ address: '93.184.216.34', family: 4 }], + request: (_url, options) => { + capturedTimeout = options.timeout; + const request = new EventEmitter(); + request.destroy = () => { + destroyCalls += 1; + queueMicrotask(() => request.emit('error', new Error('simulated stalled peer'))); + }; + request.end = () => { + queueMicrotask(() => { + if (options.timeout === undefined) { + request.emit('error', new Error('transport omitted its default timeout')); + return; + } + request.emit('timeout'); + }); + }; + return request; + }, +}); + +await assert.rejects( + () => transport.post('https://hooks.example.com/stalled', { + body: '{"event":"project.update"}', + }), + WebhookTransportError, + 'a stalled destination fails closed even when the caller supplies no AbortSignal', +); +assert.equal(capturedTimeout, 3000, 'transport owns a three-second default request timeout'); +assert.equal(destroyCalls, 1, 'the timeout actively destroys the stalled request'); + +console.log('webhook default timeout regression passed'); 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');