Skip to content

Commit

Permalink
Merge pull request #31 from ubiquity-os/development
Browse files Browse the repository at this point in the history
Merge development into main
  • Loading branch information
gentlementlegen authored Nov 23, 2024
2 parents 62b2728 + 9c26270 commit a8615df
Show file tree
Hide file tree
Showing 12 changed files with 169 additions and 50 deletions.
Binary file modified bun.lockb
Binary file not shown.
12 changes: 11 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
],
"signature": [
"dist/signature.d.ts"
],
"octokit": [
"dist/octokit.d.ts"
]
}
},
Expand All @@ -44,6 +47,11 @@
"types": "./dist/signature.d.ts",
"import": "./dist/signature.mjs",
"require": "./dist/signature.js"
},
"./octokit": {
"types": "./dist/octokit.d.ts",
"import": "./dist/octokit.mjs",
"require": "./dist/octokit.js"
}
},
"files": [
Expand Down Expand Up @@ -79,11 +87,13 @@
"@octokit/rest": "^21.0.2",
"@octokit/types": "^13.6.1",
"@octokit/webhooks": "^13.3.0",
"@sinclair/typebox": "^0.33.21",
"@ubiquity-os/ubiquity-os-logger": "^1.3.2",
"dotenv": "^16.4.5",
"hono": "^4.6.9"
},
"peerDependencies": {
"@sinclair/typebox": "0.34.3"
},
"devDependencies": {
"@commitlint/cli": "^18.6.1",
"@commitlint/config-conventional": "^18.6.2",
Expand Down
56 changes: 41 additions & 15 deletions src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ 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";
import { HandlerReturn } from "./types/sdk";

config();

Expand All @@ -18,29 +21,37 @@ interface Options {
postCommentOnError?: boolean;
settingsSchema?: TAnySchema;
envSchema?: TAnySchema;
commandSchema?: TAnySchema;
kernelPublicKey?: string;
/**
* @deprecated This disables signature verification - only for local development
*/
bypassSignatureVerification?: boolean;
}

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>) => HandlerReturn,
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,
bypassSignatureVerification: options?.bypassSignatureVerification || false,
};

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

const body = github.context.payload.inputs;
const signature = body.signature;
if (!pluginOptions.bypassSignatureVerification && !(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 +75,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 +100,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 Expand Up @@ -138,7 +164,7 @@ function getGithubWorkflowRunUrl() {
return `${github.context.payload.repository?.html_url}/actions/runs/${github.context.runId}`;
}

async function returnDataToKernel(repoToken: string, stateId: string, output: object | undefined) {
async function returnDataToKernel(repoToken: string, stateId: string, output: HandlerReturn) {
const octokit = new customOctokit({ auth: repoToken });
await octokit.rest.repos.createDispatchEvent({
owner: github.context.repo.owner,
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
43 changes: 29 additions & 14 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,34 +11,34 @@ import { Context } from "./context";
import { customOctokit } from "./octokit";
import { verifySignature } from "./signature";
import { Manifest } from "./types/manifest";
import { HandlerReturn } from "./types/sdk";

interface Options {
kernelPublicKey?: string;
logLevel?: LogLevel;
postCommentOnError?: boolean;
settingsSchema?: TAnySchema;
envSchema?: TAnySchema;
commandSchema?: TAnySchema;
/**
* @deprecated This disables signature verification - only for local development
*/
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>) => HandlerReturn,
manifest: Manifest,
options?: Options
) {
Expand All @@ -48,6 +48,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 @@ -64,12 +66,12 @@ export function createPlugin<TConfig = unknown, TEnv = unknown, TSupportedEvents
const body = await ctx.req.json();
const inputSchemaErrors = [...Value.Errors(inputSchema, body)];
if (inputSchemaErrors.length) {
console.dir(inputSchemaErrors, { depth: null });
console.log(inputSchemaErrors, { depth: null });
throw new HTTPException(400, { message: "Invalid body" });
}
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 All @@ -78,7 +80,7 @@ export function createPlugin<TConfig = unknown, TEnv = unknown, TSupportedEvents
try {
config = Value.Decode(pluginOptions.settingsSchema, Value.Default(pluginOptions.settingsSchema, inputs.settings));
} catch (e) {
console.dir(...Value.Errors(pluginOptions.settingsSchema, inputs.settings), { depth: null });
console.log(...Value.Errors(pluginOptions.settingsSchema, inputs.settings), { depth: null });
throw e;
}
} else {
Expand All @@ -91,16 +93,29 @@ export function createPlugin<TConfig = unknown, TEnv = unknown, TSupportedEvents
try {
env = Value.Decode(pluginOptions.envSchema, Value.Default(pluginOptions.envSchema, honoEnvironment));
} catch (e) {
console.dir(...Value.Errors(pluginOptions.envSchema, honoEnvironment), { depth: null });
console.log(...Value.Errors(pluginOptions.envSchema, honoEnvironment), { depth: null });
throw e;
}
} else {
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.log(...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 All @@ -109,7 +124,7 @@ export function createPlugin<TConfig = unknown, TEnv = unknown, TSupportedEvents

try {
const result = await handler(context);
return ctx.json({ stateId: inputs.stateId, output: result });
return ctx.json({ stateId: inputs.stateId, output: result ?? {} });
} catch (error) {
console.error(error);

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>;
4 changes: 3 additions & 1 deletion src/types/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,16 @@ export const runEvent = T.Union(emitterEventNames.map((o) => T.Literal(o)));
export const commandSchema = T.Object({
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.Record(T.String({ pattern: "^[A-Za-z-_]+$" }), 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>;
2 changes: 2 additions & 0 deletions src/types/sdk.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
type Return = Record<string, unknown> | undefined | void;
export type HandlerReturn = Promise<Return> | Return;
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 a8615df

Please sign in to comment.