-
Notifications
You must be signed in to change notification settings - Fork 0
Auth: keep content public and restrict uploads to administrators #917
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
c4520fe
fix(auth): keep content public and restrict uploads
BigSimmo 79355ba
docs: record auth access review
BigSimmo 812cbf7
Merge branch 'main' into codex/public-content-account-access
BigSimmo 2ed2378
fix: apply CodeRabbit auto-fixes
coderabbitai[bot] 7a2c423
Merge branch 'main' into codex/public-content-account-access
BigSimmo bfe6baf
fix(account): check favourite save result.success explicitly
cursoragent de0cddb
fix(ci): prettier-format account-data-provider
BigSimmo 7b1cb54
Merge branch 'main' into codex/public-content-account-access
BigSimmo 0b16873
fix(schema): mirror account tables into schema.sql and drift manifest
cursoragent 553ea78
fix(account): add no-store cache headers to favourites reads
cursoragent 85519ec
fix(viewer): gate document summarization behind administrator access
cursoragent 2e7595d
fix(scripts): add missing list-database-skills helper
cursoragent 966396a
fix(ci): prettier-format new account and skills tests
cursoragent File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.