Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 3 additions & 6 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -118,12 +118,9 @@ RAG_AWAIT_QUERY_LOGS=false
# Design-exploration mockup routes (/mockups/*) 404 in production builds unless
# explicitly opted in. Always reachable in dev/test.
#NEXT_PUBLIC_MOCKUPS_ENABLED=false
# Allow unauthenticated visitors to upload documents — gates an anonymous write
# path, so leave unset (off) unless you intend a public-intake workspace. When
# enabled, anonymous uploads are owned by PUBLIC_WORKSPACE_OWNER_ID so that
# owner-scoped reads still apply to them.
#NEXT_PUBLIC_PUBLIC_UPLOADS_ENABLED=false
#PUBLIC_WORKSPACE_OWNER_ID=
# Content browsing is public. Uploads and corpus-management actions require a
# signed-in user whose Supabase app_metadata.site_role is "administrator".
# Assign that claim only through the approval-gated auth:set-administrator script.
# Optional canonical public origin for generated metadata, for example
# https://clinical-kb.example.org. Railway deployments otherwise use the
# provider-supplied RAILWAY_PUBLIC_DOMAIN; request hosts are only used in dev.
Expand Down
1 change: 1 addition & 0 deletions docs/branch-review-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Use this ledger to prevent repeated branch and PR reviews when the reviewed HEAD

| Date | Branch or ref | Reviewed HEAD | Scope | Outcome | Checks |
| ---------- | -------------------------------------------------------- | ---------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 2026-07-19 | codex/public-content-account-access | c4520fe404fd0fb0616ea489468d68922123a683 | authentication, account-data isolation, anonymous content access, and administrator-only mutation pre-PR review | No high-confidence P0-P2 finding remained. Anonymous reads stay constrained to the published public corpus; bearer/cookie sessions are server-validated; favourites and preferences are queried through the service role only after authentication and always filtered by the authenticated user ID; administrator authorization trusts only Supabase app metadata; local/demo modes cannot bypass upload authorization; and the upload route authorizes before parsing or storage writes. Highest residual risk is live OAuth/provider configuration and hosted end-to-end behavior, which require deployed-environment proof. | npm run verify:pr-local passed runtime, formatting, ESLint, TypeScript, 320 test files / 2,901 tests, production build/client-secret scan, and 36 offline RAG fixtures. Focused auth/current-main integration reruns passed 74/74 and 11/11. Live Supabase migration and role/grant verification were completed separately against the approved Clinical KB project. check:production-readiness passed runtime/privacy guards but was environment-blocked by absent local Supabase/OpenAI variables. verify:ui was lock-blocked before Playwright by another registered worktree; local project identity was verified at the repository-selected server URL before cleanup. |
| 2026-07-14 | multiple remote branches (16 refs) | multiple SHAs | remote branch cleanup | Safely deleted 16 fully merged and redundant remote branches on origin (including `claude/canary-gate-fixes`, `claude/codebase-index-coverage`, `claude/design-elevation-e1e2`, `claude/design-sync-fixes-p1`, `claude/docs-script-linter`, `claude/document-image-viewer-review-ox7t11`, `claude/generation-token-starvation-fix`, `claude/github-actions-codex-issue-f4t4s5`, `claude/hero-composer-hydration`, `claude/pdf-signed-url-refresh`, `claude/pt-audit-monitor-marker-fix`, `claude/pt-audit-pr2-variant-early-exit`, `claude/pt-audit-pr4-trust-copy`, `claude/pt-audit-pt17-live-monitor`, `codex/eval-canary-quota-handling`, and `cursor/clean-sentry-lockfile-orphans-74cf`). | Confirmed zero unique commits against origin/main and MERGED/CLOSED status on GitHub via `gh pr list`. |
| 2026-07-14 | worktrees (6244, 8ba3, b6ff, e6b5, repo-improvement-review-09945c) | detached HEADs | local worktree cleanup | Safely removed and unregistered 5 clean, inactive worktrees from the git registry. | Ran `git worktree remove` and verified final active worktrees. |
| 2026-07-14 | main | 570e6ba56ae60bea56a32801b9cc96c5a8dfde4f | branch alignment | Fast-forwarded local `main` to latest `origin/main` commit. | Verified main and origin/main revisions and updated ref locally. |
Expand Down
2 changes: 2 additions & 0 deletions docs/site-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -1031,6 +1031,8 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check`

## API routes

- `/api/account/favourites` - Route discovered from app directory Source: `src/app/api/account/favourites/route.ts`.
- `/api/account/preferences` - Route discovered from app directory Source: `src/app/api/account/preferences/route.ts`.
- `/api/answer` - Generate answer response. Source: `src/app/api/answer/route.ts`.
- `/api/answer-feedback` - Route discovered from app directory Source: `src/app/api/answer-feedback/route.ts`.
- `/api/answer/stream` - Streaming answer response. Source: `src/app/api/answer/stream/route.ts`.
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@
"backfill:source-metadata": "node scripts/run-tsx.mjs scripts/backfill-source-metadata.ts",
"backfill:unknown-status": "node scripts/run-tsx.mjs scripts/derive-unknown-status.ts",
"check:supabase-project": "node scripts/run-tsx.mjs scripts/check-supabase-project.ts",
"auth:set-administrator": "node scripts/run-tsx.mjs scripts/set-site-administrator.ts",
"check:indexing": "node scripts/run-tsx.mjs scripts/check-indexing.ts",
"check:m13-migration": "node scripts/run-tsx.mjs scripts/check-m13-migration.ts",
"check:july8-live-batch": "node scripts/run-tsx.mjs scripts/check-july8-live-batch.ts",
Expand Down Expand Up @@ -166,6 +167,8 @@
"workflow:rag-lab": "node scripts/productivity-workflow.mjs rag-lab",
"workflow:operator-closeout": "node scripts/productivity-workflow.mjs operator-closeout",
"workflow:lifecycle": "node scripts/productivity-workflow.mjs lifecycle",
"skills": "node scripts/list-database-skills.mjs",
"check:skills": "node scripts/list-database-skills.mjs --check",
Comment thread
cursor[bot] marked this conversation as resolved.
"check:drift": "node scripts/run-tsx.mjs scripts/check-drift.ts",
"drift:manifest": "node scripts/run-tsx.mjs scripts/generate-drift-manifest.ts"
},
Expand Down
120 changes: 120 additions & 0 deletions scripts/list-database-skills.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
#!/usr/bin/env node
/**
* list-database-skills.mjs — list Database workflow skills under .agents/skills
* and verify the catalog stays in sync.
*
* npm run skills — print skill names and descriptions
* npm run check:skills — fail if filesystem skills and database-skills.md diverge
*/
import { readdirSync, readFileSync, statSync } from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";

const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const skillsRoot = path.join(repoRoot, ".agents", "skills");
const catalogPath = path.join(skillsRoot, "database-skills.md");

function parseFrontmatter(markdown) {
const match = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!match) return {};
const fields = {};
for (const line of match[1].split(/\r?\n/)) {
const field = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
if (field) fields[field[1]] = field[2].trim();
}
return fields;
}

/** Discover workflow skills from immediate child directories containing SKILL.md. */
export function discoverSkills(root = skillsRoot, readFile = (filePath) => readFileSync(filePath, "utf8")) {
const entries = readdirSync(root, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort();

return entries.map((id) => {
const skillPath = path.join(root, id, "SKILL.md");
if (!statSync(skillPath, { throwIfNoEntry: false })?.isFile()) {
return { id, name: id, description: "", missingSkillFile: true };
}
const frontmatter = parseFrontmatter(readFile(skillPath));
return {
id,
name: frontmatter.name ?? id,
description: frontmatter.description ?? "",
missingSkillFile: false,
};
});
}

/** Extract backtick skill ids from the maintained catalog markdown. */
export function extractCatalogSkillIds(catalogMarkdown) {
const ids = new Set();
for (const match of catalogMarkdown.matchAll(/`([a-z0-9][a-z0-9-]*)`/g)) {
ids.add(match[1]);
}
return [...ids].sort();
}

/** Return drift between discovered skills and the catalog. */
export function findSkillDrift(skills, catalogIds) {
const discoveredIds = skills.filter((skill) => !skill.missingSkillFile).map((skill) => skill.id);
const discovered = new Set(discoveredIds);
const catalog = new Set(catalogIds);
const missingFromCatalog = discoveredIds.filter((id) => !catalog.has(id));
const missingFromDisk = catalogIds.filter((id) => !discovered.has(id));
const nameMismatches = skills
.filter((skill) => !skill.missingSkillFile && skill.name !== skill.id)
.map((skill) => ({ id: skill.id, name: skill.name }));
const missingSkillFiles = skills.filter((skill) => skill.missingSkillFile).map((skill) => skill.id);
return { missingFromCatalog, missingFromDisk, nameMismatches, missingSkillFiles };
}

function printSkills(skills) {
const available = skills.filter((skill) => !skill.missingSkillFile);
console.log(`Database skills (${available.length}):`);
for (const skill of available) {
const description = skill.description || "(no description)";
console.log(`- ${skill.id} — ${description}`);
}
}

function main() {
const checkMode = process.argv.includes("--check");
const skills = discoverSkills();
const catalogMarkdown = readFileSync(catalogPath, "utf8");
const catalogIds = extractCatalogSkillIds(catalogMarkdown);
const drift = findSkillDrift(skills, catalogIds);

if (!checkMode) {
printSkills(skills);
return;
}

const problems = [];
if (drift.missingSkillFiles.length) {
problems.push(`missing SKILL.md: ${drift.missingSkillFiles.join(", ")}`);
}
if (drift.nameMismatches.length) {
problems.push(
`frontmatter name mismatch: ${drift.nameMismatches.map((item) => `${item.id} -> ${item.name}`).join(", ")}`,
);
}
if (drift.missingFromCatalog.length) {
problems.push(`not listed in database-skills.md: ${drift.missingFromCatalog.join(", ")}`);
}
if (drift.missingFromDisk.length) {
problems.push(`catalog entries without skill directories: ${drift.missingFromDisk.join(", ")}`);
}

if (problems.length > 0) {
console.error("skills check FAILED:");
for (const problem of problems) console.error(`- ${problem}`);
process.exit(1);
}

console.log(`skills check passed: ${skills.length} workflow skill(s) match database-skills.md.`);
}

const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
if (invokedDirectly) main();
46 changes: 46 additions & 0 deletions scripts/set-site-administrator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { loadEnvConfig } from "@next/env";

async function main() {
loadEnvConfig(process.cwd());

if (process.env.ALLOW_SUPABASE_ADMIN_MUTATION !== "true") {
throw new Error(
"Refusing to change Supabase Auth. Re-run only after approval with ALLOW_SUPABASE_ADMIN_MUTATION=true.",
);
}

const emailFlagIndex = process.argv.findIndex((argument) => argument === "--email");
const inlineEmail = process.argv.find((argument) => argument.startsWith("--email="))?.slice("--email=".length);
const requestedEmail = (inlineEmail ?? (emailFlagIndex >= 0 ? process.argv[emailFlagIndex + 1] : ""))
?.trim()
.toLowerCase();

if (!requestedEmail || !requestedEmail.includes("@")) {
throw new Error("Usage: npm run auth:set-administrator -- --email user@example.com");
}

const { createAdminClient } = await import("@/lib/supabase/admin");
const supabase = createAdminClient();
let matchedUser: Awaited<ReturnType<typeof supabase.auth.admin.listUsers>>["data"]["users"][number] | null = null;

for (let page = 1; page <= 100 && !matchedUser; page += 1) {
const { data, error } = await supabase.auth.admin.listUsers({ page, perPage: 1000 });
if (error) throw error;
matchedUser = data.users.find((user) => user.email?.trim().toLowerCase() === requestedEmail) ?? null;
if (data.users.length < 1000) break;
}

if (!matchedUser) throw new Error("No Supabase Auth user matched the supplied email address.");

const { error } = await supabase.auth.admin.updateUserById(matchedUser.id, {
app_metadata: { ...matchedUser.app_metadata, site_role: "administrator" },
});
if (error) throw error;

console.log("Administrator claim updated. Sign out and sign in again before using administration tools.");
}

main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : "Administrator claim update failed.");
process.exitCode = 1;
});
87 changes: 87 additions & 0 deletions src/app/api/account/favourites/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { z } from "zod";

import { jsonError } from "@/lib/http";
import { createAdminClient } from "@/lib/supabase/admin";
import { requireAuthenticatedUser } from "@/lib/supabase/auth";
import { parseJsonBody } from "@/lib/validation/body";

export const runtime = "nodejs";

const mutationSchema = z
.object({
contentType: z.enum(["service", "form", "differential"]),
contentKey: z.string().trim().min(1).max(180),
saved: z.boolean(),
})
.strict();

export async function GET(request: Request) {
try {
const supabase = createAdminClient();
const user = await requireAuthenticatedUser(request, supabase);
const { data, error } = await supabase
.from("user_favourites")
.select("content_type,content_key,created_at")
.eq("user_id", user.id)
.order("created_at", { ascending: false });
if (error) throw new Error(error.message);
return Response.json(
{
favourites: (data ?? []).map((row) => ({
contentType: row.content_type,
contentKey: row.content_key,
createdAt: row.created_at,
})),
},
{
headers: {
"Cache-Control": "private, no-store",
},
},
);
} catch (error) {
return jsonError(error);
}
}

export async function PUT(request: Request) {
try {
const supabase = createAdminClient();
const user = await requireAuthenticatedUser(request, supabase);
const input = await parseJsonBody(request, mutationSchema, "Saved-item request is invalid.");

if (input.saved) {
const { error } = await supabase
.from("user_favourites")
.upsert(
{ user_id: user.id, content_type: input.contentType, content_key: input.contentKey },
{ onConflict: "user_id,content_type,content_key", ignoreDuplicates: true },
);
if (error) throw new Error(error.message);
} else {
const { error } = await supabase
.from("user_favourites")
.delete()
.eq("user_id", user.id)
.eq("content_type", input.contentType)
.eq("content_key", input.contentKey);
if (error) throw new Error(error.message);
}

return Response.json({ saved: input.saved });
} catch (error) {
return jsonError(error);
}
}

export async function DELETE(request: Request) {
try {
const supabase = createAdminClient();
const user = await requireAuthenticatedUser(request, supabase);
const { error } = await supabase.from("user_favourites").delete().eq("user_id", user.id);
if (error) throw new Error(error.message);
return Response.json({ cleared: true });
} catch (error) {
return jsonError(error);
}
}
Loading