From 99578f2ccec9aad89933948e8faab64ec3b20a44 Mon Sep 17 00:00:00 2001 From: Etan Joseph Heyman Date: Tue, 27 Oct 2026 18:41:53 +0200 Subject: [PATCH] fix(convex): restrict operational and catalog functions to internal access Internalize 36 maintenance, catalog, and transcription functions while preserving public reads and authenticated user APIs. Guard the reviewed modules with exact public-export allow-lists. Co-Authored-By: songscript-convex-authz running gpt-6.1-sol --- convex/lyrics.ts | 8 +-- convex/maintenanceVisibility.test.ts | 81 ++++++++++++++++++++++++++++ convex/migration.ts | 10 ++-- convex/oneTimeMigration.ts | 22 ++++---- convex/seed.ts | 6 +-- convex/songs.ts | 12 ++--- convex/testData.ts | 4 +- convex/transcription.ts | 21 +++----- convex/users.ts | 4 +- convex/wordProgress.ts | 8 +-- convex/words.ts | 8 +-- 11 files changed, 129 insertions(+), 55 deletions(-) create mode 100644 convex/maintenanceVisibility.test.ts diff --git a/convex/lyrics.ts b/convex/lyrics.ts index 814e20f..c2b5736 100644 --- a/convex/lyrics.ts +++ b/convex/lyrics.ts @@ -1,4 +1,4 @@ -import { query, mutation } from "./_generated/server"; +import { query, internalMutation } from "./_generated/server"; import { v, ConvexError } from "convex/values"; export const getBySong = query({ @@ -11,7 +11,7 @@ export const getBySong = query({ }, }); -export const create = mutation({ +export const create = internalMutation({ args: { songId: v.id("songs"), lineNumber: v.number(), @@ -27,7 +27,7 @@ export const create = mutation({ }, }); -export const createMany = mutation({ +export const createMany = internalMutation({ args: { songId: v.id("songs"), lyrics: v.array( @@ -57,7 +57,7 @@ export const createMany = mutation({ // Update timestamps for specific lines (used for fixing timestamp issues) // LOCKED: Requires explicit unlock if song.timestampsLocked is true -export const updateTimestamps = mutation({ +export const updateTimestamps = internalMutation({ args: { songId: v.id("songs"), updates: v.array( diff --git a/convex/maintenanceVisibility.test.ts b/convex/maintenanceVisibility.test.ts new file mode 100644 index 0000000..1aecdec --- /dev/null +++ b/convex/maintenanceVisibility.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; +import * as oneTimeMigration from "./oneTimeMigration"; +import * as seed from "./seed"; +import * as testData from "./testData"; +import * as migration from "./migration"; +import * as wordProgress from "./wordProgress"; +import * as users from "./users"; +import * as words from "./words"; +import * as transcription from "./transcription"; +import * as songs from "./songs"; +import * as lyrics from "./lyrics"; + +// Inspect real registrations without executing any maintenance or PII handler. +const modules: Array<{ + name: string; + exports: Readonly>; + publicNames: string[]; +}> = [ + { name: "oneTimeMigration", exports: oneTimeMigration, publicNames: [] }, + { name: "seed", exports: seed, publicNames: [] }, + { name: "testData", exports: testData, publicNames: [] }, + { name: "migration", exports: migration, publicNames: ["migrateAnonymousData"] }, + { name: "users", exports: users, publicNames: ["checkEmailExists", "ensureAppUser"] }, + { name: "songs", exports: songs, publicNames: ["list", "getById"] }, + { name: "lyrics", exports: lyrics, publicNames: ["getBySong"] }, + { name: "words", exports: words, publicNames: ["getByLine", "getBySong"] }, + { name: "transcription", exports: transcription, publicNames: [] }, + { + name: "wordProgress", exports: wordProgress, + publicNames: [ + "getByUser", "getByUserWord", "getByUserPersian", "getByUserPersians", + "getByUserWords", "incrementViewCount", "incrementPlayCount", "toggleLearned", + "setLearned", "getVocabularyByLanguage", "getWordDetailsWithSongs", + "getWordPracticeHistory", + ], + }, +]; + +function registrations(exports: Readonly>) { + return Object.entries(exports).filter(([, fn]) => typeof fn === "function" && ( + "isQuery" in fn || "isMutation" in fn || "isAction" in fn + )); +} + +const functions = modules.flatMap(({ name: module, exports, publicNames }) => + registrations(exports).map(([name, fn]) => ({ + name: `${module}.${name}`, fn, isPublic: publicNames.includes(name), + })), +); + +describe("maintenance API boundary", () => { + it.each(modules)("$name exposes only its approved public API", ({ exports, publicNames }) => { + const actual = registrations(exports) + .filter(([, fn]) => typeof fn === "function" && "isPublic" in fn && fn.isPublic === true) + .map(([name]) => name); + expect(actual.sort()).toEqual([...publicNames].sort()); + }); + + it.each(functions)( + "$name has its approved client visibility", + ({ fn, isPublic }) => { + expect(fn).toHaveProperty(isPublic ? "isPublic" : "isInternal", true); + expect(fn).not.toHaveProperty(isPublic ? "isInternal" : "isPublic", true); + }, + ); + + it("has no frontend references to internal functions", () => { + const sources = import.meta.glob("../src/**/*.{ts,tsx}", { + eager: true, + query: "?raw", + import: "default", + }); + const internalNames = new Set(functions.filter((fn) => !fn.isPublic).map((fn) => fn.name)); + const references = Object.entries(sources).flatMap(([file, source]) => + [...source.matchAll(/\bapi\.(\w+)\.(\w+)/g)] + .filter((match) => internalNames.has(`${match[1]}.${match[2]}`)) + .map((match) => `${file}: ${match[0]}`), + ); + expect(references).toEqual([]); + }); +}); diff --git a/convex/migration.ts b/convex/migration.ts index 921b59c..0505907 100644 --- a/convex/migration.ts +++ b/convex/migration.ts @@ -1,4 +1,4 @@ -import { mutation, internalMutation, query } from "./_generated/server"; +import { mutation, internalMutation, internalQuery } from "./_generated/server"; import { v } from "convex/values"; import { requireAuth } from "./authHelpers"; import type { Id } from "./_generated/dataModel"; @@ -110,10 +110,10 @@ export const migrateUserIdFromBetterAuthToAppUsers = internalMutation({ }); /** - * Public wrapper to run the migration (for CLI execution) + * Internal maintenance migration (for authenticated CLI execution) * Usage: npx convex run migration:runUserIdMigration '{"oldUserId": "...", "newUserId": "..."}' */ -export const runUserIdMigration = mutation({ +export const runUserIdMigration = internalMutation({ args: { oldUserId: v.string(), newUserId: v.string(), @@ -213,7 +213,7 @@ export const runUserIdMigration = mutation({ /** * Query to verify progress data counts for a user */ -export const verifyUserProgressData = query({ +export const verifyUserProgressData = internalQuery({ args: { userId: v.string() }, handler: async (ctx, { userId }) => { const wordProgress = await ctx.db @@ -599,7 +599,7 @@ export const migrateAnonymousData = mutation({ * Update lyrics timestamps for a song * Used for applying WhisperX-generated timestamps */ -export const updateLyricsTimestamps = mutation({ +export const updateLyricsTimestamps = internalMutation({ args: { songTitle: v.string(), timestamps: v.array( diff --git a/convex/oneTimeMigration.ts b/convex/oneTimeMigration.ts index 87d541b..b674165 100644 --- a/convex/oneTimeMigration.ts +++ b/convex/oneTimeMigration.ts @@ -1,9 +1,9 @@ -import { mutation, query } from "./_generated/server"; +import { internalMutation, internalQuery } from "./_generated/server"; import { v } from "convex/values"; import { components } from "./_generated/api"; // One-time migration: Migrate visitorId data to a userId (Better Auth user ID) -export const migrateVisitorToUser = mutation({ +export const migrateVisitorToUser = internalMutation({ args: { visitorId: v.string(), userId: v.string(), // Better Auth user ID directly @@ -101,7 +101,7 @@ export const migrateVisitorToUser = mutation({ }); // Query to check what data exists for a visitorId -export const checkVisitorData = query({ +export const checkVisitorData = internalQuery({ args: { visitorId: v.string() }, handler: async (ctx, { visitorId }) => { const wordProgress = await ctx.db @@ -157,7 +157,7 @@ export const checkVisitorData = query({ }); // Delete a user record by ID -export const deleteUserRecord = mutation({ +export const deleteUserRecord = internalMutation({ args: { userId: v.id("users") }, handler: async (ctx, { userId }) => { await ctx.db.delete(userId); @@ -166,7 +166,7 @@ export const deleteUserRecord = mutation({ }); // Delete test user by email from BOTH Better Auth 'user' table AND app 'users' table -export const deleteTestUserByEmail = mutation({ +export const deleteTestUserByEmail = internalMutation({ args: { email: v.string() }, handler: async (ctx, { email }) => { const results = { @@ -219,7 +219,7 @@ export const deleteTestUserByEmail = mutation({ }); // List all app users in the database -export const listAllAppUsers = query({ +export const listAllAppUsers = internalQuery({ args: {}, handler: async (ctx) => { const users = await ctx.db.query("users").collect(); @@ -228,7 +228,7 @@ export const listAllAppUsers = query({ }); // Fix app user record - backfill authId and correct email -export const fixAppUserRecord = mutation({ +export const fixAppUserRecord = internalMutation({ args: { appUserId: v.id("users"), correctEmail: v.string(), @@ -244,7 +244,7 @@ export const fixAppUserRecord = mutation({ }); // Check what data exists for a userId (in the userId field) -export const checkUserData = query({ +export const checkUserData = internalQuery({ args: { userId: v.string() }, handler: async (ctx, { userId }) => { const wordProgress = await ctx.db @@ -302,7 +302,7 @@ export const checkUserData = query({ // BUG-026 FIX: Migrate from visitorId=BetterAuthID to userId=AppUsersID // The old system stored Better Auth ID in the visitorId field // The new system uses app users ID in the userId field -export const migrateVisitorIdToUserId = mutation({ +export const migrateVisitorIdToUserId = internalMutation({ args: { oldVisitorId: v.string(), // Better Auth ID that was stored in visitorId newUserId: v.string(), // App users ID to store in userId @@ -420,7 +420,7 @@ export const migrateVisitorIdToUserId = mutation({ }); // Migrate userId field from old Better Auth ID to new app users ID -export const migrateUserId = mutation({ +export const migrateUserId = internalMutation({ args: { oldUserId: v.string(), newUserId: v.string(), @@ -517,7 +517,7 @@ export const migrateUserId = mutation({ }); // List all unique visitorIds in the database -export const listAllVisitorIds = query({ +export const listAllVisitorIds = internalQuery({ args: {}, handler: async (ctx) => { const visitorIds = new Set(); diff --git a/convex/seed.ts b/convex/seed.ts index be034b8..a445f56 100644 --- a/convex/seed.ts +++ b/convex/seed.ts @@ -1,4 +1,4 @@ -import { mutation } from "./_generated/server"; +import { internalMutation } from "./_generated/server"; // Word-by-word breakdown for all Baraye lyrics const barayeWords = [ @@ -328,7 +328,7 @@ const barayeLyrics = [ { lineNumber: 31, startTime: 145.0, endTime: 151.46, original: "بـــرای آزادی", transliteration: "Barāye āzādi", hebrew: "בָּרָאיֶה אָזָאדִי", english: "For freedom", audioSnippetUrl: "/audio/baraye/baraye_031.mp3" }, ]; -export const seedBaraye = mutation({ +export const seedBaraye = internalMutation({ args: {}, handler: async (ctx) => { // Check if Baraye already exists (try new youtubeId first, then old) @@ -402,7 +402,7 @@ export const seedBaraye = mutation({ }); // Seed word data for Baraye song -export const seedBarayeWords = mutation({ +export const seedBarayeWords = internalMutation({ args: {}, handler: async (ctx) => { // Find Baraye song diff --git a/convex/songs.ts b/convex/songs.ts index a44f8b9..538edd8 100644 --- a/convex/songs.ts +++ b/convex/songs.ts @@ -1,4 +1,4 @@ -import { query, mutation } from "./_generated/server"; +import { query, internalMutation } from "./_generated/server"; import { v } from "convex/values"; export const list = query({ @@ -15,7 +15,7 @@ export const getById = query({ }, }); -export const create = mutation({ +export const create = internalMutation({ args: { title: v.string(), artist: v.string(), @@ -31,7 +31,7 @@ export const create = mutation({ }); // Delete a song and all its lyrics/words -export const deleteSong = mutation({ +export const deleteSong = internalMutation({ args: { songId: v.id("songs") }, handler: async (ctx, args) => { // Delete all lyrics for this song @@ -59,7 +59,7 @@ export const deleteSong = mutation({ }); // Set the video URL for a song -export const setVideoUrl = mutation({ +export const setVideoUrl = internalMutation({ args: { songId: v.id("songs"), videoUrl: v.string() }, handler: async (ctx, args) => { await ctx.db.patch(args.songId, { videoUrl: args.videoUrl }); @@ -68,7 +68,7 @@ export const setVideoUrl = mutation({ }); // Lock timestamps to prevent AI from modifying them without explicit unlock -export const lockTimestamps = mutation({ +export const lockTimestamps = internalMutation({ args: { songId: v.id("songs") }, handler: async (ctx, args) => { await ctx.db.patch(args.songId, { timestampsLocked: true }); @@ -77,7 +77,7 @@ export const lockTimestamps = mutation({ }); // Unlock timestamps (requires user to explicitly request this) -export const unlockTimestamps = mutation({ +export const unlockTimestamps = internalMutation({ args: { songId: v.id("songs") }, handler: async (ctx, args) => { await ctx.db.patch(args.songId, { timestampsLocked: false }); diff --git a/convex/testData.ts b/convex/testData.ts index 06d71ec..8a51d59 100644 --- a/convex/testData.ts +++ b/convex/testData.ts @@ -1,7 +1,7 @@ -import { mutation } from "./_generated/server"; +import { internalMutation } from "./_generated/server"; // Create test practice data for dashboard testing -export const createTestPracticeData = mutation({ +export const createTestPracticeData = internalMutation({ args: {}, handler: async (ctx) => { const visitorId = "test-visitor-123"; diff --git a/convex/transcription.ts b/convex/transcription.ts index c872203..cc8bedc 100644 --- a/convex/transcription.ts +++ b/convex/transcription.ts @@ -1,5 +1,5 @@ import { v } from "convex/values"; -import { mutation, query } from "./_generated/server"; +import { internalMutation, internalQuery } from "./_generated/server"; /** * Transcription job status tracking. @@ -20,7 +20,7 @@ export const JOB_STATUS = { /** * Create a new transcription job. */ -export const createJob = mutation({ +export const createJob = internalMutation({ args: { youtubeUrl: v.string(), language: v.string(), @@ -62,9 +62,9 @@ export const createJob = mutation({ }); /** - * Update job status (called by Python pipeline via HTTP action). + * Internal pipeline maintenance; no public HTTP update route is registered. */ -export const updateJobStatus = mutation({ +export const updateJobStatus = internalMutation({ args: { jobId: v.id("transcriptionJobs"), status: v.string(), @@ -97,7 +97,7 @@ export const updateJobStatus = mutation({ /** * Get job status for real-time updates. */ -export const getJobStatus = query({ +export const getJobStatus = internalQuery({ args: { jobId: v.id("transcriptionJobs"), }, @@ -109,7 +109,7 @@ export const getJobStatus = query({ /** * Get all jobs for a visitor. */ -export const getJobsByVisitor = query({ +export const getJobsByVisitor = internalQuery({ args: { visitorId: v.string(), }, @@ -126,7 +126,7 @@ export const getJobsByVisitor = query({ * Import transcription result as a new song. * Called after pipeline completes successfully. */ -export const importTranscription = mutation({ +export const importTranscription = internalMutation({ args: { jobId: v.id("transcriptionJobs"), }, @@ -205,10 +205,3 @@ export const importTranscription = mutation({ return songId; }, }); - -/** - * HTTP endpoint for Python pipeline to update job status. - * Used when running pipeline externally (RunPod, local, etc.) - */ -// Note: HTTP endpoints are defined in http.ts -// This would be called via: POST /api/transcription/update diff --git a/convex/users.ts b/convex/users.ts index 5372898..4ad5529 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -1,11 +1,11 @@ -import { mutation, query } from "./_generated/server"; +import { mutation, query, internalQuery } from "./_generated/server"; import { v } from "convex/values"; import { authComponent } from "./betterAuth"; /** * Debug: Get raw Better Auth user info */ -export const debugAuthUser = query({ +export const debugAuthUser = internalQuery({ args: {}, handler: async (ctx) => { const authUser = await authComponent.safeGetAuthUser(ctx); diff --git a/convex/wordProgress.ts b/convex/wordProgress.ts index 64ccdef..452aab0 100644 --- a/convex/wordProgress.ts +++ b/convex/wordProgress.ts @@ -1,4 +1,4 @@ -import { query, mutation } from "./_generated/server"; +import { query, mutation, internalMutation, internalQuery } from "./_generated/server"; import { v } from "convex/values"; import { getAuthUserId, requireAuth } from "./authHelpers"; @@ -275,7 +275,7 @@ export const setLearned = mutation({ }); // Migration: Backfill persian field for existing wordProgress records -export const migrateAddPersian = mutation({ +export const migrateAddPersian = internalMutation({ args: {}, handler: async (ctx) => { // Get all wordProgress records without persian field @@ -300,7 +300,7 @@ export const migrateAddPersian = mutation({ // Migration: Deduplicate wordProgress entries by (visitorId, persian) // Keeps the entry with highest viewCount+playCount, or most recent lastSeen if counts are equal // Merges counts from duplicates into the kept entry -export const deduplicateWordProgress = mutation({ +export const deduplicateWordProgress = internalMutation({ args: {}, handler: async (ctx) => { // Get all wordProgress records @@ -589,7 +589,7 @@ export const getWordPracticeHistory = query({ }); // Query: Check for duplicate wordProgress entries (for auditing) -export const checkDuplicates = query({ +export const checkDuplicates = internalQuery({ args: {}, handler: async (ctx) => { const allProgress = await ctx.db.query("wordProgress").collect(); diff --git a/convex/words.ts b/convex/words.ts index 050ff6f..2cc95d6 100644 --- a/convex/words.ts +++ b/convex/words.ts @@ -1,4 +1,4 @@ -import { query, mutation } from "./_generated/server"; +import { query, internalMutation } from "./_generated/server"; import { v } from "convex/values"; // Get all words for a specific line @@ -17,7 +17,7 @@ export const getByLine = query({ }); // Create a single word -export const create = mutation({ +export const create = internalMutation({ args: { songId: v.id("songs"), lineNumber: v.number(), @@ -33,7 +33,7 @@ export const create = mutation({ }); // Batch create words for a song -export const createMany = mutation({ +export const createMany = internalMutation({ args: { songId: v.id("songs"), words: v.array( @@ -74,7 +74,7 @@ export const getBySong = query({ }); // Clear all Forvo audio URLs (they don't work due to CORS/expiration) -export const clearForvoAudioUrls = mutation({ +export const clearForvoAudioUrls = internalMutation({ args: { songId: v.id("songs") }, handler: async (ctx, args) => { const words = await ctx.db