diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 6795d40e..f45dbe89 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -20,12 +20,24 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false + repository: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }} + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + + - name: Verify exact checkout revision + env: + EXPECTED_HEAD_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + shell: bash + run: | + set -euo pipefail + actual_head_sha="$(git rev-parse HEAD)" + printf 'expected_head_sha=%s\nactual_head_sha=%s\n' "$EXPECTED_HEAD_SHA" "$actual_head_sha" + test "$actual_head_sha" = "$EXPECTED_HEAD_SHA" - name: Check dependency review support id: dependency_review_support env: GH_TOKEN: ${{ github.token }} - BASE_SHA: ${{ github.event.pull_request.base.sha }} + BASE_REF: ${{ github.event.pull_request.base.ref }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} REPOSITORY: ${{ github.repository }} shell: bash @@ -40,33 +52,72 @@ jobs: api_url="${GITHUB_API_URL:-https://api.github.com}" response_file="$(mktemp)" - status="$( - curl -fsS -o "$response_file" -w '%{http_code}' \ + base_response_file="$(mktemp)" + compare_response_file="$(mktemp)" + trap 'rm -f "$response_file" "$base_response_file" "$compare_response_file"' EXIT + + base_ref_encoded="$(python3 -c 'import os, urllib.parse; print(urllib.parse.quote(os.environ["BASE_REF"], safe="/"))')" + base_status="$( + curl -sS -o "$base_response_file" -w '%{http_code}' \ -H "Accept: application/vnd.github+json" \ -H "Authorization: Bearer ${GH_TOKEN}" \ -H "X-GitHub-Api-Version: 2022-11-28" \ - "${api_url}/repos/${REPOSITORY}/dependency-graph/compare/${BASE_SHA}...${HEAD_SHA}" \ + "${api_url}/repos/${REPOSITORY}/branches/${base_ref_encoded}" \ || true )" - if [ "$status" = "200" ]; then - echo "supported=true" >>"$GITHUB_OUTPUT" - exit 0 + if [ "$base_status" != "200" ]; then + echo "::error::Unable to resolve live base branch ${BASE_REF}; GitHub API returned HTTP ${base_status}." + exit 1 fi - if [ "$status" = "403" ] || [ "$status" = "404" ]; then - echo "::warning::Dependency review is unavailable for ${REPOSITORY}; skipping dependency-review hard gate." - echo "supported=false" >>"$GITHUB_OUTPUT" - exit 0 + LIVE_BASE_SHA="$(jq -er '.commit.sha | select(test("^[0-9a-f]{40}$"))' "$base_response_file")" || { + echo "::error::Live base branch response did not contain a valid commit SHA." + exit 1 + } + printf 'live_base_sha=%s\n' "$LIVE_BASE_SHA" + + compare_status="$( + curl -sS -o "$compare_response_file" -w '%{http_code}' \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${GH_TOKEN}" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "${api_url}/repos/${REPOSITORY}/compare/${LIVE_BASE_SHA}...${HEAD_SHA}" \ + || true + )" + + if [ "$compare_status" != "200" ]; then + echo "::error::Unable to resolve the pull-request merge base for ${LIVE_BASE_SHA}...${HEAD_SHA}; GitHub API returned HTTP ${compare_status}." + exit 1 + fi + + BASE_SHA="$(jq -er '.merge_base_commit.sha | select(test("^[0-9a-f]{40}$"))' "$compare_response_file")" || { + echo "::error::Compare response did not contain a valid merge-base SHA." + exit 1 + } + echo "base_sha=$BASE_SHA" >>"$GITHUB_OUTPUT" + + status="$( + curl -sS -o "$response_file" -w '%{http_code}' \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${GH_TOKEN}" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "${api_url}/repos/${REPOSITORY}/dependency-graph/compare/${BASE_SHA}...${HEAD_SHA}" \ + || true + )" + + if [ "$status" != "200" ]; then + echo "::error::Dependency review support check failed with HTTP ${status}." + exit 1 fi - echo "::error::Dependency review support check failed with HTTP ${status}." - cat "$response_file" - exit 1 + echo "supported=true" >>"$GITHUB_OUTPUT" - name: Dependency review if: steps.dependency_review_support.outputs.supported == 'true' uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 with: + base-ref: ${{ steps.dependency_review_support.outputs.base_sha }} + head-ref: ${{ github.event.pull_request.head.sha }} fail-on-severity: moderate comment-summary-in-pr: on-failure diff --git a/.github/workflows/server-tests.yml b/.github/workflows/server-tests.yml index 458d3aa9..494f403f 100644 --- a/.github/workflows/server-tests.yml +++ b/.github/workflows/server-tests.yml @@ -25,6 +25,15 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false + repository: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }} + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + - name: Verify exact checkout revision + env: + EXPECTED_HEAD_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + run: | + actual_head_sha="$(git rev-parse HEAD)" + printf 'expected_head_sha=%s\nactual_head_sha=%s\n' "$EXPECTED_HEAD_SHA" "$actual_head_sha" + test "$actual_head_sha" = "$EXPECTED_HEAD_SHA" - name: Setup Node 22.13 uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: @@ -33,8 +42,8 @@ jobs: run: npm ci - name: Unit tests (EVM · CPM · baseline · workload) run: npm run test:unit - - name: API tests (auth · tenancy · RBAC · billing · webhooks · rate limit) - run: npm run test:api + - name: Owned production coverage evidence (unit · API under c8) + run: npm run test:coverage - name: app.js stays eval-safe (no top-level import/export) run: node -e "new Function(require('fs').readFileSync('app.js','utf8')); console.log('eval-safe OK')" @@ -45,6 +54,15 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false + repository: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }} + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + - name: Verify exact checkout revision + env: + EXPECTED_HEAD_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + run: | + actual_head_sha="$(git rev-parse HEAD)" + printf 'expected_head_sha=%s\nactual_head_sha=%s\n' "$EXPECTED_HEAD_SHA" "$actual_head_sha" + test "$actual_head_sha" = "$EXPECTED_HEAD_SHA" - name: Setup Node 22.13 uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index e434fa01..d91402f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +- Redacted invite and public-share bearer-token path segments in both ordinary + request logs and rate-limit rejection logs, including malformed trailing + paths, so access-log retention cannot become a credential disclosure channel. +- Unified server mailbox canonicalization across password, invitation, OIDC, + and legacy-migration boundaries with trim, NFC normalization, lowercase + conversion, and canonical unique-index enforcement. +- Made the public HTTPS transport serialize URLSearchParams as UTF-8 bytes so + production OIDC token exchanges reach the provider instead of failing at the + request boundary. +- Replaced the unsigned `POST /api/stripe/webhook` plan-upgrade stub with a + fail-closed raw-body HMAC-SHA-256 signature boundary. Signed deliveries are + acknowledged only; webhook JSON is not entitlement authority until durable + event reconciliation. Unsigned, stale, or body-mutated signatures fail closed, + and the public app copies protected logging and rate-limit middleware so abuse + controls still wrap the endpoint (Krawczyk et al., 1997; National Institute of + Standards and Technology, 2008). - Made contextual-orchestrator briefing requests fail closed unless an authenticated endpoint is configured. Deterministic generated text is restricted to explicit `SCOPEWEAVE_DEV=1`, message/provider responses are bounded and validated, and non-loopback HTTP transport is rejected. - Made `SCOPEWEAVE_JWT_SECRET` mandatory at startup and rejected weak or unexpanded placeholder values so production deployments fail closed. diff --git a/docs/deploy.md b/docs/deploy.md index 0cfdb799..4321917e 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -34,17 +34,53 @@ persists the database in the `scopeweave-data` volume. | `SCOPEWEAVE_JWT_SECRET` | **yes** | Signs session JWTs. Startup fails unless it contains at least 32 non-whitespace characters. | | `PORT` | no (default 8787) | Listen port | | `SCOPEWEAVE_DB` | no (default `/data/scopeweave.db`) | SQLite file path (on the volume) | -| `SCOPEWEAVE_DEV` | no | Must be `1` to enable the dev `activate-pro` endpoint. **Never set in production.** | +| `SCOPEWEAVE_DEV` | no | Must be `1` to enable development-only behavior: the `activate-pro` endpoint, the built-in OIDC mock when `OIDC_ISSUER` is unset, and the deterministic orchestrator mock when `ORCHESTRATOR_URL` is unset. **Never set in production.** | | `STRIPE_SECRET_KEY`, `STRIPE_PRICE_ID`, `STRIPE_WEBHOOK_SECRET` | for live billing | Enables real Stripe Checkout (`npm i stripe` too). Without them, billing uses the mock path. | -| `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_REDIRECT_URI` | for real SSO | Points the OIDC login at your IdP. Unset → a built-in mock IdP (dev/test only). | -| `ORCHESTRATOR_URL` | for AI 브리핑 | contextual-orchestrator 주소. Unset → deterministic mock. | -| `ORCHESTRATOR_TOKEN` | with URL | orchestrator Bearer 토큰 (`CONTEXTUAL_ORCHESTRATOR_TOKEN`). | +| `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_REDIRECT_URI` | for real SSO | Points the OIDC login at your IdP. Outside explicit `SCOPEWEAVE_DEV=1`, a missing issuer fails closed with `404 sso not configured`; the built-in mock exists only when the issuer is unset **and** development mode is explicitly enabled. | +| `ORCHESTRATOR_URL` | for AI briefing | contextual-orchestrator origin. A missing URL fails closed outside explicit `SCOPEWEAVE_DEV=1`; the deterministic mock exists only in development mode. | +| `ORCHESTRATOR_TOKEN` | with URL | Required Bearer token for configured orchestrator requests. Set it to the same value as the orchestrator's `CONTEXTUAL_ORCHESTRATOR_TOKEN` setting. | | `CLEARFOLIO_URL` | for 산출물 viewer | Clearfolio 문서 뷰어 백엔드 주소. Unset → built-in mock (dev/test). | | `CLEARFOLIO_HMAC_SECRET` | optional | Signs tenant-claim headers (`clearfolio.tenant-claims.hmac-secret`와 동일 값). | | `SCOPEWEAVE_ATTACHMENT_STATUS_CONCURRENCY` | no (default 8, maximum 32) | Maximum concurrent Clearfolio status lookups during one attachment-list request. Invalid values fall back to 8; values above 32 are clamped. | | `SCOPEWEAVE_ATTACHMENT_STATUS_TIMEOUT_MS` | no (default 3000, maximum 30000) | Hard caller-side timeout for each Clearfolio status lookup. The AbortSignal is also forwarded downstream. | | `SCOPEWEAVE_ATTACHMENT_STATUS_BUDGET_MS` | no (default 5000, maximum 60000) | Wall-clock budget for the entire best-effort refresh pass. Work not started before the deadline is deferred to a later list request. | -| `SCOPEWEAVE_RATE_LIMIT_MAX` (+ `SCOPEWEAVE_RATE_LIMIT_WINDOW_MS`) | recommended | Per-IP fixed-window rate limiting (429 + Retry-After). Off when unset. | +| `SCOPEWEAVE_RATE_LIMIT_MAX` | recommended | Per-client fixed-window request allowance. Unset or explicit `0` disables the limiter. Any other configured value must be a non-negative safe integer or startup fails. | +| `SCOPEWEAVE_RATE_LIMIT_WINDOW_MS` | no (default 60000) | Fixed-window duration in milliseconds. An explicit value must be a positive safe integer or startup fails. | +| `SCOPEWEAVE_RATE_LIMIT_BUCKETS_MAX` | no (default 10000) | Maximum number of live per-client limiter buckets held by one ScopeWeave process. An explicit value must be a positive safe integer or startup fails. Once capacity is reached, previously unseen identities share a fail-closed overflow bucket until expired regular buckets are reclaimed. | +| `SCOPEWEAVE_TRUSTED_PROXY_IPS` | only behind trusted reverse proxies | Comma-separated **immediate or chained proxy peer IPs** that ScopeWeave is allowed to trust when interpreting `X-Forwarded-For`. Configure the actual IP once; dotted IPv4-mapped Node spellings such as `::ffff:127.0.0.1` are normalized to their IPv4 address before trust comparison. Leave unset for direct deployments. | + +### Rate-limit capacity and tuning + +When the limiter is enabled, `429` responses include `Retry-After`. Client +identity is anchored to the actual network peer unless that peer is explicitly +trusted through `SCOPEWEAVE_TRUSTED_PROXY_IPS`. Valid dotted IPv4-mapped IPv6 +peer and forwarded-hop spellings are canonicalized to their underlying IPv4 +address before trust comparison and limiter-key selection. This prevents a +Node dual-stack listener from collapsing all proxied clients into one limiter +bucket merely because the socket exposed an IPv4 proxy as `::ffff:a.b.c.d`. +Invalid address text is never admitted as a trusted identity. + +The regular in-memory bucket map is deliberately bounded by +`SCOPEWEAVE_RATE_LIMIT_BUCKETS_MAX`; attacker-driven high-cardinality source +identities therefore cannot create unbounded limiter state. At capacity, new +identities use one shared overflow bucket rather than allocating new map entries. +Expired regular buckets are reclaimed by a bounded sweep before admitting new +identities. + +Size `SCOPEWEAVE_RATE_LIMIT_BUCKETS_MAX` for the maximum legitimate concurrent +client-identity population expected **per process**, with headroom for normal +bursts. Do not increase it merely to make overflow throttling disappear: first +confirm that trusted-proxy identity extraction is correct and that the observed +cardinality is legitimate. In horizontally scaled deployments, each replica has +its own limiter state; this fixed-window implementation is a process-local abuse +control, not a globally coordinated quota system. Use a shared rate-limit store +or edge control plane when a cross-replica/global quota is required. + +Limiter numeric configuration is fail-closed. A malformed, infinite, negative, +or otherwise unsafe explicit value causes startup failure instead of silently +disabling protection or resetting windows on every request. Treat such a startup +failure as a configuration incident; correct the setting rather than removing +or bypassing the limiter gate. ## Attachment status refresh operations @@ -133,6 +169,22 @@ Terminate TLS at a reverse proxy (nginx/Caddy/ALB) in front of the backend and forward to `:8787`. The client and API share the origin, so no CORS config is needed. +`X-Forwarded-For` is **ignored for the security rate-limit identity by default**. +If a reverse proxy is the only permitted ingress to ScopeWeave, list its actual +network peer address in `SCOPEWEAVE_TRUSTED_PROXY_IPS`. For multiple trusted +proxy hops, list every trusted hop. You do not need to duplicate an IPv4 proxy +as both `a.b.c.d` and Node's dotted IPv4-mapped `::ffff:a.b.c.d` representation; +ScopeWeave canonicalizes that mapped socket/hop spelling before the trust +comparison. ScopeWeave then walks `X-Forwarded-For` from right to left, skips +explicitly trusted proxy addresses, and chooses the first untrusted valid IP as +the client identity. Missing, malformed, or all-trusted forwarding evidence +falls back to the actual socket peer. + +Do not configure this trust list while untrusted clients can connect directly to +the backend. The proxy must overwrite or append forwarding information according +to a controlled ingress policy; accepting a caller-selected forwarding header +without an authenticated/trusted peer would make rate limiting bypassable. + ## Kubernetes The existing `infra/k8s/` manifests deploy the **static-only** nginx image. For diff --git a/docs/doctoring/stripe-webhook-trust-boundary.md b/docs/doctoring/stripe-webhook-trust-boundary.md new file mode 100644 index 00000000..50e04621 --- /dev/null +++ b/docs/doctoring/stripe-webhook-trust-boundary.md @@ -0,0 +1,97 @@ +# Stripe webhook trust boundary + +## Decision + +Protected `develop` previously accepted an unauthenticated +`checkout.session.completed` JSON body and upgraded `orgs.plan` to `pro`. That +is a privilege-escalation window: any caller who can POST to +`/api/stripe/webhook` can grant paid entitlements. + +The integrated HTTP composition has one ordered security envelope: + +1. `server/app.mjs` owns the public transport-peer-aware rate limiter and wraps + the route graph before any route-specific identity or database work; +2. `server/app_routes.mjs` is a transitional import seam that delegates to the + supported shared boundary in `server/application_routes.mjs` while the public + envelope initializes the historical core limiter disabled; +3. the shared boundary installs production OIDC fail-closed, invite identity + binding, and pending-invite-token redaction guards before mounting the + internal implementation graph from `server/application_routes_core.mjs`; +4. the internal graph retains request logging and metrics and contains the + single `POST /api/stripe/webhook` route; and +5. that route HMAC-SHA-256-checks the exact bounded raw body using the Stripe + `t` and `v1` signature values before parsing JSON, acknowledges an authentic + delivery with `{ received: true }`, and does **not** mutate `orgs.plan`. + +The public limiter is authoritative for public traffic, so rejected OIDC/invite +requests cannot perform route-guard database lookups before rate-limit admission. +The shared boundary still delegates guard rejections through a non-mutating core +accounting probe so request logging and metrics retain the attempted operation. +Direct consumers of `application_routes.mjs` retain the core limiter according +to their own initialization environment; the compatibility `app_routes.mjs` +seam is not a supported consumer entry point. + +`server/application_routes_core.mjs` is an internal implementation module, not +a supported application entry point. Consumers must import +`server/application_routes.mjs` or `server/app.mjs`; bypassing the supported +boundary would bypass its OIDC and invitation controls. + +Signature validity authenticates the delivery only. Durable event +deduplication, provider-state reconciliation, and entitlement writes remain +follow-up work (stacked billing lifecycle), not this hotfix. + +## Standards rationale + +HMAC-SHA-256 over the exact signed bytes is the Stripe webhook contract and +matches RFC 2104 / FIPS 198-1 keyed hashing. JSON parsing happens only after +constant-time comparison so semantically equivalent but byte-different bodies +cannot be substituted. Replay is bounded by a five-minute timestamp window. +Missing configuration fails closed with `503 stripe_webhook_not_configured` +rather than accepting unsigned traffic. + +OAuth bearer-token rules (RFC 6750; RFC 9700) do not apply to this provider +callback; the webhook secret is a shared HMAC key, not an access token. The +endpoint remains inside the ordered abuse-control and observability composition +so unsigned floods are rate-limited before route-specific work and remain +visible in request accounting. + +## Verification contract + +Regression tests must prove: + +- unsigned `checkout.session.completed` JSON never upgrades `orgs.plan`; +- a correctly signed delivery is acknowledged and still leaves plan unchanged; +- a stale timestamp or a JSON-equivalent mutated body fails signature checks; +- when `SCOPEWEAVE_RATE_LIMIT_MAX=1`, the second webhook in the window is `429`; +- `server/app.mjs` establishes the public limiter before mounting the supported + shared route graph; +- the transitional import seam delegates only to `application_routes.mjs`; +- the shared boundary installs OIDC and invitation guards before + `app.route('/', coreRoutes)`; +- both the public app and the supported shared route graph fail closed when + production OIDC is unconfigured; +- the internal core Stripe route invokes `verifyStripeWebhookRequest`; and +- the core graph contains no `checkout.session.completed` path that treats + callback JSON as entitlement authority. + +## Officer next action + +Rotate `STRIPE_WEBHOOK_SECRET` if it may have been exposed while the unsigned +stub was live. Point the Stripe endpoint at the public `/api/stripe/webhook` +path. Configure `SCOPEWEAVE_TRUSTED_PROXY_IPS` only for authenticated/controlled +reverse-proxy peers; otherwise forwarding headers are intentionally ignored. Do +not treat a `200 { received: true }` as proof that the organization is Pro until +reconciliation writes entitlements from Stripe's retrieved session state. + +## References + +Krawczyk, H., Bellare, M., & Canetti, R. (1997). *HMAC: Keyed-hashing for +message authentication* (RFC 2104). Internet Engineering Task Force. +https://doi.org/10.17487/RFC2104 + +National Institute of Standards and Technology. (2008). *The keyed-hash +message authentication code (HMAC)* (FIPS PUB 198-1). U.S. Department of +Commerce. https://doi.org/10.6028/NIST.FIPS.198-1 + +Stripe. (n.d.). *Webhook signatures*. Stripe Docs. +https://docs.stripe.com/webhooks/signatures diff --git a/docs/doctoring/trusted-proxy-client-ip.md b/docs/doctoring/trusted-proxy-client-ip.md new file mode 100644 index 00000000..38a9b2ec --- /dev/null +++ b/docs/doctoring/trusted-proxy-client-ip.md @@ -0,0 +1,108 @@ +# Trusted proxy client-IP boundary + +## Status + +**Active implementation evidence for PR #587.** This note records the security +reasoning and primary/authoritative references for ScopeWeave's rate-limit +client-identity boundary. It does not claim certification or protected-`develop` +shipment. + +## Decision + +Security-sensitive client identity must begin with the transport peer that +actually connected to the Node process. A caller-supplied `X-Forwarded-For` +header is ignored unless that immediate peer is explicitly listed in +`SCOPEWEAVE_TRUSTED_PROXY_IPS`. + +Before trust comparison, ScopeWeave canonicalizes valid Node peer and forwarded +IP spellings. In particular, Node may expose an IPv4 connection accepted by an +IPv6 dual-stack listener as a dotted IPv4-mapped IPv6 address such as +`::ffff:127.0.0.1`. ScopeWeave maps that representation to the underlying IPv4 +address before trust lookup and limiter-key selection. An operator can therefore +configure the actual IPv4 ingress address once rather than having to predict +whether a deployment listener will surface `127.0.0.1` or +`::ffff:127.0.0.1`. Invalid address text is never normalized into a trusted +identity. Ordinary IPv6 peers remain IPv6 identities. + +When the immediate peer is trusted, ScopeWeave parses the forwarding chain from +right to left. Explicitly trusted proxy hops are skipped. The first untrusted, +syntactically valid IP becomes the rate-limit identity. Missing, malformed, or +all-trusted forwarding evidence fails closed to the actual peer. In adapters +where the socket peer is unavailable, requests share one `local` bucket rather +than accepting an unauthenticated forwarding value. + +Only the security envelope in `server/app.mjs` is allowed to consume rate-limit +state. The older route-module limiter is initialized disabled when loaded by the +production envelope. Keeping two active limiters would let an attacker place a +victim address on the spoofable left side of an appended forwarding chain and +consume the victim's legacy bucket even though the trusted-boundary limiter had +correctly attributed the request to the attacker's nearest hop. + +This is intentionally an allow-list trust model. It avoids treating a header +that an Internet client can normally set itself as authenticated evidence. The +operator contract in `docs/deploy.md` therefore requires direct access to the +backend to be denied before proxy addresses are added to the trust list. + +## Availability and isolation boundary + +Failing to recognize an IPv4-mapped spelling is not a useful fail-closed state. +It makes a legitimate trusted proxy look like an ordinary client, causing every +request behind that proxy to share the proxy-peer limiter bucket while the +forwarded client identity is ignored. One noisy tenant/client can then consume +another legitimate client's capacity. Canonicalizing the mapped peer before the +trust decision preserves the security prerequisite—only an explicitly trusted +transport peer unlocks forwarding evidence—while restoring per-client isolation +for dual-stack Node deployments. + +The mapping is intentionally narrow: only syntactically valid dotted +IPv4-mapped IPv6 values are collapsed to IPv4. This is the representation +observed from Node's dual-stack socket boundary and covered by the executable +regression; this repair does not broaden trust to arbitrary hostname, subnet, or +string aliases. + +## Acceptance trace + +`tests/api/ratelimit.test.mjs` exercises the boundary: + +1. in-process/untrusted requests exhaust one bucket even when the caller changes + `X-Forwarded-For`; +2. an actual Node loopback connection is configured as a trusted ingress; +3. changing spoofable left-side forwarding values while keeping the nearest + untrusted client hop fixed still returns `429` after the configured limit; +4. a different nearest client hop receives a separate bucket; +5. repeated attacker requests with a spoofed victim address on the left do not + consume the victim's rate-limit bucket; +6. trusted proxy hops are skipped right-to-left; +7. missing, malformed, and all-trusted forwarding evidence falls back to the + transport peer; and +8. a Node peer represented as `::ffff:127.0.0.1` matches an operator trust + configuration containing only `127.0.0.1`, so two forwarded clients retain + separate limiter buckets. + +The original bypass RED evidence was the hosted Server Tests failure at +contributor head `c4a3b2f6429634c4165f7efdc71734384241e574`: the spoofed +forwarding value returned `200` where the regression required `429`. + +The cross-client poisoning RED evidence was Server Tests run `32614096313` on +the merge result containing contributor head +`dc2fe13b1825975bc38459862f48e2c645351924`: a legitimate victim request +returned `429` where the regression required `200`. + +The IPv4-mapped proxy RED is registered at contributor head +`19da67325feeb8275e730c420cb3b7782db3945e`. Server Tests run +`32629696310`, `unit-and-api` job `97170457703`, failed at the intended +regression because a second forwarded client received HTTP `429` instead of +`200` when the socket peer was `::ffff:127.0.0.1` but the trusted-proxy +configuration contained only `127.0.0.1`. Production repair +`dec4e7a2e78c95491576bb35cb3818e5340a63c9` canonicalizes the mapped peer and +forwarded-hop representations at the trust/key boundary. Fresh terminal evidence +for the final documentation head remains revision-specific and must not be +borrowed from predecessor or synthetic-only runs. + +## References (APA 7) + +Hono. (n.d.). *Node.js*. https://hono.dev/docs/getting-started/nodejs + +MDN Web Docs. (2025, July 4). *X-Forwarded-For header*. Mozilla. https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/X-Forwarded-For + +Petersson, A., & Nilsson, M. (2014). *Forwarded HTTP extension* (RFC 7239). Internet Engineering Task Force. https://doi.org/10.17487/RFC7239 diff --git a/index.html b/index.html index d24b2a88..879ad03b 100644 --- a/index.html +++ b/index.html @@ -7,6 +7,8 @@ ScopeWeave Planner + + diff --git a/package-lock.json b/package-lock.json index 00a99254..41e96002 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "fast-check": "4.9.0" }, "engines": { - "node": "^22.13.0 || >=23.4.0" + "node": "^22.13.0 || >=23.5.0" } }, "node_modules/@bcoe/v8-coverage": { diff --git a/package.json b/package.json index 8cefdc74..bb03d547 100644 --- a/package.json +++ b/package.json @@ -6,16 +6,16 @@ "packageManager": "npm@10.9.2", "description": "Production-grade pure HTML/CSS/JS WBS planner", "engines": { - "node": "^22.13.0 || >=23.4.0" + "node": "^22.13.0 || >=23.5.0" }, "scripts": { "check:python-docstrings": "node scripts/ci/static_coverage_evidence.mjs docstrings", "coverage": "npm run test:coverage", "server": "node server/server.mjs", - "test:api": "node tests/api/auth-secret.test.mjs && node tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs", - "test: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/email-identity.test.mjs && node tests/api/email-unicode-identity.test.mjs && node tests/api/smoke.mjs && node tests/api/invite-security.test.mjs && node tests/api/oidc-production-boundary.test.mjs && node tests/api/oidc-provider-metadata.test.mjs && node tests/api/oidc-core-production-failclosed.test.mjs && node tests/api/security-guard-abuse-controls.test.mjs && node tests/api/stripe-webhook.test.mjs && node tests/api/ratelimit.test.mjs && node tests/api/rate-limit-prometheus.test.mjs && node tests/api/rate-limit-shared-boundary.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/node-runtime-contract.test.mjs && 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/dependency-review-merge-base-contract.test.mjs && node tests/unit/rate-limit-observability-failure.test.mjs && node tests/unit/rate-limit-scoped-ipv6.test.mjs && node tests/unit/public-https-transport.test.mjs && node tests/unit/stripe-webhook-boundary.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/rate_limit.mjs --include=server/application_routes.mjs --include=server/application_routes_core.mjs --include=server/application_routes_implementation.mjs --include=server/auth.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --include=server/public_https_transport.mjs --include=server/stripe_webhook.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", + "test:coverage:cases": "node tests/unit/node-runtime-contract.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/dependency-review-merge-base-contract.test.mjs && node tests/unit/rate-limit-observability-failure.test.mjs && node tests/unit/public-https-transport.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/stripe-webhook-boundary.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..915febad 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -1,1410 +1,26 @@ -// 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. +// Security envelope for the ScopeWeave SaaS routes. +// +// The supported shared route graph also serves direct consumers, but the public +// Node entrypoint must remain authoritative for its own transport peer. Both +// boundaries therefore consume the same rate-limit module. The shared runtime +// context marker makes the nested limiter a no-op after the public boundary has +// admitted a request, so the shared route graph can remain correctly configured +// when it is later reused directly from the same module cache. 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'; +import { + createRateLimitMiddleware, + createRateLimitObservability, +} from './rate_limit.mjs'; + +// app_routes.mjs is a compatibility re-export of application_routes.mjs. Keep +// that supported shared boundary configured with the operator's real rate-limit +// policy; createRateLimitMiddleware deduplicates the nested mounted middleware +// through its Hono context marker instead of mutating process-global config +// during module evaluation. +const { app: routeApp } = await import('./app_routes.mjs'); export const app = new Hono(); -async function requireAuth(c, next) { - const header = c.req.header('authorization') || ''; - const token = header.startsWith('Bearer ') ? header.slice(7) : ''; - // Personal Access Token path (swk_...): look up by hash, act as its user. - if (token.startsWith('swk_')) { - const row = db.prepare('SELECT * FROM api_tokens WHERE token_hash = ?').get(hashApiToken(token)); - if (!row) return c.json({ error: 'unauthorized' }, 401); - db.prepare("UPDATE api_tokens SET last_used = datetime('now') WHERE id = ?").run(row.id); - c.set('user', { sub: row.user_id, viaPat: true }); - return next(); - } - try { - const payload = verifyToken(token); - // Session revocation: a bumped token_version invalidates all older JWTs. - const u = db.prepare('SELECT token_version FROM users WHERE id = ?').get(payload.sub); - if (!u || (payload.tv || 0) !== u.token_version) return c.json({ error: 'unauthorized' }, 401); - c.set('user', payload); - } catch { - return c.json({ error: 'unauthorized' }, 401); - } - await next(); -} - -// --- realtime: projectId -> Set -const streams = new Map(); -function broadcast(projectId, data) { - const subs = streams.get(String(projectId)); - if (!subs) return; - const chunk = new TextEncoder().encode(`data: ${JSON.stringify(data)}\n\n`); - for (const ctrl of subs) { - try { ctrl.enqueue(chunk); } catch { /* dropped subscriber */ } - } -} - -// Membership-scoped project fetch — the tenant isolation boundary. -function projectAccess(userId, projectId) { - return db.prepare( - `SELECT p.*, m.role AS memberRole FROM projects p - JOIN memberships m ON m.org_id = p.org_id - WHERE p.id = ? AND m.user_id = ?` - ).get(projectId, userId); -} - -// --- observability: in-process counters + structured request log. -const metrics = { - startedAt: new Date().toISOString(), - requests: 0, - s2xx: 0, - s4xx: 0, - s5xx: 0, - signups: 0, - projectsCreated: 0, - webhookDeliveries: 0, - attachmentStatusRefreshAttempted: 0, - attachmentStatusRefreshChanged: 0, - attachmentStatusRefreshFailed: 0, - attachmentStatusRefreshDeferred: 0, -}; - -// Outbound webhooks: POST signed JSON to each active hook subscribed to `event`. -// Fire-and-forget with a timeout, one retry on failure, and a recorded outcome -// per attempt — never blocks or fails the triggering request. -function recordDelivery(webhookId, event, status, ok, attempt) { - try { - db.prepare('INSERT INTO webhook_deliveries(webhook_id,event,status_code,ok,attempt) VALUES(?,?,?,?,?)') - .run(webhookId, event, status ?? null, ok ? 1 : 0, attempt); - } catch { /* recording must not break delivery */ } -} - -function sendWebhook(webhookId, url, sig, event, body, attempt) { - metrics.webhookDeliveries++; - const ctrl = new AbortController(); - const to = setTimeout(() => ctrl.abort(), 3000); - fetch(url, { - method: 'POST', - headers: { 'content-type': 'application/json', 'x-scopeweave-event': event, 'x-scopeweave-signature': `sha256=${sig}` }, - body, - signal: ctrl.signal, - }).then((res) => { - recordDelivery(webhookId, event, res.status, res.ok, attempt); - if (!res.ok && attempt < 2) setTimeout(() => sendWebhook(webhookId, url, sig, event, body, attempt + 1), 500); - }).catch(() => { - recordDelivery(webhookId, event, null, false, attempt); - if (attempt < 2) setTimeout(() => sendWebhook(webhookId, url, sig, event, body, attempt + 1), 500); - }).finally(() => clearTimeout(to)); -} - -function deliver(orgId, event, payload) { - let hooks; - try { - hooks = db.prepare('SELECT id, url, secret, events FROM webhooks WHERE org_id = ? AND active = 1').all(orgId); - } catch { return; } - for (const h of hooks) { - const subs = String(h.events || '').split(',').map((s) => s.trim()); - if (!(subs.includes('*') || subs.includes(event))) continue; - const body = JSON.stringify({ event, orgId: Number(orgId), payload, ts: new Date().toISOString() }); - const sig = createHmac('sha256', h.secret).update(body).digest('hex'); - sendWebhook(h.id, h.url, sig, event, body, 1); - } -} -const quietLogs = String(process.env.SCOPEWEAVE_DB || '').includes(':memory:'); // silence during tests -app.use('*', async (c, next) => { - const t = Date.now(); - await next(); - try { - metrics.requests++; - const s = c.res.status; - if (s >= 500) metrics.s5xx++; else if (s >= 400) metrics.s4xx++; else if (s >= 200) metrics.s2xx++; - if (!quietLogs) { - // structured; never logs bodies, tokens, or secrets - console.log(JSON.stringify({ ts: new Date().toISOString(), method: c.req.method, path: c.req.path, status: s, ms: Date.now() - t })); - } - } catch { /* metrics/logging must never break a request */ } -}); - -// Rate limiting (opt-in via SCOPEWEAVE_RATE_LIMIT_MAX, per client IP, fixed -// window). Protects against brute-force/abuse. Off by default so it never -// surprises tests/dev. Ceiling: per-instance in-memory → use Redis for multi-node. -const RL_MAX = Number(process.env.SCOPEWEAVE_RATE_LIMIT_MAX) || 0; -const RL_WINDOW_MS = Number(process.env.SCOPEWEAVE_RATE_LIMIT_WINDOW_MS) || 60000; -const rlBuckets = new Map(); -if (RL_MAX > 0) { - app.use('*', async (c, next) => { - const key = (c.req.header('x-forwarded-for') || '').split(',')[0].trim() || 'local'; - const now = Date.now(); - let b = rlBuckets.get(key); - if (!b || b.resetAt <= now) { b = { count: 0, resetAt: now + RL_WINDOW_MS }; rlBuckets.set(key, b); } - b.count++; - if (b.count > RL_MAX) { - const retry = Math.ceil((b.resetAt - now) / 1000); - return c.json({ error: 'rate limit exceeded' }, 429, { 'Retry-After': String(retry) }); - } - await next(); - }); -} - -app.post('/api/auth/signup', async (c) => { - const { email, password, name } = await c.req.json().catch(() => ({})); - if (!email || typeof password !== 'string' || password.length < 8) { - return c.json({ error: 'email and password (min 8 chars) required' }, 400); - } - if (db.prepare('SELECT id FROM users WHERE email = ?').get(email)) { - return c.json({ error: 'email already registered' }, 409); - } - // user + personal workspace + owner membership, atomically. - let uid; - const tx = () => { - uid = rowid(db.prepare('INSERT INTO users(email,password_hash,name) VALUES(?,?,?)') - .run(email, hashPassword(password), name || '')); - const oid = rowid(db.prepare('INSERT INTO orgs(name,owner_id) VALUES(?,?)') - .run(`${name || email}'s workspace`, uid)); - db.prepare('INSERT INTO memberships(org_id,user_id,role) VALUES(?,?,?)').run(oid, uid, 'owner'); - }; - db.exec('BEGIN'); - try { tx(); db.exec('COMMIT'); } catch (e) { db.exec('ROLLBACK'); throw e; } - metrics.signups++; - return c.json({ token: signToken({ sub: uid, email, tv: 0 }) }); -}); - -app.post('/api/auth/login', async (c) => { - const { email, password } = await c.req.json().catch(() => ({})); - const u = db.prepare('SELECT * FROM users WHERE email = ?').get(email || ''); - // Pass password through only when it is a string — verifyPassword rejects - // non-strings (objects/arrays) so they never match an empty-password hash. - if (!u || typeof password !== 'string' || !verifyPassword(password, u.password_hash)) { - return c.json({ error: 'invalid credentials' }, 401); - } - return c.json({ token: signToken({ sub: u.id, email: u.email, tv: u.token_version }) }); -}); - -app.get('/api/me', requireAuth, (c) => { - const uid = c.get('user').sub; - const user = db.prepare('SELECT id,email,name FROM users WHERE id = ?').get(uid); - const orgs = db.prepare( - `SELECT o.id,o.name,o.plan,m.role FROM orgs o - JOIN memberships m ON m.org_id = o.id WHERE m.user_id = ?` - ).all(uid); - return c.json({ user, orgs }); -}); - -// Create an additional workspace (org); the creator becomes its owner. -app.post('/api/orgs', requireAuth, async (c) => { - const uid = c.get('user').sub; - const { name } = await c.req.json().catch(() => ({})); - if (!name || !String(name).trim()) return c.json({ error: 'name required' }, 400); - let oid; - db.exec('BEGIN'); - try { - oid = rowid(db.prepare('INSERT INTO orgs(name,owner_id) VALUES(?,?)').run(String(name).trim().slice(0, 120), uid)); - db.prepare('INSERT INTO memberships(org_id,user_id,role) VALUES(?,?,?)').run(oid, uid, 'owner'); - db.exec('COMMIT'); - } catch (e) { db.exec('ROLLBACK'); throw e; } - logAudit(oid, uid, 'org.create', 'org', oid, { name }); - return c.json({ id: oid, name: String(name).trim(), role: 'owner' }); -}); - -app.get('/api/projects', requireAuth, (c) => { - const uid = c.get('user').sub; - const projects = db.prepare( - `SELECT p.id,p.name,p.base_date AS baseDate,p.version,p.org_id AS orgId,p.updated_at AS updatedAt,p.archived - FROM projects p JOIN memberships m ON m.org_id = p.org_id - WHERE m.user_id = ? ORDER BY p.archived ASC, p.updated_at DESC` - ).all(uid); - return c.json({ projects }); -}); - -app.post('/api/projects', requireAuth, async (c) => { - const uid = c.get('user').sub; - const { name, orgId } = await c.req.json().catch(() => ({})); - if (!name) return c.json({ error: 'name required' }, 400); - const org = orgId - ? db.prepare('SELECT o.id FROM orgs o JOIN memberships m ON m.org_id = o.id WHERE o.id = ? AND m.user_id = ?').get(orgId, uid) - : db.prepare('SELECT o.id FROM orgs o JOIN memberships m ON m.org_id = o.id WHERE m.user_id = ? ORDER BY o.id LIMIT 1').get(uid); - if (!org) return c.json({ error: 'no accessible org' }, 400); - if (wouldExceed(db, getOrg(org.id), 'projects')) { - return c.json({ error: 'project limit reached on the Free plan', upgrade: true, limit: PLANS.free.limits.projects }, 402); - } - const id = rowid(db.prepare('INSERT INTO projects(org_id,name,created_by) VALUES(?,?,?)').run(org.id, name, uid)); - metrics.projectsCreated++; - logAudit(org.id, uid, 'project.create', 'project', id, { name }); - return c.json({ id, name, version: 1 }); -}); - -app.get('/api/projects/:id', requireAuth, (c) => { - const p = projectAccess(c.get('user').sub, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - return c.json({ id: p.id, name: p.name, orgId: p.org_id, baseDate: p.base_date, methodology: p.methodology || 'waterfall', tasks: JSON.parse(p.tasks_json), version: p.version }); -}); - -app.put('/api/projects/:id', requireAuth, async (c) => { - const uid = c.get('user').sub; - const id = c.req.param('id'); - const p = projectAccess(uid, id); - if (!p) return c.json({ error: 'not found' }, 404); - if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden: viewer role is read-only' }, 403); - const body = await c.req.json().catch(() => ({})); - if (typeof body.version === 'number' && body.version !== p.version) { - return c.json({ error: 'version conflict', current: p.version }, 409); - } - const tasks = Array.isArray(body.tasks) ? body.tasks : JSON.parse(p.tasks_json); - const version = p.version + 1; - const methodology = ['waterfall', 'agile', 'hybrid'].includes(body.methodology) ? body.methodology : (p.methodology || 'waterfall'); - db.prepare( - "UPDATE projects SET name=?, base_date=?, tasks_json=?, version=?, methodology=?, updated_at=datetime('now') WHERE id=?" - ).run(body.name ?? p.name, body.baseDate ?? p.base_date, JSON.stringify(tasks), version, methodology, id); - logAudit(p.org_id, uid, 'project.update', 'project', id, { version, tasks: tasks.length }); - // Revision history: snapshot every save, keep the last 20 per project. - try { - db.prepare('INSERT OR REPLACE INTO project_revisions(project_id,version,name,base_date,tasks_json,saved_by) VALUES(?,?,?,?,?,?)') - .run(id, version, body.name ?? p.name, body.baseDate ?? p.base_date, JSON.stringify(tasks), uid); - db.prepare('DELETE FROM project_revisions WHERE project_id = ? AND version <= ?').run(id, version - 20); - } catch { /* history must not break saves */ } - deliver(p.org_id, 'project.update', { projectId: Number(id), version, tasks: tasks.length, by: uid }); - broadcast(id, { type: 'update', version, by: uid }); - return c.json({ version }); -}); - -// Task comments: discussion bound to a project (optionally a task). All roles -// can read; write roles can post; author or manage can delete. -app.get('/api/projects/:id/comments', requireAuth, (c) => { - const p = projectAccess(c.get('user').sub, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - const taskId = c.req.query('taskId'); - const comments = (taskId - ? db.prepare(`SELECT cm.id, cm.task_id AS taskId, cm.body, cm.created_at AS createdAt, cm.user_id AS userId, u.email - FROM comments cm LEFT JOIN users u ON u.id = cm.user_id - WHERE cm.project_id = ? AND cm.task_id = ? ORDER BY cm.id DESC LIMIT 100`).all(p.id, taskId) - : db.prepare(`SELECT cm.id, cm.task_id AS taskId, cm.body, cm.created_at AS createdAt, cm.user_id AS userId, u.email - FROM comments cm LEFT JOIN users u ON u.id = cm.user_id - WHERE cm.project_id = ? ORDER BY cm.id DESC LIMIT 100`).all(p.id)); - return c.json({ comments }); -}); - -app.post('/api/projects/:id/comments', requireAuth, async (c) => { - const uid = c.get('user').sub; - const p = projectAccess(uid, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); - const { taskId, body } = await c.req.json().catch(() => ({})); - const text = String(body || '').trim(); - if (!text) return c.json({ error: 'body required' }, 400); - if (text.length > 2000) return c.json({ error: 'comment too long (max 2000)' }, 400); - const cid = rowid(db.prepare('INSERT INTO comments(project_id,task_id,user_id,body) VALUES(?,?,?,?)') - .run(p.id, String(taskId || ''), uid, text)); - logAudit(p.org_id, uid, 'comment.create', 'project', p.id, { commentId: cid, taskId: taskId || null }); - broadcast(p.id, { type: 'comment', commentId: cid, by: uid }); - return c.json({ id: cid }); -}); - -app.delete('/api/projects/:id/comments/:cid', requireAuth, (c) => { - const uid = c.get('user').sub; - const p = projectAccess(uid, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - const cm = db.prepare('SELECT user_id FROM comments WHERE id = ? AND project_id = ?').get(c.req.param('cid'), p.id); - if (!cm) return c.json({ error: 'not found' }, 404); - if (cm.user_id !== uid && !canManage(p.memberRole)) return c.json({ error: 'forbidden' }, 403); - db.prepare('DELETE FROM comments WHERE id = ?').run(c.req.param('cid')); - return c.json({ ok: true }); -}); - -// Revision history: list, inspect, restore. -app.get('/api/projects/:id/revisions', requireAuth, (c) => { - const p = projectAccess(c.get('user').sub, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - const revisions = db.prepare( - `SELECT r.version, r.created_at AS savedAt, u.email AS savedBy FROM project_revisions r - LEFT JOIN users u ON u.id = r.saved_by WHERE r.project_id = ? ORDER BY r.version DESC` - ).all(p.id); - return c.json({ revisions }); -}); - -app.get('/api/projects/:id/revisions/:version', requireAuth, (c) => { - const p = projectAccess(c.get('user').sub, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - const r = db.prepare('SELECT version, name, base_date AS baseDate, tasks_json FROM project_revisions WHERE project_id = ? AND version = ?') - .get(p.id, c.req.param('version')); - if (!r) return c.json({ error: 'not found' }, 404); - return c.json({ version: r.version, name: r.name, baseDate: r.baseDate, tasks: JSON.parse(r.tasks_json) }); -}); - -// Restore = write the old snapshot as a NEW version (history stays linear). -app.post('/api/projects/:id/revisions/:version/restore', requireAuth, (c) => { - const uid = c.get('user').sub; - const id = c.req.param('id'); - const p = projectAccess(uid, id); - if (!p) return c.json({ error: 'not found' }, 404); - if (!canWrite(p.memberRole)) return c.json({ error: 'forbidden' }, 403); - const r = db.prepare('SELECT name, base_date, tasks_json FROM project_revisions WHERE project_id = ? AND version = ?') - .get(id, c.req.param('version')); - if (!r) return c.json({ error: 'not found' }, 404); - const version = p.version + 1; - db.prepare("UPDATE projects SET name=?, base_date=?, tasks_json=?, version=?, updated_at=datetime('now') WHERE id=?") - .run(r.name, r.base_date, r.tasks_json, version, id); - try { - db.prepare('INSERT OR REPLACE INTO project_revisions(project_id,version,name,base_date,tasks_json,saved_by) VALUES(?,?,?,?,?,?)') - .run(id, version, r.name, r.base_date, r.tasks_json, uid); - } catch { /* history must not break restore */ } - logAudit(p.org_id, uid, 'project.restore', 'project', id, { from: Number(c.req.param('version')), version }); - broadcast(id, { type: 'update', version, by: uid }); - return c.json({ version }); -}); - -// iCalendar feed: planned tasks as all-day VEVENTs — subscribable from -// Google/Outlook. Calendar apps can't send headers, so accept ?token= (same -// pattern + ceiling as /stream). PATs work via the Authorization header. -app.get('/api/projects/:id/calendar.ics', (c) => { - const header = c.req.header('authorization') || ''; - const raw = header.startsWith('Bearer ') ? header.slice(7) : (c.req.query('token') || ''); - let uid; - if (raw.startsWith('swk_')) { - const row = db.prepare('SELECT user_id FROM api_tokens WHERE token_hash = ?').get(hashApiToken(raw)); - if (!row) return c.json({ error: 'unauthorized' }, 401); - uid = row.user_id; - } else { - try { uid = verifyToken(raw).sub; } catch { return c.json({ error: 'unauthorized' }, 401); } - } - const p = projectAccess(uid, c.req.param('id')); - if (!p) return c.json({ error: 'not found' }, 404); - let tasks = []; - try { tasks = JSON.parse(p.tasks_json); } catch { /* empty */ } - const day = (s) => String(s).replaceAll('-', ''); - const nextDay = (s) => { const d = new Date(s); d.setDate(d.getDate() + 1); return d.toISOString().slice(0, 10).replaceAll('-', ''); }; - const esc = (s) => String(s).replace(/\\/g, '\\\\').replace(/[,;]/g, (m) => `\\${m}`).replace(/\n/g, '\\n'); - const lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//ScopeWeave//KO', 'CALSCALE:GREGORIAN', `X-WR-CALNAME:${esc(p.name)}`]; - for (const t of tasks) { - if (!/^\d{4}-\d{2}-\d{2}$/.test(t.plannedStartDate || '') || !/^\d{4}-\d{2}-\d{2}$/.test(t.plannedEndDate || '')) continue; - lines.push( - 'BEGIN:VEVENT', - `UID:scopeweave-${p.id}-${esc(t.id)}`, - `DTSTART;VALUE=DATE:${day(t.plannedStartDate)}`, - `DTEND;VALUE=DATE:${nextDay(t.plannedEndDate)}`, // 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(() => ({})); - 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; } -} - -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(); - } -}); +const rateLimitObservability = createRateLimitObservability(); +app.use('*', createRateLimitMiddleware(rateLimitObservability)); +app.route('/', routeApp); \ No newline at end of file diff --git a/server/app_routes.mjs b/server/app_routes.mjs new file mode 100644 index 00000000..b2beed18 --- /dev/null +++ b/server/app_routes.mjs @@ -0,0 +1,10 @@ +/** + * Transitional route import seam for the public security envelope. + * + * The supported shared application boundary is `application_routes.mjs`. + * `server/app.mjs` imports this shim while temporarily disabling the historical + * core limiter so the transport-peer-aware outer limiter is authoritative for + * public traffic. Direct consumers should import `application_routes.mjs`, not + * this compatibility seam. + */ +export { app } from './application_routes.mjs'; diff --git a/server/application_routes.mjs b/server/application_routes.mjs new file mode 100644 index 00000000..51744120 --- /dev/null +++ b/server/application_routes.mjs @@ -0,0 +1,328 @@ +import { Hono } from 'hono'; +import { recordSignup } from './signup_metrics.mjs'; +import { + createHash, + createPublicKey, + randomBytes, + verify as verifySignature, +} from 'node:crypto'; +import { canonicalEmail, db, rowid } from './db.mjs'; +import { + createExpiringAuthStateStore, + hashApiToken, + hashPassword, + signToken, + verifyToken, +} from './auth.mjs'; +import { + createRateLimitMiddleware, + createRateLimitObservability, +} from './rate_limit.mjs'; +import { + fetchPublicHttps, + validatePublicHttpsUrl, +} from './public_https_transport.mjs'; + +const configuredRateLimitMax = process.env.SCOPEWEAVE_RATE_LIMIT_MAX; +let coreRoutes; +try { + // application_routes_core.mjs uses the same shared limiter as this boundary. + // Load the nested instance with an explicit zero limit, then apply the + // transport-peer-aware policy below once before any route-specific guard or + // DB lookup. Restoring the operator value before request handling keeps the + // process environment truthful for diagnostics and child integrations. + process.env.SCOPEWEAVE_RATE_LIMIT_MAX = '0'; + ({ app: coreRoutes } = await import('./application_routes_core.mjs')); +} finally { + if (configuredRateLimitMax === undefined) delete process.env.SCOPEWEAVE_RATE_LIMIT_MAX; + else process.env.SCOPEWEAVE_RATE_LIMIT_MAX = configuredRateLimitMax; +} + +const GUARD_ACCOUNTING_METHOD = Symbol.for('scopeweave.guard_accounting.original_method'); +const MEMBERS_PATH = '/api/orgs/:id/members'; +const INVITE_ACCEPT_PATH = '/api/invites/:token/accept'; +const OIDC_ROUTE_PREFIX = '/api/auth/oidc/*'; +const OIDC_ISSUER = String(process.env.OIDC_ISSUER || '').replace(/\/$/, ''); +const OIDC_CLIENT_ID = String(process.env.OIDC_CLIENT_ID || ''); +const OIDC_CLIENT_SECRET = String(process.env.OIDC_CLIENT_SECRET || ''); +const OIDC_REDIRECT_URI = String(process.env.OIDC_REDIRECT_URI || ''); +const productionOidcConfigured = Boolean(OIDC_ISSUER && OIDC_CLIENT_ID && OIDC_CLIENT_SECRET); +const productionOidcStates = createExpiringAuthStateStore(); +const OIDC_RESPONSE_MAX_BYTES = 256 * 1024; + +function authenticatedIdentityHint(c) { + const header = c.req.header('authorization') || ''; + const token = header.startsWith('Bearer ') ? header.slice(7) : ''; + if (!token) return null; + + if (token.startsWith('swk_')) { + const user = db.prepare( + `SELECT u.id, u.email FROM api_tokens t + JOIN users u ON u.id = t.user_id + WHERE t.token_hash = ?`, + ).get(hashApiToken(token)); + return user ? { id: user.id, email: canonicalEmail(user.email) } : null; + } + + try { + const payload = verifyToken(token); + const user = db.prepare('SELECT id, email, token_version FROM users WHERE id = ?').get(payload.sub); + if (!user || Number(payload.tv ?? 0) !== Number(user.token_version ?? 0)) return null; + return { id: user.id, email: canonicalEmail(user.email) }; + } catch { + return null; + } +} + +async function guardRejectionThroughCoreAbuseControls(c, errorBody) { + const accountingEnvironment = Object.assign(Object.create(null), c.env || {}); + accountingEnvironment[GUARD_ACCOUNTING_METHOD] = c.req.method; + await coreRoutes.request(c.req.url, { method: 'OPTIONS' }, accountingEnvironment); + return c.json(errorBody, 404); +} + +async function bindInviteToAuthenticatedIdentity(c, next) { + const identity = authenticatedIdentityHint(c); + if (!identity) return next(); + + const invite = db.prepare('SELECT email, accepted_at FROM invites WHERE token = ?') + .get(c.req.param('token')); + if (!invite || invite.accepted_at) return next(); + + const invitedEmail = canonicalEmail(invite.email); + if (!invitedEmail || !identity.email || invitedEmail !== identity.email) { + return guardRejectionThroughCoreAbuseControls(c, { error: 'invalid or used invite' }); + } + return next(); +} + +async function redactPendingInviteTokens(c, next) { + await next(); + const response = c.res; + if (response.status !== 200) return; + + let payload; + try { + payload = await response.clone().json(); + } catch { + return; + } + if (!Array.isArray(payload?.invites)) return; + + const invites = payload.invites.map(({ token: _token, ...invite }) => invite); + const headers = new Headers(response.headers); + headers.delete('content-length'); + c.res = new Response(JSON.stringify({ ...payload, invites }), { + status: response.status, + statusText: response.statusText, + headers, + }); +} + +async function failClosedWhenOidcIsUnconfigured(c, next) { + if (process.env.SCOPEWEAVE_DEV !== '1' && !productionOidcConfigured) { + return guardRejectionThroughCoreAbuseControls(c, { error: 'sso not configured' }); + } + return next(); +} + +function productionOidcRedirectUri(c) { + return OIDC_REDIRECT_URI || `${new URL(c.req.url).origin}/api/auth/oidc/callback`; +} + +function parseJwtJson(segment) { + return JSON.parse(Buffer.from(segment, 'base64url').toString('utf8')); +} + +async function loadOidcProviderMetadata() { + const discoveryUrl = validatePublicHttpsUrl(`${OIDC_ISSUER}/.well-known/openid-configuration`); + const response = await fetchPublicHttps(discoveryUrl, { + signal: AbortSignal.timeout(5000), + maxResponseBytes: OIDC_RESPONSE_MAX_BYTES, + }); + if (!response.ok) throw new Error('oidc_discovery_failed'); + const metadata = await response.json(); + if (metadata?.issuer !== OIDC_ISSUER || typeof metadata?.jwks_uri !== 'string') { + throw new Error('oidc_discovery_mismatch'); + } + + return { + authorization: validatePublicHttpsUrl(metadata.authorization_endpoint || `${OIDC_ISSUER}/authorize`), + token: validatePublicHttpsUrl(metadata.token_endpoint || `${OIDC_ISSUER}/token`), + jwks: validatePublicHttpsUrl(metadata.jwks_uri), + }; +} + +async function verifyProductionOidcIdentity(idToken, expectedNonce, metadata) { + const parts = String(idToken || '').split('.'); + if (parts.length !== 3) throw new Error('invalid_oidc_token_shape'); + + const header = parseJwtJson(parts[0]); + const claims = parseJwtJson(parts[1]); + if (header.alg !== 'RS256' || typeof header.kid !== 'string' || !header.kid) { + throw new Error('unsupported_oidc_signature'); + } + + const provider = metadata || await loadOidcProviderMetadata(); + const jwksResponse = await fetchPublicHttps(provider.jwks, { + signal: AbortSignal.timeout(5000), + maxResponseBytes: OIDC_RESPONSE_MAX_BYTES, + }); + if (!jwksResponse.ok) throw new Error('oidc_jwks_failed'); + const jwks = await jwksResponse.json(); + const jwk = Array.isArray(jwks?.keys) + ? jwks.keys.find((candidate) => ( + candidate?.kid === header.kid + && candidate?.kty === 'RSA' + && (!candidate.use || candidate.use === 'sig') + && (!candidate.alg || candidate.alg === 'RS256') + )) + : null; + if (!jwk) throw new Error('oidc_signing_key_not_found'); + + const publicKey = createPublicKey({ key: jwk, format: 'jwk' }); + const signed = Buffer.from(`${parts[0]}.${parts[1]}`); + const signature = Buffer.from(parts[2], 'base64url'); + if (!verifySignature('RSA-SHA256', signed, publicKey, signature)) { + throw new Error('oidc_signature_invalid'); + } + + const now = Math.floor(Date.now() / 1000); + const audiences = Array.isArray(claims.aud) ? claims.aud : [claims.aud]; + const hasExpectedAudience = audiences.includes(OIDC_CLIENT_ID); + const authorizedPartyMatches = audiences.length <= 1 || claims.azp === OIDC_CLIENT_ID; + const temporalClaimsValid = Number.isFinite(claims.exp) + && claims.exp > now + && (!Number.isFinite(claims.nbf) || claims.nbf <= now + 60) + && (!Number.isFinite(claims.iat) || claims.iat <= now + 60); + const identityClaimsValid = claims.iss === OIDC_ISSUER + && hasExpectedAudience + && authorizedPartyMatches + && temporalClaimsValid + && claims.nonce === expectedNonce + && typeof claims.sub === 'string' + && claims.sub.length > 0 + && claims.email_verified === true + && typeof claims.email === 'string' + && canonicalEmail(claims.email).length > 0; + if (!identityClaimsValid) throw new Error('oidc_claims_invalid'); + + return canonicalEmail(claims.email); +} + +function upsertProductionSsoUser(email) { + let user = db.prepare('SELECT id, email, token_version FROM users WHERE lower(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 orgId = 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(orgId, uid, 'owner'); + db.exec('COMMIT'); + user = { id: uid, email, token_version: 0 }; + recordSignup(); + } catch (error) { + db.exec('ROLLBACK'); + throw error; + } + return user; +} + +async function productionOidcStart(c, next) { + if (!productionOidcConfigured) return next(); + + let authorizationUrl; + try { + const provider = await loadOidcProviderMetadata(); + authorizationUrl = new URL(provider.authorization); + } catch { + return c.json({ error: 'sso not configured' }, 404, { 'Cache-Control': 'no-store' }); + } + + const state = randomBytes(16).toString('hex'); + const verifier = randomBytes(32).toString('base64url'); + const nonce = randomBytes(24).toString('base64url'); + const challenge = createHash('sha256').update(verifier).digest('base64url'); + const reserved = productionOidcStates.reserve(state, { + verifier, + nonce, + exp: Date.now() + 5 * 60 * 1000, + }); + if (!reserved) { + return c.json( + { error: 'sso temporarily unavailable' }, + 503, + { 'Cache-Control': 'no-store', 'Retry-After': '60' }, + ); + } + + authorizationUrl.searchParams.set('client_id', OIDC_CLIENT_ID); + authorizationUrl.searchParams.set('redirect_uri', productionOidcRedirectUri(c)); + authorizationUrl.searchParams.set('response_type', 'code'); + authorizationUrl.searchParams.set('scope', 'openid email profile'); + authorizationUrl.searchParams.set('state', state); + authorizationUrl.searchParams.set('nonce', nonce); + authorizationUrl.searchParams.set('code_challenge', challenge); + authorizationUrl.searchParams.set('code_challenge_method', 'S256'); + return c.redirect(authorizationUrl.toString()); +} + +async function productionOidcCallback(c, next) { + if (!productionOidcConfigured) return next(); + + const state = c.req.query('state'); + const code = c.req.query('code'); + const pending = productionOidcStates.take(state); + if (!pending || !code) { + return c.json({ error: 'invalid or expired state' }, 400, { 'Cache-Control': 'no-store' }); + } + + try { + const provider = await loadOidcProviderMetadata(); + const tokenResponse = await fetchPublicHttps(provider.token, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: productionOidcRedirectUri(c), + client_id: OIDC_CLIENT_ID, + client_secret: OIDC_CLIENT_SECRET, + code_verifier: pending.verifier, + }), + signal: AbortSignal.timeout(5000), + maxResponseBytes: OIDC_RESPONSE_MAX_BYTES, + }); + if (!tokenResponse.ok) throw new Error('oidc_token_exchange_failed'); + const tokens = await tokenResponse.json(); + const email = await verifyProductionOidcIdentity(tokens?.id_token, pending.nonce, provider); + const user = upsertProductionSsoUser(email); + const token = signToken({ sub: user.id, email: user.email, tv: user.token_version || 0 }); + return c.redirect(`/#token=${token}`); + } catch { + return c.json({ error: 'invalid identity token' }, 400, { 'Cache-Control': 'no-store' }); + } +} + +/** + * Shared ScopeWeave application and transport-security boundary. + * + * Every supported consumer enters the same trusted-proxy-aware bounded rate + * limiter before authentication hints, invitation lookups, or OIDC guards. + * Production OIDC outbound discovery, token, and JWKS traffic uses the pinned + * public-HTTPS transport so HTTPS scheme checks cannot be bypassed with private + * literals, mixed DNS answers, DNS rebinding, or redirects into local networks. + */ +export const app = new Hono(); + +const rateLimitObservability = createRateLimitObservability(); +app.use('*', createRateLimitMiddleware(rateLimitObservability)); +app.use(OIDC_ROUTE_PREFIX, failClosedWhenOidcIsUnconfigured); +app.get('/api/auth/oidc/start', productionOidcStart); +app.get('/api/auth/oidc/callback', productionOidcCallback); +app.use(INVITE_ACCEPT_PATH, bindInviteToAuthenticatedIdentity); +app.use(MEMBERS_PATH, redactPendingInviteTokens); +app.route('/', coreRoutes); diff --git a/server/application_routes_core.mjs b/server/application_routes_core.mjs new file mode 100644 index 00000000..84540906 --- /dev/null +++ b/server/application_routes_core.mjs @@ -0,0 +1,85 @@ +import { Hono } from 'hono'; +import { app as implementationRoutes } from './application_routes_implementation.mjs'; +import { canonicalEmail } from './db.mjs'; + +/** + * Low-level ScopeWeave route graph with a fail-closed production OIDC boundary. + * + * Production OIDC is intentionally unavailable through this internal module. + * Supported production consumers must enter through `application_routes.mjs`, + * which validates issuer signatures and claims before creating a ScopeWeave + * session. The legacy mock flow remains reachable only in explicit development + * mode when no production issuer is configured. + */ +export const app = new Hono(); + +const GUARD_ACCOUNTING_METHOD = Symbol.for('scopeweave.guard_accounting.original_method'); +const coreOidcMockEnabled = + process.env.SCOPEWEAVE_DEV === '1' && !process.env.OIDC_ISSUER; + +/** + * Forward password-auth requests with one canonical mailbox identity. + * + * The implementation graph predates the invitation and production OIDC email + * normalization contract. Canonicalizing at this low-level shared boundary + * guarantees that signup duplicate checks, stored identities, login lookups, + * workspace labels, and signed session claims all receive the same trimmed + * NFC-normalized lowercase email. `db.mjs` separately migrates legacy rows and + * enforces a canonical unique index, so direct database writes cannot recreate + * a split identity. + * + * @param {import('hono').Context} c Current Hono request context. + * @returns {Promise} Response from the implementation route graph. + */ +async function forwardCanonicalPasswordAuth(c) { + const parsed = await c.req.json().catch(() => ({})); + const payload = parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? parsed + : {}; + const email = canonicalEmail(payload.email); + const headers = new Headers(c.req.raw.headers); + headers.set('content-type', 'application/json'); + headers.delete('content-length'); + + return implementationRoutes.request( + c.req.url, + { + method: 'POST', + headers, + body: JSON.stringify({ ...payload, email }), + }, + c.env, + ); +} + +/** + * Reject direct production OIDC start/callback requests before legacy handlers. + * + * The supported wrapper uses a non-mutating OPTIONS probe carrying a private + * symbol when a wrapper-level guard rejects a request. Forward only that probe + * into the implementation graph so its request counters and structured logger + * observe the rejection without reopening the legacy production OIDC handlers. + * + * @param {import('hono').Context} c Current Hono request context. + * @param {() => Promise} next Continues only for the development mock. + * @returns {Promise} A non-cacheable 404 or downstream result. + */ +async function requireVerifiedOidcBoundary(c, next) { + if (!coreOidcMockEnabled) { + if (c.req.method === 'OPTIONS' && c.env?.[GUARD_ACCOUNTING_METHOD]) { + return implementationRoutes.request(c.req.url, { method: 'OPTIONS' }, c.env); + } + return c.json( + { error: 'sso not configured' }, + 404, + { 'Cache-Control': 'no-store' }, + ); + } + return next(); +} + +app.post('/api/auth/signup', forwardCanonicalPasswordAuth); +app.post('/api/auth/login', forwardCanonicalPasswordAuth); +app.use('/api/auth/oidc/start', requireVerifiedOidcBoundary); +app.use('/api/auth/oidc/callback', requireVerifiedOidcBoundary); +app.route('/', implementationRoutes); diff --git a/server/application_routes_implementation.mjs b/server/application_routes_implementation.mjs new file mode 100644 index 00000000..7d464a86 --- /dev/null +++ b/server/application_routes_implementation.mjs @@ -0,0 +1,1415 @@ +// 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 { StripeWebhookError, verifyStripeWebhookRequest } from './stripe_webhook.mjs'; +import { Hono } from 'hono'; +import { readFile } from 'node:fs/promises'; +import { randomBytes, createHmac, createHash } from 'node:crypto'; +import { canonicalEmail, 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 +import { createRateLimitMiddleware, redactRequestLogPath } from './rate_limit.mjs'; +import { getSignupCount, recordSignup } from './signup_metrics.mjs'; + +const GUARD_ACCOUNTING_METHOD = Symbol.for('scopeweave.guard_accounting.original_method'); +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, + projectsCreated: 0, + webhookDeliveries: 0, + attachmentStatusRefreshAttempted: 0, + attachmentStatusRefreshChanged: 0, + attachmentStatusRefreshFailed: 0, + attachmentStatusRefreshDeferred: 0, +}; + +// Outbound webhooks: POST signed JSON to each active hook subscribed to `event`. +// Fire-and-forget with a timeout, one retry on failure, and a recorded outcome +// per attempt — never blocks or fails the triggering request. +function recordDelivery(webhookId, event, status, ok, attempt) { + try { + db.prepare('INSERT INTO webhook_deliveries(webhook_id,event,status_code,ok,attempt) VALUES(?,?,?,?,?)') + .run(webhookId, event, status ?? null, ok ? 1 : 0, attempt); + } catch { /* recording must not break delivery */ } +} + +function sendWebhook(webhookId, url, sig, event, body, attempt) { + metrics.webhookDeliveries++; + const ctrl = new AbortController(); + const to = setTimeout(() => ctrl.abort(), 3000); + fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-scopeweave-event': event, 'x-scopeweave-signature': `sha256=${sig}` }, + body, + signal: ctrl.signal, + }).then((res) => { + recordDelivery(webhookId, event, res.status, res.ok, attempt); + if (!res.ok && attempt < 2) setTimeout(() => sendWebhook(webhookId, url, sig, event, body, attempt + 1), 500); + }).catch(() => { + recordDelivery(webhookId, event, null, false, attempt); + if (attempt < 2) setTimeout(() => sendWebhook(webhookId, url, sig, event, body, attempt + 1), 500); + }).finally(() => clearTimeout(to)); +} + +function deliver(orgId, event, payload) { + let hooks; + try { + hooks = db.prepare('SELECT id, url, secret, events FROM webhooks WHERE org_id = ? AND active = 1').all(orgId); + } catch { return; } + for (const h of hooks) { + const subs = String(h.events || '').split(',').map((s) => s.trim()); + if (!(subs.includes('*') || subs.includes(event))) continue; + const body = JSON.stringify({ event, orgId: Number(orgId), payload, ts: new Date().toISOString() }); + const sig = createHmac('sha256', h.secret).update(body).digest('hex'); + sendWebhook(h.id, h.url, sig, event, body, 1); + } +} +const quietLogs = String(process.env.SCOPEWEAVE_DB || '').includes(':memory:'); // silence during tests +app.use('*', async (c, next) => { + const t = Date.now(); + await next(); + try { + metrics.requests++; + const s = c.res.status; + if (s >= 500) metrics.s5xx++; else if (s >= 400) metrics.s4xx++; else if (s >= 200) metrics.s2xx++; + if (!quietLogs) { + // structured; never logs bodies, tokens, or secrets + console.log(JSON.stringify({ ts: new Date().toISOString(), method: c.env?.[GUARD_ACCOUNTING_METHOD] || c.req.method, path: redactRequestLogPath(c.req.path), status: s, ms: Date.now() - t })); + } + } catch { /* metrics/logging must never break a request */ } +}); + +// Use the same validated, transport-peer-aware limiter as every supported +// wrapper. application_routes.mjs loads this module with an explicit zero +// limit, so the public wrapper remains the sole limiter for that request path. +app.use('*', createRateLimitMiddleware()); + +app.post('/api/auth/signup', async (c) => { + const { email: rawEmail, password, name } = await c.req.json().catch(() => ({})); + const email = canonicalEmail(rawEmail); + 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; } + recordSignup(); + return c.json({ token: signToken({ sub: uid, email, tv: 0 }) }); +}); + +app.post('/api/auth/login', async (c) => { + const { email: rawEmail, password } = await c.req.json().catch(() => ({})); + const email = canonicalEmail(rawEmail); + 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, 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 = canonicalEmail(body.email); + const inviteRole = body.role || 'member'; + if (!email) return c.json({ error: 'email required' }, 400); + if (!['admin', 'member', 'viewer'].includes(inviteRole)) return c.json({ error: 'invalid role' }, 400); + const 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 only for the authenticated account named by the invite. +app.post('/api/invites/:token/accept', requireAuth, (c) => { + const uid = c.get('user').sub; + const inv = db.prepare('SELECT * FROM invites WHERE token = ?').get(c.req.param('token')); + if (!inv || inv.accepted_at) return c.json({ error: 'invalid or used invite' }, 404); + const identity = db.prepare('SELECT email FROM users WHERE id = ?').get(uid); + if (canonicalEmail(inv.email) !== canonicalEmail(identity?.email)) { + 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 authenticity only. The supported public/shared boundary mounts +// this exact core route, so the route itself remains fail-closed even if a +// future internal consumer bypasses outer composition. Authentic callbacks are +// acknowledged but never mutate entitlements directly +// (Krawczyk et al., 1997; Stripe webhook signatures). +app.post('/api/stripe/webhook', async (c) => { + try { + await verifyStripeWebhookRequest(c.req.raw, { + secret: process.env.STRIPE_WEBHOOK_SECRET, + }); + return c.json({ received: true }, 200, { 'Cache-Control': 'no-store' }); + } catch (error) { + if (error instanceof StripeWebhookError) { + return c.json({ error: error.code }, error.status, { 'Cache-Control': 'no-store' }); + } + return c.json({ error: 'stripe_webhook_unavailable' }, 500, { 'Cache-Control': 'no-store' }); + } +}); + +// 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, signups: getSignupCount(), 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). The +// built-in mock provider is available only when OIDC is unconfigured and +// SCOPEWEAVE_DEV=1; missing production configuration fails closed. +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 && process.env.SCOPEWEAVE_DEV === '1'; +const oidcConfigured = Boolean(OIDC.issuer) || oidcMock; +const oidcStates = new Map(); // state -> { verifier, exp } +const oidcCodes = new Map(); // mock only: code -> email + +function upsertSsoUser(email) { + email = canonicalEmail(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'); + recordSignup(); + return { id: uid, email }; + } catch (e) { db.exec('ROLLBACK'); throw e; } +} + +app.get('/api/auth/oidc/start', (c) => { + if (!oidcConfigured) return c.json({ error: 'sso not configured' }, 404); + 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/auth.mjs b/server/auth.mjs index d8e147be..4b4743f8 100644 --- a/server/auth.mjs +++ b/server/auth.mjs @@ -6,6 +6,44 @@ import { db } from './db.mjs'; /** Maximum lifetime for a general ScopeWeave session token, in seconds. */ const MAX_SESSION_TTL_SECONDS = 60 * 60 * 24 * 7; +/** Maximum in-process pending OIDC authorization states per route graph. */ +const MAX_TRANSIENT_AUTH_STATES = 1024; + +/** + * Create a bounded, expiring store for one-time authentication state. + * + * Expired entries are swept before every reservation and lookup. Reservations + * fail closed once the live-state ceiling is reached instead of allowing an + * unauthenticated authorization-start flood to grow process memory without + * bound. `take` always consumes a matching state so callbacks remain single-use. + * + * @returns {{reserve:(key:string,value:{exp:number})=>boolean,take:(key:string)=>({exp:number}|null)}} + * Bounded one-time state operations for OIDC authorization flows. + */ +export function createExpiringAuthStateStore() { + const entries = new Map(); + + const pruneExpired = (now) => { + for (const [key, pending] of entries) { + if (pending.exp < now) entries.delete(key); + } + }; + + return { + reserve(key, value) { + pruneExpired(Date.now()); + if (entries.size >= MAX_TRANSIENT_AUTH_STATES) return false; + entries.set(key, value); + return true; + }, + take(key) { + pruneExpired(Date.now()); + const value = entries.get(key) ?? null; + entries.delete(key); + return value; + }, + }; +} /** * Generate a one-time-visible ScopeWeave personal access token. diff --git a/server/db.mjs b/server/db.mjs index 122b70d6..d61dd54f 100644 --- a/server/db.mjs +++ b/server/db.mjs @@ -8,6 +8,8 @@ import { dirname, join } from 'node:path'; const __dirname = dirname(fileURLToPath(import.meta.url)); const dbPath = process.env.SCOPEWEAVE_DB || join(__dirname, '..', 'data.db'); export const db = new DatabaseSync(dbPath); +export const canonicalEmail = (value) => String(value ?? '').trim().normalize('NFC').toLowerCase().normalize('NFC'); +db.function('scopeweave_canonical_email', { deterministic: true }, canonicalEmail); db.exec("PRAGMA journal_mode = WAL"); db.exec("PRAGMA foreign_keys = ON"); @@ -172,10 +174,51 @@ CREATE INDEX IF NOT EXISTS idx_projects_org ON projects(org_id); CREATE INDEX IF NOT EXISTS idx_invites_token ON invites(token); `); -// Migration for pre-existing DBs: add token_version if missing (idempotent). +// Migration for pre-existing DBs: add columns introduced after the initial schema. try { db.exec('ALTER TABLE users ADD COLUMN token_version INTEGER NOT NULL DEFAULT 0'); } catch { /* already there */ } try { db.exec('ALTER TABLE projects ADD COLUMN archived INTEGER NOT NULL DEFAULT 0'); } catch { /* already there */ } try { db.exec("ALTER TABLE projects ADD COLUMN methodology TEXT NOT NULL DEFAULT 'waterfall'"); } catch { /* already there */ } +/** + * Canonicalize legacy mailbox identities without guessing how duplicate users + * should be merged across tenant-owned records. + * + * Existing databases may contain emails that differ only by case or surrounding + * whitespace because SQLite's default UNIQUE collation is case-sensitive. The + * migration changes unambiguous identities in one transaction and adds an + * expression index so direct writes cannot recreate that split. If two existing + * user IDs collapse to the same mailbox, startup fails before modifying either + * identity; an operator must reconcile the accounts explicitly. + */ +function migrateCanonicalUserEmails() { + db.exec('BEGIN IMMEDIATE'); + try { + const canonicalUsers = new Map(); + for (const user of db.prepare('SELECT id, email FROM users ORDER BY id').all()) { + const email = canonicalEmail(user.email); + const previousId = canonicalUsers.get(email); + if (previousId !== undefined) { + throw new Error( + `canonical email collision for ${email} across user ids ${previousId}, ${user.id}; resolve duplicate identities before restart`, + ); + } + canonicalUsers.set(email, user.id); + } + + const updateEmail = db.prepare('UPDATE users SET email = ? WHERE id = ?'); + for (const user of db.prepare('SELECT id, email FROM users ORDER BY id').all()) { + const email = canonicalEmail(user.email); + if (user.email !== email) updateEmail.run(email, user.id); + } + db.exec('CREATE UNIQUE INDEX IF NOT EXISTS users_email_canonical_unique ON users(scopeweave_canonical_email(email))'); + db.exec('COMMIT'); + } catch (error) { + try { db.exec('ROLLBACK'); } catch { /* preserve the causal migration error */ } + throw error; + } +} + +migrateCanonicalUserEmails(); + // node:sqlite returns lastInsertRowid as number|bigint; normalize to Number. export const rowid = (r) => Number(r.lastInsertRowid); diff --git a/server/public_https_transport.mjs b/server/public_https_transport.mjs new file mode 100644 index 00000000..7bd02cc1 --- /dev/null +++ b/server/public_https_transport.mjs @@ -0,0 +1,298 @@ +import { lookup as dnsLookup } from 'node:dns/promises'; +import { request as httpsRequest } from 'node:https'; +import { BlockList, isIP } from 'node:net'; + +const DENIED_IPV4_BLOCKS = new BlockList(); +const DENIED_IPV6_BLOCKS = new BlockList(); +const PUBLIC_IPV6_UNICAST = new BlockList(); +const DEFAULT_MAX_RESPONSE_BYTES = 1024 * 1024; + +PUBLIC_IPV6_UNICAST.addSubnet('2000::', 3, 'ipv6'); +for (const [address, prefix, family] of [ + ['0.0.0.0', 8, 'ipv4'], ['10.0.0.0', 8, 'ipv4'], ['100.64.0.0', 10, 'ipv4'], + ['127.0.0.0', 8, 'ipv4'], ['169.254.0.0', 16, 'ipv4'], ['172.16.0.0', 12, 'ipv4'], + ['192.0.0.0', 24, 'ipv4'], ['192.0.2.0', 24, 'ipv4'], ['192.31.196.0', 24, 'ipv4'], + ['192.52.193.0', 24, 'ipv4'], ['192.88.99.0', 24, 'ipv4'], ['192.168.0.0', 16, 'ipv4'], + ['192.175.48.0', 24, 'ipv4'], ['198.18.0.0', 15, 'ipv4'], ['198.51.100.0', 24, 'ipv4'], + ['203.0.113.0', 24, 'ipv4'], ['224.0.0.0', 4, 'ipv4'], ['240.0.0.0', 4, 'ipv4'], + ['::', 128, 'ipv6'], ['::1', 128, 'ipv6'], ['::ffff:0:0', 96, 'ipv6'], + ['64:ff9b::', 96, 'ipv6'], ['64:ff9b:1::', 48, 'ipv6'], ['100::', 64, 'ipv6'], + ['100:0:0:1::', 64, 'ipv6'], ['2001::', 23, 'ipv6'], ['2001:db8::', 32, 'ipv6'], + ['2002::', 16, 'ipv6'], ['2620:4f:8000::', 48, 'ipv6'], ['3ffe::', 16, 'ipv6'], + ['3fff::', 20, 'ipv6'], ['5f00::', 16, 'ipv6'], ['fc00::', 7, 'ipv6'], + ['fe80::', 10, 'ipv6'], ['ff00::', 8, 'ipv6'], +]) { + (family === 'ipv4' ? DENIED_IPV4_BLOCKS : DENIED_IPV6_BLOCKS).addSubnet(address, prefix, family); +} + +const SAFE_ERROR = 'public HTTPS destination unavailable'; +const POLICY_ERROR = 'public HTTPS destination is not permitted'; + +/** A stable, non-secret outbound destination policy failure. */ +export class PublicHttpsDestinationError extends Error { + constructor() { + super(POLICY_ERROR); + this.name = 'PublicHttpsDestinationError'; + } +} + +/** A stable, non-secret DNS/TLS/transport failure. */ +export class PublicHttpsTransportError extends Error { + constructor() { + super(SAFE_ERROR); + this.name = 'PublicHttpsTransportError'; + } +} + +function hostAddress(hostname) { + return hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname; +} + +function isLocalHostname(hostname) { + const host = hostname.toLowerCase().replace(/\.$/, ''); + return host === 'localhost' + || host.endsWith('.localhost') + || host.endsWith('.local') + || host === 'home.arpa' + || host.endsWith('.home.arpa'); +} + +/** Return true only for ordinary public Internet IP addresses. */ +export function isPublicInternetAddress(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 an HTTPS URL, rejecting local and private literal targets. */ +export function validatePublicHttpsUrl(value) { + let destination; + try { + destination = new URL(String(value ?? '')); + } catch { + throw new PublicHttpsDestinationError(); + } + if (destination.protocol !== 'https:' + || destination.username + || destination.password + || destination.hash + || !destination.hostname + || isLocalHostname(destination.hostname)) { + throw new PublicHttpsDestinationError(); + } + const literal = hostAddress(destination.hostname); + if (isIP(literal) && !isPublicInternetAddress(literal)) throw new PublicHttpsDestinationError(); + return destination.href; +} + +async function withAbort(promise, signal) { + if (!signal) return promise; + if (signal.aborted) throw new PublicHttpsTransportError(); + let onAbort; + const aborted = new Promise((_, reject) => { + onAbort = () => reject(new PublicHttpsTransportError()); + signal.addEventListener('abort', onAbort, { once: true }); + }); + try { + return await Promise.race([promise, aborted]); + } finally { + signal.removeEventListener('abort', onAbort); + } +} + +async function resolvePublicAddresses(destination, lookup, signal) { + const literal = hostAddress(destination.hostname); + if (isIP(literal)) { + if (!isPublicInternetAddress(literal)) throw new PublicHttpsDestinationError(); + 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 PublicHttpsDestinationError || error instanceof PublicHttpsTransportError) throw error; + throw new PublicHttpsTransportError(); + } + if (!Array.isArray(answers) || answers.length === 0) throw new PublicHttpsTransportError(); + + const normalized = []; + const seen = new Set(); + for (const answer of answers) { + const address = String(answer?.address || ''); + const family = Number(answer?.family) || isIP(address); + if ((family !== 4 && family !== 6) || !isPublicInternetAddress(address)) { + throw new PublicHttpsDestinationError(); + } + const key = `${family}:${address}`; + if (!seen.has(key)) { + seen.add(key); + normalized.push({ address, family }); + } + } + if (!normalized.length) throw new PublicHttpsTransportError(); + return normalized; +} + +function pinnedLookup(address, family) { + return (_hostname, options, callback) => { + if (options?.all) callback(null, [{ address, family }]); + else callback(null, address, family); + }; +} + +function pinnedRequestOptions(destination, candidate, options = {}) { + const tlsHost = hostAddress(destination.hostname); + return { + ...options, + agent: false, + lookup: pinnedLookup(candidate.address, candidate.family), + ...(isIP(tlsHost) ? {} : { servername: tlsHost }), + }; +} + +function trackSecureConnect(request, attempt) { + request?.once?.('socket', (socket) => { + socket?.once?.('secureConnect', () => { attempt.secureConnected = true; }); + }); +} + +function appendResponseHeaders(target, source) { + for (const [name, value] of Object.entries(source || {})) { + if (Array.isArray(value)) for (const item of value) target.append(name, String(item)); + else if (value !== undefined) target.append(name, String(value)); + } +} + +function identityEncodedHeaders(headers) { + const normalized = Object.fromEntries(new Headers(headers).entries()); + delete normalized['content-length']; + normalized['accept-encoding'] = 'identity'; + return normalized; +} + +async function fetchFromCandidate(destination, candidate, options, request) { + const { method, headers, body, signal, maxResponseBytes, attempt } = options; + if (signal?.aborted) throw new PublicHttpsTransportError(); + try { + return await withAbort(new Promise((resolve, reject) => { + let settled = false; + const fail = () => { + if (settled) return; + settled = true; + reject(new PublicHttpsTransportError()); + }; + let req; + try { + req = request(destination, pinnedRequestOptions(destination, candidate, { method, headers, signal }), (response) => { + attempt.responseStarted = true; + const chunks = []; + let totalBytes = 0; + response.on?.('data', (chunk) => { + if (settled) return; + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + totalBytes += bytes.byteLength; + if (totalBytes > maxResponseBytes) { + response.destroy?.(); + fail(); + return; + } + chunks.push(bytes); + }); + response.once?.('error', fail); + response.once?.('end', () => { + if (settled) return; + const status = Number(response.statusCode) || 0; + if (status < 200 || status > 599) return fail(); + const responseHeaders = new Headers(); + appendResponseHeaders(responseHeaders, response.headers); + const responseBody = status === 204 || status === 205 || status === 304 + ? null + : (chunks.length ? Buffer.concat(chunks) : null); + try { + settled = true; + resolve(new Response(responseBody, { status, headers: responseHeaders })); + } catch { + fail(); + } + }); + }); + } catch { + fail(); + return; + } + trackSecureConnect(req, attempt); + req.once?.('error', fail); + if (body === undefined || body === null) req.end(); + else if (typeof body === 'string' || Buffer.isBuffer(body) || body instanceof Uint8Array) req.end(body); + else if (body instanceof URLSearchParams) req.end(Buffer.from(body.toString(), 'utf8')); + else if (body instanceof ArrayBuffer) req.end(new Uint8Array(body)); + else fail(); + }), signal); + } catch (error) { + if (error instanceof PublicHttpsTransportError) throw error; + throw new PublicHttpsTransportError(); + } +} + +function methodMayReplay(method) { + return method === 'GET' || method === 'HEAD'; +} + +/** + * Build a bounded public-HTTPS transport for server-side metadata and token flows. + * DNS is resolved afresh, every answer must be public, each socket is pinned to a + * validated address, SNI keeps the original hostname, pooling and redirects are + * disabled, and response buffering is bounded. Mutating requests are never + * replayed after TLS establishment or response headers because delivery is then + * ambiguous. + */ +export function createPublicHttpsTransport({ lookup = dnsLookup, request = httpsRequest } = {}) { + if (typeof lookup !== 'function' || typeof request !== 'function') { + throw new TypeError('public HTTPS transport dependencies must be functions'); + } + return Object.freeze({ + async fetch(url, { + method = 'GET', headers = {}, body, signal, maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES, + } = {}) { + const destination = new URL(validatePublicHttpsUrl(url)); + if (!Number.isSafeInteger(maxResponseBytes) || maxResponseBytes <= 0) { + throw new TypeError('maxResponseBytes must be a positive safe integer'); + } + const candidates = await resolvePublicAddresses(destination, lookup, signal); + const requestHeaders = identityEncodedHeaders(headers); + const requestMethod = String(method || 'GET').toUpperCase(); + let lastError; + for (const candidate of candidates) { + const attempt = { responseStarted: false, secureConnected: false }; + try { + return await fetchFromCandidate(destination, candidate, { + method: requestMethod, headers: requestHeaders, body, signal, maxResponseBytes, attempt, + }, request); + } catch (error) { + if (!(error instanceof PublicHttpsTransportError)) throw error; + lastError = error; + if (signal?.aborted || attempt.responseStarted || (attempt.secureConnected && !methodMayReplay(requestMethod))) { + throw error; + } + } + } + throw lastError || new PublicHttpsTransportError(); + }, + }); +} + +let activeTransport = createPublicHttpsTransport(); + +/** Fetch through ScopeWeave's process-wide pinned public-HTTPS transport. */ +export function fetchPublicHttps(url, options) { + return activeTransport.fetch(url, options); +} + +/** Replace the outbound transport only in explicit test processes. */ +export function configurePublicHttpsTransportForTests(transport) { + if (process.env.NODE_ENV !== 'test') throw new Error('public HTTPS transport override is test-only'); + if (!transport || typeof transport.fetch !== 'function') throw new TypeError('test transport must expose fetch'); + activeTransport = transport; +} diff --git a/server/rate_limit.mjs b/server/rate_limit.mjs new file mode 100644 index 00000000..5d915614 --- /dev/null +++ b/server/rate_limit.mjs @@ -0,0 +1,243 @@ +import { isIP } from 'node:net'; + +export const RATE_LIMIT_APPLIED_CONTEXT_KEY = 'scopeweaveRateLimitApplied'; + +/** + * Replace bearer-token path segments before they reach operational logs. + * @param {unknown} path Request path. + * @returns {string} Safe path retaining the route shape. + */ +export function redactRequestLogPath(path) { + return String(path) + .replace(/^\/api\/invites\/[^/]+(?=\/|$)/u, '/api/invites/:token') + .replace(/^\/api\/shared\/[^/]+(?=\/|$)/u, '/api/shared/:token'); +} + +/** + * Parse one explicit rate-limit setting without silently weakening protection. + * + * Empty or absent values use the documented fallback. Configured values must be + * finite safe integers within the caller's accepted range so a typo cannot + * accidentally disable or effectively unbound the limiter. + * + * @param {string} name Environment-variable name used in startup errors. + * @param {unknown} raw Operator-provided value. + * @param {number} fallback Value used when the setting is absent or empty. + * @param {number} minimum Smallest accepted integer. + * @returns {number} Validated integer setting. + */ +function parseSafeIntegerSetting(name, raw, fallback, minimum) { + if (raw === undefined || String(raw).trim() === '') return fallback; + const value = Number(raw); + if (!Number.isSafeInteger(value) || value < minimum) { + const range = minimum === 0 ? 'a non-negative' : 'a positive'; + throw new Error(`${name} must be ${range} safe integer`); + } + return value; +} + +/** + * Return one canonical IP spelling for trust comparisons and limiter keys. + * + * Equivalent IPv6 spellings collapse to one identity. IPv4-mapped IPv6 values + * are reduced to the underlying IPv4 address. Invalid text returns null and can + * never become a trusted proxy or attacker-selected bucket key. + * + * @param {unknown} value Candidate IP text. + * @returns {string|null} Canonical address, or null when invalid. + */ +function canonicalIp(value) { + const candidate = String(value ?? '').trim(); + const scoped = /^([^%]+)%([A-Za-z0-9_.~-]+)$/u.exec(candidate); + if (candidate.includes('%') && !scoped) return null; + const address = scoped?.[1] || candidate; + const family = isIP(address); + if (family === 0) return null; + if (family === 4) return address; + + const normalized = new URL(`http://[${address}]/`).hostname.slice(1, -1).toLowerCase(); + const mapped = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/iu.exec(normalized); + if (!mapped) return scoped ? `${normalized}%${scoped[2]}` : normalized; + + const high = Number.parseInt(mapped[1], 16); + const low = Number.parseInt(mapped[2], 16); + return `${high >>> 8}.${high & 0xff}.${low >>> 8}.${low & 0xff}`; +} + +function connectionPeerIp(c) { + const peer = canonicalIp(c.env?.incoming?.socket?.remoteAddress); + return peer || 'local'; +} + +function clientIdentity(c, trustedProxyIps) { + const peer = connectionPeerIp(c); + if (!trustedProxyIps.has(peer)) return peer; + + const forwarded = String(c.req.header('x-forwarded-for') || '') + .split(',') + .map((value) => value.trim()) + .filter(Boolean); + if (forwarded.length === 0) return peer; + + for (let index = forwarded.length - 1; index >= 0; index -= 1) { + const hop = canonicalIp(forwarded[index]); + if (!hop) return peer; + if (!trustedProxyIps.has(hop)) return hop; + } + return peer; +} + +/** + * Create process-local observability hooks for rate-limited requests. + * + * Blocked requests do not enter the route graph's ordinary logger/counters. The + * returned hooks record only that missing 429 delta and fold it into existing + * JSON or Prometheus metrics when `/api/metrics` is read. The hooks never log + * client addresses, credentials, bodies, or forwarding headers. + * + * @returns {{onBlocked: Function, afterNext: Function}} Middleware hooks. + */ +export function createRateLimitObservability() { + let rateLimitedRequests = 0; + const quietLogs = String(process.env.SCOPEWEAVE_DB || '').includes(':memory:'); + + return Object.freeze({ + onBlocked(c, { startedAt }) { + rateLimitedRequests += 1; + if (!quietLogs) { + console.log(JSON.stringify({ + ts: new Date().toISOString(), + method: c.req.method, + path: redactRequestLogPath(c.req.path), + status: 429, + ms: Date.now() - startedAt, + })); + } + }, + + async afterNext(c) { + if (c.req.path !== '/api/metrics' || c.res.status !== 200 || rateLimitedRequests === 0) return; + + const originalResponse = c.res; + try { + const headers = new Headers(originalResponse.headers); + headers.delete('content-length'); + if (c.req.query('format') === 'prometheus') { + const text = await originalResponse.clone().text(); + const adjusted = text.split('\n').map((line) => { + const match = /^(scopeweave_(?:requests|s4xx))\s+(-?\d+(?:\.\d+)?)$/u.exec(line); + if (!match) return line; + return `${match[1]} ${Number(match[2]) + rateLimitedRequests}`; + }).join('\n'); + c.res = new Response(adjusted, { status: 200, headers }); + return; + } + + const snapshot = await originalResponse.clone().json(); + if (typeof snapshot?.requests === 'number') snapshot.requests += rateLimitedRequests; + if (typeof snapshot?.s4xx === 'number') snapshot.s4xx += rateLimitedRequests; + c.res = new Response(JSON.stringify(snapshot), { status: 200, headers }); + } catch { + // Observability folding must never break or consume a successful response. + c.res = originalResponse; + } + }, + }); +} + +/** + * Build the authoritative fixed-window rate-limit middleware for one boundary. + * + * The immediate transport peer anchors trust. `X-Forwarded-For` is considered + * only when that peer is explicitly trusted, then walked right-to-left until + * the first valid untrusted client hop. In-memory state is bounded; unseen + * identities at capacity share one fail-closed overflow bucket. A context flag + * prevents a nested supported boundary from applying the same policy twice. + * + * @param {{onBlocked?: Function, afterNext?: Function}} hooks Optional lifecycle hooks. + * @returns {Function} Hono middleware. + */ +export function createRateLimitMiddleware({ onBlocked, afterNext } = {}) { + const maxRequests = parseSafeIntegerSetting( + 'SCOPEWEAVE_RATE_LIMIT_MAX', + process.env.SCOPEWEAVE_RATE_LIMIT_MAX, + 0, + 0, + ); + const windowMs = parseSafeIntegerSetting( + 'SCOPEWEAVE_RATE_LIMIT_WINDOW_MS', + process.env.SCOPEWEAVE_RATE_LIMIT_WINDOW_MS, + 60000, + 1, + ); + const bucketLimit = parseSafeIntegerSetting( + 'SCOPEWEAVE_RATE_LIMIT_BUCKETS_MAX', + process.env.SCOPEWEAVE_RATE_LIMIT_BUCKETS_MAX, + 10000, + 1, + ); + const trustedProxyIps = new Set( + String(process.env.SCOPEWEAVE_TRUSTED_PROXY_IPS || '') + .split(',') + .map(canonicalIp) + .filter(Boolean), + ); + const buckets = new Map(); + let overflowBucket; + let nextBucketSweepAt = 0; + + function bucketFor(key, now) { + let bucket = buckets.get(key); + if (bucket?.resetAt <= now) { + buckets.delete(key); + bucket = undefined; + } + if (bucket) return bucket; + + if (buckets.size >= bucketLimit && now >= nextBucketSweepAt) { + for (const [bucketKey, candidate] of buckets) { + if (candidate.resetAt <= now) buckets.delete(bucketKey); + } + nextBucketSweepAt = now + windowMs; + } + + if (buckets.size < bucketLimit) { + bucket = { count: 0, resetAt: now + windowMs }; + buckets.set(key, bucket); + return bucket; + } + + if (!overflowBucket || overflowBucket.resetAt <= now) { + overflowBucket = { count: 0, resetAt: now + windowMs }; + } + return overflowBucket; + } + + return async function rateLimitMiddleware(c, next) { + if (c.get(RATE_LIMIT_APPLIED_CONTEXT_KEY)) { + await next(); + if (afterNext) await afterNext(c); + return; + } + + c.set(RATE_LIMIT_APPLIED_CONTEXT_KEY, true); + if (maxRequests > 0) { + const startedAt = Date.now(); + const now = Date.now(); + const bucket = bucketFor(clientIdentity(c, trustedProxyIps), now); + bucket.count += 1; + if (bucket.count > maxRequests) { + const retryAfterSeconds = Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)); + if (onBlocked) await onBlocked(c, { startedAt, retryAfterSeconds }); + return c.json( + { error: 'rate limit exceeded' }, + 429, + { 'Retry-After': String(retryAfterSeconds) }, + ); + } + } + + await next(); + if (afterNext) await afterNext(c); + }; +} diff --git a/server/signup_metrics.mjs b/server/signup_metrics.mjs new file mode 100644 index 00000000..18302930 --- /dev/null +++ b/server/signup_metrics.mjs @@ -0,0 +1,9 @@ +let signupCount = 0; + +export function recordSignup() { + signupCount++; +} + +export function getSignupCount() { + return signupCount; +} diff --git a/server/stripe_webhook.mjs b/server/stripe_webhook.mjs new file mode 100644 index 00000000..0d0b0bd1 --- /dev/null +++ b/server/stripe_webhook.mjs @@ -0,0 +1,226 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; + +const STRIPE_WEBHOOK_MAX_BYTES = 256 * 1024; +const STRIPE_SIGNATURE_HEADER_MAX_LENGTH = 4096; +const STRIPE_SIGNATURE_TOLERANCE_SECONDS = 5 * 60; +const STRIPE_EVENT_FIELD_MAX_LENGTH = 255; +const HEX_SHA256_PATTERN = /^[0-9a-f]{64}$/i; +const DECIMAL_INTEGER_PATTERN = /^\d+$/; + +/** + * Stable, browser-safe Stripe webhook boundary failure. + * + * The error contains only a machine-readable classification and HTTP status; + * signatures, webhook secrets, raw provider payloads, and parser details never + * cross this boundary. + */ +export class StripeWebhookError extends Error { + /** + * Create one sanitized webhook verification failure. + * @param {string} code stable machine-readable error code + * @param {number} status HTTP response status for the adapter + */ + constructor(code, status) { + super(code); + this.name = 'StripeWebhookError'; + this.code = code; + this.status = status; + } +} + +function webhookError(code, status = 400) { + return new StripeWebhookError(code, status); +} + +function requireVerifierConfiguration(secret, nowSeconds) { + if (typeof secret !== 'string' || secret.trim().length === 0) { + throw webhookError('stripe_webhook_not_configured', 503); + } + if (!Number.isSafeInteger(nowSeconds) || nowSeconds < 0) { + throw webhookError('stripe_webhook_request_invalid'); + } +} + +async function readBoundedRawBody(request) { + if (!request || typeof request !== 'object' || !request.headers) { + throw webhookError('stripe_webhook_request_invalid'); + } + + const declaredLength = request.headers.get('content-length'); + if (declaredLength !== null) { + const normalizedLength = declaredLength.trim(); + if (!DECIMAL_INTEGER_PATTERN.test(normalizedLength)) { + throw webhookError('stripe_webhook_request_invalid'); + } + const length = Number(normalizedLength); + if (!Number.isSafeInteger(length)) { + throw webhookError('stripe_webhook_request_invalid'); + } + if (length > STRIPE_WEBHOOK_MAX_BYTES) { + throw webhookError('stripe_webhook_body_too_large', 413); + } + } + + const reader = request.body?.getReader?.(); + if (!reader || typeof reader.read !== 'function') { + throw webhookError('stripe_webhook_request_invalid'); + } + + const chunks = []; + let totalBytes = 0; + try { + for (;;) { + let result; + try { + result = await reader.read(); + } catch { + throw webhookError('stripe_webhook_request_invalid'); + } + if (result.done) break; + if (!(result.value instanceof Uint8Array)) { + throw webhookError('stripe_webhook_request_invalid'); + } + totalBytes += result.value.byteLength; + if (totalBytes > STRIPE_WEBHOOK_MAX_BYTES) { + try { + await reader.cancel(); + } catch { + // Cancellation is best effort after the byte budget has failed closed. + } + throw webhookError('stripe_webhook_body_too_large', 413); + } + chunks.push(result.value); + } + } finally { + try { + reader.releaseLock?.(); + } catch { + // Reader cleanup cannot change the verification result. + } + } + + const body = Buffer.allocUnsafe(totalBytes); + let offset = 0; + for (const chunk of chunks) { + Buffer.from(chunk).copy(body, offset); + offset += chunk.byteLength; + } + return body; +} + +function parseStripeSignatureHeader(header) { + if ( + typeof header !== 'string' + || header.length === 0 + || header.length > STRIPE_SIGNATURE_HEADER_MAX_LENGTH + ) { + throw webhookError('stripe_webhook_signature_invalid'); + } + + const timestamps = []; + const signatures = []; + for (const component of header.split(',')) { + const separator = component.indexOf('='); + if (separator <= 0) continue; + const key = component.slice(0, separator).trim(); + const value = component.slice(separator + 1).trim(); + if (key === 't') timestamps.push(value); + if (key === 'v1') signatures.push(value); + } + + if (timestamps.length !== 1 || !DECIMAL_INTEGER_PATTERN.test(timestamps[0])) { + throw webhookError('stripe_webhook_signature_invalid'); + } + const timestampText = timestamps[0]; + const timestamp = Number(timestampText); + if (!Number.isSafeInteger(timestamp) || timestamp < 0 || signatures.length === 0) { + throw webhookError('stripe_webhook_signature_invalid'); + } + + const validSignatures = signatures.filter((signature) => HEX_SHA256_PATTERN.test(signature)); + if (validSignatures.length === 0) { + throw webhookError('stripe_webhook_signature_invalid'); + } + return { timestamp, timestampText, signatures: validSignatures }; +} + +function signatureMatches(body, signatureHeader, secret, nowSeconds) { + const { timestamp, timestampText, signatures } = parseStripeSignatureHeader(signatureHeader); + if (Math.abs(nowSeconds - timestamp) > STRIPE_SIGNATURE_TOLERANCE_SECONDS) { + return false; + } + + const expected = createHmac('sha256', secret) + .update(timestampText) + .update('.') + .update(body) + .digest(); + + let matched = false; + for (const signature of signatures) { + const candidate = Buffer.from(signature, 'hex'); + if (candidate.length === expected.length && timingSafeEqual(candidate, expected)) { + matched = true; + } + } + return matched; +} + +function parseVerifiedEvent(body) { + let event; + try { + const text = new TextDecoder('utf-8', { fatal: true }).decode(body); + event = JSON.parse(text); + } catch { + throw webhookError('stripe_webhook_payload_invalid'); + } + + if (!event || typeof event !== 'object' || Array.isArray(event)) { + throw webhookError('stripe_webhook_payload_invalid'); + } + if ( + typeof event.id !== 'string' + || event.id.length === 0 + || event.id.length > STRIPE_EVENT_FIELD_MAX_LENGTH + || typeof event.type !== 'string' + || event.type.length === 0 + || event.type.length > STRIPE_EVENT_FIELD_MAX_LENGTH + ) { + throw webhookError('stripe_webhook_payload_invalid'); + } + return event; +} + +/** + * Verify and parse one Stripe webhook without mutating its signed request body. + * + * Stripe signs `timestamp + "." + raw request body`; JSON parsing therefore + * happens only after constant-time HMAC verification over the exact streamed + * bytes and the literal timestamp value supplied in the signature header. The + * request body is capped at 256 KiB before buffering, the signature header is + * bounded, and the numeric timestamp must be within five minutes of the server + * clock. Multiple `v1` values are accepted for endpoint-secret rotation. + * + * This function establishes transport authenticity only. It intentionally does + * not deduplicate event IDs, assume delivery ordering, or grant billing + * entitlements; those operations require durable provider-state reconciliation. + * + * @param {Request} request Fetch-compatible request containing the raw webhook body + * @param {object} options verifier configuration + * @param {string} options.secret Stripe endpoint signing secret + * @param {number} [options.nowSeconds] integer epoch seconds used for replay checks + * @returns {Promise>} verified bounded Stripe event object + * @throws {StripeWebhookError} for unconfigured, oversized, malformed, or unauthenticated requests + */ +export async function verifyStripeWebhookRequest(request, { + secret, + nowSeconds = Math.floor(Date.now() / 1000), +} = {}) { + requireVerifierConfiguration(secret, nowSeconds); + const body = await readBoundedRawBody(request); + const signatureHeader = request.headers.get('stripe-signature'); + if (!signatureMatches(body, signatureHeader, secret, nowSeconds)) { + throw webhookError('stripe_webhook_signature_invalid'); + } + return parseVerifiedEvent(body); +} diff --git a/tests/api/email-identity.test.mjs b/tests/api/email-identity.test.mjs new file mode 100644 index 00000000..35a07399 --- /dev/null +++ b/tests/api/email-identity.test.mjs @@ -0,0 +1,178 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_DEV = '1'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; +delete process.env.OIDC_ISSUER; +delete process.env.OIDC_CLIENT_ID; +delete process.env.OIDC_CLIENT_SECRET; +delete process.env.OIDC_REDIRECT_URI; + +const { app } = await import('../../server/app.mjs'); + +const request = (path, options = {}) => app.request(path, { + ...options, + headers: { + 'content-type': 'application/json', + ...(options.headers || {}), + }, +}); + +const jsonBody = (value) => JSON.stringify(value); + +let response = await request('/api/auth/signup', { + method: 'POST', + body: jsonBody({ + email: ' User@Example.COM ', + password: 'password123', + name: 'Canonical User', + }), +}); +assert.equal(response.status, 200, 'mixed-case signup succeeds'); +const { token } = await response.json(); +assert.ok(token, 'signup returns a session token'); + +response = await request('/api/me', { + headers: { authorization: `Bearer ${token}` }, +}); +assert.equal(response.status, 200, 'new account is readable'); +assert.equal( + (await response.json()).user.email, + 'user@example.com', + 'new accounts persist the canonical trimmed lowercase email identity', +); + +response = await request('/api/auth/login', { + method: 'POST', + body: jsonBody({ email: 'USER@EXAMPLE.COM', password: 'password123' }), +}); +assert.equal(response.status, 200, 'login resolves the same mailbox case-insensitively'); + +response = await request('/api/auth/signup', { + method: 'POST', + body: jsonBody({ email: 'user@example.com', password: 'password123' }), +}); +assert.equal(response.status, 409, 'canonical-equivalent signup cannot create a second identity'); + +response = await request('/api/auth/signup', { + method: 'POST', + body: jsonBody({ email: 'é@example.com', password: 'password123' }), +}); +assert.equal(response.status, 200, 'composed Unicode mailbox signup succeeds'); +response = await request('/api/auth/signup', { + method: 'POST', + body: jsonBody({ email: 'e\u0301@example.com', password: 'password123' }), +}); +assert.equal(response.status, 409, 'decomposed Unicode mailbox cannot create a duplicate identity'); +response = await request('/api/auth/login', { + method: 'POST', + body: jsonBody({ email: 'e\u0301@example.com', password: 'password123' }), +}); +assert.equal(response.status, 200, 'decomposed Unicode mailbox resolves the composed identity'); + +function seedLegacyUsers(rows) { + const directory = mkdtempSync(join(tmpdir(), 'scopeweave-email-identity-')); + const databasePath = join(directory, 'legacy.sqlite'); + const database = new DatabaseSync(databasePath); + database.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')) + ); + `); + const insert = database.prepare( + 'INSERT INTO users(id,email,password_hash,name,token_version) VALUES(?,?,?,?,0)', + ); + for (const row of rows) insert.run(row.id, row.email, 'salt:hash', row.name || ''); + database.close(); + return { directory, databasePath }; +} + +function importDatabase(databasePath) { + return spawnSync( + process.execPath, + ['--input-type=module', '--eval', "await import('./server/db.mjs')"], + { + cwd: process.cwd(), + env: { ...process.env, SCOPEWEAVE_DB: databasePath }, + encoding: 'utf8', + }, + ); +} + +{ + const { directory, databasePath } = seedLegacyUsers([ + { id: 1, email: ' Legacy.User@Example.COM ', name: 'Legacy User' }, + ]); + try { + const result = importDatabase(databasePath); + assert.equal( + result.status, + 0, + `unambiguous legacy email migration succeeds: ${result.stderr}`, + ); + + const database = new DatabaseSync(databasePath); + assert.equal( + database.prepare('SELECT email FROM users WHERE id = 1').get().email, + 'legacy.user@example.com', + 'unambiguous legacy identities are canonicalized during migration', + ); + const index = database.prepare( + "SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ?", + ).get('users_email_canonical_unique'); + assert.match( + String(index?.sql || ''), + /UNIQUE\s+INDEX[\s\S]*scopeweave_canonical_email\s*\(\s*email\s*\)/iu, + 'database enforces one canonical mailbox identity even for direct writes', + ); + database.close(); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +{ + const { directory, databasePath } = seedLegacyUsers([ + { id: 1, email: 'Collision@Example.COM', name: 'First' }, + { id: 2, email: 'collision@example.com', name: 'Second' }, + ]); + try { + const result = importDatabase(databasePath); + assert.notEqual( + result.status, + 0, + 'legacy canonical-email collisions fail startup rather than auto-merging tenant identities', + ); + assert.match( + result.stderr, + /canonical email collision/i, + 'startup failure explains the operator remediation boundary', + ); + + const database = new DatabaseSync(databasePath); + assert.deepEqual( + database.prepare('SELECT id,email FROM users ORDER BY id').all() + .map((row) => ({ id: row.id, email: row.email })), + [ + { id: 1, email: 'Collision@Example.COM' }, + { id: 2, email: 'collision@example.com' }, + ], + 'failed migration leaves colliding legacy identities untouched for explicit remediation', + ); + database.close(); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +console.log('email canonical identity contract passed'); diff --git a/tests/api/email-unicode-identity.test.mjs b/tests/api/email-unicode-identity.test.mjs new file mode 100644 index 00000000..5f3bbb49 --- /dev/null +++ b/tests/api/email-unicode-identity.test.mjs @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; + +function seed(rows) { + const directory = mkdtempSync(join(tmpdir(), 'scopeweave-unicode-email-')); + const databasePath = join(directory, 'legacy.sqlite'); + const database = new DatabaseSync(databasePath); + database.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')) + )`); + const insert = database.prepare('INSERT INTO users(id,email,password_hash,name) VALUES(?,?,?,?)'); + for (const row of rows) insert.run(row.id, row.email, 'salt:hash', row.name || ''); + database.close(); + return { directory, databasePath }; +} + +function migrate(databasePath) { + return spawnSync(process.execPath, ['--input-type=module', '--eval', ` + process.env.SCOPEWEAVE_DB = ${JSON.stringify(databasePath)}; + const { db } = await import('./server/db.mjs'); + const rows = db.prepare('SELECT id,email FROM users ORDER BY id').all().map(({id,email}) => ({id,email})); + let duplicateBlocked = false; + try { + db.prepare('INSERT INTO users(email,password_hash,name) VALUES(?,?,?)') + .run('ÄLICE@EXAMPLE.COM', 'salt:hash', 'Duplicate'); + } catch { duplicateBlocked = true; } + console.log(JSON.stringify({ rows, duplicateBlocked })); + `], { cwd: process.cwd(), encoding: 'utf8', env: { ...process.env } }); +} + +{ + const { directory, databasePath } = seed([{ id: 1, email: ' A\u0308LICE@Example.COM ', name: 'Legacy' }]); + try { + const result = migrate(databasePath); + assert.equal(result.status, 0, `unicode legacy migration succeeds: ${result.stderr}`); + const snapshot = JSON.parse(result.stdout.trim().split('\n').at(-1)); + assert.deepEqual(snapshot.rows, [{ id: 1, email: 'älice@example.com' }], 'legacy email canonicalization uses the same Unicode-aware JavaScript algorithm as runtime auth'); + assert.equal(snapshot.duplicateBlocked, true, 'database uniqueness rejects a canonical-equivalent Unicode mailbox after migration'); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +{ + const { directory, databasePath } = seed([ + { id: 1, email: 'A\u0308lice@example.com', name: 'First' }, + { id: 2, email: 'älice@example.com', name: 'Second' }, + ]); + try { + const result = migrate(databasePath); + assert.notEqual(result.status, 0, 'Unicode canonical collisions fail startup instead of silently choosing an account'); + assert.match(result.stderr, /canonical email collision/i); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +console.log('unicode email identity migration contract passed'); diff --git a/tests/api/invite-security.test.mjs b/tests/api/invite-security.test.mjs new file mode 100644 index 00000000..7ccdfe37 --- /dev/null +++ b/tests/api/invite-security.test.mjs @@ -0,0 +1,164 @@ +import assert from 'node:assert/strict'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_DEV = '1'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; +process.env.STRIPE_SECRET_KEY = 'sk_test_scopeweave_invites'; +process.env.STRIPE_PRICE_ID = 'price_scopeweave_invites'; +process.env.STRIPE_WEBHOOK_SECRET = 'whsec_scopeweave_invite_secret'; + +const { app } = await import('../../server/app.mjs?invite-security=1'); +const { app: applicationRoutes } = await import('../../server/application_routes.mjs'); +const { app: internalCoreRoutes } = await import('../../server/application_routes_core.mjs?invite-internal-security=1'); +const { db } = await import('../../server/db.mjs'); +const body = (value) => JSON.stringify(value); +const request = (path, options = {}) => app.request(path, { + ...options, + headers: { 'content-type': 'application/json', ...(options.headers || {}) }, +}); +const coreRequest = (path, options = {}) => applicationRoutes.request(path, { + ...options, + headers: { 'content-type': 'application/json', ...(options.headers || {}) }, +}); +const internalCoreRequest = (path, options = {}) => internalCoreRoutes.request(path, { + ...options, + headers: { 'content-type': 'application/json', ...(options.headers || {}) }, +}); + +async function signup(email) { + const response = await request('/api/auth/signup', { + method: 'POST', + body: body({ email, password: 'password123' }), + }); + assert.equal(response.status, 200, `signup succeeds for ${email}`); + return (await response.json()).token; +} + +const ownerToken = await signup('invite-owner@example.com'); +const viewerToken = await signup('invite-viewer@example.com'); +const attackerToken = await signup('invite-attacker@example.com'); +const targetToken = await signup('Target.Invitee@Example.com'); +const ownerAuth = { authorization: `Bearer ${ownerToken}` }; +const viewerAuth = { authorization: `Bearer ${viewerToken}` }; +const attackerAuth = { authorization: `Bearer ${attackerToken}` }; +const targetAuth = { authorization: `Bearer ${targetToken}` }; + +let response = await request('/api/me', { headers: ownerAuth }); +const ownerMe = await response.json(); +const orgId = ownerMe.orgs[0].id; + +response = await request(`/api/orgs/${orgId}/invites`, { + method: 'POST', + headers: ownerAuth, + body: body({ email: 'invite-viewer@example.com', role: 'viewer' }), +}); +assert.equal(response.status, 200); +const viewerInvite = await response.json(); +response = await request(`/api/invites/${viewerInvite.token}/accept`, { + method: 'POST', + headers: viewerAuth, +}); +assert.equal(response.status, 200, 'intended viewer can join'); + +response = await request(`/api/orgs/${orgId}/invites`, { + method: 'POST', + headers: ownerAuth, + body: body({ email: 'TARGET.INVITEE@EXAMPLE.COM', role: 'admin' }), +}); +assert.equal(response.status, 200); +const targetInvite = await response.json(); +assert.ok(targetInvite.token, 'creator receives the bearer token for delivery'); + +response = await internalCoreRequest(`/api/orgs/${orgId}/members`, { headers: viewerAuth }); +assert.equal(response.status, 200, 'internal core roster remains safe even when mounted without the outer boundary'); +const internalCoreRoster = await response.json(); +const internalCorePendingTarget = internalCoreRoster.invites.find((invite) => invite.email === 'target.invitee@example.com'); +assert.ok(internalCorePendingTarget, 'internal core preserves pending invitation workflow state'); +assert.equal( + 'token' in internalCorePendingTarget, + false, + 'internal core never retrieves pending-invite bearer tokens for roster responses', +); + +response = await internalCoreRequest(`/api/invites/${targetInvite.token}/accept`, { + method: 'POST', + headers: attackerAuth, +}); +assert.equal(response.status, 404, 'internal core binds invite redemption to the invited identity'); +assert.deepEqual(await response.json(), { error: 'invalid or used invite' }); + +response = await request(`/api/orgs/${orgId}/members`, { headers: viewerAuth }); +assert.equal(response.status, 200, 'viewer may inspect the organization roster'); +const roster = await response.json(); +const pendingTarget = roster.invites.find((invite) => invite.email === 'target.invitee@example.com'); +assert.ok(pendingTarget, 'pending invitation remains visible as workflow state'); +assert.equal('token' in pendingTarget, false, 'roster never discloses pending invite bearer tokens'); + +response = await coreRequest(`/api/orgs/${orgId}/members`, { headers: viewerAuth }); +assert.equal(response.status, 200, 'the protected route graph exposes the same safe roster contract'); +const coreRoster = await response.json(); +const corePendingTarget = coreRoster.invites.find((invite) => invite.email === 'target.invitee@example.com'); +assert.ok(corePendingTarget, 'protected route graph preserves pending invitation workflow state'); +assert.equal( + 'token' in corePendingTarget, + false, + 'protected route graph cannot bypass pending-invite bearer-token redaction', +); + +response = await coreRequest(`/api/invites/${targetInvite.token}/accept`, { + method: 'POST', + headers: attackerAuth, +}); +assert.equal(response.status, 404, 'protected route graph binds invite redemption to the invited identity'); +assert.deepEqual(await response.json(), { error: 'invalid or used invite' }); + +response = await request(`/api/invites/${targetInvite.token}/accept`, { + method: 'POST', + headers: attackerAuth, +}); +assert.equal(response.status, 404, 'authenticated account with the wrong email cannot redeem the invite'); +assert.deepEqual(await response.json(), { error: 'invalid or used invite' }); + +response = await request('/api/me', { headers: attackerAuth }); +const attackerMe = await response.json(); +assert.equal( + attackerMe.orgs.some((org) => Number(org.id) === Number(orgId)), + false, + 'mismatched redemption creates no organization membership', +); + +response = await request(`/api/invites/${targetInvite.token}/accept`, { + method: 'POST', + headers: targetAuth, +}); +assert.equal(response.status, 200, 'case-insensitively matching invited identity can redeem'); +assert.equal((await response.json()).role, 'admin'); + +response = await request(`/api/invites/${targetInvite.token}/accept`, { + method: 'POST', + headers: targetAuth, +}); +assert.equal(response.status, 404, 'accepted invitation cannot be replayed'); + +response = await request(`/api/orgs/${orgId}/invites`, { + method: 'POST', + headers: ownerAuth, + body: body({ email: 'target.invitee@example.com', role: 'member' }), +}); +assert.equal(response.status, 200); +const revokedCredentialProbeInvite = await response.json(); +const attackerUser = db.prepare('SELECT id FROM users WHERE email = ?').get('invite-attacker@example.com'); +assert.ok(attackerUser?.id, 'attacker account exists before session revocation'); +db.prepare('UPDATE users SET token_version = token_version + 1 WHERE id = ?').run(attackerUser.id); +response = await request(`/api/invites/${revokedCredentialProbeInvite.token}/accept`, { + method: 'POST', + headers: attackerAuth, +}); +assert.equal( + response.status, + 401, + 'revoked credentials are rejected by authentication before invite identity comparison can become an oracle', +); +assert.deepEqual(await response.json(), { error: 'unauthorized' }); + +console.log('invite identity-boundary regression passed'); diff --git a/tests/api/oidc-core-production-failclosed.test.mjs b/tests/api/oidc-core-production-failclosed.test.mjs new file mode 100644 index 00000000..933afd7f --- /dev/null +++ b/tests/api/oidc-core-production-failclosed.test.mjs @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; + +const regression = String.raw` + import assert from 'node:assert/strict'; + + process.env.SCOPEWEAVE_DB = ':memory:'; + delete process.env.SCOPEWEAVE_DEV; + process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; + process.env.STRIPE_SECRET_KEY = 'sk_test_scopeweave_oidc_core_boundary'; + process.env.STRIPE_PRICE_ID = 'price_scopeweave_oidc_core_boundary'; + process.env.STRIPE_WEBHOOK_SECRET = 'whsec_scopeweave_oidc_core_boundary_secret'; + process.env.OIDC_ISSUER = 'https://issuer.example'; + process.env.OIDC_CLIENT_ID = 'scopeweave-client'; + process.env.OIDC_CLIENT_SECRET = 'scopeweave-secret'; + process.env.OIDC_REDIRECT_URI = 'https://scopeweave.example/api/auth/oidc/callback'; + + const { app: internalCoreRoutes } = await import('./server/application_routes_core.mjs?oidc-core-production-failclosed=1'); + + let response = await internalCoreRoutes.request('https://scopeweave.example/api/auth/oidc/start'); + assert.equal(response.status, 404, 'internal core graph must not expose production OIDC authorization'); + assert.equal(response.headers.get('location'), null, 'internal core graph never redirects to a production issuer'); + assert.deepEqual(await response.json(), { error: 'sso not configured' }); + + response = await internalCoreRoutes.request( + 'https://scopeweave.example/api/auth/oidc/callback?state=attacker-state&code=attacker-code', + ); + assert.equal(response.status, 404, 'internal core graph must not process production OIDC callbacks'); + assert.equal(response.headers.get('location'), null, 'internal core callback cannot mint an application session'); + assert.deepEqual(await response.json(), { error: 'sso not configured' }); +`; + +const result = spawnSync( + process.execPath, + ['--input-type=module', '--eval', regression], + { cwd: process.cwd(), encoding: 'utf8', env: { ...process.env } }, +); + +assert.equal( + result.status, + 0, + `direct core production OIDC fail-closed regression failed\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, +); + +console.log('Direct core production OIDC fail-closed regression passed'); diff --git a/tests/api/oidc-production-boundary.test.mjs b/tests/api/oidc-production-boundary.test.mjs new file mode 100644 index 00000000..eafdf368 --- /dev/null +++ b/tests/api/oidc-production-boundary.test.mjs @@ -0,0 +1,213 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +delete process.env.SCOPEWEAVE_DEV; +delete process.env.OIDC_ISSUER; +delete process.env.OIDC_CLIENT_ID; +delete process.env.OIDC_CLIENT_SECRET; +delete process.env.OIDC_REDIRECT_URI; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; +process.env.STRIPE_SECRET_KEY = 'sk_test_scopeweave_oidc_boundary'; +process.env.STRIPE_PRICE_ID = 'price_scopeweave_oidc_boundary'; +process.env.STRIPE_WEBHOOK_SECRET = 'whsec_scopeweave_oidc_boundary_secret'; + +const { app } = await import('../../server/app.mjs?oidc-production-boundary=1'); +const { app: applicationRoutes } = await import('../../server/application_routes.mjs?oidc-shared-boundary=1'); +const { app: internalCoreRoutes } = await import('../../server/application_routes_core.mjs?oidc-internal-boundary=1'); + +let response = await app.request('https://scopeweave.example/api/auth/oidc/start?email=victim@example.com'); +assert.equal(response.status, 404, 'missing production OIDC configuration fails closed'); +assert.equal(response.headers.get('location'), null, 'production never redirects into the built-in mock IdP'); +assert.deepEqual(await response.json(), { error: 'sso not configured' }); + +response = await app.request( + 'https://scopeweave.example/api/auth/oidc/mock/authorize?state=attacker&email=victim@example.com&redirect_uri=https%3A%2F%2Fscopeweave.example%2Fapi%2Fauth%2Foidc%2Fcallback', +); +assert.equal(response.status, 404, 'built-in mock authorize endpoint is unreachable outside explicit development mode'); +assert.equal(response.headers.get('location'), null, 'mock endpoint cannot mint a production callback code'); +assert.deepEqual(await response.json(), { error: 'sso not configured' }); + +response = await applicationRoutes.request('https://scopeweave.example/api/auth/oidc/start?email=victim@example.com'); +assert.equal(response.status, 404, 'shared route graph also fails closed when production OIDC is unconfigured'); +assert.equal(response.headers.get('location'), null, 'shared route graph never redirects into the built-in mock IdP'); +assert.deepEqual(await response.json(), { error: 'sso not configured' }); + +response = await applicationRoutes.request( + 'https://scopeweave.example/api/auth/oidc/mock/authorize?state=attacker&email=victim@example.com&redirect_uri=https%3A%2F%2Fscopeweave.example%2Fapi%2Fauth%2Foidc%2Fcallback', +); +assert.equal(response.status, 404, 'shared route graph keeps the mock authorize endpoint closed outside explicit development mode'); +assert.equal(response.headers.get('location'), null, 'shared route graph cannot mint a production callback code'); +assert.deepEqual(await response.json(), { error: 'sso not configured' }); + +response = await internalCoreRoutes.request('https://scopeweave.example/api/auth/oidc/start?email=victim@example.com'); +assert.equal(response.status, 404, 'internal core graph fails closed rather than enabling mock OIDC when production configuration is absent'); +assert.equal(response.headers.get('location'), null, 'internal core graph never redirects into the mock IdP without explicit development mode'); +assert.deepEqual(await response.json(), { error: 'sso not configured' }); + +response = await internalCoreRoutes.request( + 'https://scopeweave.example/api/auth/oidc/mock/authorize?state=attacker&email=victim@example.com&redirect_uri=https%3A%2F%2Fscopeweave.example%2Fapi%2Fauth%2Foidc%2Fcallback', +); +assert.equal(response.status, 404, 'internal core mock authorize stays disabled outside explicit development mode'); +assert.equal(response.headers.get('location'), null, 'internal core cannot mint a production callback code'); +assert.deepEqual(await response.json(), { error: 'mock disabled' }); + +const productionIdentityRegression = String.raw` + import assert from 'node:assert/strict'; + import { generateKeyPairSync, sign } from 'node:crypto'; + + process.env.NODE_ENV = 'test'; + process.env.SCOPEWEAVE_DB = ':memory:'; + delete process.env.SCOPEWEAVE_DEV; + process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; + process.env.STRIPE_SECRET_KEY = 'sk_test_scopeweave_oidc_signature'; + process.env.STRIPE_PRICE_ID = 'price_scopeweave_oidc_signature'; + process.env.STRIPE_WEBHOOK_SECRET = 'whsec_scopeweave_oidc_signature_secret'; + process.env.OIDC_ISSUER = 'https://issuer.example'; + process.env.OIDC_CLIENT_ID = 'scopeweave-client'; + process.env.OIDC_CLIENT_SECRET = 'scopeweave-secret'; + process.env.OIDC_REDIRECT_URI = 'https://scopeweave.example/api/auth/oidc/callback'; + + const encode = (value) => Buffer.from(JSON.stringify(value)).toString('base64url'); + const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); + const publicJwk = { + ...publicKey.export({ format: 'jwk' }), + kid: 'issuer-signing-key', + use: 'sig', + alg: 'RS256', + }; + let issuedIdToken = ''; + + const signIdToken = (claims, header = { alg: 'RS256', typ: 'JWT', kid: publicJwk.kid }) => { + const protectedHeader = encode(header); + const payload = encode(claims); + const signingInput = protectedHeader + '.' + payload; + const signature = sign('RSA-SHA256', Buffer.from(signingInput), privateKey).toString('base64url'); + return signingInput + '.' + signature; + }; + + const oidcFetch = async (url) => { + const target = String(url); + if (target === 'https://issuer.example/token') { + return new Response(JSON.stringify({ id_token: issuedIdToken }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + if (target === 'https://issuer.example/.well-known/openid-configuration') { + return new Response(JSON.stringify({ + issuer: process.env.OIDC_ISSUER, + jwks_uri: 'https://issuer.example/jwks', + }), { status: 200, headers: { 'content-type': 'application/json' } }); + } + if (target === 'https://issuer.example/jwks') { + return new Response(JSON.stringify({ keys: [publicJwk] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + throw new Error('unexpected OIDC fetch: ' + target); + }; + + const { configurePublicHttpsTransportForTests } = await import('./server/public_https_transport.mjs'); + configurePublicHttpsTransportForTests({ fetch: oidcFetch }); + const { app: configuredRoutes } = await import('./server/application_routes.mjs?oidc-production-token-regression=1'); + const { db, rowid } = await import('./server/db.mjs'); + const begin = async () => { + const start = await configuredRoutes.request('https://scopeweave.example/api/auth/oidc/start'); + assert.equal(start.status, 302, 'configured OIDC starts the authorization-code flow'); + const authorization = new URL(start.headers.get('location')); + assert.equal(authorization.origin, 'https://issuer.example'); + assert.equal(authorization.pathname, '/authorize'); + assert.equal(authorization.searchParams.get('client_id'), process.env.OIDC_CLIENT_ID); + assert.equal(authorization.searchParams.get('response_type'), 'code'); + assert.equal(authorization.searchParams.get('code_challenge_method'), 'S256'); + const state = authorization.searchParams.get('state'); + const nonce = authorization.searchParams.get('nonce'); + assert.ok(state, 'authorization request carries a server-generated state'); + assert.ok(nonce, 'authorization request binds the returned ID token with a nonce'); + return { state, nonce }; + }; + + const callback = async (state, code) => configuredRoutes.request( + 'https://scopeweave.example/api/auth/oidc/callback?state=' + encodeURIComponent(state) + '&code=' + encodeURIComponent(code), + ); + + const realDateNow = Date.now; + let controlledNow = realDateNow(); + Date.now = () => controlledNow; + const abandonedStates = []; + for (let index = 0; index < 1024; index += 1) abandonedStates.push(await begin()); + let result = await configuredRoutes.request('https://scopeweave.example/api/auth/oidc/start'); + assert.equal(result.status, 503, 'OIDC authorization state storage fails closed at its bounded capacity'); + assert.equal(result.headers.get('cache-control'), 'no-store', 'OIDC saturation response is never cached'); + assert.deepEqual(await result.json(), { error: 'sso temporarily unavailable' }); + controlledNow += 5 * 60 * 1000 + 1; + const afterExpiry = await begin(); + Date.now = realDateNow; + result = await callback(afterExpiry.state, 'discard-expired-capacity-probe'); + assert.equal(result.status, 400, 'a capacity probe can be consumed without minting a session'); + + const forged = await begin(); + issuedIdToken = [ + encode({ alg: 'none', typ: 'JWT' }), + encode({ iss: process.env.OIDC_ISSUER, aud: process.env.OIDC_CLIENT_ID, exp: Math.floor(Date.now() / 1000) + 300, nonce: forged.nonce, sub: 'attacker-subject', email_verified: true, email: 'attacker-chosen@example.com' }), + '', + ].join('.'); + result = await callback(forged.state, 'attacker-code'); + assert.equal(result.status, 400, 'an unsigned identity token must never mint a ScopeWeave session'); + assert.equal(result.headers.get('location'), null, 'rejected identity tokens never return an application session fragment'); + + result = await configuredRoutes.request('https://scopeweave.example/api/auth/oidc/callback?state=unknown&code=attacker-code'); + assert.equal(result.status, 400, 'unknown authorization state fails closed before token exchange'); + + const valid = await begin(); + const now = Math.floor(Date.now() / 1000); + issuedIdToken = signIdToken({ iss: process.env.OIDC_ISSUER, aud: process.env.OIDC_CLIENT_ID, exp: now + 300, iat: now, nonce: valid.nonce, sub: 'verified-subject', email_verified: true, email: 'verified@example.com' }); + result = await callback(valid.state, 'valid-code'); + assert.equal(result.status, 302, 'a valid issuer-signed ID token completes the production OIDC flow'); + assert.match(result.headers.get('location') || '', /^\/#token=/, 'successful OIDC returns only the ScopeWeave session in a URL fragment'); + + const replay = await callback(valid.state, 'replayed-code'); + assert.equal(replay.status, 400, 'OIDC state is single-use after a successful callback'); + + const legacyUserId = rowid(db.prepare('INSERT INTO users(email,password_hash,name) VALUES(?,?,?)').run('Legacy.User@Example.com', 'legacy-password-hash', 'Legacy User')); + const legacyOrgId = rowid(db.prepare('INSERT INTO orgs(name,owner_id) VALUES(?,?)').run('Legacy workspace', legacyUserId)); + db.prepare('INSERT INTO memberships(org_id,user_id,role) VALUES(?,?,?)').run(legacyOrgId, legacyUserId, 'owner'); + const usersBeforeCaseLink = db.prepare('SELECT COUNT(*) AS count FROM users').get().count; + const orgsBeforeCaseLink = db.prepare('SELECT COUNT(*) AS count FROM orgs').get().count; + + const legacyCase = await begin(); + issuedIdToken = signIdToken({ iss: process.env.OIDC_ISSUER, aud: process.env.OIDC_CLIENT_ID, exp: now + 300, iat: now, nonce: legacyCase.nonce, sub: 'legacy-verified-subject', email_verified: true, email: 'legacy.user@example.com' }); + result = await callback(legacyCase.state, 'legacy-case-code'); + assert.equal(result.status, 302, 'verified OIDC email reuses an existing account regardless of stored email case'); + const sessionToken = new URL(result.headers.get('location'), 'https://scopeweave.example').hash.slice('#token='.length); + const sessionPayload = JSON.parse(Buffer.from(sessionToken.split('.')[1], 'base64url').toString('utf8')); + assert.equal(sessionPayload.sub, legacyUserId, 'OIDC session remains bound to the pre-existing account'); + assert.equal(db.prepare('SELECT COUNT(*) AS count FROM users').get().count, usersBeforeCaseLink, 'case-only identity differences never create a duplicate user'); + assert.equal(db.prepare('SELECT COUNT(*) AS count FROM orgs').get().count, orgsBeforeCaseLink, 'reusing an existing case-variant account never creates a second personal workspace'); + assert.equal(db.prepare('SELECT email FROM users WHERE id = ?').get(legacyUserId).email, 'Legacy.User@Example.com', 'account linking does not silently rewrite the stored login identifier'); + + const wrongAudience = await begin(); + issuedIdToken = signIdToken({ iss: process.env.OIDC_ISSUER, aud: 'different-client', exp: now + 300, iat: now, nonce: wrongAudience.nonce, sub: 'verified-subject', email_verified: true, email: 'verified@example.com' }); + result = await callback(wrongAudience.state, 'wrong-audience-code'); + assert.equal(result.status, 400, 'issuer-signed tokens for another audience are rejected'); + + const unverifiedEmail = await begin(); + issuedIdToken = signIdToken({ iss: process.env.OIDC_ISSUER, aud: process.env.OIDC_CLIENT_ID, exp: now + 300, iat: now, nonce: unverifiedEmail.nonce, sub: 'verified-subject', email_verified: false, email: 'victim@example.com' }); + result = await callback(unverifiedEmail.state, 'unverified-email-code'); + assert.equal(result.status, 400, 'unverified email claims cannot link or create a ScopeWeave account'); +`; + +const productionIdentityResult = spawnSync( + process.execPath, + ['--input-type=module', '--eval', productionIdentityRegression], + { cwd: process.cwd(), encoding: 'utf8' }, +); +assert.equal( + productionIdentityResult.status, + 0, + `configured production OIDC must authenticate the IdP before trusting identity claims\nstdout:\n${productionIdentityResult.stdout}\nstderr:\n${productionIdentityResult.stderr}`, +); + +console.log('OIDC production fail-closed regression passed'); diff --git a/tests/api/oidc-provider-metadata.test.mjs b/tests/api/oidc-provider-metadata.test.mjs new file mode 100644 index 00000000..a2298156 --- /dev/null +++ b/tests/api/oidc-provider-metadata.test.mjs @@ -0,0 +1,108 @@ +import assert from 'node:assert/strict'; +import { generateKeyPairSync, sign } from 'node:crypto'; + +process.env.NODE_ENV = 'test'; +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_DEV = '1'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; +process.env.OIDC_ISSUER = 'https://issuer.example/tenant'; +process.env.OIDC_CLIENT_ID = 'scopeweave-client'; +process.env.OIDC_CLIENT_SECRET = 'scopeweave-secret'; +process.env.OIDC_REDIRECT_URI = 'https://scopeweave.example/api/auth/oidc/callback'; + +const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); +const publicJwk = { ...publicKey.export({ format: 'jwk' }), kid: 'metadata-key', use: 'sig', alg: 'RS256' }; +const encode = (value) => Buffer.from(JSON.stringify(value)).toString('base64url'); +let issuedIdToken = ''; +let discoveryMode = 'valid'; +const tokenBodies = []; + +function signedIdentity(nonce, email = 'sso.user@example.com') { + const now = Math.floor(Date.now() / 1000); + const header = encode({ alg: 'RS256', typ: 'JWT', kid: publicJwk.kid }); + const payload = encode({ + iss: process.env.OIDC_ISSUER, + aud: process.env.OIDC_CLIENT_ID, + exp: now + 300, + iat: now, + nonce, + sub: 'subject-1', + email_verified: true, + email, + }); + const input = `${header}.${payload}`; + return `${input}.${sign('RSA-SHA256', Buffer.from(input), privateKey).toString('base64url')}`; +} + +const oidcFetch = async (url, options = {}) => { + const target = String(url); + if (target === 'https://issuer.example/tenant/.well-known/openid-configuration') { + if (discoveryMode === 'private-endpoints') { + return Response.json({ + issuer: process.env.OIDC_ISSUER, + authorization_endpoint: 'https://login.example/oauth2/v2/authorize', + token_endpoint: 'https://127.0.0.1/internal-token', + jwks_uri: 'https://[::1]/internal-jwks', + }); + } + return Response.json({ + issuer: process.env.OIDC_ISSUER, + authorization_endpoint: 'https://login.example/oauth2/v2/authorize', + token_endpoint: 'https://tokens.example/oauth2/v2/token', + jwks_uri: 'https://keys.example/oidc/jwks.json', + }); + } + if (target === 'https://tokens.example/oauth2/v2/token') { + tokenBodies.push(options.body); + return Response.json({ id_token: issuedIdToken }); + } + if (target === 'https://keys.example/oidc/jwks.json') return Response.json({ keys: [publicJwk] }); + throw new Error(`unexpected OIDC fetch: ${target}`); +}; + +const { configurePublicHttpsTransportForTests } = await import('../../server/public_https_transport.mjs'); +configurePublicHttpsTransportForTests({ fetch: oidcFetch }); +const { app } = await import('../../server/application_routes.mjs?provider-metadata=1'); +const { db } = await import('../../server/db.mjs'); + +async function begin() { + const response = await app.request('https://scopeweave.example/api/auth/oidc/start'); + assert.equal(response.status, 302, 'complete production OIDC remains active even in development mode'); + const authorization = new URL(response.headers.get('location')); + assert.equal(authorization.origin, 'https://login.example'); + assert.equal(authorization.pathname, '/oauth2/v2/authorize', 'authorization endpoint comes from validated discovery metadata'); + return { state: authorization.searchParams.get('state'), nonce: authorization.searchParams.get('nonce') }; +} + +async function metrics() { + const response = await app.request('https://scopeweave.example/api/metrics'); + assert.equal(response.status, 200); + return response.json(); +} + +assert.equal((await metrics()).signups, 0); +discoveryMode = 'private-endpoints'; +let response = await app.request('https://scopeweave.example/api/auth/oidc/start'); +assert.equal(response.status, 404, 'discovery metadata cannot authorize server-side token or JWKS requests to private HTTPS destinations'); +discoveryMode = 'valid'; + +let flow = await begin(); +issuedIdToken = signedIdentity(flow.nonce, 'e\u0301xample@example.com'); +response = await app.request(`https://scopeweave.example/api/auth/oidc/callback?state=${encodeURIComponent(flow.state)}&code=first-code`); +assert.equal(response.status, 302, 'token exchange uses the discovered token endpoint and accepts the signed identity'); +assert.ok(tokenBodies[0] instanceof URLSearchParams, 'production OIDC sends a URLSearchParams token body'); +assert.match(tokenBodies[0].toString(), /grant_type=authorization_code/); +assert.equal((await metrics()).signups, 1, 'creating an OIDC-backed account increments the same signup metric as password signup'); + +flow = await begin(); +issuedIdToken = signedIdentity(flow.nonce, 'éxample@example.com'); +response = await app.request(`https://scopeweave.example/api/auth/oidc/callback?state=${encodeURIComponent(flow.state)}&code=second-code`); +assert.equal(response.status, 302); +assert.equal( + db.prepare('SELECT email FROM users WHERE email = ?').get('éxample@example.com')?.email, + 'éxample@example.com', + 'decomposed OIDC mailbox is stored in NFC form', +); +assert.equal((await metrics()).signups, 1, 'reusing an existing OIDC account does not double-count signup'); + +console.log('OIDC provider metadata and signup accounting contract passed'); diff --git a/tests/api/rate-limit-prometheus.test.mjs b/tests/api/rate-limit-prometheus.test.mjs new file mode 100644 index 00000000..208feed8 --- /dev/null +++ b/tests/api/rate-limit-prometheus.test.mjs @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict'; + +const validJwtSecret = '0123456789abcdef0123456789abcdef'; +process.env.SCOPEWEAVE_DB = ':memory:'; +process.env.SCOPEWEAVE_JWT_SECRET = validJwtSecret; +process.env.SCOPEWEAVE_RATE_LIMIT_MAX = '3'; +process.env.SCOPEWEAVE_RATE_LIMIT_WINDOW_MS = '60000'; +process.env.SCOPEWEAVE_RATE_LIMIT_BUCKETS_MAX = '20'; +process.env.SCOPEWEAVE_TRUSTED_PROXY_IPS = '127.0.0.1'; + +const { app } = await import('../../server/app.mjs'); +const nodeEnv = { incoming: { socket: { remoteAddress: '127.0.0.1' } } }; +const requestFrom = (client, path = '/api/health') => app.request( + path, + { headers: { 'x-forwarded-for': client } }, + nodeEnv, +); + +function metricValue(body, name) { + const match = new RegExp(`^${name}\\s+(-?\\d+(?:\\.\\d+)?)$`, 'm').exec(body); + assert.ok(match, `${name} is present as an unlabeled Prometheus metric`); + return Number(match[1]); +} + +const beforeBody = await (await requestFrom( + '198.51.100.240', + '/api/metrics?format=prometheus', +)).text(); +const beforeRequests = metricValue(beforeBody, 'scopeweave_requests'); +const beforeS4xx = metricValue(beforeBody, 'scopeweave_s4xx'); + +for (let i = 0; i < 3; i += 1) { + assert.equal( + (await requestFrom('203.0.113.240')).status, + 200, + 'requests below the configured envelope limit remain allowed', + ); +} +assert.equal( + (await requestFrom('203.0.113.240')).status, + 429, + 'the fourth request is blocked by the envelope limiter', +); + +const afterBody = await (await requestFrom( + '198.51.100.241', + '/api/metrics?format=prometheus', +)).text(); +const afterRequests = metricValue(afterBody, 'scopeweave_requests'); +const afterS4xx = metricValue(afterBody, 'scopeweave_s4xx'); + +assert.equal( + afterRequests - beforeRequests, + 5, + 'Prometheus request totals include the prior metrics read, three allowed requests, and the blocked 429', +); +assert.equal( + afterS4xx - beforeS4xx, + 1, + 'Prometheus 4xx totals include the blocked 429 exactly once', +); + +console.log('✓ rate-limit Prometheus observability regression passed'); diff --git a/tests/api/rate-limit-shared-boundary.test.mjs b/tests/api/rate-limit-shared-boundary.test.mjs new file mode 100644 index 00000000..47a2350d --- /dev/null +++ b/tests/api/rate-limit-shared-boundary.test.mjs @@ -0,0 +1,149 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; + +const validJwtSecret = '0123456789abcdef0123456789abcdef'; + +function runProbe(source) { + return spawnSync( + process.execPath, + ['--input-type=module', '--eval', source], + { + cwd: process.cwd(), + encoding: 'utf8', + env: { ...process.env }, + }, + ); +} + +const directBoundaryIdentityProbe = runProbe(` + import assert from 'node:assert/strict'; + process.env.SCOPEWEAVE_DB = ':memory:'; + process.env.SCOPEWEAVE_JWT_SECRET = '${validJwtSecret}'; + process.env.SCOPEWEAVE_RATE_LIMIT_MAX = '2'; + process.env.SCOPEWEAVE_RATE_LIMIT_WINDOW_MS = '60000'; + process.env.SCOPEWEAVE_RATE_LIMIT_BUCKETS_MAX = '8'; + delete process.env.SCOPEWEAVE_TRUSTED_PROXY_IPS; + const { app } = await import('./server/application_routes.mjs'); + const statusFor = async (forwardedFor) => (await app.request('/api/health', { + headers: { 'x-forwarded-for': forwardedFor }, + })).status; + assert.equal(await statusFor('198.51.100.1'), 200); + assert.equal(await statusFor('198.51.100.2'), 200); + assert.equal( + await statusFor('198.51.100.3'), + 429, + 'the supported shared boundary must ignore caller-controlled forwarding data when no trusted transport peer exists', + ); +`); +assert.equal( + directBoundaryIdentityProbe.status, + 0, + `Shared-boundary client-identity regression failed:\n${directBoundaryIdentityProbe.stderr}`, +); + +const importOrderIsolationProbe = runProbe(` + import assert from 'node:assert/strict'; + process.env.SCOPEWEAVE_DB = ':memory:'; + process.env.SCOPEWEAVE_JWT_SECRET = '${validJwtSecret}'; + process.env.SCOPEWEAVE_RATE_LIMIT_MAX = '1'; + process.env.SCOPEWEAVE_RATE_LIMIT_WINDOW_MS = '60000'; + process.env.SCOPEWEAVE_RATE_LIMIT_BUCKETS_MAX = '8'; + delete process.env.SCOPEWEAVE_TRUSTED_PROXY_IPS; + + await import('./server/app.mjs'); + const { app: sharedApp } = await import('./server/application_routes.mjs'); + + assert.equal((await sharedApp.request('/api/health')).status, 200); + assert.equal( + (await sharedApp.request('/api/health')).status, + 429, + 'importing the public app first must not leave the separately supported shared boundary cached with rate limiting disabled', + ); +`); +assert.equal( + importOrderIsolationProbe.status, + 0, + `Shared-boundary import-order regression failed:\n${importOrderIsolationProbe.stderr}`, +); + +const inviteOrderingProbe = runProbe(` + import assert from 'node:assert/strict'; + import { randomUUID } from 'node:crypto'; + import { rmSync } from 'node:fs'; + import { tmpdir } from 'node:os'; + import { join } from 'node:path'; + const dbPath = join(tmpdir(), 'scopeweave-rate-limit-guard-' + randomUUID() + '.sqlite'); + process.env.SCOPEWEAVE_DB = dbPath; + process.env.SCOPEWEAVE_JWT_SECRET = '${validJwtSecret}'; + process.env.SCOPEWEAVE_RATE_LIMIT_MAX = '1'; + process.env.SCOPEWEAVE_RATE_LIMIT_WINDOW_MS = '60000'; + process.env.SCOPEWEAVE_RATE_LIMIT_BUCKETS_MAX = '8'; + process.env.SCOPEWEAVE_TRUSTED_PROXY_IPS = '127.0.0.1'; + const nodeEnv = { incoming: { socket: { remoteAddress: '127.0.0.1' } } }; + let app; + const requestFrom = (client, path, options = {}) => app.request(path, { + ...options, + headers: { 'content-type': 'application/json', ...(options.headers || {}), 'x-forwarded-for': client }, + }, nodeEnv); + const originalLog = console.log; + const requestLogs = []; + let db; + console.log = (line) => requestLogs.push(String(line)); + try { + const [{ app: loadedApp }, { db: loadedDb }, { signToken }] = await Promise.all([ + import('./server/application_routes.mjs'), + import('./server/db.mjs'), + import('./server/auth.mjs'), + ]); + app = loadedApp; + db = loadedDb; + db.prepare('INSERT INTO users(id,email,password_hash,name) VALUES(?,?,?,?)') + .run(1, 'rate-limit-owner@example.com', '', ''); + db.prepare('INSERT INTO users(id,email,password_hash,name) VALUES(?,?,?,?)') + .run(2, 'wrong-identity@example.com', '', ''); + db.prepare('INSERT INTO orgs(id,name,owner_id) VALUES(?,?,?)').run(1, 'rate-limit-org', 1); + db.prepare('INSERT INTO invites(id,org_id,email,role,token,invited_by) VALUES(?,?,?,?,?,?)') + .run(1, 1, 'intended-invitee@example.com', 'viewer', 'invite-ordering-sentinel', 1); + const attackerToken = signToken({ sub: 2, email: 'wrong-identity@example.com', tv: 0 }); + const inviteToken = 'invite-ordering-sentinel'; + + const first = await requestFrom('198.51.100.77', '/api/invites/' + inviteToken + '/accept', { + method: 'POST', + headers: { authorization: 'Bearer ' + attackerToken }, + }); + assert.equal(first.status, 404, 'the first wrong-identity invite request reaches the guard'); + + db.exec('DROP TABLE invites'); + const second = await requestFrom('198.51.100.77', '/api/invites/' + inviteToken + '/accept', { + method: 'POST', + headers: { authorization: 'Bearer ' + attackerToken }, + }); + assert.equal( + second.status, + 429, + 'the exhausted shared-boundary limiter rejects before invite identity-row lookup', + ); + + const inviteLogs = requestLogs + .map((line) => { + try { return JSON.parse(line); } catch { return null; } + }) + .filter((entry) => entry?.path === '/api/invites/:token/accept'); + assert.deepEqual( + inviteLogs.map(({ method, status }) => ({ method, status })), + [{ method: 'POST', status: 404 }, { method: 'POST', status: 429 }], + 'guard accounting and blocked observability retain the original POST method', + ); + } finally { + console.log = originalLog; + db?.close(); + for (const suffix of ['', '-shm', '-wal']) rmSync(dbPath + suffix, { force: true }); + } +`); +assert.equal( + inviteOrderingProbe.status, + 0, + `Shared-boundary limiter-order regression failed:\n${inviteOrderingProbe.stderr}`, +); + +console.log('✓ shared-boundary rate-limit regressions passed'); diff --git a/tests/api/ratelimit.test.mjs b/tests/api/ratelimit.test.mjs index 7157e523..294112ec 100644 --- a/tests/api/ratelimit.test.mjs +++ b/tests/api/ratelimit.test.mjs @@ -1,16 +1,244 @@ // Rate-limit test — runs in its own process with the limiter enabled low. // Run: node tests/api/ratelimit.test.mjs import assert from 'node:assert'; +import { spawnSync } from 'node:child_process'; +import { once } from 'node:events'; +import { serve } from '@hono/node-server'; +import { randomUUID } from 'node:crypto'; +import { rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const validJwtSecret = '0123456789abcdef0123456789abcdef'; +const redactionDbPath = join(tmpdir(), `scopeweave-rate-limit-redaction-${randomUUID()}.sqlite`); +const importRateLimitApp = (overrides) => spawnSync( + process.execPath, + ['--input-type=module', '--eval', "await import('./server/app.mjs')"], + { + cwd: process.cwd(), + encoding: 'utf8', + env: { + ...process.env, + SCOPEWEAVE_DB: ':memory:', + SCOPEWEAVE_JWT_SECRET: validJwtSecret, + SCOPEWEAVE_RATE_LIMIT_MAX: '3', + SCOPEWEAVE_RATE_LIMIT_WINDOW_MS: '60000', + SCOPEWEAVE_RATE_LIMIT_BUCKETS_MAX: '7', + ...overrides, + }, + }, +); + +for (const [name, value] of [ + ['SCOPEWEAVE_RATE_LIMIT_MAX', '-1'], + ['SCOPEWEAVE_RATE_LIMIT_MAX', 'not-a-number'], + ['SCOPEWEAVE_RATE_LIMIT_MAX', 'Infinity'], + ['SCOPEWEAVE_RATE_LIMIT_WINDOW_MS', '0'], + ['SCOPEWEAVE_RATE_LIMIT_WINDOW_MS', '-1'], + ['SCOPEWEAVE_RATE_LIMIT_WINDOW_MS', 'not-a-number'], + ['SCOPEWEAVE_RATE_LIMIT_WINDOW_MS', 'Infinity'], + ['SCOPEWEAVE_RATE_LIMIT_BUCKETS_MAX', '0'], + ['SCOPEWEAVE_RATE_LIMIT_BUCKETS_MAX', '-1'], + ['SCOPEWEAVE_RATE_LIMIT_BUCKETS_MAX', 'not-a-number'], + ['SCOPEWEAVE_RATE_LIMIT_BUCKETS_MAX', 'Infinity'], +]) { + const result = importRateLimitApp({ [name]: value }); + assert.notEqual(result.status, 0, `${name}=${value} must fail startup instead of weakening limiter semantics`); + assert.match(result.stderr, new RegExp(name), 'startup error identifies the invalid limiter setting'); +} + +assert.equal( + importRateLimitApp({ SCOPEWEAVE_RATE_LIMIT_MAX: '0' }).status, + 0, + 'explicit zero keeps the documented disabled-limiter contract', +); + +const mappedPeerProbe = spawnSync( + process.execPath, + [ + '--input-type=module', + '--eval', + `import assert from 'node:assert/strict'; + process.env.SCOPEWEAVE_DB = ':memory:'; + process.env.SCOPEWEAVE_JWT_SECRET = '${validJwtSecret}'; + process.env.SCOPEWEAVE_RATE_LIMIT_MAX = '3'; + process.env.SCOPEWEAVE_RATE_LIMIT_WINDOW_MS = '60000'; + process.env.SCOPEWEAVE_RATE_LIMIT_BUCKETS_MAX = '20'; + process.env.SCOPEWEAVE_TRUSTED_PROXY_IPS = '127.0.0.1'; + const { app } = await import('./server/app.mjs'); + const nodeEnv = { incoming: { socket: { remoteAddress: '::ffff:127.0.0.1' } } }; + const requestFrom = (client) => app.request('/api/health', { headers: { 'x-forwarded-for': client } }, nodeEnv); + for (let i = 0; i < 3; i++) assert.equal((await requestFrom('203.0.113.70')).status, 200); + assert.equal( + (await requestFrom('198.51.100.70')).status, + 200, + 'an IPv4-mapped Node peer must match the configured IPv4 trusted proxy and preserve separate client buckets', + );`, + ], + { cwd: process.cwd(), encoding: 'utf8', env: { ...process.env } }, +); +assert.equal( + mappedPeerProbe.status, + 0, + `IPv4-mapped trusted-proxy regression failed:\n${mappedPeerProbe.stderr}`, +); + +const equivalentIpv6PeerProbe = spawnSync( + process.execPath, + [ + '--input-type=module', + '--eval', + `import assert from 'node:assert/strict'; + process.env.SCOPEWEAVE_DB = ':memory:'; + process.env.SCOPEWEAVE_JWT_SECRET = '${validJwtSecret}'; + process.env.SCOPEWEAVE_RATE_LIMIT_MAX = '3'; + process.env.SCOPEWEAVE_RATE_LIMIT_WINDOW_MS = '60000'; + process.env.SCOPEWEAVE_RATE_LIMIT_BUCKETS_MAX = '20'; + process.env.SCOPEWEAVE_TRUSTED_PROXY_IPS = '0:0:0:0:0:0:0:1'; + const { app } = await import('./server/app.mjs'); + const nodeEnv = { incoming: { socket: { remoteAddress: '::1' } } }; + const requestFrom = (client) => app.request('/api/health', { headers: { 'x-forwarded-for': client } }, nodeEnv); + for (let i = 0; i < 3; i++) assert.equal((await requestFrom('203.0.113.71')).status, 200); + assert.equal( + (await requestFrom('198.51.100.71')).status, + 200, + 'equivalent IPv6 spellings for a trusted proxy must resolve to the same identity and preserve separate client buckets', + );`, + ], + { cwd: process.cwd(), encoding: 'utf8', env: { ...process.env } }, +); +assert.equal( + equivalentIpv6PeerProbe.status, + 0, + `Equivalent-IPv6 trusted-proxy regression failed:\n${equivalentIpv6PeerProbe.stderr}`, +); + +const directCoreProbe = spawnSync( + process.execPath, + [ + '--input-type=module', + '--eval', + `import assert from 'node:assert/strict'; + process.env.SCOPEWEAVE_DB = ':memory:'; + process.env.SCOPEWEAVE_JWT_SECRET = '${validJwtSecret}'; + process.env.SCOPEWEAVE_RATE_LIMIT_MAX = '3'; + process.env.SCOPEWEAVE_RATE_LIMIT_WINDOW_MS = '60000'; + process.env.SCOPEWEAVE_RATE_LIMIT_BUCKETS_MAX = '20'; + process.env.SCOPEWEAVE_TRUSTED_PROXY_IPS = '127.0.0.1'; + const { app } = await import('./server/application_routes_core.mjs?direct-core-rate-limit=1'); + const nodeEnv = { incoming: { socket: { remoteAddress: '127.0.0.1' } } }; + const requestFrom = (client) => app.request('/api/health', { headers: { 'x-forwarded-for': client } }, nodeEnv); + for (let i = 0; i < 3; i++) assert.equal((await requestFrom('203.0.113.72')).status, 200); + assert.equal( + (await requestFrom('203.0.113.72')).status, + 429, + 'direct core consumers use the shared bounded rate-limit policy', + ); + assert.equal( + (await requestFrom('198.51.100.72')).status, + 200, + 'direct core consumers honor trusted proxy client separation', + );`, + ], + { cwd: process.cwd(), encoding: 'utf8', env: { ...process.env } }, +); +assert.equal( + directCoreProbe.status, + 0, + `Direct application core rate-limit regression failed:\n${directCoreProbe.stderr}`, +); + +const observabilityProbe = spawnSync( + process.execPath, + [ + '--input-type=module', + '--eval', + `import assert from 'node:assert/strict'; + process.env.SCOPEWEAVE_DB = ':memory:'; + process.env.SCOPEWEAVE_JWT_SECRET = '${validJwtSecret}'; + process.env.SCOPEWEAVE_RATE_LIMIT_MAX = '3'; + process.env.SCOPEWEAVE_RATE_LIMIT_WINDOW_MS = '60000'; + process.env.SCOPEWEAVE_RATE_LIMIT_BUCKETS_MAX = '20'; + process.env.SCOPEWEAVE_TRUSTED_PROXY_IPS = '127.0.0.1'; + const { app } = await import('./server/app.mjs'); + const nodeEnv = { incoming: { socket: { remoteAddress: '127.0.0.1' } } }; + const requestFrom = (client, path = '/api/health') => app.request(path, { headers: { 'x-forwarded-for': client } }, nodeEnv); + const before = await (await requestFrom('198.51.100.240', '/api/metrics')).json(); + for (let i = 0; i < 3; i++) assert.equal((await requestFrom('203.0.113.240')).status, 200); + assert.equal((await requestFrom('203.0.113.240')).status, 429, 'fourth request is blocked by the envelope limiter'); + const after = await (await requestFrom('198.51.100.241', '/api/metrics')).json(); + assert.equal( + after.requests - before.requests, + 5, + 'operational request totals include the prior metrics read, three allowed requests, and the blocked 429', + ); + assert.equal(after.s4xx - before.s4xx, 1, 'rate-limited 429 responses remain visible in 4xx metrics');`, + ], + { cwd: process.cwd(), encoding: 'utf8', env: { ...process.env } }, +); +assert.equal( + observabilityProbe.status, + 0, + `Rate-limit observability regression failed:\n${observabilityProbe.stderr}`, +); + +const redactionProbe = spawnSync( + process.execPath, + [ + '--input-type=module', + '--eval', + `process.env.SCOPEWEAVE_DB = ${JSON.stringify(redactionDbPath)}; + process.env.SCOPEWEAVE_JWT_SECRET = '${validJwtSecret}'; + process.env.SCOPEWEAVE_RATE_LIMIT_MAX = '1'; + process.env.SCOPEWEAVE_RATE_LIMIT_WINDOW_MS = '60000'; + const { app } = await import('./server/app.mjs?rate-limit-log-redaction=1'); + const path = '/api/invites/live-invite-secret-sentinel/accept'; + await app.request(path, { method: 'POST' }); + await app.request(path, { method: 'POST' }); + const { app: shareApp } = await import('./server/app.mjs?rate-limit-share-log-redaction=1'); + const sharePath = '/api/shared/live-share-secret-sentinel/extra'; + await shareApp.request(sharePath); + await shareApp.request(sharePath); + process.stdout.write('RATE_LIMIT_REDACTION_DONE\\n');`, + ], + { cwd: process.cwd(), encoding: 'utf8', env: { ...process.env } }, +); +assert.equal(redactionProbe.status, 0, `Rate-limit log redaction regression failed:\n${redactionProbe.stderr}`); +const redactionLogs = redactionProbe.stdout + .split('\n') + .filter((line) => line.startsWith('{')) + .map((line) => JSON.parse(line)); +assert.deepEqual( + redactionLogs.map((entry) => entry.path), + [ + '/api/invites/:token/accept', + '/api/invites/:token/accept', + '/api/shared/:token/extra', + '/api/shared/:token/extra', + ], + 'allowed and blocked invite/share requests redact every secret path segment', +); +for (const entry of redactionLogs) assert.equal( + /live-(?:invite|share)-secret-sentinel/u.test(entry.path), + false, + 'request logs never contain bearer tokens', +); +rmSync(redactionDbPath, { force: true }); +rmSync(`${redactionDbPath}-shm`, { force: true }); +rmSync(`${redactionDbPath}-wal`, { force: true }); process.env.SCOPEWEAVE_DB = ':memory:'; process.env.SCOPEWEAVE_RATE_LIMIT_MAX = '3'; -process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; +process.env.SCOPEWEAVE_RATE_LIMIT_BUCKETS_MAX = '7'; +process.env.SCOPEWEAVE_TRUSTED_PROXY_IPS = '127.0.0.1,::1,::ffff:127.0.0.1'; +process.env.SCOPEWEAVE_JWT_SECRET = validJwtSecret; const { app } = await import('../../server/app.mjs'); const req = (path, opts = {}) => app.request(path, { ...opts, headers: { 'content-type': 'application/json', 'x-forwarded-for': '203.0.113.7', ...(opts.headers || {}) } }); -// first 3 pass, 4th+ are limited +// In-process calls have no authenticated network peer, so they deliberately +// share one fail-closed bucket and ignore caller-controlled forwarding data. let statuses = []; for (let i = 0; i < 5; i++) statuses.push((await req('/api/health')).status); assert.deepEqual(statuses.slice(0, 3), [200, 200, 200], 'first 3 under the limit'); @@ -18,9 +246,82 @@ assert.equal(statuses[3], 429, '4th request rate-limited'); const limited = await req('/api/health'); assert.equal(limited.status, 429, 'still limited within the window'); assert.ok(limited.headers.get('retry-after'), 'Retry-After header present'); +const spoofed = await req('/api/health', { headers: { 'x-forwarded-for': '198.51.100.9' } }); +assert.equal(spoofed.status, 429, 'untrusted X-Forwarded-For cannot select a new rate-limit bucket'); + +// Exercise the actual Node transport boundary. The loopback socket is an +// explicitly trusted ingress for this test, so only forwarding hops anchored +// to that peer may select a client bucket. +const server = serve({ fetch: app.fetch, hostname: '127.0.0.1', port: 0 }); +if (!server.listening) await once(server, 'listening'); +try { + const address = server.address(); + assert.ok(address && typeof address === 'object', 'test server exposes a TCP address'); + const base = `http://127.0.0.1:${address.port}`; + const viaProxy = (forwarded) => fetch(`${base}/api/health`, { + headers: forwarded === undefined ? {} : { 'x-forwarded-for': forwarded }, + }); + + // A malicious client may alter left-side values, but a trusted proxy's + // nearest appended client hop remains the same and therefore exhausts one + // bucket rather than creating attacker-selected buckets. + for (let i = 0; i < 3; i++) { + assert.equal((await viaProxy(`192.0.2.${10 + i}, 203.0.113.7`)).status, 200); + } + assert.equal( + (await viaProxy('192.0.2.99, 203.0.113.7')).status, + 429, + 'changing spoofable left-side forwarding data cannot evade the client bucket' + ); + assert.equal( + (await viaProxy('192.0.2.123, 198.51.100.9')).status, + 200, + 'a genuinely different nearest client hop receives a separate bucket' + ); + + // A trusted proxy appends the attacker's real nearest hop to any spoofable + // client-supplied left-side forwarding chain. That left-side value must not + // consume a different legitimate client's limiter state. + for (let i = 0; i < 3; i++) { + assert.equal( + (await viaProxy('203.0.113.50, 198.51.100.50')).status, + 200, + 'attacker requests stay in the attacker bucket' + ); + } + assert.equal( + (await viaProxy('203.0.113.50')).status, + 200, + 'spoofable left-side forwarding data cannot poison another client bucket' + ); + + // Trusted proxy chains are skipped from right to left until the first + // untrusted client IP is reached. + assert.equal((await viaProxy('198.51.100.10, 127.0.0.1')).status, 200); + + // Missing, invalid, or all-trusted forwarding evidence fails closed to the + // actual peer instead of accepting arbitrary strings as identities. + assert.equal((await viaProxy()).status, 200); + assert.equal((await viaProxy('not-an-ip-one')).status, 200); + assert.equal((await viaProxy('not-an-ip-two')).status, 200); + assert.equal((await viaProxy('not-an-ip-three')).status, 429); + assert.equal((await viaProxy('127.0.0.1')).status, 429); -// a different client IP has its own bucket -const other = await req('/api/health', { headers: { 'x-forwarded-for': '198.51.100.9' } }); -assert.equal(other.status, 200, 'different IP not limited'); + // Distinct trusted-proxy client addresses must not grow in-memory limiter + // state without bound. Prime enough new identities that this assertion does + // not depend on a bucket created by an earlier phase still being live. Even + // if every earlier regular bucket has expired, at most seven new identities + // can receive regular buckets and the fourth overflow request must be blocked. + const overflowStatuses = []; + for (let i = 0; i < 11; i += 1) { + overflowStatuses.push((await viaProxy(`192.0.2.${201 + i}`)).status); + } + assert.ok( + overflowStatuses.includes(429), + 'within bucket capacity plus the per-window allowance, new identities converge on the bounded overflow bucket and are throttled' + ); +} finally { + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); +} console.log('✓ rate-limit tests passed'); diff --git a/tests/api/security-guard-abuse-controls.test.mjs b/tests/api/security-guard-abuse-controls.test.mjs new file mode 100644 index 00000000..a7f15555 --- /dev/null +++ b/tests/api/security-guard-abuse-controls.test.mjs @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const dbPath = join(tmpdir(), `scopeweave-guard-abuse-${randomUUID()}.sqlite`); +process.env.SCOPEWEAVE_DB = dbPath; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; +process.env.SCOPEWEAVE_RATE_LIMIT_MAX = '1'; +process.env.SCOPEWEAVE_RATE_LIMIT_WINDOW_MS = '60000'; +process.env.STRIPE_SECRET_KEY = 'sk_test_scopeweave_guard_abuse'; +process.env.STRIPE_PRICE_ID = 'price_scopeweave_guard_abuse'; +process.env.STRIPE_WEBHOOK_SECRET = 'whsec_scopeweave_guard_abuse'; +delete process.env.SCOPEWEAVE_DEV; +delete process.env.OIDC_ISSUER; + +const originalLog = console.log; +const requestLogs = []; +console.log = (line) => requestLogs.push(String(line)); + +try { + const { app } = await import('../../server/application_routes.mjs?security-guard-abuse=1'); + const url = 'https://scopeweave.example/api/auth/oidc/start'; + + let response = await app.request(url); + assert.equal(response.status, 404, 'unconfigured production OIDC fails closed'); + assert.deepEqual(await response.json(), { error: 'sso not configured' }); + + response = await app.request(url); + assert.equal( + response.status, + 429, + 'a repeated guard-rejected request still passes through the existing abuse-control middleware', + ); + assert.deepEqual(await response.json(), { error: 'rate limit exceeded' }); + + const oidcLogs = requestLogs + .map((line) => { + try { return JSON.parse(line); } catch { return null; } + }) + .filter((entry) => entry?.path === '/api/auth/oidc/start'); + assert.deepEqual( + oidcLogs.map(({ method, status }) => ({ method, status })), + [ + { method: 'GET', status: 404 }, + { method: 'GET', status: 429 }, + ], + 'guard rejections preserve the attempted HTTP method in structured request observability', + ); +} finally { + console.log = originalLog; + rmSync(dbPath, { force: true }); + rmSync(`${dbPath}-shm`, { force: true }); + rmSync(`${dbPath}-wal`, { force: true }); +} + +console.log('security guard abuse-control regression passed'); diff --git a/tests/api/stripe-webhook.test.mjs b/tests/api/stripe-webhook.test.mjs new file mode 100644 index 00000000..26a0683b --- /dev/null +++ b/tests/api/stripe-webhook.test.mjs @@ -0,0 +1,174 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { createHmac } from 'node:crypto'; +import test from 'node:test'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +delete process.env.SCOPEWEAVE_DEV; +process.env.SCOPEWEAVE_PUBLIC_ORIGIN = 'https://scopeweave.example'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; +process.env.STRIPE_SECRET_KEY = 'sk_test_scopeweave_webhook'; +process.env.STRIPE_PRICE_ID = 'price_scopeweave_webhook'; +process.env.STRIPE_WEBHOOK_SECRET = 'whsec_scopeweave_api_webhook_secret'; + +const { app } = await import('../../server/app.mjs?stripe-webhook-api-hotfix-test=1'); + +const WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET; +const jsonHeaders = { 'content-type': 'application/json' }; + +function signatureHeader(body, timestamp = Math.floor(Date.now() / 1000)) { + const digest = createHmac('sha256', WEBHOOK_SECRET) + .update(String(timestamp)) + .update('.') + .update(body) + .digest('hex'); + return `t=${timestamp},v1=${digest}`; +} + +async function signupAndOrg() { + const signup = await app.request('https://scopeweave.example/api/auth/signup', { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify({ + email: `webhook-${Date.now()}-${Math.random()}@example.test`, + password: 'password123', + name: 'Webhook Owner', + }), + }); + assert.equal(signup.status, 200); + const { token } = await signup.json(); + const me = await app.request('https://scopeweave.example/api/me', { + headers: { authorization: `Bearer ${token}` }, + }); + assert.equal(me.status, 200); + const body = await me.json(); + return { token, orgId: body.orgs[0].id }; +} + +async function currentPlan(token) { + const response = await app.request('https://scopeweave.example/api/me', { + headers: { authorization: `Bearer ${token}` }, + }); + assert.equal(response.status, 200); + return (await response.json()).orgs[0].plan; +} + +function checkoutCompletedBody(orgId) { + return JSON.stringify({ + id: `evt_checkout_${orgId}`, + type: 'checkout.session.completed', + data: { + object: { + id: `cs_test_${orgId}`, + client_reference_id: String(orgId), + metadata: { orgId: String(orgId) }, + }, + }, + }); +} + +test('Stripe webhook stays behind the application abuse-control middleware', () => { + const child = spawnSync(process.execPath, ['--input-type=module', '--eval', ` + process.env.SCOPEWEAVE_DB = ':memory:'; + delete process.env.SCOPEWEAVE_DEV; + process.env.SCOPEWEAVE_PUBLIC_ORIGIN = 'https://scopeweave.example'; + process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; + process.env.STRIPE_SECRET_KEY = 'sk_test_scopeweave_webhook'; + process.env.STRIPE_PRICE_ID = 'price_scopeweave_webhook'; + process.env.STRIPE_WEBHOOK_SECRET = 'whsec_scopeweave_api_webhook_secret'; + process.env.SCOPEWEAVE_RATE_LIMIT_MAX = '1'; + process.env.SCOPEWEAVE_RATE_LIMIT_WINDOW_MS = '60000'; + + const { app } = await import('./server/app.mjs?stripe-webhook-middleware-regression=1'); + const request = () => app.request('https://scopeweave.example/api/stripe/webhook', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ id: 'evt_unsigned_flood', type: 'checkout.session.completed' }), + }); + const first = await request(); + const second = await request(); + process.stdout.write(JSON.stringify({ + first: first.status, + second: second.status, + secondBody: await second.json(), + })); + `], { + cwd: process.cwd(), + encoding: 'utf8', + }); + + assert.equal(child.status, 0, child.stderr); + assert.deepEqual(JSON.parse(child.stdout.trim()), { + first: 400, + second: 429, + secondBody: { error: 'rate limit exceeded' }, + }); +}); + +test('unsigned Stripe webhook cannot upgrade an organization', async () => { + const { token, orgId } = await signupAndOrg(); + assert.equal(await currentPlan(token), 'free'); + + const body = checkoutCompletedBody(orgId); + const response = await app.request('https://scopeweave.example/api/stripe/webhook', { + method: 'POST', + headers: jsonHeaders, + body, + }); + + assert.equal(response.status, 400); + assert.deepEqual(await response.json(), { error: 'stripe_webhook_signature_invalid' }); + assert.equal(response.headers.get('cache-control'), 'no-store'); + assert.equal(await currentPlan(token), 'free'); +}); + +test('verified webhook is acknowledged but does not grant entitlement before durable reconciliation', async () => { + const { token, orgId } = await signupAndOrg(); + const body = checkoutCompletedBody(orgId); + const response = await app.request('https://scopeweave.example/api/stripe/webhook', { + method: 'POST', + headers: { + ...jsonHeaders, + 'stripe-signature': signatureHeader(body), + }, + body, + }); + + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { received: true }); + assert.equal(response.headers.get('cache-control'), 'no-store'); + assert.equal( + await currentPlan(token), + 'free', + 'authenticated delivery alone cannot bypass durable duplicate/order/provider-state reconciliation', + ); +}); + +test('stale signed delivery and raw-body mutation fail before entitlement state changes', async () => { + const { token, orgId } = await signupAndOrg(); + const body = checkoutCompletedBody(orgId); + const staleTimestamp = Math.floor(Date.now() / 1000) - 301; + + let response = await app.request('https://scopeweave.example/api/stripe/webhook', { + method: 'POST', + headers: { + ...jsonHeaders, + 'stripe-signature': signatureHeader(body, staleTimestamp), + }, + body, + }); + assert.equal(response.status, 400); + assert.deepEqual(await response.json(), { error: 'stripe_webhook_signature_invalid' }); + + response = await app.request('https://scopeweave.example/api/stripe/webhook', { + method: 'POST', + headers: { + ...jsonHeaders, + 'stripe-signature': signatureHeader(body), + }, + body: `${body}\n`, + }); + assert.equal(response.status, 400); + assert.deepEqual(await response.json(), { error: 'stripe_webhook_signature_invalid' }); + assert.equal(await currentPlan(token), 'free'); +}); diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index 149440e5..1373fbc0 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -1,14 +1,27 @@ -// This contract prevents a subtle CI regression: the central review gate may -// invoke `test:coverage` directly, so that script itself must create Istanbul -// JSON rather than merely execute tests without instrumentation. import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; -const packageJson = JSON.parse( - readFileSync(new URL('../../package.json', import.meta.url), 'utf8'), -); -const scripts = packageJson.scripts; +const packageJson = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')); +const scripts = packageJson.scripts || {}; +const serverWorkflow = readFileSync(new URL('../../.github/workflows/server-tests.yml', import.meta.url), 'utf8'); +const dependencyWorkflow = readFileSync(new URL('../../.github/workflows/dependency-review.yml', import.meta.url), 'utf8'); +const publicApp = readFileSync(new URL('../../server/app.mjs', import.meta.url), 'utf8'); +const routeImportSeam = readFileSync(new URL('../../server/app_routes.mjs', import.meta.url), 'utf8'); +const applicationRoutes = readFileSync(new URL('../../server/application_routes.mjs', import.meta.url), 'utf8'); +const applicationRoutesCore = readFileSync(new URL('../../server/application_routes_core.mjs', import.meta.url), 'utf8'); +const applicationRoutesImplementation = readFileSync(new URL('../../server/application_routes_implementation.mjs', import.meta.url), 'utf8'); +const rateLimitModule = readFileSync(new URL('../../server/rate_limit.mjs', import.meta.url), 'utf8'); +assert.match( + serverWorkflow, + /npm run test:coverage/, + 'server CI invokes the exact coverage producer', +); +assert.equal( + (serverWorkflow.match(/run:\s*npm run test:api/g) || []).length, + 0, + 'server CI does not execute the API suite separately when owned coverage already executes it', +); assert.equal( scripts.coverage, 'npm run test:coverage', @@ -24,25 +37,194 @@ assert.match( /--reporter=json-summary\b/, 'test:coverage also creates the Istanbul JSON summary', ); +assert.doesNotMatch( + scripts['test:coverage:cases'], + /npm run (?:coverage|test:coverage)(?:\s|$)/, + 'coverage cases never recursively invoke a coverage wrapper', +); +const exactCheckoutRepository = + "repository: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }}"; +const exactCheckoutRef = + "ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}"; +assert.equal( + serverWorkflow.split(exactCheckoutRepository).length - 1, + 2, + 'both server CI jobs bind checkout to the submitted repository on pull requests', +); +assert.equal( + serverWorkflow.split(exactCheckoutRef).length - 1, + 2, + 'both server CI jobs bind checkout to the exact submitted head SHA on pull requests', +); +assert.equal( + (serverWorkflow.match(/- name: Verify exact checkout revision/g) || []).length, + 2, + 'both server CI jobs attest the actual checkout revision before executing repository code', +); +assert.equal( + (serverWorkflow.match(/actual_head_sha="\$\(git rev-parse HEAD\)"/g) || []).length, + 2, + 'both server CI jobs measure the actual checked-out revision', +); +assert.equal( + (serverWorkflow.match(/test "\$actual_head_sha" = "\$EXPECTED_HEAD_SHA"/g) || []).length, + 2, + 'both server CI jobs fail closed when checkout identity differs from the expected exact head', +); +assert.equal( + dependencyWorkflow.split(exactCheckoutRepository).length - 1, + 1, + 'dependency review binds checkout to the submitted repository on pull requests', +); +assert.equal( + dependencyWorkflow.split(exactCheckoutRef).length - 1, + 1, + 'dependency review binds checkout to the exact submitted head SHA on pull requests', +); +assert.equal( + (dependencyWorkflow.match(/- name: Verify exact checkout revision/g) || []).length, + 1, + 'dependency review attests the actual checkout revision before executing the gate', +); +assert.match( + dependencyWorkflow, + /BASE_REF: \$\{\{ github\.event\.pull_request\.base\.ref \}\}/, + 'dependency review starts from the named base branch rather than stale event base SHA evidence', +); +assert.doesNotMatch( + dependencyWorkflow, + /BASE_SHA: \$\{\{ github\.event\.pull_request\.base\.sha \}\}/, + 'dependency review does not trust the historical event base SHA as the live comparison base', +); +assert.match( + dependencyWorkflow, + /urllib\.parse\.quote\(os\.environ\["BASE_REF"\], safe="\/"\)/, + 'dependency review preserves branch-name slashes while encoding other unsafe URL characters', +); +assert.match( + dependencyWorkflow, + /branches\/\$\{base_ref_encoded\}/, + 'dependency review independently resolves the current base branch tip through the GitHub API', +); +assert.match( + dependencyWorkflow, + /echo "base_sha=\$BASE_SHA" >>"\$GITHUB_OUTPUT"/, + 'dependency review publishes the independently resolved live base SHA for the action comparison', +); +assert.match( + dependencyWorkflow, + /base-ref: \$\{\{ steps\.dependency_review_support\.outputs\.base_sha \}\}/, + 'dependency-review-action receives the independently resolved live base SHA explicitly', +); +assert.match( + dependencyWorkflow, + /head-ref: \$\{\{ github\.event\.pull_request\.head\.sha \}\}/, + 'dependency-review-action receives the exact contributor head SHA explicitly', +); +assert.doesNotMatch( + dependencyWorkflow, + /Dependency review is unavailable[\s\S]{0,300}supported=false[\s\S]{0,100}exit 0/, + 'dependency review never converts unavailable pull-request comparison evidence into a green skip', +); assert.match( scripts['test:coverage'], - /--include=server\/attachment_status\.mjs/, - 'the bounded refresh module is instrumented', + /--include=server\/app\.mjs/, + 'the public transport-peer security envelope remains owned-production coverage', ); assert.match( scripts['test:coverage'], - /--include=server\/clearfolio\.mjs/, - 'the abortable Clearfolio adapter is instrumented', + /--include=server\/rate_limit\.mjs/, + 'the shared authoritative rate-limit implementation remains owned-production coverage', +); +assert.match( + scripts['test:coverage'], + /--include=server\/application_routes\.mjs/, + 'the shared security boundary remains owned-production coverage', +); +assert.match( + scripts['test:coverage'], + /--include=server\/application_routes_core\.mjs/, + 'the direct-core fail-closed boundary remains owned-production coverage', +); +assert.match( + scripts['test:coverage'], + /--include=server\/application_routes_implementation\.mjs/, + 'the protected application implementation remains owned-production coverage after the boundary split', +); +assert.match( + scripts['test:coverage'], + /--include=server\/stripe_webhook\.mjs/, + 'the Stripe webhook verifier is owned-production coverage', ); assert.match( scripts['test:coverage:cases'], - /tests\/unit\/clearfolio-status-signal\.test\.mjs/, - 'the Clearfolio signal and HTTP failure regression executes under c8', + /tests\/unit\/stripe-webhook-boundary\.test\.mjs/, + 'the raw-body Stripe trust-boundary regression executes under c8', +); +assert.match( + scripts['test:api'], + /tests\/api\/stripe-webhook\.test\.mjs/, + 'the public Stripe webhook entitlement regression executes in normal API CI', +); +assert.match( + publicApp, + /await import\(['"]\.\/app_routes\.mjs['"]\)/, + 'the public app loads the guarded route graph only after establishing the authoritative limiter envelope', +); +assert.match( + publicApp, + /import\s*\{\s*createRateLimitMiddleware,\s*createRateLimitObservability,?\s*\}\s*from\s*['"]\.\/rate_limit\.mjs['"]/, + 'the public and shared boundaries consume the same authoritative rate-limit implementation', +); +assert.doesNotMatch( + publicApp, + /function\s+(?:parseSafeIntegerSetting|canonicalIp|rateLimitBucket)\s*\(/, + 'the public envelope does not fork validation, client identity, or bucket semantics from rate_limit.mjs', +); +assert.match( + publicApp, + /app\.use\(\s*['"]\*['"]\s*,\s*createRateLimitMiddleware\(rateLimitObservability\)\s*\)/, + 'the public envelope installs the shared limiter before mounting the guarded route graph', +); +assert.match( + rateLimitModule, + /Math\.max\(1,\s*Math\.ceil\(\(bucket\.resetAt - now\) \/ 1000\)\)/, + 'the single limiter source preserves a positive Retry-After boundary', +); +assert.match( + publicApp, + /app\.route\(\s*['"]\/['"]\s*,\s*routeApp\s*\)/, + 'the transport-peer-aware limiter wraps the supported shared route graph', +); +assert.match( + routeImportSeam, + /export \{ app \} from ['"]\.\/application_routes\.mjs['"]/, + 'the transitional import seam delegates to the single supported shared application boundary', +); +assert.match( + applicationRoutes, + /app\.use\(OIDC_ROUTE_PREFIX, failClosedWhenOidcIsUnconfigured\)[\s\S]*app\.use\(INVITE_ACCEPT_PATH, bindInviteToAuthenticatedIdentity\)[\s\S]*app\.use\(MEMBERS_PATH, redactPendingInviteTokens\)[\s\S]*app\.route\(\s*['"]\/['"]\s*,\s*coreRoutes\s*\)/, + 'shared OIDC and invitation controls wrap the implementation graph before it is mounted', +); +assert.match( + applicationRoutesCore, + /app\.use\(\s*['"]\/api\/auth\/oidc\/start['"]\s*,\s*requireVerifiedOidcBoundary\s*\)[\s\S]*app\.use\(\s*['"]\/api\/auth\/oidc\/callback['"]\s*,\s*requireVerifiedOidcBoundary\s*\)[\s\S]*app\.route\(\s*['"]\/['"]\s*,\s*implementationRoutes\s*\)/, + 'the direct-core boundary fails production OIDC closed before delegating to the legacy implementation graph', +); +assert.match( + applicationRoutesImplementation, + /verifyStripeWebhookRequest/, + 'the protected Stripe route remains fail-closed in the delegated implementation graph', ); assert.doesNotMatch( + applicationRoutesImplementation, + /checkout\.session\.completed[\s\S]{0,500}UPDATE orgs SET plan = 'pro'/, + 'checkout.session.completed JSON is never authority to upgrade orgs.plan', +); +assert.match( scripts['test:coverage:cases'], - /npm run (?:coverage|test:coverage)(?:\s|$)/, - 'coverage cases never recursively invoke a coverage wrapper', + /tests\/unit\/clearfolio-status-signal\.test\.mjs/, + 'the Clearfolio signal and HTTP failure regression executes under c8', ); -console.log('✓ coverage script contract tests passed'); +console.log('coverage script contract passed'); diff --git a/tests/unit/dependency-review-merge-base-contract.test.mjs b/tests/unit/dependency-review-merge-base-contract.test.mjs new file mode 100644 index 00000000..251cbff4 --- /dev/null +++ b/tests/unit/dependency-review-merge-base-contract.test.mjs @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +const dependencyWorkflow = readFileSync( + new URL('../../.github/workflows/dependency-review.yml', import.meta.url), + 'utf8', +); + +assert.match( + dependencyWorkflow, + /compare\/\$\{LIVE_BASE_SHA\}\.\.\.\$\{HEAD_SHA\}/, + 'dependency review pins merge-base resolution to the independently resolved live base SHA and exact contributor head', +); +assert.doesNotMatch( + dependencyWorkflow, + /compare\/\$\{base_ref_encoded\}\.\.\.\$\{HEAD_SHA\}/, + 'dependency review never re-resolves a moving base branch after capturing its live SHA', +); +assert.match( + dependencyWorkflow, + /\.merge_base_commit\.sha\s*\|\s*select\(test\("\^\[0-9a-f\]\{40\}\$"\)\)/, + 'dependency review validates the compare API merge-base SHA before publishing it', +); +assert.match( + dependencyWorkflow, + /echo "base_sha=\$BASE_SHA" >>"\$GITHUB_OUTPUT"/, + 'dependency review publishes the validated merge base for the dependency action', +); +assert.match( + dependencyWorkflow, + /base-ref: \$\{\{ steps\.dependency_review_support\.outputs\.base_sha \}\}/, + 'dependency-review-action compares from the validated merge base', +); + +console.log('dependency review merge-base contract passed'); diff --git a/tests/unit/node-runtime-contract.test.mjs b/tests/unit/node-runtime-contract.test.mjs new file mode 100644 index 00000000..81961bac --- /dev/null +++ b/tests/unit/node-runtime-contract.test.mjs @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +const packageJson = JSON.parse( + readFileSync(new URL('../../package.json', import.meta.url), 'utf8'), +); +const databaseSource = readFileSync(new URL('../../server/db.mjs', import.meta.url), 'utf8'); + +assert.match( + databaseSource, + /\bdb\.function\(/, + 'the database bootstrap registers a JavaScript-backed SQLite function', +); +assert.equal( + packageJson.engines?.node, + '^22.13.0 || >=23.5.0', + 'the supported Node range must exclude Node 23.4 because DatabaseSync.function starts at Node 23.5 on the 23.x line', +); + +console.log('✓ Node runtime contract tests passed'); diff --git a/tests/unit/public-https-transport.test.mjs b/tests/unit/public-https-transport.test.mjs new file mode 100644 index 00000000..2b8be29c --- /dev/null +++ b/tests/unit/public-https-transport.test.mjs @@ -0,0 +1,179 @@ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { + PublicHttpsDestinationError, + PublicHttpsTransportError, + configurePublicHttpsTransportForTests, + createPublicHttpsTransport, + fetchPublicHttps, + isPublicInternetAddress, + validatePublicHttpsUrl, +} from '../../server/public_https_transport.mjs'; + +const PUBLIC_A = { address: '93.184.216.34', family: 4 }; +const PUBLIC_B = { address: '93.184.216.35', family: 4 }; + +assert.equal(isPublicInternetAddress('93.184.216.34'), true); +assert.equal(isPublicInternetAddress('127.0.0.1'), false); +assert.equal(isPublicInternetAddress('::1'), false); +assert.equal(isPublicInternetAddress('not-an-ip'), false); +assert.throws(() => validatePublicHttpsUrl('http://example.com'), PublicHttpsDestinationError); +assert.throws(() => validatePublicHttpsUrl('https://localhost/path'), PublicHttpsDestinationError); +assert.throws(() => validatePublicHttpsUrl('https://127.0.0.1/path'), PublicHttpsDestinationError); +assert.equal(validatePublicHttpsUrl('https://example.com/a'), 'https://example.com/a'); + +await assert.rejects( + createPublicHttpsTransport({ + lookup: async () => [PUBLIC_A, { address: '10.0.0.8', family: 4 }], + request: () => { throw new Error('request must not run after mixed DNS'); }, + }).fetch('https://idp.example.test/.well-known/openid-configuration'), + PublicHttpsDestinationError, +); + +const attempts = []; +const transport = createPublicHttpsTransport({ + lookup: async () => [PUBLIC_A, PUBLIC_A, PUBLIC_B], + request: (_url, options, callback) => { + const req = new EventEmitter(); + req.end = () => { + options.lookup('ignored.example', {}, (error, address, family) => { + assert.ifError(error); + attempts.push({ address, family, agent: options.agent, servername: options.servername, method: options.method }); + if (address === PUBLIC_A.address) { + queueMicrotask(() => req.emit('error', new Error('simulated first-address failure'))); + return; + } + const response = new EventEmitter(); + response.statusCode = 200; + response.headers = { 'content-type': 'application/json' }; + response.destroy = () => {}; + callback(response); + queueMicrotask(() => { + response.emit('data', Buffer.from('{"issuer":"https://idp.example.test"}')); + response.emit('end'); + }); + }); + }; + return req; + }, +}); +const response = await transport.fetch('https://idp.example.test/.well-known/openid-configuration'); +assert.deepEqual(await response.json(), { issuer: 'https://idp.example.test' }); +assert.deepEqual(attempts.map(({ address }) => address), [PUBLIC_A.address, PUBLIC_B.address]); +assert.equal(attempts[1].agent, false); +assert.equal(attempts[1].servername, 'idp.example.test'); +assert.equal(attempts[1].method, 'GET'); + +const noContent = createPublicHttpsTransport({ + lookup: async () => [PUBLIC_A], + request: (_url, _options, callback) => { + const req = new EventEmitter(); + req.end = () => { + const upstream = new EventEmitter(); + upstream.statusCode = 204; + upstream.headers = {}; + upstream.destroy = () => {}; + callback(upstream); + queueMicrotask(() => { upstream.emit('data', Buffer.from('ignored')); upstream.emit('end'); }); + }; + return req; + }, +}); +assert.equal(await (await noContent.fetch('https://idp.example.test/empty')).text(), ''); + +let encodedBody; +const formBodyTransport = createPublicHttpsTransport({ + lookup: async () => [PUBLIC_A], + request: (_url, _options, callback) => { + const req = new EventEmitter(); + req.end = (body) => { + encodedBody = body; + const upstream = new EventEmitter(); + upstream.statusCode = 200; + upstream.headers = {}; + callback(upstream); + queueMicrotask(() => upstream.emit('end')); + }; + return req; + }, +}); +await formBodyTransport.fetch('https://idp.example.test/token', { + method: 'POST', + body: new URLSearchParams({ code: 'one-time', redirect_uri: 'https://scopeweave.example/callback' }), +}); +assert.ok(Buffer.isBuffer(encodedBody)); +assert.equal( + encodedBody.toString('utf8'), + 'code=one-time&redirect_uri=https%3A%2F%2Fscopeweave.example%2Fcallback', + 'URLSearchParams bodies are serialized as UTF-8 bytes before transport', +); + +const oversized = createPublicHttpsTransport({ + lookup: async () => [PUBLIC_A], + request: (_url, _options, callback) => { + const req = new EventEmitter(); + req.end = () => { + const upstream = new EventEmitter(); + upstream.statusCode = 200; + upstream.headers = {}; + upstream.destroy = () => {}; + callback(upstream); + queueMicrotask(() => upstream.emit('data', Buffer.alloc(9))); + }; + return req; + }, +}); +await assert.rejects(oversized.fetch('https://idp.example.test/jwks', { maxResponseBytes: 8 }), PublicHttpsTransportError); + +let mutatingAttempts = 0; +const noPostReplay = createPublicHttpsTransport({ + lookup: async () => [PUBLIC_A, PUBLIC_B], + request: (_url, options) => { + mutatingAttempts += 1; + const req = new EventEmitter(); + req.end = () => { + options.lookup('ignored.example', {}, (error) => { + assert.ifError(error); + const socket = new EventEmitter(); + req.emit('socket', socket); + queueMicrotask(() => { + socket.emit('secureConnect'); + queueMicrotask(() => req.emit('error', new Error('closed after TLS'))); + }); + }); + }; + return req; + }, +}); +await assert.rejects(noPostReplay.fetch('https://idp.example.test/token', { + method: 'POST', + headers: { 'content-length': '9999' }, + body: new URLSearchParams({ code: 'one-time' }).toString(), +}), PublicHttpsTransportError); +assert.equal(mutatingAttempts, 1); + +await assert.rejects(transport.fetch('https://idp.example.test/jwks', { maxResponseBytes: 0 }), /positive safe integer/); +assert.throws(() => createPublicHttpsTransport({ lookup: null }), /dependencies must be functions/); + +const controller = new AbortController(); +controller.abort(); +await assert.rejects(transport.fetch('https://idp.example.test/jwks', { signal: controller.signal }), PublicHttpsTransportError); + +delete process.env.NODE_ENV; +assert.throws( + () => configurePublicHttpsTransportForTests({ fetch() {} }), + /test-only/, +); +process.env.NODE_ENV = 'test'; +let injectedCalls = 0; +configurePublicHttpsTransportForTests({ + async fetch(url) { + injectedCalls += 1; + return Response.json({ url: String(url) }); + }, +}); +assert.deepEqual(await (await fetchPublicHttps('https://example.com/test')).json(), { url: 'https://example.com/test' }); +assert.equal(injectedCalls, 1); +assert.throws(() => configurePublicHttpsTransportForTests({}), /must expose fetch/); + +console.log('public HTTPS destination, DNS pinning, replay, and response-bound regressions passed'); diff --git a/tests/unit/rate-limit-observability-failure.test.mjs b/tests/unit/rate-limit-observability-failure.test.mjs new file mode 100644 index 00000000..563d516b --- /dev/null +++ b/tests/unit/rate-limit-observability-failure.test.mjs @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +const { createRateLimitObservability } = await import('../../server/rate_limit.mjs'); + +const hooks = createRateLimitObservability(); +hooks.onBlocked( + { req: { method: 'GET', path: '/api/health' } }, + { startedAt: Date.now() }, +); + +const originalResponse = new Response('{malformed-json', { + status: 200, + headers: { 'content-type': 'application/json' }, +}); +const context = { + req: { + path: '/api/metrics', + query: () => undefined, + }, + res: originalResponse, +}; + +await assert.doesNotReject( + () => hooks.afterNext(context), + 'metrics-folding failures never turn a successful request into an exception', +); +assert.equal( + context.res, + originalResponse, + 'a failed metrics fold leaves the original successful response object in place', +); +assert.equal( + await originalResponse.text(), + '{malformed-json', + 'a failed metrics fold reads only a clone so the original response body remains consumable', +); + +console.log('rate-limit observability failure isolation passed'); diff --git a/tests/unit/rate-limit-scoped-ipv6.test.mjs b/tests/unit/rate-limit-scoped-ipv6.test.mjs new file mode 100644 index 00000000..6fed1f06 --- /dev/null +++ b/tests/unit/rate-limit-scoped-ipv6.test.mjs @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; + +process.env.SCOPEWEAVE_RATE_LIMIT_MAX = '1'; +process.env.SCOPEWEAVE_RATE_LIMIT_WINDOW_MS = '60000'; +process.env.SCOPEWEAVE_RATE_LIMIT_BUCKETS_MAX = '8'; +process.env.SCOPEWEAVE_TRUSTED_PROXY_IPS = 'fe80::1%eth0'; + +const { createRateLimitMiddleware } = await import('../../server/rate_limit.mjs?scoped-ipv6=1'); +const middleware = createRateLimitMiddleware(); + +function context(forwardedFor) { + const state = new Map(); + return { + env: { incoming: { socket: { remoteAddress: 'fe80::1%eth0' } } }, + req: { + method: 'GET', + path: '/probe', + header(name) { return name.toLowerCase() === 'x-forwarded-for' ? forwardedFor : ''; }, + query() { return undefined; }, + }, + get(key) { return state.get(key); }, + set(key, value) { state.set(key, value); }, + json(body, status, headers) { return new Response(JSON.stringify(body), { status, headers }); }, + }; +} + +let admitted = false; +await middleware(context('198.51.100.1'), async () => { admitted = true; }); +assert.equal(admitted, true, 'scoped trusted peer admits its first forwarded client without URL parsing failure'); + +admitted = false; +await middleware(context('198.51.100.2'), async () => { admitted = true; }); +assert.equal(admitted, true, 'the same scoped trusted peer can distinguish a second forwarded client'); + +console.log('scoped IPv6 trusted-peer rate-limit contract passed'); diff --git a/tests/unit/stripe-webhook-boundary.test.mjs b/tests/unit/stripe-webhook-boundary.test.mjs new file mode 100644 index 00000000..c4191958 --- /dev/null +++ b/tests/unit/stripe-webhook-boundary.test.mjs @@ -0,0 +1,207 @@ +import assert from 'node:assert/strict'; +import { createHmac } from 'node:crypto'; +import test from 'node:test'; + +const SECRET = 'whsec_scopeweave_webhook_test_secret'; +const NOW_SECONDS = 1_800_000_000; + +const { StripeWebhookError, verifyStripeWebhookRequest } = await import( + '../../server/stripe_webhook.mjs' +); + +function signatureHeader(bodyBytes, timestamp = NOW_SECONDS, secret = SECRET, extra = '') { + const digest = createHmac('sha256', secret) + .update(String(timestamp)) + .update('.') + .update(bodyBytes) + .digest('hex'); + return `t=${timestamp},v1=${digest}${extra}`; +} + +function webhookRequest(bodyBytes, { + signature = signatureHeader(bodyBytes), + contentLength, +} = {}) { + const headers = new Headers({ + 'content-type': 'application/json', + 'stripe-signature': signature, + }); + if (contentLength !== undefined) headers.set('content-length', String(contentLength)); + return new Request('https://scopeweave.example/api/stripe/webhook', { + method: 'POST', + headers, + body: bodyBytes, + duplex: 'half', + }); +} + +function encoded(value) { + return new TextEncoder().encode(value); +} + +async function expectWebhookError(operation, code, status) { + await assert.rejects(operation, (error) => { + assert.ok(error instanceof StripeWebhookError); + assert.equal(error.code, code); + assert.equal(error.status, status); + return true; + }); +} + +test('verified webhook preserves the exact signed raw body and returns bounded event identity', async () => { + const bytes = encoded('{\n "id":"evt_scopeweave_1",\n "type":"checkout.session.completed",\n "data":{"object":{"client_reference_id":"7"}}\n}\n'); + const event = await verifyStripeWebhookRequest(webhookRequest(bytes), { + secret: SECRET, + nowSeconds: NOW_SECONDS, + }); + + assert.equal(event.id, 'evt_scopeweave_1'); + assert.equal(event.type, 'checkout.session.completed'); + assert.equal(event.data.object.client_reference_id, '7'); +}); + +test('signature verification fails when JSON-equivalent bytes differ from the signed body', async () => { + const signedBytes = encoded('{"id":"evt_raw","type":"checkout.session.completed"}'); + const mutatedBytes = encoded('{ "id": "evt_raw", "type": "checkout.session.completed" }'); + + await expectWebhookError( + () => verifyStripeWebhookRequest(webhookRequest(mutatedBytes, { + signature: signatureHeader(signedBytes), + }), { + secret: SECRET, + nowSeconds: NOW_SECONDS, + }), + 'stripe_webhook_signature_invalid', + 400, + ); +}); + +test('signature verification preserves the literal signed timestamp bytes', async () => { + const bytes = encoded('{"id":"evt_literal_timestamp","type":"invoice.paid"}'); + const literalTimestamp = `0${NOW_SECONDS}`; + const request = webhookRequest(bytes, { + signature: signatureHeader(bytes, literalTimestamp), + }); + + const event = await verifyStripeWebhookRequest(request, { + secret: SECRET, + nowSeconds: NOW_SECONDS, + }); + assert.equal(event.id, 'evt_literal_timestamp'); +}); + +test('signature parser accepts one matching v1 value and rejects malformed, missing, stale, or future signatures', async () => { + const bytes = encoded('{"id":"evt_sig","type":"invoice.paid"}'); + const valid = signatureHeader(bytes); + const validDigest = valid.split('v1=')[1]; + + const multiple = webhookRequest(bytes, { + signature: `t=${NOW_SECONDS},v1=${'0'.repeat(64)},v1=${validDigest}`, + }); + assert.equal((await verifyStripeWebhookRequest(multiple, { + secret: SECRET, + nowSeconds: NOW_SECONDS, + })).id, 'evt_sig'); + + for (const signature of [ + '', + `t=${NOW_SECONDS}`, + `v1=${validDigest}`, + `t=not-a-number,v1=${validDigest}`, + `t=${NOW_SECONDS},v1=xyz`, + signatureHeader(bytes, NOW_SECONDS - 301), + signatureHeader(bytes, NOW_SECONDS + 301), + ]) { + const request = webhookRequest(bytes, { signature }); + await expectWebhookError( + () => verifyStripeWebhookRequest(request, { + secret: SECRET, + nowSeconds: NOW_SECONDS, + }), + 'stripe_webhook_signature_invalid', + 400, + ); + } +}); + +test('body byte ceiling rejects declared and streamed oversize requests before JSON parsing', async () => { + const small = encoded('{"id":"evt_size","type":"invoice.paid"}'); + await expectWebhookError( + () => verifyStripeWebhookRequest(webhookRequest(small, { + contentLength: 262_145, + }), { + secret: SECRET, + nowSeconds: NOW_SECONDS, + }), + 'stripe_webhook_body_too_large', + 413, + ); + + const large = encoded(JSON.stringify({ + id: 'evt_stream_size', + type: 'invoice.paid', + data: 'x'.repeat(262_144), + })); + await expectWebhookError( + () => verifyStripeWebhookRequest(webhookRequest(large), { + secret: SECRET, + nowSeconds: NOW_SECONDS, + }), + 'stripe_webhook_body_too_large', + 413, + ); +}); + +test('invalid content length, payload JSON, event identity, and verifier configuration fail closed', async () => { + const validBytes = encoded('{"id":"evt_valid","type":"invoice.paid"}'); + + for (const contentLength of ['-1', 'NaN', '1.5', '999999999999999999999999']) { + await expectWebhookError( + () => verifyStripeWebhookRequest(webhookRequest(validBytes, { contentLength }), { + secret: SECRET, + nowSeconds: NOW_SECONDS, + }), + 'stripe_webhook_request_invalid', + 400, + ); + } + + const malformed = encoded('{"id":'); + await expectWebhookError( + () => verifyStripeWebhookRequest(webhookRequest(malformed), { + secret: SECRET, + nowSeconds: NOW_SECONDS, + }), + 'stripe_webhook_payload_invalid', + 400, + ); + + for (const value of [ + null, + [], + {}, + { id: '', type: 'invoice.paid' }, + { id: 'evt_ok', type: '' }, + { id: 'x'.repeat(256), type: 'invoice.paid' }, + { id: 'evt_ok', type: 'x'.repeat(256) }, + ]) { + const bytes = encoded(JSON.stringify(value)); + await expectWebhookError( + () => verifyStripeWebhookRequest(webhookRequest(bytes), { + secret: SECRET, + nowSeconds: NOW_SECONDS, + }), + 'stripe_webhook_payload_invalid', + 400, + ); + } + + await expectWebhookError( + () => verifyStripeWebhookRequest(webhookRequest(validBytes), { + secret: ' ', + nowSeconds: NOW_SECONDS, + }), + 'stripe_webhook_not_configured', + 503, + ); +}); diff --git a/tests/unit/toast-accessibility.test.mjs b/tests/unit/toast-accessibility.test.mjs index c0aa79a0..b954b7f7 100644 --- a/tests/unit/toast-accessibility.test.mjs +++ b/tests/unit/toast-accessibility.test.mjs @@ -1,5 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; import { readFileSync } from 'node:fs'; const indexHtml = readFileSync(new URL('../../index.html', import.meta.url), 'utf8'); @@ -35,11 +36,37 @@ 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 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'); + const child = spawnSync(process.execPath, ['--input-type=module', '--eval', ` + process.env.SCOPEWEAVE_DB = ':memory:'; + delete process.env.SCOPEWEAVE_DEV; + process.env.SCOPEWEAVE_PUBLIC_ORIGIN = 'https://scopeweave.example'; + process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; + process.env.STRIPE_SECRET_KEY = 'sk_test_scopeweave_toast'; + process.env.STRIPE_PRICE_ID = 'price_scopeweave_toast'; + process.env.STRIPE_WEBHOOK_SECRET = 'whsec_scopeweave_toast_secret'; + const { app } = await import('./server/app.mjs?toast-route-contract=2'); + const response = await app.request('https://scopeweave.example/toast-state.css'); + const body = await response.text(); + process.stdout.write(JSON.stringify({ + status: response.status, + contentType: response.headers.get('content-type'), + hasVisibleState: /\\.toast\\.visible\\s*\\{/.test(body), + })); + `], { + cwd: process.cwd(), + encoding: 'utf8', + }); + + assert.equal(child.status, 0, child.stderr); + const resultLine = child.stdout.trim().split('\n').at(-1); + assert.deepEqual( + JSON.parse(resultLine), + { status: 200, contentType: 'text/css; charset=utf-8', hasVisibleState: true }, + 'SaaS wildcard static route serves the shipped 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');