bridge-index-background.mjs
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd
feat(calendar): enforce agent context tiers in retrieval AP…
Human
minor
⚠ breaking
17 hours ago
| 1 | /** |
| 2 | * Netlify Background Function: long-running re-index path for the bridge. |
| 3 | * |
| 4 | * The `-background` filename suffix is what tells Netlify to give this function |
| 5 | * the 15-minute timeout (vs 60 s for synchronous functions like `bridge.mjs`) |
| 6 | * and to return 202 to the caller within ~50 ms regardless of how long the |
| 7 | * actual work takes (docs.netlify.com/build/functions/background-functions). |
| 8 | * |
| 9 | * How a re-index reaches this function: |
| 10 | * 1. Browser → `POST /api/v1/index` (proxied to `netlify/functions/bridge.mjs`). |
| 11 | * 2. Sync handler runs preflight (`lib/bridge-index-preflight-estimate.mjs`) |
| 12 | * and decides this work won't fit in 60 s. |
| 13 | * 3. Sync handler `acquireJobLock` + HTTP-POSTs to THIS endpoint with HMAC |
| 14 | * headers (signed by `lib/bridge-internal-hmac.mjs`). |
| 15 | * 4. THIS function validates the HMAC, mounts the same Express app from |
| 16 | * `hub/bridge/server.mjs` (which contains the `POST /api/v1/index` route), |
| 17 | * sets `globalThis.__bridge_internal_request` so the route SKIPS the |
| 18 | * routing decision and runs inline, then invokes the route via |
| 19 | * `serverless-http`. |
| 20 | * 5. After the route finishes (success OR error), the route's own code |
| 21 | * releases the job lock and writes `setLastIndexedAt`. |
| 22 | * |
| 23 | * Auth (defense in depth): |
| 24 | * - HMAC over `(canisterUid, vaultId, jobId, ts)` signed with `SESSION_SECRET` |
| 25 | * proves the request came from the bridge sync handler. |
| 26 | * - JWT in `Authorization: Bearer …` is forwarded verbatim from the original |
| 27 | * browser request, so `requireBridgeAuth` still requires a real user. |
| 28 | * - This function refuses any path other than `POST /api/v1/index` so the |
| 29 | * blast radius of a future HMAC bypass would be limited to the same route |
| 30 | * a forged sync request could already hit. |
| 31 | */ |
| 32 | |
| 33 | import serverless from 'serverless-http'; |
| 34 | import { connectLambda, getStore } from '@netlify/blobs'; |
| 35 | import { app } from '../../hub/bridge/server.mjs'; |
| 36 | import { verifyInternalRequest } from '../../lib/bridge-internal-hmac.mjs'; |
| 37 | |
| 38 | /** |
| 39 | * Reject anything other than `POST` to the index route. Defense-in-depth: the |
| 40 | * HMAC + JWT already gate the request, but this is one more belt so a future |
| 41 | * routing mistake cannot accidentally expose another endpoint. |
| 42 | */ |
| 43 | function isAllowedRoute(httpMethod, path) { |
| 44 | if (httpMethod !== 'POST') return false; |
| 45 | if (typeof path !== 'string') return false; |
| 46 | // Netlify forwards the original request path, possibly prefixed with the |
| 47 | // function path. Accept any of: `/api/v1/index`, `/.netlify/functions/bridge-index-background`, |
| 48 | // `/.netlify/functions/bridge-index-background/api/v1/index`. |
| 49 | if (path === '/api/v1/index') return true; |
| 50 | if (path === '/.netlify/functions/bridge-index-background') return true; |
| 51 | if (path === '/.netlify/functions/bridge-index-background/') return true; |
| 52 | if (path === '/.netlify/functions/bridge-index-background/api/v1/index') return true; |
| 53 | return false; |
| 54 | } |
| 55 | |
| 56 | export const handler = async (event, context) => { |
| 57 | const headers = (event && event.headers) || {}; |
| 58 | const httpMethod = (event && event.httpMethod) || 'POST'; |
| 59 | const eventPath = (event && (event.path || event.rawPath)) || ''; |
| 60 | |
| 61 | // 1. Route guard. |
| 62 | if (!isAllowedRoute(httpMethod, eventPath)) { |
| 63 | return { |
| 64 | statusCode: 404, |
| 65 | headers: { 'content-type': 'application/json' }, |
| 66 | body: JSON.stringify({ error: 'Not found' }), |
| 67 | }; |
| 68 | } |
| 69 | |
| 70 | // 2. HMAC validation. The shared secret must match what the sync function |
| 71 | // (running with the SAME `SESSION_SECRET` env var) used to sign the request. |
| 72 | // `HUB_JWT_SECRET` is checked as a legacy fallback to match `userIdFromJwt` |
| 73 | // in `hub/bridge/server.mjs`. |
| 74 | const secret = process.env.SESSION_SECRET || process.env.HUB_JWT_SECRET || ''; |
| 75 | const verified = verifyInternalRequest(secret, { |
| 76 | canisterUid: headers['x-bridge-internal-uid'], |
| 77 | vaultId: headers['x-bridge-internal-vault-id'], |
| 78 | jobId: headers['x-bridge-internal-job-id'], |
| 79 | ts: headers['x-bridge-internal-ts'], |
| 80 | sig: headers['x-bridge-internal-sig'], |
| 81 | }); |
| 82 | if (!verified.ok) { |
| 83 | console.warn('[bridge-index-background] HMAC verification failed:', verified.reason); |
| 84 | return { |
| 85 | statusCode: 401, |
| 86 | headers: { 'content-type': 'application/json' }, |
| 87 | body: JSON.stringify({ error: 'Unauthorized internal request', reason: verified.reason }), |
| 88 | }; |
| 89 | } |
| 90 | |
| 91 | // 3. Set up the Netlify Blob store the same way `bridge.mjs` does. Eventual |
| 92 | // consistency is fine for the background path since the lock + sidecar |
| 93 | // blobs are read by the next user-triggered request, not by the caller of |
| 94 | // this background function. |
| 95 | connectLambda(event); |
| 96 | const consistency = |
| 97 | String(process.env.NETLIFY_BLOBS_CONSISTENCY || '') |
| 98 | .trim() |
| 99 | .toLowerCase() === 'strong' |
| 100 | ? 'strong' |
| 101 | : 'eventual'; |
| 102 | globalThis.__netlify_blob_store = getStore({ name: 'bridge-data', consistency }); |
| 103 | |
| 104 | // 4. Set the internal-request marker that `hub/bridge/server.mjs` reads via |
| 105 | // `req.bridgeInternalRequest`. This is what tells the index handler to |
| 106 | // SKIP its routing decision and execute inline regardless of size. |
| 107 | globalThis.__bridge_internal_request = { |
| 108 | canisterUid: verified.payload.canisterUid, |
| 109 | vaultId: verified.payload.vaultId, |
| 110 | jobId: verified.payload.jobId, |
| 111 | }; |
| 112 | |
| 113 | try { |
| 114 | // Force the path the express app sees to `/api/v1/index` so the route resolver |
| 115 | // hits the right handler regardless of how Netlify rewrote the URL. |
| 116 | const routedEvent = { ...event, path: '/api/v1/index', rawUrl: '/api/v1/index' }; |
| 117 | return await serverless(app)(routedEvent, context); |
| 118 | } finally { |
| 119 | delete globalThis.__netlify_blob_store; |
| 120 | delete globalThis.__bridge_internal_request; |
| 121 | } |
| 122 | }; |
File History
2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd
feat(calendar): enforce agent context tiers in retrieval AP…
Human
minor
⚠
17 hours ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6
docs: accept Calendar Events v0 spec with Phase 0 security …
Human
23 hours ago