@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/user.ts ADDED
@@ -0,0 +1,472 @@
1
+ import type { Plugin } from "cordis";
2
+ import {
3
+ PackagePublisherConflictError,
4
+ PackagePublisherDecodeError,
5
+ decodePackagePublicationReceiptV1,
6
+ decodePackageRevisionHistoryV1,
7
+ decodePublishPackageCommandV1,
8
+ type PackageCandidateV1,
9
+ type PackagePublicationReceiptV1,
10
+ type PackageRevisionHistoryV1,
11
+ type PublishPackageCommandV1,
12
+ type RollbackPackageCommandV1,
13
+ } from "./shared.js";
14
+
15
+ const STATE_KEY = "package-publisher:state:v1";
16
+ const RECEIPT_PREFIX = "package-publisher:receipt:";
17
+ const RECOVERY_DELAY_MS = 60_000;
18
+
19
+ interface PendingPublication {
20
+ userId: string;
21
+ commandId: string;
22
+ fingerprint: string;
23
+ packageRevision: number;
24
+ applicationHash: string;
25
+ publishedAt: string;
26
+ candidate: PackageCandidateV1;
27
+ }
28
+
29
+ interface StoredState extends PackageRevisionHistoryV1 {
30
+ pending?: PendingPublication;
31
+ }
32
+
33
+ interface StoredReceipt {
34
+ fingerprint: string;
35
+ receipt: PackagePublicationReceiptV1;
36
+ }
37
+
38
+ export interface PackagePublisherTransaction {
39
+ get<T>(key: string): Promise<T | undefined>;
40
+ put<T>(key: string, value: T): Promise<void>;
41
+ setAlarm(scheduledTime: number | Date): Promise<void>;
42
+ transaction?<T>(
43
+ callback: (storage: PackagePublisherTransaction) => Promise<T>,
44
+ ): Promise<T>;
45
+ }
46
+
47
+ interface PackagePublisherStorage extends PackagePublisherTransaction {
48
+ transaction<T>(
49
+ callback: (storage: PackagePublisherTransaction) => Promise<T>,
50
+ ): Promise<T>;
51
+ }
52
+
53
+ export interface PackagePublisherUserHost {
54
+ storage: PackagePublisherStorage;
55
+ hash(candidate: PackageCandidateV1): Promise<string>;
56
+ storeAndVerify(input: {
57
+ userId: string;
58
+ applicationHash: string;
59
+ candidate: PackageCandidateV1;
60
+ }): Promise<void>;
61
+ now?: () => Date;
62
+ }
63
+
64
+ function initialState(): StoredState {
65
+ return { schemaVersion: 1, revision: 0, revisions: [] };
66
+ }
67
+
68
+ function storedRecord(value: unknown, label: string): Record<string, unknown> {
69
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
70
+ throw new PackagePublisherDecodeError(`${label} must be an object`);
71
+ }
72
+ return value as Record<string, unknown>;
73
+ }
74
+
75
+ function storedExactKeys(
76
+ value: Record<string, unknown>,
77
+ required: readonly string[],
78
+ optional: readonly string[],
79
+ label: string,
80
+ ): void {
81
+ const allowed = new Set([...required, ...optional]);
82
+ if (
83
+ required.some((key) => !(key in value)) ||
84
+ Object.keys(value).some((key) => !allowed.has(key))
85
+ ) {
86
+ throw new PackagePublisherDecodeError(
87
+ `${label} has unknown or missing fields`,
88
+ );
89
+ }
90
+ }
91
+
92
+ function decodeStoredState(value: unknown): StoredState {
93
+ const state = storedRecord(value, "stored publication state");
94
+ storedExactKeys(
95
+ state,
96
+ ["schemaVersion", "revision", "revisions"],
97
+ ["activePackageRevision", "pending"],
98
+ "stored publication state",
99
+ );
100
+ const history = decodePackageRevisionHistoryV1({
101
+ schemaVersion: state.schemaVersion,
102
+ revision: state.revision,
103
+ revisions: state.revisions,
104
+ ...(state.activePackageRevision === undefined
105
+ ? {}
106
+ : { activePackageRevision: state.activePackageRevision }),
107
+ });
108
+ if (
109
+ history.activePackageRevision !== undefined &&
110
+ !history.revisions.some(
111
+ (revision) => revision.packageRevision === history.activePackageRevision,
112
+ )
113
+ ) {
114
+ throw new PackagePublisherDecodeError(
115
+ "active package revision was not published",
116
+ );
117
+ }
118
+ if (state.pending === undefined) return history;
119
+ const pending = storedRecord(state.pending, "pending publication");
120
+ storedExactKeys(
121
+ pending,
122
+ [
123
+ "userId",
124
+ "commandId",
125
+ "fingerprint",
126
+ "packageRevision",
127
+ "applicationHash",
128
+ "publishedAt",
129
+ "candidate",
130
+ ],
131
+ [],
132
+ "pending publication",
133
+ );
134
+ if (
135
+ typeof pending.userId !== "string" ||
136
+ typeof pending.fingerprint !== "string" ||
137
+ pending.fingerprint.length < 1 ||
138
+ typeof pending.applicationHash !== "string" ||
139
+ !pending.applicationHash.startsWith("sha256:") ||
140
+ typeof pending.publishedAt !== "string" ||
141
+ !Number.isFinite(Date.parse(pending.publishedAt)) ||
142
+ !Number.isSafeInteger(pending.packageRevision) ||
143
+ (pending.packageRevision as number) < 1
144
+ ) {
145
+ throw new PackagePublisherDecodeError("pending publication is invalid");
146
+ }
147
+ const decoded = decodePublishPackageCommandV1({
148
+ schemaVersion: 1,
149
+ commandId: pending.commandId,
150
+ expectedRevision: history.revision,
151
+ candidate: pending.candidate,
152
+ });
153
+ if (decoded.candidate.checks.some((check) => check.status !== "passed")) {
154
+ throw new PackagePublisherDecodeError(
155
+ "pending publication contains failed checks",
156
+ );
157
+ }
158
+ return {
159
+ ...history,
160
+ pending: {
161
+ userId: pending.userId,
162
+ commandId: decoded.commandId,
163
+ fingerprint: pending.fingerprint,
164
+ packageRevision: pending.packageRevision as number,
165
+ applicationHash: pending.applicationHash,
166
+ publishedAt: pending.publishedAt,
167
+ candidate: decoded.candidate,
168
+ },
169
+ };
170
+ }
171
+
172
+ function decodeStoredReceipt(value: unknown): StoredReceipt {
173
+ const stored = storedRecord(value, "stored publication receipt");
174
+ storedExactKeys(
175
+ stored,
176
+ ["fingerprint", "receipt"],
177
+ [],
178
+ "stored publication receipt",
179
+ );
180
+ if (typeof stored.fingerprint !== "string" || stored.fingerprint.length < 1) {
181
+ throw new PackagePublisherDecodeError(
182
+ "stored publication receipt fingerprint is invalid",
183
+ );
184
+ }
185
+ return {
186
+ fingerprint: stored.fingerprint,
187
+ receipt: decodePackagePublicationReceiptV1(stored.receipt),
188
+ };
189
+ }
190
+
191
+ async function readState(
192
+ storage: PackagePublisherTransaction,
193
+ ): Promise<StoredState> {
194
+ const value = await storage.get<unknown>(STATE_KEY);
195
+ return value === undefined ? initialState() : decodeStoredState(value);
196
+ }
197
+
198
+ async function readReceipt(
199
+ storage: PackagePublisherTransaction,
200
+ commandId: string,
201
+ ): Promise<StoredReceipt | undefined> {
202
+ const value = await storage.get<unknown>(`${RECEIPT_PREFIX}${commandId}`);
203
+ return value === undefined ? undefined : decodeStoredReceipt(value);
204
+ }
205
+
206
+ function cloneHistory(state: StoredState): PackageRevisionHistoryV1 {
207
+ return structuredClone({
208
+ schemaVersion: 1,
209
+ revision: state.revision,
210
+ ...(state.activePackageRevision === undefined
211
+ ? {}
212
+ : { activePackageRevision: state.activePackageRevision }),
213
+ revisions: state.revisions,
214
+ });
215
+ }
216
+
217
+ export class PackagePublisherUserContribution {
218
+ private readonly pendingExecutions = new Map<
219
+ string,
220
+ Promise<PackagePublicationReceiptV1>
221
+ >();
222
+
223
+ constructor(private readonly host: PackagePublisherUserHost) {}
224
+
225
+ async read(): Promise<PackageRevisionHistoryV1> {
226
+ return cloneHistory(await readState(this.host.storage));
227
+ }
228
+
229
+ async activeApplicationHash(): Promise<string | undefined> {
230
+ const history = await this.read();
231
+ return history.revisions.find(
232
+ (revision) => revision.packageRevision === history.activePackageRevision,
233
+ )?.applicationHash;
234
+ }
235
+
236
+ async publish(
237
+ userId: string,
238
+ command: PublishPackageCommandV1,
239
+ ): Promise<PackagePublicationReceiptV1> {
240
+ if (command.candidate.checks.some((check) => check.status !== "passed")) {
241
+ throw new PackagePublisherDecodeError(
242
+ "all required checks must pass before publication",
243
+ );
244
+ }
245
+ const fingerprint = JSON.stringify(command);
246
+ const applicationHash = await this.host.hash(command.candidate);
247
+ const pending = await this.host.storage.transaction(async (storage) => {
248
+ const receipt = await readReceipt(storage, command.commandId);
249
+ if (receipt) {
250
+ if (receipt.fingerprint !== fingerprint) {
251
+ throw new PackagePublisherDecodeError(
252
+ `command ID collision: ${command.commandId}`,
253
+ );
254
+ }
255
+ return receipt.receipt;
256
+ }
257
+ const state = await readState(storage);
258
+ if (state.pending) {
259
+ if (
260
+ state.pending.commandId !== command.commandId ||
261
+ state.pending.fingerprint !== fingerprint
262
+ ) {
263
+ throw new PackagePublisherConflictError(state.revision);
264
+ }
265
+ return state.pending;
266
+ }
267
+ if (state.revision !== command.expectedRevision) {
268
+ throw new PackagePublisherConflictError(state.revision);
269
+ }
270
+ const publication: PendingPublication = {
271
+ userId,
272
+ commandId: command.commandId,
273
+ fingerprint,
274
+ packageRevision:
275
+ Math.max(0, ...state.revisions.map((item) => item.packageRevision)) +
276
+ 1,
277
+ applicationHash,
278
+ publishedAt: (this.host.now?.() ?? new Date()).toISOString(),
279
+ candidate: structuredClone(command.candidate),
280
+ };
281
+ await storage.put(STATE_KEY, { ...state, pending: publication });
282
+ await storage.setAlarm(
283
+ (this.host.now?.() ?? new Date()).getTime() + RECOVERY_DELAY_MS,
284
+ );
285
+ return publication;
286
+ });
287
+ if ("status" in pending) return structuredClone(pending);
288
+ return this.executePending(pending);
289
+ }
290
+
291
+ async recover(): Promise<PackagePublicationReceiptV1 | undefined> {
292
+ const state = await readState(this.host.storage);
293
+ if (!state.pending) return undefined;
294
+ return this.executePending(state.pending);
295
+ }
296
+
297
+ private executePending(
298
+ pending: PendingPublication,
299
+ ): Promise<PackagePublicationReceiptV1> {
300
+ const existing = this.pendingExecutions.get(pending.commandId);
301
+ if (existing) return existing;
302
+ const execution = this.performPending(pending).finally(() => {
303
+ if (this.pendingExecutions.get(pending.commandId) === execution) {
304
+ this.pendingExecutions.delete(pending.commandId);
305
+ }
306
+ });
307
+ this.pendingExecutions.set(pending.commandId, execution);
308
+ return execution;
309
+ }
310
+
311
+ private async performPending(
312
+ pending: PendingPublication,
313
+ ): Promise<PackagePublicationReceiptV1> {
314
+ try {
315
+ await this.host.storeAndVerify({
316
+ userId: pending.userId,
317
+ applicationHash: pending.applicationHash,
318
+ candidate: pending.candidate,
319
+ });
320
+ return await this.finishPublication(pending);
321
+ } catch (error) {
322
+ return await this.failPublication(
323
+ pending,
324
+ error instanceof Error
325
+ ? error.message
326
+ : "candidate verification failed",
327
+ );
328
+ }
329
+ }
330
+
331
+ private finishPublication(
332
+ pending: PendingPublication,
333
+ ): Promise<PackagePublicationReceiptV1> {
334
+ return this.host.storage.transaction(async (storage) => {
335
+ const state = await readState(storage);
336
+ if (state.pending?.commandId !== pending.commandId) {
337
+ const stored = await readReceipt(storage, pending.commandId);
338
+ if (stored?.fingerprint === pending.fingerprint) {
339
+ return structuredClone(stored.receipt);
340
+ }
341
+ throw new PackagePublisherConflictError(state.revision);
342
+ }
343
+ const nextRevision = state.revision + 1;
344
+ const receipt: PackagePublicationReceiptV1 = {
345
+ schemaVersion: 1,
346
+ commandId: pending.commandId,
347
+ status: "active",
348
+ revision: nextRevision,
349
+ packageRevision: pending.packageRevision,
350
+ applicationHash: pending.applicationHash,
351
+ };
352
+ const nextState: StoredState = {
353
+ schemaVersion: 1,
354
+ revision: nextRevision,
355
+ activePackageRevision: pending.packageRevision,
356
+ revisions: [
357
+ ...state.revisions,
358
+ {
359
+ packageRevision: pending.packageRevision,
360
+ applicationHash: pending.applicationHash,
361
+ publishedAt: pending.publishedAt,
362
+ checks: structuredClone(pending.candidate.checks),
363
+ },
364
+ ],
365
+ };
366
+ await storage.put(STATE_KEY, nextState);
367
+ await storage.put(`${RECEIPT_PREFIX}${pending.commandId}`, {
368
+ fingerprint: pending.fingerprint,
369
+ receipt,
370
+ } satisfies StoredReceipt);
371
+ return structuredClone(receipt);
372
+ });
373
+ }
374
+
375
+ private failPublication(
376
+ pending: PendingPublication,
377
+ failure: string,
378
+ ): Promise<PackagePublicationReceiptV1> {
379
+ return this.host.storage.transaction(async (storage) => {
380
+ const state = await readState(storage);
381
+ const stored = await readReceipt(storage, pending.commandId);
382
+ if (stored?.fingerprint === pending.fingerprint) {
383
+ return structuredClone(stored.receipt);
384
+ }
385
+ if (state.pending?.commandId !== pending.commandId) {
386
+ throw new PackagePublisherConflictError(state.revision);
387
+ }
388
+ const nextRevision = state.revision + 1;
389
+ const receipt: PackagePublicationReceiptV1 = {
390
+ schemaVersion: 1,
391
+ commandId: pending.commandId,
392
+ status: "failed",
393
+ revision: nextRevision,
394
+ failure,
395
+ };
396
+ await storage.put(STATE_KEY, {
397
+ ...state,
398
+ revision: nextRevision,
399
+ pending: undefined,
400
+ });
401
+ await storage.put(`${RECEIPT_PREFIX}${pending.commandId}`, {
402
+ fingerprint: pending.fingerprint,
403
+ receipt,
404
+ } satisfies StoredReceipt);
405
+ return structuredClone(receipt);
406
+ });
407
+ }
408
+
409
+ rollback(
410
+ command: RollbackPackageCommandV1,
411
+ ): Promise<PackagePublicationReceiptV1> {
412
+ const fingerprint = JSON.stringify(command);
413
+ return this.host.storage.transaction(async (storage) => {
414
+ const receiptKey = `${RECEIPT_PREFIX}${command.commandId}`;
415
+ const stored = await readReceipt(storage, command.commandId);
416
+ if (stored) {
417
+ if (stored.fingerprint !== fingerprint) {
418
+ throw new PackagePublisherDecodeError(
419
+ `command ID collision: ${command.commandId}`,
420
+ );
421
+ }
422
+ return structuredClone(stored.receipt);
423
+ }
424
+ const state = await readState(storage);
425
+ if (state.pending)
426
+ throw new PackagePublisherConflictError(state.revision);
427
+ if (state.revision !== command.expectedRevision) {
428
+ throw new PackagePublisherConflictError(state.revision);
429
+ }
430
+ const target = state.revisions.find(
431
+ (revision) => revision.packageRevision === command.packageRevision,
432
+ );
433
+ if (!target) {
434
+ throw new PackagePublisherDecodeError(
435
+ `package revision ${command.packageRevision} was not found`,
436
+ );
437
+ }
438
+ const nextRevision = state.revision + 1;
439
+ const receipt: PackagePublicationReceiptV1 = {
440
+ schemaVersion: 1,
441
+ commandId: command.commandId,
442
+ status: "active",
443
+ revision: nextRevision,
444
+ packageRevision: target.packageRevision,
445
+ applicationHash: target.applicationHash,
446
+ };
447
+ await storage.put(STATE_KEY, {
448
+ ...state,
449
+ revision: nextRevision,
450
+ activePackageRevision: target.packageRevision,
451
+ });
452
+ await storage.put(receiptKey, {
453
+ fingerprint,
454
+ receipt,
455
+ } satisfies StoredReceipt);
456
+ return structuredClone(receipt);
457
+ });
458
+ }
459
+ }
460
+
461
+ export function createPackagePublisherUserContribution(
462
+ host: PackagePublisherUserHost,
463
+ ): PackagePublisherUserContribution {
464
+ return new PackagePublisherUserContribution(host);
465
+ }
466
+
467
+ export function createPackagePublisherUserPlugin(
468
+ host: PackagePublisherUserHost,
469
+ lifecycle: { mount(value: PackagePublisherUserContribution): () => void },
470
+ ): Plugin {
471
+ return () => lifecycle.mount(createPackagePublisherUserContribution(host));
472
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2023",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "allowImportingTsExtensions": true,
7
+ "resolveJsonModule": true,
8
+ "strict": true,
9
+ "noEmit": true,
10
+ "skipLibCheck": true,
11
+ "lib": ["ES2023", "DOM", "DOM.Iterable"],
12
+ "types": ["bun", "vite/client"]
13
+ },
14
+ "include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
15
+ }
package/vite.config.ts ADDED
@@ -0,0 +1,31 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import vue from "@vitejs/plugin-vue";
3
+ import { defineConfig } from "vite";
4
+
5
+ export default defineConfig({
6
+ plugins: [vue()],
7
+ build: {
8
+ outDir: fileURLToPath(new URL("./dist", import.meta.url)),
9
+ emptyOutDir: true,
10
+ manifest: "manifest.json",
11
+ assetsInlineLimit: Number.POSITIVE_INFINITY,
12
+ lib: {
13
+ entry: fileURLToPath(new URL("./src/client/index.ts", import.meta.url)),
14
+ formats: ["es"],
15
+ },
16
+ rollupOptions: {
17
+ external: [
18
+ "vue",
19
+ "@cordisjs/client",
20
+ "@frockbot/client-core",
21
+ "@frockbot/client-ui",
22
+ "@frockbot/plugin-shell/shared",
23
+ ],
24
+ output: {
25
+ entryFileNames: "assets/package-publisher-[hash].js",
26
+ chunkFileNames: "assets/chunk-[hash].js",
27
+ assetFileNames: "assets/[name]-[hash][extname]",
28
+ },
29
+ },
30
+ },
31
+ });
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # @frockbot/plugin-package-publisher
2
-
3
- Placeholder reserving this name. See https://github.com/timoconnellaus/frockbot.