diff --git a/apps/web/src/app/api/npc-chat/route.ts b/apps/web/src/app/api/npc-chat/route.ts index 466f36e..69fdcb2 100644 --- a/apps/web/src/app/api/npc-chat/route.ts +++ b/apps/web/src/app/api/npc-chat/route.ts @@ -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. @@ -299,6 +314,7 @@ function buildSystemPrompt( "", modeBlock, ...(sessionBlock ? ["", sessionBlock] : []), + ...(integrationsBlock ? ["", integrationsBlock] : []), ...(skillsBlock ? ["", skillsBlock] : []), ].join("\n"); } @@ -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 diff --git a/apps/web/src/app/api/npcs/[id]/permissions/route.ts b/apps/web/src/app/api/npcs/[id]/permissions/route.ts new file mode 100644 index 0000000..2ce1ef2 --- /dev/null +++ b/apps/web/src/app/api/npcs/[id]/permissions/route.ts @@ -0,0 +1,188 @@ +// /api/npcs/[id]/permissions — owner-only management of an NPC's +// capability grants. +// +// GET /api/npcs//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= +// → { actions } lazy per-integration action list, split out so +// opening the panel doesn't spawn N CORE tool lookups. +// PUT /api/npcs//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 }); +} diff --git a/apps/web/src/features/group-chat/server/npc-reply.ts b/apps/web/src/features/group-chat/server/npc-reply.ts index 2a519ad..892e66d 100644 --- a/apps/web/src/features/group-chat/server/npc-reply.ts +++ b/apps/web/src/features/group-chat/server/npc-reply.ts @@ -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, diff --git a/apps/web/src/lib/aura.ts b/apps/web/src/lib/aura.ts index 18291d0..1fe0cf7 100644 --- a/apps/web/src/lib/aura.ts +++ b/apps/web/src/lib/aura.ts @@ -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 { + 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 diff --git a/apps/web/src/lib/npc-templates.ts b/apps/web/src/lib/npc-templates.ts index b47507c..73b5f6b 100644 --- a/apps/web/src/lib/npc-templates.ts +++ b/apps/web/src/lib/npc-templates.ts @@ -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">; @@ -212,10 +216,13 @@ export function normalizePermissions(raw: unknown): NpcPermissions { if (!entry || typeof entry !== "object") continue; const e = entry as Record; 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; diff --git a/apps/web/src/lib/npc-tools.ts b/apps/web/src/lib/npc-tools.ts index 216e39c..d0ae50b 100644 --- a/apps/web/src/lib/npc-tools.ts +++ b/apps/web/src/lib/npc-tools.ts @@ -18,6 +18,7 @@ import { z } from "zod"; import type { TownCatalog } from "@town/types"; +import { AURA_INTEGRATION_ACTION_COST, debitAuraBySlug } from "./aura"; import { prisma } from "./db"; import type { NpcPermissions } from "./npc-templates"; import { recordTownActivity } from "./town-activity"; @@ -132,22 +133,25 @@ class IntegrationResolver { } } +// Helpers take the EFFECTIVE grant list (owner_only entries already +// filtered by speaker), not the raw permissions blob — one shared gate. +type IntegrationGrantEntry = NonNullable[number]; + function integrationGrant( - perms: NpcPermissions, + grants: IntegrationGrantEntry[], slug: string, ): { allowed: boolean; actions?: string[] } { - const list = perms.integrations ?? []; - const entry = list.find((g) => g.slug === slug); + const entry = grants.find((g) => g.slug === slug); if (!entry) return { allowed: false }; return { allowed: true, actions: entry.actions }; } function isActionAllowed( - perms: NpcPermissions, + grants: IntegrationGrantEntry[], slug: string, action: string, ): boolean { - const grant = integrationGrant(perms, slug); + const grant = integrationGrant(grants, slug); if (!grant.allowed) return false; // No `actions` filter → level-1 grant (full integration). if (!grant.actions) return true; @@ -218,6 +222,10 @@ export function buildNpcTools( permissions: NpcPermissions, callableSkills: CallableSkillMeta[] = [], townCtx: TownContext | null = null, + // Whether the SPEAKER is the town owner — separate from townCtx since + // both the legacy owner-only path and group chat pass townCtx=null but + // differ on this. Gates owner_only integration grants below. + speakerIsOwner: boolean = townCtx?.isOwner ?? false, ): Record { const ctxOrErr = makeContext(ownerToken); const tools: Record = {}; @@ -311,11 +319,16 @@ export function buildNpcTools( } // ── Integrations (list/list-actions/execute) ────────────────────────── - const hasAnyIntegrationGrant = (permissions.integrations ?? []).length > 0; + // + // owner_only grants are stripped from the effective list unless the + // speaker is the owner — filtered here (build time) so the tools don't + // even register for a visitor, same as any other ungranted tool. + const effectiveGrants = (permissions.integrations ?? []).filter( + (g) => !g.owner_only || speakerIsOwner, + ); + const hasAnyIntegrationGrant = effectiveGrants.length > 0; if (hasAnyIntegrationGrant && !("error" in ctxOrErr)) { - const grantedSlugs = new Set( - (permissions.integrations ?? []).map((g) => g.slug), - ); + const grantedSlugs = new Set(effectiveGrants.map((g) => g.slug)); const resolver = new IntegrationResolver(ctxOrErr); tools.list_integrations = tool({ @@ -363,7 +376,7 @@ export function buildNpcTools( }; if ("error" in res && res.error) return res; const actions = Array.isArray(res.actions) ? res.actions : []; - const grant = integrationGrant(permissions, slug); + const grant = integrationGrant(effectiveGrants, slug); // Level-1 grant: return everything. Level-2: filter to whitelist. const filtered = grant.actions ? actions.filter( @@ -391,17 +404,47 @@ export function buildNpcTools( if (!slug || !grantedSlugs.has(slug)) { return { error: "integration-not-permitted" }; } - if (!isActionAllowed(permissions, slug, action)) { + if (!isActionAllowed(effectiveGrants, slug, action)) { return { error: "action-not-permitted", action, slug }; } - return await coreFetch( + // Attribution for CORE's IntegrationCallLog.source. No townCtx + // (group chat / legacy) falls back to a generic "town". + const source = townCtx?.npcId ? `town:npc:${townCtx.npcId}` : "town"; + const result = await coreFetch( ctxOrErr, `/api/v1/integration_account/${encodeURIComponent(integration_account_id)}/action`, { method: "POST", - body: JSON.stringify({ action, parameters }), + body: JSON.stringify({ action, parameters, source }), }, ); + // Fire-and-forget audit row + aura debit — never block the reply + // on either. Discovery tools stay free; only execution costs aura. + if (townCtx) { + const ok = !( + result && + typeof result === "object" && + "error" in (result as Record) + ); + void recordTownActivity({ + townSlug: townCtx.townSlug, + kind: "integration_action", + subjectKey: townCtx.subjectKey, + subjectName: townCtx.subjectName, + subjectCharacter: townCtx.subjectCharacter, + metadata: { + npcId: townCtx.npcId, + npcName: townCtx.npcName, + slug, + action, + ok, + }, + }).catch((e) => + console.warn("[town-activity] integration_action failed", e), + ); + void debitAuraBySlug(townCtx.townSlug, AURA_INTEGRATION_ACTION_COST); + } + return result; }, }); } diff --git a/apps/web/src/lib/town-activity.ts b/apps/web/src/lib/town-activity.ts index 1b09014..a4bc75f 100644 --- a/apps/web/src/lib/town-activity.ts +++ b/apps/web/src/lib/town-activity.ts @@ -25,7 +25,10 @@ export type TownActivityKind = | "npc_chat" | "tag_awarded" | "item_awarded" - | "group_chat_started"; + | "group_chat_started" + // One row per execute_integration_action call, never deduped. + // metadata: { npcId, npcName, slug, action, ok } + | "integration_action"; const DEDUPE_WINDOW_MS = 60 * 60 * 1000; // 1 hour diff --git a/apps/web/src/ui/NpcAccess.tsx b/apps/web/src/ui/NpcAccess.tsx new file mode 100644 index 0000000..cd8a5ef --- /dev/null +++ b/apps/web/src/ui/NpcAccess.tsx @@ -0,0 +1,379 @@ +"use client"; + +// NPC Access panel — owner-only editor for which CORE integrations each +// NPC may use during chat. Renders as a HudButton ("ACCESS") in +// TownGame's top-right owner row, opening a right-side drawer styled +// after Suggestions.tsx. Owns its own open/close state rather than +// going through ui/store.ts — nothing in the game engine needs it. +// +// Talks only to Town routes (browser never holds CORE tokens): +// GET /api/npcs?town= for the roster, GET/PUT +// /api/npcs//permissions for grants (permissions normalised +// server-side), and ...?actions_for= to lazy-load one +// integration's action list. Grant shape mirrors NpcPermissions +// (lib/npc-templates.ts): absent = no access, {slug} = all actions, +// {slug, actions} = whitelist, owner_only = visitor-invisible. + +import { useCallback, useEffect, useState } from "react"; + +import { HudButton } from "./HudButton"; + +interface NpcRow { + id: string; + name: string; + description: string; +} + +interface AvailableIntegration { + integration_account_id: string; + slug: string; + name: string; +} + +interface IntegrationGrant { + slug: string; + actions?: string[]; + owner_only?: boolean; +} + +// Local editable copy of the full permissions blob. Non-integration keys +// (core / skills / town) are carried through untouched on save so this +// panel can't accidentally strip memory_search or award grants. +type PermissionsBlob = Record & { + integrations?: IntegrationGrant[]; +}; + +interface ActionInfo { + name: string; + description: string; +} + +export function NpcAccess({ townSlug }: { townSlug: string }) { + const [open, setOpen] = useState(false); + const [npcs, setNpcs] = useState([]); + const [selected, setSelected] = useState(null); + const [perms, setPerms] = useState(null); + const [available, setAvailable] = useState([]); + const [warning, setWarning] = useState(null); + const [actionsCache, setActionsCache] = useState< + Record + >({}); + const [expanded, setExpanded] = useState(null); + const [busy, setBusy] = useState(false); + const [saved, setSaved] = useState(false); + + // Close on Escape — same affordance as Suggestions. + useEffect(() => { + if (!open) return; + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") setOpen(false); + } + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [open]); + + // Roster loads once per open; stale-on-reopen is fine (rosters change + // via deploy/suggestions, not mid-session). + useEffect(() => { + if (!open) return; + void fetch(`/api/npcs?town=${encodeURIComponent(townSlug)}`) + .then((r) => r.json()) + .then((d: { npcs?: NpcRow[] }) => setNpcs(d.npcs ?? [])) + .catch(() => setNpcs([])); + }, [open, townSlug]); + + const loadNpc = useCallback((id: string) => { + setSelected(id); + setPerms(null); + setExpanded(null); + setSaved(false); + setWarning(null); + void fetch(`/api/npcs/${encodeURIComponent(id)}/permissions`) + .then((r) => r.json()) + .then( + (d: { + permissions?: PermissionsBlob; + available?: AvailableIntegration[]; + warning?: string; + }) => { + setPerms(d.permissions ?? {}); + setAvailable(d.available ?? []); + if (d.warning) setWarning(d.warning); + }, + ) + .catch(() => setWarning("load-failed")); + }, []); + + const grantFor = (slug: string): IntegrationGrant | undefined => + perms?.integrations?.find((g) => g.slug === slug); + + const mutateGrants = ( + fn: (list: IntegrationGrant[]) => IntegrationGrant[], + ) => { + setPerms((p) => (p ? { ...p, integrations: fn(p.integrations ?? []) } : p)); + setSaved(false); + }; + + const toggleIntegration = (slug: string) => { + mutateGrants((list) => + list.some((g) => g.slug === slug) + ? list.filter((g) => g.slug !== slug) + : // New grants default to owner_only — the safe posture for + // write-capable integrations. The owner opts INTO visitor + // access per integration, not out of it. + [...list, { slug, owner_only: true }], + ); + }; + + const toggleOwnerOnly = (slug: string) => { + mutateGrants((list) => + list.map((g) => + g.slug === slug ? { ...g, owner_only: !g.owner_only } : g, + ), + ); + }; + + const toggleAction = (slug: string, action: string, all: ActionInfo[]) => { + mutateGrants((list) => + list.map((g) => { + if (g.slug !== slug) return g; + // `actions` undefined = level-1 "all actions". First uncheck + // materialises the full list minus the toggled one; re-checking + // everything collapses back to undefined (level 1). + const current = g.actions ?? all.map((a) => a.name); + const next = current.includes(action) + ? current.filter((a) => a !== action) + : [...current, action]; + const isAll = all.every((a) => next.includes(a.name)); + const { actions: _drop, ...rest } = g; + return isAll ? rest : { ...rest, actions: next }; + }), + ); + }; + + const expandActions = (accountId: string) => { + setExpanded((e) => (e === accountId ? null : accountId)); + if (actionsCache[accountId] || !selected) return; + void fetch( + `/api/npcs/${encodeURIComponent(selected)}/permissions?actions_for=${encodeURIComponent(accountId)}`, + ) + .then((r) => r.json()) + .then((d: { actions?: ActionInfo[] }) => + setActionsCache((c) => ({ ...c, [accountId]: d.actions ?? [] })), + ) + .catch(() => setActionsCache((c) => ({ ...c, [accountId]: [] }))); + }; + + const save = async () => { + if (!selected || !perms) return; + setBusy(true); + try { + const res = await fetch( + `/api/npcs/${encodeURIComponent(selected)}/permissions`, + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ permissions: perms }), + }, + ); + if (res.ok) { + const d = (await res.json()) as { permissions?: PermissionsBlob }; + // Adopt the server-normalised blob so the panel shows exactly + // what will gate the next chat (dropped keys disappear here). + if (d.permissions) setPerms(d.permissions); + setSaved(true); + } else { + setWarning(`save-${res.status}`); + } + } finally { + setBusy(false); + } + }; + + return ( + <> + setOpen(true)} title="NPC tool access"> + ACCESS + + + {open ? ( + <> + {/* Click-outside scrim — same idea as Suggestions, but `fixed` + instead of `absolute`: this component mounts inside the + top-right HUD row (an absolutely-positioned flex strip), so + `absolute inset-0` would resolve against that tiny row and + clip the drawer to a sliver. Panel.tsx sets the precedent + for fixed overlays. */} +
setOpen(false)} + /> + + + ) : null} + + ); +} diff --git a/apps/web/src/ui/TownGame.tsx b/apps/web/src/ui/TownGame.tsx index 523b964..ac9f1ad 100644 --- a/apps/web/src/ui/TownGame.tsx +++ b/apps/web/src/ui/TownGame.tsx @@ -55,6 +55,7 @@ import { TransitionLoading } from "./TransitionLoading"; import { CommandBar } from "./CommandBar"; import { tinykeys } from "tinykeys"; import { installCanvasFocusPolicy } from "../game/canvasFocus"; +import { NpcAccess } from "./NpcAccess"; // The mount point: a canvas owned by React, populated by kaplay in useEffect, // and a sibling overlay layer for the React-rendered UI (HUD, prompt, panels). @@ -416,6 +417,7 @@ export function TownGame(props: TownGameProps = {}) { ) : null} {!isVisitor ? : null} + {!isVisitor && ownerSlug ? : null}
{prompt ? (