Fix Forgejo token secret ref validation

This commit is contained in:
Paperclip Bot 2026-06-03 03:03:03 +00:00
parent fc50e74989
commit d95caa1cb9
5 changed files with 136 additions and 2 deletions

View file

@ -1,4 +1,5 @@
import type { ForgejoPluginConfig } from "./types.js";
import { FORGEJO_TOKEN_REF_FORMAT_MESSAGE, isPaperclipSecretRef } from "./secret-ref.js";
function optionalString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
@ -39,6 +40,10 @@ export function validateConfig(raw: Record<string, unknown>): { ok: boolean; err
errors.push("forgejoBaseUrl, forgejoTokenRef, forgejoOwner, and forgejoRepo must all be configured together.");
}
if (config.forgejoTokenRef && !isPaperclipSecretRef(config.forgejoTokenRef)) {
errors.push(FORGEJO_TOKEN_REF_FORMAT_MESSAGE);
}
if (!config.defaultCompanyId) {
warnings.push("defaultCompanyId is not set; the reconciliation job cannot backfill unsynced issues across a company.");
}

View file

@ -1,5 +1,6 @@
import type { PluginContext } from "@paperclipai/plugin-sdk";
import { readConfig } from "./config.js";
import { assertForgejoTokenRef } from "./secret-ref.js";
import type { ForgejoIssuePayload, ForgejoIssueRecord } from "./types.js";
function joinUrl(baseUrl: string, path: string): string {
@ -15,7 +16,17 @@ export async function createForgejoIssue(
throw new Error("Forgejo outbound sync is not fully configured.");
}
const token = await ctx.secrets.resolve(config.forgejoTokenRef);
const secretRef = assertForgejoTokenRef(config.forgejoTokenRef);
let token: string;
try {
token = await ctx.secrets.resolve(secretRef);
} catch (error) {
throw new Error(
"Failed to resolve forgejoTokenRef. Use a Paperclip company secret UUID and re-save the field through the secret picker if it currently contains a visible secret name.",
{ cause: error }
);
}
const response = await ctx.http.fetch(
joinUrl(config.forgejoBaseUrl, `/api/v1/repos/${encodeURIComponent(config.forgejoOwner)}/${encodeURIComponent(config.forgejoRepo)}/issues`),
{

View file

@ -38,7 +38,7 @@ const manifest: PaperclipPluginManifestV1 = {
type: "string",
title: "Forgejo Token Secret Ref",
format: "secret-ref",
description: "Secret reference for outbound Forgejo API authentication."
description: "Paperclip company secret reference UUID for outbound Forgejo API authentication."
},
forgejoOwner: {
type: "string",

17
src/secret-ref.ts Normal file
View file

@ -0,0 +1,17 @@
const PAPERCLIP_SECRET_REF_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export const FORGEJO_TOKEN_REF_FORMAT_MESSAGE =
"forgejoTokenRef must be a Paperclip secret reference UUID. Re-save the field through the Paperclip secret picker or paste the secret UUID, not the visible secret name.";
export function isPaperclipSecretRef(value: string): boolean {
return PAPERCLIP_SECRET_REF_RE.test(value.trim());
}
export function assertForgejoTokenRef(secretRef: string | undefined): string {
if (!secretRef || !isPaperclipSecretRef(secretRef)) {
throw new Error(FORGEJO_TOKEN_REF_FORMAT_MESSAGE);
}
return secretRef.trim();
}

View file

@ -0,0 +1,101 @@
import { describe, expect, it, vi } from "vitest";
import { validateConfig } from "../src/config.js";
import { createForgejoIssue } from "../src/forgejo-client.js";
describe("forgejo client", () => {
it("resolves a Paperclip-managed secret ref and sends the Forgejo token", async () => {
const secretRef = "11111111-1111-4111-8111-111111111111";
const resolveSecret = vi.fn(async () => "forgejo-token");
const fetch = vi.fn(async () => ({
ok: true,
json: async () => ({
id: 42,
number: 7,
html_url: "https://forgejo.example/acme/repo/issues/7",
url: "https://forgejo.example/api/v1/repos/acme/repo/issues/7"
})
}));
const result = await createForgejoIssue(
{
config: {
get: async () => ({
forgejoBaseUrl: "https://forgejo.example",
forgejoTokenRef: secretRef,
forgejoOwner: "acme",
forgejoRepo: "repo"
})
},
secrets: { resolve: resolveSecret },
http: { fetch }
} as never,
{
title: "Example issue",
body: "body"
}
);
expect(resolveSecret).toHaveBeenCalledWith(secretRef);
expect(fetch).toHaveBeenCalledWith(
"https://forgejo.example/api/v1/repos/acme/repo/issues",
expect.objectContaining({
method: "POST",
headers: expect.objectContaining({
authorization: "token forgejo-token"
})
})
);
expect(result).toEqual({
id: 42,
number: 7,
url: "https://forgejo.example/acme/repo/issues/7",
apiUrl: "https://forgejo.example/api/v1/repos/acme/repo/issues/7"
});
});
it("rejects visible secret names with an actionable, sanitized error", async () => {
const invalidRef = "forgejo-ake-paperclip-forgejo-issue-plugin-issues";
const resolveSecret = vi.fn();
const fetch = vi.fn();
expect(validateConfig({
forgejoBaseUrl: "https://forgejo.example",
forgejoTokenRef: invalidRef,
forgejoOwner: "acme",
forgejoRepo: "repo"
})).toEqual({
ok: false,
errors: [
"forgejoTokenRef must be a Paperclip secret reference UUID. Re-save the field through the Paperclip secret picker or paste the secret UUID, not the visible secret name."
],
warnings: [
"defaultCompanyId is not set; the reconciliation job cannot backfill unsynced issues across a company.",
"syncIssueLabel is not set; the plugin will use the default label \"forgejo-sync\"."
]
});
await expect(createForgejoIssue(
{
config: {
get: async () => ({
forgejoBaseUrl: "https://forgejo.example",
forgejoTokenRef: invalidRef,
forgejoOwner: "acme",
forgejoRepo: "repo"
})
},
secrets: { resolve: resolveSecret },
http: { fetch }
} as never,
{
title: "Example issue",
body: "body"
}
)).rejects.toThrow(
"forgejoTokenRef must be a Paperclip secret reference UUID. Re-save the field through the Paperclip secret picker or paste the secret UUID, not the visible secret name."
);
expect(resolveSecret).not.toHaveBeenCalled();
expect(fetch).not.toHaveBeenCalled();
});
});