Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
ea0865d
feat(webhooks): add inbound webhook bot trigger endpoint and webhook …
Aug 27, 2026
eae8309
fix(webhooks): require per-bot bearer secret on inbound wake
cursoragent Aug 27, 2026
9353c73
style(contracts): format webhook RunActivity trigger enum
cursoragent Aug 27, 2026
fc7a3a0
fix(adapters): load webhook secret id from destroy lock query
cursoragent Aug 27, 2026
92539fa
feat(routines): surface schedule and webhook triggers in the editor
cursoragent Aug 28, 2026
183988e
fix(web): keep routine editor open after save
cursoragent Aug 28, 2026
dbddbe9
test(web): align routine e2e with editor staying open after save
cursoragent Aug 28, 2026
674dfe1
fix(web): default new routine drafts to active
cursoragent Aug 28, 2026
a130f3a
chore: retrigger CI for routine e2e fixes
cursoragent Aug 28, 2026
61a292d
test(web): wait for routine save RPC before leaving the editor
cursoragent Aug 28, 2026
a67e179
merge(main): resolve conflicts and keep webhook routine editor
cursoragent Aug 28, 2026
65dc55c
merge: include routine e2e wait-for-save fix
cursoragent Aug 28, 2026
701ea51
style(web): biome-format routine editor one-shot arm helpers
cursoragent Aug 28, 2026
06a9815
test(web): scope auth-lifecycle message assert to transcript
cursoragent Aug 28, 2026
94bc55a
fix(webhooks): harden idempotency nonce and routine save UX
cursoragent Aug 28, 2026
7b5daad
fix(web): surface routine test-run failures in the editor
cursoragent Aug 28, 2026
5298662
merge(main): resolve conflicts for webhook routine trigger UX
cursoragent Aug 28, 2026
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
3 changes: 3 additions & 0 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import { cors } from "hono/cors";
import { type AppEnv, loadEnv } from "./env.js";
import { createRouter } from "./router.js";
import { mountVoiceHttpRoutes } from "./voice.js";
import { mountWebhookHttpRoutes } from "./webhook.js";

export interface AppHandles {
app: Hono;
Expand Down Expand Up @@ -314,6 +315,8 @@ export async function createApp(
if (!session?.user) return null;
return requireMembership(prisma, session.user.id).catch(() => null);
});
mountWebhookHttpRoutes(app, { prisma, secrets, events, jobs });

app.get("/health", (c) =>
c.json({
ok: true,
Expand Down
70 changes: 63 additions & 7 deletions apps/api/src/router.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createHash, randomUUID } from "node:crypto";
import { createHash, randomBytes, randomUUID } from "node:crypto";
import { implement, ORPCError } from "@orpc/server";
import {
type AdapterContext,
Expand Down Expand Up @@ -797,6 +797,48 @@ export function createRouter(deps: RouterDeps) {
);
return { ok: true as const };
}),
rotateWebhookSecret: authed.bots.rotateWebhookSecret.handler(async ({ context, input }) => {
const bot = await repos.getBot(context.actor, input.botId);
const plaintext = randomBytes(32).toString("base64url");
const stored = await deps.secrets.put(plaintext, {
operationId: "bots.rotateWebhookSecret",
traceId: "bots.rotateWebhookSecret",
workspaceId: context.actor.workspaceId,
userId: context.actor.userId,
signal: context.signal ?? new AbortController().signal,
});
await deps.prisma.$transaction(async (tx) => {
const previousSecretId = bot.webhookSecretId;
await tx.secret.create({
data: {
id: stored.id,
userId: context.actor.userId,
workspaceId: context.actor.workspaceId,
kind: "webhook",
ciphertext: stored.ciphertext,
},
});
await tx.bot.update({
where: { id: bot.id },
data: { webhookSecretId: stored.id },
});
if (previousSecretId) {
await tx.secret.deleteMany({
where: {
id: previousSecretId,
workspaceId: context.actor.workspaceId,
userId: context.actor.userId,
kind: "webhook",
},
});
}
});
return {
secret: plaintext,
path: `/api/v1/bots/${bot.id}/webhook`,
webhookConfigured: true as const,
};
}),
},
groups: {
create: authed.groups.create.handler(async ({ context, input }) =>
Expand Down Expand Up @@ -1685,9 +1727,9 @@ export function createRouter(deps: RouterDeps) {
});
}
const bot = await repos.getBot(context.actor, input.botId);
// Validate every recurring cron even when inactive; @once has no next date.
// Validate every recurring cron even when inactive; @once and webhook-only have no next date.
let nextRunAt: Date | null = null;
if (!isOneShotRoutineCrons(input.crons)) {
if (input.crons.length > 0 && !isOneShotRoutineCrons(input.crons)) {
const computedNextRunAt = nextRoutineDate(input.crons, input.timezone);
nextRunAt = input.active ? computedNextRunAt : null;
}
Expand All @@ -1702,6 +1744,7 @@ export function createRouter(deps: RouterDeps) {
timezone: input.timezone,
notify: input.notify,
active: input.active,
webhookEnabled: input.webhookEnabled,
nextRunAt,
},
});
Expand Down Expand Up @@ -1731,6 +1774,12 @@ export function createRouter(deps: RouterDeps) {
const active = input.active ?? existing.active;
const crons = input.crons ?? existing.crons;
const timezone = input.timezone ?? existing.timezone;
const webhookEnabled = input.webhookEnabled ?? existing.webhookEnabled;
if (crons.length === 0 && !webhookEnabled) {
throw new ORPCError("BAD_REQUEST", {
message: "Add a schedule or webhook trigger",
});
}
if (hasMixedOneShotSchedule(crons)) {
throw new ORPCError("BAD_REQUEST", {
message: "A one-time schedule can't be combined with other schedules.",
Expand All @@ -1754,7 +1803,9 @@ export function createRouter(deps: RouterDeps) {
JSON.stringify(input.crons) !== JSON.stringify(existing.crons)) ||
(input.timezone !== undefined && input.timezone !== existing.timezone);
const recalculatedNextRunAt =
!isOneShotRoutineCrons(crons) && (scheduleChanged || (active && !existing.nextRunAt))
crons.length > 0 &&
!isOneShotRoutineCrons(crons) &&
(scheduleChanged || (active && !existing.nextRunAt))
? nextRoutineDate(crons, timezone)
: null;
let armedOneShotAt: Date | null = null;
Expand All @@ -1778,9 +1829,11 @@ export function createRouter(deps: RouterDeps) {
}
const nextRunAt = !active
? null
: isOneShotRoutineCrons(crons)
? (armedOneShotAt ?? existing.nextRunAt)
: (recalculatedNextRunAt ?? existing.nextRunAt);
: crons.length === 0
? null
: isOneShotRoutineCrons(crons)
? (armedOneShotAt ?? existing.nextRunAt)
: (recalculatedNextRunAt ?? existing.nextRunAt);
const row = await deps.prisma.routine.update({
where: { id: existing.id },
data: {
Expand All @@ -1790,6 +1843,7 @@ export function createRouter(deps: RouterDeps) {
timezone: input.timezone,
active: input.active,
notify: input.notify,
webhookEnabled: input.webhookEnabled,
nextRunAt,
},
});
Expand Down Expand Up @@ -3414,6 +3468,7 @@ function mapRoutine(row: {
timezone: string;
active: boolean;
notify: boolean;
webhookEnabled: boolean;
lastRunAt: Date | null;
nextRunAt: Date | null;
createdAt: Date;
Expand All @@ -3427,6 +3482,7 @@ function mapRoutine(row: {
timezone: row.timezone,
active: row.active,
notify: row.notify,
webhookEnabled: row.webhookEnabled,
lastRunAt: row.lastRunAt?.toISOString() ?? null,
nextRunAt: row.nextRunAt?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
Expand Down
Loading
Loading