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');