diff --git a/apps/docs/content/author-an-app.mdx b/apps/docs/content/author-an-app.mdx index d9d56ff7e1..a069ecc55d 100644 --- a/apps/docs/content/author-an-app.mdx +++ b/apps/docs/content/author-an-app.mdx @@ -140,6 +140,7 @@ const requirements = { async (ctx: MutationContext) => { ctx.app.accounts.datadog; // linked for everyone by people who can edit the app + ctx.user.id; // the host's opaque caller identity, independent of linked accounts ctx.user.accounts.github; // the caller's own linked account }; ``` @@ -164,6 +165,26 @@ running keep their members' schedules. Deploying a schedule as `appCron` or history and pending approvals, and the app schedule starts paused until an app manager enables it. +### Caller identity + +`ctx.user.id` identifies the caller in the host product. It is an opaque value +that stays the same across that person's calls, including calls to apps with +no account requirements. `ctx.user.accounts` still holds the caller's linked +accounts declared under `user: { accounts }`. The caller ID is independent of +those accounts and of the accounts linked for everyone under `app`. + +Use the ID to attribute app records or enforce per-user limits. It is not an +account ID or an app owner, and grants no permissions. The host supplies it +after authentication; tool input cannot choose another caller. + +In a factory, use `ctx.user?.id`: app-scoped background work evaluates the +factory without `ctx.user`. App-scoped handlers and workflow steps also have +no `ctx.user`. A host without user identities may leave `ctx.user.id` undefined +even in user scope; require an ID before relying on per-user attribution. + +The app database and `ctx.cache` are shared by callers. Include the caller ID +in cache keys and filter stored rows when their contents belong to one caller. + ## Queries and mutations Both become tools. The difference is what they may do to the app's own stored diff --git a/bun.lock b/bun.lock index bde5a29cbf..f394af4143 100644 --- a/bun.lock +++ b/bun.lock @@ -486,7 +486,7 @@ }, "packages/apps": { "name": "apps", - "version": "0.0.1-beta.109", + "version": "0.0.1-beta.116", "dependencies": { "@cfworker/json-schema": "4.1.1", "@csstools/css-tokenizer": "4.0.2", diff --git a/e2e/test-plan.ts b/e2e/test-plan.ts index f8af8fc3df..d01bb2c9e8 100644 --- a/e2e/test-plan.ts +++ b/e2e/test-plan.ts @@ -8083,6 +8083,25 @@ export const scenarios = plan({ local: na("This scenario uses hosted app management routes."), }, }, + appCallerIdentity: { + fixtures: "actors", + file: "app-caller-identity.spec.ts", + title: "shared app handlers and cached dynamic tools retain each caller's stable identity", + targets: { + "self-host": scheduled, + cloud: scheduled, + local: na("Local has no hosted organization members to share an app with."), + }, + }, + localAppCallerIdentity: { + file: "local-app-caller-identity.spec.ts", + title: "Local app handlers receive the stable caller independently of accounts and tool input", + targets: { + local: scheduled, + "self-host": na("Hosted caller identity has a separate shared-app scenario."), + cloud: na("Hosted caller identity has a separate shared-app scenario."), + }, + }, workflowStarts: { fixtures: "actors", file: "workflows.spec.ts", diff --git a/e2e/tests/app-caller-identity.spec.ts b/e2e/tests/app-caller-identity.spec.ts new file mode 100644 index 0000000000..f450dae9d8 --- /dev/null +++ b/e2e/tests/app-caller-identity.spec.ts @@ -0,0 +1,291 @@ +/** A shared app's handlers and cached dynamic declarations stay bound to the calling user. */ +import { expect, layer } from "@effect/vitest"; +import { Clock, Effect, Schema } from "effect"; +import { appLinksFixture } from "../support/app-links.ts"; +import { Api, body, type Session } from "../support/api.ts"; +import { HostedLive, withHostedCase } from "../support/case.ts"; +import { scenarios } from "../test-plan.ts"; +import { McpClient } from "../support/mcp-client.ts"; +import { Resource } from "../support/contracts.ts"; +import { WorkflowRun } from "../support/workflow-app.ts"; +import { Browser } from "../support/browser.ts"; + +const source = `import { + defineApp, dynamicRouter, mutation, object, query, router, string, workflow +} from "apps"; +import { createAppHandler, hostContext } from "apps/host"; +const portable = createAppHandler(defineApp({}, { + tools: router({ who: query({ input: object({}) }, async (ctx) => ({ + hasUser: "user" in ctx, id: ctx.user?.id ?? null, + })) }), +})); +const direct = query({ input: object({}) }, async (ctx) => { + const invoke = async (accounts, caller) => { + const response = await portable(new Request("https://app.invalid", { + method: "POST", body: JSON.stringify({ operation: "call", tool: "who", input: {} }), + }), hostContext(accounts, undefined, caller)); + return { status: response.status, body: await response.json() }; + }; + return { + caller: await invoke({ app: {} }, ctx.user.id), + app: await invoke({ app: {} }, undefined), + subjectless: await invoke({ app: {}, user: {} }, undefined), + }; +}); +const who = query({ input: object({}) }, async (ctx) => ctx.user.id ?? "missing"); +const spoof = mutation({ input: object({ userId: string() }) }, async (ctx, input) => ({ + callerId: ctx.user.id ?? "missing", + suppliedId: input.userId, +})); +const record = workflow({ input: object({}) }, async (ctx) => + ctx.step.do("caller", async (step) => step.user.id ?? "missing")); +const launch = mutation({ input: object({}) }, async (ctx) => + ctx.workflows.start({ workflow: "record", input: {} })); +export default defineApp({}, async (ctx) => { + const id = ctx.user?.id ?? "missing"; + const name = "caller_" + id; + const fromFactory = query({ input: object({}) }, async () => id); + const generated = dynamicRouter({ + list: async () => [{ + name, + description: "Caller " + id, + inputSchema: { type: "object", properties: {} }, + readOnly: true, + }], + resolve: async (requested) => requested === name ? fromFactory : undefined, + }); + return { tools: router({ who, direct, spoof, generated, launch }), workflows: { record } }; +});`; + +const SessionIdentity = Schema.NullOr( + Schema.Struct({ user: Schema.Struct({ id: Schema.String }) }), +); +const ToolIndex = Schema.Struct({ items: Schema.Array(Schema.Struct({ name: Schema.String })) }); +const ToolList = Schema.Struct({ + items: Schema.Array( + Schema.Struct({ name: Schema.String, description: Schema.optionalKey(Schema.String) }), + ), +}); +const Tool = Schema.Struct({ + name: Schema.String, + description: Schema.optionalKey(Schema.String), +}); +const SpoofResult = Schema.Struct({ callerId: Schema.String, suppliedId: Schema.String }); +const Token = Schema.Struct({ id: Schema.String, key: Schema.RedactedFromValue(Schema.String) }); +const Completed = Schema.Struct({ + status: Schema.Literal("completed"), + execution: Schema.Struct({ ok: Schema.Literal(true), value: Schema.String }), +}); + +layer(HostedLive, { excludeTestServices: true })("App caller identity", (it) => { + it.effect(scenarios.appCallerIdentity.title, (context) => + withHostedCase( + context, + Effect.gen(function* () { + const api = yield* Api, + mcp = yield* McpClient, + browser = yield* Browser; + const fixture = yield* appLinksFixture([{ path: "index.ts", content: source }]); + const { actors, path } = fixture; + + const userId = (actor: Session) => + Effect.gen(function* () { + const response = yield* api.request(actor, "GET", "/api/auth/get-session"); + expect(response.status).toBe(200); + const session = yield* body(SessionIdentity, response); + if (session === null) throw new Error("The authenticated session has no user."); + return session.user.id; + }); + const [ownerId, memberId] = yield* Effect.all( + [userId(actors.owner), userId(actors.member)], + { concurrency: 2 }, + ); + expect(ownerId).not.toBe(memberId); + + const generatedName = (id: string) => `generated.caller_${id}`; + const index = (actor: Session) => + api.request(actor, "GET", `${path}/tools/index`).pipe( + Effect.tap((response) => Effect.sync(() => expect(response.status).toBe(200))), + Effect.flatMap((response) => body(ToolIndex, response)), + ); + const list = (actor: Session) => + api.request(actor, "GET", `${path}/tools`).pipe( + Effect.tap((response) => Effect.sync(() => expect(response.status).toBe(200))), + Effect.flatMap((response) => body(ToolList, response)), + ); + const get = (actor: Session, name: string) => + api.request(actor, "GET", `${path}/tools/${encodeURIComponent(name)}`).pipe( + Effect.tap((response) => Effect.sync(() => expect(response.status).toBe(200))), + Effect.flatMap((response) => body(Tool, response)), + ); + const call = (actor: Session, tool: string, kind: "query" | "mutation", input: object) => + api.request(actor, "POST", `${path}/tools/call`, { tool, kind, input }); + + // Both users have the same empty account set when these routes read the declaration cache. + const ownerIndex = yield* index(actors.owner); + expect(ownerIndex.items.map((tool) => tool.name)).toContain(generatedName(ownerId)); + const memberIndex = yield* index(actors.member); + expect(memberIndex.items.map((tool) => tool.name)).toContain(generatedName(memberId)); + expect(memberIndex.items.map((tool) => tool.name)).not.toContain(generatedName(ownerId)); + + const ownerList = yield* list(actors.owner); + const ownerListedTool = ownerList.items.find( + (tool) => tool.name === generatedName(ownerId), + ); + expect(ownerListedTool?.description).toBe(`Caller ${ownerId}`); + const memberList = yield* list(actors.member); + const memberListedTool = memberList.items.find( + (tool) => tool.name === generatedName(memberId), + ); + expect(memberListedTool?.description).toBe(`Caller ${memberId}`); + expect(memberList.items.some((tool) => tool.name === generatedName(ownerId))).toBe(false); + + expect(yield* get(actors.owner, generatedName(ownerId))).toMatchObject({ + name: generatedName(ownerId), + description: `Caller ${ownerId}`, + }); + expect(yield* get(actors.member, generatedName(memberId))).toMatchObject({ + name: generatedName(memberId), + description: `Caller ${memberId}`, + }); + + const query = (actor: Session) => + Effect.gen(function* () { + const response = yield* call(actor, "who", "query", {}); + expect(response.status).toBe(200); + return yield* body(Schema.String, response); + }); + expect(yield* query(actors.owner)).toBe(ownerId); + expect(yield* query(actors.member)).toBe(memberId); + expect(yield* query(actors.owner)).toBe(ownerId); + + // Portable hosts can supply a caller without any user-account requirements. + const direct = yield* call(actors.member, "direct", "query", {}); + expect(direct.status).toBe(200); + expect(direct.body).toEqual({ + caller: { status: 200, body: { ok: true, value: { hasUser: true, id: memberId } } }, + app: { status: 200, body: { ok: true, value: { hasUser: false, id: null } } }, + subjectless: { status: 200, body: { ok: true, value: { hasUser: true, id: null } } }, + }); + + // Subscription queries execute lazily, after the HTTP handler's auth scope has ended. + const subscribe = (actor: Session) => + Effect.gen(function* () { + yield* browser.login(actor); + yield* browser.use("Open the authenticated app dashboard", (page) => + page.goto(`/org/${actors.organization.slug}/apps/${fixture.app.id}`), + ); + const event = yield* browser.use( + "Read the caller's first subscription snapshot", + (page) => + page.evaluate( + (path) => + fetch(`${path}/data/subscribe`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "who", input: {} }), + }).then((response) => { + if (!response.ok || response.body === null) + throw new Error(`Subscription HTTP ${response.status}`); + const reader = response.body.getReader(), + decoder = new TextDecoder(); + let text = ""; + const next = (): Promise => + reader.read().then((chunk) => { + if (chunk.done) throw new Error("Subscription ended without a snapshot"); + text += decoder.decode(chunk.value, { stream: true }); + const lines = text.split("\n"); + const data = lines.slice(0, -1).find((line) => line.startsWith("data: ")); + return data === undefined ? next() : data.slice(6); + }); + return next().finally(() => reader.cancel()); + }), + path, + ), + ); + return yield* Schema.decodeUnknownEffect( + Schema.fromJsonString( + Schema.Struct({ + value: Schema.String, + revision: Schema.Number, + }), + ), + )(event); + }); + expect((yield* subscribe(actors.owner)).value).toBe(ownerId); + expect((yield* subscribe(actors.member)).value).toBe(memberId); + + const ownerSpoof = yield* call(actors.owner, "spoof", "mutation", { userId: memberId }); + expect(ownerSpoof.status).toBe(200); + expect(yield* body(SpoofResult, ownerSpoof)).toEqual({ + callerId: ownerId, + suppliedId: memberId, + }); + const memberSpoof = yield* call(actors.member, "spoof", "mutation", { userId: ownerId }); + expect(memberSpoof.status).toBe(200); + expect(yield* body(SpoofResult, memberSpoof)).toEqual({ + callerId: memberId, + suppliedId: ownerId, + }); + + const ownerFactoryCall = yield* call(actors.owner, generatedName(ownerId), "query", {}); + expect(ownerFactoryCall.status).toBe(200); + expect(yield* body(Schema.String, ownerFactoryCall)).toBe(ownerId); + const memberFactoryCall = yield* call(actors.member, generatedName(memberId), "query", {}); + expect(memberFactoryCall.status).toBe(200); + expect(yield* body(Schema.String, memberFactoryCall)).toBe(memberId); + + // Steps refresh accounts while keeping the member that started the run. + const workflowCaller = (actor: Session) => + Effect.gen(function* () { + const launched = yield* call(actor, "launch", "mutation", {}); + expect(launched.status).toBe(200); + const run = yield* body(Resource, launched); + const deadline = (yield* Clock.currentTimeMillis) + 15000; + for (;;) { + const response = yield* api.request(actor, "GET", `${path}/workflow-runs/${run.id}`); + expect(response.status).toBe(200); + const current = yield* body(WorkflowRun, response); + if (current.status === "complete") return current.output; + expect(current.status, JSON.stringify(current)).not.toBe("errored"); + expect(yield* Clock.currentTimeMillis).toBeLessThan(deadline); + yield* Effect.sleep("100 millis"); + } + }); + expect(yield* workflowCaller(actors.owner)).toBe(ownerId); + expect(yield* workflowCaller(actors.member)).toBe(memberId); + + const issued = yield* api.request(actors.member, "POST", "/api/auth/api-key/create", { + name: "Caller identity fixture", + }); + expect(issued.status).toBe(200); + const token = yield* body(Token, issued); + yield* Effect.addFinalizer(() => + api + .request(actors.member, "POST", "/api/auth/api-key/delete", { + keyId: token.id, + }) + .pipe(Effect.asVoid, Effect.orDie), + ); + const client = yield* mcp.connect(token.key, "caller identity", { + organization: actors.organization.id, + }); + const result = yield* client.use("MCP caller identity", (client, signal) => + client.callTool( + { + name: "execute", + arguments: { + code: `return await tools[${JSON.stringify(fixture.app.slug)}].who({});`, + }, + }, + undefined, + { signal }, + ), + ); + expect( + (yield* Schema.decodeUnknownEffect(Completed)(result.structuredContent)).execution.value, + ).toBe(memberId); + }).pipe(Effect.provide(McpClient.layer)), + ), + ); +}); diff --git a/e2e/tests/app-package.spec.ts b/e2e/tests/app-package.spec.ts index b4f2ab14da..06dfc1122c 100644 --- a/e2e/tests/app-package.spec.ts +++ b/e2e/tests/app-package.spec.ts @@ -147,7 +147,7 @@ layer(HostedLive, { excludeTestServices: true })("Packaged apps", (it) => { expect(rejected.body).toMatchObject({ _tag: "VersionBuildFailed", reason: - "This app's apps framework uses host protocol 99. This host supports protocol 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13. Declare a supported apps version.", + "This app's apps framework uses host protocol 99. This host supports protocol 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14. Declare a supported apps version.", }); expect((yield* call("queries.version")).body).toEqual(Published); expect( diff --git a/e2e/tests/local-app-caller-identity.spec.ts b/e2e/tests/local-app-caller-identity.spec.ts new file mode 100644 index 0000000000..baa5fb269a --- /dev/null +++ b/e2e/tests/local-app-caller-identity.spec.ts @@ -0,0 +1,64 @@ +/** Local calls expose the product's one caller even without linked accounts. */ +import { expect, layer } from "@effect/vitest"; +import { Effect, Redacted, Schema } from "effect"; +import { Api, body } from "../support/api.ts"; +import { TestLive, withCase } from "../support/case.ts"; +import { Target } from "../support/platform.ts"; +import { appsManifest } from "../support/apps-release.ts"; +import { scenarios } from "../test-plan.ts"; + +const source = `import { defineApp, mutation, object, query, router, string } from "apps"; +const who = query({ input: object({}) }, async (ctx) => ctx.user.id ?? "missing"); +const spoof = mutation({ input: object({ caller: string() }) }, async (ctx) => ctx.user.id ?? "missing"); +export default defineApp({}, { tools: router({ who, spoof }) });`; + +layer(TestLive, { excludeTestServices: true })("Local app caller identity", (it) => { + it.effect(scenarios.localAppCallerIdentity.title, (context) => + withCase( + context, + Effect.gen(function* () { + const api = yield* Api, + target = yield* Target, + session = yield* api.session(); + const headers = { authorization: `Bearer ${Redacted.value(target.apiKey)}` }; + const deployed = yield* session.send( + "POST", + "/v1/apps/deploy", + { + owner: "local", + name: "Caller identity", + files: [{ path: "index.ts", content: source }, appsManifest], + }, + headers, + ); + expect(deployed.status).toBe(200); + const { app } = yield* body( + Schema.Struct({ app: Schema.Struct({ id: Schema.String }) }), + deployed, + ); + yield* Effect.addFinalizer(() => + session + .send("DELETE", `/v1/apps/${app.id}`, undefined, headers) + .pipe(Effect.asVoid, Effect.orDie), + ); + const call = (tool: string, kind: "query" | "mutation", input: Schema.Json) => + session.send("POST", "/v1/tools/call", { app: app.id, tool, kind, input }, headers).pipe( + Effect.tap((response) => Effect.sync(() => expect(response.status).toBe(200))), + Effect.flatMap((response) => + body( + Schema.Struct({ + status: Schema.Literal("completed"), + value: Schema.String, + }), + response, + ), + ), + Effect.map((result) => result.value), + ); + expect(yield* call("who", "query", {})).toBe("local"); + expect(yield* call("spoof", "mutation", { caller: "synthetic-other" })).toBe("local"); + expect(yield* call("who", "query", {})).toBe("local"); + }), + ), + ); +}); diff --git a/packages/apps/README.md b/packages/apps/README.md index 779c2247f7..b398f7f812 100644 --- a/packages/apps/README.md +++ b/packages/apps/README.md @@ -71,6 +71,18 @@ Accounts bind at execution time. Live documents cannot change credential placement or send credentials to another origin. Editing generated source and redeploying is required to change those static choices. +`ctx.user.id` is the caller's opaque identity in the host product. It stays the +same across that person's calls, including calls to apps with no account +requirements. Use it to attribute app records or enforce per-user limits. It +is not an account ID or an app owner, and grants no permissions. The host +supplies it after authentication; tool arguments cannot select another caller. +Hosts without subjects leave it `undefined`. User schedules, user-account +webhooks and workflow steps keep the user they run as. App-scope work has no +`ctx.user`. + +The app database and `ctx.cache` are shared by callers. Include `ctx.user.id` +in cache keys and filter stored rows when their contents belong to one caller. + Requirements are declared by scope. `user: { accounts }` holds each caller's own accounts, read as `ctx.user.accounts`; `app: { accounts }` holds the app's own accounts, which people who can edit the app link for everyone, read as diff --git a/packages/apps/package.json b/packages/apps/package.json index 120603a39b..2bb4248216 100644 --- a/packages/apps/package.json +++ b/packages/apps/package.json @@ -1,6 +1,6 @@ { "name": "apps", - "version": "0.0.1-beta.115", + "version": "0.0.1-beta.116", "private": true, "description": "TypeScript apps with typed tools, accounts, storage, and UI.", "type": "module", diff --git a/packages/apps/protocols/14.json b/packages/apps/protocols/14.json new file mode 100644 index 0000000000..736d79a088 --- /dev/null +++ b/packages/apps/protocols/14.json @@ -0,0 +1,190 @@ +{ + "protocol": 14, + "previous": 13, + "messages": { + "invocation": { + "_tag": "Objects", + "propertySignatures": [ + { + "name": "command", + "type": { + "$message": "request" + }, + "isOptional": false + }, + { + "name": "accounts", + "type": { + "$message": "accounts" + }, + "isOptional": false + }, + { + "name": "caller", + "type": { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMinLength", + "payload": { + "minLength": 1 + } + } + } + ] + }, + "isOptional": true + }, + { + "name": "approval", + "type": { + "_tag": "Objects", + "propertySignatures": [ + { + "name": "tool", + "type": { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMinLength", + "payload": { + "minLength": 1 + } + } + } + ] + }, + "isOptional": false + }, + { + "name": "input", + "type": { + "_tag": "Declaration", + "representation": { + "id": "effect/schema/Json", + "payload": null + } + }, + "isOptional": false + } + ] + }, + "isOptional": true + }, + { + "name": "replay", + "type": { + "_tag": "Objects", + "propertySignatures": [ + { + "name": "key", + "type": { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMinLength", + "payload": { + "minLength": 1 + } + } + } + ] + }, + "isOptional": false + }, + { + "name": "fingerprint", + "type": { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMinLength", + "payload": { + "minLength": 1 + } + } + } + ] + }, + "isOptional": false + } + ] + }, + "isOptional": true + }, + { + "name": "deadline", + "type": { + "_tag": "Number", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isFinite", + "payload": null + } + }, + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isGreaterThanOrEqualTo", + "payload": { + "minimum": 0 + } + } + } + ] + }, + "isOptional": true + }, + { + "name": "workflowRun", + "type": { + "_tag": "String", + "checks": [ + { + "_tag": "Filter", + "representation": { + "id": "effect/schema/isMinLength", + "payload": { + "minLength": 1 + } + } + } + ] + }, + "isOptional": true + } + ] + } + }, + "unchanged": [ + "request", + "response", + "requirements", + "catalog", + "catalogSummary", + "sharedCatalog", + "skills", + "elicitationRequest", + "elicitationReply", + "workflowStep", + "workflowStepReply", + "workflowControl", + "cacheCommand", + "cacheReply", + "accountCheck", + "accounts", + "migrate" + ], + "references": {}, + "shared": {} +} diff --git a/packages/apps/src/contracts/app.ts b/packages/apps/src/contracts/app.ts index 7f51e36391..eae8ca21d1 100644 --- a/packages/apps/src/contracts/app.ts +++ b/packages/apps/src/contracts/app.ts @@ -96,7 +96,11 @@ export interface AppAccountsContext { /** The caller's own linked accounts. App-scope work has no caller, so it has none. */ export interface UserAccountsContext { - readonly user: { readonly accounts: BoundAccounts }; + readonly user: { + /** Opaque host identity, stable across calls. Undefined when the host has no subjects. */ + readonly id: string | undefined; + readonly accounts: BoundAccounts; + }; } /** diff --git a/packages/apps/src/contracts/host.ts b/packages/apps/src/contracts/host.ts index 75a74bec22..63569098c9 100644 --- a/packages/apps/src/contracts/host.ts +++ b/packages/apps/src/contracts/host.ts @@ -87,8 +87,9 @@ export { protocol9 } from "./protocols/9.ts"; export { protocol10 } from "./protocols/10.ts"; export { protocol11 } from "./protocols/11.ts"; export { protocol12 } from "./protocols/12.ts"; +export { protocol13 } from "./protocols/13.ts"; export { - current as protocol13, + current as protocol14, AccountCheckCommand, CredentialHost, MigrateCommand, @@ -207,6 +208,8 @@ export const scopedSlots = (accounts: { /** Trusted invocation context, supplied separately from the Request. */ export interface HostContext { + /** Host-authenticated caller. Absent for app-scope work and hosts without subjects. */ + readonly caller?: string; /** Trusted host deadline; never accepted in public operation JSON. */ readonly deadline?: typeof InvocationDeadline.Type; /** Host-owned cache storage and refresh lifetime, separate from app database transactions. */ diff --git a/packages/apps/src/contracts/protocol-version.ts b/packages/apps/src/contracts/protocol-version.ts index f0ab1c6d9e..095656f48d 100644 --- a/packages/apps/src/contracts/protocol-version.ts +++ b/packages/apps/src/contracts/protocol-version.ts @@ -3,4 +3,4 @@ * snapshot. Its schemas are `protocols/current.ts`; released protocols are frozen. See notes/apps-publishing.md. * This file has no imports so the package build script can read it directly. */ -export const frameworkProtocol = 13; +export const frameworkProtocol = 14; diff --git a/packages/apps/src/contracts/protocols/13.ts b/packages/apps/src/contracts/protocols/13.ts new file mode 100644 index 0000000000..c204ae9781 --- /dev/null +++ b/packages/apps/src/contracts/protocols/13.ts @@ -0,0 +1,112 @@ +/** + * Host protocol 13 adds app and user account scopes. App-scope work omits user accounts. + * This released boundary imports only earlier protocols and remains frozen. + */ +import { Schema } from "effect"; +import { + DeclaredRequirements as PreviousRequirements, + HostInvocation as PreviousInvocation, + ResolvedAccounts as PreviousAccounts, + HostedTool as PreviousTool, + HostedRouter, + HostRequest as PreviousRequest, + protocol12, +} from "./12.ts"; +import { JsonValue, OperationSchedule as PreviousSchedule } from "./1.ts"; + +export * from "./12.ts"; + +export const DeclaredRequirements = Schema.Struct({ + ...PreviousRequirements.fields, + capabilities: Schema.optionalKey( + Schema.Struct({ + skills: Schema.Literal(true), + toolIndex: Schema.optionalKey(Schema.Literal(true)), + skillSources: Schema.optionalKey(Schema.Literal(true)), + scheduledTools: Schema.optionalKey(Schema.Literal(true)), + sharedDefinitions: Schema.optionalKey(Schema.Literal(true)), + }), + ), + accounts: Schema.Struct({ + app: PreviousRequirements.fields.accounts, + user: PreviousRequirements.fields.accounts, + }), +}); + +export const ResolvedAccounts = Schema.Struct({ + app: PreviousAccounts, + user: Schema.optionalKey(PreviousAccounts), +}); + +export const HostRequest = Schema.Union( + PreviousRequest.members.map((member) => { + if ("fields" in member && "detail" in member.fields) + return Schema.Struct({ + ...member.fields, + definitions: Schema.optionalKey(Schema.Literal("shared")), + }); + return member; + }), +); + +export const OperationSchedule = Schema.Struct({ + name: PreviousSchedule.fields.name, + scope: Schema.optionalKey(Schema.Literal("app")), + timing: PreviousSchedule.fields.timing, + input: PreviousSchedule.fields.input, +}); +export const HostedTool = Schema.Struct({ + schedules: Schema.optionalKey(Schema.Array(OperationSchedule)), + name: PreviousTool.fields.name, + router: PreviousTool.fields.router, + tags: PreviousTool.fields.tags, + description: PreviousTool.fields.description, + inputSchema: PreviousTool.fields.inputSchema, + readOnly: PreviousTool.fields.readOnly, + kindConfirmed: Schema.optionalKey(Schema.Boolean), + title: PreviousTool.fields.title, + outputSchema: PreviousTool.fields.outputSchema, + annotations: PreviousTool.fields.annotations, + _meta: PreviousTool.fields._meta, +}); +export const HostedToolSummary = HostedTool.mapFields( + ({ inputSchema: _input, outputSchema: _output, _meta, ...fields }) => fields, +); +export const HostedCatalog = Schema.Struct({ + tools: Schema.Array(HostedTool), + routers: Schema.Array(HostedRouter), +}); +export const HostedCatalogSummary = Schema.Struct({ + tools: Schema.Array(HostedToolSummary), + routers: Schema.Array(HostedRouter), +}); +export const HostedSharedTool = Schema.Struct({ + ...HostedTool.fields, + shared: Schema.optionalKey(Schema.Array(Schema.Literals(["inputSchema", "outputSchema"]))), +}); +export const HostedSharedCatalog = Schema.Struct({ + tools: Schema.Array(HostedSharedTool), + routers: Schema.Array(HostedRouter), + definitions: Schema.Array(JsonValue), + defs: Schema.Array(Schema.Record(Schema.String, Schema.Int)), +}); + +export const HostInvocation = Schema.Struct({ + ...PreviousInvocation.fields, + accounts: ResolvedAccounts, + command: HostRequest, +}); + +export const protocol13 = { + version: 13, + schemas: { + ...protocol12.schemas, + requirements: DeclaredRequirements, + accounts: ResolvedAccounts, + invocation: HostInvocation, + request: HostRequest, + catalog: HostedCatalog, + catalogSummary: HostedCatalogSummary, + sharedCatalog: HostedSharedCatalog, + }, +} as const; diff --git a/packages/apps/src/contracts/protocols/current.ts b/packages/apps/src/contracts/protocols/current.ts index c1683dbc3d..22ea9e1b1a 100644 --- a/packages/apps/src/contracts/protocols/current.ts +++ b/packages/apps/src/contracts/protocols/current.ts @@ -575,6 +575,8 @@ export type HostResponse = typeof HostResponse.Type; export const HostInvocation = Schema.Struct({ command: HostRequest, accounts: ResolvedAccounts, + /** Trusted host identity; never part of the public command. */ + caller: Schema.optionalKey(Schema.NonEmptyString), approval: Schema.optionalKey(TrustedToolApproval), replay: Schema.optionalKey(WorkflowReplay), deadline: Schema.optionalKey(InvocationDeadline), diff --git a/packages/apps/src/contracts/protocols/released.ts b/packages/apps/src/contracts/protocols/released.ts index e2368feb16..0c5187ac1e 100644 --- a/packages/apps/src/contracts/protocols/released.ts +++ b/packages/apps/src/contracts/protocols/released.ts @@ -15,6 +15,7 @@ import { protocol9 } from "./9.ts"; import { protocol10 } from "./10.ts"; import { protocol11 } from "./11.ts"; import { protocol12 } from "./12.ts"; +import { protocol13 } from "./13.ts"; export const releasedProtocols = [ protocol1, @@ -29,4 +30,5 @@ export const releasedProtocols = [ protocol10, protocol11, protocol12, + protocol13, ] as const; diff --git a/packages/apps/src/host.ts b/packages/apps/src/host.ts index e84dd66eb3..671942e06a 100644 --- a/packages/apps/src/host.ts +++ b/packages/apps/src/host.ts @@ -22,8 +22,21 @@ import { export type { HostContext, ResolvedAccountsInput } from "./contracts/host.ts"; export { isolatedCacheSession } from "./implementation/cache-session.ts"; -/** Parse trusted account bindings and an optional exact-call approval. Throws safe boundary errors on invalid input. */ -export const hostContext = (accounts: ResolvedAccountsInput, approval?: unknown): HostContext => ({ +/** Parse host-owned accounts, approval and caller identity. Never take the caller from public request input. */ +export const hostContext = ( + accounts: ResolvedAccountsInput, + approval?: unknown, + caller?: unknown, +): HostContext => ({ + ...(caller === undefined + ? {} + : { + caller: Effect.runSync( + Schema.decodeUnknownEffect(Schema.NonEmptyString)(caller).pipe( + Effect.mapError(() => new HostInputInvalid()), + ), + ), + }), ...(approval === undefined ? {} : { diff --git a/packages/apps/src/implementation/host.ts b/packages/apps/src/implementation/host.ts index 3e7dc3f155..4a5b39e752 100644 --- a/packages/apps/src/implementation/host.ts +++ b/packages/apps/src/implementation/host.ts @@ -336,7 +336,7 @@ function bindScope( /** * Bind an invocation's accounts by scope: `ctx.app.accounts` always, and `ctx.user.accounts` when - * the invocation has a caller. Without `user` accounts the context has no `user`. + * the host supplies a caller or a user-account scope. App-only work has neither. */ function bindAccounts( slots: ScopedSlots, @@ -350,9 +350,14 @@ function bindAccounts( Redacted.value(context.accounts), ); const app = yield* bindScope(slots.app, declarations.accounts.app, selected.app); - if (selected.user === undefined) return { app: { accounts: app } }; - const user = yield* bindScope(slots.user, declarations.accounts.user, selected.user); - return { app: { accounts: app }, user: { accounts: user } }; + if (selected.user === undefined && context.caller === undefined) + return { app: { accounts: app } }; + const user = yield* bindScope(slots.user, declarations.accounts.user, selected.user ?? {}); + const id = + context.caller === undefined + ? undefined + : yield* Schema.decodeUnknownEffect(Schema.NonEmptyString)(context.caller); + return { app: { accounts: app }, user: { id, accounts: user } }; }), new HostAccountsInvalid(), ); @@ -715,7 +720,12 @@ function dispatch( Effect.gen(function* () { const current = yield* execution.resolve(); const accounts = yield* safe( - () => bindAccounts(native.accounts, declared, current), + // A run keeps its initiating caller; each step refreshes only the accounts. + () => + bindAccounts(native.accounts, declared, { + ...current, + ...(context.caller === undefined ? {} : { caller: context.caller }), + }), new WorkflowFailure({ reason: "credentials", retryable: false }), ); return { diff --git a/packages/sdk/src/contracts/workerd-host.ts b/packages/sdk/src/contracts/workerd-host.ts index b579bb09b4..d369c029ad 100644 --- a/packages/sdk/src/contracts/workerd-host.ts +++ b/packages/sdk/src/contracts/workerd-host.ts @@ -33,6 +33,7 @@ export const WorkerInvocation = Schema.Struct({ database: Schema.Boolean, command: HostRequest, accounts: HostAccounts, + caller: Schema.optionalKey(Schema.NonEmptyString), approval: Schema.optionalKey(TrustedToolApproval), replay: Schema.optionalKey(WorkflowReplay), deadline: Schema.optionalKey(InvocationDeadline), @@ -129,5 +130,6 @@ export const PreparedWorkflow = Schema.Union([ state: Schema.Literal("execute"), seed: WorkflowSeed, accounts: HostAccounts, + caller: Schema.optionalKey(Schema.NonEmptyString), }), ]); diff --git a/packages/sdk/src/implementation/app-protocols.ts b/packages/sdk/src/implementation/app-protocols.ts index d63bcdba56..8d5f264cde 100644 --- a/packages/sdk/src/implementation/app-protocols.ts +++ b/packages/sdk/src/implementation/app-protocols.ts @@ -57,11 +57,11 @@ export interface AppProtocol { readonly sealedCredentials: boolean; } -/** Protocol 13 is the host's current protocol, so its messages need no conversion. */ -const protocol13: AppProtocol = { - version: 13, +/** Protocol 14 adds the caller's host identity beside the invocation's account bindings. */ +const protocol14: AppProtocol = { + version: 14, workerEntry: appBridge, - nodeEntry: nodeAppEntry(13), + nodeEntry: nodeAppEntry(14), invocation: (input) => JSON.stringify(input), request: (command) => command, refuse: () => undefined, @@ -71,6 +71,15 @@ const protocol13: AppProtocol = { sealedCredentials: true, }; +/** Earlier builds have no caller identity field. Keep their retained entry body unchanged. */ +const withoutCaller = ({ caller: _caller, ...input }: HostInvocation) => input; +const protocol13: AppProtocol = { + ...protocol14, + version: 13, + nodeEntry: nodeAppEntry(13), + invocation: (input) => JSON.stringify(withoutCaller(input)), +}; + /** * Bundles of protocol 12 and earlier read one flat record of requirements and accounts. They * declare no app requirements, so they have no app accounts: the host sends them the invocation's @@ -94,8 +103,8 @@ const scopedRequirements = (command: HostRequest, body: unknown) => /** Protocol 12 and earlier: the invocation's and each workflow step's accounts, unscoped. */ const unscoped = { - invocation: >(input: Input) => - JSON.stringify({ ...input, accounts: unscopedAccounts(input.accounts) }), + invocation: (input: HostInvocation) => + JSON.stringify({ ...withoutCaller(input), accounts: unscopedAccounts(input.accounts) }), workflow: (execution: WorkflowExecution): WorkflowExecution => ({ ...execution, resolve: () => @@ -214,7 +223,7 @@ type LegacyVersion = keyof typeof legacyProtocols; */ const prefixKind = (tool: string) => tool.startsWith("queries.") ? "query" : tool.startsWith("mutations.") ? "mutation" : undefined; -const legacyCommand = (command: HostRequest) => +const legacyCommand = (command: HostRequest): HostRequest => command.operation === "call" ? { operation: "call", tool: command.tool, input: command.input } : command; @@ -272,6 +281,7 @@ const protocols: ReadonlyMap = new Map( protocol11, protocol12, protocol13, + protocol14, ].map((protocol) => [protocol.version, protocol]), ); diff --git a/packages/sdk/src/implementation/app-runner.ts b/packages/sdk/src/implementation/app-runner.ts index f23217cb49..40ec30f2b8 100644 --- a/packages/sdk/src/implementation/app-runner.ts +++ b/packages/sdk/src/implementation/app-runner.ts @@ -126,6 +126,7 @@ export interface AppRunnerHost { /** The authorized call. Credentials travel here, never in a Worker name or retained code. */ export interface AppInvocation { + readonly caller?: string; readonly app: string; readonly build: string; /** @@ -856,6 +857,7 @@ export const makeAppRunner = (host: AppRunnerHost) => { const body = protocol.invocation({ command: invocation.command, accounts, + ...(invocation.caller === undefined ? {} : { caller: invocation.caller }), ...(invocation.approval === undefined ? {} : { approval: invocation.approval }), ...(invocation.replay === undefined ? {} : { replay: invocation.replay }), ...(invocation.deadline === undefined ? {} : { deadline: invocation.deadline }), diff --git a/packages/sdk/src/implementation/app-runtime.ts b/packages/sdk/src/implementation/app-runtime.ts index 00c1f66cd8..c35ad41840 100644 --- a/packages/sdk/src/implementation/app-runtime.ts +++ b/packages/sdk/src/implementation/app-runtime.ts @@ -171,6 +171,7 @@ export const appRuntime = (host: AppRuntimeHost) => database: input.database, command, accounts: Redacted.value(input.accounts), + ...(input.caller === undefined ? {} : { caller: input.caller }), // Workers RPC structured-clones its arguments; Effect headers carry a prototype it rejects. headers: Object.fromEntries(Object.entries(yield* traceHeaders)), ...(input.hostApi === undefined ? {} : { hostApi: input.hostApi }), diff --git a/packages/sdk/src/implementation/declarations.ts b/packages/sdk/src/implementation/declarations.ts index 1ea7c50cd8..f0ec0002fa 100644 --- a/packages/sdk/src/implementation/declarations.ts +++ b/packages/sdk/src/implementation/declarations.ts @@ -132,6 +132,8 @@ export const makeDeclarations = (options: { appCacheNamespace(state.version.build, versionHostApi(options.hostApi, state.version)), // A caller's evaluation and actorless work see different contexts: only one has `user`. state.scope, + // Factories may vary tools and skills by caller, even with no user accounts. + state.scope === "user" ? state.actor : undefined, selections, ]), ), diff --git a/packages/sdk/src/implementation/runtime.ts b/packages/sdk/src/implementation/runtime.ts index ee541bf509..c6ae90a898 100644 --- a/packages/sdk/src/implementation/runtime.ts +++ b/packages/sdk/src/implementation/runtime.ts @@ -41,11 +41,15 @@ export interface ResolvedAppRuntime { readonly app: string; readonly build: BuildId; readonly accounts: ResolvedAccountsInput; + /** Trusted host identity, never a public operation input. */ + readonly caller?: string; }) => Promise; readonly inspect: (input: { readonly app: string; readonly build: BuildId; readonly accounts: ResolvedAccountsInput; + /** Trusted host identity, never a public operation input. */ + readonly caller?: string; readonly tools?: readonly string[]; readonly scheduled?: true; }) => Promise; @@ -53,12 +57,16 @@ export interface ResolvedAppRuntime { readonly app: string; readonly build: BuildId; readonly accounts: ResolvedAccountsInput; + /** Trusted host identity, never a public operation input. */ + readonly caller?: string; }) => Promise; readonly query: (input: { readonly app: string; readonly build: BuildId; readonly database: boolean; readonly accounts: ResolvedAccountsInput; + /** Trusted host identity, never a public operation input. */ + readonly caller?: string; readonly name: string; readonly input: Json; }) => Promise; @@ -67,6 +75,8 @@ export interface ResolvedAppRuntime { readonly build: BuildId; readonly database: boolean; readonly accounts: ResolvedAccountsInput; + /** Trusted host identity, never a public operation input. */ + readonly caller?: string; readonly name: string; readonly input: Json; }) => Promise; @@ -75,6 +85,8 @@ export interface ResolvedAppRuntime { readonly build: BuildId; readonly database: boolean; readonly accounts: ResolvedAccountsInput; + /** Trusted host identity, never a public operation input. */ + readonly caller?: string; readonly command: import("apps/contracts").WebhookCommand; }) => Promise; readonly call: (input: { @@ -84,6 +96,8 @@ export interface ResolvedAppRuntime { readonly build: BuildId; readonly database: boolean; readonly accounts: ResolvedAccountsInput; + /** Trusted host identity, never a public operation input. */ + readonly caller?: string; readonly tool: string; readonly kind?: "query" | "mutation"; readonly input: Json; diff --git a/packages/sdk/src/implementation/tools.ts b/packages/sdk/src/implementation/tools.ts index c25b6928ae..ea48a54ff9 100644 --- a/packages/sdk/src/implementation/tools.ts +++ b/packages/sdk/src/implementation/tools.ts @@ -467,7 +467,10 @@ function resolveFor( state.scope === "app" ? { app: selections.app } : { app: selections.app, user: selections.user }; - return { accounts: Redacted.make(accounts) }; + return { + accounts: Redacted.make(accounts), + ...(state.scope === "user" && state.actor !== undefined ? { caller: state.actor } : {}), + }; }); } @@ -555,7 +558,7 @@ const renewRefused = ( accounts.user === undefined ? { app: replaceScope(accounts.app) } : { app: replaceScope(accounts.app), user: replaceScope(accounts.user) }; - return { accounts: Redacted.make(renewedAccounts) } satisfies InvocationContext; + return { ...context, accounts: Redacted.make(renewedAccounts) } satisfies InvocationContext; }); function invocation( diff --git a/packages/sdk/src/implementation/worker-bridge.ts b/packages/sdk/src/implementation/worker-bridge.ts index 910c6ee413..6ae119bcfa 100644 --- a/packages/sdk/src/implementation/worker-bridge.ts +++ b/packages/sdk/src/implementation/worker-bridge.ts @@ -35,14 +35,14 @@ export default { clock: host.isolatedClock, async fetch(request, env) { // This entry point has no public route or host bindings. Only the trusted loader calls it. - const { command, accounts, approval, replay, deadline, workflowRun } = await request.json(); + const { command, accounts, approval, caller, replay, deadline, workflowRun } = await request.json(); const lifetime = new AbortController(); const delivery = env?.ELICITATION; const elicitation = delivery == null ? undefined : host.isolatedElicitation((prompt) => delivery(prompt), lifetime); try { return await handler(new Request("https://app.internal/dispatch", { method: "POST", headers: { "content-type": "application/json", traceparent: request.headers.get("traceparent") ?? "" }, body: JSON.stringify(command), signal: AbortSignal.any([request.signal, lifetime.signal]) - }), { ...host.hostContext(accounts, approval), files, ...(env?.CACHE === undefined ? {} : { cache: env.CACHE }), ...(replay === undefined ? {} : { replay }), ...(deadline === undefined ? {} : { deadline }), ...(env?.WORKFLOW && workflowRun ? { workflow: host.isolatedWorkflowExecution(workflowRun, env.WORKFLOW, lifetime.signal) } : {}), ...(env?.WORKFLOW_CONTROLS ? { workflowControls: host.isolatedWorkflowControls(env.WORKFLOW_CONTROLS) } : {}), ...(elicitation === undefined ? {} : { elicitation }), ...(env?.STORAGE === undefined ? {} : { storage: env.STORAGE }) }); + }), { ...host.hostContext(accounts, approval, caller), files, ...(env?.CACHE === undefined ? {} : { cache: env.CACHE }), ...(replay === undefined ? {} : { replay }), ...(deadline === undefined ? {} : { deadline }), ...(env?.WORKFLOW && workflowRun ? { workflow: host.isolatedWorkflowExecution(workflowRun, env.WORKFLOW, lifetime.signal) } : {}), ...(env?.WORKFLOW_CONTROLS ? { workflowControls: host.isolatedWorkflowControls(env.WORKFLOW_CONTROLS) } : {}), ...(elicitation === undefined ? {} : { elicitation }), ...(env?.STORAGE === undefined ? {} : { storage: env.STORAGE }) }); } finally { lifetime.abort(); } } };`; @@ -60,7 +60,7 @@ export const nodeAppEntry = (protocol: number) => (files: readonly SourceFile[]) `const files = ${JSON.stringify(serverFiles(files))};`, // Redacted owns a private store per Effect instance. Decode on // the host side and re-wrap with the selected app framework. - "export default (request, context, accounts) => handler(request, { ...context, ...host.hostContext(accounts, context.approval), files });", + "export default (request, context, accounts) => handler(request, { ...context, ...host.hostContext(accounts, context.approval, context.caller), files });", ].join("\n"); /** diff --git a/packages/sdk/src/implementation/workerd-client.ts b/packages/sdk/src/implementation/workerd-client.ts index d93a7bf66d..adb2f602e0 100644 --- a/packages/sdk/src/implementation/workerd-client.ts +++ b/packages/sdk/src/implementation/workerd-client.ts @@ -93,6 +93,7 @@ export const workerdHostHandler = (options: { state: "execute", seed, accounts: Redacted.value(context.accounts), + ...(context.caller === undefined ? {} : { caller: context.caller }), }); } case "load": { diff --git a/packages/sdk/src/implementation/workerd-entry.ts b/packages/sdk/src/implementation/workerd-entry.ts index 656e3e8b6e..0db5f6167f 100644 --- a/packages/sdk/src/implementation/workerd-entry.ts +++ b/packages/sdk/src/implementation/workerd-entry.ts @@ -454,7 +454,7 @@ export class AppWorkflows extends WorkflowEntrypoint(work: () => Promise) => Effect.tryPromise({ try: work, catch: decodeWorkflowFailure }); const execution: WorkflowExecution = { @@ -509,6 +509,7 @@ export class AppWorkflows extends WorkflowEntrypoint