@frockbot/plugin-package-publisher 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/shared.ts ADDED
@@ -0,0 +1,309 @@
1
+ export type PackageCheckStatus = "passed" | "failed";
2
+
3
+ export interface PackageCheckV1 {
4
+ name: string;
5
+ status: PackageCheckStatus;
6
+ }
7
+
8
+ export interface PackageCandidateV1 {
9
+ source: string;
10
+ applicationArtifact: string;
11
+ checks: PackageCheckV1[];
12
+ }
13
+
14
+ export interface PublishPackageCommandV1 {
15
+ schemaVersion: 1;
16
+ commandId: string;
17
+ expectedRevision: number;
18
+ candidate: PackageCandidateV1;
19
+ }
20
+
21
+ export interface RollbackPackageCommandV1 {
22
+ schemaVersion: 1;
23
+ commandId: string;
24
+ expectedRevision: number;
25
+ packageRevision: number;
26
+ }
27
+
28
+ export interface PackageRevisionV1 {
29
+ packageRevision: number;
30
+ applicationHash: string;
31
+ publishedAt: string;
32
+ checks: PackageCheckV1[];
33
+ }
34
+
35
+ export interface PackageRevisionHistoryV1 {
36
+ schemaVersion: 1;
37
+ revision: number;
38
+ activePackageRevision?: number;
39
+ revisions: PackageRevisionV1[];
40
+ }
41
+
42
+ export interface PackagePublicationReceiptV1 {
43
+ schemaVersion: 1;
44
+ commandId: string;
45
+ status: "active" | "failed";
46
+ revision: number;
47
+ packageRevision?: number;
48
+ applicationHash?: string;
49
+ failure?: string;
50
+ }
51
+
52
+ export class PackagePublisherDecodeError extends Error {
53
+ override readonly name = "PackagePublisherDecodeError";
54
+ }
55
+
56
+ export class PackagePublisherConflictError extends Error {
57
+ override readonly name = "PackagePublisherConflictError";
58
+ constructor(readonly currentRevision: number) {
59
+ super(`package revision is ${currentRevision}`);
60
+ }
61
+ }
62
+
63
+ function record(value: unknown, label: string): Record<string, unknown> {
64
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
65
+ throw new PackagePublisherDecodeError(`${label} must be an object`);
66
+ }
67
+ return value as Record<string, unknown>;
68
+ }
69
+
70
+ function exactKeys(
71
+ value: Record<string, unknown>,
72
+ keys: readonly string[],
73
+ label: string,
74
+ ): void {
75
+ const expected = new Set(keys);
76
+ if (
77
+ Object.keys(value).length !== keys.length ||
78
+ Object.keys(value).some((key) => !expected.has(key))
79
+ ) {
80
+ throw new PackagePublisherDecodeError(
81
+ `${label} has unknown or missing fields`,
82
+ );
83
+ }
84
+ }
85
+
86
+ function identifier(value: unknown, label: string): string {
87
+ if (
88
+ typeof value !== "string" ||
89
+ value.length < 1 ||
90
+ value.length > 128 ||
91
+ !/^[A-Za-z0-9._:-]+$/.test(value)
92
+ ) {
93
+ throw new PackagePublisherDecodeError(`${label} is invalid`);
94
+ }
95
+ return value;
96
+ }
97
+
98
+ function revision(value: unknown, label: string): number {
99
+ if (!Number.isSafeInteger(value) || (value as number) < 0) {
100
+ throw new PackagePublisherDecodeError(`${label} is invalid`);
101
+ }
102
+ return value as number;
103
+ }
104
+
105
+ function decodeChecks(value: unknown): PackageCheckV1[] {
106
+ if (!Array.isArray(value) || value.length < 1 || value.length > 100) {
107
+ throw new PackagePublisherDecodeError("checks must not be empty");
108
+ }
109
+ return value.map((entry, index) => {
110
+ const check = record(entry, `checks[${index}]`);
111
+ exactKeys(check, ["name", "status"], `checks[${index}]`);
112
+ const name = identifier(check.name, `checks[${index}].name`);
113
+ if (check.status !== "passed" && check.status !== "failed") {
114
+ throw new PackagePublisherDecodeError(
115
+ `checks[${index}].status is invalid`,
116
+ );
117
+ }
118
+ return { name, status: check.status };
119
+ });
120
+ }
121
+
122
+ export function decodePublishPackageCommandV1(
123
+ value: unknown,
124
+ ): PublishPackageCommandV1 {
125
+ const command = record(value, "publish command");
126
+ exactKeys(
127
+ command,
128
+ ["schemaVersion", "commandId", "expectedRevision", "candidate"],
129
+ "publish command",
130
+ );
131
+ if (command.schemaVersion !== 1) {
132
+ throw new PackagePublisherDecodeError("schemaVersion must be 1");
133
+ }
134
+ const candidate = record(command.candidate, "candidate");
135
+ exactKeys(
136
+ candidate,
137
+ ["source", "applicationArtifact", "checks"],
138
+ "candidate",
139
+ );
140
+ if (
141
+ typeof candidate.source !== "string" ||
142
+ candidate.source.length < 1 ||
143
+ candidate.source.length > 5_000_000
144
+ ) {
145
+ throw new PackagePublisherDecodeError("candidate source is invalid");
146
+ }
147
+ if (
148
+ typeof candidate.applicationArtifact !== "string" ||
149
+ candidate.applicationArtifact.length < 1 ||
150
+ candidate.applicationArtifact.length > 10_000_000
151
+ ) {
152
+ throw new PackagePublisherDecodeError(
153
+ "candidate application artifact is invalid",
154
+ );
155
+ }
156
+ return {
157
+ schemaVersion: 1,
158
+ commandId: identifier(command.commandId, "commandId"),
159
+ expectedRevision: revision(command.expectedRevision, "expectedRevision"),
160
+ candidate: {
161
+ source: candidate.source,
162
+ applicationArtifact: candidate.applicationArtifact,
163
+ checks: decodeChecks(candidate.checks),
164
+ },
165
+ };
166
+ }
167
+
168
+ export function decodePackageRevisionHistoryV1(
169
+ value: unknown,
170
+ ): PackageRevisionHistoryV1 {
171
+ const history = record(value, "package revision history");
172
+ const keys = Object.keys(history);
173
+ const allowed = new Set([
174
+ "schemaVersion",
175
+ "revision",
176
+ "activePackageRevision",
177
+ "revisions",
178
+ ]);
179
+ if (
180
+ keys.some((key) => !allowed.has(key)) ||
181
+ !keys.includes("schemaVersion") ||
182
+ !keys.includes("revision") ||
183
+ !keys.includes("revisions")
184
+ ) {
185
+ throw new PackagePublisherDecodeError(
186
+ "package revision history has unknown or missing fields",
187
+ );
188
+ }
189
+ if (history.schemaVersion !== 1 || !Array.isArray(history.revisions)) {
190
+ throw new PackagePublisherDecodeError(
191
+ "package revision history is invalid",
192
+ );
193
+ }
194
+ const revisions = history.revisions.map((entry, index) => {
195
+ const item = record(entry, `revisions[${index}]`);
196
+ exactKeys(
197
+ item,
198
+ ["packageRevision", "applicationHash", "publishedAt", "checks"],
199
+ `revisions[${index}]`,
200
+ );
201
+ if (
202
+ typeof item.applicationHash !== "string" ||
203
+ !item.applicationHash.startsWith("sha256:") ||
204
+ typeof item.publishedAt !== "string" ||
205
+ !Number.isFinite(Date.parse(item.publishedAt))
206
+ ) {
207
+ throw new PackagePublisherDecodeError(`revisions[${index}] is invalid`);
208
+ }
209
+ const packageRevision = revision(
210
+ item.packageRevision,
211
+ `revisions[${index}].packageRevision`,
212
+ );
213
+ if (packageRevision < 1) {
214
+ throw new PackagePublisherDecodeError(
215
+ `revisions[${index}].packageRevision is invalid`,
216
+ );
217
+ }
218
+ return {
219
+ packageRevision,
220
+ applicationHash: item.applicationHash,
221
+ publishedAt: item.publishedAt,
222
+ checks: decodeChecks(item.checks),
223
+ };
224
+ });
225
+ const activePackageRevision =
226
+ history.activePackageRevision === undefined
227
+ ? undefined
228
+ : revision(history.activePackageRevision, "activePackageRevision");
229
+ if (activePackageRevision !== undefined && activePackageRevision < 1) {
230
+ throw new PackagePublisherDecodeError("activePackageRevision is invalid");
231
+ }
232
+ return {
233
+ schemaVersion: 1,
234
+ revision: revision(history.revision, "revision"),
235
+ ...(activePackageRevision === undefined ? {} : { activePackageRevision }),
236
+ revisions,
237
+ };
238
+ }
239
+
240
+ export function decodePackagePublicationReceiptV1(
241
+ value: unknown,
242
+ ): PackagePublicationReceiptV1 {
243
+ const receipt = record(value, "publication receipt");
244
+ const allowed = new Set([
245
+ "schemaVersion",
246
+ "commandId",
247
+ "status",
248
+ "revision",
249
+ "packageRevision",
250
+ "applicationHash",
251
+ "failure",
252
+ ]);
253
+ if (
254
+ Object.keys(receipt).some((key) => !allowed.has(key)) ||
255
+ receipt.schemaVersion !== 1 ||
256
+ (receipt.status !== "active" && receipt.status !== "failed")
257
+ ) {
258
+ throw new PackagePublisherDecodeError("publication receipt is invalid");
259
+ }
260
+ const result: PackagePublicationReceiptV1 = {
261
+ schemaVersion: 1,
262
+ commandId: identifier(receipt.commandId, "commandId"),
263
+ status: receipt.status,
264
+ revision: revision(receipt.revision, "revision"),
265
+ };
266
+ if (receipt.packageRevision !== undefined) {
267
+ result.packageRevision = revision(
268
+ receipt.packageRevision,
269
+ "packageRevision",
270
+ );
271
+ }
272
+ if (receipt.applicationHash !== undefined) {
273
+ if (typeof receipt.applicationHash !== "string") {
274
+ throw new PackagePublisherDecodeError("applicationHash is invalid");
275
+ }
276
+ result.applicationHash = receipt.applicationHash;
277
+ }
278
+ if (receipt.failure !== undefined) {
279
+ if (typeof receipt.failure !== "string") {
280
+ throw new PackagePublisherDecodeError("failure is invalid");
281
+ }
282
+ result.failure = receipt.failure;
283
+ }
284
+ return result;
285
+ }
286
+
287
+ export function decodeRollbackPackageCommandV1(
288
+ value: unknown,
289
+ ): RollbackPackageCommandV1 {
290
+ const command = record(value, "rollback command");
291
+ exactKeys(
292
+ command,
293
+ ["schemaVersion", "commandId", "expectedRevision", "packageRevision"],
294
+ "rollback command",
295
+ );
296
+ if (command.schemaVersion !== 1) {
297
+ throw new PackagePublisherDecodeError("schemaVersion must be 1");
298
+ }
299
+ const packageRevision = revision(command.packageRevision, "packageRevision");
300
+ if (packageRevision < 1) {
301
+ throw new PackagePublisherDecodeError("packageRevision is invalid");
302
+ }
303
+ return {
304
+ schemaVersion: 1,
305
+ commandId: identifier(command.commandId, "commandId"),
306
+ expectedRevision: revision(command.expectedRevision, "expectedRevision"),
307
+ packageRevision,
308
+ };
309
+ }
@@ -0,0 +1,288 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createPackagePublisherUserContribution } from "./user.js";
3
+ import type { PackagePublisherTransaction } from "./user.js";
4
+
5
+ class MemoryStorage implements PackagePublisherTransaction {
6
+ readonly values = new Map<string, unknown>();
7
+ readonly alarmTransactions: boolean[] = [];
8
+ private transactionActive = false;
9
+
10
+ constructor(
11
+ private readonly onAlarm?: (scheduledTime: number | Date) => void,
12
+ ) {}
13
+
14
+ get<T>(key: string): Promise<T | undefined> {
15
+ return Promise.resolve(
16
+ structuredClone(this.values.get(key)) as T | undefined,
17
+ );
18
+ }
19
+
20
+ put<T>(key: string, value: T): Promise<void> {
21
+ this.values.set(key, structuredClone(value));
22
+ return Promise.resolve();
23
+ }
24
+
25
+ async transaction<T>(
26
+ callback: (storage: PackagePublisherTransaction) => Promise<T>,
27
+ ): Promise<T> {
28
+ this.transactionActive = true;
29
+ try {
30
+ return await callback(this);
31
+ } finally {
32
+ this.transactionActive = false;
33
+ }
34
+ }
35
+
36
+ setAlarm(scheduledTime: number | Date): Promise<void> {
37
+ this.alarmTransactions.push(this.transactionActive);
38
+ this.onAlarm?.(scheduledTime);
39
+ return Promise.resolve();
40
+ }
41
+ }
42
+
43
+ const candidate = {
44
+ source: "source archive",
45
+ applicationArtifact: "export default { fetch() {} }",
46
+ checks: [{ name: "test", status: "passed" as const }],
47
+ };
48
+
49
+ describe("Package Publisher User contribution", () => {
50
+ test("publishes a verified immutable revision and replays the command", async () => {
51
+ const effects: string[] = [];
52
+ const storage = new MemoryStorage((scheduledTime) => {
53
+ effects.push(`scheduled:${new Date(scheduledTime).toISOString()}`);
54
+ });
55
+ const contribution = createPackagePublisherUserContribution({
56
+ storage,
57
+ now: () => new Date("2026-09-01T00:00:00.000Z"),
58
+ hash: () => Promise.resolve("sha256:artifact-one"),
59
+ storeAndVerify: async ({ applicationHash }) => {
60
+ effects.push(applicationHash);
61
+ },
62
+ });
63
+
64
+ const command = {
65
+ schemaVersion: 1 as const,
66
+ commandId: "publish-1",
67
+ expectedRevision: 0,
68
+ candidate,
69
+ };
70
+ const first = await contribution.publish("user-1", command);
71
+ const replay = await contribution.publish("user-1", command);
72
+
73
+ expect(first).toEqual({
74
+ schemaVersion: 1,
75
+ commandId: "publish-1",
76
+ status: "active",
77
+ revision: 1,
78
+ packageRevision: 1,
79
+ applicationHash: "sha256:artifact-one",
80
+ });
81
+ expect(replay).toEqual(first);
82
+ expect(effects).toEqual([
83
+ "scheduled:2026-09-01T00:01:00.000Z",
84
+ "sha256:artifact-one",
85
+ ]);
86
+ expect(storage.alarmTransactions).toEqual([true]);
87
+ expect(await contribution.read()).toEqual({
88
+ schemaVersion: 1,
89
+ revision: 1,
90
+ activePackageRevision: 1,
91
+ revisions: [
92
+ {
93
+ packageRevision: 1,
94
+ applicationHash: "sha256:artifact-one",
95
+ publishedAt: "2026-09-01T00:00:00.000Z",
96
+ checks: [{ name: "test", status: "passed" }],
97
+ },
98
+ ],
99
+ });
100
+ });
101
+
102
+ test("resumes a durably pending publication after its host is reconstructed", async () => {
103
+ const storage = new MemoryStorage();
104
+ const order: string[] = [];
105
+ await storage.put("package-publisher:state:v1", {
106
+ schemaVersion: 1,
107
+ revision: 0,
108
+ revisions: [],
109
+ pending: {
110
+ userId: "user-1",
111
+ commandId: "publish-1",
112
+ fingerprint: "fingerprint",
113
+ packageRevision: 1,
114
+ applicationHash: "sha256:artifact-one",
115
+ publishedAt: "2026-09-01T00:00:00.000Z",
116
+ candidate,
117
+ },
118
+ });
119
+ const contribution = createPackagePublisherUserContribution({
120
+ storage,
121
+ hash: () => Promise.resolve("sha256:artifact-one"),
122
+ storeAndVerify: () => {
123
+ order.push("verified");
124
+ return Promise.resolve();
125
+ },
126
+ });
127
+
128
+ const receipt = await contribution.recover();
129
+
130
+ expect(order).toEqual(["verified"]);
131
+ expect(receipt).toMatchObject({ status: "active", packageRevision: 1 });
132
+ expect((await contribution.read()).activePackageRevision).toBe(1);
133
+ expect(await contribution.recover()).toBeUndefined();
134
+ });
135
+
136
+ test("deduplicates concurrent delivery of a pending publication effect", async () => {
137
+ const storage = new MemoryStorage();
138
+ let releaseVerification: (() => void) | undefined;
139
+ const verificationStarted = Promise.withResolvers<void>();
140
+ const verificationReleased = new Promise<void>((resolve) => {
141
+ releaseVerification = resolve;
142
+ });
143
+ let effects = 0;
144
+ const contribution = createPackagePublisherUserContribution({
145
+ storage,
146
+ hash: () => Promise.resolve("sha256:artifact-one"),
147
+ storeAndVerify: async () => {
148
+ effects += 1;
149
+ verificationStarted.resolve();
150
+ await verificationReleased;
151
+ },
152
+ });
153
+ const command = {
154
+ schemaVersion: 1 as const,
155
+ commandId: "publish-concurrent",
156
+ expectedRevision: 0,
157
+ candidate,
158
+ };
159
+
160
+ const publication = contribution.publish("user-1", command);
161
+ await verificationStarted.promise;
162
+ const recovery = contribution.recover();
163
+ releaseVerification?.();
164
+ const [published, recovered] = await Promise.all([publication, recovery]);
165
+
166
+ expect(recovered).toEqual(published);
167
+ expect(effects).toBe(1);
168
+ });
169
+
170
+ test("rejects malformed publication state at the durable storage seam", async () => {
171
+ const storage = new MemoryStorage();
172
+ await storage.put("package-publisher:state:v1", {
173
+ schemaVersion: 1,
174
+ revision: 0,
175
+ revisions: [],
176
+ unknown: true,
177
+ });
178
+ const contribution = createPackagePublisherUserContribution({
179
+ storage,
180
+ hash: () => Promise.resolve("sha256:unused"),
181
+ storeAndVerify: () => Promise.resolve(),
182
+ });
183
+
184
+ let failure: unknown;
185
+ try {
186
+ await contribution.read();
187
+ } catch (error) {
188
+ failure = error;
189
+ }
190
+ expect(failure).toBeInstanceOf(Error);
191
+ expect((failure as Error).message).toContain("unknown or missing fields");
192
+ });
193
+
194
+ test("records verification failure without replacing the active revision", async () => {
195
+ const storage = new MemoryStorage();
196
+ let shouldFail = false;
197
+ const contribution = createPackagePublisherUserContribution({
198
+ storage,
199
+ hash: ({ applicationArtifact }) =>
200
+ Promise.resolve(`sha256:${applicationArtifact.length}`),
201
+ storeAndVerify: () =>
202
+ shouldFail
203
+ ? Promise.reject(new Error("candidate did not become healthy"))
204
+ : Promise.resolve(),
205
+ });
206
+
207
+ await contribution.publish("user-1", {
208
+ schemaVersion: 1,
209
+ commandId: "publish-1",
210
+ expectedRevision: 0,
211
+ candidate,
212
+ });
213
+ shouldFail = true;
214
+ const failed = await contribution.publish("user-1", {
215
+ schemaVersion: 1,
216
+ commandId: "publish-2",
217
+ expectedRevision: 1,
218
+ candidate: {
219
+ ...candidate,
220
+ applicationArtifact: "broken application",
221
+ },
222
+ });
223
+
224
+ expect(failed.status).toBe("failed");
225
+ expect(failed.failure).toBe("candidate did not become healthy");
226
+ expect((await contribution.read()).activePackageRevision).toBe(1);
227
+ });
228
+
229
+ test("blocks failed checks and rolls every Bot back by changing the shared active revision", async () => {
230
+ const contribution = createPackagePublisherUserContribution({
231
+ storage: new MemoryStorage(),
232
+ hash: ({ applicationArtifact }) =>
233
+ Promise.resolve(`sha256:${applicationArtifact.length}`),
234
+ storeAndVerify: () => Promise.resolve(),
235
+ });
236
+
237
+ let checkFailure: unknown;
238
+ try {
239
+ await contribution.publish("user-1", {
240
+ schemaVersion: 1,
241
+ commandId: "publish-failed-tests",
242
+ expectedRevision: 0,
243
+ candidate: {
244
+ ...candidate,
245
+ checks: [{ name: "test", status: "failed" }],
246
+ },
247
+ });
248
+ } catch (error) {
249
+ checkFailure = error;
250
+ }
251
+ expect(checkFailure).toBeInstanceOf(Error);
252
+ expect((checkFailure as Error).message).toContain(
253
+ "all required checks must pass",
254
+ );
255
+
256
+ const first = await contribution.publish("user-1", {
257
+ schemaVersion: 1,
258
+ commandId: "publish-1",
259
+ expectedRevision: 0,
260
+ candidate,
261
+ });
262
+ await contribution.publish("user-1", {
263
+ schemaVersion: 1,
264
+ commandId: "publish-2",
265
+ expectedRevision: first.revision,
266
+ candidate: {
267
+ ...candidate,
268
+ applicationArtifact: "second artifact",
269
+ },
270
+ });
271
+ const rollback = await contribution.rollback({
272
+ schemaVersion: 1,
273
+ commandId: "rollback-1",
274
+ expectedRevision: 2,
275
+ packageRevision: 1,
276
+ });
277
+
278
+ expect(rollback).toEqual({
279
+ schemaVersion: 1,
280
+ commandId: "rollback-1",
281
+ status: "active",
282
+ revision: 3,
283
+ packageRevision: 1,
284
+ applicationHash: first.applicationHash,
285
+ });
286
+ expect((await contribution.read()).activePackageRevision).toBe(1);
287
+ });
288
+ });