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
1 change: 1 addition & 0 deletions packages/contract/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"./domain": "./src/domain.ts",
"./fs": "./src/fs.ts",
"./git": "./src/git.ts",
"./hub": "./src/hub.ts",
"./packages": "./src/packages.ts",
"./skills": "./src/skills.ts",
"./project": "./src/project.ts",
Expand Down
87 changes: 87 additions & 0 deletions packages/contract/src/hub.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { Schema } from "effect";

/** Wire vocabulary between a Pie daemon and a Hub (docs/rfc/pie-hub.md, section 5). */

export const HUB_PROTOCOL_VERSION = 1;

/** Application close code: Hub rejected or revoked this Environment; do not reconnect. */
export const HUB_CLOSE_REVOKED = 4403;

const HubIdentifierSchema = Schema.String.check(Schema.isPattern(/^[a-z][a-z0-9_.-]{0,63}$/));

export const HubEventSchema = Schema.Struct({
version: Schema.Literal(HUB_PROTOCOL_VERSION),
/** `<source>:<vendor delivery id>`; stable across Hub restarts. */
eventId: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(200)),
source: HubIdentifierSchema,
/** The vendor's own event name, plus its action when it has one: `issue_comment.created`. */
type: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(200)),
/** The webhook it arrived on; absent for events injected through Hub's own API. */
webhookId: Schema.optionalKey(Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(64))),
/** Timezone-aware ISO-8601 instants set by Hub. */
receivedAt: Schema.String.check(Schema.isMaxLength(40)),
expiresAt: Schema.String.check(Schema.isMaxLength(40)),
/** The vendor's JSON, unchanged. Size is bounded by Hub's event size limit, not here. */
payload: Schema.Unknown,
});
export type HubEvent = typeof HubEventSchema.Type;

/** Daemon to Hub, first frame after the socket opens. */
export const HubHelloFrameSchema = Schema.Struct({
type: Schema.Literal("hub.hello"),
protocol: Schema.Number.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1)),
environmentId: Schema.String.check(Schema.isUUID()),
/** Optional features the daemon understands; later additions are optional-only. */
features: Schema.optionalKey(Schema.Array(HubIdentifierSchema).check(Schema.isMaxLength(32))),
});
export type HubHelloFrame = typeof HubHelloFrameSchema.Type;

/** Hub to daemon, after a hello Hub accepted: the enrollment is active. */
export const HubWelcomeFrameSchema = Schema.Struct({
type: Schema.Literal("hub.welcome"),
protocol: Schema.Number.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1)),
});
export type HubWelcomeFrame = typeof HubWelcomeFrameSchema.Type;

/** Hub to daemon. `deliveryAttempt` counts sends of this event, starting at 1. */
export const HubEventDeliverFrameSchema = Schema.Struct({
type: Schema.Literal("hub.event.deliver"),
event: HubEventSchema,
deliveryAttempt: Schema.Number.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1)),
});
export type HubEventDeliverFrame = typeof HubEventDeliverFrameSchema.Type;

/** Daemon to Hub. A rejection carries a stable code and nothing else. */
export const HubEventAckFrameSchema = Schema.Union([
Schema.Struct({
type: Schema.Literal("hub.event.ack"),
eventId: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(200)),
status: Schema.Literals(["accepted", "duplicate"]),
}),
Schema.Struct({
type: Schema.Literal("hub.event.ack"),
eventId: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(200)),
status: Schema.Literal("rejected"),
code: Schema.String.check(Schema.isPattern(/^[a-z][a-z0-9_]{0,63}$/)),
}),
]);
export type HubEventAckFrame = typeof HubEventAckFrameSchema.Type;

export const HubPingFrameSchema = Schema.Struct({ type: Schema.Literal("hub.ping") });
export const HubPongFrameSchema = Schema.Struct({ type: Schema.Literal("hub.pong") });

/** Frames Hub sends. */
export const HubServerFrameSchema = Schema.Union([
HubWelcomeFrameSchema,
HubEventDeliverFrameSchema,
HubPingFrameSchema,
]);
export type HubServerFrame = typeof HubServerFrameSchema.Type;

/** Frames the daemon sends. */
export const HubDaemonFrameSchema = Schema.Union([
HubHelloFrameSchema,
HubEventAckFrameSchema,
HubPongFrameSchema,
]);
export type HubDaemonFrame = typeof HubDaemonFrameSchema.Type;
105 changes: 105 additions & 0 deletions packages/contract/test/hub.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { Exit, Schema } from "effect";
import { describe, expect, it } from "vitest";

import {
HUB_CLOSE_REVOKED,
HUB_PROTOCOL_VERSION,
HubDaemonFrameSchema,
HubEventSchema,
HubServerFrameSchema,
} from "../src/hub";

const UUID = "0195b4b3-6dc4-7d41-a9ce-3ab5dcb6cc61";

const accepts = <A>(schema: Schema.ConstraintDecoder<A>, value: unknown): boolean =>
Exit.isSuccess(Schema.decodeUnknownExit(schema)(value));

const event = {
version: HUB_PROTOCOL_VERSION,
eventId: "github:72d3162e-cc78-11e3-81ab-4c9367dc0958",
source: "github",
type: "issue_comment.created",
webhookId: "wh_1",
receivedAt: "2026-10-07T00:00:00.000Z",
expiresAt: "2026-10-08T00:00:00.000Z",
payload: { action: "created", comment: { body: "hi" } },
};

describe("HubEvent", () => {
it("keeps the vendor payload as is and allows hub events without a webhook", () => {
const { webhookId: _webhookId, ...hubEvent } = event;
expect(accepts(HubEventSchema, event)).toBe(true);
expect(accepts(HubEventSchema, { ...hubEvent, source: "hub" })).toBe(true);
expect(accepts(HubEventSchema, { ...event, payload: [1, "x", null] })).toBe(true);
});

it("rejects an unknown version, a bad source, and an empty or oversized id", () => {
expect(accepts(HubEventSchema, { ...event, version: 2 })).toBe(false);
expect(accepts(HubEventSchema, { ...event, source: "GitHub" })).toBe(false);
expect(accepts(HubEventSchema, { ...event, eventId: "" })).toBe(false);
expect(accepts(HubEventSchema, { ...event, eventId: "x".repeat(201) })).toBe(false);
});
});

describe("daemon frames", () => {
it("accepts hello, ack and pong", () => {
expect(
accepts(HubDaemonFrameSchema, {
type: "hub.hello",
protocol: 1,
environmentId: UUID,
features: ["hold"],
}),
).toBe(true);
expect(
accepts(HubDaemonFrameSchema, {
type: "hub.event.ack",
eventId: event.eventId,
status: "accepted",
}),
).toBe(true);
expect(accepts(HubDaemonFrameSchema, { type: "hub.pong" })).toBe(true);
});

it("requires a stable code on a rejection and refuses one elsewhere", () => {
const ack = { type: "hub.event.ack", eventId: event.eventId };
expect(
accepts(HubDaemonFrameSchema, { ...ack, status: "rejected", code: "unsupported_source" }),
).toBe(true);
expect(accepts(HubDaemonFrameSchema, { ...ack, status: "rejected" })).toBe(false);
expect(accepts(HubDaemonFrameSchema, { ...ack, status: "rejected", code: "Not Stable!" })).toBe(
false,
);
expect(accepts(HubDaemonFrameSchema, { ...ack, status: "unknown" })).toBe(false);
});

it("rejects a hello with a non-UUID Environment or a bad protocol", () => {
const hello = { type: "hub.hello", protocol: 1, environmentId: UUID };
expect(accepts(HubDaemonFrameSchema, { ...hello, environmentId: "local" })).toBe(false);
expect(accepts(HubDaemonFrameSchema, { ...hello, protocol: 0 })).toBe(false);
});
});

describe("hub frames", () => {
it("accepts welcome, deliver and ping", () => {
expect(accepts(HubServerFrameSchema, { type: "hub.welcome", protocol: 1 })).toBe(true);
expect(
accepts(HubServerFrameSchema, { type: "hub.event.deliver", event, deliveryAttempt: 1 }),
).toBe(true);
expect(accepts(HubServerFrameSchema, { type: "hub.ping" })).toBe(true);
});

it("rejects frames sent in the wrong direction and a zero delivery attempt", () => {
expect(accepts(HubServerFrameSchema, { type: "hub.pong" })).toBe(false);
expect(accepts(HubDaemonFrameSchema, { type: "hub.ping" })).toBe(false);
expect(
accepts(HubServerFrameSchema, { type: "hub.event.deliver", event, deliveryAttempt: 0 }),
).toBe(false);
});
});

describe("close codes", () => {
it("uses the application range for revocation", () => {
expect(HUB_CLOSE_REVOKED).toBe(4403);
});
});
Loading