warpmetal 0.4.2 → 0.6.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/payment.js CHANGED
@@ -694,6 +694,8 @@ export async function readPaymentArtifact(path, expected) {
694
694
 
695
695
  export function paymentWorkflow({
696
696
  taskId,
697
+ serverId,
698
+ kind = "checkout",
697
699
  requestEnvelopePath,
698
700
  paymentArtifactPath,
699
701
  }) {
@@ -746,17 +748,30 @@ export function paymentWorkflow({
746
748
  ],
747
749
  },
748
750
  submit: {
749
- argv: [
750
- "warpmetal",
751
- "checkout",
752
- "submit",
753
- "--task",
754
- taskId,
755
- "--payment-artifact",
756
- paymentArtifactPath,
757
- "--wait",
758
- "--json",
759
- ],
751
+ argv:
752
+ kind === "renewal"
753
+ ? [
754
+ "warpmetal",
755
+ "renewal",
756
+ "submit",
757
+ "--server",
758
+ serverId,
759
+ "--payment-artifact",
760
+ paymentArtifactPath,
761
+ "--wait",
762
+ "--json",
763
+ ]
764
+ : [
765
+ "warpmetal",
766
+ "checkout",
767
+ "submit",
768
+ "--task",
769
+ taskId,
770
+ "--payment-artifact",
771
+ paymentArtifactPath,
772
+ "--wait",
773
+ "--json",
774
+ ],
760
775
  },
761
776
  compatibility: {
762
777
  alternativeInput: "--payment-signature-file <path>",
package/src/ssh.js CHANGED
@@ -1,5 +1,13 @@
1
- import { createHash } from "node:crypto";
2
- import { lstat, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import {
3
+ chmod,
4
+ lstat,
5
+ mkdir,
6
+ mkdtemp,
7
+ readFile,
8
+ rm,
9
+ writeFile,
10
+ } from "node:fs/promises";
3
11
  import { tmpdir } from "node:os";
4
12
  import { join, resolve } from "node:path";
5
13
  import { spawnSync } from "node:child_process";
@@ -92,6 +100,107 @@ async function requireMissing(path) {
92
100
  }
93
101
  }
94
102
 
103
+ async function isMissing(path) {
104
+ try {
105
+ await lstat(path);
106
+ return false;
107
+ } catch (error) {
108
+ if (error?.code === "ENOENT") return true;
109
+ throw error;
110
+ }
111
+ }
112
+
113
+ export function serverKeyName(hostname, requestedName) {
114
+ const normalizedHostname = String(hostname || "").trim().toLowerCase();
115
+ if (
116
+ !/^(?=.{1,63}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(
117
+ normalizedHostname,
118
+ )
119
+ ) {
120
+ throw new CliError(
121
+ "--hostname must be a valid DNS label before generating an SSH identity.",
122
+ { exitCode: 2 },
123
+ );
124
+ }
125
+ let value = String(requestedName || normalizedHostname)
126
+ .trim()
127
+ .toLowerCase();
128
+ if (!value.startsWith("warpmetal-")) value = `warpmetal-${value}`;
129
+ if (!/^[a-z0-9][a-z0-9._-]{0,79}$/.test(value)) {
130
+ throw new CliError(
131
+ "The SSH key name must contain only lowercase letters, digits, dots, underscores, or hyphens.",
132
+ { exitCode: 2 },
133
+ );
134
+ }
135
+ return { hostname: normalizedHostname, keyName: value };
136
+ }
137
+
138
+ export async function generateServerKey(
139
+ directory,
140
+ hostname,
141
+ requestedName,
142
+ { spawn = spawnSync, random = randomBytes } = {},
143
+ ) {
144
+ const normalized = serverKeyName(hostname, requestedName);
145
+ const identityDirectory = resolve(directory);
146
+ await mkdir(identityDirectory, { recursive: true, mode: 0o700 });
147
+ if (process.platform !== "win32") await chmod(identityDirectory, 0o700);
148
+
149
+ let keyName = normalized.keyName;
150
+ let privatePath = join(identityDirectory, keyName);
151
+ for (let attempt = 0; attempt < 32; attempt += 1) {
152
+ const publicPath = `${privatePath}.pub`;
153
+ if ((await isMissing(privatePath)) && (await isMissing(publicPath))) break;
154
+ const suffix = random(2).toString("hex");
155
+ keyName = `${normalized.keyName.slice(0, 75)}-${suffix}`;
156
+ privatePath = join(identityDirectory, keyName);
157
+ }
158
+ const publicPath = `${privatePath}.pub`;
159
+ await requireMissing(privatePath);
160
+ await requireMissing(publicPath);
161
+ const result = spawn(
162
+ "ssh-keygen",
163
+ [
164
+ "-q",
165
+ "-t",
166
+ "ed25519",
167
+ "-N",
168
+ "",
169
+ "-C",
170
+ `warpmetal:${normalized.hostname}`,
171
+ "-f",
172
+ privatePath,
173
+ ],
174
+ { stdio: ["ignore", "ignore", "pipe"], shell: false },
175
+ );
176
+ if (result.error?.code === "ENOENT") {
177
+ throw new CliError(
178
+ "ssh-keygen is required to generate a WarpMetal server identity.",
179
+ { exitCode: 2 },
180
+ );
181
+ }
182
+ if (result.error) throw result.error;
183
+ if (result.status !== 0) {
184
+ throw new CliError("ssh-keygen could not generate the server identity.", {
185
+ exitCode: 4,
186
+ });
187
+ }
188
+ if (process.platform !== "win32") {
189
+ await chmod(privatePath, 0o600);
190
+ await chmod(publicPath, 0o644);
191
+ }
192
+ const publicKey = await readSshPublicKey(publicPath);
193
+ return {
194
+ keyName,
195
+ hostnameAtCreation: normalized.hostname,
196
+ privateKeyPath: privatePath,
197
+ publicKeyPath: publicPath,
198
+ keyType: "ed25519",
199
+ sshFingerprint: sshFingerprint(publicKey),
200
+ sshPublicKey: publicKey,
201
+ };
202
+ }
203
+
95
204
  export function sshFingerprint(publicKey) {
96
205
  const parts = publicKey.trim().split(/\s+/);
97
206
  if (parts.length < 2)
package/src/state.js CHANGED
@@ -5,7 +5,7 @@ import { dirname, isAbsolute, join, resolve } from "node:path";
5
5
 
6
6
  import { CliError } from "./errors.js";
7
7
 
8
- const STATE_VERSION = 2;
8
+ const STATE_VERSION = 3;
9
9
 
10
10
  function emptyState() {
11
11
  return {
@@ -16,6 +16,8 @@ function emptyState() {
16
16
  runtimes: {},
17
17
  sandboxes: {},
18
18
  accessGrants: {},
19
+ identities: {},
20
+ renewals: {},
19
21
  };
20
22
  }
21
23
 
@@ -62,6 +64,24 @@ function migrateState(value) {
62
64
  runtimes: {},
63
65
  sandboxes: {},
64
66
  accessGrants: {},
67
+ identities: {},
68
+ renewals: {},
69
+ };
70
+ }
71
+ if (
72
+ value?.version === 2 &&
73
+ value.orders &&
74
+ value.servers &&
75
+ value.operations &&
76
+ value.runtimes &&
77
+ value.sandboxes &&
78
+ value.accessGrants
79
+ ) {
80
+ return {
81
+ ...value,
82
+ version: STATE_VERSION,
83
+ identities: {},
84
+ renewals: {},
65
85
  };
66
86
  }
67
87
  return value;
@@ -80,7 +100,9 @@ function validateState(input) {
80
100
  !value.operations ||
81
101
  !value.runtimes ||
82
102
  !value.sandboxes ||
83
- !value.accessGrants
103
+ !value.accessGrants ||
104
+ !value.identities ||
105
+ !value.renewals
84
106
  ) {
85
107
  throw new CliError("Incomplete WarpMetal state file.", { exitCode: 2 });
86
108
  }
@@ -121,7 +143,7 @@ export class StateStore {
121
143
  return result;
122
144
  }
123
145
 
124
- async savePreparedOrder(response, checkoutBody) {
146
+ async savePreparedOrder(response, checkoutBody, identityId) {
125
147
  const { task, ownerToken } = response;
126
148
  if (!task?.id || !task?.serverId || !ownerToken) {
127
149
  throw new CliError("WarpMetal returned an incomplete prepared order.");
@@ -134,6 +156,7 @@ export class StateStore {
134
156
  checkoutPath: task.checkoutPath,
135
157
  checkoutBody,
136
158
  ownerToken,
159
+ identityId,
137
160
  createdAt: new Date().toISOString(),
138
161
  };
139
162
  state.servers[task.serverId] = {
@@ -141,8 +164,115 @@ export class StateStore {
141
164
  serverId: task.serverId,
142
165
  taskId: task.id,
143
166
  ownerToken,
167
+ identityId,
168
+ };
169
+ if (identityId && state.identities[identityId]) {
170
+ state.identities[identityId].taskId = task.id;
171
+ state.identities[identityId].serverId = task.serverId;
172
+ state.identities[identityId].boundAt = new Date().toISOString();
173
+ }
174
+ });
175
+ }
176
+
177
+ async saveIdentity(identity) {
178
+ if (!identity?.identityId || !identity?.keyName || !identity?.publicKeyPath) {
179
+ throw new CliError("The SSH identity record is incomplete.", { exitCode: 2 });
180
+ }
181
+ await this.update((state) => {
182
+ state.identities[identity.identityId] = {
183
+ ...identity,
184
+ createdAt: identity.createdAt || new Date().toISOString(),
144
185
  };
145
186
  });
187
+ return identity;
188
+ }
189
+
190
+ async bindIdentity(identityId, taskId, serverId) {
191
+ await this.update((state) => {
192
+ const identity = state.identities[identityId];
193
+ if (!identity) {
194
+ throw new CliError(`No local SSH identity exists for ${identityId}.`, {
195
+ exitCode: 2,
196
+ });
197
+ }
198
+ if (taskId) identity.taskId = taskId;
199
+ identity.serverId = serverId;
200
+ identity.boundAt = new Date().toISOString();
201
+ const server = state.servers[serverId] || { serverId };
202
+ server.identityId = identityId;
203
+ state.servers[serverId] = server;
204
+ });
205
+ }
206
+
207
+ async identity(identityId) {
208
+ return (await this.read()).identities[identityId];
209
+ }
210
+
211
+ async identityForServer(serverId) {
212
+ const state = await this.read();
213
+ const identityId = state.servers[serverId]?.identityId;
214
+ if (identityId && state.identities[identityId]) {
215
+ return state.identities[identityId];
216
+ }
217
+ return Object.values(state.identities).find(
218
+ (identity) => identity.serverId === serverId,
219
+ );
220
+ }
221
+
222
+ async saveRenewalPolicy(serverId, policy, wallet, refillTargetAtomic) {
223
+ await this.update((state) => {
224
+ state.renewals[serverId] = {
225
+ ...(state.renewals[serverId] || {}),
226
+ serverId,
227
+ policy,
228
+ wallet,
229
+ refillTargetAtomic,
230
+ updatedAt: new Date().toISOString(),
231
+ };
232
+ });
233
+ }
234
+
235
+ async saveRenewalChallenge(serverId, challenge) {
236
+ await this.update((state) => {
237
+ const renewal = state.renewals[serverId] || { serverId };
238
+ Object.assign(renewal, challenge, {
239
+ challengeSavedAt: new Date().toISOString(),
240
+ });
241
+ state.renewals[serverId] = renewal;
242
+ });
243
+ }
244
+
245
+ async saveRenewalPaymentAttempt(serverId, payment) {
246
+ await this.update((state) => {
247
+ const renewal = state.renewals[serverId];
248
+ if (!renewal) {
249
+ throw new CliError(`No local renewal state exists for ${serverId}.`, {
250
+ exitCode: 2,
251
+ });
252
+ }
253
+ if (
254
+ renewal.paymentRequestDigest &&
255
+ renewal.paymentRequestDigest !== payment.requestDigest
256
+ ) {
257
+ throw new CliError(
258
+ `The wallet payment attempt does not match the saved renewal for ${serverId}.`,
259
+ { exitCode: 2 },
260
+ );
261
+ }
262
+ Object.assign(renewal, {
263
+ walletPaymentAttemptId: payment.attemptId,
264
+ walletBuyerPaymentIdentifier: payment.buyerPaymentIdentifier,
265
+ walletName: payment.wallet,
266
+ walletPayerAddress: payment.payerAddress,
267
+ paymentArtifactPath: payment.path,
268
+ paymentArtifactExpiresAt: payment.expiresAt,
269
+ paymentArtifactSavedAt: new Date().toISOString(),
270
+ });
271
+ });
272
+ }
273
+
274
+ async renewal(serverId) {
275
+ return (await this.read()).renewals[serverId];
146
276
  }
147
277
 
148
278
  async savePaymentChallenge(
@@ -217,12 +347,13 @@ export class StateStore {
217
347
  });
218
348
  }
219
349
 
220
- async saveOperation(operationId, serverId, kind) {
350
+ async saveOperation(operationId, serverId, kind, metadata = {}) {
221
351
  await this.update((state) => {
222
352
  state.operations[operationId] = {
223
353
  operationId,
224
354
  serverId,
225
355
  kind,
356
+ ...metadata,
226
357
  createdAt: new Date().toISOString(),
227
358
  };
228
359
  });
@@ -350,6 +481,26 @@ export class StateStore {
350
481
  runtimes: Object.values(state.runtimes),
351
482
  sandboxes: Object.values(state.sandboxes),
352
483
  accessGrants: Object.values(state.accessGrants),
484
+ identities: Object.values(state.identities).map((identity) => ({
485
+ identityId: identity.identityId,
486
+ keyName: identity.keyName,
487
+ hostnameAtCreation: identity.hostnameAtCreation,
488
+ publicKeyPath: identity.publicKeyPath,
489
+ privateKeyPath: identity.privateKeyPath,
490
+ sshFingerprint: identity.sshFingerprint,
491
+ serverId: identity.serverId,
492
+ taskId: identity.taskId,
493
+ createdAt: identity.createdAt,
494
+ boundAt: identity.boundAt,
495
+ })),
496
+ renewals: Object.values(state.renewals).map((renewal) => ({
497
+ serverId: renewal.serverId,
498
+ wallet: renewal.wallet,
499
+ refillTargetAtomic: renewal.refillTargetAtomic,
500
+ policy: renewal.policy,
501
+ paymentAttemptId: renewal.paymentAttemptId,
502
+ paymentArtifactExpiresAt: renewal.paymentArtifactExpiresAt,
503
+ })),
353
504
  };
354
505
  }
355
506