Skip to content
Closed
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
17 changes: 17 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -225,4 +225,21 @@ HINDSIGHT_API_LOG_LEVEL=info
# Optional: Require a shared access key to view the Control Plane UI.
# When set, visitors see a login page and must enter the key before
# accessing the dashboard or any /api/* routes (except /api/health).
# This key is the admin scope: it sees every bank (prefix "").
# HINDSIGHT_CP_ACCESS_KEY=your-shared-secret-key

# Optional: Additional scoped tokens, each mapped to a bank-id prefix, so the
# Control Plane can be embedded per-user. JSON array of {token, prefix, label?}.
# A scoped token only sees banks equal to its prefix or namespaced under it
# ("u2" matches "u2" and "u2--*", never "u20"). The admin key above stays all-banks.
# HINDSIGHT_CP_TOKENS=[{"token":"user-2-token","prefix":"u2","label":"user 2"}]

# Optional: SameSite policy for the CP session cookie. Set to "none" so the
# cookie survives inside a cross-site iframe (adds Secure + Partitioned/CHIPS;
# requires HTTPS). Leave unset for local http dev to keep SameSite=Lax.
# HINDSIGHT_CP_COOKIE_SAMESITE=none

# Optional: Origins allowed to embed the Control Plane in an iframe
# (Content-Security-Policy: frame-ancestors). Space- or comma-separated list,
# e.g. the tokengate origin. Falls back to 'self' when unset.
# HINDSIGHT_CP_FRAME_ANCESTORS=https://tokengate.example.com
102 changes: 102 additions & 0 deletions hindsight-control-plane/src/app/api/auth/embed-login/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { NextRequest, NextResponse } from "next/server";
import { localizeApiErrorPayload } from "@/lib/i18n/api-errors";

import {
ACCESS_KEY_COOKIE,
SESSION_MAX_AGE_SECONDS,
createSessionToken,
sessionCookieOptions,
} from "@/lib/auth/session";
import { resolveToken } from "@/lib/auth/tokens";
import { sanitizeReturnTo, withBasePath } from "@/lib/base-path";

const DEFAULT_RETURN_TO = "/dashboard";

/**
* Cross-site auto-login for the tokengate iframe. A hidden `<form target=iframe>`
* POSTs the token here (form-encoded or JSON); we force-recreate the session
* cookie with the resolved prefix and 302 to the sanitized `returnTo` so the
* iframe lands directly on the scoped dashboard. The 302 + Set-Cookie is what
* makes a single cross-site POST render the dashboard.
*/
export async function POST(request: NextRequest) {
const accessKey = process.env.HINDSIGHT_CP_ACCESS_KEY;
const contentType = request.headers.get("content-type") ?? "";
const isJson = contentType.includes("application/json");

if (!accessKey) {
return isJson
? NextResponse.json(
localizeApiErrorPayload(request, {
error: "Access key not configured",
errorKey: "api.errors.auth.accessKeyNotConfigured",
}),
{ status: 503 }
)
: htmlError(503, "Access key not configured");
}

let token: string | undefined;
let returnTo: string | null | undefined;

if (isJson) {
try {
const body = (await request.json()) as { token?: string; returnTo?: string };
token = body.token;
returnTo = body.returnTo;
} catch {
return NextResponse.json(
localizeApiErrorPayload(request, {
error: "Invalid request body",
errorKey: "api.errors.auth.invalidRequestBody",
}),
{ status: 400 }
);
}
} else {
const form = await request.formData();
const rawToken = form.get("token");
const rawReturnTo = form.get("returnTo");
token = typeof rawToken === "string" ? rawToken : undefined;
returnTo = typeof rawReturnTo === "string" ? rawReturnTo : undefined;
}

const resolved = resolveToken(token);
if (!resolved) {
return isJson
? NextResponse.json(
localizeApiErrorPayload(request, {
error: "Invalid access key",
errorKey: "api.errors.auth.invalidAccessKey",
}),
{ status: 401 }
)
: htmlError(401, "Invalid access key");
}

// Relative (path-only) Location so the browser resolves it against the public
// origin it actually loaded, not the internal upstream host (request.url is
// 0.0.0.0:9999 behind nginx). sanitizeReturnTo already forbids off-origin
// targets, so a relative path can never become an open redirect.
const target = withBasePath(sanitizeReturnTo(returnTo, DEFAULT_RETURN_TO));
const response = new NextResponse(null, {
status: 302,
headers: { Location: target },
});

response.cookies.set({
name: ACCESS_KEY_COOKIE,
value: await createSessionToken(accessKey, resolved.prefix),
...sessionCookieOptions(request),
maxAge: SESSION_MAX_AGE_SECONDS,
});

return response;
}

function htmlError(status: number, message: string): NextResponse {
return new NextResponse(`<!doctype html><html><body><p>${message}</p></body></html>`, {
status,
headers: { "content-type": "text/html; charset=utf-8" },
});
}
26 changes: 4 additions & 22 deletions hindsight-control-plane/src/app/api/auth/login/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
createSessionToken,
sessionCookieOptions,
} from "@/lib/auth/session";
import { resolveToken } from "@/lib/auth/tokens";

export async function POST(request: NextRequest) {
const accessKey = process.env.HINDSIGHT_CP_ACCESS_KEY;
Expand Down Expand Up @@ -35,12 +36,9 @@ export async function POST(request: NextRequest) {
);
}

const providedKey = body.key;
const resolved = resolveToken(body.key);

// Constant-time comparison to prevent timing attacks
const isValid = providedKey && constantTimeCompare(providedKey, accessKey);

if (!isValid) {
if (!resolved) {
return NextResponse.json(
localizeApiErrorPayload(request, {
error: "Invalid access key",
Expand All @@ -54,26 +52,10 @@ export async function POST(request: NextRequest) {

response.cookies.set({
name: ACCESS_KEY_COOKIE,
value: await createSessionToken(accessKey),
value: await createSessionToken(accessKey, resolved.prefix),
...sessionCookieOptions(request),
maxAge: SESSION_MAX_AGE_SECONDS,
});

return response;
}

/**
* Constant-time string comparison to prevent timing attacks.
*/
function constantTimeCompare(a: string, b: string): boolean {
if (a.length !== b.length) {
return false;
}

let result = 0;
for (let i = 0; i < a.length; i++) {
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
}

return result === 0;
}
18 changes: 18 additions & 0 deletions hindsight-control-plane/src/app/api/auth/whoami/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { NextRequest, NextResponse } from "next/server";

import { getSessionPrefix } from "@/lib/auth/session";
import { labelForPrefix } from "@/lib/auth/tokens";

/**
* Reports the current session's bank scope so the client can tailor the UI
* (hide admin-only actions, foreign chrome). Requires a valid session: it lives
* under `/api/` and is not in PUBLIC_PATTERNS, so middleware already gates it.
*/
export async function GET(request: NextRequest) {
const prefix = (await getSessionPrefix(request)) ?? "";
return NextResponse.json({
isAdmin: prefix === "",
prefix,
label: labelForPrefix(prefix),
});
}
27 changes: 24 additions & 3 deletions hindsight-control-plane/src/app/api/banks/route.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,34 @@
import { NextResponse } from "next/server";
import { NextRequest, NextResponse } from "next/server";
import { localizeApiErrorPayload } from "@/lib/i18n/api-errors";
import { sdk, lowLevelClient } from "@/lib/hindsight-client";
import { respondWithSdk } from "@/lib/sdk-response";
import { getSessionPrefix } from "@/lib/auth/session";
import { bankAllowed } from "@/lib/auth/tokens";
import { assertBankAllowed } from "@/lib/auth/bank-guard";

const HTTP_CREATED = 201;

export async function GET(request: Request) {
type BankListEntry = { bank_id?: string };
type BankListData = { banks?: BankListEntry[] };

export async function GET(request: NextRequest) {
const response = await sdk.listBanks({ client: lowLevelClient });

const prefix = await getSessionPrefix(request);
if (prefix && response.data) {
const data = response.data as BankListData;
if (Array.isArray(data.banks)) {
response.data = {
...data,
banks: data.banks.filter((bank) => bankAllowed(prefix, bank.bank_id ?? "")),
} as typeof response.data;
}
}

return respondWithSdk(response, "Failed to fetch banks", { request });
}

export async function POST(request: Request) {
export async function POST(request: NextRequest) {
let body;
try {
body = await request.json();
Expand All @@ -35,6 +53,9 @@ export async function POST(request: Request) {
);
}

const forbidden = await assertBankAllowed(request, bank_id);
if (forbidden) return forbidden;

const response = await sdk.createOrUpdateBank({
client: lowLevelClient,
path: { bank_id },
Expand Down
5 changes: 5 additions & 0 deletions hindsight-control-plane/src/app/api/extract/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";

import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client";
import { assertBankAllowed } from "@/lib/auth/bank-guard";

/**
* Proxy for the dataplane dry-run extraction endpoint: extract facts from text with a candidate
Expand All @@ -10,6 +11,10 @@ export async function POST(request: NextRequest) {
try {
const body = await request.json();
const bankId = body.bank_id || "default";

const forbidden = await assertBankAllowed(request, bankId);
if (forbidden) return forbidden;

const {
content,
retain_mission,
Expand Down
4 changes: 4 additions & 0 deletions hindsight-control-plane/src/app/api/files/retain/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { localizeApiErrorPayload } from "@/lib/i18n/api-errors";
import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client";
import { assertBankAllowed } from "@/lib/auth/bank-guard";

export async function POST(request: NextRequest) {
try {
Expand Down Expand Up @@ -44,6 +45,9 @@ export async function POST(request: NextRequest) {
);
}

const forbidden = await assertBankAllowed(request, bankId);
if (forbidden) return forbidden;

// Use the shared dataplane URL configuration
const url = dataplaneBankUrl(bankId, "/files/retain");

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { localizeApiErrorPayload } from "@/lib/i18n/api-errors";
import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client";
import { assertBankAllowed } from "@/lib/auth/bank-guard";

export async function GET(
request: NextRequest,
Expand Down Expand Up @@ -75,6 +76,9 @@ export async function PATCH(
);
}

const forbidden = await assertBankAllowed(request, bankId);
if (forbidden) return forbidden;

// Curation fields only; bank_id is a routing param, not part of the body.
const { text, context, occurred_start, occurred_end, fact_type, entities, state, reason } =
body;
Expand Down
4 changes: 4 additions & 0 deletions hindsight-control-plane/src/app/api/memories/retain/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { localizeApiErrorPayload } from "@/lib/i18n/api-errors";
import { hindsightClient } from "@/lib/hindsight-client";
import { assertBankAllowed } from "@/lib/auth/bank-guard";

export async function POST(request: NextRequest) {
try {
Expand All @@ -17,6 +18,9 @@ export async function POST(request: NextRequest) {
);
}

const forbidden = await assertBankAllowed(request, bankId);
if (forbidden) return forbidden;

const { items, document_id, document_tags, observation_scopes } = body;

// Map observation_scopes into each item if provided at request level
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { localizeApiErrorPayload } from "@/lib/i18n/api-errors";
import { sdk, lowLevelClient } from "@/lib/hindsight-client";
import { respondWithSdk } from "@/lib/sdk-response";
import { assertBankAllowed } from "@/lib/auth/bank-guard";

export async function POST(request: NextRequest) {
let body;
Expand All @@ -28,6 +29,9 @@ export async function POST(request: NextRequest) {
);
}

const forbidden = await assertBankAllowed(request, bankId);
if (forbidden) return forbidden;

const { items } = body;

const response = await sdk.retainMemories({
Expand Down
5 changes: 5 additions & 0 deletions hindsight-control-plane/src/app/api/recall/route.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { NextRequest, NextResponse } from "next/server";
import { localizeApiErrorPayload } from "@/lib/i18n/api-errors";
import { lowLevelClient, sdk } from "@/lib/hindsight-client";
import { assertBankAllowed } from "@/lib/auth/bank-guard";

export async function POST(request: NextRequest) {
try {
const body = await request.json();
const bankId = body.bank_id || body.agent_id || "default";

const forbidden = await assertBankAllowed(request, bankId);
if (forbidden) return forbidden;

const {
query,
types,
Expand Down
5 changes: 5 additions & 0 deletions hindsight-control-plane/src/app/api/reflect/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { localizeApiErrorPayload } from "@/lib/i18n/api-errors";
import { sdk, lowLevelClient } from "@/lib/hindsight-client";
import { respondWithSdk } from "@/lib/sdk-response";
import { assertBankAllowed } from "@/lib/auth/bank-guard";

export async function POST(request: NextRequest) {
let body;
Expand All @@ -17,6 +18,10 @@ export async function POST(request: NextRequest) {
);
}
const bankId = body.bank_id || body.agent_id || "default";

const forbidden = await assertBankAllowed(request, bankId);
if (forbidden) return forbidden;

const {
query,
budget,
Expand Down
Loading