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
16 changes: 16 additions & 0 deletions packages/workshop-backend/src/external-message-gateway.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -36,4 +38,18 @@ export class ExternalMessageGateway extends WorkerEntrypoint<Cloudflare.Env, Ext
title: input.gadgetTitle,
});
}

async addCollaborator(input: AddExternalCollaboratorInput): Promise<CollaboratorInfo | null> {
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,
});
}
}
37 changes: 37 additions & 0 deletions packages/workshop-backend/src/overseer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6612,6 +6612,43 @@ export class OverseerDurableObject extends DurableObject<Cloudflare.Env> {
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<CollaboratorInfo | null> {
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)
Expand Down
20 changes: 20 additions & 0 deletions packages/workshop-shared/src/external-message-gateway.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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<SubmitExternalMessageResult>;

/**
* 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<CollaboratorInfo | null>;
}
Loading