@casecore/release-agent 0.1.4 → 0.1.7

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.
@@ -2,11 +2,10 @@ import assert from 'node:assert/strict';
2
2
  import { timingSafeEqual } from 'node:crypto';
3
3
  import { createServer } from 'node:http';
4
4
  import { configurationHook } from './configuration-hook.mjs';
5
+ import { verifyPublicationTerminalReceipt } from './publication-terminal-receipt.mjs';
5
6
 
6
7
  export function producerServer(producer, token, publication = {}) {
7
- assert.ok(
8
- typeof token === 'string' && token.length >= 43 && !/\s/.test(token),
9
- );
8
+ assert.ok(typeof token === 'string' && token.length >= 43 && !/\s/.test(token));
10
9
  const expected = Buffer.from(`Bearer ${token}`);
11
10
  const server = createServer(async (req, res) => {
12
11
  const reply = (code, body) => {
@@ -17,31 +16,29 @@ export function producerServer(producer, token, publication = {}) {
17
16
  res.end(JSON.stringify(body));
18
17
  };
19
18
  const supplied = Buffer.from(req.headers.authorization ?? '');
20
- if (
21
- supplied.length !== expected.length ||
22
- !timingSafeEqual(supplied, expected)
23
- )
19
+ if (supplied.length !== expected.length || !timingSafeEqual(supplied, expected))
24
20
  return reply(401, { error: 'Unauthorized' });
25
21
  try {
26
22
  const url = new URL(req.url, 'http://localhost');
27
23
  if (await configurationHook(req, url, producer, publication, reply)) return;
28
- if (
29
- req.method === 'GET' &&
30
- url.pathname === '/v1/release-control/snapshot'
31
- ) {
32
- assert.equal(
33
- url.searchParams.get('installationKey'),
34
- producer.installationKey,
35
- );
36
- return reply(
37
- 200,
38
- await producer.snapshot(url.searchParams.get('after') ?? '0'),
39
- );
24
+ const terminal = url.pathname.match(
25
+ /^\/v1\/release-control\/publications\/([a-f\d-]{36})\/terminal$/i
26
+ );
27
+ if (req.method === 'GET' && terminal && !url.search) {
28
+ const receipt = publication.readTerminalReceipt
29
+ ? await publication.readTerminalReceipt(terminal[1])
30
+ : producer.journal.read().commands[terminal[1]]?.deliveryTerminalReceipt;
31
+ if (!receipt) return reply(404, { error: 'Terminal receipt unavailable' });
32
+ const verified = verifyPublicationTerminalReceipt(receipt, token);
33
+ assert.equal(verified.proof.bindings.commandId, terminal[1]);
34
+ assert.equal(verified.proof.bindings.installationKey, producer.installationKey);
35
+ return reply(200, verified);
36
+ }
37
+ if (req.method === 'GET' && url.pathname === '/v1/release-control/snapshot') {
38
+ assert.equal(url.searchParams.get('installationKey'), producer.installationKey);
39
+ return reply(200, await producer.snapshot(url.searchParams.get('after') ?? '0'));
40
40
  }
41
- if (
42
- req.method === 'POST' &&
43
- url.pathname === '/v1/release-control/commands'
44
- ) {
41
+ if (req.method === 'POST' && url.pathname === '/v1/release-control/commands') {
45
42
  let bytes = 0;
46
43
  const chunks = [];
47
44
  for await (const chunk of req) {
@@ -51,37 +48,21 @@ export function producerServer(producer, token, publication = {}) {
51
48
  }
52
49
  return reply(
53
50
  200,
54
- await producer.command(
55
- JSON.parse(Buffer.concat(chunks).toString('utf8')),
56
- ),
51
+ await producer.command(JSON.parse(Buffer.concat(chunks).toString('utf8')))
57
52
  );
58
53
  }
59
54
  const publicationMatch = url.pathname.match(
60
- /^\/v1\/release-control\/publications\/([a-f\d-]{36})(\/report)?$/i,
55
+ /^\/v1\/release-control\/publications\/([a-f\d-]{36})(\/report)?$/i
61
56
  );
62
57
  if (publicationMatch && !url.search) {
63
- if (
64
- req.method === 'GET' &&
65
- !publicationMatch[2] &&
66
- publication.claimPublication
67
- ) {
58
+ if (req.method === 'GET' && !publicationMatch[2] && publication.claimPublication) {
68
59
  const rawRun = req.headers[publication.workflowRunHeader ?? 'x-myrobotica-workflow-run'];
69
60
  assert.ok(typeof rawRun === 'string' && /^[1-9]\d*$/.test(rawRun));
70
61
  const workflowRunId = Number(rawRun);
71
62
  assert.ok(Number.isSafeInteger(workflowRunId));
72
- return reply(
73
- 200,
74
- await publication.claimPublication(
75
- publicationMatch[1],
76
- workflowRunId,
77
- ),
78
- );
63
+ return reply(200, await publication.claimPublication(publicationMatch[1], workflowRunId));
79
64
  }
80
- if (
81
- req.method === 'POST' &&
82
- publicationMatch[2] &&
83
- publication.reportPublication
84
- ) {
65
+ if (req.method === 'POST' && publicationMatch[2] && publication.reportPublication) {
85
66
  let bytes = 0;
86
67
  const chunks = [];
87
68
  for await (const chunk of req) {
@@ -93,17 +74,14 @@ export function producerServer(producer, token, publication = {}) {
93
74
  200,
94
75
  await publication.reportPublication(
95
76
  publicationMatch[1],
96
- JSON.parse(Buffer.concat(chunks).toString('utf8')),
97
- ),
77
+ JSON.parse(Buffer.concat(chunks).toString('utf8'))
78
+ )
98
79
  );
99
80
  }
100
81
  }
101
- const match = url.pathname.match(
102
- /^\/v1\/release-control\/commands\/([a-f\d-]{36})$/i,
103
- );
82
+ const match = url.pathname.match(/^\/v1\/release-control\/commands\/([a-f\d-]{36})$/i);
104
83
  if (req.method === 'GET' && match) {
105
- if (!producer.acknowledgement(match[1]))
106
- return reply(404, { error: 'Unknown command' });
84
+ if (!producer.acknowledgement(match[1])) return reply(404, { error: 'Unknown command' });
107
85
  const ack = await producer.reconcile(match[1]);
108
86
  return reply(ack ? 200 : 404, ack ?? { error: 'Unknown command' });
109
87
  }
@@ -3,16 +3,18 @@ import { blocker, smokeScenarios, safeStatus } from './producer-support.mjs';
3
3
  import path from 'node:path';
4
4
  import { createProtectedJsonStore } from './journal.mjs';
5
5
  import { validProducerState } from './producer-journal.mjs';
6
- import { installOperationBarrier, markOperationStateLoaded, reserveExclusiveOperation } from './exclusive-operation.mjs';
6
+ import {
7
+ installOperationBarrier,
8
+ markOperationStateLoaded,
9
+ reserveExclusiveOperation,
10
+ } from './exclusive-operation.mjs';
7
11
 
8
12
  export class ProducerState {
9
- constructor({
10
- installationKey,
11
- journalFile,
12
- provider,
13
- now = () => new Date(),
14
- sourcePolicy,
15
- }) {
13
+ #engineInstance;
14
+ static isEngineInstance(value) {
15
+ return value !== null && typeof value === 'object' && #engineInstance in value;
16
+ }
17
+ constructor({ installationKey, journalFile, provider, now = () => new Date(), sourcePolicy }) {
16
18
  assert.match(installationKey, /^[\w.-]{1,200}$/);
17
19
  assert.ok(path.isAbsolute(journalFile));
18
20
  Object.assign(this, { installationKey, journalFile, provider, now, sourcePolicy });
@@ -26,7 +28,7 @@ export class ProducerState {
26
28
  (Number.isSafeInteger(provider.snapshotTimeoutMs) &&
27
29
  provider.snapshotTimeoutMs > 0 &&
28
30
  provider.snapshotTimeoutMs <= 30000),
29
- 'Invalid fixed provider snapshot budget',
31
+ 'Invalid fixed provider snapshot budget'
30
32
  );
31
33
  this.state = {
32
34
  schema: 1,
@@ -47,9 +49,7 @@ export class ProducerState {
47
49
  async initialize() {
48
50
  this.state = this.journal.read();
49
51
  // A restarted process must reconcile a previously admitted mutation first.
50
- for (const run of this.state.runs.filter((r) =>
51
- ['running', 'queued'].includes(r.status),
52
- )) {
52
+ for (const run of this.state.runs.filter((r) => ['running', 'queued'].includes(r.status))) {
53
53
  if (
54
54
  run.status === 'queued' &&
55
55
  run.lane === 'scheduled' &&
@@ -62,7 +62,7 @@ export class ProducerState {
62
62
  blocker(
63
63
  'RECONCILIATION_REQUIRED',
64
64
  'Предыдущее выполнение требует проверки фактического состояния',
65
- true,
65
+ true
66
66
  ),
67
67
  ];
68
68
  }
@@ -108,15 +108,9 @@ export class ProducerState {
108
108
  occurredAt: this.now().toISOString(),
109
109
  code,
110
110
  frames,
111
- ...(smokeScenarios.has(error?.scenario)
112
- ? { scenario: error.scenario }
113
- : {}),
114
- ...(safeStatus(error?.expectedStatus)
115
- ? { expectedStatus: error.expectedStatus }
116
- : {}),
117
- ...(safeStatus(error?.actualStatus)
118
- ? { actualStatus: error.actualStatus }
119
- : {}),
111
+ ...(smokeScenarios.has(error?.scenario) ? { scenario: error.scenario } : {}),
112
+ ...(safeStatus(error?.expectedStatus) ? { expectedStatus: error.expectedStatus } : {}),
113
+ ...(safeStatus(error?.actualStatus) ? { actualStatus: error.actualStatus } : {}),
120
114
  ...([
121
115
  'ENOTFOUND',
122
116
  'EAI_AGAIN',
@@ -131,9 +125,7 @@ export class ProducerState {
131
125
  ? { transportCode: error.transportCode }
132
126
  : {}),
133
127
  };
134
- this.state.diagnostics = [...(this.state.diagnostics ?? []), item].slice(
135
- -200,
136
- );
128
+ this.state.diagnostics = [...(this.state.diagnostics ?? []), item].slice(-200);
137
129
  return code;
138
130
  }
139
131
  async snapshot(after = '0', { deadlineAt } = {}) {
@@ -144,8 +136,8 @@ export class ProducerState {
144
136
  this.provider.snapshotTimeoutMs ?? 10000,
145
137
  deadlineAt
146
138
  ? Date.parse(deadlineAt) - this.now().getTime()
147
- : (this.provider.snapshotTimeoutMs ?? 10000),
148
- ),
139
+ : (this.provider.snapshotTimeoutMs ?? 10000)
140
+ )
149
141
  );
150
142
  const controller = new AbortController();
151
143
  let timer;
@@ -155,22 +147,17 @@ export class ProducerState {
155
147
  reject(
156
148
  Object.assign(Error('Producer status read timed out'), {
157
149
  code: 'PRODUCER_STATUS_TIMEOUT',
158
- }),
150
+ })
159
151
  );
160
152
  }, limit);
161
153
  });
162
154
  let data;
163
155
  try {
164
- data = await Promise.race([
165
- this.provider.snapshot({ signal: controller.signal }),
166
- timeout,
167
- ]);
156
+ data = await Promise.race([this.provider.snapshot({ signal: controller.signal }), timeout]);
168
157
  } finally {
169
158
  clearTimeout(timer);
170
159
  }
171
- const candidates = new Map(
172
- data.candidates.map((candidate) => [candidate.id, candidate]),
173
- );
160
+ const candidates = new Map(data.candidates.map((candidate) => [candidate.id, candidate]));
174
161
  for (const item of Object.values(this.state.commands)) {
175
162
  if (
176
163
  item.command.action !== 'publish' ||
@@ -13,6 +13,56 @@ export * from './protocol.js';
13
13
  export interface ProviderContext {
14
14
  signal: AbortSignal;
15
15
  }
16
+ declare const publicationTerminalBrand: unique symbol;
17
+ export interface PublicationTerminalAuthority {
18
+ readonly [publicationTerminalBrand]: true;
19
+ }
20
+ export interface PublicationTerminalBindings {
21
+ action: 'publish' | 'deploy';
22
+ commandId: string;
23
+ runId: string;
24
+ installationKey: string;
25
+ repository: string;
26
+ ref: string;
27
+ provider: RunnerCommand['executionProvider'];
28
+ candidateId: string;
29
+ delivery: OriginalDelivery;
30
+ }
31
+ export interface PublicationTerminalProof {
32
+ schema: 1;
33
+ bindings: PublicationTerminalBindings;
34
+ status: 'deployed' | 'failed';
35
+ completedAt: string;
36
+ artifactIdentity: ArtifactIdentity | null;
37
+ readback: ProductionReadback;
38
+ failure: PublicationFailureFact | null;
39
+ }
40
+ export function readPublicationTerminalAuthority(
41
+ authority: PublicationTerminalAuthority,
42
+ bindings: PublicationTerminalBindings
43
+ ): PublicationTerminalProof;
44
+ export interface PublicationTerminalReceipt {
45
+ schema: 1;
46
+ kind: 'publication_terminal';
47
+ issuedAt: string;
48
+ proof: PublicationTerminalProof;
49
+ mac: string;
50
+ }
51
+ export function sealPublicationTerminalReceipt(
52
+ authority: PublicationTerminalAuthority,
53
+ bindings: PublicationTerminalBindings,
54
+ token: string
55
+ ): Promise<PublicationTerminalReceipt>;
56
+ /** Authenticated data, never an execution capability. */
57
+ export function verifyPublicationTerminalReceipt(
58
+ receipt: PublicationTerminalReceipt,
59
+ token: string
60
+ ): PublicationTerminalReceipt;
61
+ export interface TerminalVerificationContext extends ProviderContext {
62
+ completedAt: string;
63
+ status: 'deployed' | 'failed';
64
+ artifactIdentity?: ArtifactIdentity;
65
+ }
16
66
  export interface ExecutionContext {
17
67
  stage<T>(
18
68
  id: StageId,
@@ -39,6 +89,13 @@ export interface ReleaseProvider {
39
89
  actions: RunnerCommand['action'][];
40
90
  snapshotTimeoutMs?: number;
41
91
  remotePublication?: boolean;
92
+ publicationTerminal?(
93
+ command: RunnerCommand,
94
+ context: ProviderContext & {
95
+ authority: PublicationTerminalAuthority;
96
+ bindings: PublicationTerminalBindings;
97
+ }
98
+ ): Promise<void>;
42
99
  snapshot(context: ProviderContext): Promise<{
43
100
  connection: ProducerSnapshot['connection'];
44
101
  installed?: ProductionReadback | null;
@@ -56,13 +113,30 @@ export interface ReleaseProvider {
56
113
  verifyPublicationArtifact?(
57
114
  command: RunnerCommand,
58
115
  identity: ArtifactIdentity,
59
- delivery: OriginalDelivery
116
+ delivery: OriginalDelivery,
117
+ context?: TerminalVerificationContext
118
+ ): Promise<boolean>;
119
+ verifyPublicationDelivery?(
120
+ command: RunnerCommand,
121
+ delivery: OriginalDelivery,
122
+ context?: TerminalVerificationContext
123
+ ): Promise<boolean>;
124
+ verifyArtifactDelivery?(
125
+ command: RunnerCommand,
126
+ identity: ArtifactIdentity,
127
+ delivery: OriginalDelivery,
128
+ source: { repository: string; ref: string; sourceCommit: string },
129
+ context: TerminalVerificationContext
60
130
  ): Promise<boolean>;
61
- verifyPublicationDelivery?(command: RunnerCommand, delivery: OriginalDelivery): Promise<boolean>;
62
131
  reconcilePublicationFailure?(
63
132
  command: RunnerCommand,
64
133
  context: ProviderContext & { delivery: OriginalDelivery | null; recoveryDeadlineAt: number }
65
134
  ): Promise<PublicationFailureGuard | null>;
135
+ verifyPublicationSourceFailure?(
136
+ command: RunnerCommand,
137
+ execution: SourceRecoveryExecution,
138
+ context: ProviderContext & { recoveryDeadlineAt: number }
139
+ ): Promise<boolean>;
66
140
  validatePublicationResume?(
67
141
  command: RunnerCommand,
68
142
  item: Record<string, unknown>
@@ -81,13 +155,25 @@ export interface ReleaseProvider {
81
155
  ): Promise<Record<string, unknown>>;
82
156
  productionFailure?(fact: ProductionReadback): Error | undefined;
83
157
  }
84
- export interface PublicationFailureFact {
158
+ export interface SourceRecoveryExecution {
159
+ schema: 1;
160
+ kind: 'source_not_pushed';
161
+ id: string;
162
+ candidateId: string;
163
+ sourceCommit: string;
164
+ requestedAt: string;
165
+ deadlineAt: string;
166
+ policy: 'immediate';
167
+ }
168
+ export function validateSourceRecoveryExecution(
169
+ execution: unknown,
170
+ command?: RunnerCommand
171
+ ): SourceRecoveryExecution;
172
+ interface PublicationFailureCommon {
85
173
  schema: 1;
86
- kind: 'failed_before_installation';
87
174
  commandId: string;
88
175
  installationKey: string;
89
176
  expectedSource: { repository: string; ref: string; sourceCommit: string };
90
- delivery: OriginalDelivery;
91
177
  observedAt: string;
92
178
  stage: 'source_ready' | 'ci' | 'build' | 'verify' | 'publish';
93
179
  authenticated: true;
@@ -96,6 +182,15 @@ export interface PublicationFailureFact {
96
182
  publicationResolved: true;
97
183
  installationNotStarted: true;
98
184
  }
185
+ export type PublicationFailureFact = PublicationFailureCommon &
186
+ (
187
+ | { kind: 'failed_before_installation'; delivery: OriginalDelivery }
188
+ | {
189
+ kind: 'failed_before_source_push';
190
+ sourceExecution: SourceRecoveryExecution;
191
+ stage: 'source_ready';
192
+ }
193
+ );
99
194
  export interface PublicationFailureGuard {
100
195
  fact: PublicationFailureFact;
101
196
  baselineReadback: ProductionReadback;
@@ -152,6 +247,7 @@ export function producerServer(
152
247
  workflowRunHeader?: string;
153
248
  claimPublication?(commandId: string, workflowRunId: number): Promise<unknown>;
154
249
  reportPublication?(commandId: string, report: unknown): Promise<unknown>;
250
+ readTerminalReceipt?(commandId: string): Promise<PublicationTerminalReceipt | null | undefined>;
155
251
  configure?(request: ConfigurationRequest): Promise<ConfigurationAcknowledgement | undefined>;
156
252
  reconcileConfiguration?(
157
253
  configurationId: string
package/dist/producer.mjs CHANGED
@@ -3,7 +3,13 @@ import { sameIdentity, acceptedProduction } from './protocol.mjs';
3
3
  import { blocker, failureMessage, failedProduction } from './producer-support.mjs';
4
4
  import { ProducerExecution } from './producer-execution.mjs';
5
5
  import { reconcilePublicationFailure } from './producer-publication-failure.mjs';
6
- export { publicationFailureProofDigest } from './producer-publication-failure.mjs';
6
+ import { notifyPublicationTerminal } from './publication-terminal.mjs';
7
+ export {
8
+ readPublicationTerminalAuthority,
9
+ sealPublicationTerminalReceipt,
10
+ verifyPublicationTerminalReceipt,
11
+ } from './publication-terminal.mjs';
12
+ export { publicationFailureProofDigest, validateSourceRecoveryExecution } from './producer-publication-failure.mjs';
7
13
  export {
8
14
  sameIdentity,
9
15
  validateExpectedSource,
@@ -18,6 +24,43 @@ export class ReleaseControlProducer extends ProducerExecution {
18
24
  const item = this.state.commands[commandId];
19
25
  assert.ok(item, 'Unknown command');
20
26
  const run = this.state.runs.find((r) => r.commandId === commandId);
27
+ if (
28
+ ['publish', 'deploy'].includes(item.command.action) &&
29
+ typeof this.provider.publicationTerminal === 'function'
30
+ ) {
31
+ if (run?.status === 'failed' && item.publicationFailureReconciliation?.state === 'settled') {
32
+ await reconcilePublicationFailure(this, item, run, { terminalRetry: true });
33
+ return this.acknowledgement(commandId);
34
+ }
35
+ if (run?.status === 'deployed') {
36
+ const controller = new AbortController();
37
+ let timer;
38
+ try {
39
+ const fact = await Promise.race([
40
+ this.provider.readProduction(this.resolvedCommand(item.command), {
41
+ signal: controller.signal,
42
+ }),
43
+ new Promise((_, reject) => {
44
+ timer = setTimeout(() => {
45
+ controller.abort();
46
+ reject(Error('PUBLICATION_TERMINAL_READBACK_TIMEOUT'));
47
+ }, 30000);
48
+ }),
49
+ ]);
50
+ assert.ok(
51
+ acceptedProduction(fact, this.resolvedCommand(item.command), this.now()),
52
+ 'PUBLICATION_TERMINAL_READBACK_REQUIRED'
53
+ );
54
+ run.readback = structuredClone(fact);
55
+ await this.save();
56
+ await notifyPublicationTerminal(this, item, run);
57
+ } finally {
58
+ controller.abort();
59
+ clearTimeout(timer);
60
+ }
61
+ return this.acknowledgement(commandId);
62
+ }
63
+ }
21
64
  if (
22
65
  item.command.action === 'publish' &&
23
66
  run?.status === 'blocked' &&
@@ -140,6 +183,7 @@ export class ReleaseControlProducer extends ProducerExecution {
140
183
  await this.save();
141
184
  }
142
185
  }
186
+ await notifyPublicationTerminal(this, item, run);
143
187
  return this.acknowledgement(commandId);
144
188
  }
145
189
  }
@@ -1,31 +1,33 @@
1
1
  {
2
2
  "schema": 1,
3
3
  "package": "@casecore/release-agent",
4
- "version": "0.1.4",
5
- "sourceCommit": "a888a2b91e4f5797b2e18dd3ee0463ed56395923",
4
+ "version": "0.1.7",
5
+ "sourceCommit": "517e8581d9e651cf4188c084e5a65e95b2e5bc82",
6
6
  "sourceVerified": true,
7
7
  "sourceDirty": false,
8
8
  "buildKind": "release",
9
- "contentDigest": "sha256:492c711a81655001e6d77fb48c19363d2f8f27d24ef5afbe514bde6b26294000",
9
+ "contentDigest": "sha256:bd08880215c8d1203050961fa9971925b212d463354b847d1574d92c4720c524",
10
10
  "files": {
11
11
  "src/configuration-hook.mjs": "sha256:1171bf982cbf7cb0092f63b633ef0b0a0947d8ed1106f88c9175c8add33d81b1",
12
- "src/exclusive-operation.mjs": "sha256:5a1d0b7d3aa41b901132e239586d66d6c7aa2c16682a7db033a7aab91b1d781a",
12
+ "src/exclusive-operation.mjs": "sha256:d5c2718a2a4228d09559e914360329383ae276f034bb468c52642a4bd6aa0d99",
13
13
  "src/identity.mjs": "sha256:253021058fc79232d83f3ecba6f1023fd046f27aabdf9c129a103d3c9ccb07f5",
14
- "src/index.mjs": "sha256:76bda5e1dc376bb7033d8fdbddb0afa85c7c68bbe028aea078ffceaaa35ec75e",
14
+ "src/index.mjs": "sha256:2df871905b31d8cb287dd0700765be6dd9d76fbb91d25b9275f37b07ab4669b5",
15
15
  "src/journal.mjs": "sha256:35a2620d56084ec1aa164e09d77b8e77096413d9572f7b601da8ef7149b996bd",
16
16
  "src/pipeline.mjs": "sha256:7a2d39d235ce7160441d177200389c5f3eae1cc494ec36082a23e8d64a0abe3f",
17
- "src/producer-admission.mjs": "sha256:975c7dac25d4c8f94423e5a4db2bd99c556afd6492b26fbda58e841f1df8fc3c",
18
- "src/producer-delivery.mjs": "sha256:b20d9ae20dee90bbbe739245a84c9f4b91b1ab958b5e2995ff8705da24ba3988",
19
- "src/producer-execution.mjs": "sha256:6d9fbca493e4386c92c4d853a66ece3677ec7ab644d6da15b68dd744f30186ef",
20
- "src/producer-journal.mjs": "sha256:0934057702fb611b9a8d9dde58cb2c2024f436a49a4e5f6ab02f8884a320cea7",
21
- "src/producer-publication-failure.mjs": "sha256:726b2b85880143b70219473896b894f64b25b1c495f77488f1e73ce950b839f6",
17
+ "src/producer-admission.mjs": "sha256:03d868141daf92351b3e1a4e70997c87fc39044624644536a374aa86d108d12d",
18
+ "src/producer-delivery.mjs": "sha256:04439a95afcb52d879cc83b99fa1d7716f4dff84bb7c07f88d31f534799e76a8",
19
+ "src/producer-execution.mjs": "sha256:e78724fa2f2fd4946ae8103bb5165b363eb968fab42add4fed572155f51871d9",
20
+ "src/producer-journal.mjs": "sha256:d257e2042d92b828de44247b6affbe0b06e00263a1bb1939a1faa032daeaca9e",
21
+ "src/producer-publication-failure.mjs": "sha256:f077e62424cffb5123a81444e872fb2e2dec9b71dec89be7ca00b37227f8a648",
22
22
  "src/producer-publication.mjs": "sha256:c09fa07b37507093b71bbaff3af9df8ec2331c0cba24a4b936d247e9b35257f7",
23
- "src/producer-server.mjs": "sha256:def28e10a17eb80fb04ba69b09050731bb005949f949a7f1fb46dae267323434",
24
- "src/producer-state.mjs": "sha256:c3fcdace4c6579be092d39803cfb308e4bf88060b3f75bb7fc8b91284609b4ce",
23
+ "src/producer-server.mjs": "sha256:fc67ceced78239a1d159ff1af82b0061a407891523ad2a1a9139a3d4d158619c",
24
+ "src/producer-state.mjs": "sha256:61d6a914c268f94790c459562436bfe95efd07865ca1052b0b58760d9087d535",
25
25
  "src/producer-support.mjs": "sha256:0a10f6fa50cd8e729ba432640d0845004488ead2bcc1c9383f02548037f83d66",
26
- "src/producer.mjs": "sha256:6d0b875a9901a14edf5b93c771356f5352e924ff7977c13b027343c9bdca42f4",
26
+ "src/producer.mjs": "sha256:50addd14e7ae0ae7fb41fb5a4c733f1876d0551befb6db909edeed6cb5f44c1a",
27
27
  "src/protocol-internals.mjs": "sha256:d08a0c72079d091792c5327adafac5ffc38ea20138a6c44b88879e7157a890b3",
28
28
  "src/protocol.mjs": "sha256:1fe135a2e60922b517886a392c2b0c2250b773abda8b1a1fcd9af86df2be6dae",
29
+ "src/publication-terminal-receipt.mjs": "sha256:b4ecc620f7e4325ec9d42652b8f1620f609fad65b1400cd2810a0a2f7e7b6794",
30
+ "src/publication-terminal.mjs": "sha256:643663218b31526a47c88f6ea2475428d94e2450b49225c253f39d54de30fed2",
29
31
  "src/snapshot-validator.mjs": "sha256:1279aa19e0932776bc1998935f70ca6fae65391f96b60e2d65bcab3b2cb46036",
30
32
  "src/source-admission.mjs": "sha256:25e0e3cc163895c3f6b5057162538c4c313bd37e67956c63b2624df52517b437",
31
33
  "src/source-non-admission.mjs": "sha256:fd513148f9cd8beb1faddf9ad30189913d387a3cd6bc67f3308e7b0cbee46396",
@@ -34,7 +36,7 @@
34
36
  "types/index.d.ts": "sha256:92ef7b5600f05d52dcb646fc3e4d2b4944ead52ff1c9ee23bb9f4659bebf05a6",
35
37
  "types/journal.d.ts": "sha256:79beb61af50b00400588a87426a7f5b38bf38d4b8224284dc6e0631378aa244e",
36
38
  "types/pipeline.d.ts": "sha256:c9135e25fb852377c32a16ce781405393c5857886e3f77754b715747202c70a9",
37
- "types/producer.d.ts": "sha256:980aad137365f6c131b6bfabfab411a73c4a2b2fb35a167152ef8ed446ec1ade",
39
+ "types/producer.d.ts": "sha256:16cc19bd45fbf0cec564c2d6ec5d5ad00f3f866c89eb8af94e6b5c33ed1be22f",
38
40
  "types/protocol.d.ts": "sha256:02c06986c81bfc4214169f62da8e0566704b71a5add58434dfca92c673fad4ce",
39
41
  "types/source-store.d.ts": "sha256:ce2086dd682e8f57bc5e1581b411ec9fe1cf1cfba8dccafe38c22d102c2b9904",
40
42
  "types/types.d.ts": "sha256:cdc6664854bffc371a3da45f29bf1b31f14fd61b66e8f8bae8309dd836f6de6e"
@@ -0,0 +1,119 @@
1
+ import assert from 'node:assert/strict';
2
+ import { createHmac, timingSafeEqual } from 'node:crypto';
3
+ import { completeIdentity, keys, timestamp, uuid } from './protocol-internals.mjs';
4
+ import { validateOriginalDelivery } from './producer-support.mjs';
5
+
6
+ const domain = 'casecore.release-agent/publication-terminal/v1\0';
7
+ const sorted = (value) => {
8
+ if (Array.isArray(value)) return value.map(sorted);
9
+ if (value && typeof value === 'object')
10
+ return Object.fromEntries(
11
+ Object.keys(value)
12
+ .sort()
13
+ .map((key) => [key, sorted(value[key])])
14
+ );
15
+ assert.ok(value === null || ['string', 'boolean', 'number'].includes(typeof value));
16
+ assert.ok(typeof value !== 'number' || Number.isFinite(value));
17
+ return value;
18
+ };
19
+ const body = (receipt) =>
20
+ JSON.stringify(
21
+ sorted({
22
+ schema: receipt.schema,
23
+ kind: receipt.kind,
24
+ issuedAt: receipt.issuedAt,
25
+ proof: receipt.proof,
26
+ })
27
+ );
28
+ function secret(token) {
29
+ assert.ok(
30
+ typeof token === 'string' && token.length >= 43 && token.length <= 4096 && !/\s/.test(token),
31
+ 'PUBLICATION_TERMINAL_TOKEN_REQUIRED'
32
+ );
33
+ return token;
34
+ }
35
+ export function validatePublicationTerminalReceipt(receipt) {
36
+ keys(receipt, ['schema', 'kind', 'issuedAt', 'proof', 'mac']);
37
+ assert.equal(receipt.schema, 1);
38
+ assert.equal(receipt.kind, 'publication_terminal');
39
+ timestamp(receipt.issuedAt);
40
+ assert.match(receipt.mac, /^[a-f0-9]{64}$/);
41
+ const proof = receipt.proof;
42
+ keys(proof, [
43
+ 'schema',
44
+ 'bindings',
45
+ 'status',
46
+ 'completedAt',
47
+ 'artifactIdentity',
48
+ 'readback',
49
+ 'failure',
50
+ ]);
51
+ assert.equal(proof.schema, 1);
52
+ assert.ok(['deployed', 'failed'].includes(proof.status));
53
+ timestamp(proof.completedAt);
54
+ const bindings = proof.bindings;
55
+ keys(bindings, [
56
+ 'action',
57
+ 'commandId',
58
+ 'runId',
59
+ 'installationKey',
60
+ 'repository',
61
+ 'ref',
62
+ 'provider',
63
+ 'candidateId',
64
+ 'delivery',
65
+ ]);
66
+ assert.ok(['publish', 'deploy'].includes(bindings.action));
67
+ assert.match(bindings.commandId, uuid);
68
+ assert.match(bindings.runId, uuid);
69
+ assert.match(bindings.installationKey, /^[\w.-]{1,200}$/);
70
+ assert.match(bindings.repository, /^[\w.-]+\/[\w.-]+$/);
71
+ assert.ok(
72
+ typeof bindings.ref === 'string' && bindings.ref.length > 0 && bindings.ref.length <= 200
73
+ );
74
+ assert.ok(['github', 'local'].includes(bindings.provider));
75
+ assert.ok(
76
+ typeof bindings.candidateId === 'string' &&
77
+ bindings.candidateId.length > 0 &&
78
+ bindings.candidateId.length <= 200
79
+ );
80
+ validateOriginalDelivery(bindings.delivery);
81
+ assert.ok(Date.parse(proof.completedAt) >= Date.parse(bindings.delivery.requestedAt));
82
+ if (proof.status === 'deployed') {
83
+ assert.ok(Date.parse(proof.completedAt) <= Date.parse(bindings.delivery.deadlineAt));
84
+ assert.ok(completeIdentity(proof.artifactIdentity));
85
+ } else assert.ok(proof.artifactIdentity === null || completeIdentity(proof.artifactIdentity));
86
+ assert.ok(proof.readback && typeof proof.readback === 'object');
87
+ assert.ok(proof.failure === null || (proof.failure && typeof proof.failure === 'object'));
88
+ assert.ok(Buffer.byteLength(body(receipt)) <= 262144, 'PUBLICATION_TERMINAL_RECEIPT_TOO_LARGE');
89
+ return receipt;
90
+ }
91
+ // Internal data constructor. Public sealing additionally requires the genuine
92
+ // active engine capability and durably writes the existing command journal.
93
+ export function createPublicationTerminalReceipt(proof, token) {
94
+ const receipt = {
95
+ schema: 1,
96
+ kind: 'publication_terminal',
97
+ issuedAt: new Date().toISOString(),
98
+ proof: structuredClone(proof),
99
+ mac: '',
100
+ };
101
+ receipt.mac = createHmac('sha256', secret(token))
102
+ .update(domain)
103
+ .update(body(receipt))
104
+ .digest('hex');
105
+ return validatePublicationTerminalReceipt(receipt);
106
+ }
107
+ /** Authenticated data only: this never creates or revives a WeakMap capability. */
108
+ export function verifyPublicationTerminalReceipt(receipt, token) {
109
+ validatePublicationTerminalReceipt(receipt);
110
+ const expected = createHmac('sha256', secret(token))
111
+ .update(domain)
112
+ .update(body(receipt))
113
+ .digest();
114
+ assert.ok(
115
+ timingSafeEqual(expected, Buffer.from(receipt.mac, 'hex')),
116
+ 'PUBLICATION_TERMINAL_RECEIPT_AUTHENTICATION_REQUIRED'
117
+ );
118
+ return structuredClone(receipt);
119
+ }