Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
VITE_SENTRY_DSN=
VITE_POSTHOG_KEY=
VITE_POSTHOG_HOST=https://eu.i.posthog.com
VITE_GOOGLE_PICKER_API_KEY=
50 changes: 50 additions & 0 deletions .superset/workspace-trial-nginx.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}

server {
listen 127.0.0.1:18480;
server_name codex-remote.tailab0ab3.ts.net;

if ($http_host !~ ^(codex-remote\.tailab0ab3\.ts\.net(:18443)?|127\.0\.0\.1:18480)$) { return 444; }

root /usr/share/nginx/html;
index index.html;

add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; img-src 'self' data: https:; font-src 'self' data: https://fonts.gstatic.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; script-src 'self' https://*.jam.dev; worker-src 'self' blob:; frame-src 'self' https://accounts.google.com https://*.jam.dev; connect-src 'self' wss://codex-remote.tailab0ab3.ts.net:18443 https://*.jam.dev https://oauth2.googleapis.com https://www.googleapis.com;" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
add_header X-Robots-Tag "noindex, nofollow" always;

location ^~ /supabase/ {
proxy_pass http://127.0.0.1:54321/;
proxy_set_header Host 127.0.0.1:54321;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Host $host;
}

location = /collaboration {
proxy_pass http://127.0.0.1:12345;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host 127.0.0.1:12345;
proxy_read_timeout 1h;
}

location ^~ /access/ {
proxy_pass http://127.0.0.1:12345;
proxy_set_header Host 127.0.0.1:12345;
}

location ^~ /review/ {
proxy_pass http://127.0.0.1:12345;
proxy_set_header Host 127.0.0.1:12345;
}

location ~ /\. { return 404; }
location ~* \.(?:env|log|lock|md|ya?ml)$ { return 404; }
location / { try_files $uri $uri/ /index.html; }
}
136 changes: 136 additions & 0 deletions .superset/workspace-trial.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
#!/usr/bin/env bash
set -euo pipefail

DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$DIR/lib/lock.sh"

DB_LOCK="$HOME/.superset/debatelab-db.lock"
TRIAL_ORIGIN="https://codex-remote.tailab0ab3.ts.net:18443"
APP_PORT=18480
COLLABORATION_PORT=12345
SERVE_PORT=18443
Comment on lines +8 to +11

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Trial boundary is undocumented

This change introduces a Tailscale-served origin, three dedicated ports, and a new Nginx runtime boundary. The repository directive requires the owning focused documentation to be updated whenever commands, ports, workflows, or runtime boundaries change. That required documentation must be added before merging.

Context Used: AGENTS.md (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: .superset/workspace-trial.sh
Line: 8-11

Comment:
**Trial boundary is undocumented**

This change introduces a Tailscale-served origin, three dedicated ports, and a new Nginx runtime boundary. The repository directive requires the owning focused documentation to be updated whenever commands, ports, workflows, or runtime boundaries change. That required documentation must be added before merging.

**Context Used:** AGENTS.md ([source](https://github.com/programcode/debatelab/blob/main/AGENTS.md))

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Codex Fix in Cursor Fix in Claude Code

PROXY_NAME="debatelab-workspace-trial-proxy-$$"

runtime_dir=""
stack_started=false
serve_started=false
proxy_started=false
owned_pids=()

stop_owned_processes() {
local index
if [ "$proxy_started" = true ]; then
docker rm --force "$PROXY_NAME" >/dev/null 2>&1 || true
fi
for ((index=${#owned_pids[@]}-1; index>=0; index--)); do
kill -- "-${owned_pids[$index]}" 2>/dev/null || true
done
for ((index=${#owned_pids[@]}-1; index>=0; index--)); do
wait "${owned_pids[$index]}" 2>/dev/null || true
done
}

cleanup() {
local status=$?
trap - EXIT INT TERM
if [ "$serve_started" = true ]; then
tailscale serve --https="$SERVE_PORT" off >/dev/null 2>&1 || status=1
fi
stop_owned_processes
if [ "$stack_started" = true ] && ! supabase stop; then
status=1
fi
if [ -n "$runtime_dir" ] && [ -d "$runtime_dir" ]; then
rm -r -- "$runtime_dir"
fi
lock_release "$DB_LOCK"
exit "$status"
}

for port in "$APP_PORT" "$COLLABORATION_PORT"; do
if ss -ltn "sport = :$port" | tail -n +2 | grep -q .; then
echo "Workspace trial port $port is already in use" >&2
exit 1
fi
done
if tailscale serve status --json | node -e 'const s=JSON.parse(require("fs").readFileSync(0,"utf8")); process.exit(s.TCP?.[process.argv[1]] ? 0 : 1)' "$SERVE_PORT"; then
echo "Tailscale Serve port $SERVE_PORT is already configured" >&2
exit 1
fi
if supabase status -o json >/dev/null 2>&1; then
echo "A local Supabase stack is already running" >&2
exit 1
fi

lock_acquire "$DB_LOCK" 600
trap cleanup EXIT INT TERM
runtime_dir="$(mktemp -d)"

supabase start >"$runtime_dir/supabase.log" 2>&1
stack_started=true
status_json="$(supabase status -o json 2>/dev/null)"
status_value() {
node -e 'const value=JSON.parse(process.argv[1])[process.argv[2]]; if (!value) process.exit(1); process.stdout.write(String(value))' "$status_json" "$1"
}
api_url="$(status_value API_URL)"
anon_key="$(status_value ANON_KEY)"
service_role_key="$(status_value SERVICE_ROLE_KEY)"
database_url="$(status_value DB_URL)"
project_id="$(basename "$(pwd)")"
mailer_host="$(docker ps --filter "name=supabase_inbucket_$project_id" --format '{{.Names}}')"
if [ -z "$mailer_host" ]; then
echo "Local Mailpit container is unavailable" >&2
exit 1
fi

LOCAL_SERVICE_ROLE_KEY="$service_role_key" \
LOCAL_MAILER_HOST="$mailer_host" \
node scripts/workspace-test-env.mjs .superset/workspace-test.env "$runtime_dir/function.env" "$TRIAL_ORIGIN"

setsid supabase functions serve workspace-invites --no-verify-jwt \
--env-file "$runtime_dir/function.env" >"$runtime_dir/functions.log" 2>&1 &
owned_pids+=("$!")

COLLABORATION_DATABASE_URL="$database_url" \
COLLABORATION_PORT="$COLLABORATION_PORT" \
COLLABORATION_ALLOWED_ORIGINS="$TRIAL_ORIGIN" \
SUPABASE_URL="$api_url" \
SUPABASE_ANON_KEY="$anon_key" \
setsid npm run start:collaboration >"$runtime_dir/collaboration.log" 2>&1 &
owned_pids+=("$!")

VITE_LOCAL_PREVIEW=true \
VITE_LOCAL_SUPABASE_URL="$TRIAL_ORIGIN/supabase" \
VITE_LOCAL_SUPABASE_ANON_KEY="$anon_key" \
VITE_COLLABORATION_URL="wss://codex-remote.tailab0ab3.ts.net:$SERVE_PORT/collaboration" \
npm run build

setsid docker run --rm --name "$PROXY_NAME" --network host \
--volume "$PWD/dist:/usr/share/nginx/html:ro" \
--volume "$DIR/workspace-trial-nginx.conf:/etc/nginx/conf.d/default.conf:ro" \
nginx:alpine >"$runtime_dir/proxy.log" 2>&1 &
owned_pids+=("$!")
proxy_started=true

for _ in $(seq 1 200); do
if curl --fail --silent --output /dev/null --header "Host: codex-remote.tailab0ab3.ts.net:$SERVE_PORT" "http://127.0.0.1:$APP_PORT"; then break; fi
sleep 0.1
done
curl --fail --silent --output /dev/null --header "Host: codex-remote.tailab0ab3.ts.net:$SERVE_PORT" "http://127.0.0.1:$APP_PORT"

tailscale serve --bg --https="$SERVE_PORT" "http://127.0.0.1:$APP_PORT"
serve_started=true
tailscale_ip="$(tailscale ip -4)"
curl --fail --silent --output /dev/null \
--resolve "codex-remote.tailab0ab3.ts.net:$SERVE_PORT:$tailscale_ip" \
"$TRIAL_ORIGIN"

echo "Workspace trial ready: $TRIAL_ORIGIN"
echo "Commit: $(git rev-parse HEAD)"
echo "Stop with: systemctl --user stop debatelab-workspace-trial.service"

while kill -0 "${owned_pids[0]}" 2>/dev/null && kill -0 "${owned_pids[1]}" 2>/dev/null; do
sleep 5
done
echo "A workspace trial process exited unexpectedly" >&2
exit 1
85 changes: 85 additions & 0 deletions e2e/workspace-collaboration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const anonKey = process.env.LOCAL_SUPABASE_ANON_KEY;
const serviceKey = process.env.LOCAL_SUPABASE_SERVICE_ROLE_KEY;
const mailerUrl = process.env.WORKSPACE_TEST_MAILER_URL;
const collaborationPort = Number(process.env.COLLABORATION_PORT ?? 12345);
const reuseCollaboration = process.env.WORKSPACE_TEST_REUSE_COLLABORATION === "true";

test.skip(!supabaseUrl || !anonKey || !serviceKey || !mailerUrl, "Local workspace services are required");

Expand Down Expand Up @@ -42,6 +43,10 @@ async function stop(child: ChildProcess | undefined) {
}

async function startCollaboration() {
if (reuseCollaboration) {
await waitForPort(collaborationPort);
return undefined;
}
const child = spawn("npm", ["run", "start:collaboration"], {
cwd: process.cwd(),
detached: true,
Expand Down Expand Up @@ -488,3 +493,83 @@ test("workspace evidence, team AI review, and practice survive a teammate reload
await Promise.allSettled(ids.map((id) => admin.auth.admin.deleteUser(id)));
}
});

test("private writing and independent imports export useful workspace files", async ({ browser }, testInfo) => {
test.skip(testInfo.project.name === "mobile-chromium", "The complete mobile collaboration journey runs above");
test.setTimeout(120_000);
const admin = createClient(supabaseUrl!, serviceKey!, { auth: { persistSession: false } });
const password = `Local-${crypto.randomUUID()}!`;
const ids: string[] = [];
const contexts: BrowserContext[] = [];
let collaboration: ChildProcess | undefined;

try {
const email = `portability-${Date.now()}-${crypto.randomUUID()}@workspace.test`;
const created = await admin.auth.admin.createUser({ email, email_confirm: true, password });
if (created.error || !created.data.user) throw created.error ?? new Error("User creation failed");
ids.push(created.data.user.id);
const client = createClient(supabaseUrl!, anonKey!, { auth: { persistSession: false } });
const signedIn = await client.auth.signInWithPassword({ email, password });
if (signedIn.error || !signedIn.data.session) throw signedIn.error ?? new Error("Sign in failed");

collaboration = await startCollaboration();
const owner = await authenticatedPage(browser, signedIn.data.session);
contexts.push(owner.context);
await owner.page.goto("/app/workspaces");
await owner.page.getByRole("textbox", { name: "Motion" }).fill("Schools should teach media literacy");
await owner.page.getByRole("button", { name: "Create workspace" }).click();
await expect(owner.page).toHaveURL(/\/app\/workspaces\/[0-9a-f-]+$/, { timeout: 20_000 });
await owner.page.getByRole("button", { name: "R1" }).click();
await owner.page.locator(".ProseMirror").fill("Reusable R1 paragraph");
await expect(owner.page.getByText("Saved", { exact: true })).toBeVisible();

await owner.page.locator(".ProseMirror").click();
await owner.page.locator(".ProseMirror").press("ControlOrMeta+A");
owner.page.once("dialog", (dialog) => dialog.accept("R1 snippet"));
await owner.page.getByRole("button", { name: "Save snippet" }).click();
owner.page.once("dialog", (dialog) => dialog.accept("R1 template"));
await owner.page.getByRole("button", { name: "Save template" }).click();
await expect.poll(async () => {
const result = await admin.from("private_writing_items").select("id").eq("user_id", ids[0]);
if (result.error) throw result.error;
return result.data.length;
}).toBe(2);

await owner.page.goto("/app/workspaces");
await owner.page.getByRole("textbox", { name: "Motion" }).fill("Cities should expand transit");
await owner.page.getByRole("button", { name: "Create workspace" }).click();
await expect(owner.page).toHaveURL(/\/app\/workspaces\/[0-9a-f-]+$/, { timeout: 20_000 });
await owner.page.getByRole("button", { name: "R1" }).click();
await openPanel(owner.page, "Import, reuse & export");
const snippet = owner.page.getByRole("group", { name: "R1 snippet" });
await snippet.getByRole("button", { name: "Insert copy" }).click();
await expect(owner.page.locator(".ProseMirror")).toContainText("Reusable R1 paragraph");
const changed = await admin.from("private_writing_items").update({
content: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "Changed original" }] }] },
}).eq("user_id", ids[0]).eq("title", "R1 snippet");
if (changed.error) throw changed.error;
await expect(owner.page.locator(".ProseMirror")).not.toContainText("Changed original");

await owner.page.locator("#workspace-document-import").setInputFiles({
buffer: Buffer.from("# Importált beszéd\n\n1. Árvíztűrő tükörfúrógép\n2. [Forrás](https://example.com)"),
mimeType: "text/markdown",
name: "speech.md",
});
await expect(owner.page.getByLabel("Import preview")).toContainText("Árvíztűrő");
await owner.page.getByRole("button", { name: "Replace with independent copy" }).click();
await expect(owner.page.locator(".ProseMirror")).toContainText("Árvíztűrő tükörfúrógép");
await expect(owner.page.getByText("Saved", { exact: true })).toBeVisible();

const portability = owner.page.locator("details").filter({ has: owner.page.locator("summary").filter({ hasText: "Import, reuse & export" }) });
for (const name of ["DOCX", "PDF", "Packet DOCX", "Packet PDF"]) {
const download = owner.page.waitForEvent("download");
await portability.getByRole("button", { name, exact: true }).click();
await expect(await download).toBeTruthy();
}
await expect(portability.getByRole("button", { name: "Connect another Google account" })).toBeVisible();
} finally {
await stop(collaboration);
await Promise.allSettled(contexts.map((context) => context.close()));
await Promise.allSettled(ids.map((id) => admin.auth.admin.deleteUser(id)));
}
});
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"test:collaboration": "vitest run --config services/collaboration/vitest.config.ts",
"start:collaboration": "tsx services/collaboration/src/index.ts",
"test:watch": "vitest",
"test:edge": "deno test --allow-read --allow-env=DEMO_CODE_PEPPER,EXA_API_KEY,SUPABASE_URL,SUPABASE_ANON_KEY,SUPABASE_SERVICE_ROLE_KEY,SPARRING_INTEGRITY_SECRET,OPENROUTER_API_KEY,WORKSPACE_INVITE_SECRET,WORKSPACE_INVITE_MAILER_URL,WORKSPACE_INVITE_MAILER_TOKEN,WORKSPACE_INVITE_MAILER_FORMAT,WORKSPACE_INVITE_BASE_URL,WORKSPACE_INVITE_ALLOWED_ORIGINS,WORKSPACE_INVITE_SERVICE_ROLE_KEY --allow-net=127.0.0.1 supabase/functions/",
"test:edge": "deno test --allow-read --allow-env=DEMO_CODE_PEPPER,EXA_API_KEY,SUPABASE_URL,SUPABASE_ANON_KEY,SUPABASE_SERVICE_ROLE_KEY,SPARRING_INTEGRITY_SECRET,OPENROUTER_API_KEY,WORKSPACE_INVITE_SECRET,WORKSPACE_INVITE_MAILER_URL,WORKSPACE_INVITE_MAILER_TOKEN,WORKSPACE_INVITE_MAILER_FORMAT,WORKSPACE_INVITE_BASE_URL,WORKSPACE_INVITE_ALLOWED_ORIGINS,WORKSPACE_INVITE_SERVICE_ROLE_KEY,GOOGLE_CONNECTION_ENCRYPTION_KEY,GOOGLE_OAUTH_CLIENT_ID,GOOGLE_OAUTH_CLIENT_SECRET --allow-net=127.0.0.1 supabase/functions/",
"test:db": "supabase test db && bash scripts/test-daily-quota.sh && bash scripts/test-workspace-invite-concurrency.sh && bash scripts/test-workspace-mutation-concurrency.sh",
"test:e2e": "playwright test --project=desktop-chromium --project=mobile-chromium",
"test:e2e:webkit": "playwright test --project=webkit",
Expand Down
2 changes: 2 additions & 0 deletions playwright.workspace.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { defineConfig, devices } from "@playwright/test";

const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? "http://127.0.0.1:18081";
const hostResolverRules = process.env.PLAYWRIGHT_HOST_RESOLVER_RULES;

export default defineConfig({
testDir: "./e2e",
Expand All @@ -11,6 +12,7 @@ export default defineConfig({
use: {
actionTimeout: 10_000,
baseURL,
launchOptions: hostResolverRules ? { args: [`--host-resolver-rules=${hostResolverRules}`] } : undefined,
locale: "en-US",
screenshot: "only-on-failure",
trace: "retain-on-failure",
Expand Down
6 changes: 4 additions & 2 deletions scripts/workspace-test-env.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { readFile, writeFile } from "node:fs/promises";

const [source, target] = process.argv.slice(2);
const [source, target, appOrigin] = process.argv.slice(2);
const serviceRoleKey = process.env.LOCAL_SERVICE_ROLE_KEY;
const mailerHost = process.env.LOCAL_MAILER_HOST;
if (!source || !target || !serviceRoleKey || !mailerHost) throw new Error("Local Edge environment is incomplete");
const fixture = (await readFile(source, "utf8")).replace("workspace-mailer.invalid", mailerHost);
const fixture = (await readFile(source, "utf8"))
.replace("workspace-mailer.invalid", mailerHost)
.replaceAll("http://127.0.0.1:18081", appOrigin || "http://127.0.0.1:18081");
await writeFile(target, `${fixture.trim()}\nWORKSPACE_INVITE_SERVICE_ROLE_KEY=${serviceRoleKey}\n`, { mode: 0o600 });
Loading
Loading