README.md markdown
153 lines 20.2 KB
Raw
sha256:da72236a2c7a121dcdc60fcca47a8ff86cc1176f6076d8d4cbc0614569737d33 fix(gateway): mount OAuth routers before API fallback Human minor ⚠ breaking 26 days ago

Knowtation Hub Gateway

OAuth (Google/GitHub) + proxy for the hosted product. Users log in here; the gateway proxies all /api/v1/* requests to the ICP canister with an X-User-Id header (proof the canister trusts). Trust model: JWT sub → x-user-id on canister requests — see docs/HUB-API.md (auth). For pre-merge verification of bridge-backed POST /api/v1/import (including 4C drop) when BRIDGE_URL is set, see docs/IMPORT-URL-AND-DOCUMENTS-PHASES.md §4V.

Routes

  • GET /health, GET /api/v1/health — Health (no auth).
  • GET /api/v1/auth/providers — Which OAuth providers are configured (no auth). Response includes google, github, and apple booleans (apple: true when APPLE_CLIENT_ID is set and not offline-locked).
  • GET /api/v1/auth/session — C7 Session introspection. Bearer JWT required. Returns verified identity + API scopes: { sub, provider, id, name, role, iat, exp, scopes }. Safe for cross-origin callers (Scooling, external services). Response is derived from the signed token only — no DB call. Scopes are role-derived (admin → [vault:read, vault:write, admin]; member → [vault:read, vault:write]); explicit per-user scope management is C4. Apple sessions from native exchange introspect here unchanged (provider: "apple", sub: "apple:<sub>").
  • POST /api/v1/auth/native-apple-exchange — Sign in with Apple identity-assertion exchange (not Passport Google/GitHub; not api/v1/auth/native PKCE). Body: { identity_token, nonce?, full_name? }. Verifies Apple JWKS server-side, mints hosted session JWT (type: "session", provider: "apple"). Success: { schema_version: 1, token_type: "Bearer", access_token, expires_in }. No ktn_refresh cookie on this route. Fail-closed codes: BAD_REQUEST, APPLE_ASSERTION_INVALID, OAUTH_DISABLED, NOT_CONFIGURED, APPLE_JWKS_UNAVAILABLE. See hub/gateway/apple-identity-token.mjs and docs/KN-APPLE-NATIVE-HOSTED-EXCHANGE-FREEZE.md.
  • POST /api/v1/auth/refresh — Exchange the ktn_refresh HttpOnly cookie for a fresh access_token Bearer JWT. Rotates the refresh token (reuse detection + family revocation). See hub/gateway/refresh-token-store.mjs.
  • POST /api/v1/auth/establish-refresh — Exchange a valid human type:session Bearer JWT for a durable refresh record. Browser (request has Origin): HttpOnly cookie only; JSON body is { schema_version: 1, established: true } and never includes refresh_token (even if Accept asks for it). Origin must be on HUB_CORS_ORIGIN (or equal HUB_BASE_URL origin when that list is empty). CLI (no Origin): requires Accept: application/vnd.knowtation.refresh-token+json else 406; success returns { schema_version: 1, refresh_token, token_type: "refresh" } and must not Set-Cookie. Used when OAuth redirect cannot attach Set-Cookie (Netlify); Hub UI calls the browser mode after login; scripts/hub-session-refresh.mjs --save-access-token uses the CLI media type via scripts/lib/hub-session-auth.mjs.
  • POST /api/v1/auth/logout — Revoke the ktn_refresh cookie server-side (burns the token family) and clear the cookie.
  • GET /scooling/note-outline/smoke?path=... — Local/staging-only Scooling NoteOutline smoke bridge. Disabled unless SCOOLING_NOTE_OUTLINE_SMOKE_ENABLED=1 and SCOOLING_NOTE_OUTLINE_SMOKE_ENV=local|staging. The gateway owns the upstream bearer token, rejects request credentials from Scooling, validates the upstream body-free NoteOutline, and returns only the knowtation.note_outline/v1 JSON contract.
  • POST /scooling/write-back/smoke — Staging-only, metadata-only Scooling write-back target smoke check. Disabled unless SCOOLING_WRITE_BACK_SMOKE_ENABLED=1 and SCOOLING_WRITE_BACK_SMOKE_ENV=staging. It checks canister metadata availability and returns dry-run capability flags only; it never accepts raw credentials or performs live writes.
  • GET /auth/login?provider=google|github — Redirect to OAuth (plan routes).
  • GET /api/v1/auth/login?provider=... — Redirects to /auth/login for Hub UI compatibility.
  • GET /auth/callback/google, GET /auth/callback/github — OAuth callbacks; on success redirect to HUB_UI_ORIGIN/?token=<jwt>.
  • GET /api/v1/billing/summary — JWT. Hosted billing pools (tier, monthly/add-on cents). See hub/gateway/billing-*.mjs and docs/TOKEN-SAVINGS.md (billing hooks).
  • POST /api/v1/billing/webhook — Stripe webhook (raw JSON body). No JWT.
  • GET /api/v1/notes/facets — JWT + X-Vault-Id. Aggregates projects, tags, and folders from the canister note list (hub/gateway/note-facets.mjs); not proxied as a literal canister path.
  • POST /api/v1/notes/delete-by-project, POST /api/v1/notes/rename-project — JWT + X-Vault-Id; editor/admin/member (not viewer). Gateway orchestrates canister list/delete/write + proposal discards (hub/gateway/metadata-bulk-canister.mjs). The Hub static bundle must include PR #65 (web/hub/hub.js) so Settings on hosted actually calls these routes. See HUB-METADATA-BULK-OPS.md.
  • POST /api/v1/notes/copy — JWT; editor/admin (not viewer). Copy or move one note between vaults (from_vault_id, to_vault_id, path, optional delete_source). Gateway-only orchestration (GET → POST → optional DELETE on canister); then bridge re-index for affected vaults. Hub UI: note detail Copy to vault…. Contract: HUB-API.md §3.3.
  • GET/POST/DELETE /api/v1/* (other) — Proxied to canister with X-User-Id from JWT (e.g. DELETE /api/v1/notes/:path removes a note). Returns 401 if no valid token. When BILLING_ENFORCE is on, some routes may return 402 (quota).
  • POST /api/v1/roles/evaluator-may-approve — JWT + admin (via requireAdmin). Proxied to bridge when BRIDGE_URL is set (per-evaluator approve flag); same origin as other Team routes.
  • Proposal approve/discard: POST /api/v1/proposals/:id/approve and …/discard are checked on the gateway using the actor JWT and bridge role + may_approve_proposals (from GET /api/v1/role / hosted-context). The canister uses the effective workspace user id and does not distinguish actor roles; gateway enforcement is required for correct RBAC. Discard requires admin. Approve requires admin or evaluator with permission (per-user in Team / bridge blob, else HUB_EVALUATOR_MAY_APPROVE=1 fallback when no per-user entry).
  • LLM proposal helpers: Review hints run on this gateway after proposal create when hints are enabled (KNOWTATION_HUB_PROPOSAL_REVIEW_HINTS or admin-saved prefs in hub/gateway/proposal-llm-store.mjs / Netlify Blob; see POST /api/v1/settings/proposal-policy). Enrich runs here when enabled (same pattern; POST /api/v1/proposals/:id/enrich). Implementation: proposal-review-hints-async.mjs, proposal-enrich-hosted.mjs. Deploy the hub canister from this repo first (stable migration V4 adds enrich fields). See docs/HUB-PROPOSAL-LLM-FEATURES.md.

Canister proxy URL (important)

The canister proxy runs under app.use('/api/v1', …). Express sets req.baseUrl + req.path to the full API path (e.g. /api/v1/notes); req.originalUrl alone can be wrong under Netlify/serverless-http. See hub/gateway/request-path.mjs (effectiveRequestPath, upstreamPathAndQuery).

When the gateway re-serializes the JSON body (e.g. provenance merge), it removes the incoming Content-Length, Transfer-Encoding, and Content-Encoding before fetch to the canister. Keeping the client’s Content-Length (from the shorter pre-merge body) can cause Undici to hang or mis-handle the write relative to the new body, so create/save appears to do nothing; the canister may also see truncated or invalid JSON.

Environment

Variable Required Description
CANISTER_URL Yes Canister HTTP URL (e.g. https://<canister-id>.ic0.app).
SESSION_SECRET or HUB_JWT_SECRET Yes Secret to sign JWTs.
HUB_BASE_URL Yes (prod) Public URL of this gateway (for OAuth callback). E.g. https://knowtation.store if gateway is same origin.
HUB_UI_ORIGIN No Origin of the Hub UI (for post-login redirect). Defaults to HUB_BASE_URL. E.g. https://knowtation.store.
BRIDGE_URL No URL of the Hub Bridge (for Connect GitHub + Back up now). Must be a full URL with http:// or https:// and the bridge origin only: e.g. https://knowtation-bridge.netlify.app — no trailing slash, no path (no /api/... or /auth/...). A host-only value (no scheme) fails at gateway startup. When set, gateway redirects/proxies /api/v1/auth/github-connect and /api/v1/vault/* to the bridge so the UI can use one origin.
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET No Google OAuth (enables "Continue with Google").
GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET No GitHub OAuth (enables "Continue with GitHub").
APPLE_CLIENT_ID No Sign in with Apple — expected aud for POST /api/v1/auth/native-apple-exchange (native Bundle ID or Services ID). When set, GET /api/v1/auth/providers advertises apple: true. Never commit real values.
GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET No GitHub OAuth (enables "Continue with GitHub").
GATEWAY_PORT or PORT No Port (default 3340).
SCOOLING_NOTE_OUTLINE_SMOKE_ENABLED No Set to 1 only for local/staging structural UX smoke validation.
SCOOLING_NOTE_OUTLINE_SMOKE_ENV No Must be local or staging for GET /scooling/note-outline/smoke to answer; any other value returns 404.
SCOOLING_NOTE_OUTLINE_SMOKE_UPSTREAM No Full HTTP(S) URL for the auth-gated upstream GET /api/v1/note-outline endpoint. Must not include credentials, query string, or fragment.
SCOOLING_NOTE_OUTLINE_SMOKE_BEARER_TOKEN No Bearer token held only by the gateway bridge for the upstream NoteOutline request. Scooling must not receive, send, store, or log this token.
SCOOLING_WRITE_BACK_SMOKE_ENABLED No Set to 1 only on staging to expose POST /scooling/write-back/smoke for Scooling target validation.
SCOOLING_WRITE_BACK_SMOKE_ENV No Must be staging for the Scooling smoke endpoint to answer; any other value returns 404.
HUB_CORS_ORIGIN Yes (prod) if Hub UI is on another origin Comma-separated origins, e.g. https://knowtation.store,https://www.knowtation.store. Required for credentialed CORS responses — see hub/gateway/cors-middleware.mjs.
HUB_JWT_EXPIRY No JWT expiry for issued API tokens (gateway default in code is 24h unless overridden; match docs/AGENT-INTEGRATION.md).
HUB_ADMIN_USER_IDS No Comma-separated user IDs (e.g. google:123,github:456) who get role admin in the JWT and pass requireAdmin without a bridge call. When BRIDGE_URL is set, Team admins (bridge GET /api/v1/role → role: admin) also pass requireAdmin for gateway-only admin routes (e.g. POST /api/v1/settings/proposal-policy, workspace, vault-access, invites). Bootstrap operators often list at least one id here; further admins can be granted in Team without redeploying env. See docs/PARITY-MATRIX-HOSTED.md (hosted admin flows).
HUB_EVALUATOR_MAY_APPROVE No Set to 1 so evaluators may approve when they have no explicit row in the bridge blob hub_evaluator_may_approve (per-user overrides still win). Gateway and bridge honor this; GET /api/v1/settings exposes effective hub_evaluator_may_approve per user.
BILLING_ENFORCE No Set to true to deduct credits and return 402 when monthly + add-on pools are exhausted (default off = beta open usage).
BILLING_SHADOW_LOG No Set to true or 1 to emit structured JSON (type: knowtation_billing_shadow) per billable operation for usage research (works even when enforcement is off).
STRIPE_SECRET_KEY No Stripe API key for webhooks and (future) Checkout sessions.
STRIPE_WEBHOOK_SECRET No Signing secret for POST /api/v1/billing/webhook.
STRIPE_PRICE_STARTER, STRIPE_PRICE_PRO, STRIPE_PRICE_TEAM No Stripe Price ids for subscription tiers → included credits/month.
STRIPE_PRICE_PACK_10, STRIPE_PRICE_PACK_25, STRIPE_PRICE_PACK_50 No Stripe Price ids for add-on packs (10 / 25 / 50 credits).

Hub static UI (web/hub/config.js, separate from this server’s env): The browser bundle may set window.HUB_MCP_PUBLIC_URL to the public URL of a persistent gateway that serves POST /mcp (e.g. https://mcp.example.com/mcp), so Settings → Integrations → Copy Hub URL, token & vault can emit KNOWTATION_MCP_URL next to KNOWTATION_HUB_URL. The Netlify site for the REST API does not run stateful MCP; operators who split API (Netlify) and MCP (VPS) document both URLs in that copy block. See docs/AGENT-INTEGRATION.md §2–3.

Billing storage: Local file data/hosted_billing.json (gitignored with data/). On Netlify, the gateway function uses Blob store gateway-billing (see netlify/functions/gateway.mjs).

Checkout metadata: Subscription and pack Checkout Sessions should include metadata.user_id (Hub JWT sub). Pack sessions should include metadata.credits_cents (e.g. 1000 for $10) or use a mapped Price id above.

Google OAuth — redirect URI (fixes redirect_uri_mismatch)

Passport uses callbackURL = HUB_BASE_URL + '/auth/callback/google' (not /api/v1/...). The full Node Hub under hub/server.mjs uses /api/v1/auth/callback/google; the gateway does not.

In Google Cloud Console → OAuth client → Authorized redirect URIs, add the URI that matches HUB_BASE_URL:

If you run gateway on Set HUB_BASE_URL Add this exact URI in Google
Default (3340) http://localhost:3340 http://localhost:3340/auth/callback/google
3333 http://localhost:3333 http://localhost:3333/auth/callback/google

Production: https://YOUR-GATEWAY-URL/auth/callback/google (no trailing slash).

If Google only has http://localhost:3333/api/v1/auth/callback/google (full Hub) and you log in via the gateway, the request fails with Error 400: redirect_uri_mismatch — add the /auth/callback/google URI for the port you use.

Run locally

cd hub/gateway
npm install
export CANISTER_URL=https://<canister-id>.ic0.app
export SESSION_SECRET=your-secret
export HUB_BASE_URL=http://localhost:3340
export GOOGLE_CLIENT_ID=... GOOGLE_CLIENT_SECRET=...
# optional: GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET
npm start

Point the Hub UI at the same origin as HUB_BASE_URL (e.g. window.HUB_API_BASE_URL = 'http://localhost:3340'). Login will redirect to Google/GitHub and back; then all API calls go through the gateway to the canister with X-User-Id.

Deploy (e.g. Netlify)

  • This repo: Production path is netlify/functions/gateway.mjs plus root netlify.toml. The build runs scripts/netlify-redirects.mjs to generate public/_redirects (per-site: gateway vs bridge is controlled by USE_BRIDGE_FUNCTION on the bridge site only). Do not add a catch-all [[redirects]] in root netlify.toml when using a second Netlify site for the bridge — see deploy/bridge packaging and hub/bridge/README.md (Netlify Blobs).
  • Local / generic Node: Build is not required when running npm start as a normal server.
  • For other hosts, use a Node adapter or deploy the Express app as you would any Node service; set HUB_BASE_URL and HUB_UI_ORIGIN to production URLs.
  • Ensure CANISTER_URL points to the deployed canister and SESSION_SECRET is set in env (no secrets in repo).

Persistent gateway startup and OAuth routing

server.mjs imports the existing OAuth and MCP router factories statically and constructs enabled routers before registering the /api/v1 proxy fallback or opening the listener. Native discovery lives at /api/v1/auth/native/.well-known/oauth-authorization-server; device discovery lives at /api/v1/auth/device/.well-known/oauth-authorization-server. Both are public when durable agent authentication is enabled. Root MCP OAuth discovery remains separate. Netlify and offline-locked mode keep durable OAuth disabled.

Do not move these mounts into unawaited dynamic imports: Express preserves registration order, so the proxy fallback would return 401 UNAUTHORIZED before either router runs, even after the imports finish. Required router import or construction errors now stop startup instead of leaving a partially functional gateway. Expired-code pruning remains best effort.

Run node --test test/gateway-oauth-bootstrap.test.mjs from the repository root to check the actual startup boundary, public discovery, native/device flows, MCP sessions, disabled modes, and injected startup failures. This test uses generated credentials, temporary stores, and loopback mock upstreams; it disables local .env loading.

For a production rollout, first inspect the running revision, PM2 process, application directory, nginx routing, and installed dependencies without dumping environment values. Compare the deployed source with the tested artifact before copying it. Preserve the previous code for rollback, retain durable authentication data, and restart only the gateway process after operator approval. No dependency or data migration is required by the route-ordering repair. A restart disconnects in-memory MCP sessions and registrations; durable refresh records remain on disk. Recheck discovery and an authorized MCP session after restarting, and restore the previous code and restart that same process if checks fail. Treat CORS configuration changes as a separate rollout item.

Post-deploy verification (GitHub backup + CORS)

  1. CORS (Hub UI on knowtation.store / www): From the repo root, run npm run check:gateway-cors. Each listed origin should get a specific Allow-Origin and Allow-Credentials: true. If not, set HUB_CORS_ORIGIN on this gateway site to both apex and www (hub/gateway/cors-middleware.mjs), then redeploy.

    Hosted “Back up now”: POST /api/v1/vault/sync triggers a CORS preflight (OPTIONS). The gateway must answer OPTIONS with 204 on that path; it must not forward preflight to the bridge (the bridge only implements POST). If preflight fails, the Hub shows Could not reach the API even when GET /api/v1/settings works.

  2. BRIDGE_URL: Must be the bridge origin only — full URL with https://, no path (e.g. https://knowtation-bridge.netlify.app). Wrong values produce malformed redirect URLs; see docs/CONNECT-GITHUB-AND-STORAGE-CHECK.md §2–3.

  3. SESSION_SECRET / HUB_JWT_SECRET: The bridge site (and the persistent MCP host) must use the same primary secret as this gateway so JWTs verify on /api/v1/vault/github-status and Connect GitHub. Mismatch → Settings shows “Not connected” after OAuth; see docs/CONNECT-GITHUB-AND-STORAGE-CHECK.md §6.

  4. SESSION_SECRET_PREVIOUS (rotation window only): Verify-only fallback secret used by hub/lib/session-secret-rotation.mjs during a zero-downtime SESSION_SECRET rotation (SEC-KN-P6-ROTATE runbook, docs/SEC-KN-P6-ROTATE-FREEZE.md §6). It is never used to sign JWTs, HMACs, or GitHub-token encryption. Set previous ← OLD and primary ← NEW on gateway + bridge + MCP host in lockstep, wait out the drain window (floor: web-session default 24h via HUB_JWT_EXPIRY unless a shorter expiry is proven on all three hosts — plus Phase C exchange 900s and MCP OAuth access TTL), then unset SESSION_SECRET_PREVIOUS everywhere. After the rotation closes, stored GitHub tokens encrypted under OLD may require a one-time Connect GitHub re-connect.

Reference

  • HUB-API.md — API contract (auth, proposals, vault headers)
File History 1 commit
sha256:da72236a2c7a121dcdc60fcca47a8ff86cc1176f6076d8d4cbc0614569737d33 fix(gateway): mount OAuth routers before API fallback Human minor ⚠ 26 days ago