Skip to content

Commit

Permalink
feat: command interface
Browse files Browse the repository at this point in the history
  • Loading branch information
whilefoo committed Nov 15, 2024
1 parent 004e173 commit eeae4e6
Show file tree
Hide file tree
Showing 9 changed files with 127 additions and 43 deletions.
Binary file modified bun.lockb
Binary file not shown.
50 changes: 36 additions & 14 deletions src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { customOctokit } from "./octokit";
import { sanitizeMetadata } from "./util";
import { verifySignature } from "./signature";
import { KERNEL_PUBLIC_KEY } from "./constants";
import { jsonType } from "./types/util";
import { commandCallSchema } from "./types/command";

config();

Expand All @@ -18,29 +20,34 @@ interface Options {
postCommentOnError?: boolean;
settingsSchema?: TAnySchema;
envSchema?: TAnySchema;
commandSchema?: TAnySchema;
kernelPublicKey?: string;
disableSignatureVerification?: boolean; // only use for local development
}

const inputSchema = T.Object({
stateId: T.String(),
eventName: T.String(),
eventPayload: T.String(),
eventPayload: jsonType(T.Record(T.String(), T.Any())),
command: jsonType(commandCallSchema),
authToken: T.String(),
settings: T.String(),
settings: jsonType(T.Record(T.String(), T.Any())),
ref: T.String(),
signature: T.String(),
});

export async function createActionsPlugin<TConfig = unknown, TEnv = unknown, TSupportedEvents extends WebhookEventName = WebhookEventName>(
handler: (context: Context<TConfig, TEnv, TSupportedEvents>) => Promise<Record<string, unknown> | undefined>,
export async function createActionsPlugin<TConfig = unknown, TEnv = unknown, TCommand = unknown, TSupportedEvents extends WebhookEventName = WebhookEventName>(
handler: (context: Context<TConfig, TEnv, TCommand, TSupportedEvents>) => Promise<Record<string, unknown> | undefined>,
options?: Options
) {
const pluginOptions = {
logLevel: options?.logLevel ?? LOG_LEVEL.INFO,
postCommentOnError: options?.postCommentOnError ?? true,
settingsSchema: options?.settingsSchema,
envSchema: options?.envSchema,
commandSchema: options?.commandSchema,
kernelPublicKey: options?.kernelPublicKey ?? KERNEL_PUBLIC_KEY,
disableSignatureVerification: options?.disableSignatureVerification || false,
};

const pluginGithubToken = process.env.PLUGIN_GITHUB_TOKEN;
Expand All @@ -49,6 +56,13 @@ export async function createActionsPlugin<TConfig = unknown, TEnv = unknown, TSu
return;
}

const body = github.context.payload.inputs;
const signature = body.signature;
if (!pluginOptions.disableSignatureVerification && !(await verifySignature(pluginOptions.kernelPublicKey, body, signature))) {
core.setFailed(`Error: Invalid signature`);
return;
}

const inputPayload = github.context.payload.inputs;
const inputSchemaErrors = [...Value.Errors(inputSchema, inputPayload)];
if (inputSchemaErrors.length) {
Expand All @@ -57,22 +71,17 @@ export async function createActionsPlugin<TConfig = unknown, TEnv = unknown, TSu
return;
}
const inputs = Value.Decode(inputSchema, inputPayload);
const signature = inputs.signature;
if (!(await verifySignature(pluginOptions.kernelPublicKey, inputs, signature))) {
core.setFailed(`Error: Invalid signature`);
return;
}

let config: TConfig;
if (pluginOptions.settingsSchema) {
try {
config = Value.Decode(pluginOptions.settingsSchema, Value.Default(pluginOptions.settingsSchema, JSON.parse(inputs.settings)));
config = Value.Decode(pluginOptions.settingsSchema, Value.Default(pluginOptions.settingsSchema, inputs.settings));
} catch (e) {
console.dir(...Value.Errors(pluginOptions.settingsSchema, JSON.parse(inputs.settings)), { depth: null });
console.dir(...Value.Errors(pluginOptions.settingsSchema, inputs.settings), { depth: null });
throw e;
}
} else {
config = JSON.parse(inputs.settings) as TConfig;
config = inputs.settings as TConfig;
}

let env: TEnv;
Expand All @@ -87,9 +96,22 @@ export async function createActionsPlugin<TConfig = unknown, TEnv = unknown, TSu
env = process.env as TEnv;
}

const context: Context<TConfig, TEnv, TSupportedEvents> = {
let command: TCommand | null = null;
if (inputs.command && pluginOptions.commandSchema) {
try {
command = Value.Decode(pluginOptions.commandSchema, Value.Default(pluginOptions.commandSchema, inputs.command));
} catch (e) {
console.dir(...Value.Errors(pluginOptions.commandSchema, inputs.command), { depth: null });
throw e;
}
} else if (inputs.command) {
command = inputs.command as TCommand;
}

const context: Context<TConfig, TEnv, TCommand, TSupportedEvents> = {
eventName: inputs.eventName as TSupportedEvents,
payload: JSON.parse(inputs.eventPayload),
payload: inputs.eventPayload,
command: command,
octokit: new customOctokit({ auth: inputs.authToken }),
config: config,
env: env,
Expand Down
3 changes: 2 additions & 1 deletion src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ import { EmitterWebhookEvent as WebhookEvent, EmitterWebhookEventName as Webhook
import { Logs } from "@ubiquity-os/ubiquity-os-logger";
import { customOctokit } from "./octokit";

export interface Context<TConfig = unknown, TEnv = unknown, TSupportedEvents extends WebhookEventName = WebhookEventName> {
export interface Context<TConfig = unknown, TEnv = unknown, TCommand = unknown, TSupportedEvents extends WebhookEventName = WebhookEventName> {
eventName: TSupportedEvents;
payload: {
[K in TSupportedEvents]: K extends WebhookEventName ? WebhookEvent<K> : never;
}[TSupportedEvents]["payload"];
command: TCommand | null;
octokit: InstanceType<typeof customOctokit>;
config: TConfig;
env: TEnv;
Expand Down
31 changes: 21 additions & 10 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,27 +18,23 @@ interface Options {
postCommentOnError?: boolean;
settingsSchema?: TAnySchema;
envSchema?: TAnySchema;
commandSchema?: TAnySchema;
bypassSignatureVerification?: boolean;
}

const inputSchema = T.Object({
stateId: T.String(),
eventName: T.String(),
eventPayload: T.Record(T.String(), T.Any()),
command: T.Union([T.Null(), T.Object({ name: T.String(), parameters: T.Unknown() })]),
authToken: T.String(),
settings: T.Record(T.String(), T.Any()),
ref: T.String(),
signature: T.String(),
bypassSignatureVerification: T.Optional(
T.Boolean({
default: false,
description: "Bypass signature verification (caution: only use this if you know what you're doing)",
})
),
});

export function createPlugin<TConfig = unknown, TEnv = unknown, TSupportedEvents extends WebhookEventName = WebhookEventName>(
handler: (context: Context<TConfig, TEnv, TSupportedEvents>) => Promise<Record<string, unknown> | undefined>,
export function createPlugin<TConfig = unknown, TEnv = unknown, TCommand = unknown, TSupportedEvents extends WebhookEventName = WebhookEventName>(
handler: (context: Context<TConfig, TEnv, TCommand, TSupportedEvents>) => Promise<Record<string, unknown> | undefined>,
manifest: Manifest,
options?: Options
) {
Expand All @@ -48,6 +44,8 @@ export function createPlugin<TConfig = unknown, TEnv = unknown, TSupportedEvents
postCommentOnError: options?.postCommentOnError ?? true,
settingsSchema: options?.settingsSchema,
envSchema: options?.envSchema,
commandSchema: options?.commandSchema,
bypassSignatureVerification: options?.bypassSignatureVerification || false,
};

const app = new Hono();
Expand All @@ -69,7 +67,7 @@ export function createPlugin<TConfig = unknown, TEnv = unknown, TSupportedEvents
}
const inputs = Value.Decode(inputSchema, body);
const signature = inputs.signature;
if (!options?.bypassSignatureVerification && !(await verifySignature(pluginOptions.kernelPublicKey, inputs, signature))) {
if (!pluginOptions.bypassSignatureVerification && !(await verifySignature(pluginOptions.kernelPublicKey, inputs, signature))) {
throw new HTTPException(400, { message: "Invalid signature" });
}

Expand Down Expand Up @@ -98,9 +96,22 @@ export function createPlugin<TConfig = unknown, TEnv = unknown, TSupportedEvents
env = ctx.env as TEnv;
}

const context: Context<TConfig, TEnv, TSupportedEvents> = {
let command: TCommand | null = null;
if (inputs.command && pluginOptions.commandSchema) {
try {
command = Value.Decode(pluginOptions.commandSchema, Value.Default(pluginOptions.commandSchema, inputs.command));
} catch (e) {
console.dir(...Value.Errors(pluginOptions.commandSchema, inputs.command), { depth: null });
throw e;
}
} else if (inputs.command) {
command = inputs.command as TCommand;
}

const context: Context<TConfig, TEnv, TCommand, TSupportedEvents> = {
eventName: inputs.eventName as TSupportedEvents,
payload: inputs.eventPayload,
command: command,
octokit: new customOctokit({ auth: inputs.authToken }),
config: config,
env: env,
Expand Down
2 changes: 2 additions & 0 deletions src/signature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ interface Inputs {
authToken: unknown;
settings: unknown;
ref: unknown;
command: unknown;
}

export async function verifySignature(publicKeyPem: string, inputs: Inputs, signature: string) {
Expand All @@ -16,6 +17,7 @@ export async function verifySignature(publicKeyPem: string, inputs: Inputs, sign
settings: inputs.settings,
authToken: inputs.authToken,
ref: inputs.ref,
command: inputs.command,
};
const pemContents = publicKeyPem.replace("-----BEGIN PUBLIC KEY-----", "").replace("-----END PUBLIC KEY-----", "").trim();
const binaryDer = Uint8Array.from(atob(pemContents), (c) => c.charCodeAt(0));
Expand Down
5 changes: 5 additions & 0 deletions src/types/command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { StaticDecode, Type as T } from "@sinclair/typebox";

export const commandCallSchema = T.Union([T.Null(), T.Object({ name: T.String(), parameters: T.Unknown() })]);

export type CommandCall = StaticDecode<typeof commandCallSchema>;
5 changes: 4 additions & 1 deletion src/types/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,19 @@ import { emitterEventNames } from "@octokit/webhooks";
export const runEvent = T.Union(emitterEventNames.map((o) => T.Literal(o)));

export const commandSchema = T.Object({
name: T.String({ minLength: 1 }),
description: T.String({ minLength: 1 }),
"ubiquity:example": T.String({ minLength: 1 }),
parameters: T.Optional(T.Record(T.String(), T.Any())),
});

export const manifestSchema = T.Object({
name: T.String({ minLength: 1 }),
description: T.Optional(T.String({ default: "" })),
commands: T.Optional(T.Record(T.String(), commandSchema, { default: {} })),
commands: T.Optional(T.Array(commandSchema, { default: [] })),
"ubiquity:listeners": T.Optional(T.Array(runEvent, { default: [] })),
configuration: T.Optional(T.Record(T.String(), T.Any(), { default: {} })),
skipBotEvents: T.Optional(T.Boolean({ default: true })),
});

export type Manifest = Static<typeof manifestSchema>;
11 changes: 11 additions & 0 deletions src/types/util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Type, TAnySchema } from "@sinclair/typebox";
import { Value } from "@sinclair/typebox/value";

export function jsonType<TSchema extends TAnySchema>(type: TSchema) {
return Type.Transform(Type.String())
.Decode((value) => {
const parsed = JSON.parse(value);
return Value.Decode<TSchema>(type, Value.Default(type, parsed));
})
.Encode((value) => JSON.stringify(value));
}
Loading

0 comments on commit eeae4e6

Please sign in to comment.