Skip to content
Draft
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
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
# Client (inlined into the browser bundle)
VITE_CONVEX_URL=
VITE_CONVEX_SITE_URL=
VITE_NODEROOM_AUTH_REQUIRED=0
VITE_NODEROOM_AUTH_PROVIDER=github
VITE_NODEROOM_FREE_ONLY=0
VITE_VOICE_STT_PROVIDER_ORDER=provider,browser
VITE_VOICE_TTS_PROVIDER_ORDER=browser,provider
Expand All @@ -17,6 +19,11 @@ VITE_NOTEBOOK_SYNC=
# Production safety switches. Leave disabled for local deterministic/demo rooms.
# Set NODEROOM_REQUIRE_CONVEX_IDENTITY=1 in production if anonymous room tokens are not acceptable.
NODEROOM_REQUIRE_CONVEX_IDENTITY=0
# Convex Auth production OAuth values live on the Convex deployment, never in
# Vite or Vercel client variables. Convex Auth also requires JWT_PRIVATE_KEY,
# JWKS, and SITE_URL; initialize them with `npx @convex-dev/auth --prod`.
AUTH_GITHUB_ID=
AUTH_GITHUB_SECRET=
PROVIDER_EGRESS_REQUIRE_ALLOWLIST=0
NODEAGENT_ALLOWED_PROVIDERS=openai,anthropic,gemini,openrouter,nebius,local
# Optional emergency brakes. Examples:
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ playwright/.cache/
.proofloop/orchestrator/
.proofloop/prod-proxy-longrun/
.proofloop/runner/
.proofloop/rollback/
.proofloop/workers/
.proofloop/memory/
.proofloop/agents/
Expand Down
4 changes: 4 additions & 0 deletions convex/_generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type * as alwaysOnShape from "../alwaysOnShape.js";
import type * as artifacts from "../artifacts.js";
import type * as auditBundle from "../auditBundle.js";
import type * as auditLog from "../auditLog.js";
import type * as auth from "../auth.js";
import type * as benchmarkGrade from "../benchmarkGrade.js";
import type * as captures from "../captures.js";
import type * as capturesNode from "../capturesNode.js";
Expand Down Expand Up @@ -57,6 +58,7 @@ import type * as nodemem from "../nodemem.js";
import type * as nodememCompile from "../nodememCompile.js";
import type * as notebookAgent from "../notebookAgent.js";
import type * as notebookGraph from "../notebookGraph.js";
import type * as notebookKernel from "../notebookKernel.js";
import type * as notebookProcessing from "../notebookProcessing.js";
import type * as noteworthy from "../noteworthy.js";
import type * as okf from "../okf.js";
Expand Down Expand Up @@ -102,6 +104,7 @@ declare const fullApi: ApiFromModules<{
artifacts: typeof artifacts;
auditBundle: typeof auditBundle;
auditLog: typeof auditLog;
auth: typeof auth;
benchmarkGrade: typeof benchmarkGrade;
captures: typeof captures;
capturesNode: typeof capturesNode;
Expand Down Expand Up @@ -136,6 +139,7 @@ declare const fullApi: ApiFromModules<{
nodememCompile: typeof nodememCompile;
notebookAgent: typeof notebookAgent;
notebookGraph: typeof notebookGraph;
notebookKernel: typeof notebookKernel;
notebookProcessing: typeof notebookProcessing;
noteworthy: typeof noteworthy;
okf: typeof okf;
Expand Down
2 changes: 1 addition & 1 deletion convex/agentJobRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ type PublicAgentJobStream = {

type LiveOperationKind = "action" | "query" | "mutation" | "model_call" | "tool_call" | "scheduler" | "lease" | "checkpoint";

const QUERY_TOOLS = new Set(["snapshot", "list_artifacts", "awareness", "read_range", "search_sheet_context", "fetch_source", "read_notebook"]);
const QUERY_TOOLS = new Set(["snapshot", "list_artifacts", "awareness", "read_range", "search_sheet_context", "inspect_workbook", "verify_workbook", "fetch_source", "read_notebook"]);
const MUTATION_TOOLS = new Set(["propose_lock", "release_lock", "edit_cell", "create_draft", "say", "update_wiki", "append_notebook_outline", "write_cell_result", "write_locked_cell", "write_locked_cell_result", "write_locked_cells", "write_locked_cell_results"]);

function envNumber(name: string, fallback: number, min: number, max: number): number {
Expand Down
10 changes: 10 additions & 0 deletions convex/auth.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { AuthConfig } from "convex/server";

export default {
providers: [
{
domain: process.env.CONVEX_SITE_URL!,
applicationID: "convex",
},
],
} satisfies AuthConfig;
11 changes: 11 additions & 0 deletions convex/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import GitHub from "@auth/core/providers/github";
import { Password } from "@convex-dev/auth/providers/Password";
import { convexAuth } from "@convex-dev/auth/server";

const githubConfigured = Boolean(process.env.AUTH_GITHUB_ID && process.env.AUTH_GITHUB_SECRET);

export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({
// Production uses GitHub OAuth. Password auth exists only to make local and
// isolated preview authentication deterministic without third-party secrets.
providers: [githubConfigured ? GitHub : Password],
});
2 changes: 2 additions & 0 deletions convex/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ import type { StreamId } from "@convex-dev/persistent-text-streaming";
import { streamingComponent } from "./streaming";
import { streamPrivateReplyText } from "./streamingModel";
import { privateAgentSystemPrompt } from "./agent";
import { auth } from "./auth";

const http = httpRouter();
auth.addHttpRoutes(http);
const assertVoiceRequesterRef = makeFunctionReference<"query">("voice:assertVoiceRequester");

const CORS = { "Access-Control-Allow-Origin": "*", Vary: "Origin" } as const;
Expand Down
9 changes: 7 additions & 2 deletions convex/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ export async function hashToken(token: string): Promise<string> {
return `v1:${salt}:${await sha256Hex(`${salt}:${token}`)}`;
}

async function verifyTokenHash(token: string, storedHash?: string): Promise<boolean> {
export async function authTokenMatchesHash(token: string, storedHash?: string): Promise<boolean> {
requireStrongAuthToken(token);
if (!storedHash) return false;
if (storedHash?.startsWith("v1:")) {
Expand Down Expand Up @@ -149,13 +149,18 @@ export async function requireActorProof(ctx: DbCtx, roomId: Id<"rooms">, proof:
}
if (member.revokedAt != null) throw new Error("actor_revoked");
const identity = await ctx.auth.getUserIdentity();
if (productionIdentityRequired()) {
if (!identity) throw new Error("production_identity_required");
if (!member.authSubject || member.authSubject !== identity.subject) throw new Error("identity_mismatch");
return { kind: "user" as const, id: String(member._id), name: member.name };
}
if (identity && member.authSubject && member.authSubject === identity.subject) {
return { kind: "user" as const, id: String(member._id), name: member.name };
}
if (!token) throw new Error("invalid_actor_token");
let valid = false;
try {
valid = await verifyTokenHash(token, member.authTokenHash);
valid = await authTokenMatchesHash(token, member.authTokenHash);
} catch {
throw new Error("invalid_actor_token");
}
Expand Down
27 changes: 27 additions & 0 deletions convex/notebookKernel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"use node";

import { v } from "convex/values";
import { makeFunctionReference } from "convex/server";
import { action } from "./_generated/server";
import { actorProofV } from "./lib";
import { executeNotebookKernel, type NotebookKernelRequest, type NotebookKernelResult } from "../src/notebook/notebookKernel";

const assertMemberRef = makeFunctionReference<"query">("captures:assertMember") as any;

export const execute = action({
args: {
roomId: v.id("rooms"),
requester: actorProofV,
kind: v.union(v.literal("calculation"), v.literal("sql"), v.literal("chart")),
input: v.string(),
tables: v.optional(v.any()),
},
handler: async (ctx, args): Promise<NotebookKernelResult> => {
await ctx.runQuery(assertMemberRef, { roomId: args.roomId, requester: args.requester });
return executeNotebookKernel({
kind: args.kind,
input: args.input,
tables: args.tables as NotebookKernelRequest["tables"],
}, { backend: "convex", now: Date.now() });
},
});
84 changes: 71 additions & 13 deletions convex/rooms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@
* (mutations are deterministic — no Math.random/uuid inside). Anonymous join is a
* stand-in for `@convex-dev/auth`'s Anonymous provider (see docs/STACK.md). */
import { v } from "convex/values";
import { mutation, query, type MutationCtx } from "./_generated/server";
import { internal } from "./_generated/api";
import { internalMutation, mutation, query, type MutationCtx } from "./_generated/server";
import type { Id } from "./_generated/dataModel";
import { actorProofV, getRequiredProductionIdentity, hashToken, requireActorProof, type ActorValue } from "./lib";
import { actorProofV, authTokenMatchesHash, getRequiredProductionIdentity, hashToken, requireActorProof, type ActorValue } from "./lib";
import { syncSpreadsheetIndexFromSeed } from "./spreadsheetIndexLib";
import { assertCreateArtifactLimits } from "./artifacts";

Expand Down Expand Up @@ -459,7 +460,7 @@ export const create = mutation({
for (const art of seedArtifacts) assertCreateArtifactLimits(art);
const existing = await ctx.db.query("rooms").withIndex("by_code", (q) => q.eq("code", code)).first();
if (existing) throw new Error("room_code_taken");
const roomId = await ctx.db.insert("rooms", { code, title: a.title, hostId: "", autoAllow: a.autoAllow ?? false, status: "live", createdAt: now });
const roomId = await ctx.db.insert("rooms", { code, title: a.title, hostId: "", autoAllow: a.autoAllow ?? false, status: "live", createdAt: now, experience: "workspace", starterBackfill: "ready" });
const memberId = await ctx.db.insert("members", { roomId, name: a.hostName, role: "host", anon: false, color: palette[0], authTokenHash: await hashToken(a.authToken), authSubject: identity?.subject, lastSeenAt: now });
await ctx.db.patch(roomId, { hostId: memberId });
await ctx.db.insert("agentSessions", { roomId, agentId: "agent_room", agentName: "Room NodeAgent", scope: "public", status: "idle", lastAction: "started", updatedAt: now });
Expand All @@ -475,7 +476,10 @@ export const create = mutation({
});

export const createStarterRoom = mutation({
args: { code: v.string(), title: v.string(), hostName: v.string(), authToken: v.string(), autoAllow: v.optional(v.boolean()) },
args: {
code: v.string(), title: v.string(), hostName: v.string(), authToken: v.string(), autoAllow: v.optional(v.boolean()),
deferHeavySeed: v.optional(v.boolean()),
},
handler: async (ctx, a) => {
const now = Date.now();
const identity = await getRequiredProductionIdentity(ctx);
Expand All @@ -484,7 +488,17 @@ export const createStarterRoom = mutation({
if (a.title.length > MAX_TITLE_LEN || a.hostName.length > MAX_NAME_LEN) throw new Error("field_too_long");
const existing = await ctx.db.query("rooms").withIndex("by_code", (q) => q.eq("code", code)).first();
if (existing) throw new Error("room_code_taken");
const roomId = await ctx.db.insert("rooms", { code, title: a.title, hostId: "", autoAllow: a.autoAllow ?? false, status: "live", createdAt: now });
const deferHeavySeed = a.deferHeavySeed === true;
const roomId = await ctx.db.insert("rooms", {
code,
title: a.title,
hostId: "",
autoAllow: a.autoAllow ?? false,
status: "live",
createdAt: now,
experience: "sample",
starterBackfill: deferHeavySeed ? "pending" : "ready",
});
const memberId = await ctx.db.insert("members", {
roomId,
name: a.hostName,
Expand All @@ -500,25 +514,53 @@ export const createStarterRoom = mutation({
await ctx.db.insert("agentSessions", { roomId, agentId: "agent_priv", agentName: "Your NodeAgent", scope: "private", ownerId: memberId, status: "idle", lastAction: "started", updatedAt: now });
const actor = { kind: "user" as const, id: String(memberId), name: a.hostName };
await ctx.db.insert("traces", { roomId, ts: now, actor, type: "room_created", summary: `${a.hostName} created the room` });
const companyResearchId = await insertStarterArtifact(ctx, { roomId, kind: "sheet", title: "Company research", seed: startupResearchSeed(), actor, now, meta: startupResearchMeta() });
await insertStarterArtifact(ctx, { roomId, kind: "note", title: "Diligence memo", seed: starterNoteSeed(), actor, now });
await insertStarterArtifact(ctx, { roomId, kind: "wall", title: "Risk / opportunity wall", seed: starterWallSeed(), actor, now });
await insertStarterArtifact(ctx, { roomId, kind: "sheet", title: "Runway / milestones", seed: starterRunwaySeed(), actor, now, meta: starterRunwayMeta() });
await insertStarterArtifact(ctx, { roomId, kind: "note", title: "Open questions / workplan", seed: starterWorkplanSeed(), actor, now });
await insertStarterArtifact(ctx, { roomId, kind: "sheet", title: "Q3 variance", seed: starterSheetSeed(), actor, now });
await seedStarterMessages(ctx, { roomId, host: actor, now });
await padStarterTraces(ctx, { roomId, artifactId: companyResearchId, now });
if (deferHeavySeed) {
// Keep the first room frame free to render before the scale fixture starts its large write set.
await ctx.scheduler.runAfter(1_000, internal.rooms.finishStarterRoom, { roomId, memberId });
} else {
const companyResearchId = await insertStarterArtifact(ctx, { roomId, kind: "sheet", title: "Company research", seed: startupResearchSeed(), actor, now, meta: startupResearchMeta() });
await seedStarterMessages(ctx, { roomId, host: actor, now });
await padStarterTraces(ctx, { roomId, artifactId: companyResearchId, now });
}
return { roomId, memberId };
},
});

export const finishStarterRoom = internalMutation({
args: { roomId: v.id("rooms"), memberId: v.id("members") },
handler: async (ctx, { roomId, memberId }) => {
const room = await ctx.db.get(roomId);
if (!room || room.starterBackfill !== "pending") return { ok: false as const, reason: "not_pending" as const };
const member = await ctx.db.get(memberId);
if (!member || String(member.roomId) !== String(roomId)) return { ok: false as const, reason: "member_missing" as const };
const now = Date.now();
const actor: ActorValue = { kind: "user", id: String(memberId), name: member.name };
const artifacts = await ctx.db.query("artifacts").withIndex("by_room", (q) => q.eq("roomId", roomId)).collect();
const companyResearch = artifacts.find((artifact) => artifact.title === "Company research");
const companyResearchId = companyResearch
? companyResearch._id
: await insertStarterArtifact(ctx, { roomId, kind: "sheet", title: "Company research", seed: startupResearchSeed(), actor, now, meta: startupResearchMeta() });
const existingMessages = await ctx.db.query("messages").withIndex("by_room_channel", (q) => q.eq("roomId", roomId).eq("channel", "public")).take(1);
if (existingMessages.length === 0) await seedStarterMessages(ctx, { roomId, host: actor, now });
await padStarterTraces(ctx, { roomId, artifactId: companyResearchId, now });
await ctx.db.patch(roomId, { starterBackfill: "ready" });
return { ok: true as const };
},
});

export const ensureStarterRoomState = mutation({
args: { roomId: v.id("rooms"), requester: actorProofV },
handler: async (ctx, { roomId, requester }) => {
const room = await ctx.db.get(roomId);
if (!room) throw new Error("room_not_found");
const actor = await requireActorProof(ctx, roomId, requester);
if (String(room.hostId) !== actor.id) throw new Error("host_required");
if (room.experience === "workspace") return { ok: false as const, reason: "not_sample_workspace" as const };
const now = Date.now();
const artifacts = await ctx.db.query("artifacts").withIndex("by_room", (q) => q.eq("roomId", roomId)).collect();
let addedArtifacts = 0;
Expand Down Expand Up @@ -570,6 +612,7 @@ export const ensureStarterRoomState = mutation({
if (publicMessages.length < 20) await seedStarterMessages(ctx, { roomId, host: actor, now });
if (companyResearch) await padStarterTraces(ctx, { roomId, artifactId: companyResearch._id, now });
if (room.title === "Blank NodeRoom") await ctx.db.patch(roomId, { title: "Startup diligence" });
if (room.starterBackfill !== "ready") await ctx.db.patch(roomId, { starterBackfill: "ready" });

return { ok: true as const, addedArtifacts, patchedCells };
},
Expand All @@ -586,14 +629,26 @@ export const joinAnonymous = mutation({
if (a.name.length > MAX_NAME_LEN) throw new Error("field_too_long");
const existing = await ctx.db.query("members").withIndex("by_room", (q) => q.eq("roomId", room._id)).collect();
const activeMembers = existing.filter((m) => m.revokedAt == null);
const identityMatch = identity ? activeMembers.find((member) => member.authSubject === identity.subject) : undefined;
// Lost-response recovery: retrying with the same room-scoped token resumes the
// original member (including the host) instead of consuming another seat.
// Authenticated users also resume across browsers without consuming another
// room seat or relying on the first browser's room token.
const tokenMatches = identityMatch ? [] : await Promise.all(activeMembers.map((member) => authTokenMatchesHash(a.authToken, member.authTokenHash)));
const resumed = identityMatch ?? activeMembers[tokenMatches.findIndex(Boolean)];
if (resumed) {
await ctx.db.patch(resumed._id, { lastSeenAt: now });
return { roomId: room._id, memberId: resumed._id, name: resumed.name, resumed: true as const };
}
// Abuse gates: room capacity + join-rate window (joins are members created in the last 60s).
if (activeMembers.length >= MAX_MEMBERS_PER_ROOM) return { error: "room_full" as const };
const recentJoins = existing.filter((m) => m._creationTime > now - 60_000).length;
if (recentJoins >= MAX_JOINS_PER_MINUTE) return { error: "join_rate_limited" as const };
const count = activeMembers.length;
const memberId = await ctx.db.insert("members", { roomId: room._id, name: a.name, role: "member", anon, color: palette[count % palette.length], authTokenHash: await hashToken(a.authToken), authSubject: identity?.subject, lastSeenAt: now });
const authTokenHash = await hashToken(a.authToken);
const memberId = await ctx.db.insert("members", { roomId: room._id, name: a.name, role: "member", anon, color: palette[count % palette.length], authTokenHash, authSubject: identity?.subject, lastSeenAt: now });
await ctx.db.insert("traces", { roomId: room._id, ts: now, actor: { kind: "user", id: memberId, name: a.name }, type: "member_joined", summary: `${a.name} joined${anon ? " (anon)" : ""}` });
return { roomId: room._id, memberId };
return { roomId: room._id, memberId, name: a.name };
},
});

Expand All @@ -603,6 +658,9 @@ export const leave = mutation({
const actor = await requireActorProof(ctx, roomId, requester);
const member = await ctx.db.get(actor.id as Id<"members">);
if (!member || String(member.roomId) !== String(roomId)) throw new Error("actor_not_in_room");
if (member.role === "host") {
return { ok: false as const, reason: "host_transfer_required" as const };
}
const now = Date.now();
await ctx.db.patch(member._id, { lastSeenAt: now, revokedAt: now });
await ctx.db.insert("traces", {
Expand Down Expand Up @@ -637,7 +695,7 @@ export const byCode = query({
args: { code: v.string() },
handler: async (ctx, { code }) => {
const r = await ctx.db.query("rooms").withIndex("by_code", (q) => q.eq("code", code.toUpperCase())).first();
return r ? { roomId: r._id } : null;
return r ? { roomId: r._id, experience: r.experience ?? "workspace" as const } : null;
},
});

Expand Down Expand Up @@ -692,7 +750,7 @@ export const full = query({
};
});
return {
room: { id: room._id, code: room.code, title: room.title, hostId: room.hostId, autoAllow: room.autoAllow, status: room.status, createdAt: room.createdAt },
room: { id: room._id, code: room.code, title: room.title, hostId: room.hostId, autoAllow: room.autoAllow, status: room.status, createdAt: room.createdAt, experience: room.experience, starterBackfill: room.starterBackfill },
members, artifacts, locks, sessions, drafts,
};
},
Expand Down Expand Up @@ -742,7 +800,7 @@ export const meta = query({
};
});
return {
room: { id: room._id, code: room.code, title: room.title, hostId: room.hostId, autoAllow: room.autoAllow, status: room.status, createdAt: room.createdAt },
room: { id: room._id, code: room.code, title: room.title, hostId: room.hostId, autoAllow: room.autoAllow, status: room.status, createdAt: room.createdAt, experience: room.experience, starterBackfill: room.starterBackfill },
members, artifacts, locks, sessions, drafts,
};
},
Expand Down
Loading
Loading