diff --git a/.env.example b/.env.example
index fe52542..3cf1828 100644
--- a/.env.example
+++ b/.env.example
@@ -9,3 +9,4 @@ JUDGE0_API_KEY=
# SUBMISSION_REPOSITORY_TOKEN because GitHub reserves the GITHUB_ prefix.
GITHUB_REPOSITORY_TOKEN=github_pat_REPLACE_ME
GITHUB_REPOSITORY_OWNER=eeminionn
+GITHUB_REPOSITORY_NAME=tomatin-code-lab-resoluciones
diff --git a/.github/workflows/supabase.yml b/.github/workflows/supabase.yml
index 1fbfa86..d367deb 100644
--- a/.github/workflows/supabase.yml
+++ b/.github/workflows/supabase.yml
@@ -25,6 +25,7 @@ jobs:
PROJECT_ID: ${{ secrets.SUPABASE_PROJECT_ID }}
SUBMISSION_REPOSITORY_TOKEN: ${{ secrets.SUBMISSION_REPOSITORY_TOKEN }}
SUBMISSION_REPOSITORY_OWNER: ${{ vars.SUBMISSION_REPOSITORY_OWNER }}
+ SUBMISSION_REPOSITORY_NAME: ${{ vars.SUBMISSION_REPOSITORY_NAME }}
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -60,13 +61,39 @@ jobs:
- name: Apply database migrations
run: supabase db push
+ - name: Verify central submission repository
+ run: |
+ owner="${SUBMISSION_REPOSITORY_OWNER:-eeminionn}"
+ repository="${SUBMISSION_REPOSITORY_NAME:-tomatin-code-lab-resoluciones}"
+ status="$(
+ curl --silent --show-error \
+ --output repository.json \
+ --write-out "%{http_code}" \
+ --header "Accept: application/vnd.github+json" \
+ --header "Authorization: Bearer $SUBMISSION_REPOSITORY_TOKEN" \
+ --header "X-GitHub-Api-Version: 2026-03-10" \
+ "https://api.github.com/repos/$owner/$repository"
+ )"
+ test "$status" = "200" || {
+ echo "::error::The submission token cannot access $owner/$repository (HTTP $status)."
+ exit 1
+ }
+ jq --exit-status \
+ '.private == true and .permissions.push == true' \
+ repository.json >/dev/null || {
+ echo "::error::$owner/$repository must be private and writable by the submission token."
+ exit 1
+ }
+
- name: Configure repository sync
run: |
owner="${SUBMISSION_REPOSITORY_OWNER:-eeminionn}"
+ repository="${SUBMISSION_REPOSITORY_NAME:-tomatin-code-lab-resoluciones}"
supabase secrets set \
--project-ref "$PROJECT_ID" \
GITHUB_REPOSITORY_TOKEN="$SUBMISSION_REPOSITORY_TOKEN" \
- GITHUB_REPOSITORY_OWNER="$owner"
+ GITHUB_REPOSITORY_OWNER="$owner" \
+ GITHUB_REPOSITORY_NAME="$repository"
- name: Deploy Edge Functions
run: supabase functions deploy --project-ref "$PROJECT_ID"
diff --git a/README.md b/README.md
index 467c141..bc47085 100644
--- a/README.md
+++ b/README.md
@@ -22,11 +22,11 @@ II con veinte misiones originales.
backend cuando está disponible.
- JavaScript en Web Worker, Python con Pyodide y ejecución remota con Judge0.
- Supabase Auth exclusivamente con GitHub, Postgres, Realtime, RLS,
- invitaciones de un uso y roles `owner`, `mentor` y `student`.
+ invitaciones configurables y roles `owner`, `mentor` y `student`.
- Asignaciones por estudiante, revisión, reentrega, XP idempotente y ranking
basado únicamente en tareas aprobadas.
-- Un repositorio privado por estudiante bajo `eeminionn`, con una carpeta por
- misión y actualización automática al entregar.
+- Un único repositorio privado de resoluciones bajo `eeminionn`, con carpetas
+ aisladas por estudiante y actualización automática al entregar.
- Panel del mentor con métricas reales, matriz filtrable, actividad reciente,
detalle por estudiante, historial de código guardado y vista estudiante de
solo lectura.
@@ -79,24 +79,36 @@ VITE_SUPABASE_PUBLISHABLE_KEY=sb_publishable_...
a las Edge Functions. `JUDGE0_URL` y `JUDGE0_API_KEY` son opcionales; por
defecto se usa la instancia pública de Judge0 CE.
-## Repositorios de estudiantes
+## Entregas en GitHub
-La aplicación crea un repositorio privado
-`tomatin-code-lab-USUARIO-ID` bajo `eeminionn`, invita al estudiante como
-colaborador y guarda cada entrega en:
+La aplicación usa un único repositorio privado
+`eeminionn/tomatin-code-lab-resoluciones` y guarda cada entrega en:
```text
-misiones/slug-de-la-mision/solucion.js
-misiones/slug-de-la-mision/solucion.py
-misiones/slug-de-la-mision/solucion.cpp
+resoluciones/usuario-id-corto/misiones/slug-de-la-mision/solucion.js
+resoluciones/usuario-id-corto/misiones/slug-de-la-mision/solucion.py
+resoluciones/usuario-id-corto/misiones/slug-de-la-mision/solucion.cpp
```
+Los estudiantes no son colaboradores del repositorio central. GitHub concede
+permisos por repositorio, no por carpeta; invitarlos permitiría que vieran
+respuestas ajenas. El estudiante consulta su código, historial y estado de
+sincronización desde el aula, mientras que el mentor puede abrir la carpeta
+privada desde `/admin`. Los commits usan el correo `noreply` verificado del
+estudiante para conservar la atribución cuando GitHub puede asociarla.
+
Para activarlo, crea un fine-grained personal access token de GitHub cuyo
-resource owner sea `eeminionn`, con acceso a todos los repositorios y permisos
-`Administration: Read and write` y `Contents: Read and write`. Guárdalo como el
-secret de Actions `SUBMISSION_REPOSITORY_TOKEN` y vuelve a ejecutar `Deploy
-Supabase`. La variable opcional `SUBMISSION_REPOSITORY_OWNER` permite cambiar
-el propietario; si no existe, se usa `eeminionn`.
+resource owner sea `eeminionn`, con acceso al repositorio central y permisos
+`Administration: Read and write` y `Contents: Read and write`. Guárdalo como
+el secret de Actions `SUBMISSION_REPOSITORY_TOKEN` y vuelve a ejecutar `Deploy
+Supabase`. Las variables opcionales `SUBMISSION_REPOSITORY_OWNER` y
+`SUBMISSION_REPOSITORY_NAME` permiten cambiar el propietario y el nombre; sus
+valores por defecto son `eeminionn` y `tomatin-code-lab-resoluciones`.
+
+Los repositorios individuales creados por versiones anteriores no se eliminan
+automáticamente. La siguiente sesión o entrega migra el registro del estudiante
+al almacenamiento central; los repositorios anteriores pueden archivarse
+después de comprobar la copia y definir una política de conservación.
## Verificación
diff --git a/docs/BETA_ROLLOUT.md b/docs/BETA_ROLLOUT.md
index 7b119ab..be97904 100644
--- a/docs/BETA_ROLLOUT.md
+++ b/docs/BETA_ROLLOUT.md
@@ -7,12 +7,12 @@ versión estable de la raíz no se reemplaza todavía.
## Barreras automáticas
- CI ejecuta las pruebas heredadas, TypeScript, Edge Functions, las 60
- soluciones de referencia, el build y nueve recorridos Playwright.
+ soluciones de referencia, el build y los recorridos Playwright.
- pgTAP aplica todas las migraciones desde cero y verifica RLS, privacidad y
contratos de versión.
- GitHub Pages falla si faltan las variables públicas de Supabase.
- Supabase falla si faltan los secretos de despliegue o de sincronización con
- los repositorios estudiantiles.
+ el repositorio privado de resoluciones.
- Después de publicar, Pages comprueba que `/beta/` responda y sirva la versión
3.0 desde la ruta correcta.
@@ -23,7 +23,8 @@ versión estable de la raíz no se reemplaza todavía.
- [ ] Abrir la Misión 01 en dos acciones y comprenderla sin revelar pistas.
- [ ] Guardar código distinto en JavaScript, Python y C++ sin perder borradores.
- [ ] Ejecutar al menos un caso correcto y uno incorrecto en cada lenguaje.
-- [ ] Entregar una misión y comprobar su actualización en el repositorio privado.
+- [ ] Entregar una misión y comprobar que cada estudiante actualice únicamente
+ su carpeta dentro del repositorio privado central.
- [ ] Revisar desde `/admin`, comentar una línea y solicitar cambios.
- [ ] Confirmar que la reentrega usa la versión actual sin borrar el historial.
- [ ] Aprobar una entrega y comprobar que el XP se emita una sola vez.
diff --git a/supabase/functions/_shared/github-paths.ts b/supabase/functions/_shared/github-paths.ts
new file mode 100644
index 0000000..7fff1ef
--- /dev/null
+++ b/supabase/functions/_shared/github-paths.ts
@@ -0,0 +1,38 @@
+import type { Language } from "./types.ts";
+
+const FILE_NAMES: Record = {
+ javascript: "solucion.js",
+ python: "solucion.py",
+ cpp: "solucion.cpp",
+};
+
+function safeSegment(value: string, fallback: string): string {
+ return (
+ value
+ .toLowerCase()
+ .replace(/[^a-z0-9._-]+/g, "-")
+ .replace(/^[._-]+|[._-]+$/g, "")
+ .slice(0, 80) || fallback
+ );
+}
+
+export function studentSubmissionFolder(
+ githubLogin: string,
+ userId: string,
+): string {
+ const login = safeSegment(githubLogin, "estudiante");
+ const shortId =
+ userId.toLowerCase().replace(/[^a-z0-9]/g, "").slice(0, 8) || "sin-id";
+ return `${login}-${shortId}`;
+}
+
+export function submissionFilePath(input: {
+ githubLogin: string;
+ userId: string;
+ missionSlug: string;
+ language: Language;
+}): string {
+ const student = studentSubmissionFolder(input.githubLogin, input.userId);
+ const mission = safeSegment(input.missionSlug, "mision");
+ return `resoluciones/${student}/misiones/${mission}/${FILE_NAMES[input.language]}`;
+}
diff --git a/supabase/functions/_shared/github-submissions.ts b/supabase/functions/_shared/github-submissions.ts
index c00fe89..a51db1e 100644
--- a/supabase/functions/_shared/github-submissions.ts
+++ b/supabase/functions/_shared/github-submissions.ts
@@ -1,10 +1,16 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Language } from "./types.ts";
+import {
+ studentSubmissionFolder,
+ submissionFilePath,
+} from "./github-paths.ts";
const GITHUB_API = "https://api.github.com";
const GITHUB_API_VERSION = "2026-03-10";
const DEFAULT_OWNER = "eeminionn";
-const DEFAULT_PREFIX = "tomatin-code-lab-";
+const DEFAULT_REPOSITORY_NAME = "tomatin-code-lab-resoluciones";
+const REPOSITORY_DESCRIPTION =
+ "Entregas privadas de estudiantes de Tomatin Code Lab, administradas por el backend del aula.";
interface ProfileRow {
id: string;
@@ -23,7 +29,14 @@ interface RepositoryRow {
html_url: string;
visibility: "private";
status: "ready" | "error";
- collaborator_status: "pending" | "invited" | "active" | "error";
+ collaborator_status:
+ | "pending"
+ | "invited"
+ | "active"
+ | "not_required"
+ | "error";
+ storage_mode: "legacy_per_student" | "central";
+ student_path: string | null;
last_synced_at: string | null;
last_error: string | null;
}
@@ -42,17 +55,20 @@ interface GitHubRepository {
interface GitHubContent {
sha: string;
- html_url: string | null;
}
interface GitHubContentCommit {
- content: GitHubContent | null;
commit: {
sha: string;
html_url: string | null;
};
}
+interface GitHubAuthor {
+ name: string;
+ email: string;
+}
+
export interface RepositorySyncResult {
status: "synced" | "pending_setup" | "failed" | "not_applicable";
message: string;
@@ -93,8 +109,9 @@ function githubConfiguration() {
token: Deno.env.get("GITHUB_REPOSITORY_TOKEN")?.trim() ?? "",
owner:
Deno.env.get("GITHUB_REPOSITORY_OWNER")?.trim() || DEFAULT_OWNER,
- prefix:
- Deno.env.get("GITHUB_REPOSITORY_PREFIX")?.trim() || DEFAULT_PREFIX,
+ repositoryName:
+ Deno.env.get("GITHUB_REPOSITORY_NAME")?.trim() ||
+ DEFAULT_REPOSITORY_NAME,
};
}
@@ -130,14 +147,6 @@ function safeError(error: unknown): string {
return message.replace(/\s+/g, " ").slice(0, 1000);
}
-function repositoryName(prefix: string, login: string, userId: string): string {
- return `${prefix}${login}-${userId.slice(0, 8)}`
- .toLowerCase()
- .replace(/[^a-z0-9._-]+/g, "-")
- .replace(/^[._-]+|[._-]+$/g, "")
- .slice(0, 100);
-}
-
function encodeGitHubPath(path: string): string {
return path.split("/").map(encodeURIComponent).join("/");
}
@@ -162,6 +171,8 @@ function toFrontendRepository(repository: RepositoryRow) {
visibility: repository.visibility,
status: repository.status,
collaboratorStatus: repository.collaborator_status,
+ storageMode: repository.storage_mode,
+ studentPath: repository.student_path ?? undefined,
lastSyncedAt: repository.last_synced_at ?? undefined,
lastError: repository.last_error ?? undefined,
};
@@ -234,6 +245,66 @@ async function findGitHubRepository(
}
}
+async function upsertGitHubContent(input: {
+ token: string;
+ owner: string;
+ repository: string;
+ path: string;
+ branch: string;
+ message: string;
+ content: string;
+ author?: GitHubAuthor;
+}): Promise {
+ const encodedOwner = encodeURIComponent(input.owner);
+ const encodedRepository = encodeURIComponent(input.repository);
+ const encodedPath = encodeGitHubPath(input.path);
+
+ for (let attempt = 0; attempt < 3; attempt += 1) {
+ let currentFile: GitHubContent | null = null;
+ try {
+ const contentResponse = await githubFetch(
+ input.token,
+ `/repos/${encodedOwner}/${encodedRepository}/contents/${encodedPath}?ref=${encodeURIComponent(input.branch)}`,
+ );
+ currentFile = (await contentResponse.json()) as GitHubContent;
+ } catch (error) {
+ if (!(error instanceof GitHubApiError) || error.status !== 404) {
+ throw error;
+ }
+ }
+
+ try {
+ const response = await githubFetch(
+ input.token,
+ `/repos/${encodedOwner}/${encodedRepository}/contents/${encodedPath}`,
+ {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ message: input.message,
+ content: encodeBase64(input.content),
+ branch: input.branch,
+ ...(currentFile?.sha ? { sha: currentFile.sha } : {}),
+ ...(input.author ? { author: input.author } : {}),
+ }),
+ },
+ );
+ return (await response.json()) as GitHubContentCommit;
+ } catch (error) {
+ if (
+ !(error instanceof GitHubApiError) ||
+ error.status !== 409 ||
+ attempt === 2
+ ) {
+ throw error;
+ }
+ await new Promise((resolve) => setTimeout(resolve, 150 * 2 ** attempt));
+ }
+ }
+
+ throw new Error("No se pudo actualizar la entrega después de reintentar.");
+}
+
async function upsertRepository(
admin: SupabaseClient,
row: Omit,
@@ -264,19 +335,25 @@ export async function provisionStudentRepository(
const { profile, classId } = context;
const current = await storedRepository(admin, classId, userId);
+ const config = githubConfiguration();
+ const studentPath = studentSubmissionFolder(
+ profile.github_login ?? "estudiante",
+ userId,
+ );
if (
current?.status === "ready" &&
- (current.collaborator_status === "active" ||
- current.collaborator_status === "invited")
+ current.storage_mode === "central" &&
+ current.owner_login.toLowerCase() === config.owner.toLowerCase() &&
+ current.repository_name === config.repositoryName &&
+ current.student_path === studentPath
) {
return {
status: "ready",
- message: "Repositorio listo.",
+ message: "Carpeta privada de entregas lista.",
repository: toFrontendRepository(current),
};
}
- const config = githubConfiguration();
if (!config.token) {
return {
status: "pending_setup",
@@ -295,10 +372,7 @@ export async function provisionStudentRepository(
);
}
- const name =
- current?.repository_name ??
- repositoryName(config.prefix, profile.github_login, userId);
- const description = `Private Tomatin Code Lab submissions for @${profile.github_login}.`;
+ const name = config.repositoryName;
let githubRepository = await findGitHubRepository(
config.token,
config.owner,
@@ -306,43 +380,58 @@ export async function provisionStudentRepository(
);
if (!githubRepository) {
- const createResponse = await githubFetch(config.token, "/user/repos", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
+ try {
+ const createResponse = await githubFetch(config.token, "/user/repos", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ name,
+ description: REPOSITORY_DESCRIPTION,
+ private: true,
+ auto_init: true,
+ has_issues: false,
+ has_projects: false,
+ has_wiki: false,
+ has_discussions: false,
+ }),
+ });
+ githubRepository = (await createResponse.json()) as GitHubRepository;
+ } catch (error) {
+ if (!(error instanceof GitHubApiError) || error.status !== 422) {
+ throw error;
+ }
+ githubRepository = await findGitHubRepository(
+ config.token,
+ config.owner,
name,
- description,
- private: true,
- auto_init: true,
- has_issues: false,
- has_projects: false,
- has_wiki: false,
- has_discussions: false,
- }),
- });
- githubRepository = (await createResponse.json()) as GitHubRepository;
- } else if (
- !githubRepository.private ||
- githubRepository.description !== description
- ) {
+ );
+ if (!githubRepository) throw error;
+ }
+ }
+
+ if (!githubRepository.private) {
throw new Error(
- `El repositorio ${config.owner}/${name} ya existe y no pertenece a este curso.`,
+ `El repositorio ${config.owner}/${name} debe ser privado.`,
);
}
- let collaboratorStatus: RepositoryRow["collaborator_status"] = "pending";
- let collaboratorError: string | null = null;
- try {
- const collaboratorResponse = await githubFetch(
+ if (githubRepository.description !== REPOSITORY_DESCRIPTION) {
+ const updateResponse = await githubFetch(
config.token,
- `/repos/${encodeURIComponent(config.owner)}/${encodeURIComponent(name)}/collaborators/${encodeURIComponent(profile.github_login)}`,
- { method: "PUT" },
+ `/repos/${encodeURIComponent(config.owner)}/${encodeURIComponent(name)}`,
+ {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ description: REPOSITORY_DESCRIPTION,
+ has_issues: false,
+ has_projects: false,
+ has_wiki: false,
+ has_discussions: false,
+ }),
+ },
);
- collaboratorStatus =
- collaboratorResponse.status === 201 ? "invited" : "active";
- } catch (error) {
- collaboratorStatus = "error";
- collaboratorError = safeError(error);
+ githubRepository = (await updateResponse.json()) as GitHubRepository;
}
const saved = await upsertRepository(admin, {
@@ -352,20 +441,17 @@ export async function provisionStudentRepository(
repository_name: githubRepository.name,
html_url: githubRepository.html_url,
visibility: "private",
- status: collaboratorStatus === "error" ? "error" : "ready",
- collaborator_status: collaboratorStatus,
+ status: "ready",
+ collaborator_status: "not_required",
+ storage_mode: "central",
+ student_path: studentPath,
last_synced_at: current?.last_synced_at ?? null,
- last_error: collaboratorError,
+ last_error: null,
});
return {
status: "ready",
- message:
- collaboratorStatus === "invited"
- ? "Repositorio creado; la invitación de GitHub está pendiente."
- : collaboratorStatus === "active"
- ? "Repositorio listo."
- : "Repositorio creado, pero la invitación debe reintentarse.",
+ message: "Carpeta privada de entregas lista.",
repository: toFrontendRepository(saved),
};
}
@@ -430,12 +516,12 @@ export async function syncSubmissionToGitHub(
};
}
- const fileNames: Record = {
- javascript: "solucion.js",
- python: "solucion.py",
- cpp: "solucion.cpp",
- };
- const filePath = `misiones/${mission.slug}/${fileNames[input.language]}`;
+ const filePath = submissionFilePath({
+ githubLogin: context.profile.github_login ?? "estudiante",
+ userId: input.userId,
+ missionSlug: mission.slug,
+ language: input.language,
+ });
let repository: RepositoryRow | null = null;
try {
@@ -452,7 +538,7 @@ export async function syncSubmissionToGitHub(
return {
status: "pending_setup",
message:
- "La entrega quedó guardada en el aula; el repositorio se activará al conectar GitHub.",
+ "La entrega quedó guardada en el aula; la carpeta se sincronizará cuando la conexión privada esté disponible.",
path: filePath,
};
}
@@ -473,7 +559,6 @@ export async function syncSubmissionToGitHub(
const config = githubConfiguration();
const encodedOwner = encodeURIComponent(repository.owner_login);
const encodedName = encodeURIComponent(repository.repository_name);
- const encodedPath = encodeGitHubPath(filePath);
const repositoryResponse = await githubFetch(
config.token,
`/repos/${encodedOwner}/${encodedName}`,
@@ -481,17 +566,6 @@ export async function syncSubmissionToGitHub(
const githubRepository =
(await repositoryResponse.json()) as GitHubRepository;
- let currentFile: GitHubContent | null = null;
- try {
- const contentResponse = await githubFetch(
- config.token,
- `/repos/${encodedOwner}/${encodedName}/contents/${encodedPath}?ref=${encodeURIComponent(githubRepository.default_branch)}`,
- );
- currentFile = (await contentResponse.json()) as GitHubContent;
- } catch (error) {
- if (!(error instanceof GitHubApiError) || error.status !== 404) throw error;
- }
-
const author =
context.profile.github_id && context.profile.github_login
? {
@@ -499,26 +573,17 @@ export async function syncSubmissionToGitHub(
email: `${context.profile.github_id}+${context.profile.github_login}@users.noreply.github.com`,
}
: undefined;
- const updateResponse = await githubFetch(
- config.token,
- `/repos/${encodedOwner}/${encodedName}/contents/${encodedPath}`,
- {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- message: `Entrega: ${mission.title} (${input.language})`,
- content: encodeBase64(input.code),
- branch: githubRepository.default_branch,
- ...(currentFile?.sha ? { sha: currentFile.sha } : {}),
- ...(author ? { author } : {}),
- }),
- },
- );
- const committed = (await updateResponse.json()) as GitHubContentCommit;
+ const committed = await upsertGitHubContent({
+ token: config.token,
+ owner: repository.owner_login,
+ repository: repository.repository_name,
+ path: filePath,
+ branch: githubRepository.default_branch,
+ message: `Entrega: ${mission.title} (${input.language})`,
+ content: input.code,
+ author,
+ });
const syncedAt = new Date().toISOString();
- const fileUrl =
- committed.content?.html_url ??
- `${repository.html_url}/blob/${githubRepository.default_branch}/${filePath}`;
const commitUrl = committed.commit.html_url ?? undefined;
await input.admin
@@ -540,8 +605,6 @@ export async function syncSubmissionToGitHub(
return {
status: "synced",
message: `${filePath} quedó actualizado.`,
- repositoryUrl: repository.html_url,
- fileUrl,
path: filePath,
commitSha: committed.commit.sha,
};
@@ -563,7 +626,6 @@ export async function syncSubmissionToGitHub(
status: "failed",
message:
"La entrega quedó guardada en el aula, pero GitHub rechazó la sincronización.",
- repositoryUrl: repository?.html_url,
path: filePath,
};
}
diff --git a/supabase/migrations/202607300007_central_submission_repository.sql b/supabase/migrations/202607300007_central_submission_repository.sql
new file mode 100644
index 0000000..cc98250
--- /dev/null
+++ b/supabase/migrations/202607300007_central_submission_repository.sql
@@ -0,0 +1,49 @@
+alter table public.student_repositories
+ add column storage_mode text not null default 'legacy_per_student',
+ add column student_path text;
+
+alter table public.student_repositories
+ add constraint student_repositories_storage_mode_check
+ check (storage_mode in ('legacy_per_student', 'central')),
+ add constraint student_repositories_student_path_check
+ check (
+ student_path is null
+ or (
+ char_length(student_path) between 1 and 100
+ and student_path ~ '^[a-z0-9][a-z0-9._-]*$'
+ )
+ ),
+ add constraint student_repositories_mode_path_check
+ check (
+ (storage_mode = 'legacy_per_student' and student_path is null)
+ or (storage_mode = 'central' and student_path is not null)
+ );
+
+alter table public.student_repositories
+ drop constraint if exists student_repositories_owner_login_repository_name_key,
+ drop constraint if exists student_repositories_collaborator_status_check;
+
+alter table public.student_repositories
+ add constraint student_repositories_collaborator_status_check
+ check (
+ collaborator_status in (
+ 'pending',
+ 'invited',
+ 'active',
+ 'not_required',
+ 'error'
+ )
+ );
+
+create unique index student_repositories_class_student_path_key
+ on public.student_repositories(class_id, student_path)
+ where student_path is not null;
+
+create index student_repositories_physical_repository_idx
+ on public.student_repositories(owner_login, repository_name);
+
+comment on column public.student_repositories.storage_mode is
+ 'legacy_per_student for repositories created before 3.1; central for the shared private repository.';
+
+comment on column public.student_repositories.student_path is
+ 'Server-derived folder for this student inside the central private repository.';
diff --git a/supabase/tests/rls.sql b/supabase/tests/rls.sql
index a0aa144..a8804cc 100644
--- a/supabase/tests/rls.sql
+++ b/supabase/tests/rls.sql
@@ -1,7 +1,7 @@
begin;
create extension if not exists pgtap with schema extensions;
-select plan(35);
+select plan(39);
insert into auth.users (
id, instance_id, aud, role, email, encrypted_password,
@@ -643,5 +643,99 @@ select throws_ok(
'a third student cannot exceed the invitation capacity'
);
+set local role service_role;
+
+select lives_ok(
+ $$
+ insert into public.student_repositories (
+ class_id,
+ user_id,
+ owner_login,
+ repository_name,
+ html_url,
+ status,
+ collaborator_status,
+ storage_mode,
+ student_path
+ )
+ values
+ (
+ '00000000-0000-0000-0000-000000000201',
+ '00000000-0000-0000-0000-000000000102',
+ 'eeminionn',
+ 'tomatin-code-lab-resoluciones',
+ 'https://github.com/eeminionn/tomatin-code-lab-resoluciones',
+ 'ready',
+ 'not_required',
+ 'central',
+ 'student-one-00000102'
+ ),
+ (
+ '00000000-0000-0000-0000-000000000201',
+ '00000000-0000-0000-0000-000000000103',
+ 'eeminionn',
+ 'tomatin-code-lab-resoluciones',
+ 'https://github.com/eeminionn/tomatin-code-lab-resoluciones',
+ 'ready',
+ 'not_required',
+ 'central',
+ 'student-two-00000103'
+ )
+ $$,
+ 'multiple students can map to the same private repository'
+);
+
+set local role authenticated;
+select set_config(
+ 'request.jwt.claim.sub',
+ '00000000-0000-0000-0000-000000000102',
+ true
+);
+select set_config(
+ 'request.jwt.claims',
+ '{"sub":"00000000-0000-0000-0000-000000000102","role":"authenticated"}',
+ true
+);
+
+select is(
+ (select count(*) from public.student_repositories),
+ 1::bigint,
+ 'a student reads only their central repository mapping'
+);
+
+select set_config(
+ 'request.jwt.claim.sub',
+ '00000000-0000-0000-0000-000000000103',
+ true
+);
+select set_config(
+ 'request.jwt.claims',
+ '{"sub":"00000000-0000-0000-0000-000000000103","role":"authenticated"}',
+ true
+);
+
+select is(
+ (select count(*) from public.student_repositories),
+ 1::bigint,
+ 'another student cannot read the first student mapping'
+);
+
+select set_config(
+ 'request.jwt.claim.sub',
+ '00000000-0000-0000-0000-000000000101',
+ true
+);
+select set_config(
+ 'request.jwt.claims',
+ '{"sub":"00000000-0000-0000-0000-000000000101","role":"authenticated"}',
+ true
+);
+
+select is(
+ (select count(*) from public.student_repositories),
+ 2::bigint,
+ 'class staff reads every central repository mapping'
+);
+
select * from finish();
rollback;
diff --git a/v2/src/pages/AccessPage.tsx b/v2/src/pages/AccessPage.tsx
index 9e1f2d3..e265804 100644
--- a/v2/src/pages/AccessPage.tsx
+++ b/v2/src/pages/AccessPage.tsx
@@ -106,7 +106,7 @@ export function AccessPage() {
{hasInvitation
- ? "Usa la cuenta de GitHub donde quieres recibir tus entregas."
+ ? "Usa la cuenta de GitHub que identificará tus entregas."
: "El acceso de estudiantes requiere una invitación activa del curso."}
diff --git a/v2/src/pages/DashboardPage.tsx b/v2/src/pages/DashboardPage.tsx
index 0183c93..bd00876 100644
--- a/v2/src/pages/DashboardPage.tsx
+++ b/v2/src/pages/DashboardPage.tsx
@@ -249,25 +249,40 @@ export function Component() {
{repository ? (
<>
-
-
- {repository.name}
- Repositorio privado
-
-
-
+ {repository.storageMode === "central" ? (
+
+
+ Carpeta privada sincronizada
+
+ {repository.studentPath
+ ? `resoluciones/${repository.studentPath}`
+ : "Repositorio central del curso"}
+
+
+
+ ) : (
+
+
+ {repository.name}
+ Repositorio individual anterior
+
+
+
+ )}
{repository.lastSyncedAt
? `Actualizado ${formatDate(repository.lastSyncedAt, true)}`
- : repository.collaboratorStatus === "invited"
+ : repository.storageMode === "central"
+ ? "Carpeta lista"
+ : repository.collaboratorStatus === "invited"
? "Invitación enviada"
: "Repositorio listo"}
@@ -276,7 +291,7 @@ export function Component() {
- Preparando repositorio
+ Preparando carpeta privada
@{subjectProfile.githubLogin}
diff --git a/v2/src/pages/MentorPage.tsx b/v2/src/pages/MentorPage.tsx
index b23eccf..30d3a68 100644
--- a/v2/src/pages/MentorPage.tsx
+++ b/v2/src/pages/MentorPage.tsx
@@ -444,6 +444,10 @@ function StudentDirectory({
const repository = snapshot.repositories.find(
(entry) => entry.userId === selected.id,
);
+ const repositoryHref =
+ repository?.storageMode === "central" && repository.studentPath
+ ? `${repository.htmlUrl}/tree/main/resoluciones/${repository.studentPath}`
+ : repository?.htmlUrl;
const latestActivity = progress
.filter((entry) => entry.lastActivityAt)
.sort(
@@ -582,12 +586,23 @@ function StudentDirectory({
) : null}
{repository ? (
-
+
- {repository.name}
+ {repository.storageMode === "central"
+ ? `resoluciones/${repository.studentPath ?? selected.githubLogin ?? "estudiante"}`
+ : repository.name}
) : (
- Repositorio pendiente
+ Carpeta de entregas pendiente
)}
diff --git a/v2/src/state/classroom-context.tsx b/v2/src/state/classroom-context.tsx
index 1dde4f0..e4c545f 100644
--- a/v2/src/state/classroom-context.tsx
+++ b/v2/src/state/classroom-context.tsx
@@ -320,6 +320,8 @@ async function fetchSupabaseSnapshot(session: Session) {
visibility: row.visibility,
status: row.status,
collaboratorStatus: row.collaborator_status,
+ storageMode: row.storage_mode ?? "legacy_per_student",
+ studentPath: row.student_path ?? undefined,
lastSyncedAt: row.last_synced_at ?? undefined,
lastError: row.last_error ?? undefined,
})),
@@ -365,11 +367,14 @@ export function ClassroomProvider({ children }: { children: ReactNode }) {
const loaded = await fetchSupabaseSnapshot(data.session);
setProfile(loaded.profile);
setSnapshot(loaded.snapshot);
+ const ownRepository = loaded.snapshot.repositories.find(
+ (repository) => repository.userId === loaded.profile.id,
+ );
if (
loaded.profile.role === "student" &&
- !loaded.snapshot.repositories.some(
- (repository) => repository.userId === loaded.profile.id,
- ) &&
+ (!ownRepository ||
+ ownRepository.storageMode !== "central" ||
+ ownRepository.status === "error") &&
!provisionRequested.current.has(loaded.profile.id)
) {
provisionRequested.current.add(loaded.profile.id);
@@ -383,7 +388,7 @@ export function ClassroomProvider({ children }: { children: ReactNode }) {
...current,
repositories: [
...current.repositories.filter(
- (entry) => entry.id !== repository.id,
+ (entry) => entry.userId !== repository.userId,
),
repository,
],
diff --git a/v2/src/styles.css b/v2/src/styles.css
index 2a6b238..9cafb9f 100644
--- a/v2/src/styles.css
+++ b/v2/src/styles.css
@@ -1452,6 +1452,15 @@ fieldset legend {
background: rgba(103, 232, 165, 0.05);
}
+.repository-location {
+ cursor: default;
+}
+
+.repository-location:hover {
+ border-color: var(--line);
+ background: rgba(255, 255, 255, 0.025);
+}
+
.repository-link > span,
.repository-pending > span:last-child {
display: grid;
@@ -4139,10 +4148,15 @@ fieldset legend {
.student-current-state > a {
display: inline-flex;
+ max-width: 62%;
+ min-width: 0;
align-items: center;
gap: 6px;
+ overflow: hidden;
color: var(--cyan);
font-size: 9px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
.student-current-state svg {
diff --git a/v2/src/types.ts b/v2/src/types.ts
index 761733a..e20acac 100644
--- a/v2/src/types.ts
+++ b/v2/src/types.ts
@@ -236,7 +236,14 @@ export interface StudentRepository {
htmlUrl: string;
visibility: "private";
status: "ready" | "error";
- collaboratorStatus: "pending" | "invited" | "active" | "error";
+ collaboratorStatus:
+ | "pending"
+ | "invited"
+ | "active"
+ | "not_required"
+ | "error";
+ storageMode: "legacy_per_student" | "central";
+ studentPath?: string;
lastSyncedAt?: string;
lastError?: string;
}
diff --git a/v2/tests/github-submission-path.test.ts b/v2/tests/github-submission-path.test.ts
new file mode 100644
index 0000000..ae040bf
--- /dev/null
+++ b/v2/tests/github-submission-path.test.ts
@@ -0,0 +1,37 @@
+// @vitest-environment node
+
+import { describe, expect, it } from "vitest";
+import {
+ studentSubmissionFolder,
+ submissionFilePath,
+} from "../../supabase/functions/_shared/github-paths";
+
+describe("central GitHub submission paths", () => {
+ it("uses a stable student folder with a collision-resistant suffix", () => {
+ expect(
+ studentSubmissionFolder(
+ "Louu27",
+ "ff7b18ec-1286-4c47-8fc5-9dbaf35664cc",
+ ),
+ ).toBe("louu27-ff7b18ec");
+ });
+
+ it("removes path traversal and unsupported characters", () => {
+ expect(studentSubmissionFolder("../L O U", "../../ABC-123")).toBe(
+ "l-o-u-abc123",
+ );
+ });
+
+ it("derives the complete path without trusting a client path", () => {
+ expect(
+ submissionFilePath({
+ githubLogin: "louu27",
+ userId: "ff7b18ec-1286-4c47-8fc5-9dbaf35664cc",
+ missionSlug: "../La Once de Tomatin",
+ language: "python",
+ }),
+ ).toBe(
+ "resoluciones/louu27-ff7b18ec/misiones/la-once-de-tomatin/solucion.py",
+ );
+ });
+});
diff --git a/v2/tests/security-contract.test.ts b/v2/tests/security-contract.test.ts
index 3445051..cf78b2c 100644
--- a/v2/tests/security-contract.test.ts
+++ b/v2/tests/security-contract.test.ts
@@ -108,6 +108,12 @@ describe("security contracts", () => {
),
"utf8",
);
+ const centralRepositoryMigration = readFileSync(
+ resolve(
+ "supabase/migrations/202607300007_central_submission_repository.sql",
+ ),
+ "utf8",
+ );
const repositoryService = readFileSync(
resolve("supabase/functions/_shared/github-submissions.ts"),
"utf8",
@@ -125,12 +131,22 @@ describe("security contracts", () => {
expect(repositoryMigration).toContain(
"user_id = auth.uid() or is_class_staff(class_id)",
);
+ expect(centralRepositoryMigration).toContain(
+ "storage_mode in ('legacy_per_student', 'central')",
+ );
+ expect(centralRepositoryMigration).toContain(
+ "student_repositories_class_student_path_key",
+ );
expect(repositoryService).toContain(
'Deno.env.get("GITHUB_REPOSITORY_TOKEN")',
);
- expect(repositoryService).toContain("private: true");
expect(repositoryService).toContain(
- "misiones/${mission.slug}/${fileNames[input.language]}",
+ 'Deno.env.get("GITHUB_REPOSITORY_NAME")',
);
+ expect(repositoryService).toContain("private: true");
+ expect(repositoryService).toContain('collaborator_status: "not_required"');
+ expect(repositoryService).not.toContain("/collaborators/");
+ expect(repositoryService).toContain("attempt < 3");
+ expect(repositoryService).toContain("error.status !== 409");
});
});