diff --git a/docs/OPS_SIGNER_PLAN.md b/docs/OPS_SIGNER_PLAN.md index 2b51bc3..50a88b1 100644 --- a/docs/OPS_SIGNER_PLAN.md +++ b/docs/OPS_SIGNER_PLAN.md @@ -17,12 +17,11 @@ The page must never accept arbitrary calldata from query params, a mutable datab ## Initial Route - Add `src/pages/ops/index.jsx`. -- Add `netlify/edge-functions/ops-access.js`. - Use the existing app providers: Next.js, Wagmi, RainbowKit, viem, and the configured Gnosis Chain transport. - Rabby should work as an injected wallet through RainbowKit/Wagmi. - Require Gnosis Chain (`chainId: 100`) for the initial action set. - Do not link this route from regular app navigation. -- Protect the route at the Netlify edge because the app is statically exported. +- Keep the route out of normal app navigation and mark it `noindex,nofollow`. ## Repository Layout @@ -34,7 +33,6 @@ Suggested files: - `src/ops/actionTypes/snapshotLinkUpsert.js` - `src/ops/components/OpsActionCard.jsx` - `src/pages/ops/index.jsx` -- `netlify/edge-functions/ops-access.js` Keep action builders and validation logic in the repo. Keep active action manifests in the repo for v0. @@ -131,24 +129,16 @@ Optional hardening: - Add an action checksum to the UI for copy-review. - Add a manual "I reviewed this transaction" checkbox for owner-only actions. -## Access Gate +## Route Exposure -The page should be hidden from regular app flow and regular URL access. Because `futarchy.fi` is built as a static -Next export, this gate lives in a Netlify Edge Function rather than `getServerSideProps`. +The page should be hidden from the regular app flow but should load directly at `/ops`. Production behavior: -- `OPS_SIGNER_ACCESS_KEY` must be set in the deployment environment. -- `/ops` without access returns 404. -- `/ops?access=` sets an HttpOnly, Secure, SameSite=Strict, HMAC-signed cookie scoped to `/ops`, then redirects to `/ops`. -- The cookie is checked by the Netlify Edge Function on later visits. -- The access key is not embedded into the client bundle. -- The page remains unlinked from normal navigation and is marked `noindex,nofollow`. - -Development behavior: - -- `next dev` serves the static page directly for local UI work. -- `netlify dev` can test the access gate when `OPS_SIGNER_ACCESS_KEY` is set. +- `/ops` is a direct URL. +- The page is not linked from regular navigation. +- The page is marked `noindex,nofollow`. +- Transaction signing remains protected by wallet, chain, owner, precheck, simulation, and postcheck gates. ## First Implementation Steps diff --git a/netlify/edge-functions/ops-access.js b/netlify/edge-functions/ops-access.js deleted file mode 100644 index 5a9581c..0000000 --- a/netlify/edge-functions/ops-access.js +++ /dev/null @@ -1,131 +0,0 @@ -const ACCESS_KEY_ENV = 'OPS_SIGNER_ACCESS_KEY'; -const ACCESS_QUERY_PARAM = 'access'; -const COOKIE_NAME = 'futarchy_ops_access'; -const COOKIE_TTL_SECONDS = 24 * 60 * 60; -const COOKIE_VERSION = 'v1'; - -function getEnv(name) { - if (globalThis.Netlify?.env?.get) { - return globalThis.Netlify.env.get(name); - } - - if (typeof Deno !== 'undefined' && Deno.env?.get) { - return Deno.env.get(name); - } - - return undefined; -} - -function notFound() { - return new Response('Not found', { - status: 404, - headers: { - 'cache-control': 'no-store', - 'content-type': 'text/plain; charset=utf-8', - 'x-robots-tag': 'noindex, nofollow', - }, - }); -} - -function timingSafeEqual(left = '', right = '') { - const leftBytes = new TextEncoder().encode(left); - const rightBytes = new TextEncoder().encode(right); - const length = Math.max(leftBytes.length, rightBytes.length); - let diff = leftBytes.length ^ rightBytes.length; - - for (let index = 0; index < length; index += 1) { - diff |= (leftBytes[index] || 0) ^ (rightBytes[index] || 0); - } - - return diff === 0; -} - -function base64Url(bytes) { - let value = ''; - bytes.forEach((byte) => { - value += String.fromCharCode(byte); - }); - - return btoa(value).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/u, ''); -} - -async function signToken(secret, expiresAt) { - const encoder = new TextEncoder(); - const key = await crypto.subtle.importKey( - 'raw', - encoder.encode(secret), - { name: 'HMAC', hash: 'SHA-256' }, - false, - ['sign'] - ); - const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(`ops:${expiresAt}`)); - - return base64Url(new Uint8Array(signature)); -} - -async function createCookieToken(secret) { - const expiresAt = Date.now() + COOKIE_TTL_SECONDS * 1000; - const signature = await signToken(secret, expiresAt); - - return `${COOKIE_VERSION}.${expiresAt}.${signature}`; -} - -async function isCookieTokenValid(token, secret) { - const [version, expiresAt, signature] = String(token || '').split('.'); - const expiresAtNumber = Number(expiresAt); - - if (version !== COOKIE_VERSION || !Number.isFinite(expiresAtNumber) || expiresAtNumber <= Date.now()) { - return false; - } - - const expectedSignature = await signToken(secret, expiresAt); - - return timingSafeEqual(signature, expectedSignature); -} - -function setAccessCookieResponse(url, token) { - const redirectUrl = new URL('/ops', url); - const isLocalhost = redirectUrl.hostname === 'localhost' || redirectUrl.hostname === '127.0.0.1'; - const secureAttribute = isLocalhost ? '' : '; Secure'; - - return new Response(null, { - status: 302, - headers: { - 'cache-control': 'no-store', - location: redirectUrl.pathname, - 'set-cookie': `${COOKIE_NAME}=${encodeURIComponent(token)}; Path=/ops; Max-Age=${COOKIE_TTL_SECONDS}; HttpOnly${secureAttribute}; SameSite=Strict`, - 'x-robots-tag': 'noindex, nofollow', - }, - }); -} - -export default async function opsAccess(request, context) { - const accessKey = getEnv(ACCESS_KEY_ENV); - - if (!accessKey) { - return notFound(); - } - - const url = new URL(request.url); - const queryAccess = url.searchParams.get(ACCESS_QUERY_PARAM); - - if (queryAccess && timingSafeEqual(queryAccess, accessKey)) { - const token = await createCookieToken(accessKey); - - return setAccessCookieResponse(url, token); - } - - if (await isCookieTokenValid(context.cookies.get(COOKIE_NAME), accessKey)) { - const response = await context.next(); - response.headers.set('x-robots-tag', 'noindex, nofollow'); - response.headers.set('cache-control', 'no-store'); - - return response; - } - - return notFound(); -} - -export const config = { - path: ['/ops', '/ops/', '/ops.html', '/ops/index.html'], -};