diff --git a/scripts/check-knowledge-website.cjs b/scripts/check-knowledge-website.cjs index 95fcf73..150feb7 100644 --- a/scripts/check-knowledge-website.cjs +++ b/scripts/check-knowledge-website.cjs @@ -3,7 +3,7 @@ const assert = require('node:assert/strict') const { execFileSync } = require('node:child_process') const { randomUUID } = require('node:crypto') -const { cpSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync, rmSync, symlinkSync } = require('node:fs') +const { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync, rmSync, symlinkSync } = require('node:fs') const path = require('node:path') const os = require('node:os') @@ -26,7 +26,7 @@ function pdf() { try { // Copy only website sources and the approved importer input, never .env, // private raw imports, generated releases, credentials or deployment state. - for (const relative of ['docusaurus.config.js', 'package.json', 'sidebars.js', 'src', 'static', 'blog', 'our-initiatives', 'knowledge', 'plugins', 'scripts/preview-config.cjs', 'scripts/prepare-slides.py', 'services/knowledge/package.json', 'services/knowledge/src', 'services/knowledge/scripts/build-knowledge.ts', 'services/knowledge/data/bootstrap-corpus.json']) { + for (const relative of ['docusaurus.config.js', 'package.json', 'sidebars.js', 'src', 'static', 'blog', 'our-initiatives', 'knowledge', ...(existsSync(path.join(checkout, 'knowledge-assets/slides')) ? ['knowledge-assets/slides'] : []), 'plugins', 'scripts/preview-config.cjs', 'scripts/prepare-slides.py', 'services/knowledge/package.json', 'services/knowledge/src', 'services/knowledge/scripts/build-knowledge.ts', 'services/knowledge/data/bootstrap-corpus.json']) { mkdirSync(path.dirname(path.join(root, relative)), { recursive: true }) cpSync(path.join(checkout, relative), path.join(root, relative), { recursive: true, filter: source => !/^(?:\.env(?:\..*)?|\.dev\.vars(?:\..*)?|\.wrangler|\.generated|secrets|credentials)$/.test(path.basename(source)) && !/\.(?:pem|key|p12|pfx)$/.test(source) }) } diff --git a/services/knowledge/docs/management-security-review-2026-11-05.md b/services/knowledge/docs/management-security-review-2026-11-05.md new file mode 100644 index 0000000..2a521a8 --- /dev/null +++ b/services/knowledge/docs/management-security-review-2026-11-05.md @@ -0,0 +1,39 @@ +# Management security review, 5 November 2026 + +## Outcome and scope + +**No high-confidence exploitable vulnerabilities identified in the reviewed management paths.** A minor upload-size validation edge case was corrected: the previous base64-length calculation admitted a PDF one byte over 8 MiB. The server now checks the decoded byte count; exact-limit and one-byte-over requests are regression-tested. + +Reviewed runtime source: `bdcdb2f`, including the organisation gate, generated slide paths and GitHub-backed approvals. Deployed management Worker version: `8e035c84-5cdc-4a1d-8dc9-3a3c8068221e`. This is a focused code/configuration review and acceptance check, not proof that no vulnerability can exist. The independent website dependency backlog remains open. + +## Access, sessions and request boundaries + +- Every private asset/API checks the user's current active UCLAIS membership (stable organisation ID), repository write permission and any configured account allowlist. Missing/pending membership, outside collaborators, revoked write access and unverifiable provider responses fail closed. No individual allowlist is configured for the organisation-wide policy. +- OAuth uses PKCE/S256 and encrypted, expiring state. Session cookies are purpose-separated AES-GCM values with random nonces, Secure/HttpOnly/SameSite=Lax and a host-only prefix, with an absolute lifetime of at most one hour. Tampering, duplicate cookies, wrong-purpose values and expiry are checked. +- Mutations require the exact management Origin, a session-bound CSRF header and JSON. Forged Bearer headers, lookalike origins, missing CSRF and wrong content types are refused before repository work. Request bodies are bounded while streaming. +- GitHub requests use fixed HTTPS API/repository destinations and refuse redirects. Public status checks use fixed public hosts and bounded same-origin image samples. Error responses omit provider bodies and credentials. UI and diff rendering use text values rather than HTML insertion. + +## Upload and approval boundaries + +New slide paths are generated server-side from allowlisted collections and bounded, normalised metadata. User-supplied arbitrary paths cannot select new write destinations. Collisions with existing Markdown, metadata or PDF assets are refused; replacements retain their existing paths and written material. Writes create proposal branches and PRs, never direct changes to `main`. + +PDF uploads have an exact 8 MiB API limit. Publication uses the existing bounded text extractor, sanitised downloads and raster viewers. The real 22-page Multivariate Regression upload passes the complete publication build after fixing the isolated checker to include uploaded PDF sources. No active annotations were found in that deck. Its failed check was an omitted-input test-workspace bug, not evidence of an unsafe PDF. + +Approvals use the signed-in reviewer's user token and the existing Pull requests write permission. The server re-fetches the PR, requires an open non-draft `committee/*` branch in the fixed repository targeting `main`, checks the complete changed-file list (at most 100 files), refuses self-approval and rejects a head commit changed since the review opened. It submits only `APPROVE` for the reviewed `commit_id`; client-supplied repository, reviewer or action overrides cannot alter that. Preview text is bounded and escaped; omitted/binary changes link to GitHub for review. Explicit review confirmation is required. The app cannot merge, override reviews or change branch rules. GitHub determines whether the recorded approval satisfies its rules, including stale-review handling. + +## Provider configuration and credential checks + +- GitHub installation `178606665` was rechecked: only `UCLAIS/UCLAIS.github.io` is selected. Permissions are Contents/Pull requests write and Actions/Members/Metadata read. No additional permission or credential was added for approvals. +- Cloudflare's production gateway was rechecked: `MANAGEMENT` binds only to `uclai-management`, and quota handling remains **Fail closed**. Deployment verified the free-only account and retained the existing encrypted secret names. Runtime bindings have no deployment credential or storage service. +- Gitleaks found no candidates in the final tracked source snapshot, Worker bundle, reachable history or extracted text from the submitted PDF. The known local configured secret value had no exact matches in 4,852 reachable Git blobs or 471 checked artefact files. Secret values were not printed in these checks. +- GitHub secret scanning and push protection are enabled; the alerts API returned zero alerts. The management/knowledge service's full dependency audit returned zero advisories on 5 November. The separate website lockfile was not upgraded or cleared by this review; see the [earlier dependency assessment](security-review-2026-11-03.md). + +## Verification and practical limits + +All **373 service tests across 26 files**, TypeScript and the management Worker build passed. Native workerd tests cover OAuth, private data, review loading, provider redirects and existing-session denial after fixture membership/write revocation. Handler tests cover approval identity, self-approval, stale commits, repository/branch scope, missing confirmation, request limits, CSRF/Origin and forged authority fields. + +**16 live anonymous boundary checks passed**, including private review/approval denial, forged cookies, cross-origin preflight, host restriction and OAuth state/PKCE/cookie checks. Real member sign-in worked and all four dashboard service checks reported Healthy. A live PDF submission without a path produced the expected three generated files in synthetic PR #90; it was closed and its branch deleted without publishing. The live review screen loaded all four files of the real PR #89 and disabled approval for its author. + +A second real reviewer has not submitted an approval through this GUI, and no real member was removed for a revocation test. Those success/denial cases are transport/handler/native-runtime fixtures, not claims of external account acceptance. The local pinned workerd also uses an earlier supported compatibility date than production, as documented in the fixture. + +Uploads and branches are public on GitHub immediately, before merge; form acknowledgement does not scan every future document for confidential material. Scans cannot exclude exposure in inaccessible copies, images or undetected formats. Stateless logout clears the browser cookie but cannot revoke a stolen copy before expiry; repository/membership/token revocation or rotating the session secret blocks its continued use. These are explicit operating limits, not a claim of unrestricted safety. diff --git a/services/knowledge/docs/management.md b/services/knowledge/docs/management.md index 628cb36..05c84b2 100644 --- a/services/knowledge/docs/management.md +++ b/services/knowledge/docs/management.md @@ -7,13 +7,13 @@ The management service is a separate authenticated Worker at `https://manage.ucl - Edit Markdown under `knowledge/faqs`, `guides`, `tutorials` and `nexus`. - Add or replace PDF slide decks up to 8 MiB. Export PowerPoint or Google Slides to PDF before uploading. Selectable PDF text is required; image-only decks fail the knowledge checks. - Show website publication, MCP health and release alignment, Luma feed freshness, slide build validation and bounded cover-image checks. Checks run on opening Overview or pressing Refresh. There is no unattended monitoring. -- Show pending management pull requests and recent website/knowledge workflow runs. Review and merge on GitHub using the repository's normal rules. +- Show open management pull requests and recent website/knowledge workflow runs. Another member can review the changed files and approve through this page; GitHub records the review under their own account. Merge on GitHub using the repository's normal rules. The pinned external archive remains maintained through its existing importer. Editing arbitrary website code, notebook uploads, learning-path administration, deleting material, merging pull requests and rerunning workflows are outside this first version. ## Access and credentials -The registered **GitHub App** is **UCL AI Society Management**, owned by **UCLAIS** and installed on **only `UCLAIS/UCLAIS.github.io`**. Its homepage is `https://manage.uclai.co.uk` and callback is `https://manage.uclai.co.uk/auth/callback`. Retain these repository permissions: +The registered **GitHub App** is **UCL AI Society Management**, owned by **UCLAIS** and installed on **only `UCLAIS/UCLAIS.github.io`**. Its homepage is `https://manage.uclai.co.uk` and callback is `https://manage.uclai.co.uk/auth/callback`. Access requires **active UCLAIS membership and repository write access**. Retain these permissions: | Permission | Access | Purpose | | --- | --- | --- | @@ -21,17 +21,24 @@ The registered **GitHub App** is **UCL AI Society Management**, owned by **UCLAI | Pull requests | Read and write | Submit changes for review | | Actions | Read | Report existing build/deployment runs | | Metadata | Read | Verify the repository and user access | +| Members (organisation) | Read | Verify active UCLAIS membership | + +The membership check uses `/user/memberships/orgs/UCLAIS` and verifies the organisation's stable numeric ID (`61075473`). It supports private memberships. Pending invitations, non-members (including outside collaborators with repository write access), a different organisation and unverifiable membership are denied. Membership is checked again on every private request, so an existing session cannot retain access after removal. The read-only Members permission must also be approved on the existing UCLAIS installation. Older user grants may need renewed sign-in. See [GitHub's membership endpoint documentation](https://docs.github.com/en/rest/orgs/members#get-an-organization-membership-for-the-authenticated-user). Leave webhooks and device flow disabled. Enable expiring user access tokens. Users authorise the app through OAuth with S256 PKCE. The service uses each user's token, so GitHub constrains actions to both the app's permissions and the user's permissions. No app private key or installation token is needed. See [GitHub's user authorisation documentation](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-user-access-token-for-a-github-app). -Set `GITHUB_CLIENT_ID` and `MANAGEMENT_ORIGIN` in the management configuration. Set `GITHUB_CLIENT_SECRET` and `SESSION_SECRET` with Wrangler's secret input or the Cloudflare dashboard, never a committed file or public website variable. Generate `SESSION_SECRET` from at least 32 cryptographically random bytes, encoded as base64. The code requires at least 43 characters. Optional `ALLOWED_GITHUB_IDS` further restricts access to comma-separated stable numeric IDs; it does not replace the repository-write requirement. +Set `GITHUB_CLIENT_ID` and `MANAGEMENT_ORIGIN` in the management configuration. Set `GITHUB_CLIENT_SECRET` and `SESSION_SECRET` with Wrangler's secret input or the Cloudflare dashboard, never a committed file or public website variable. Generate `SESSION_SECRET` from at least 32 cryptographically random bytes, encoded as base64. The code requires at least 43 characters. Optional `ALLOWED_GITHUB_IDS` further restricts access to comma-separated stable numeric IDs; it does not replace either membership or repository-write requirements. Organisation-wide access uses no individual allowlist. -All management assets and APIs check the user's current repository write access. The session lasts at most one hour and is an encrypted, authenticated, host-only Secure/HttpOnly cookie. JavaScript receives only a CSRF nonce, never the GitHub token. Writes also require the exact management Origin. Sessions have no server-side store: signing out clears the browser cookie, and a copied cookie remains usable until its expiry or GitHub access revocation. Rotating `SESSION_SECRET` invalidates all sessions. Do not add a production authentication bypass for previews. +All management assets and APIs check the user's current active UCLAIS membership and repository write access. The session lasts at most one hour and is an encrypted, authenticated, host-only Secure/HttpOnly cookie. JavaScript receives only a CSRF nonce, never the GitHub token. Writes also require the exact management Origin. Sessions have no server-side store: signing out clears the browser cookie, and a copied cookie remains usable until its expiry or GitHub access revocation. Rotating `SESSION_SECRET` invalidates all sessions. Do not add a production authentication bypass for previews. ## Publication and PDF processing Every save uses a fixed base commit. A changed `main` causes a conflict instead of silently overwriting newer work. Successful submissions create unique `committee/*` branches and pull requests. If GitHub saves a branch but does not confirm a pull request, the error names the branch for recovery; do not blindly resubmit. +**Review and approve** opens the submission's file changes and links to its GitHub files, including PDFs. Text is displayed as plain text. Previews are bounded; omitted or truncated changes must be reviewed on GitHub before confirming. The API supports only open, non-draft `committee/*` branches within the website repository targeting `main`, checks a complete file list (at most 100 files), rejects the author's own approval and refuses a head commit changed since the reviewer opened it. Approval requires a valid session, CSRF/Origin checks and explicit confirmation, then sends `APPROVE` with the reviewed `commit_id` through the reviewer's own GitHub token. It neither merges the PR nor changes branch rules; GitHub decides whether the review satisfies its requirements. No new app permission is needed. A reviewer should still inspect build failures and all changed files, including any manually added code. + +New session paths are generated on the server from the collection, academic year, optional series/session number and title. For example, “Multivariate Regression” in Tutorials for 2026/27 becomes `knowledge/tutorials/2026-27/multivariate-regression.md`. The upload form needs no manually entered path. A collision with an existing source, sidecar or PDF is refused; choose the existing session in the replacement list or use a different title. Replacements retain their existing URL. + New decks produce a canonical Markdown file, a `.meta.json` sidecar and a PDF under `knowledge-assets/slides//source.pdf`. The sidecar's `slides` field must match that exact path. Replacing a deck preserves the canonical file, metadata and existing additional downloads. **Written notes remain independently maintained and must be reviewed for consistency with the replacement PDF.** The knowledge importer includes the PDF's extracted text with explicit slide/page locators. The website build uses the existing bounded PDF parser, sanitising slide preparation and Poppler renderer to create a download and raster images under ignored `static/materials/_generated/`. The downloadable copy rebuilds ordinary HTTPS links, drops internal destinations and page actions, and refuses chained actions and interactive annotations. Generated decks go into the site build, not a second content database. Original uploads are outside the website's static directory. Existing decks without a `slides` sidecar retain their existing viewers. Draft or withdrawn documents do not produce generated viewers. @@ -51,7 +58,7 @@ pnpm management:build pnpm management:gateway ``` -`management:build` bundles without deploying. `management:gateway` prepares a separate Pages advanced-mode gateway under `.generated/management-gateway/`. The knowledge CI workflow runs both without secrets. No workflow automatically deploys management. +`management:build` bundles without deploying. `management:gateway` prepares a separate Pages advanced-mode gateway under `.generated/management-gateway/`. The knowledge CI workflow runs both without secrets. The isolated website publication check also copies existing `knowledge-assets/slides` alongside the canonical knowledge files, so uploaded PDFs remain available for validation and rendering. No workflow automatically deploys management. Keep the society's Cloudflare account on Workers Free. Deploy `wrangler.management.jsonc` only after configuring the GitHub App and secrets. The Worker has public `workers.dev` and preview URLs disabled, and its assets run through the Worker before delivery. A live exact-8-MiB upload succeeded on Free on 4 November 2026; this is an acceptance check, not a guarantee of throughput or spare CPU under load. Quota exhaustion may stop access. Do not enable paid resources to bypass a failed check. @@ -85,8 +92,8 @@ Live browser acceptance passed as `@sometimesyash`: - Authorisation and repeat sign-in, account display, editable library and current workflow reads. - A Markdown proposal (#83), a 637-byte PDF proposal (#84), an exact 8,388,608-byte PDF proposal (#85) and a replacement proposal (#86). Downloaded Git blobs exactly matched both uploaded fixtures; paths and sidecars were correct. Replacement left the existing Markdown/notes and downloads unchanged. All four synthetic PRs were closed and their proposal branches deleted; none was merged or published. - Sign-out returned the login screen and the same browser's private session API denied access. Sign-in succeeded again after the unused initial client secret ending `5395e977` was revoked. GitHub confirms only the working replacement remains. -- MCP and Luma report Healthy. Website and slide publication remain Unverified because the production website has not yet received the build-manifest support. +- Initially, MCP and Luma reported Healthy while website and slide publication remained Unverified pending the publishing merge. After the user's explicitly approved one-time administrator merge of PR #82, website and knowledge deployments completed at `eed0a51`. Both public services serve release `456feaf0ffca9355d8f06b8502301b36474d0be1712196b3d4895d3ebc5e2203`; both published cover images loaded and all four dashboard checks reported Healthy. Public MCP SDK initialisation, the five read-only tools and a search call also passed. The actual Workers runtime fixture checks native OAuth/fetch, redirects and immediate denial of an existing session after its repository-write permission is revoked. The full service suite has 319 passing tests across 25 files. No real maintainer permission was removed and no second non-maintainer GitHub account was used: those are not claimed as live acceptance results. The local pinned runtime predates the production compatibility date; that limitation is documented in the fixture. -Source and publishing changes are in [PR #82](https://github.com/UCLAIS/UCLAIS.github.io/pull/82). Both CI checks passed at `1fe8286`; the protected branch still requires review. Merge the publishing support before expecting PDF content proposals to publish successfully on the website and MCP. A successful proposal is not a production publication. Management does not merge its own content proposals or invoke an administrator bypass. +Source and publishing changes were merged through [PR #82](https://github.com/UCLAIS/UCLAIS.github.io/pull/82), after both final CI checks passed at `cbb83fd`. Branch rules were not changed. A successful proposal is not a production publication: future content submissions still require normal review and both deployments. Management does not merge its own content proposals or invoke an administrator bypass. diff --git a/services/knowledge/management-ui/app.js b/services/knowledge/management-ui/app.js index 389d78c..68f7210 100644 --- a/services/knowledge/management-ui/app.js +++ b/services/knowledge/management-ui/app.js @@ -4,6 +4,7 @@ let session; let library = { commit: '', files: [] }; let documentCommit = ''; let busy = false; +let review; const element = (tag, text, className) => { const node = document.createElement(tag); if (text !== undefined) node.textContent = text; if (className) node.className = className; return node; }; function link(text, url) { const node = element('a', text); const target = new URL(url); if (target.protocol !== 'https:') throw new Error('Invalid link.'); node.href = target.href; node.target = '_blank'; node.rel = 'noopener noreferrer'; return node; } function message(text, error = false, url) { const node = $('message'); node.replaceChildren(document.createTextNode(text + ' ')); if (url) node.append(link('Open pull request ↗', url)); node.hidden = false; node.classList.toggle('error', error); node.scrollIntoView({ behavior: 'instant', block: 'nearest' }); } @@ -41,10 +42,10 @@ $('slide-target').addEventListener('change', () => { const replace = !!$('slide- $('slide-file').addEventListener('change', () => { const file = $('slide-file').files[0]; $('file-detail').textContent = file ? `${file.name} · ${(file.size / 1024 / 1024).toFixed(2)} MiB` : 'Export your deck to PDF. Maximum 8 MiB.'; }); async function submit(form, action) { if (busy) return; busy = true; - const buttons = [...document.querySelectorAll('button')]; buttons.forEach(button => { button.disabled = true; }); form.setAttribute('aria-busy', 'true'); + const buttons = [...document.querySelectorAll('button')].map(button => [button, button.disabled]); buttons.forEach(([button]) => { button.disabled = true; }); form.setAttribute('aria-busy', 'true'); try { const pr = await action(); message(`Submitted as pull request #${pr.number}. Review and merge it to publish.`, false, pr.url); await loadProposals(); } catch (error) { message(error.message, true); } - finally { busy = false; buttons.forEach(button => { button.disabled = false; }); form.removeAttribute('aria-busy'); } + finally { busy = false; buttons.forEach(([button, disabled]) => { button.disabled = disabled; }); form.removeAttribute('aria-busy'); } } $('knowledge-form').addEventListener('submit', event => { event.preventDefault(); submit(event.currentTarget, async () => { const result = await api('/api/knowledge', { path: $('document-path').value.trim(), content: $('document-content').value, commit: documentCommit }); @@ -55,15 +56,45 @@ $('slides-form').addEventListener('submit', event => { event.preventDefault(); s const file = $('slide-file').files[0]; if (!file || !/\.pdf$/i.test(file.name) || file.size > 8 * 1024 * 1024) throw new Error('Choose a PDF no larger than 8 MiB.'); const existing = $('slide-target').value; - if (!existing && (!$('slide-title').value.trim() || !$('slide-path').value.trim())) throw new Error('Add a session title and path.'); - const path = existing || `knowledge/${$('slide-collection').value}/${$('slide-path').value.trim().replace(/\.md$/, '')}.md`; - const result = await api('/api/slides', { path, replace: !!existing, title: $('slide-title').value, year: $('slide-year').value, series: $('slide-series').value, order: $('slide-order').value, notes: $('slide-notes').value, commit: library.commit, pdf: await readPdf(file) }); + if (!existing && !$('slide-title').value.trim()) throw new Error('Add a session title.'); + const result = await api('/api/slides', { ...(existing ? { path: existing } : { collection: $('slide-collection').value }), replace: !!existing, title: $('slide-title').value, year: $('slide-year').value, series: $('slide-series').value, order: $('slide-order').value, notes: $('slide-notes').value, commit: library.commit, pdf: await readPdf(file) }); $('slides-public').checked = false; $('slide-file').value = ''; $('file-detail').textContent = 'Export your deck to PDF. Maximum 8 MiB.'; return result; }); }); async function loadProposals() { - try { const data = await api('/api/proposals'); $('proposals').replaceChildren(...data.proposals.map(pr => { const row = element('div', undefined, 'row'); const content = element('div'); content.append(link(pr.title, pr.url), element('p', `#${pr.number} · @${pr.author}`)); row.append(content, element('span', 'In review', 'badge unknown')); return row; })); if (!data.proposals.length) $('proposals').append(element('p', 'No management submissions awaiting review.', 'muted')); } + try { const data = await api('/api/proposals'); $('proposals').replaceChildren(...data.proposals.map(pr => { const row = element('div', undefined, 'row'); const content = element('div'); content.append(link(pr.title, pr.url), element('p', `#${pr.number} · @${pr.author}`)); const button = element('button', 'Review and approve', 'secondary'); button.type = 'button'; button.addEventListener('click', () => openReview(pr.number)); row.append(content, button); return row; })); if (!data.proposals.length) $('proposals').append(element('p', 'No open management submissions.', 'muted')); } catch { $('proposals').replaceChildren(element('p', 'Submissions could not be loaded. Open GitHub to check.', 'muted')); } } +function reviewControls() { $('approve-review').disabled = busy || !review?.canApprove || ! $('review-confirm').checked; } +async function openReview(number) { + review = undefined; $('review-panel').hidden = false; $('review-title').textContent = 'Loading submission…'; $('review-summary').textContent = ''; $('review-files').replaceChildren(); $('review-confirm').checked = false; $('review-confirm').disabled = true; reviewControls(); + try { + const selected = await api('/api/proposal?number=' + number); review = selected; + $('review-title').textContent = selected.title; + const summary = $('review-summary'); summary.replaceChildren(document.createTextNode(`#${selected.number} · @${selected.author} · Commit ${selected.head.slice(0, 7)}. `), link('Full review on GitHub ↗', selected.url)); + $('review-files').replaceChildren(...selected.files.map(file => { + const details = element('details'); details.open = selected.files.length <= 4; + details.append(element('summary', `${file.path} · ${file.status}`), link('Open this file on GitHub ↗', file.url)); + if (file.patch) details.append(element('pre', file.patch, 'review-diff')); + if (file.limited) details.append(element('p', 'The full change is not shown here. Open the file and full diff on GitHub before approving; PDF files open there too.', 'muted')); + return details; + })); + $('review-confirm').disabled = !selected.canApprove; + $('review-rule').textContent = selected.canApprove ? 'This records your approval on GitHub for this commit. Merge and publication remain subject to GitHub’s normal rules.' : 'Someone else must approve your submission, just as on GitHub.'; + reviewControls(); $('review-panel').scrollIntoView({behavior:'instant',block:'start'}); + } catch (error) { $('review-title').textContent = 'Review unavailable'; message(error.message, true); } +} +$('review-confirm').addEventListener('change', reviewControls); +$('close-review').addEventListener('click', () => { review = undefined; $('review-panel').hidden = true; reviewControls(); }); +$('review-form').addEventListener('submit', async event => { + event.preventDefault(); if (busy || !review?.canApprove || !$('review-confirm').checked) return; + const selected = review; busy = true; reviewControls(); + try { + const result = await api('/api/approve', {number:selected.number, head:selected.head, reviewed:true}); + message(`Approval recorded on GitHub for #${selected.number} at ${selected.head.slice(0, 7)}. Review the checks and merge on GitHub to publish.`, false, result.url); + if (review === selected) { review.canApprove = false; $('review-confirm').disabled = true; $('review-rule').textContent = 'Your approval is recorded on GitHub. Merge and publication are separate steps.'; } + } catch (error) { message(error.message, true); } + finally { busy = false; reviewControls(); } +}); async function loadStatus() { $('refresh-status').disabled = true; $('checked-at').textContent = 'Checking live services…'; try { diff --git a/services/knowledge/management-ui/index.html b/services/knowledge/management-ui/index.html index a0a5e47..4bd1ced 100644 --- a/services/knowledge/management-ui/index.html +++ b/services/knowledge/management-ui/index.html @@ -8,9 +8,9 @@
-

The society, running

Overview.

Service health and the journey from submitted to published.

Checks run when you open or refresh this page.

Checking services…

Publication

Website and MCP deploy separately

Awaiting review

All pull requests ↗

Loading submissions…

Endpoint checks verify the published services. A particular ChatGPT or Claude connection needs its own connection test. Luma controls registration and event details.

+

The society, running

Overview.

Service health and the journey from submitted to published.

Checks run when you open or refresh this page.

Checking services…

Publication

Website and MCP deploy separately

Open submissions

All pull requests ↗

Loading submissions…

Endpoint checks verify the published services. A particular ChatGPT or Claude connection needs its own connection test. Luma controls registration and event details.

- +
diff --git a/services/knowledge/management-ui/style.css b/services/knowledge/management-ui/style.css index cf25491..7fef3bb 100644 --- a/services/knowledge/management-ui/style.css +++ b/services/knowledge/management-ui/style.css @@ -1 +1,3 @@ :root{--bg:#fdfbf8;--ink:#261c2e;--muted:#655b6a;--purple:#2c1748;--line:#d9d0dc;--surface:#f2edf4;font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:var(--ink);background:var(--bg);font-size:14px;color-scheme:light}*{box-sizing:border-box}body{margin:0}a{color:var(--purple);text-underline-offset:4px}button,input,textarea,select{font:inherit}button{background:var(--purple);border:1px solid var(--purple);color:#fff;padding:12px 18px;cursor:pointer;font-weight:550}button:disabled{cursor:wait;opacity:.55}button.secondary{background:transparent;color:var(--purple)}button.quiet{border:0;background:transparent;color:var(--muted);padding:8px}a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid #76518f;outline-offset:4px}header{height:100px;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;align-items:center;padding:0 40px;gap:20px}.brand{font:27px Georgia,serif;text-decoration:none;letter-spacing:-.7px}.brand span{display:block;font:11px system-ui;letter-spacing:.14em;text-transform:uppercase;color:var(--muted);margin-top:7px}.account{display:flex;align-items:center;gap:18px;font-size:12px}.workspace{display:grid;grid-template-columns:240px minmax(0,1fr);min-height:calc(100vh - 170px);max-width:1680px;margin:auto}aside{border-right:1px solid var(--line);padding:42px 24px;display:flex;flex-direction:column}.eyebrow{font-size:10px;font-weight:650;text-transform:uppercase;letter-spacing:.15em;color:var(--muted);margin:0 0 16px}nav{display:grid;gap:5px}nav button{text-align:left;border:0;background:transparent;color:var(--muted);font-weight:450;padding:14px 12px}nav button span{font:11px ui-monospace,monospace;margin-right:12px;opacity:.6}nav button[aria-current]{background:var(--surface);color:var(--purple);font-weight:600;border-left:2px solid var(--purple)}.aside-note{margin-top:auto;padding:80px 12px 0;font-size:12px;line-height:1.65}.aside-note strong{font-weight:550}.aside-note p{color:var(--muted);margin:8px 0 18px}main{padding:48px clamp(24px,4vw,64px);min-width:0}.page-heading{display:flex;justify-content:space-between;align-items:flex-end;gap:28px;margin-bottom:34px}.page-heading button{flex-shrink:0}.page-heading .eyebrow{margin-bottom:12px}h1{font:normal clamp(42px,4.5vw,64px)/1 Georgia,serif;letter-spacing:-2px;color:var(--purple);margin:0 0 18px}h2{font:normal 27px Georgia,serif;color:var(--purple);margin:0}.page-heading p:not(.eyebrow){color:var(--muted);line-height:1.6;margin:0;max-width:570px}.muted{color:var(--muted);font-size:12px;line-height:1.6}.checks{border-top:1px solid var(--line);margin:20px 0 42px}.check{display:grid;grid-template-columns:155px minmax(0,1fr) 105px;gap:22px;align-items:center;padding:24px 0;border-bottom:1px solid var(--line)}.check h3{font-size:14px;font-weight:600;margin:0}.check p{color:var(--muted);font-size:12px;line-height:1.65;margin:0}.badge{justify-self:end;font-size:11px;font-weight:600;display:inline-flex;align-items:center;gap:7px}.badge::before{content:"";width:6px;height:6px;background:currentColor;border-radius:50%}.ready{color:#37602d}.degraded{color:#8c5b12}.unavailable{color:#9b3541}.unknown{color:var(--muted)}.section-title{display:flex;justify-content:space-between;gap:16px;align-items:center;margin:38px 0 18px}.section-title a{font-size:12px}.rows{border-top:1px solid var(--line)}.row{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:18px 0;border-bottom:1px solid var(--line);font-size:13px}.row p{margin:5px 0 0;font-size:12px;color:var(--muted)}.row a{max-width:75%;overflow-wrap:anywhere}.footnote{font-size:12px;line-height:1.7;color:var(--muted);max-width:680px;margin-top:28px}.editor-layout{display:grid;grid-template-columns:minmax(180px,.7fr) minmax(0,1.8fr);gap:36px}.library{border-right:1px solid var(--line);padding-right:24px}.document-list{display:grid;margin-top:14px;max-height:670px;overflow-y:auto}.document-list button{background:transparent;border:0;border-bottom:1px solid var(--line);color:var(--ink);text-align:left;padding:16px 8px;overflow-wrap:anywhere;font-size:12px;font-weight:450;line-height:1.6}.document-list button[aria-current]{background:var(--surface);color:var(--purple)}label{display:block;font-weight:550;font-size:12px;margin:0 0 9px}input:not([type=checkbox]),select,textarea{width:100%;background:#fffefa;color:var(--ink);border:1px solid var(--line);border-radius:0;padding:12px;margin-bottom:22px;min-width:0}textarea{resize:vertical;line-height:1.65}#document-content{font:12px/1.7 ui-monospace,SFMono-Regular,Consolas,monospace;tab-size:2}.hint{font-size:12px;line-height:1.6;color:var(--muted);margin:-12px 0 22px}.consent{display:flex;gap:10px;align-items:flex-start;font-weight:400;font-size:12px;color:var(--muted);line-height:1.65;margin:4px 0 24px}.consent input{margin:4px 0 0;accent-color:var(--purple);flex-shrink:0}.submit-hint{font-size:11px;color:var(--muted);margin-left:14px}.upload-layout{display:grid;grid-template-columns:minmax(0,1.5fr) minmax(210px,.8fr);gap:64px}.upload-guide{border-left:1px solid var(--line);padding-left:32px}.upload-guide ol{padding-left:18px}.upload-guide li{padding:12px 0 20px 8px}.upload-guide strong{font-size:13px}.upload-guide li p{font-size:12px;line-height:1.7;color:var(--muted)}.field-grid{display:grid;grid-template-columns:1fr 1fr;gap:20px}.optional{font-weight:400;color:var(--muted);margin-left:6px}.file-picker{padding:24px;border:1px dashed #a897b2;margin:6px 0 24px;background:var(--surface)}.file-picker input{background:transparent;border:0;padding:8px 0;margin-bottom:20px}.file-picker .hint{margin-bottom:0}.notice,#message{background:var(--surface);padding:16px 20px;font-size:13px;line-height:1.7;margin:0 0 28px;border-left:3px solid var(--purple)}#message.error{border-color:#9b3541;background:#fcf0ef}#message a{font-weight:600}.notice{font-size:12px}footer{border-top:1px solid var(--line);padding:24px 40px;color:var(--muted);font-size:11px;display:flex;justify-content:space-between;gap:20px}.skip{position:absolute;top:-100px;padding:12px;background:var(--bg);z-index:10}.skip:focus{top:8px}[hidden]{display:none!important}@media(max-width:1000px){.workspace{grid-template-columns:190px minmax(0,1fr)}aside{padding:34px 14px}.upload-layout{gap:28px;grid-template-columns:minmax(0,1fr)}.upload-guide{border-left:0;border-top:1px solid var(--line);padding:28px 0 0}.upload-guide ol{display:flex;gap:28px}.editor-layout{grid-template-columns:1fr}.library{border-right:0;border-bottom:1px solid var(--line);padding:0 0 20px}.document-list{max-height:160px}.check{grid-template-columns:130px 1fr;gap:10px}.check p{grid-column:1/-1;grid-row:2}.badge{grid-column:2;grid-row:1}}@media(max-width:650px){header{height:auto;padding:22px;align-items:flex-start}.brand{font-size:22px}.brand span{font-size:9px}.account{flex-direction:column;align-items:flex-end;gap:4px}.workspace{display:block}aside{padding:18px 16px;border-right:0;border-bottom:1px solid var(--line)}aside>.eyebrow,.aside-note{display:none}nav{display:flex;gap:4px}nav button{flex:1;font-size:11px;padding:12px 5px}nav button span{display:none}main{padding:30px 22px}.page-heading{align-items:flex-start;flex-direction:column;gap:20px}h1{font-size:46px}.section-title{align-items:flex-start;flex-direction:column;gap:8px}.upload-guide ol{display:block}.field-grid{grid-template-columns:1fr;gap:0}.row{align-items:flex-start}.submit-hint{display:block;margin:12px 0 0}footer{padding:22px;flex-direction:column;gap:10px}}@media(prefers-reduced-motion:no-preference){button,a{transition:background .15s,color .15s}button:hover{filter:brightness(1.12)}} + +#review-panel{border-top:1px solid var(--line);margin-top:32px;padding-top:8px}#review-files details{border-bottom:1px solid var(--line);padding:18px 0}#review-files summary{cursor:pointer;overflow-wrap:anywhere;margin-bottom:12px;font-size:12px;font-weight:600}#review-files a{font-size:12px}.review-diff{font:11px/1.65 ui-monospace,SFMono-Regular,Consolas,monospace;white-space:pre-wrap;overflow-wrap:anywhere;max-height:420px;overflow:auto;background:var(--surface);padding:16px}button:disabled{cursor:not-allowed} diff --git a/services/knowledge/src/management/github.ts b/services/knowledge/src/management/github.ts index f6db75b..ece36f2 100644 --- a/services/knowledge/src/management/github.ts +++ b/services/knowledge/src/management/github.ts @@ -3,6 +3,9 @@ import { KnowledgeError } from '../types'; export const REPOSITORY = 'UCLAIS/UCLAIS.github.io'; export const REPO_URL = `https://github.com/${REPOSITORY}`; +const MEMBERSHIP_PATH = '/user/memberships/orgs/UCLAIS'; +const ORGANIZATION_ID = 61075473; +const ACCESS_MESSAGE = 'This GitHub account must be an active UCLAIS member with write access to the society website repository.'; export interface TreeEntry { path: string; type: string; sha: string; size?: number; mode: string } export type Network = typeof fetch; export class Github { @@ -16,15 +19,20 @@ export class Github { }); if (!response.ok) { await response.body?.cancel(); + if (path === MEMBERSHIP_PATH && response.status === 404) throw new KnowledgeError('forbidden', ACCESS_MESSAGE, 403); throw new KnowledgeError('github_unavailable', response.status === 401 ? 'GitHub sign-in expired. Sign out and sign in again.' : response.status === 403 || response.status === 429 ? 'GitHub refused this request. Check repository access or try again later.' : 'GitHub could not complete this request. Refresh before retrying.', response.status === 401 ? 401 : 502); } return response.status === 204 ? undefined as T : await response.json() as T; } repo(path = '', options: { method?: string; body?: unknown } = {}) { return this.api(`/repos/${REPOSITORY}${path}`, options); } async actor(allowedIds?: string) { - const [user, repo] = await Promise.all([this.api<{ id: number; login: string }>('/user'), this.repo<{ permissions?: { push?: boolean; maintain?: boolean; admin?: boolean } }>()]); + const [user, repo, membership] = await Promise.all([ + this.api<{ id: number; login: string }>('/user'), + this.repo<{ permissions?: { push?: boolean; maintain?: boolean; admin?: boolean } }>(), + this.api<{ state?: string; organization?: { id?: number } }>(MEMBERSHIP_PATH), + ]); const allowed = (allowedIds || '').split(',').map(value => value.trim()).filter(Boolean); - if (!Number.isSafeInteger(user.id) || !user.login || !repo.permissions?.push || (allowed.length && !allowed.includes(String(user.id)))) throw new KnowledgeError('forbidden', 'This GitHub account needs write access to the society website repository.', 403); + if (!Number.isSafeInteger(user.id) || !user.login || repo.permissions?.push !== true || membership?.state !== 'active' || membership.organization?.id !== ORGANIZATION_ID || (allowed.length && !allowed.includes(String(user.id)))) throw new KnowledgeError('forbidden', ACCESS_MESSAGE, 403); return { id: user.id, login: user.login, admin: repo.permissions.admin === true }; } async snapshot() { @@ -68,6 +76,24 @@ export function safeText(value: unknown, maximum = 512 * 1024): string { if (containsSensitiveText(value)) throw new KnowledgeError('sensitive_content', 'Remove credential-like content before submitting.', 400); return value; } +export function newSlideDetails(input: { collection?: unknown; title?: unknown; year?: unknown; series?: unknown; order?: unknown; path?: unknown }) { + // Legacy open forms send a path instead of a collection. Its folder can select + // the collection, but new filenames are always generated here on the server. + const collection = input.collection ?? (typeof input.path === 'string' && input.path.startsWith('knowledge/nexus/') ? 'nexus' : 'tutorials'); + if (collection !== 'tutorials' && collection !== 'nexus') throw new KnowledgeError('invalid_path', 'Choose Tutorials or Nexus Labs.', 400); + const title = safeText(input.title, 200).replace(/[\r\n]/g, ' ').trim(); + if (typeof input.year !== 'string' || !/^\d{4}\/\d{2}$/.test(input.year)) throw new KnowledgeError('invalid_metadata', 'Choose an academic year such as 2026/27.', 400); + const year = input.year; + const series = typeof input.series === 'string' && input.series.trim() ? safeText(input.series, 200).trim() : undefined; + const order = input.order === undefined || input.order === '' ? undefined : Number(input.order); + if (order !== undefined && (!series || !Number.isInteger(order) || order < 1 || order > 10000)) throw new KnowledgeError('invalid_metadata', 'A session number needs a series and must be between 1 and 10,000.', 400); + const slug = (text: string, length: number) => text.normalize('NFKD').replace(/\p{M}/gu, '').toLowerCase().replace(/['’]/g, '').replace(/[^a-z0-9]+/g, '-').slice(0, length).replace(/^-+|-+$/g, ''); + const titleSlug = slug(title, 100); const seriesSlug = series ? slug(series, 70) : undefined; + if (!titleSlug || (series && !seriesSlug)) throw new KnowledgeError('invalid_metadata', 'Include a letter or number in the title and series so a URL can be generated.', 400); + const name = `${order ? `session-${order}-` : ''}${titleSlug}`; + const path = knowledgePath(`knowledge/${collection}/${year.replace('/', '-')}/${seriesSlug ? seriesSlug + '/' : ''}${name}.md`); + return { path, title, year, series, order }; +} export function slidePath(sourcePath: string): string { knowledgePath(sourcePath); if (!/^knowledge\/(tutorials|nexus)\//.test(sourcePath)) throw new KnowledgeError('invalid_path', 'Slides belong to a tutorial or Nexus session.', 400); diff --git a/services/knowledge/src/management/reviews.ts b/services/knowledge/src/management/reviews.ts new file mode 100644 index 0000000..88d1df3 --- /dev/null +++ b/services/knowledge/src/management/reviews.ts @@ -0,0 +1,38 @@ +import { KnowledgeError } from '../types'; +import { Github, REPOSITORY, REPO_URL } from './github'; + +export function proposalNumber(value: unknown): number { + if ((typeof value !== 'string' && typeof value !== 'number') || !/^[1-9][0-9]{0,8}$/.test(String(value))) throw new KnowledgeError('invalid_proposal', 'Choose a valid submission.', 400); + return Number(value); +} + +export async function proposalReview(github: Github, number: number, reviewerId: number) { + const pr = await github.repo(`/pulls/${number}`); + if (pr.state !== 'open' || pr.draft === true || pr.base?.ref !== 'main' || pr.base?.repo?.full_name !== REPOSITORY || pr.head?.repo?.full_name !== REPOSITORY || !pr.head?.ref?.startsWith('committee/') || !/^[a-f0-9]{40}$/.test(pr.head?.sha || '') || !Number.isSafeInteger(pr.user?.id)) { + throw new KnowledgeError('unsupported_proposal', 'Review this pull request on GitHub. This page supports open management submissions to main.', 400); + } + if (!Number.isInteger(pr.changed_files) || pr.changed_files < 1 || pr.changed_files > 100) throw new KnowledgeError('large_proposal', 'Review this larger submission on GitHub.', 400); + const files = await github.repo(`/pulls/${number}/files?per_page=100`); + if (!Array.isArray(files) || files.length !== pr.changed_files) throw new KnowledgeError('incomplete_review', 'The changed files could not be verified. Refresh or review on GitHub.', 502); + let remaining = 256 * 1024; + return { + number, title: String(pr.title), author: String(pr.user.login), head: pr.head.sha as string, + url: `${REPO_URL}/pull/${number}`, canApprove: pr.user.id !== reviewerId, + files: files.map(file => { + const patch = typeof file.patch === 'string' ? file.patch : ''; + const displayed = patch.slice(0, Math.min(remaining, 64 * 1024)); remaining -= displayed.length; + return { path: String(file.filename), status: String(file.status), patch: displayed, + limited: !patch || displayed.length !== patch.length, + url: file.status === 'removed' ? `${REPO_URL}/pull/${number}/files` : `${REPO_URL}/blob/${pr.head.sha}/${String(file.filename).split('/').map(encodeURIComponent).join('/')}` }; + }), + }; +} + +export async function approveProposal(github: Github, number: number, head: string, reviewerId: number) { + const proposal = await proposalReview(github, number, reviewerId); + if (!proposal.canApprove) throw new KnowledgeError('self_approval', 'Someone else must approve your submission, just as on GitHub.', 403); + if (proposal.head !== head) throw new KnowledgeError('stale_review', 'This submission changed. Reopen it and review the latest changes before approving.', 409); + const review = await github.repo<{ id: number; state: string; commit_id: string }>(`/pulls/${number}/reviews`, { method: 'POST', body: { event: 'APPROVE', commit_id: head } }); + if (review.state !== 'APPROVED' || review.commit_id !== head || !Number.isSafeInteger(review.id)) throw new KnowledgeError('approval_unconfirmed', 'GitHub did not confirm the approval. Check the pull request before retrying.', 502); + return { number, head, url: `${REPO_URL}/pull/${number}#pullrequestreview-${review.id}` }; +} diff --git a/services/knowledge/src/management/worker.ts b/services/knowledge/src/management/worker.ts index d95e76f..14cef3b 100644 --- a/services/knowledge/src/management/worker.ts +++ b/services/knowledge/src/management/worker.ts @@ -1,8 +1,9 @@ import { KnowledgeError } from '../types'; import { limitedText, escapeHtml } from '../browser'; import { assertWrite, challenge, LOGIN_COOKIE, ManagementEnv, origin, random, readCookie, seal, Session, SESSION_COOKIE, sessionCookie, unseal } from './auth'; -import { Github, knowledgePath, REPO_URL, safeText, slidePath } from './github'; +import { Github, knowledgePath, newSlideDetails, REPO_URL, safeText, slidePath } from './github'; import { toolingStatus } from './status'; +import { approveProposal, proposalNumber, proposalReview } from './reviews'; const headers = () => new Headers({ 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer', 'X-Frame-Options': 'DENY', 'Content-Security-Policy': "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; form-action 'self'; frame-ancestors 'none'; base-uri 'none'" }); const response = (value: unknown, status = 200) => Response.json(value, { status, headers: headers() }); @@ -10,7 +11,7 @@ function redirect(location: string, cookies: string[] = []) { const values = headers(); values.set('Location', location); for (const cookie of cookies) values.append('Set-Cookie', cookie); return new Response(null, { status: 302, headers: values }); } -function login(message = 'Sign in with a GitHub account that has write access to the society website repository.', status = 200) { +function login(message = 'Sign in with a GitHub account that is an active UCLAIS member and has write access to the society website repository.', status = 200) { const values = headers(); values.set('Content-Type', 'text/html; charset=utf-8'); return new Response(`Society Management · UCL AI Society
UCL AI Society / Committee

Society
Management.

${escapeHtml(message)}

Continue with GitHub
Knowledge, teaching materials and service status.
Uploads submitted to our public GitHub repository are public before a merge.
`, { status, headers: values }); } @@ -55,7 +56,7 @@ export async function handleManagement(request: Request, env: ManagementEnv): Pr const session = await unseal(env, 'session', readCookie(request, SESSION_COOKIE)); if (!session) return url.pathname.startsWith('/api/') ? response({ message: 'Sign in to continue.' }, 401) : login(); const github = new Github(session.token); - const actor = await github.actor(env.ALLOWED_GITHUB_IDS); // Recheck permissions on every request, including assets. + const actor = await github.actor(env.ALLOWED_GITHUB_IDS); // Recheck active organisation membership and write access, including assets. if (request.method === 'POST') assertWrite(request, env, session); else if (!['GET', 'HEAD'].includes(request.method)) return response({ message: 'Method not allowed.' }, 405); if (url.pathname === '/api/session' && request.method === 'GET') return response({ user: actor, csrf: session.csrf, repository: REPO_URL }); @@ -74,6 +75,13 @@ export async function handleManagement(request: Request, env: ManagementEnv): Pr return response({ path, content: await github.text(entry), commit: snapshot.commit }); } if (url.pathname === '/api/status' && request.method === 'GET') return response(await toolingStatus(github)); + if (url.pathname === '/api/proposal' && request.method === 'GET') return response(await proposalReview(github, proposalNumber(url.searchParams.get('number')), actor.id)); + if (url.pathname === '/api/approve' && request.method === 'POST') { + const input = JSON.parse(await limitedText(request, 8192)); + if (input.reviewed !== true) throw new KnowledgeError('review_required', 'Review the changes and confirm before approving.', 400); + const head = requiredString(input.head, /^[a-f0-9]{40}$/, 'Reopen the submission before approving.'); + return response(await approveProposal(github, proposalNumber(input.number), head, actor.id), 201); + } if (url.pathname === '/api/proposals' && request.method === 'GET') { const pulls = await github.repo('/pulls?state=open&base=main&per_page=100'); return response({ proposals: pulls.filter(item => item.head?.ref?.startsWith('committee/')).map(item => ({ title: item.title, url: item.html_url, number: item.number, author: item.user.login, updatedAt: item.updated_at })) }); @@ -87,26 +95,25 @@ export async function handleManagement(request: Request, env: ManagementEnv): Pr } if (url.pathname === '/api/slides' && request.method === 'POST') { const input = JSON.parse(await limitedText(request, 12 * 1024 * 1024)); - const path = knowledgePath(input.path); const pdfPath = slidePath(path); + const details = input.replace === true ? undefined : newSlideDetails(input); + const path = details?.path || knowledgePath(input.path); const pdfPath = slidePath(path); const base = requiredString(input.commit, /^[a-f0-9]{40}$/, 'Reload the library before submitting.'); const data = requiredString(input.pdf, /^[A-Za-z0-9+/]+={0,2}$/, 'Upload a PDF exported from your slide deck.'); - if (data.length > Math.ceil(8 * 1024 * 1024 / 3) * 4 || data.length % 4 || !atob(data.slice(0, 12)).startsWith('%PDF-')) throw new KnowledgeError('invalid_pdf', 'Use a PDF no larger than 8 MiB.', 400); + const decodedBytes = data.length / 4 * 3 - (data.endsWith('==') ? 2 : data.endsWith('=') ? 1 : 0); + if (decodedBytes > 8 * 1024 * 1024 || data.length % 4 || !atob(data.slice(0, 12)).startsWith('%PDF-')) throw new KnowledgeError('invalid_pdf', 'Use a PDF no larger than 8 MiB.', 400); const snapshot = await github.snapshot(); if (snapshot.commit !== base) throw new KnowledgeError('stale_base', 'The repository changed. Reload before submitting.', 409); const sourceExists = snapshot.entries.some(entry => entry.path === path); - if (sourceExists !== (input.replace === true)) throw new KnowledgeError('existing_source', sourceExists ? 'This path already exists. Choose it in the replacement list or use a new path.' : 'The session no longer exists. Reload the library.', 409); const metadataPath = path.replace(/\.md$/, '.meta.json'); + if (input.replace === true && !sourceExists) throw new KnowledgeError('existing_source', 'The session no longer exists. Reload the library.', 409); + if (input.replace !== true && snapshot.entries.some(entry => [path, metadataPath, pdfPath].includes(entry.path))) throw new KnowledgeError('existing_source', 'A session with this title already exists for this year and series. Select it in the replacement list or use a different title.', 409); const existingMeta = snapshot.entries.find(entry => entry.path === metadataPath); const metadata = existingMeta ? JSON.parse(await github.text(existingMeta)) : {}; if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) throw new KnowledgeError('invalid_metadata', 'The existing metadata needs correction in GitHub.', 400); const changes: { path: string; content: string; encoding?: 'utf-8' | 'base64' }[] = [{ path: pdfPath, content: data, encoding: 'base64' }]; if (!sourceExists) { - const title = safeText(input.title, 200).replace(/[\r\n]/g, ' '); - const year = typeof input.year === 'string' && /^\d{4}\/\d{2}$/.test(input.year) ? input.year : undefined; - const series = typeof input.series === 'string' && input.series.trim() ? safeText(input.series, 200) : undefined; - const order = input.order === undefined || input.order === '' ? undefined : Number(input.order); - if (order !== undefined && (!series || !Number.isInteger(order) || order < 1 || order > 10000)) throw new KnowledgeError('invalid_metadata', 'A session number needs a series and must be between 1 and 10,000.', 400); - Object.assign(metadata, { title, type: path.startsWith('knowledge/nexus/') ? 'nexus-session' : 'tutorial', archived: true, status: 'published', ...(year ? { year } : {}), ...(series ? { series } : {}), ...(order ? { order } : {}) }); + const { title, year, series, order } = details!; + Object.assign(metadata, { title, type: path.startsWith('knowledge/nexus/') ? 'nexus-session' : 'tutorial', archived: true, status: 'published', year, ...(series ? { series } : {}), ...(order ? { order } : {}) }); changes.push({ path, content: `# ${title}\n\n## Additional information\n\n${typeof input.notes === 'string' && input.notes.trim() ? safeText(input.notes, 20000) : 'Slides and source text are provided in the attached deck.'}\n` }); } metadata.slides = pdfPath; diff --git a/services/knowledge/tests/management-reviews.test.ts b/services/knowledge/tests/management-reviews.test.ts new file mode 100644 index 0000000..3e1d593 --- /dev/null +++ b/services/knowledge/tests/management-reviews.test.ts @@ -0,0 +1,98 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ManagementEnv, seal, SESSION_COOKIE } from '../src/management/auth'; +import { Github, REPOSITORY } from '../src/management/github'; +import { approveProposal, proposalNumber, proposalReview } from '../src/management/reviews'; +import { handleManagement } from '../src/management/worker'; + +const head = 'a'.repeat(40); +const proposal = () => ({state:'open',draft:false,title:'Lesson update',user:{id:456,login:'author'},base:{ref:'main',repo:{full_name:REPOSITORY}},head:{sha:head,ref:'committee/fixture',repo:{full_name:REPOSITORY}},changed_files:1}); +const file = {filename:'knowledge/faqs/lesson.md',status:'modified',patch:'@@ -1 +1 @@\n-Old\n+'}; +function fixture(pr: any = proposal(), files: any[] = [file]) { + const network = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => { + const target = new URL(String(url)); + expect(target.origin).toBe('https://api.github.com'); + const path = target.pathname; + if (path === `/repos/${REPOSITORY}/pulls/89`) return Response.json(pr); + if (path === `/repos/${REPOSITORY}/pulls/89/files`) return Response.json(files); + if (path === `/repos/${REPOSITORY}/pulls/89/reviews` && init?.method === 'POST') return Response.json({id:99,state:'APPROVED',commit_id:head}); + throw new Error('Unexpected review request.'); + }); + return {github:new Github('test-review-token',network as typeof fetch),network}; +} +afterEach(()=>{vi.restoreAllMocks();vi.unstubAllGlobals();}); + +describe('GitHub-backed management approvals',()=>{ + it('returns reviewable text and fixed-repository links for another member',async()=>{ + const {github,network}=fixture(); + const result=await proposalReview(github,89,123); + expect(result).toMatchObject({head,canApprove:true,author:'author'}); + expect(result.files[0]).toMatchObject({patch:file.patch,url:`https://github.com/${REPOSITORY}/blob/${head}/knowledge/faqs/lesson.md`}); + expect(network.mock.calls.every(([,init])=>init?.method==='GET')).toBe(true); + }); + it('records exactly an APPROVE review at the reviewed commit, without merging',async()=>{ + const {github,network}=fixture(); + expect(await approveProposal(github,89,head,123)).toMatchObject({number:89,head,url:expect.stringContaining('#pullrequestreview-99')}); + const mutations=network.mock.calls.filter(([,init])=>init?.method==='POST'); + expect(mutations).toHaveLength(1); + expect(String(mutations[0][0])).toBe(`https://api.github.com/repos/${REPOSITORY}/pulls/89/reviews`); + expect(JSON.parse(String(mutations[0][1]?.body))).toEqual({event:'APPROVE',commit_id:head}); + }); + it('blocks self-approval on the server even when the browser is bypassed',async()=>{ + const {github,network}=fixture(); + expect((await proposalReview(github,89,456)).canApprove).toBe(false); + await expect(approveProposal(github,89,head,456)).rejects.toMatchObject({code:'self_approval',status:403}); + expect(network.mock.calls.some(([,init])=>init?.method==='POST')).toBe(false); + }); + it('refuses a changed head before sending any approval',async()=>{ + const {github,network}=fixture(); + await expect(approveProposal(github,89,'b'.repeat(40),123)).rejects.toMatchObject({code:'stale_review',status:409}); + expect(network.mock.calls.some(([,init])=>init?.method==='POST')).toBe(false); + }); + it.each(['closed','draft','base','foreign-base','fork','non-management','invalid-head'])('refuses unsupported proposal scope: %s',async kind=>{ + const pr=proposal(); + if(kind==='closed') pr.state='closed'; + if(kind==='draft') pr.draft=true; + if(kind==='base') pr.base.ref='other'; + if(kind==='foreign-base') pr.base.repo.full_name='other/repo'; + if(kind==='fork') pr.head.repo.full_name='other/repo'; + if(kind==='non-management') pr.head.ref='application-code'; + if(kind==='invalid-head') pr.head.sha='bad'; + const {github,network}=fixture(pr); + await expect(approveProposal(github,89,head,123)).rejects.toMatchObject({code:'unsupported_proposal'}); + expect(network.mock.calls.some(([,init])=>init?.method==='POST')).toBe(false); + }); + it('refuses incomplete file lists and bounds the diff preview',async()=>{ + const pr=proposal();pr.changed_files=2; + await expect(proposalReview(fixture(pr).github,89,123)).rejects.toMatchObject({code:'incomplete_review'}); + pr.changed_files=5; + const result=await proposalReview(fixture(pr,Array(5).fill({...file,patch:'x'.repeat(70000)})).github,89,123); + expect(result.files.reduce((size,item)=>size+item.patch.length,0)).toBe(256*1024); + expect(result.files.every(item=>item.limited)).toBe(true); + pr.changed_files=101; + await expect(proposalReview(fixture(pr).github,89,123)).rejects.toMatchObject({code:'large_proposal'}); + }); + it.each(['../89','89/reviews',0,-1,null,'01','89?repo=other',[89]])('rejects invalid proposal identifiers: %j',value=>{ + expect(()=>proposalNumber(value)).toThrow(); + }); + it('requires the authenticated reviewer, CSRF/Origin and explicit review confirmation at the API',async()=>{ + const env={MANAGEMENT_ORIGIN:'https://manage.uclai.co.uk',GITHUB_CLIENT_ID:'test',GITHUB_CLIENT_SECRET:'test-secret',SESSION_SECRET:'s'.repeat(64),RATE_LIMITER:{limit:async()=>({success:true})}} as unknown as ManagementEnv; + const session={token:'test-review-session-token',csrf:'review-csrf',expires:Date.now()+60000}; + const cookie=await seal(env,'session',session); + vi.spyOn(Github.prototype,'actor').mockResolvedValue({id:123,login:'reviewer',admin:false}); + const {network}=fixture();vi.stubGlobal('fetch',network); + const request=(body:object,extra:Record={})=>new Request(env.MANAGEMENT_ORIGIN+'/api/approve',{method:'POST',headers:{Cookie:`${SESSION_COOKIE}=${cookie}`,Origin:env.MANAGEMENT_ORIGIN,'X-CSRF-Token':session.csrf,'Content-Type':'application/json',...extra},body:JSON.stringify(body)}); + expect((await handleManagement(request({number:89,head,reviewed:true},{Origin:'https://evil.example'}),env)).status).toBe(403); + expect((await handleManagement(request({number:89,head,reviewed:true},{'X-CSRF-Token':''}),env)).status).toBe(403); + expect((await handleManagement(request({number:89,head,reviewed:true},{Authorization:'Bearer forged'}),env)).status).toBe(403); + expect((await handleManagement(request({number:89,head}),env)).status).toBe(400); + expect((await handleManagement(request({number:89,head,reviewed:true,extra:'x'.repeat(9000)}),env)).status).toBe(413); + expect(network).not.toHaveBeenCalled(); + const result=await handleManagement(request({number:89,head,reviewed:true,event:'MERGE',reviewerId:456,repository:'other/private'}),env); + expect(result.status).toBe(201);expect(await result.text()).not.toContain(session.token); + expect(JSON.parse(String(network.mock.calls.find(([,init])=>init?.method==='POST')?.[1]?.body))).toEqual({event:'APPROVE',commit_id:head}); + vi.mocked(Github.prototype.actor).mockResolvedValue({id:456,login:'author',admin:false}); + expect((await handleManagement(request({number:89,head,reviewed:true,reviewerId:123}),env)).status).toBe(403); + expect(network.mock.calls.filter(([,init])=>init?.method==='POST')).toHaveLength(1); + expect((await handleManagement(new Request(env.MANAGEMENT_ORIGIN+'/api/approve'),env)).status).toBe(401); + }); +}); diff --git a/services/knowledge/tests/management-runtime.test.ts b/services/knowledge/tests/management-runtime.test.ts index 59b80d5..e304cd1 100644 --- a/services/knowledge/tests/management-runtime.test.ts +++ b/services/knowledge/tests/management-runtime.test.ts @@ -22,7 +22,7 @@ describe('management sign-in and status in the actual Workers runtime', () => { // Adapt only the limiter/assets bindings. Production authentication, native // fetch/Request, cookie crypto, GitHub client and status code stay unchanged. const script = (await build({ stdin:{ contents:`import app from './src/management/worker.ts'; export default { fetch(request,env) { return app.fetch(request,{...env,RATE_LIMITER:{limit:async()=>({success:true})},ASSETS:{fetch:async()=>new Response('PRIVATE MANAGEMENT UI',{headers:{'Content-Type':'text/html'}})}}); } };`, resolveDir:process.cwd(), sourcefile:'management-runtime-fixture.ts' }, bundle:true, write:false, format:'esm', platform:'browser', target:'es2022', conditions:['workerd','worker','browser'], external:['cloudflare:workers','node:*'], logLevel:'silent' })).outputFiles[0].text; - const remote = `let revoked=false, redirectApi=false, unexpected=0; + const remote = `let revoked=false, redirectApi=false, membership='active', unexpected=0; export default { async fetch(request) { const url=new URL(request.url); if(url.hostname==='fixture.example') { @@ -30,6 +30,7 @@ export default { async fetch(request) { if(url.pathname==='/grant') revoked=false; if(url.pathname==='/redirect-api') redirectApi=true; if(url.pathname==='/normal-api') redirectApi=false; + if(url.pathname==='/membership') membership=url.searchParams.get('state')||'active'; return Response.json({unexpected}); } if(url.hostname==='github.com' && url.pathname==='/login/oauth/access_token') { @@ -42,7 +43,15 @@ export default { async fetch(request) { if(request.headers.get('Authorization')!=='Bearer runtime-test-user-token') return new Response('Invalid test token',{status:401}); if(redirectApi) return new Response('Do not follow',{status:302,headers:{Location:'https://forbidden.example/collect'}}); if(url.pathname==='/user') return Response.json({id:123,login:'member'}); + if(url.pathname==='/user/memberships/orgs/UCLAIS') { + if(membership==='missing') return new Response('Not a member',{status:404}); + if(membership==='unavailable') return new Response('Provider unavailable',{status:503}); + if(membership==='blocked') return new Response('Missing app permission',{status:403}); + return Response.json({state:membership==='wrong-org'?'active':membership,organization:{id:membership==='wrong-org'?1:61075473}}); + } if(url.pathname==='/repos/UCLAIS/UCLAIS.github.io') return Response.json({permissions:{push:!revoked}}); + if(url.pathname==='/repos/UCLAIS/UCLAIS.github.io/pulls/89') return Response.json({state:'open',draft:false,title:'Review fixture',user:{id:456,login:'author'},base:{ref:'main',repo:{full_name:'UCLAIS/UCLAIS.github.io'}},head:{sha:'a'.repeat(40),ref:'committee/fixture',repo:{full_name:'UCLAIS/UCLAIS.github.io'}},changed_files:1}); + if(url.pathname==='/repos/UCLAIS/UCLAIS.github.io/pulls/89/files') return Response.json([{filename:'knowledge/faqs/example.md',status:'modified',patch:'@@ -1 +1 @@\\n-Old\\n+New'}]); if(url.pathname.endsWith('/actions/runs')) return Response.json({workflow_runs:[]}); if(url.pathname.endsWith('/commits/main')) return Response.json({sha:'b'.repeat(40),commit:{tree:{sha:'c'.repeat(40)}}}); if(url.pathname.includes('/git/trees/')) return Response.json({truncated:false,tree:[{path:'knowledge/faqs/example.md',type:'blob',mode:'100644',size:20,sha:'d'.repeat(40)}]}); @@ -69,6 +78,8 @@ export default { async fetch(request) { expect(await ui.text()).toBe('PRIVATE MANAGEMENT UI'); const library = await app.fetch(origin+'/api/library',{headers}); expect(library.status).toBe(200); expect(await library.json()).toMatchObject({files:[{path:'knowledge/faqs/example.md'}]}); + const review = await app.fetch(origin+'/api/proposal?number=89',{headers}); + expect(review.status).toBe(200); expect(await review.json()).toMatchObject({number:89,canApprove:true,head:'a'.repeat(40),files:[{path:'knowledge/faqs/example.md'}]}); const status = await app.fetch(origin+'/api/status',{headers}); expect(status.status).toBe(200); const body = await status.json() as any; @@ -91,4 +102,18 @@ export default { async fetch(request) { expect(denied.status).toBe(403); expect(await denied.text()).not.toContain('PRIVATE MANAGEMENT UI'); } finally { await upstream.fetch('https://fixture.example/grant'); } }); + it.each(['pending','missing','wrong-org','unavailable','blocked'])('denies sign-in and existing private sessions when membership is %s, even with repository write access', async state=>{ + const cookie = await session(); + await upstream.fetch('https://fixture.example/membership?state='+state); + try { + const expected = ['unavailable','blocked'].includes(state) ? 502 : 403; + const callback = await signIn(); + expect(callback.status).toBe(expected); + expect(callback.headers.get('Set-Cookie') || '').not.toContain('__Host-committee-session='); + for(const path of ['/app.js','/api/library']) { + const denied = await app.fetch(origin+path,{headers:{Cookie:'__Host-committee-session='+cookie}}); + expect(denied.status).toBe(expected); expect(await denied.text()).not.toContain('PRIVATE MANAGEMENT UI'); + } + } finally { await upstream.fetch('https://fixture.example/membership?state=active'); } + }); }); diff --git a/services/knowledge/tests/management.test.ts b/services/knowledge/tests/management.test.ts index c43abdd..261055d 100644 --- a/services/knowledge/tests/management.test.ts +++ b/services/knowledge/tests/management.test.ts @@ -1,10 +1,18 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { assertWrite, challenge, LOGIN_COOKIE, ManagementEnv, seal, SESSION_COOKIE, unseal } from '../src/management/auth'; -import { Github, knowledgePath, slidePath } from '../src/management/github'; +import { Github, knowledgePath, newSlideDetails, slidePath } from '../src/management/github'; import { handleManagement } from '../src/management/worker'; const env = { MANAGEMENT_ORIGIN: 'https://manage.uclai.co.uk', GITHUB_CLIENT_ID: 'configured-id', GITHUB_CLIENT_SECRET: 'configured-secret', SESSION_SECRET: 'a'.repeat(64), ASSETS: { fetch: vi.fn(async () => new Response('PRIVATE UI')) }, RATE_LIMITER: { limit: vi.fn(async () => ({ success: true })) } } as unknown as ManagementEnv; const session = { token: 'test-token', csrf: 'csrf-test', expires: Date.now() + 60_000 }; +const activeMembership = { state: 'active', organization: { id: 61075473 } }; +function actorResponse(url: RequestInfo | URL, push = true) { + const path = new URL(String(url)).pathname; + if (path === '/user') return Response.json({ id: 123, login: 'member' }); + if (path === '/user/memberships/orgs/UCLAIS') return Response.json(activeMembership); + if (path === '/repos/UCLAIS/UCLAIS.github.io') return Response.json({ permissions: { push } }); + throw new Error('Unexpected actor request.'); +} afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); describe('management security boundaries', () => { @@ -28,18 +36,49 @@ describe('management security boundaries', () => { expect(network).not.toHaveBeenCalled(); vi.unstubAllGlobals(); }); - it('requires both live repository write permission and any configured stable-ID restriction', async () => { - const network = vi.fn(async (url: any) => Response.json(String(url).endsWith('/user') ? { id: 123, login: 'member' } : { permissions: { push: false } })); + it('requires active membership, live repository write permission and any configured stable-ID restriction', async () => { + const network = vi.fn(async (url: RequestInfo | URL) => actorResponse(url, false)); const github = new Github('token', network as typeof fetch); await expect(github.actor()).rejects.toMatchObject({ code: 'forbidden' }); - network.mockImplementation(async (url: any) => Response.json(String(url).endsWith('/user') ? { id: 123, login: 'member' } : { permissions: { push: true } })); + network.mockImplementation(async (url: RequestInfo | URL) => actorResponse(url)); await expect(github.actor('456')).rejects.toMatchObject({ code: 'forbidden' }); await expect(github.actor('123')).resolves.toMatchObject({ id: 123 }); }); + it.each([ + { state: 'pending', organization: { id: 61075473 } }, + { state: 'active', organization: { id: 1 } }, + { state: 'active' }, + {}, + null, + ])('denies malformed, pending or different-organisation membership even with write access: %j', async membership => { + const network = vi.fn(async (url: RequestInfo | URL) => new URL(String(url)).pathname === '/user/memberships/orgs/UCLAIS' ? Response.json(membership) : actorResponse(url)); + await expect(new Github('token', network as typeof fetch).actor()).rejects.toMatchObject({ code: 'forbidden', status: 403 }); + }); + it.each([404, 403, 500])('fails closed when the membership endpoint returns %i', async status => { + const network = vi.fn(async (url: RequestInfo | URL) => new URL(String(url)).pathname === '/user/memberships/orgs/UCLAIS' ? new Response('PRIVATE_PROVIDER_BODY', { status }) : actorResponse(url)); + await expect(new Github('token', network as typeof fetch).actor()).rejects.toMatchObject({ status: status === 404 ? 403 : 502 }); + }); + it.each(['pending', 'missing', 'wrong-org', 'unavailable'])('denies authenticated writes before any repository mutation when membership is %s', async state => { + // Miniflare's external proxy rejects synthetic POST Origin headers before + // entering the Worker. Exercise a valid Origin/CSRF POST at the handler here; + // native tests separately prove OAuth and existing private-session denial. + const network = vi.fn(async (url: RequestInfo | URL, _init?: RequestInit) => { + if (new URL(String(url)).pathname !== '/user/memberships/orgs/UCLAIS') return actorResponse(url); + if (state === 'unavailable') return new Response('PRIVATE_PROVIDER_BODY', {status:503}); + if (state === 'missing') return new Response('Not a member', {status:404}); + return Response.json({state:state === 'pending' ? 'pending' : 'active',organization:{id:state === 'wrong-org' ? 1 : 61075473}}); + }); + vi.stubGlobal('fetch', network); + const cookie = await seal(env, 'session', session); + const result = await handleManagement(new Request(env.MANAGEMENT_ORIGIN+'/api/knowledge',{method:'POST',headers:{Cookie:`${SESSION_COOKIE}=${cookie}`,Origin:env.MANAGEMENT_ORIGIN,'Content-Type':'application/json','X-CSRF-Token':session.csrf},body:'{}'}),env); + expect(result.status).toBe(state === 'unavailable' ? 502 : 403); + expect(await result.text()).not.toContain('PRIVATE_PROVIDER_BODY'); + expect(network.mock.calls.every(([,init]) => init?.method === 'GET')).toBe(true); + }); it('calls receiver-sensitive network APIs without binding the Github client as this', async () => { const network = function(this: unknown, url: RequestInfo | URL) { if (this !== undefined) throw new TypeError('Illegal invocation'); - return Promise.resolve(Response.json(String(url).endsWith('/user') ? { id:123, login:'member' } : { permissions:{ push:true } })); + return Promise.resolve(actorResponse(url)); }; await expect(new Github('test-token', network as typeof fetch).actor()).resolves.toMatchObject({ id:123, login:'member' }); }); @@ -64,7 +103,7 @@ describe('management security boundaries', () => { vi.stubGlobal('fetch', function(this:unknown, url:RequestInfo | URL) { if (this !== undefined && this !== globalThis) throw new TypeError('Illegal invocation'); if (String(url).endsWith('/login/oauth/access_token')) return Promise.resolve(Response.json({ access_token:'known-test-user-token', expires_in:28800 })); - return Promise.resolve(Response.json(String(url).endsWith('/user') ? { id:123, login:'member' } : { permissions:{ push:true } })); + return Promise.resolve(actorResponse(url)); }); const result = await handleManagement(new Request(env.MANAGEMENT_ORIGIN + '/auth/callback?code=test-code&state=expected', { headers:{ Cookie:`${LOGIN_COOKIE}=${value}` } }), env); expect(result.status).toBe(302); @@ -85,6 +124,28 @@ describe('management security boundaries', () => { expect(() => assertWrite(request(env.MANAGEMENT_ORIGIN, ''), env, session)).toThrow(); expect(() => assertWrite(request(env.MANAGEMENT_ORIGIN, session.csrf), env, session)).not.toThrow(); }); + it.each([ + {headers:{Origin:'https://manage.uclai.co.uk.evil.example'},status:403}, + {headers:{'X-CSRF-Token':''},status:403}, + {headers:{Authorization:'Bearer forged-test-token'},status:403}, + {headers:{'Content-Type':'text/plain'},status:415}, + ])('rejects an authenticated upload with invalid write headers before any repository work: %j', async sample => { + vi.spyOn(Github.prototype,'actor').mockResolvedValue({id:123,login:'member',admin:false}); + const snapshot = vi.spyOn(Github.prototype,'snapshot'); + const proposal = vi.spyOn(Github.prototype,'proposal'); + const cookie = await seal(env,'session',session); + const headers = new Headers({Cookie:`${SESSION_COOKIE}=${cookie}`,Origin:env.MANAGEMENT_ORIGIN,'X-CSRF-Token':session.csrf,'Content-Type':'application/json'}); + Object.entries(sample.headers).forEach(([key,value])=>headers.set(key,value!)); + const result = await handleManagement(new Request(env.MANAGEMENT_ORIGIN+'/api/slides',{method:'POST',headers,body:'{}'}),env); + expect(result.status).toBe(sample.status); + expect(snapshot).not.toHaveBeenCalled(); expect(proposal).not.toHaveBeenCalled(); + }); + it('rejects duplicate encrypted session cookies without calling GitHub', async () => { + const network = vi.fn(); vi.stubGlobal('fetch',network); + const cookie = await seal(env,'session',session); + const result = await handleManagement(new Request(env.MANAGEMENT_ORIGIN+'/api/session',{headers:{Cookie:`${SESSION_COOKIE}=${cookie}; ${SESSION_COOKIE}=${cookie}`}}),env); + expect(result.status).toBe(401); expect(network).not.toHaveBeenCalled(); + }); it('does not let content editing address workflows, private files, traversal or arbitrary slides', () => { for (const input of ['.github/workflows/deploy.yml', 'knowledge/faqs/../secret.md', 'knowledge/tutorials/.hidden.md', 'knowledge/tutorials/_templates/a.md', 'knowledge/faqs/README.md', 'knowledge/faqs/evil%2f.md', 'knowledge/withdrawn.json']) expect(() => knowledgePath(input)).toThrow(); expect(knowledgePath('knowledge/nexus/2026-27/session.md')).toBe('knowledge/nexus/2026-27/session.md'); @@ -98,7 +159,7 @@ describe('management security boundaries', () => { }); it('refuses revoked permissions on an existing session before serving private assets', async () => { const value = await seal(env, 'session', session); - vi.stubGlobal('fetch', vi.fn(async (url: any) => Response.json(String(url).endsWith('/user') ? { id: 123, login: 'member' } : { permissions: { push: false } }))); + vi.stubGlobal('fetch', vi.fn(async (url: RequestInfo | URL) => actorResponse(url, false))); const result = await handleManagement(new Request(env.MANAGEMENT_ORIGIN + '/app.js', { headers: { Cookie: `${SESSION_COOKIE}=${value}` } }), env); expect(result.status).toBe(403); expect(await result.text()).not.toContain('PRIVATE UI'); vi.unstubAllGlobals(); @@ -124,13 +185,49 @@ describe('management security boundaries', () => { expect(changes.some(change => change.path === canonical)).toBe(false); expect(JSON.parse(changes[1].content)).toEqual({ attribution: 'Original author', year: '2026/27', license: 'CC-BY', slides: 'knowledge-assets/slides/tutorials/lesson/source.pdf' }); }); - it('does not silently replace an existing session through the new-deck form', async () => { + it('generates a safe, readable session path from the academic year, series and title', () => { + expect(newSlideDetails({collection:'tutorials',year:'2026/27',series:'Machine Learning',order:'2',title:'Múltivariate Regression: x₁ & x₂',path:'.github/workflows/unsafe.yml'})).toMatchObject({path:'knowledge/tutorials/2026-27/machine-learning/session-2-multivariate-regression-x1-x2.md',year:'2026/27',order:2}); + expect(newSlideDetails({collection:'nexus',year:'2026/27',title:'What’s AI?'}).path).toBe('knowledge/nexus/2026-27/whats-ai.md'); + }); + it.each([ + {collection:'../guides',year:'2026/27',title:'Lesson'}, + {collection:'tutorials',year:'../2026',title:'Lesson'}, + {collection:'tutorials',year:'2026/27',title:'../'}, + {collection:'tutorials',year:'2026/27',title:'Lesson',order:'2'}, + {collection:'tutorials',year:'2026/27',title:'Lesson',series:'ML',order:'1.5'}, + ])('rejects invalid autogenerated-path metadata before any write: %j', input => { + expect(() => newSlideDetails(input)).toThrow(); + }); + it('submits a new deck using a server-generated path even if an arbitrary path is supplied', async () => { + vi.spyOn(Github.prototype,'actor').mockResolvedValue({id:123,login:'member',admin:false}); + const base = 'a'.repeat(40); + vi.spyOn(Github.prototype,'snapshot').mockResolvedValue({commit:base,treeSha:base,entries:[]}); + const proposal = vi.spyOn(Github.prototype,'proposal').mockResolvedValue({number:99,url:'https://github.com/UCLAIS/UCLAIS.github.io/pull/99',commit:base}); + const value = await seal(env,'session',session); + const result = await handleManagement(new Request(env.MANAGEMENT_ORIGIN+'/api/slides',{method:'POST',headers:{Cookie:`${SESSION_COOKIE}=${value}`,Origin:env.MANAGEMENT_ORIGIN,'X-CSRF-Token':session.csrf,'Content-Type':'application/json'},body:JSON.stringify({collection:'tutorials',year:'2026/27',title:'Multivariate Regression',path:'.github/workflows/unsafe.yml',commit:base,pdf:btoa('%PDF-1.4\ntest fixture')})}),env); + expect(result.status).toBe(201); + const changes = proposal.mock.calls[0][1]; + expect(changes.map(change => change.path)).toEqual(['knowledge-assets/slides/tutorials/2026-27/multivariate-regression/source.pdf','knowledge/tutorials/2026-27/multivariate-regression.md','knowledge/tutorials/2026-27/multivariate-regression.meta.json']); + expect(JSON.parse(changes[2].content)).toMatchObject({title:'Multivariate Regression',year:'2026/27',slides:changes[0].path}); + }); + it.each([8 * 1024 * 1024, 8 * 1024 * 1024 + 1])('enforces the exact decoded PDF byte limit for %i bytes', async bytes => { + vi.spyOn(Github.prototype,'actor').mockResolvedValue({id:123,login:'member',admin:false}); + const base = 'a'.repeat(40); + const snapshot = vi.spyOn(Github.prototype,'snapshot').mockResolvedValue({commit:base,treeSha:base,entries:[]}); + const proposal = vi.spyOn(Github.prototype,'proposal').mockResolvedValue({number:99,url:'https://github.com/UCLAIS/UCLAIS.github.io/pull/99',commit:base}); + const pdf = Buffer.alloc(bytes,32); pdf.write('%PDF-1.4\n'); + const cookie = await seal(env,'session',session); + const result = await handleManagement(new Request(env.MANAGEMENT_ORIGIN+'/api/slides',{method:'POST',headers:{Cookie:`${SESSION_COOKIE}=${cookie}`,Origin:env.MANAGEMENT_ORIGIN,'X-CSRF-Token':session.csrf,'Content-Type':'application/json'},body:JSON.stringify({collection:'tutorials',year:'2026/27',title:'Limit check',commit:base,pdf:pdf.toString('base64')})}),env); + expect(result.status).toBe(bytes === 8 * 1024 * 1024 ? 201 : 400); + if (bytes > 8 * 1024 * 1024) { expect(snapshot).not.toHaveBeenCalled(); expect(proposal).not.toHaveBeenCalled(); } + }); + it.each(['knowledge/tutorials/2026-27/lesson.md','knowledge/tutorials/2026-27/lesson.meta.json','knowledge-assets/slides/tutorials/2026-27/lesson/source.pdf'])('refuses an autogenerated path collision with %s', async existingPath => { vi.spyOn(Github.prototype, 'actor').mockResolvedValue({ id: 123, login: 'member', admin: false }); const base = 'a'.repeat(40); - vi.spyOn(Github.prototype, 'snapshot').mockResolvedValue({ commit: base, treeSha: base, entries: [{ path: 'knowledge/tutorials/lesson.md', sha: base, type: 'blob', mode: '100644' }] }); + vi.spyOn(Github.prototype, 'snapshot').mockResolvedValue({ commit: base, treeSha: base, entries: [{ path: existingPath, sha: base, type: 'blob', mode: '100644' }] }); const proposal = vi.spyOn(Github.prototype, 'proposal'); const value = await seal(env, 'session', session); - const result = await handleManagement(new Request(env.MANAGEMENT_ORIGIN + '/api/slides', { method: 'POST', headers: { Cookie: `${SESSION_COOKIE}=${value}`, Origin: env.MANAGEMENT_ORIGIN, 'X-CSRF-Token': session.csrf, 'Content-Type': 'application/json' }, body: JSON.stringify({ path: 'knowledge/tutorials/lesson.md', commit: base, pdf: btoa('%PDF-1.4\ntest fixture') }) }), env); + const result = await handleManagement(new Request(env.MANAGEMENT_ORIGIN + '/api/slides', { method: 'POST', headers: { Cookie: `${SESSION_COOKIE}=${value}`, Origin: env.MANAGEMENT_ORIGIN, 'X-CSRF-Token': session.csrf, 'Content-Type': 'application/json' }, body: JSON.stringify({ collection:'tutorials',year:'2026/27',title:'Lesson',commit: base, pdf: btoa('%PDF-1.4\ntest fixture') }) }), env); expect(result.status).toBe(409); expect(proposal).not.toHaveBeenCalled(); }); });