@serviceme/devtools-core 0.1.8 → 0.2.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/dist/auth.d.mts +590 -0
- package/dist/auth.d.ts +590 -0
- package/dist/auth.js +842 -0
- package/dist/auth.js.map +1 -0
- package/dist/auth.mjs +804 -0
- package/dist/auth.mjs.map +1 -0
- package/dist/device.d.mts +456 -0
- package/dist/device.d.ts +456 -0
- package/dist/device.js +696 -0
- package/dist/device.js.map +1 -0
- package/dist/device.mjs +647 -0
- package/dist/device.mjs.map +1 -0
- package/dist/index-CrNC-aao.d.ts +277 -0
- package/dist/index-Dmyy4urr.d.mts +277 -0
- package/dist/index.d.mts +1372 -27
- package/dist/index.d.ts +1372 -27
- package/dist/index.js +5125 -888
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +5022 -906
- package/dist/index.mjs.map +1 -0
- package/dist/skill-linker.d.mts +188 -0
- package/dist/skill-linker.d.ts +188 -0
- package/dist/skill-linker.js +374 -0
- package/dist/skill-linker.js.map +1 -0
- package/dist/skill-linker.mjs +330 -0
- package/dist/skill-linker.mjs.map +1 -0
- package/dist/skill-store.d.mts +35 -0
- package/dist/skill-store.d.ts +35 -0
- package/dist/skill-store.js +291 -0
- package/dist/skill-store.js.map +1 -0
- package/dist/skill-store.mjs +254 -0
- package/dist/skill-store.mjs.map +1 -0
- package/dist/submit.d.mts +3 -0
- package/dist/submit.d.ts +3 -0
- package/dist/submit.js +166 -0
- package/dist/submit.js.map +1 -0
- package/dist/submit.mjs +130 -0
- package/dist/submit.mjs.map +1 -0
- package/dist/toolbox.d.mts +240 -0
- package/dist/toolbox.d.ts +240 -0
- package/dist/toolbox.js +530 -0
- package/dist/toolbox.js.map +1 -0
- package/dist/toolbox.mjs +482 -0
- package/dist/toolbox.mjs.map +1 -0
- package/dist/types-B9gk3dXH.d.mts +62 -0
- package/dist/types-B9gk3dXH.d.ts +62 -0
- package/package.json +50 -5
package/dist/device.js
ADDED
|
@@ -0,0 +1,696 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/device/index.ts
|
|
31
|
+
var device_exports = {};
|
|
32
|
+
__export(device_exports, {
|
|
33
|
+
DEVICE_JSON_SCHEMA_VERSION: () => DEVICE_JSON_SCHEMA_VERSION,
|
|
34
|
+
DeviceAuthHeaders: () => DeviceAuthHeaders,
|
|
35
|
+
DeviceCore: () => DeviceCore,
|
|
36
|
+
DeviceReenrollRequiresAuthError: () => DeviceReenrollRequiresAuthError,
|
|
37
|
+
DeviceSecretVersionMismatchError: () => DeviceSecretVersionMismatchError,
|
|
38
|
+
Enroller: () => Enroller,
|
|
39
|
+
FsIdentityFileBackend: () => FsIdentityFileBackend,
|
|
40
|
+
IdentityStore: () => IdentityStore,
|
|
41
|
+
buildSignedHeaders: () => buildSignedHeaders,
|
|
42
|
+
createDeviceRequestSignature: () => createDeviceRequestSignature,
|
|
43
|
+
deriveInstallationId: () => deriveInstallationId,
|
|
44
|
+
fingerprintSource: () => fingerprintSource,
|
|
45
|
+
randomInstallationId: () => randomInstallationId
|
|
46
|
+
});
|
|
47
|
+
module.exports = __toCommonJS(device_exports);
|
|
48
|
+
|
|
49
|
+
// src/device/deviceAuth.ts
|
|
50
|
+
var import_node_crypto = require("crypto");
|
|
51
|
+
var DeviceAuthHeaders = {
|
|
52
|
+
deviceId: "x-ms-device-id",
|
|
53
|
+
deviceSecret: "x-ms-device-secret",
|
|
54
|
+
signature: "x-ms-device-signature",
|
|
55
|
+
timestamp: "x-ms-device-timestamp",
|
|
56
|
+
secretVersion: "x-ms-device-secret-version"
|
|
57
|
+
};
|
|
58
|
+
function createDeviceRequestSignature(params) {
|
|
59
|
+
const basis = [
|
|
60
|
+
params.method.toUpperCase(),
|
|
61
|
+
params.path,
|
|
62
|
+
String(params.timestamp),
|
|
63
|
+
params.body,
|
|
64
|
+
params.secret
|
|
65
|
+
].join("\n");
|
|
66
|
+
return (0, import_node_crypto.createHash)("sha256").update(basis).digest("hex");
|
|
67
|
+
}
|
|
68
|
+
function buildSignedHeaders(params) {
|
|
69
|
+
const timestamp = params.timestamp ?? Date.now();
|
|
70
|
+
const signature = createDeviceRequestSignature({
|
|
71
|
+
method: params.method,
|
|
72
|
+
path: params.path,
|
|
73
|
+
timestamp,
|
|
74
|
+
body: params.body,
|
|
75
|
+
secret: params.deviceSecret
|
|
76
|
+
});
|
|
77
|
+
return {
|
|
78
|
+
[DeviceAuthHeaders.deviceId]: params.publicId,
|
|
79
|
+
[DeviceAuthHeaders.deviceSecret]: params.deviceSecret,
|
|
80
|
+
[DeviceAuthHeaders.signature]: signature,
|
|
81
|
+
[DeviceAuthHeaders.timestamp]: String(timestamp),
|
|
82
|
+
[DeviceAuthHeaders.secretVersion]: String(params.secretVersion)
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// src/device/Enroller.ts
|
|
87
|
+
var import_node_crypto3 = require("crypto");
|
|
88
|
+
|
|
89
|
+
// src/device/InstallationId.ts
|
|
90
|
+
var import_node_crypto2 = require("crypto");
|
|
91
|
+
var os = __toESM(require("os"));
|
|
92
|
+
function fingerprintMaterial() {
|
|
93
|
+
let username = "unknown";
|
|
94
|
+
try {
|
|
95
|
+
username = os.userInfo().username;
|
|
96
|
+
} catch {
|
|
97
|
+
username = process.env.USER ?? process.env.USERNAME ?? "unknown";
|
|
98
|
+
}
|
|
99
|
+
return [
|
|
100
|
+
os.hostname(),
|
|
101
|
+
username,
|
|
102
|
+
os.platform(),
|
|
103
|
+
os.arch(),
|
|
104
|
+
process.versions.node ?? "unknown"
|
|
105
|
+
].join("|");
|
|
106
|
+
}
|
|
107
|
+
function deriveInstallationId() {
|
|
108
|
+
const material = fingerprintMaterial();
|
|
109
|
+
const digest = (0, import_node_crypto2.createHash)("sha256").update(material).digest("hex");
|
|
110
|
+
return formatAsV4(digest.slice(0, 32));
|
|
111
|
+
}
|
|
112
|
+
function randomInstallationId() {
|
|
113
|
+
return (0, import_node_crypto2.randomUUID)();
|
|
114
|
+
}
|
|
115
|
+
function fingerprintSource() {
|
|
116
|
+
return fingerprintMaterial();
|
|
117
|
+
}
|
|
118
|
+
function formatAsV4(hex32) {
|
|
119
|
+
const chars = hex32.split("");
|
|
120
|
+
const versionIdx = 12;
|
|
121
|
+
const variantIdx = 16;
|
|
122
|
+
const versionChar = parseInt(chars[versionIdx] ?? "8", 16) & 0 | 4;
|
|
123
|
+
chars[versionIdx] = versionChar.toString(16);
|
|
124
|
+
const variantChar = parseInt(chars[variantIdx] ?? "8", 16) & 3 | 8;
|
|
125
|
+
chars[variantIdx] = variantChar.toString(16);
|
|
126
|
+
const formatted = chars.join("");
|
|
127
|
+
return `${formatted.slice(0, 8)}-${formatted.slice(8, 12)}-${formatted.slice(12, 16)}-${formatted.slice(16, 20)}-${formatted.slice(20, 32)}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// src/device/Enroller.ts
|
|
131
|
+
var SECRET_BYTES = 32;
|
|
132
|
+
var PUBLIC_ID_BYTES = 16;
|
|
133
|
+
var defaultRandomBytes = (size) => {
|
|
134
|
+
return (0, import_node_crypto3.randomBytes)(size);
|
|
135
|
+
};
|
|
136
|
+
var DeviceReenrollRequiresAuthError = class extends Error {
|
|
137
|
+
constructor(message = "Re-enroll on a claimed device requires current device credentials or the bound user") {
|
|
138
|
+
super(message);
|
|
139
|
+
this.name = "DeviceReenrollRequiresAuthError";
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
var DeviceSecretVersionMismatchError = class extends Error {
|
|
143
|
+
constructor(message = "Device secret version mismatch \u2014 server has rotated past the local copy") {
|
|
144
|
+
super(message);
|
|
145
|
+
this.name = "DeviceSecretVersionMismatchError";
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
var Enroller = class {
|
|
149
|
+
constructor(opts) {
|
|
150
|
+
this.inflight = null;
|
|
151
|
+
this.identity = opts.identityStore;
|
|
152
|
+
this.now = opts.now ?? (() => /* @__PURE__ */ new Date());
|
|
153
|
+
this.random = opts.randomBytes ?? defaultRandomBytes;
|
|
154
|
+
this.enrollRequest = opts.enrollRequest;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Read the current binding state without touching the disk.
|
|
158
|
+
* Returns `anonymous` when no identity is stored.
|
|
159
|
+
*/
|
|
160
|
+
async currentState() {
|
|
161
|
+
const stored = await this.identity.read();
|
|
162
|
+
return stored?.bindingState ?? "anonymous";
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Drive the enrollment flow.
|
|
166
|
+
*
|
|
167
|
+
* @param force when true, drop the local identity and start fresh
|
|
168
|
+
* (server treats this as a brand-new install).
|
|
169
|
+
* @param requireAuth when true, refuse to silently re-enroll an
|
|
170
|
+
* existing claimed device — throw
|
|
171
|
+
* `DeviceReenrollRequiresAuthError` instead.
|
|
172
|
+
*/
|
|
173
|
+
/**
|
|
174
|
+
* Resolve when any in-flight enrollment completes. Returns immediately
|
|
175
|
+
* when no enrollment is in progress. Allows callers (e.g. the extension's
|
|
176
|
+
* `buildDeviceAuthHeaders`) to wait for a concurrent `syncDeviceInfo()`
|
|
177
|
+
* enrollment before attempting to read the identity from the store.
|
|
178
|
+
*/
|
|
179
|
+
async waitForEnrollment() {
|
|
180
|
+
if (this.inflight) {
|
|
181
|
+
await this.inflight;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
async enroll(opts = {}) {
|
|
185
|
+
if (this.inflight) {
|
|
186
|
+
return this.inflight;
|
|
187
|
+
}
|
|
188
|
+
const promise = this.runEnroll(opts);
|
|
189
|
+
this.inflight = promise;
|
|
190
|
+
try {
|
|
191
|
+
return await promise;
|
|
192
|
+
} finally {
|
|
193
|
+
if (this.inflight === promise) this.inflight = null;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/** Test seam — surface the underlying identity store. */
|
|
197
|
+
getIdentityStore() {
|
|
198
|
+
return this.identity;
|
|
199
|
+
}
|
|
200
|
+
/** True when an enrollment is currently in-flight. Used by callers (e.g. the extension's `buildDeviceAuthHeaders`) to skip triggering a competing enrollment. */
|
|
201
|
+
isEnrolling() {
|
|
202
|
+
return this.inflight !== null;
|
|
203
|
+
}
|
|
204
|
+
async runEnroll(opts) {
|
|
205
|
+
const { written } = await this.identity.mutate(async (current) => {
|
|
206
|
+
const existing = opts.force ? null : current;
|
|
207
|
+
if (!opts.force && current) {
|
|
208
|
+
if (current.bindingState === "expired") {
|
|
209
|
+
} else if (opts.requireAuth && (current.bindingState === "claimed" || current.bindingState === "pending")) {
|
|
210
|
+
throw new DeviceReenrollRequiresAuthError();
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const installationId = current?.installationId ?? deriveInstallationId();
|
|
214
|
+
const machineId = current?.machineId ?? "unknown";
|
|
215
|
+
const platform3 = current?.platform ?? "unknown";
|
|
216
|
+
let response;
|
|
217
|
+
if (this.enrollRequest) {
|
|
218
|
+
response = await this.enrollRequest({
|
|
219
|
+
installationId,
|
|
220
|
+
machineId,
|
|
221
|
+
platform: platform3,
|
|
222
|
+
existing,
|
|
223
|
+
force: Boolean(opts.force),
|
|
224
|
+
requireAuth: Boolean(opts.requireAuth)
|
|
225
|
+
});
|
|
226
|
+
} else {
|
|
227
|
+
response = synthesizeEnrollResponse(this.random, existing);
|
|
228
|
+
}
|
|
229
|
+
const next = {
|
|
230
|
+
version: current?.version ?? 1,
|
|
231
|
+
installationId,
|
|
232
|
+
machineId,
|
|
233
|
+
platform: platform3,
|
|
234
|
+
hostname: current?.hostname,
|
|
235
|
+
publicId: response.publicId,
|
|
236
|
+
secretVersion: response.secretVersion,
|
|
237
|
+
bindingState: response.bindingState,
|
|
238
|
+
deviceSecret: response.deviceSecret,
|
|
239
|
+
previousDeviceSecret: existing?.deviceSecret,
|
|
240
|
+
previousSecretExpiresAt: opts.force || response.secretVersion === (existing?.secretVersion ?? 0) + 1 ? void 0 : existing?.previousSecretExpiresAt,
|
|
241
|
+
lastEnrollAt: this.now().toISOString(),
|
|
242
|
+
lastSyncAt: existing?.lastSyncAt,
|
|
243
|
+
lastSyncError: void 0
|
|
244
|
+
};
|
|
245
|
+
return { next };
|
|
246
|
+
});
|
|
247
|
+
return {
|
|
248
|
+
publicId: written.publicId,
|
|
249
|
+
bindingState: written.bindingState,
|
|
250
|
+
expiresAt: deriveExpiresAt(written, this.now)
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Rotate the HMAC secret. Keeps the previous secret for the grace
|
|
255
|
+
* window (default 7 days per `2.需求澄清.md` §1.2) — the
|
|
256
|
+
* `previousSecretExpiresAt` is stamped on the persisted identity.
|
|
257
|
+
*/
|
|
258
|
+
async rotateSecret(opts = {}) {
|
|
259
|
+
const gracePeriodDays = opts.gracePeriodDays ?? 7;
|
|
260
|
+
const now = this.now();
|
|
261
|
+
const newSecret = this.random(SECRET_BYTES).toString("hex");
|
|
262
|
+
const { written } = await this.identity.mutate(async (current) => {
|
|
263
|
+
if (!current) {
|
|
264
|
+
throw new Error("Cannot rotate-secret without a prior enrollment");
|
|
265
|
+
}
|
|
266
|
+
const graceExpiresAt = new Date(now.getTime() + gracePeriodDays * 24 * 60 * 60 * 1e3);
|
|
267
|
+
const next = {
|
|
268
|
+
...current,
|
|
269
|
+
deviceSecret: newSecret,
|
|
270
|
+
previousDeviceSecret: current.deviceSecret,
|
|
271
|
+
previousSecretExpiresAt: graceExpiresAt.toISOString(),
|
|
272
|
+
secretVersion: current.secretVersion + 1,
|
|
273
|
+
lastEnrollAt: now.toISOString()
|
|
274
|
+
};
|
|
275
|
+
return { next };
|
|
276
|
+
});
|
|
277
|
+
return {
|
|
278
|
+
publicId: written.publicId,
|
|
279
|
+
secretVersion: written.secretVersion,
|
|
280
|
+
gracePeriodDays
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Mark the device as `expired`. Used when the server returns a
|
|
285
|
+
* device-expiry response; the next `enroll()` call forces a fresh
|
|
286
|
+
* round-trip.
|
|
287
|
+
*/
|
|
288
|
+
async markExpired() {
|
|
289
|
+
await this.identity.mutate(async (current) => {
|
|
290
|
+
if (!current) {
|
|
291
|
+
return { next: current ?? await emptyIdentity(this.random), result: void 0 };
|
|
292
|
+
}
|
|
293
|
+
const next = { ...current, bindingState: "expired" };
|
|
294
|
+
return { next };
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Mark the device as `claimed`. Called by the bridge after a
|
|
299
|
+
* successful `device.claim` server response.
|
|
300
|
+
*/
|
|
301
|
+
async markClaimed() {
|
|
302
|
+
await this.identity.mutate(async (current) => {
|
|
303
|
+
if (!current) {
|
|
304
|
+
throw new Error("Cannot mark-claimed without a prior enrollment");
|
|
305
|
+
}
|
|
306
|
+
const next = {
|
|
307
|
+
...current,
|
|
308
|
+
bindingState: "claimed",
|
|
309
|
+
lastSyncAt: this.now().toISOString(),
|
|
310
|
+
lastSyncError: void 0
|
|
311
|
+
};
|
|
312
|
+
return { next };
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
function deriveExpiresAt(_identity, _now) {
|
|
317
|
+
return void 0;
|
|
318
|
+
}
|
|
319
|
+
function synthesizeEnrollResponse(random, existing) {
|
|
320
|
+
const publicId = existing?.publicId ?? random(PUBLIC_ID_BYTES).toString("hex");
|
|
321
|
+
const secretVersion = (existing?.secretVersion ?? 0) + 1;
|
|
322
|
+
return {
|
|
323
|
+
publicId,
|
|
324
|
+
deviceSecret: random(SECRET_BYTES).toString("hex"),
|
|
325
|
+
secretVersion,
|
|
326
|
+
bindingState: existing?.bindingState === "claimed" ? "claimed" : "anonymous"
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
async function emptyIdentity(random) {
|
|
330
|
+
return {
|
|
331
|
+
version: 1,
|
|
332
|
+
installationId: deriveInstallationId(),
|
|
333
|
+
machineId: "unknown",
|
|
334
|
+
platform: "unknown",
|
|
335
|
+
publicId: random(PUBLIC_ID_BYTES).toString("hex"),
|
|
336
|
+
secretVersion: 1,
|
|
337
|
+
bindingState: "anonymous",
|
|
338
|
+
deviceSecret: random(SECRET_BYTES).toString("hex"),
|
|
339
|
+
lastEnrollAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// src/device/IdentityStore.ts
|
|
344
|
+
var fsp = __toESM(require("fs/promises"));
|
|
345
|
+
var os3 = __toESM(require("os"));
|
|
346
|
+
var path2 = __toESM(require("path"));
|
|
347
|
+
var import_promises = require("timers/promises");
|
|
348
|
+
|
|
349
|
+
// src/paths/userHome.ts
|
|
350
|
+
var os2 = __toESM(require("os"));
|
|
351
|
+
var path = __toESM(require("path"));
|
|
352
|
+
var SERVICEME_DIR_NAME = ".serviceme";
|
|
353
|
+
var SERVICEME_HOME_ENV = "SERVICEME_HOME";
|
|
354
|
+
var activeOverrides = {};
|
|
355
|
+
function resolveHomeDir() {
|
|
356
|
+
const injected = activeOverrides.homeDir;
|
|
357
|
+
if (injected !== void 0) {
|
|
358
|
+
return injected;
|
|
359
|
+
}
|
|
360
|
+
return os2.homedir();
|
|
361
|
+
}
|
|
362
|
+
function resolveServicemeHomeEnv() {
|
|
363
|
+
const injected = activeOverrides.servicemeHomeEnv;
|
|
364
|
+
if (injected !== void 0) {
|
|
365
|
+
return injected.length > 0 ? injected : void 0;
|
|
366
|
+
}
|
|
367
|
+
const envValue = process.env[SERVICEME_HOME_ENV];
|
|
368
|
+
return envValue && envValue.length > 0 ? envValue : void 0;
|
|
369
|
+
}
|
|
370
|
+
function getServicemeHome() {
|
|
371
|
+
const override = resolveServicemeHomeEnv();
|
|
372
|
+
if (override !== void 0) {
|
|
373
|
+
return path.resolve(override);
|
|
374
|
+
}
|
|
375
|
+
return path.join(resolveHomeDir(), SERVICEME_DIR_NAME);
|
|
376
|
+
}
|
|
377
|
+
var DEVICE_JSON_FILENAME = "device.json";
|
|
378
|
+
function getDeviceJsonPath() {
|
|
379
|
+
return path.join(getServicemeHome(), DEVICE_JSON_FILENAME);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// src/device/types.ts
|
|
383
|
+
var DEVICE_JSON_SCHEMA_VERSION = 1;
|
|
384
|
+
|
|
385
|
+
// src/device/IdentityStore.ts
|
|
386
|
+
var FILE_MODE = 384;
|
|
387
|
+
var LOCK_DIR_MODE = 448;
|
|
388
|
+
var DEFAULT_LOCK_TIMEOUT_MS = 5e3;
|
|
389
|
+
var DEFAULT_LOCK_RETRY_MS = 25;
|
|
390
|
+
var TMP_SUFFIX = ".tmp";
|
|
391
|
+
var FsIdentityFileBackend = class {
|
|
392
|
+
async exists(filePath) {
|
|
393
|
+
try {
|
|
394
|
+
await fsp.access(filePath);
|
|
395
|
+
return true;
|
|
396
|
+
} catch {
|
|
397
|
+
return false;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
async read(filePath) {
|
|
401
|
+
try {
|
|
402
|
+
const buf = await fsp.readFile(filePath, "utf8");
|
|
403
|
+
const parsed = JSON.parse(buf);
|
|
404
|
+
return migratePersistedIdentity(parsed);
|
|
405
|
+
} catch (err) {
|
|
406
|
+
if (isNodeError(err) && err.code === "ENOENT") return null;
|
|
407
|
+
throw err;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
async write(filePath, payload) {
|
|
411
|
+
await fsp.mkdir(path2.dirname(filePath), { recursive: true });
|
|
412
|
+
const tmpPath = `${filePath}${TMP_SUFFIX}`;
|
|
413
|
+
const bytes = Buffer.from(JSON.stringify(payload, null, " "), "utf8");
|
|
414
|
+
await fsp.rm(tmpPath, { force: true });
|
|
415
|
+
const handle = await fsp.open(tmpPath, "w", FILE_MODE);
|
|
416
|
+
try {
|
|
417
|
+
await handle.writeFile(bytes);
|
|
418
|
+
await handle.sync();
|
|
419
|
+
} finally {
|
|
420
|
+
await handle.close();
|
|
421
|
+
}
|
|
422
|
+
await fsp.rename(tmpPath, filePath);
|
|
423
|
+
await fsp.chmod(filePath, FILE_MODE).catch(() => void 0);
|
|
424
|
+
return { bytesWritten: bytes.byteLength, tmpPath };
|
|
425
|
+
}
|
|
426
|
+
async delete(filePath) {
|
|
427
|
+
await fsp.rm(filePath, { force: true });
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
function migratePersistedIdentity(parsed) {
|
|
431
|
+
if (!isRecord(parsed)) {
|
|
432
|
+
throw new Error("device.json: top-level must be an object");
|
|
433
|
+
}
|
|
434
|
+
const version = parsed.version;
|
|
435
|
+
if (version === DEVICE_JSON_SCHEMA_VERSION) {
|
|
436
|
+
if (parsed.publicId === void 0 && parsed.deviceSecret === void 0 && ("claimed" in parsed || "publicKeyFingerprint" in parsed)) {
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
return parsed;
|
|
440
|
+
}
|
|
441
|
+
if (typeof version === "number" && version < DEVICE_JSON_SCHEMA_VERSION) {
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
444
|
+
throw new Error(`device.json: unsupported schema version ${String(version)}`);
|
|
445
|
+
}
|
|
446
|
+
function isRecord(value) {
|
|
447
|
+
return typeof value === "object" && value !== null;
|
|
448
|
+
}
|
|
449
|
+
function isNodeError(value) {
|
|
450
|
+
return value instanceof Error && typeof value.code === "string";
|
|
451
|
+
}
|
|
452
|
+
var FileLock = class {
|
|
453
|
+
constructor(filePath, timeoutMs, retryMs) {
|
|
454
|
+
this.acquired = false;
|
|
455
|
+
this.dirPath = `${filePath}.lock`;
|
|
456
|
+
this.timeoutMs = timeoutMs;
|
|
457
|
+
this.retryMs = retryMs;
|
|
458
|
+
}
|
|
459
|
+
async acquire() {
|
|
460
|
+
const start = Date.now();
|
|
461
|
+
while (true) {
|
|
462
|
+
try {
|
|
463
|
+
await fsp.mkdir(this.dirPath, { mode: LOCK_DIR_MODE });
|
|
464
|
+
this.acquired = true;
|
|
465
|
+
return;
|
|
466
|
+
} catch (err) {
|
|
467
|
+
if (!isNodeError(err) || err.code !== "EEXIST") {
|
|
468
|
+
throw err;
|
|
469
|
+
}
|
|
470
|
+
if (Date.now() - start >= this.timeoutMs) {
|
|
471
|
+
throw new Error(`IdentityStore lock acquisition timed out for ${this.dirPath}`);
|
|
472
|
+
}
|
|
473
|
+
await (0, import_promises.setTimeout)(this.retryMs);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
async release() {
|
|
478
|
+
if (!this.acquired) return;
|
|
479
|
+
this.acquired = false;
|
|
480
|
+
await fsp.rm(this.dirPath, { recursive: true, force: true });
|
|
481
|
+
}
|
|
482
|
+
};
|
|
483
|
+
var IdentityStore = class {
|
|
484
|
+
constructor(opts = {}) {
|
|
485
|
+
this.filePath = opts.filePath ?? getDeviceJsonPath();
|
|
486
|
+
this.backend = opts.backend ?? new FsIdentityFileBackend();
|
|
487
|
+
this.hooks = opts.hooks ?? {};
|
|
488
|
+
this.lockTimeoutMs = opts.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
|
|
489
|
+
this.lockRetryMs = opts.lockRetryMs ?? DEFAULT_LOCK_RETRY_MS;
|
|
490
|
+
this.now = opts.now ?? (() => /* @__PURE__ */ new Date());
|
|
491
|
+
}
|
|
492
|
+
/** Absolute path to the underlying JSON file (test seam). */
|
|
493
|
+
getFilePath() {
|
|
494
|
+
return this.filePath;
|
|
495
|
+
}
|
|
496
|
+
/** True when the JSON file already exists on disk. */
|
|
497
|
+
async exists() {
|
|
498
|
+
return this.backend.exists(this.filePath);
|
|
499
|
+
}
|
|
500
|
+
/** Read the persisted identity; returns `null` when no identity is stored. */
|
|
501
|
+
async read() {
|
|
502
|
+
return this.backend.read(this.filePath);
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Atomically write the given identity. Concurrent writers are
|
|
506
|
+
* serialized via the file lock; the read-modify-write happens
|
|
507
|
+
* inside the lock so callers can't see a partial state.
|
|
508
|
+
*/
|
|
509
|
+
async write(next) {
|
|
510
|
+
await this.hooks.beforeWrite?.(next);
|
|
511
|
+
const lock = new FileLock(this.filePath, this.lockTimeoutMs, this.lockRetryMs);
|
|
512
|
+
await lock.acquire();
|
|
513
|
+
try {
|
|
514
|
+
const stamped = {
|
|
515
|
+
...next,
|
|
516
|
+
version: DEVICE_JSON_SCHEMA_VERSION
|
|
517
|
+
};
|
|
518
|
+
const result = await this.backend.write(this.filePath, stamped);
|
|
519
|
+
await this.hooks.afterWrite?.(stamped);
|
|
520
|
+
return result;
|
|
521
|
+
} finally {
|
|
522
|
+
await lock.release();
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Read-modify-write under the same lock. The mutator receives the
|
|
527
|
+
* current identity (or `null` on first call) and returns the
|
|
528
|
+
* replacement. Throwing inside the mutator aborts the write.
|
|
529
|
+
*/
|
|
530
|
+
async mutate(mutator) {
|
|
531
|
+
const lock = new FileLock(this.filePath, this.lockTimeoutMs, this.lockRetryMs);
|
|
532
|
+
await lock.acquire();
|
|
533
|
+
try {
|
|
534
|
+
const current = await this.backend.read(this.filePath);
|
|
535
|
+
const { next, result } = await mutator(current);
|
|
536
|
+
const stamped = {
|
|
537
|
+
...next,
|
|
538
|
+
version: DEVICE_JSON_SCHEMA_VERSION
|
|
539
|
+
};
|
|
540
|
+
await this.hooks.beforeWrite?.(stamped);
|
|
541
|
+
await this.backend.write(this.filePath, stamped);
|
|
542
|
+
await this.hooks.afterWrite?.(stamped);
|
|
543
|
+
return { result, written: stamped };
|
|
544
|
+
} finally {
|
|
545
|
+
await lock.release();
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
/** Wipe the persisted identity (used by `device.enroll --force`). */
|
|
549
|
+
async clear() {
|
|
550
|
+
await this.backend.delete(this.filePath);
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Resolve the installation metadata for the current machine.
|
|
554
|
+
* Pure helper — no I/O, just `os.*` calls.
|
|
555
|
+
*/
|
|
556
|
+
resolveInstallationMetadata() {
|
|
557
|
+
const machineId = os3.hostname();
|
|
558
|
+
const platform3 = os3.platform();
|
|
559
|
+
return {
|
|
560
|
+
installationId: "",
|
|
561
|
+
// intentionally empty; caller fills via deriveInstallationId()
|
|
562
|
+
machineId,
|
|
563
|
+
platform: platform3
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* Ensure the parent directory exists (`~/.serviceme/`). Idempotent.
|
|
568
|
+
* Useful when the bootstrap phase5 placeholder wasn't run yet.
|
|
569
|
+
*/
|
|
570
|
+
async ensureHome() {
|
|
571
|
+
await fsp.mkdir(getServicemeHome(), { recursive: true });
|
|
572
|
+
await fsp.mkdir(path2.dirname(this.filePath), { recursive: true });
|
|
573
|
+
}
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
// src/device/DeviceCore.ts
|
|
577
|
+
var DeviceCore = class {
|
|
578
|
+
constructor(opts = {}) {
|
|
579
|
+
this.identity = opts.identityStore ?? new IdentityStore();
|
|
580
|
+
this.enroller = new Enroller({
|
|
581
|
+
identityStore: this.identity,
|
|
582
|
+
enrollRequest: opts.enrollRequest,
|
|
583
|
+
now: opts.now
|
|
584
|
+
});
|
|
585
|
+
this.resolveInstallationId = opts.resolveInstallationId ?? deriveInstallationId;
|
|
586
|
+
}
|
|
587
|
+
/** Read-only snapshot of the device status (matches `device.status` wire shape). */
|
|
588
|
+
async status() {
|
|
589
|
+
const stored = await this.identity.read();
|
|
590
|
+
return stored ? {
|
|
591
|
+
bindingState: stored.bindingState,
|
|
592
|
+
identity: projectIdentity(stored),
|
|
593
|
+
metadata: projectMetadata(stored),
|
|
594
|
+
lastSyncAt: stored.lastSyncAt,
|
|
595
|
+
lastSyncError: stored.lastSyncError
|
|
596
|
+
} : { bindingState: "anonymous" };
|
|
597
|
+
}
|
|
598
|
+
/** Enroll (or re-enroll) the device. */
|
|
599
|
+
async enroll(opts = {}) {
|
|
600
|
+
return this.enroller.enroll(opts);
|
|
601
|
+
}
|
|
602
|
+
/** Wait for any in-flight enrollment to finish. Use before `buildSignedHeaders` so that a concurrent `syncDeviceInfo` enrollment has time to write the identity to the store. */
|
|
603
|
+
async waitForEnrollment() {
|
|
604
|
+
await this.enroller.waitForEnrollment();
|
|
605
|
+
}
|
|
606
|
+
/** True when an enrollment is currently in-flight. Used by callers to skip triggering a competing enrollment. */
|
|
607
|
+
isEnrolling() {
|
|
608
|
+
return this.enroller.isEnrolling();
|
|
609
|
+
}
|
|
610
|
+
/** Rotate the HMAC secret while keeping the previous one for the grace window. */
|
|
611
|
+
async rotateSecret(opts = {}) {
|
|
612
|
+
const result = await this.enroller.rotateSecret(opts);
|
|
613
|
+
const stored = await this.identity.read();
|
|
614
|
+
return {
|
|
615
|
+
...result,
|
|
616
|
+
gracePeriodEndsAt: stored?.previousSecretExpiresAt
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
/** Build the canonical 5-header map for an outbound signed request. */
|
|
620
|
+
async buildSignedHeaders(input) {
|
|
621
|
+
const stored = await this.identity.read();
|
|
622
|
+
if (!stored) return null;
|
|
623
|
+
return buildSignedHeaders({
|
|
624
|
+
method: input.method,
|
|
625
|
+
path: input.path,
|
|
626
|
+
body: input.body,
|
|
627
|
+
publicId: stored.publicId,
|
|
628
|
+
deviceSecret: stored.deviceSecret,
|
|
629
|
+
secretVersion: stored.secretVersion
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
/** Raw stored identity (CLI/extension internal use). Test seam too. */
|
|
633
|
+
async readIdentity() {
|
|
634
|
+
return this.identity.read();
|
|
635
|
+
}
|
|
636
|
+
/** Wipe the local identity (the `--force` path before re-enroll). */
|
|
637
|
+
async clear() {
|
|
638
|
+
await this.identity.clear();
|
|
639
|
+
}
|
|
640
|
+
/** Mark the device as claimed (called by the bridge after a successful claim). */
|
|
641
|
+
async markClaimed() {
|
|
642
|
+
await this.enroller.markClaimed();
|
|
643
|
+
}
|
|
644
|
+
/** Mark the device as expired (server returned an expiry response). */
|
|
645
|
+
async markExpired() {
|
|
646
|
+
await this.enroller.markExpired();
|
|
647
|
+
}
|
|
648
|
+
/** Expose the identity store (CLI uses it for direct file access in tests). */
|
|
649
|
+
getIdentityStore() {
|
|
650
|
+
return this.identity;
|
|
651
|
+
}
|
|
652
|
+
/** Expose the enroller (CLI uses it for state inspection). */
|
|
653
|
+
getEnroller() {
|
|
654
|
+
return this.enroller;
|
|
655
|
+
}
|
|
656
|
+
/** Header name constants — re-exported from `deviceAuth.ts`. */
|
|
657
|
+
getHeaderNames() {
|
|
658
|
+
return DeviceAuthHeaders;
|
|
659
|
+
}
|
|
660
|
+
/** Compute the installation id for the current machine. */
|
|
661
|
+
getInstallationId() {
|
|
662
|
+
return this.resolveInstallationId();
|
|
663
|
+
}
|
|
664
|
+
};
|
|
665
|
+
function projectIdentity(stored) {
|
|
666
|
+
return {
|
|
667
|
+
publicId: stored.publicId,
|
|
668
|
+
secretVersion: stored.secretVersion,
|
|
669
|
+
bindingState: stored.bindingState
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
function projectMetadata(stored) {
|
|
673
|
+
return {
|
|
674
|
+
installationId: stored.installationId,
|
|
675
|
+
machineId: stored.machineId,
|
|
676
|
+
platform: stored.platform,
|
|
677
|
+
hostname: stored.hostname
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
681
|
+
0 && (module.exports = {
|
|
682
|
+
DEVICE_JSON_SCHEMA_VERSION,
|
|
683
|
+
DeviceAuthHeaders,
|
|
684
|
+
DeviceCore,
|
|
685
|
+
DeviceReenrollRequiresAuthError,
|
|
686
|
+
DeviceSecretVersionMismatchError,
|
|
687
|
+
Enroller,
|
|
688
|
+
FsIdentityFileBackend,
|
|
689
|
+
IdentityStore,
|
|
690
|
+
buildSignedHeaders,
|
|
691
|
+
createDeviceRequestSignature,
|
|
692
|
+
deriveInstallationId,
|
|
693
|
+
fingerprintSource,
|
|
694
|
+
randomInstallationId
|
|
695
|
+
});
|
|
696
|
+
//# sourceMappingURL=device.js.map
|