Skip to content

Commit da0054b

Browse files
authored
Merge pull request #38 from HomicideZero/pr/authenticated-mode-board-auth
fix: authenticate Paperclip API calls in authenticated deployment mode
2 parents e4e0f85 + 46aa454 commit da0054b

8 files changed

Lines changed: 65 additions & 10 deletions

File tree

src/commands.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,13 @@ interface Interaction {
4141
export interface CommandContext {
4242
baseUrl: string;
4343
companyId: string;
44+
/** Discord bot token — used for Discord API calls. */
4445
token: string;
46+
/** Optional Paperclip board API key — attached to Paperclip API calls that
47+
* require board authentication (approve/reject, create issues, etc.).
48+
* Empty string disables the Authorization header, which is correct for
49+
* `local_trusted` deployments. */
50+
paperclipBoardApiKey?: string;
4551
defaultChannelId: string;
4652
/** PluginContext for lazy company-ID resolution at command time. */
4753
pluginCtx?: PluginContext;
@@ -396,6 +402,7 @@ async function handleSlashCommand(
396402
getOption(subcommand.options ?? [], "id"),
397403
member?.user.username,
398404
baseUrl,
405+
cmdCtx?.paperclipBoardApiKey,
399406
);
400407
case "budget":
401408
return handleBudget(ctx, getOption(subcommand.options ?? [], "agent"), companyId);
@@ -552,6 +559,7 @@ async function handleApprove(
552559
approvalId: string | undefined,
553560
username?: string,
554561
baseUrl?: string,
562+
apiKey?: string,
555563
): Promise<unknown> {
556564
if (!approvalId) {
557565
return respondToInteraction({
@@ -568,7 +576,7 @@ async function handleApprove(
568576
method: "POST",
569577
headers: { "Content-Type": "application/json" },
570578
body: JSON.stringify({ decidedByUserId: `discord:${username ?? "unknown"}` }),
571-
});
579+
}, apiKey);
572580
throwOnRetryableStatus(r);
573581
return r;
574582
});
@@ -1144,6 +1152,7 @@ async function handleButtonClick(
11441152
const actor = username ?? "Discord user";
11451153
const base = cmdCtx?.baseUrl ?? "http://localhost:3100";
11461154
const token = cmdCtx?.token ?? "";
1155+
const apiKey = cmdCtx?.paperclipBoardApiKey ?? "";
11471156

11481157
if (customId.startsWith("approval_approve_")) {
11491158
const approvalId = customId.replace("approval_approve_", "");
@@ -1155,7 +1164,7 @@ async function handleButtonClick(
11551164
method: "POST",
11561165
headers: { "Content-Type": "application/json" },
11571166
body: JSON.stringify({ decidedByUserId: `discord:${actor}` }),
1158-
});
1167+
}, apiKey);
11591168
throwOnRetryableStatus(r);
11601169
return r;
11611170
});
@@ -1206,7 +1215,7 @@ async function handleButtonClick(
12061215
method: "POST",
12071216
headers: { "Content-Type": "application/json" },
12081217
body: JSON.stringify({ decidedByUserId: `discord:${actor}` }),
1209-
});
1218+
}, apiKey);
12101219
throwOnRetryableStatus(r);
12111220
return r;
12121221
});
@@ -1537,6 +1546,7 @@ async function handleCommands(
15371546
channelId,
15381547
getOption(sub.options ?? [], "name") ?? "",
15391548
getOption(sub.options ?? [], "args") ?? "",
1549+
cmdCtx?.paperclipBoardApiKey ?? "",
15401550
);
15411551
case "delete":
15421552
return handleCommandsDelete(ctx, companyId, getOption(sub.options ?? [], "name") ?? "");
@@ -1666,6 +1676,7 @@ async function handleCommandsRun(
16661676
channelId: string,
16671677
name: string,
16681678
args: string,
1679+
paperclipBoardApiKey: string,
16691680
): Promise<unknown> {
16701681
if (!name.trim()) {
16711682
return respondToInteraction({
@@ -1694,6 +1705,7 @@ async function handleCommandsRun(
16941705
channelId,
16951706
companyId,
16961707
baseUrl,
1708+
paperclipBoardApiKey,
16971709
workflow,
16981710
args,
16991711
});
@@ -1806,6 +1818,7 @@ async function handleWorkflowApprovalButton(
18061818
baseUrl,
18071819
approvalId,
18081820
approved,
1821+
cmdCtx?.paperclipBoardApiKey ?? "",
18091822
);
18101823

18111824
const statusText = approved ? "Approved" : "Rejected";

src/constants.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
export const PLUGIN_ID = "paperclip-plugin-discord";
2-
export const PLUGIN_VERSION = "0.7.2";
2+
export const PLUGIN_VERSION = "0.7.3";
33

44
export const WEBHOOK_KEYS = {
55
discordInteractions: "discord-interactions",
@@ -15,6 +15,7 @@ export const EXPORT_NAMES = {
1515

1616
export const DEFAULT_CONFIG = {
1717
discordBotTokenRef: "",
18+
paperclipBoardApiKeyRef: "",
1819
defaultGuildId: "",
1920
defaultChannelId: "",
2021
approvalsChannelId: "",

src/manifest.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,14 @@ const manifest: PaperclipPluginManifestV1 = {
5555
"Secret UUID for your Discord Bot token. Create the secret in Settings → Secrets, then paste its UUID here.",
5656
default: DEFAULT_CONFIG.discordBotTokenRef,
5757
},
58+
paperclipBoardApiKeyRef: {
59+
type: "string",
60+
format: "secret-ref",
61+
title: "Paperclip Board API Key (secret reference)",
62+
description:
63+
"Optional. Secret UUID for a Paperclip board API key. Required when Paperclip is deployed in `authenticated` mode so that plugin-originated calls (approve/reject buttons, workflow steps, inbound reply routing) can satisfy server-side board-auth checks. Create a board API key in Settings → API Keys, store it as a secret, then paste the secret UUID here. Leave blank for `local_trusted` deployments.",
64+
default: DEFAULT_CONFIG.paperclipBoardApiKeyRef,
65+
},
5866
defaultGuildId: {
5967
type: "string",
6068
title: "Default Guild (Server) ID",

src/paperclip-fetch.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,23 @@
1010
*
1111
* Native `fetch` has no such restriction, so we use it for all calls
1212
* that target the Paperclip base URL.
13+
*
14+
* Auth: when Paperclip is deployed in `authenticated` mode (the default
15+
* for public deployments), server routes that call `assertBoard(req)`
16+
* (approvals, board mutations, etc.) require an Authorization: Bearer
17+
* header carrying a board API key. Pass `apiKey` to attach it. In
18+
* `local_trusted` deployments unauthenticated requests are implicitly
19+
* promoted to `board`, so `apiKey` can be omitted.
1320
*/
1421
export function paperclipFetch(
1522
url: string,
1623
init?: RequestInit,
24+
apiKey?: string,
1725
): Promise<Response> {
18-
return fetch(url, init);
26+
if (!apiKey) return fetch(url, init);
27+
const headers = new Headers(init?.headers);
28+
if (!headers.has("Authorization")) {
29+
headers.set("Authorization", `Bearer ${apiKey}`);
30+
}
31+
return fetch(url, { ...init, headers });
1932
}

src/worker.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ import {
6161

6262
type DiscordConfig = {
6363
discordBotTokenRef: string;
64+
paperclipBoardApiKeyRef?: string;
6465
defaultGuildId: string;
6566
defaultChannelId: string;
6667
approvalsChannelId: string;
@@ -269,6 +270,9 @@ const plugin = definePlugin({
269270
}
270271

271272
const token = await ctx.secrets.resolve(config.discordBotTokenRef);
273+
const paperclipBoardApiKey = config.paperclipBoardApiKeyRef
274+
? await ctx.secrets.resolve(config.paperclipBoardApiKeyRef)
275+
: "";
272276
const baseUrl = config.paperclipBaseUrl || "http://localhost:3100";
273277
const retentionDays = config.intelligenceRetentionDays || 30;
274278
const defaultGuildId = normalizeDiscordId(config.defaultGuildId);
@@ -287,6 +291,7 @@ const plugin = definePlugin({
287291
baseUrl,
288292
companyId,
289293
token,
294+
paperclipBoardApiKey,
290295
defaultChannelId,
291296
pluginCtx: ctx,
292297
};
@@ -386,6 +391,7 @@ const plugin = definePlugin({
386391
authorUserId: `discord:${message.author.username}`,
387392
}),
388393
},
394+
paperclipBoardApiKey,
389395
);
390396
await ctx.metrics.write(METRIC_NAMES.inboundRouted, 1);
391397
ctx.logger.info("Routed Discord reply to issue comment", {

src/workflow-engine.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ async function execFetchIssue(
121121
step: WorkflowStep,
122122
wfCtx: WorkflowContext,
123123
baseUrl: string,
124+
apiKey: string,
124125
): Promise<StepResult> {
125126
const issueId = interpolate(step.issueId ?? "", wfCtx);
126127
if (!issueId) return { ok: false, error: "Missing issueId" };
@@ -129,7 +130,7 @@ async function execFetchIssue(
129130
const resp = await paperclipFetch(`${baseUrl}/api/issues/${issueId}`, {
130131
method: "GET",
131132
headers: { "Content-Type": "application/json" },
132-
});
133+
}, apiKey);
133134
if (!resp.ok) return { ok: false, error: `API ${resp.status}` };
134135
const data = await resp.json();
135136
return { ok: true, result: data };
@@ -232,6 +233,7 @@ async function execCreateIssue(
232233
wfCtx: WorkflowContext,
233234
companyId: string,
234235
baseUrl: string,
236+
apiKey: string,
235237
): Promise<StepResult> {
236238
const title = interpolate(step.title ?? "", wfCtx);
237239
if (!title) return { ok: false, error: "Missing title" };
@@ -250,7 +252,7 @@ async function execCreateIssue(
250252
method: "POST",
251253
headers: { "Content-Type": "application/json" },
252254
body: JSON.stringify(payload),
253-
});
255+
}, apiKey);
254256
if (!resp.ok) return { ok: false, error: `API ${resp.status}` };
255257
const data = await resp.json();
256258
return { ok: true, result: data };
@@ -355,6 +357,9 @@ export interface WorkflowRunOptions {
355357
channelId: string;
356358
companyId: string;
357359
baseUrl: string;
360+
/** Paperclip board API key. Empty string disables Authorization header
361+
* (correct for `local_trusted` deployments). Required for `authenticated`. */
362+
paperclipBoardApiKey: string;
358363
workflow: Workflow;
359364
args: string;
360365
/** Resume from this step index (for approval continuation) */
@@ -369,7 +374,7 @@ export async function runWorkflow(opts: WorkflowRunOptions): Promise<{
369374
suspended?: boolean;
370375
error?: string;
371376
}> {
372-
const { ctx, token, channelId, companyId, baseUrl, workflow, args } = opts;
377+
const { ctx, token, channelId, companyId, baseUrl, paperclipBoardApiKey, workflow, args } = opts;
373378

374379
const wfCtx: WorkflowContext = opts.resumeCtx
375380
? { ...opts.resumeCtx, prevResult: null }
@@ -389,7 +394,7 @@ export async function runWorkflow(opts: WorkflowRunOptions): Promise<{
389394

390395
switch (step.type) {
391396
case "fetch_issue":
392-
result = await execFetchIssue(ctx, step, wfCtx, baseUrl);
397+
result = await execFetchIssue(ctx, step, wfCtx, baseUrl, paperclipBoardApiKey);
393398
break;
394399
case "invoke_agent":
395400
result = await execInvokeAgent(ctx, step, wfCtx, companyId);
@@ -401,7 +406,7 @@ export async function runWorkflow(opts: WorkflowRunOptions): Promise<{
401406
result = await execSendMessage(ctx, step, wfCtx, token, channelId);
402407
break;
403408
case "create_issue":
404-
result = await execCreateIssue(ctx, step, wfCtx, companyId, baseUrl);
409+
result = await execCreateIssue(ctx, step, wfCtx, companyId, baseUrl, paperclipBoardApiKey);
405410
break;
406411
case "wait_approval":
407412
result = await execWaitApproval(ctx, step, wfCtx, token, channelId, workflow.name, i, companyId);
@@ -481,6 +486,7 @@ export async function resumeWorkflowAfterApproval(
481486
baseUrl: string,
482487
approvalId: string,
483488
approved: boolean,
489+
paperclipBoardApiKey: string,
484490
): Promise<{ ok: boolean; error?: string }> {
485491
const pending = (await ctx.state.get({
486492
scopeKind: "company",
@@ -527,6 +533,7 @@ export async function resumeWorkflowAfterApproval(
527533
channelId,
528534
companyId,
529535
baseUrl,
536+
paperclipBoardApiKey,
530537
workflow,
531538
args: pending.wfCtx.fullArgs,
532539
resumeFromStep: pending.stepIndex + 1,

tests/commands.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ const defaultCmdCtx: CommandContext = {
5858
baseUrl: "http://localhost:3100",
5959
companyId: "default",
6060
token: "test-token",
61+
paperclipBoardApiKey: "test-board-key",
6162
defaultChannelId: "ch-1",
6263
};
6364

@@ -235,6 +236,7 @@ describe("/clip approve", () => {
235236
expect(mockPaperclipFetch).toHaveBeenCalledWith(
236237
"https://app.example.com/api/approvals/apr-1/approve",
237238
expect.objectContaining({ method: "POST" }),
239+
expect.any(String),
238240
);
239241
expect(result.data.embeds[0].color).toBe(COLORS.GREEN);
240242
});
@@ -333,6 +335,7 @@ describe("button clicks", () => {
333335
expect(mockPaperclipFetch).toHaveBeenCalledWith(
334336
"https://app.example.com/api/approvals/apr-1/approve",
335337
expect.objectContaining({ method: "POST" }),
338+
expect.any(String),
336339
);
337340
expect(result.type).toBe(7);
338341
expect(result.data.embeds[0].description).toContain("Approved");
@@ -354,6 +357,7 @@ describe("button clicks", () => {
354357
expect(mockPaperclipFetch).toHaveBeenCalledWith(
355358
"https://app.example.com/api/approvals/apr-2/reject",
356359
expect.objectContaining({ method: "POST" }),
360+
expect.any(String),
357361
);
358362
expect(result.type).toBe(7);
359363
expect(result.data.embeds[0].description).toContain("Rejected");

tests/workflow-engine.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ const baseOpts = {
6363
channelId: "ch-1",
6464
companyId: "company-1",
6565
baseUrl: "http://localhost:3100",
66+
paperclipBoardApiKey: "",
6667
args: "",
6768
};
6869

@@ -90,6 +91,7 @@ describe("runWorkflow — template interpolation", () => {
9091
expect(mockPaperclipFetch).toHaveBeenCalledWith(
9192
expect.stringContaining("/api/issues/my-issue-id"),
9293
expect.anything(),
94+
expect.any(String),
9395
);
9496
});
9597

@@ -284,6 +286,7 @@ describe("runWorkflow — step types", () => {
284286
method: "POST",
285287
body: expect.stringContaining("Bug: login-broken"),
286288
}),
289+
expect.any(String),
287290
);
288291
});
289292

0 commit comments

Comments
 (0)