Skip to content
Open
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
19 changes: 19 additions & 0 deletions apps/web/src/app/api/npc-chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,21 @@ function buildSystemPrompt(
? `Session key: ${sessionKey}\nThis is an opaque per-visitor identifier — use it verbatim as a stable naming key when you create artifacts for this visitor in external systems (e.g. document filenames a downstream NPC will search for). Never read it aloud to the visitor; internal plumbing only.`
: null;

// Advertise granted integrations by name so the model knows a concrete
// capability exists before deciding whether to call list_integrations —
// otherwise it tends to answer "I can't check your email" even when
// granted. Lists all granted slugs including owner_only ones; the tool
// layer (npc-tools.ts) is the actual gate, this is just a hint.
const grantedSlugs = (npc.permissions.integrations ?? []).map((g) =>
safeInline(g.slug, 40),
);
const integrationsBlock =
grantedSlugs.length === 0
? null
: `Connected tools: the resident has granted you access to their ${grantedSlugs.join(
", ",
)} account${grantedSlugs.length === 1 ? "" : "s"}. When the conversation calls for it, use list_integrations → list_integration_actions → execute_integration_action to act on them. Confirm with the speaker before performing writes (sending, creating, updating).`;

// Inject preloaded skill content as a labelled block so the model treats
// it as reference material, not voice. Each skill is sanitised by
// safeBlock to neutralise injected control markers.
Expand All @@ -299,6 +314,7 @@ function buildSystemPrompt(
"",
modeBlock,
...(sessionBlock ? ["", sessionBlock] : []),
...(integrationsBlock ? ["", integrationsBlock] : []),
...(skillsBlock ? ["", skillsBlock] : []),
].join("\n");
}
Expand Down Expand Up @@ -461,6 +477,9 @@ export async function POST(req: Request) {
npc.permissions,
callableSkills,
townCtx,
// Explicit: the legacy owner-only path has no townCtx but IS the
// owner — without this, owner_only grants would hide from them too.
viewer.isOwner,
);

// Visibility: log every chat startup with the tool surface the model
Expand Down
188 changes: 188 additions & 0 deletions apps/web/src/app/api/npcs/[id]/permissions/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
// /api/npcs/[id]/permissions — owner-only management of an NPC's
// capability grants.
//
// GET /api/npcs/<id>/permissions
// → { permissions, available } stored grants + the owner's
// connected CORE integrations (fetched with the owner's own
// token — this route is owner-only, so caller IS owner).
// GET ...?actions_for=<integration_account_id>
// → { actions } lazy per-integration action list, split out so
// opening the panel doesn't spawn N CORE tool lookups.
// PUT /api/npcs/<id>/permissions { permissions }
// → { ok, permissions } runs through normalizePermissions(),
// the same normaliser `town deploy` uses, so the UI and the
// CLI can't diverge on shape.
//
// Auth: session cookie or CORE PAT (lib/auth-bearer), then an explicit
// npc→town→ownerId check. Visitors get 403.

import { NextResponse } from "next/server";

import { resolveUser } from "@/lib/auth-bearer";
import { getCoreToken } from "@/lib/core-token";
import { prisma } from "@/lib/db";
import { normalizePermissions } from "@/lib/npc-templates";

const CORE_BASE = () => process.env.CORE_OAUTH_BASE;

/** Load the NPC and verify the caller owns its town. Returns null on
* any miss — callers map that to 404/403 without leaking which. */
async function resolveOwnedNpc(req: Request, npcId: string) {
const resolved = await resolveUser(req);
if (!resolved) return { error: 401 as const };
const npc = await prisma.npc.findUnique({
where: { id: npcId },
include: { town: { select: { id: true, slug: true, ownerId: true } } },
});
if (!npc) return { error: 404 as const };
if (npc.town.ownerId !== resolved.user.id) return { error: 403 as const };
return { npc, user: resolved.user };
}

export async function GET(
req: Request,
ctx: { params: Promise<{ id: string }> },
) {
const { id } = await ctx.params;
const owned = await resolveOwnedNpc(req, id);
if ("error" in owned) {
return NextResponse.json(
{
error:
owned.error === 401
? "unauthorized"
: owned.error === 403
? "forbidden"
: "not-found",
},
{ status: owned.error },
);
}

const base = CORE_BASE();
const token = await getCoreToken(req);
if (!base || !token) {
// Owner has no live CORE session (e.g. PAT-only login that expired).
// Still return the stored permissions so the panel renders read-only.
return NextResponse.json({
permissions: owned.npc.permissions ?? {},
available: [],
warning: "core-unavailable",
});
}

const url = new URL(req.url);
const actionsFor = url.searchParams.get("actions_for");

// Lazy action-list branch — proxies CORE's
// GET /api/v1/integration_account/:id/action (see core's
// integration-operations.ts). We proxy rather than letting the browser
// hit CORE directly because the browser never holds CORE tokens
// (AGENTS.md: only the opaque sid cookie).
if (actionsFor) {
const res = await fetch(
`${base}/api/v1/integration_account/${encodeURIComponent(actionsFor)}/action`,
{ headers: { authorization: `Bearer ${token}` } },
);
if (!res.ok) {
console.warn(`[npc-permissions] CORE ${res.status} listing actions`);
return NextResponse.json({ actions: [], warning: `core-${res.status}` });
}
const data = (await res.json()) as {
actions?: Array<{ name?: string; description?: string }>;
};
return NextResponse.json({
// Only name + description reach the browser — inputSchema is model
// material, not something the whitelist UI needs.
actions: (data.actions ?? [])
.filter((a) => typeof a.name === "string")
.map((a) => ({ name: a.name, description: a.description ?? "" })),
});
}

// Main branch: stored grants + the owner's connected integrations.
const res = await fetch(`${base}/api/v1/integration_account`, {
headers: { authorization: `Bearer ${token}` },
});
let available: Array<{
integration_account_id: string;
slug: string;
name: string;
}> = [];
if (res.ok) {
const data = (await res.json()) as {
accounts?: Array<{
id?: string;
integrationDefinition?: { slug?: string; name?: string };
}>;
};
available = (data.accounts ?? [])
.filter(
(a) =>
typeof a.id === "string" &&
typeof a.integrationDefinition?.slug === "string",
)
.map((a) => ({
integration_account_id: a.id!,
slug: a.integrationDefinition!.slug!,
name: a.integrationDefinition!.name ?? a.integrationDefinition!.slug!,
}));
} else {
console.warn(`[npc-permissions] CORE ${res.status} listing accounts`);
}

return NextResponse.json({
permissions: owned.npc.permissions ?? {},
available,
});
}

export async function PUT(
req: Request,
ctx: { params: Promise<{ id: string }> },
) {
const { id } = await ctx.params;
const owned = await resolveOwnedNpc(req, id);
if ("error" in owned) {
return NextResponse.json(
{
error:
owned.error === 401
? "unauthorized"
: owned.error === 403
? "forbidden"
: "not-found",
},
{ status: owned.error },
);
}

let body: { permissions?: unknown };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "bad-request" }, { status: 400 });
}

// normalizePermissions drops anything it doesn't recognise, so a
// malformed/hostile payload degrades to a NARROWER grant, never a
// wider one. Same failure posture as the MDX loader.
const permissions = normalizePermissions(body.permissions);

await prisma.npc.update({
where: { id: owned.npc.id },
data: { permissions: permissions as object },
});

console.log("[npc-permissions] updated", {
npcId: owned.npc.id,
townSlug: owned.npc.town.slug,
integrations: permissions.integrations?.map((g) => ({
slug: g.slug,
actions: g.actions?.length ?? "all",
owner_only: g.owner_only ?? false,
})),
});

return NextResponse.json({ ok: true, permissions });
}
2 changes: 2 additions & 0 deletions apps/web/src/features/group-chat/server/npc-reply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,8 @@ export async function generateAndPublishNpcReply(
]);
// townCtx = null → grant_tag / give_item won't register; every other
// permitted tool will. Awards stay 1-1-only by construction.
// speakerIsOwner stays at its default (false) — a mixed room hides
// owner_only grants even when the owner is present.
const tools = buildNpcTools(
ownerToken,
npc.permissions,
Expand Down
24 changes: 24 additions & 0 deletions apps/web/src/lib/aura.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,30 @@
import { prisma } from "./db";

export const AURA_GUEST_CREDIT = 10;
export const AURA_INTEGRATION_ACTION_COST = 10;

/** Debit aura by town slug (npc-tools only has the slug, not townId).
* Clamped at 0, same as the token-usage debit. Best-effort — callers
* fire-and-forget. Table names unqualified so the query resolves via
* the connection's search_path, matching the aura-regen worker. */
export async function debitAuraBySlug(
townSlug: string,
amount: number,
): Promise<void> {
if (amount <= 0) return;
try {
await prisma.$executeRaw`
UPDATE "Aura" a
SET current = GREATEST(a.current - ${amount}, 0),
"updatedAt" = NOW()
FROM "Town" t
WHERE t.slug = ${townSlug}
AND a."townId" = t.id
`;
} catch (e) {
console.warn("[aura] integration-action debit failed", e);
}
}

/** Credit AURA_GUEST_CREDIT aura the first time this visitor lands on
* this town. Idempotent: once a TownActivity `visit` row exists for
Expand Down
9 changes: 8 additions & 1 deletion apps/web/src/lib/npc-templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ export interface NpcPermissions {
integrations?: Array<{
slug: string;
actions?: string[];
/** When true, tools for this integration only register while the
* OWNER is speaking — guards write-capable integrations (gmail
* send, github create) since NPCs run on the owner's CORE token. */
owner_only?: boolean;
}>;
core?: {
tasks?: Array<"read" | "write">;
Expand Down Expand Up @@ -212,10 +216,13 @@ export function normalizePermissions(raw: unknown): NpcPermissions {
if (!entry || typeof entry !== "object") continue;
const e = entry as Record<string, unknown>;
if (typeof e.slug !== "string") continue;
const item: { slug: string; actions?: string[] } = { slug: e.slug };
const item: { slug: string; actions?: string[]; owner_only?: boolean } = {
slug: e.slug,
};
if (Array.isArray(e.actions)) {
item.actions = e.actions.filter((a): a is string => typeof a === "string");
}
if (typeof e.owner_only === "boolean") item.owner_only = e.owner_only;
list.push(item);
}
out.integrations = list;
Expand Down
Loading