diff --git a/packages/workshop-backend/src/external-message-gateway.ts b/packages/workshop-backend/src/external-message-gateway.ts index 6a48a5a9b..1878ced15 100644 --- a/packages/workshop-backend/src/external-message-gateway.ts +++ b/packages/workshop-backend/src/external-message-gateway.ts @@ -1,6 +1,8 @@ import { WorkerEntrypoint } from "cloudflare:workers"; import { validateRpc } from "capnweb-validate"; +import type { CollaboratorInfo } from "@gadgets/workshop-shared/api"; import { + type AddExternalCollaboratorInput, type ExternalMessageGateway as ExternalMessageGatewayContract, type SubmitExternalMessageInput, type SubmitExternalMessageResult, @@ -36,4 +38,18 @@ export class ExternalMessageGateway extends WorkerEntrypoint { + let source = this.ctx.props.source; + if (!source) throw new Error("ExternalMessageGateway source prop is required."); + + // Same DO name as submitExternalMessage — gadgetKey is gateway-namespaced. + let overseer = this.ctx.exports.OverseerDurableObject.getByName(`${source}:${input.gadgetKey}`); + + return await overseer.addExternalCollaborator({ + username: input.username, + role: input.role, + note: input.note, + }); + } } diff --git a/packages/workshop-backend/src/overseer.ts b/packages/workshop-backend/src/overseer.ts index d6267e31f..ef1e5ef92 100644 --- a/packages/workshop-backend/src/overseer.ts +++ b/packages/workshop-backend/src/overseer.ts @@ -6612,6 +6612,43 @@ export class OverseerDurableObject extends DurableObject { return { accepted: true, chatPath: `/workspace/${this.ctx.id.toString()}?chat=${chatId}` }; } + /** + * Grant Build (or another role) to an existing account on behalf of the workspace owner. + * + * Used by ExternalMessageGateway when a Teams (or other) gateway has already authenticated the + * username. This is not the session-bound Overseer.addCollaborator path — there is no browser + * client here — so the share is attributed to the owner. Returns null when the username has no + * account (same contract as Overseer.addCollaborator). Throws when sharing is prohibited. + */ + async addExternalCollaborator(input: { + username: string; + role: CollaboratorRole; + note?: string; + }): Promise { + let ownerProfileId = this.impl.ownerProfileId; + if (!ownerProfileId) { + // Workspace has not been claimed yet; there is nothing to share into. + return null; + } + + let userDo = this.impl.users.get(this.impl.users.idFromName(input.username)); + let profile = await userDo.whoamiIfExists(); + if (!profile) return null; + + if (this.impl.storage.prohibitAllSharing.get()) { + throw new Error( + "This workspace has observed sensitive data. To prevent leaks, the workspace cannot be " + + "shared."); + } + + return (await this.impl.getSharingManager()).addCollaborator({ + caller: { profileId: ownerProfileId, isOwner: true }, + profile, + role: input.role, + note: input.note, + }); + } + // Initialize this workspace's default gadget from a blueprint's code snapshot. Called by // AuthenticatedApi.newGadgetFromBlueprint() after creating (and opening) the DO. async initializeFromBlueprint(code: Uint8Array, title: string, output?: BlueprintOutput) diff --git a/packages/workshop-shared/src/external-message-gateway.ts b/packages/workshop-shared/src/external-message-gateway.ts index 875e63a41..d1b280c5d 100644 --- a/packages/workshop-shared/src/external-message-gateway.ts +++ b/packages/workshop-shared/src/external-message-gateway.ts @@ -1,4 +1,5 @@ import type { RpcStub, RpcTarget } from "cloudflare:workers"; +import type { CollaboratorInfo, CollaboratorRole } from "./api.ts"; /** A completed Gadget response that should be delivered back to the chat gateway. */ export type GadgetResponse = { @@ -45,8 +46,27 @@ export type SubmitExternalMessageResult = message: string; }; +/** Grant a role to an existing account on a gateway-owned workspace. */ +export type AddExternalCollaboratorInput = { + // Selects the workspace (same gadgetKey namespace as submitExternalMessage). + gadgetKey: string; + // Username/email the gateway has already authenticated. Never accept a model- or + // client-supplied identity here — the gateway is the trusted boundary. + username: string; + role: CollaboratorRole; + note?: string; +}; + /** Service binding RPC interface used by chat gateway workers. */ export interface ExternalMessageGateway { /** Submit an external chat message for Gadget routing and execution. */ submitExternalMessage(input: SubmitExternalMessageInput): Promise; + + /** + * Add a collaborator to a gateway-owned workspace, acting as the workspace owner. + * + * Returns null when `username` has no account. Gateways must only pass usernames they have + * authenticated (e.g. Teams roster → Graph email). Throws when sharing is prohibited. + */ + addCollaborator(input: AddExternalCollaboratorInput): Promise; }