Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
ec3dde5
Add authenticated Subrouter team-vault APIs
lawrencecchen Jul 29, 2026
2aa57dc
Merge remote-tracking branch 'origin/main' into feat-subrouter-local-…
lawrencecchen Jul 29, 2026
fbc6ef2
Decouple Subrouter CLI login from transcript storage
lawrencecchen Jul 29, 2026
752e049
Log safe Subrouter control-plane failures
lawrencecchen Jul 29, 2026
503f66c
Merge remote-tracking branch 'origin/main' into feat-subrouter-local-…
lawrencecchen Jul 29, 2026
da58e5f
Merge remote-tracking branch 'origin/main' into feat-subrouter-local-…
lawrencecchen Jul 29, 2026
e6677dd
Test Subrouter auth, validation, and team pagination
lawrencecchen Jul 29, 2026
2e3eec7
Harden Subrouter auth and team authorization
lawrencecchen Jul 29, 2026
27424cf
Merge remote-tracking branch 'origin/main' into feat-subrouter-local-…
lawrencecchen Jul 29, 2026
37c7a50
Test lease auth mode and bounded permission discovery
lawrencecchen Jul 29, 2026
17aaf42
Preserve lease constraints and bound team discovery
lawrencecchen Jul 29, 2026
7510625
Test fail-closed Subrouter authorization
lawrencecchen Jul 29, 2026
1be7fb2
Harden Subrouter authorization and device consent
lawrencecchen Jul 29, 2026
f645315
Test Subrouter authorization review regressions
lawrencecchen Jul 29, 2026
cac38df
Harden Subrouter device and team authorization
lawrencecchen Jul 29, 2026
bf35110
Test Subrouter dashboard authorization recovery
lawrencecchen Jul 29, 2026
8d42172
Render Subrouter authorization recovery state
lawrencecchen Jul 29, 2026
7270306
Test complete Subrouter authorization deadlines
lawrencecchen Jul 29, 2026
7094892
Bound cloud authorization and harden API edges
lawrencecchen Jul 29, 2026
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
2 changes: 1 addition & 1 deletion web/app/[locale]/dashboard/components/ai-account-forms.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export function AddAiAccountForms({ teamId }: { teamId: string }) {
) => {
setStatus(kind, { state: "submitting" });
try {
const response = await fetch(`/api/subrouter/accounts?teamId=${encodeURIComponent(teamId)}&validate=1`, {
const response = await fetch(`/api/subrouter/accounts?teamId=${encodeURIComponent(teamId)}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
Expand Down
51 changes: 51 additions & 0 deletions web/app/api/subrouter/accounts/[accountId]/repair/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { cloudDb } from "../../../../../../db/client";
import { readSubrouterAccountInput } from "../../../../../../services/subrouter/accountInput";
import { resolveSubrouterRequestContext } from "../../../../../../services/subrouter/requestContext";
import { subrouterErrorResponse } from "../../../../../../services/subrouter/routeHelpers";
import { getTenantForTeam } from "../../../../../../services/subrouter/tenants";
import { jsonResponse } from "../../../../../../services/vms/routeHelpers";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

type RouteContext = {
readonly params: Promise<{ readonly accountId: string }>;
};

export async function POST(
request: Request,
routeContext: RouteContext,
): Promise<Response> {
const { accountId: rawAccountId } = await routeContext.params;
const accountId = rawAccountId.trim();
if (!accountId || accountId.length > 200) {
return jsonResponse({ error: "invalid_request" }, 400);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

const resolved = await resolveSubrouterRequestContext(request, {
manageAccounts: true,
});
if (!resolved.ok) return resolved.response;
const context = resolved.value;

const input = await readSubrouterAccountInput(request);
if (!input.ok) {
return jsonResponse({ error: "invalid_request" }, input.status);
}
try {
const tenant = await getTenantForTeam(
cloudDb(),
context.team.teamId,
{ tenantKeySecret: context.config.tenantKeySecret },
);
if (!tenant) return jsonResponse({ error: "account_not_found" }, 404);
const account = await context.client.repairAccount(
tenant.tenantKey,
accountId,
input.value,
);
return jsonResponse({ teamId: context.team.teamId, account });
} catch (err) {
return subrouterErrorResponse(err);
}
}
38 changes: 5 additions & 33 deletions web/app/api/subrouter/accounts/[accountId]/route.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,11 @@
import { cloudDb } from "../../../../../db/client";
import {
browserMutationOriginAllowed,
jsonResponse,
parseBearer,
requestedVmTeamIdFromRequest,
requiresBrowserMutationProtection,
} from "../../../../../services/vms/routeHelpers";
import {
unauthorized,
verifyRequest,
} from "../../../../../services/vms/auth";
import {
createSubrouterClient,
subrouterRuntimeConfig,
} from "../../../../../services/subrouter/client";
import {
resolveTeam,
serviceUnavailableResponse,
subrouterErrorResponse,
} from "../../../../../services/subrouter/routeHelpers";
import { resolveSubrouterRequestContext } from "../../../../../services/subrouter/requestContext";
import { getTenantForTeam } from "../../../../../services/subrouter/tenants";

export const runtime = "nodejs";
Expand All @@ -35,26 +22,11 @@ export async function DELETE(request: Request, context: RouteContext): Promise<R
return jsonResponse({ error: "invalid_request" }, 400);
}

const requestedTeamId = requestedVmTeamIdFromRequest(request);
const user = await verifyRequest(request, {
requestedTeamId,
allowCookie: true,
});
if (!user) return unauthorized();
const bearer = parseBearer(request);
if (requiresBrowserMutationProtection(request.method, bearer) && !browserMutationOriginAllowed(request)) {
return jsonResponse({ error: "forbidden" }, 403);
}

const team = resolveTeam(request, user);
if (!team.ok) return team.response;

const config = subrouterRuntimeConfig();
if (!config) return serviceUnavailableResponse();
const client = createSubrouterClient({
baseUrl: config.baseUrl,
adminToken: config.adminToken,
const resolved = await resolveSubrouterRequestContext(request, {
manageAccounts: true,
});
if (!resolved.ok) return resolved.response;
const { team, config, client } = resolved.value;

try {
const tenant = await getTenantForTeam(
Expand Down
244 changes: 18 additions & 226 deletions web/app/api/subrouter/accounts/route.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,12 @@
import { cloudDb } from "../../../../db/client";
import {
browserMutationOriginAllowed,
jsonResponse,
parseBearer,
requestedVmTeamIdFromRequest,
requiresBrowserMutationProtection,
} from "../../../../services/vms/routeHelpers";
import { readSubrouterAccountInput } from "../../../../services/subrouter/accountInput";
import {
unauthorized,
verifyRequest,
} from "../../../../services/vms/auth";
import {
createSubrouterClient,
subrouterRuntimeConfig,
type ClaudeAccountInput,
type CodexAccountInput,
type SubrouterAccountInput,
} from "../../../../services/subrouter/client";
import {
resolveTeam,
serviceUnavailableResponse,
subrouterErrorResponse,
} from "../../../../services/subrouter/routeHelpers";
import { resolveSubrouterRequestContext } from "../../../../services/subrouter/requestContext";
import {
getTenantForTeam,
getOrCreateTenantForTeam,
Expand All @@ -30,12 +15,10 @@ import {
export const runtime = "nodejs";
export const dynamic = "force-dynamic";

const MAX_REQUEST_BYTES = 64 * 1024;
const MAX_LABEL_LENGTH = 120;

export async function GET(request: Request): Promise<Response> {
const context = await resolveRequestContext(request);
if (!context.ok) return context.response;
const resolved = await resolveSubrouterRequestContext(request);
if (!resolved.ok) return resolved.response;
const context = resolved.value;

try {
const tenant = await getTenantForTeam(
Expand All @@ -56,17 +39,16 @@ export async function GET(request: Request): Promise<Response> {
}

export async function POST(request: Request): Promise<Response> {
const context = await resolveRequestContext(request);
if (!context.ok) return context.response;

const body = await readBoundedJson(request);
if (!body.ok) return jsonResponse({ error: "invalid_request" }, body.status);

const input = validateAccountInput(body.value);
if (!input.ok) return jsonResponse({ error: "invalid_request" }, 400);

const validate = requestUrl(request)?.searchParams.get("validate") === "1";
const resolved = await resolveSubrouterRequestContext(request, {
manageAccounts: true,
});
if (!resolved.ok) return resolved.response;
const context = resolved.value;

const input = await readSubrouterAccountInput(request);
if (!input.ok) {
return jsonResponse({ error: "invalid_request" }, input.status);
}
try {
const tenant = await getOrCreateTenantForTeam(
cloudDb(),
Expand All @@ -77,202 +59,12 @@ export async function POST(request: Request): Promise<Response> {
tenantKeySecret: context.config.tenantKeySecret,
},
);
const account = await context.client.createAccount(tenant.tenantKey, input.value, { validate });
const account = await context.client.createAccount(
tenant.tenantKey,
input.value,
);
return jsonResponse({ teamId: context.team.teamId, account });
} catch (err) {
return subrouterErrorResponse(err);
}
}

async function resolveRequestContext(request: Request): Promise<
| {
ok: true;
team: { teamId: string; teamName: string };
config: NonNullable<ReturnType<typeof subrouterRuntimeConfig>>;
client: ReturnType<typeof createSubrouterClient>;
}
| { ok: false; response: Response }
> {
const requestedTeamId = requestedVmTeamIdFromRequest(request);
const user = await verifyRequest(request, {
requestedTeamId,
allowCookie: true,
});
if (!user) return { ok: false, response: unauthorized() };
const bearer = parseBearer(request);
if (requiresBrowserMutationProtection(request.method, bearer) && !browserMutationOriginAllowed(request)) {
return {
ok: false,
response: jsonResponse({ error: "forbidden" }, 403),
};
}

const team = resolveTeam(request, user);
if (!team.ok) return team;

const config = subrouterRuntimeConfig();
if (!config) {
return {
ok: false,
response: serviceUnavailableResponse(),
};
}

return {
ok: true,
team,
config,
client: createSubrouterClient({
baseUrl: config.baseUrl,
adminToken: config.adminToken,
}),
};
}

async function readBoundedJson(
request: Request,
): Promise<{ ok: true; value: Record<string, unknown> } | { ok: false; status: number }> {
const lengthHeader = request.headers.get("content-length");
if (lengthHeader && Number(lengthHeader) > MAX_REQUEST_BYTES) {
return { ok: false, status: 413 };
}
const bounded = await readBoundedBody(request, MAX_REQUEST_BYTES);
if (!bounded.ok) return bounded;
const raw = bounded.value;

let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return { ok: false, status: 400 };
}
if (!isRecord(parsed)) return { ok: false, status: 400 };
return { ok: true, value: parsed };
}

function validateAccountInput(
body: Record<string, unknown>,
): { ok: true; value: SubrouterAccountInput } | { ok: false } {
const provider = body.provider;
if (typeof provider !== "string") return { ok: false };
const label = optionalLabel(body.label);
if (label === false) return { ok: false };

switch (provider) {
case "claude": {
const claudeAiOauth = body.claudeAiOauth;
if (!isRecord(claudeAiOauth)) return { ok: false };
if (!requiredString(claudeAiOauth.accessToken) || !requiredString(claudeAiOauth.refreshToken)) {
return { ok: false };
}
if (
typeof claudeAiOauth.expiresAt !== "string" &&
typeof claudeAiOauth.expiresAt !== "number"
) {
return { ok: false };
}
return {
ok: true,
value: { provider, ...(label ? { label } : {}), claudeAiOauth: claudeAiOauth as ClaudeAccountInput["claudeAiOauth"] },
};
}
case "anthropic-apikey": {
const apiKey = trimmedString(body.apiKey);
if (!apiKey.startsWith("sk-ant-")) return { ok: false };
return {
ok: true,
value: { provider, ...(label ? { label } : {}), apiKey },
};
}
case "codex": {
const tokens = body.tokens;
if (!isRecord(tokens)) return { ok: false };
if (
!requiredString(tokens.accessToken) ||
!requiredString(tokens.refreshToken) ||
!requiredString(tokens.idToken) ||
!requiredString(tokens.accountID)
) {
return { ok: false };
}
return {
ok: true,
value: { provider, ...(label ? { label } : {}), tokens: tokens as CodexAccountInput["tokens"] },
};
}
case "openai-apikey": {
const apiKey = trimmedString(body.apiKey);
if (!apiKey.startsWith("sk-")) return { ok: false };
return {
ok: true,
value: { provider, ...(label ? { label } : {}), apiKey },
};
}
default:
return { ok: false };
}
}

function optionalLabel(value: unknown): string | false | undefined {
if (value === undefined || value === null) return undefined;
if (typeof value !== "string") return false;
const trimmed = value.trim();
if (!trimmed) return undefined;
if (trimmed.length > MAX_LABEL_LENGTH) return false;
return trimmed;
}

function requiredString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}

function trimmedString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}

async function readBoundedBody(
request: Request,
maxBytes: number,
): Promise<{ ok: true; value: string } | { ok: false; status: number }> {
const body = request.body;
if (!body) return { ok: true, value: "" };

const reader = body.getReader();
const chunks: Uint8Array[] = [];
let totalBytes = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
totalBytes += value.byteLength;
if (totalBytes > maxBytes) {
await reader.cancel().catch(() => {});
return { ok: false, status: 413 };
}
chunks.push(value);
}
} catch {
return { ok: false, status: 400 };
}

const merged = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
merged.set(chunk, offset);
offset += chunk.byteLength;
}
return { ok: true, value: new TextDecoder().decode(merged) };
}

function requestUrl(request: Request): URL | null {
try {
return new URL(request.url);
} catch {
return null;
}
}

function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
Loading