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, andapplebooleans (apple: truewhenAPPLE_CLIENT_IDis 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/nativePKCE). 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 }. Noktn_refreshcookie on this route. Fail-closed codes:BAD_REQUEST,APPLE_ASSERTION_INVALID,OAUTH_DISABLED,NOT_CONFIGURED,APPLE_JWKS_UNAVAILABLE. Seehub/gateway/apple-identity-token.mjsanddocs/KN-APPLE-NATIVE-HOSTED-EXCHANGE-FREEZE.md. - POST /api/v1/auth/refresh — Exchange the
ktn_refreshHttpOnly cookie for a freshaccess_tokenBearer JWT. Rotates the refresh token (reuse detection + family revocation). Seehub/gateway/refresh-token-store.mjs. - POST /api/v1/auth/establish-refresh — Exchange a valid human
type:sessionBearer JWT for a durable refresh record. Browser (request hasOrigin): HttpOnly cookie only; JSON body is{ schema_version: 1, established: true }and never includesrefresh_token(even ifAcceptasks for it). Origin must be onHUB_CORS_ORIGIN(or equalHUB_BASE_URLorigin when that list is empty). CLI (noOrigin): requiresAccept: application/vnd.knowtation.refresh-token+jsonelse 406; success returns{ schema_version: 1, refresh_token, token_type: "refresh" }and must notSet-Cookie. Used when OAuth redirect cannot attachSet-Cookie(Netlify); Hub UI calls the browser mode after login;scripts/hub-session-refresh.mjs --save-access-tokenuses the CLI media type viascripts/lib/hub-session-auth.mjs. - POST /api/v1/auth/logout — Revoke the
ktn_refreshcookie 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=1andSCOOLING_NOTE_OUTLINE_SMOKE_ENV=local|staging. The gateway owns the upstream bearer token, rejects request credentials from Scooling, validates the upstream body-freeNoteOutline, and returns only theknowtation.note_outline/v1JSON contract. - POST /scooling/write-back/smoke — Staging-only, metadata-only Scooling write-back target smoke check. Disabled unless
SCOOLING_WRITE_BACK_SMOKE_ENABLED=1andSCOOLING_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/loginfor 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-*.mjsand 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, andfoldersfrom 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, optionaldelete_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/approveand…/discardare 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, elseHUB_EVALUATOR_MAY_APPROVE=1fallback 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_HINTSor admin-saved prefs inhub/gateway/proposal-llm-store.mjs/ Netlify Blob; seePOST /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.mjsplus rootnetlify.toml. The build runsscripts/netlify-redirects.mjsto generatepublic/_redirects(per-site: gateway vs bridge is controlled byUSE_BRIDGE_FUNCTIONon the bridge site only). Do not add a catch-all[[redirects]]in rootnetlify.tomlwhen using a second Netlify site for the bridge — seedeploy/bridgepackaging andhub/bridge/README.md(Netlify Blobs). - Local / generic Node: Build is not required when running
npm startas 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)
CORS (Hub UI on knowtation.store / www): From the repo root, run
npm run check:gateway-cors. Each listed origin should get a specificAllow-OriginandAllow-Credentials: true. If not, setHUB_CORS_ORIGINon this gateway site to both apex and www (hub/gateway/cors-middleware.mjs), then redeploy.Hosted “Back up now”:
POST /api/v1/vault/synctriggers a CORS preflight (OPTIONS). The gateway must answerOPTIONSwith 204 on that path; it must not forward preflight to the bridge (the bridge only implementsPOST). If preflight fails, the Hub shows Could not reach the API even whenGET /api/v1/settingsworks.BRIDGE_URL: Must be the bridge origin only — full URL withhttps://, 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.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-statusand Connect GitHub. Mismatch → Settings shows “Not connected” after OAuth; see docs/CONNECT-GITHUB-AND-STORAGE-CHECK.md §6.SESSION_SECRET_PREVIOUS(rotation window only): Verify-only fallback secret used byhub/lib/session-secret-rotation.mjsduring a zero-downtimeSESSION_SECRETrotation (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. Setprevious ← OLDandprimary ← NEWon gateway + bridge + MCP host in lockstep, wait out the drain window (floor: web-session default24hviaHUB_JWT_EXPIRYunless a shorter expiry is proven on all three hosts — plus Phase C exchange 900s and MCP OAuth access TTL), then unsetSESSION_SECRET_PREVIOUSeverywhere. 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)