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
120 changes: 120 additions & 0 deletions src/clients/wroom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import * as v from "valibot";

import { env } from "../lib/env";
import { request } from "../lib/request";

const WebhookSchema = v.object({
config: v.object({
_id: v.string(),
url: v.string(),
active: v.boolean(),
name: v.nullable(v.string()),
secret: v.nullable(v.string()),
headers: v.record(v.string(), v.string()),
documentsPublished: v.boolean(),
documentsUnpublished: v.boolean(),
releasesCreated: v.boolean(),
releasesUpdated: v.boolean(),
tagsCreated: v.boolean(),
tagsDeleted: v.boolean(),
}),
});
type Webhook = v.InferOutput<typeof WebhookSchema>;

export async function getWebhooks(config: {
repo: string;
token: string | undefined;
host: string | undefined;
}): Promise<Webhook[]> {
const { repo, token, host } = config;
const wroomUrl = getWroomUrl(repo, host);
const url = new URL("app/settings/webhooks", wroomUrl);
const response = await request(url, {
credentials: { "prismic-auth": token },
schema: v.array(WebhookSchema),
});
return response;
}

export async function triggerWebhook(
id: string,
config: { repo: string; token: string | undefined; host: string | undefined },
): Promise<void> {
const { repo, token, host } = config;
const wroomUrl = getWroomUrl(repo, host);
const url = new URL(`app/settings/webhooks/${id}/trigger`, wroomUrl);
await request(url, {
method: "POST",
credentials: { "prismic-auth": token },
});
}

export async function createWebhook(
webhookConfig: Omit<Webhook["config"], "_id" | "active" | "headers">,
config: { repo: string; token: string | undefined; host: string | undefined },
): Promise<void> {
const { repo, token, host } = config;
const wroomUrl = getWroomUrl(repo, host);
const url = new URL(`app/settings/webhooks/create`, wroomUrl);
const body = new FormData();
body.set("url", webhookConfig.url);
body.set("name", webhookConfig.name ?? "");
body.set("secret", webhookConfig.secret ?? "");
body.set("headers", JSON.stringify({}));
body.set("active", "on");
body.set("documentsPublished", webhookConfig.documentsUnpublished.toString());
body.set("documentsUnpublished", webhookConfig.documentsUnpublished.toString());
body.set("releasesCreated", webhookConfig.documentsUnpublished.toString());
body.set("releasesUpdated", webhookConfig.documentsUnpublished.toString());
body.set("tagsCreated", webhookConfig.documentsUnpublished.toString());
body.set("documentsPublished", webhookConfig.documentsUnpublished.toString());
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All trigger fields use wrong property value

High Severity

In both createWebhook and updateWebhook, every trigger field reads its value from webhookConfig.documentsUnpublished instead of the corresponding property. For example, documentsPublished uses webhookConfig.documentsUnpublished, releasesCreated uses webhookConfig.documentsUnpublished, etc. This means all triggers are always set to whatever the documentsUnpublished value is, completely ignoring the caller's intended per-trigger settings.

Additional Locations (1)
Fix in Cursor Fix in Web

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate key overwrites documentsPublished, tagsDeleted never set

High Severity

In both createWebhook and updateWebhook, the last body.set call uses the key "documentsPublished" instead of "tagsDeleted". This means tagsDeleted is never sent to the API, and the documentsPublished value is set twice (with the second call overwriting the first). The tagsDeleted trigger can never be configured through either function.

Additional Locations (1)
Fix in Cursor Fix in Web

await request(url, {
method: "POST",
body,
credentials: { "prismic-auth": token },
});
}

export async function updateWebhook(
id: string,
webhookConfig: Omit<Webhook["config"], "_id">,
config: { repo: string; token: string | undefined; host: string | undefined },
): Promise<void> {
const { repo, token, host } = config;
const wroomUrl = getWroomUrl(repo, host);
const url = new URL(`app/settings/webhooks/${id}`, wroomUrl);
const body = new FormData();
body.set("url", webhookConfig.url);
body.set("name", webhookConfig.name ?? "");
body.set("secret", webhookConfig.secret ?? "");
body.set("headers", JSON.stringify(webhookConfig.headers ?? {}));
body.set("active", webhookConfig.active ? "on" : "off");
body.set("documentsPublished", webhookConfig.documentsUnpublished.toString());
body.set("documentsUnpublished", webhookConfig.documentsUnpublished.toString());
body.set("releasesCreated", webhookConfig.documentsUnpublished.toString());
body.set("releasesUpdated", webhookConfig.documentsUnpublished.toString());
body.set("tagsCreated", webhookConfig.documentsUnpublished.toString());
body.set("documentsPublished", webhookConfig.documentsUnpublished.toString());
await request(url, {
method: "POST",
body,
credentials: { "prismic-auth": token },
});
}

export async function deleteWebhook(
id: string,
config: { repo: string; token: string | undefined; host: string | undefined },
): Promise<void> {
const { repo, token, host } = config;
const wroomUrl = getWroomUrl(repo, host);
const url = new URL(`app/settings/webhooks/${id}/delete`, wroomUrl);
await request(url, {
method: "POST",
credentials: { "prismic-auth": token },
});
}

function getWroomUrl(repo: string, host = env.PRISMIC_HOST): URL {
return new URL(`https://${repo}.${host}/`);
}
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { captureError, setupSentry } from "./lib/sentry";
import { login } from "./login";
import { logout } from "./logout";
import { sync } from "./sync";
import { webhook } from "./webhook";
import { whoami } from "./whoami";

const HELP = `
Expand All @@ -22,6 +23,7 @@ USAGE
COMMANDS
init Initialize a Prismic project
sync Sync types and slices from Prismic
webhook Manage webhooks
login Log in to Prismic
logout Log out of Prismic
whoami Show the currently logged in user
Expand Down Expand Up @@ -71,6 +73,9 @@ if (version) {
case "logout":
await logout();
break;
case "webhook":
await webhook();
break;
case "whoami":
await whoami();
break;
Expand Down
100 changes: 100 additions & 0 deletions src/webhook-add-header.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { parseArgs } from "node:util";

import { getWebhooks, updateWebhook } from "./clients/wroom";
import { getHost, getToken } from "./lib/auth";
import { safeGetRepositoryFromConfig } from "./lib/config";
import { UnknownRequestError } from "./lib/request";

const HELP = `
Add a custom HTTP header to a webhook.

By default, this command reads the repository from prismic.config.json at the
project root.

USAGE
prismic webhook add-header <url> <key> <value> [flags]

ARGUMENTS
<url> Webhook URL
<key> Header name
<value> Header value

FLAGS
-r, --repo string Repository domain
-h, --help Show help for command

LEARN MORE
Use \`prismic <command> <subcommand> --help\` for more information about a command.
`.trim();

export async function webhookAddHeader(): Promise<void> {
const {
values: { help, repo = await safeGetRepositoryFromConfig() },
positionals: [webhookUrl, headerKey, headerValue],
} = parseArgs({
args: process.argv.slice(4), // skip: node, script, "webhook", "add-header"
options: {
repo: { type: "string", short: "r" },
help: { type: "boolean", short: "h" },
},
allowPositionals: true,
});

if (help) {
console.info(HELP);
return;
}

if (!webhookUrl) {
console.error("Missing required argument: <url>");
process.exitCode = 1;
return;
}

if (!headerKey) {
console.error("Missing required argument: <key>");
process.exitCode = 1;
return;
}

if (!headerValue) {
console.error("Missing required argument: <value>");
process.exitCode = 1;
return;
}

if (!repo) {
console.error("Missing prismic.config.json or --repo option");
process.exitCode = 1;
return;
}

const token = await getToken();
const host = await getHost();
const webhooks = await getWebhooks({ repo, token, host });
const webhook = webhooks.find((w) => w.config.url === webhookUrl);
if (!webhook) {
console.error(`Webhook not found: ${webhookUrl}`);
process.exitCode = 1;
return;
}

const id = webhook.config._id;

const updatedConfig = structuredClone(webhook.config);
updatedConfig.headers[headerKey] = headerValue;

try {
await updateWebhook(id, updatedConfig, { repo, token, host });
} catch (error) {
if (error instanceof UnknownRequestError) {
const message = await error.text();
console.error(`Failed to add header: ${message}`);
process.exitCode = 1;
return;
}
throw error;
}

console.info(`Header added: ${headerKey}`);
}
127 changes: 127 additions & 0 deletions src/webhook-create.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { parseArgs } from "node:util";

import { createWebhook } from "./clients/wroom";
import { getHost, getToken } from "./lib/auth";
import { safeGetRepositoryFromConfig } from "./lib/config";
import { UnknownRequestError } from "./lib/request";
import { TRIGGER_DISPLAY } from "./webhook-view";

const HELP = `
Create a new webhook in a Prismic repository.

By default, this command reads the repository from prismic.config.json at the
project root.

USAGE
prismic webhook create <url> [flags]

ARGUMENTS
<url> Webhook URL to receive events

FLAGS
-n, --name string Webhook name
-s, --secret string Secret for webhook signature
-t, --trigger string Trigger events (can be repeated)
-r, --repo string Repository domain
-h, --help Show help for command

TRIGGERS
document.published When documents are published
document.unpublished When documents are unpublished
release.created When a release is created
release.updated When a release is edited or deleted
tag.created When a tag is created
tag.deleted When a tag is deleted

If no triggers specified, all are enabled.

LEARN MORE
Use \`prismic <command> <subcommand> --help\` for more information about a command.
`.trim();

const VALID_TRIGGERS = Object.values(TRIGGER_DISPLAY);

export async function webhookCreate(): Promise<void> {
const {
values: { help, repo = await safeGetRepositoryFromConfig(), name, secret, trigger = [] },
positionals: [webhookUrl],
} = parseArgs({
args: process.argv.slice(4), // skip: node, script, "webhook", "create"
options: {
name: { type: "string", short: "n" },
secret: { type: "string", short: "s" },
trigger: { type: "string", multiple: true, short: "t" },
repo: { type: "string", short: "r" },
help: { type: "boolean", short: "h" },
},
allowPositionals: true,
});

if (help) {
console.info(HELP);
return;
}

if (!webhookUrl) {
console.error("Missing required argument: <url>");
process.exitCode = 1;
return;
}

if (!repo) {
console.error("Missing prismic.config.json or --repo option");
process.exitCode = 1;
return;
}

// Validate triggers
for (const t of trigger) {
if (!VALID_TRIGGERS.includes(t)) {
console.error(`Invalid trigger: ${t}`);
console.error(`Valid triggers: ${VALID_TRIGGERS.join(", ")}`);
process.exitCode = 1;
return;
}
}

// Build trigger settings
const defaultValue = trigger.length > 0 ? false : true;
const triggers: Record<keyof typeof TRIGGER_DISPLAY, boolean> = {
documentsPublished: defaultValue,
documentsUnpublished: defaultValue,
releasesCreated: defaultValue,
releasesUpdated: defaultValue,
tagsCreated: defaultValue,
tagsDeleted: defaultValue,
};
for (const t of trigger) {
const [apiField] = Object.entries(TRIGGER_DISPLAY).find(([, display]) => t === display) ?? [];
if (!apiField) continue;
triggers[apiField as keyof typeof TRIGGER_DISPLAY] = true;
}

const token = await getToken();
const host = await getHost();

try {
await createWebhook(
{
url: webhookUrl,
name: name ?? null,
secret: secret ?? null,
...triggers,
},
{ repo, token, host },
);
} catch (error) {
if (error instanceof UnknownRequestError) {
const message = await error.text();
console.error(`Failed to create webhook: ${message}`);
process.exitCode = 1;
return;
}
throw error;
}

console.info(`Webhook created: ${webhookUrl}`);
}
Loading