@cjhyy/code-shell-server 0.9.36 → 0.9.38

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.
Files changed (31) hide show
  1. package/dist/bin/code-shell-cloud-account.d.ts +2 -0
  2. package/dist/bin/code-shell-cloud-account.js +53 -0
  3. package/dist/index.serve.d.ts +4 -0
  4. package/dist/index.serve.js +3 -0
  5. package/dist/panels/management.js +12 -1
  6. package/dist/panels/types.d.ts +5 -3
  7. package/dist/project-runtime/account-adoption.d.ts +34 -0
  8. package/dist/project-runtime/account-adoption.js +280 -0
  9. package/dist/project-runtime/account-binding.d.ts +18 -0
  10. package/dist/project-runtime/account-binding.js +42 -0
  11. package/dist/project-runtime/backup.d.ts +10 -0
  12. package/dist/project-runtime/backup.js +113 -20
  13. package/dist/project-runtime/cloud-account-auth.d.ts +23 -0
  14. package/dist/project-runtime/cloud-account-auth.js +287 -0
  15. package/dist/project-runtime/cloud-browser-auth.d.ts +15 -0
  16. package/dist/project-runtime/cloud-browser-auth.js +352 -0
  17. package/dist/project-runtime/control-authority.d.ts +46 -0
  18. package/dist/project-runtime/control-authority.js +15 -0
  19. package/dist/project-runtime/control-server.d.ts +17 -0
  20. package/dist/project-runtime/control-server.js +94 -26
  21. package/dist/project-runtime/docker-provider.d.ts +6 -0
  22. package/dist/project-runtime/docker-provider.js +10 -5
  23. package/dist/project-runtime/lock.js +181 -12
  24. package/dist/project-runtime/manager.d.ts +16 -8
  25. package/dist/project-runtime/manager.js +89 -15
  26. package/dist/project-runtime/proxy.d.ts +3 -3
  27. package/dist/project-runtime/proxy.js +15 -5
  28. package/dist/project-runtime/registry.d.ts +21 -1
  29. package/dist/project-runtime/registry.js +224 -6
  30. package/dist/serve/headless-server.js +14 -0
  31. package/package.json +7 -6
@@ -1,17 +1,19 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { execFile, spawn } from "node:child_process";
3
- import { constants, createWriteStream } from "node:fs";
3
+ import { closeSync, constants, createWriteStream, fsyncSync, fstatSync, lstatSync, openSync, writeFileSync, } from "node:fs";
4
4
  import { lstat, mkdir, open, readdir, realpath, rm, writeFile } from "node:fs/promises";
5
5
  import { dirname, join, relative, resolve, sep } from "node:path";
6
6
  import { pipeline } from "node:stream/promises";
7
7
  import { Transform } from "node:stream";
8
8
  import { HubAuthStore } from "../hub/auth-store.js";
9
9
  import { ProjectRegistry } from "./registry.js";
10
+ import { isCloudAccountOrigin } from "./account-binding.js";
10
11
  const LABEL = "io.codeshell.project-runtime";
11
12
  const FORMAT = "codeshell.project-installation-backup";
12
13
  const MAX_CONTROL_FILE = 16 * 1024 * 1024;
13
14
  const SHA = /^[a-f0-9]{64}$/;
14
15
  const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
16
+ const MULTI_CONTROL_FILES = ["project-control/initialized", "project-control/registry.json"];
15
17
  const digest = (value) => ({
16
18
  sha256: createHash("sha256").update(value).digest("hex"),
17
19
  bytes: value.length,
@@ -256,6 +258,14 @@ async function copyControl(source, destination, records, prefix = "") {
256
258
  }
257
259
  }
258
260
  }
261
+ async function assertMultiControlInventory(source) {
262
+ for (const name of await readdir(source))
263
+ if (!["project-control", "project-control.lock", "project-runtime-secrets"].includes(name))
264
+ throw new Error("Multi-account backup refuses unknown controller state files.");
265
+ for (const name of await readdir(join(source, "project-control")))
266
+ if (!["initialized", "registry.json"].includes(name))
267
+ throw new Error("Multi-account backup refuses unknown project control files.");
268
+ }
259
269
  /** Offline, owner-only backup. It never stops projects or modifies their volumes. */
260
270
  export async function backupProjectInstallation(options) {
261
271
  imageId(options.helperImage);
@@ -263,15 +273,21 @@ export async function backupProjectInstallation(options) {
263
273
  await directory(source);
264
274
  // Do not initialize a fresh registry as a side effect of a mistaken backup path.
265
275
  await regular(join(source, "project-control/registry.json"), 512 * 1024);
266
- const registry = new ProjectRegistry(source);
276
+ const registry = new ProjectRegistry(source, Date.now, {
277
+ multiAccountOrigin: options.multiAccountOrigin,
278
+ });
267
279
  let destination;
268
280
  try {
269
281
  const rows = registry.all();
270
282
  if (rows.some((row) => row.status !== "stopped"))
271
283
  throw new Error("Stop all projects and the controller before backup.");
272
- const auth = new HubAuthStore({ dataDir: source });
273
- if (!auth.isInitialized())
274
- throw new Error("Complete administrator setup before backing up.");
284
+ if (registry.multiAccount)
285
+ await assertMultiControlInventory(source);
286
+ if (!registry.multiAccount) {
287
+ const auth = new HubAuthStore({ dataDir: source });
288
+ if (!auth.isInitialized())
289
+ throw new Error("Complete administrator setup before backing up.");
290
+ }
275
291
  const installationId = registry.installationId;
276
292
  const projects = rows.map(({ id, ownerId, generation }) => ({ id, ownerId, generation }));
277
293
  await stopped(installationId, projects);
@@ -281,7 +297,8 @@ export async function backupProjectInstallation(options) {
281
297
  await mkdir(join(destination, "volumes"), { mode: 0o700 });
282
298
  const manifest = {
283
299
  format: FORMAT,
284
- version: 1,
300
+ version: registry.multiAccount ? 2 : 1,
301
+ ...(registry.multiAccount ? { multiAccountOrigin: registry.multiAccount.accountOrigin } : {}),
285
302
  createdAt: new Date().toISOString(),
286
303
  installationId,
287
304
  helperImage: options.helperImage,
@@ -289,7 +306,20 @@ export async function backupProjectInstallation(options) {
289
306
  control: Object.create(null),
290
307
  volumes: [],
291
308
  };
292
- await copyControl(source, join(destination, "control"), manifest.control);
309
+ if (registry.multiAccount) {
310
+ await mkdir(join(destination, "control/project-control"), { mode: 0o700 });
311
+ for (const path of MULTI_CONTROL_FILES) {
312
+ const bytes = await controlBytes(source, path);
313
+ await writeFile(join(destination, "control", path), bytes, {
314
+ mode: 0o600,
315
+ flag: "wx",
316
+ flush: true,
317
+ });
318
+ manifest.control[path] = digest(bytes);
319
+ }
320
+ }
321
+ else
322
+ await copyControl(source, join(destination, "control"), manifest.control);
293
323
  for (const project of projects) {
294
324
  let found = 0;
295
325
  for (const kind of ["data", "workspace"]) {
@@ -316,6 +346,8 @@ export async function backupProjectInstallation(options) {
316
346
  }
317
347
  await stopped(installationId, projects);
318
348
  registry.all(); // Recheck registry ownership and unchanged bytes after asynchronous work.
349
+ if (registry.multiAccount)
350
+ await assertMultiControlInventory(source);
319
351
  const manifestBytes = JSON.stringify(manifest, null, 2) + "\n";
320
352
  if (Buffer.byteLength(manifestBytes) > 1024 * 1024)
321
353
  throw new Error("Backup manifest exceeds its supported size.");
@@ -342,7 +374,10 @@ export async function backupProjectInstallation(options) {
342
374
  }
343
375
  function validateManifest(value) {
344
376
  if (value?.format !== FORMAT ||
345
- value.version !== 1 ||
377
+ ![1, 2].includes(value.version) ||
378
+ (value.version === 1
379
+ ? value.multiAccountOrigin !== undefined
380
+ : !isCloudAccountOrigin(value.multiAccountOrigin)) ||
346
381
  !UUID.test(value.installationId) ||
347
382
  !Array.isArray(value.projects) ||
348
383
  value.projects.length > 128 ||
@@ -375,12 +410,16 @@ function validateManifest(value) {
375
410
  }
376
411
  if (Object.keys(value.control).length > 4096)
377
412
  throw new Error("Invalid control inventory.");
413
+ if (value.version === 2 &&
414
+ Object.keys(value.control).sort().join() !== MULTI_CONTROL_FILES.join())
415
+ throw new Error("Multi-account backup requires its exact controller inventory.");
378
416
  for (const [path, entry] of Object.entries(value.control)) {
379
417
  if (!path ||
380
418
  path.includes("\\") ||
381
419
  path.split("/").some((part) => !part || part === "." || part === "..") ||
382
420
  /[\0\r\n]/.test(path) ||
383
421
  path === "restore-in-progress" ||
422
+ ["project-runtime-secrets", "administrator-setup.txt", "environment.json"].some((obsolete) => path.startsWith(obsolete + "/")) ||
384
423
  path.startsWith("project-control.lock/") ||
385
424
  path === "project-control.lock" ||
386
425
  !entry ||
@@ -404,7 +443,7 @@ function validateManifest(value) {
404
443
  for (const path of [
405
444
  "project-control/registry.json",
406
445
  "project-control/initialized",
407
- "hub/auth.json",
446
+ ...(value.version === 1 ? ["hub/auth.json"] : []),
408
447
  ])
409
448
  if (!Object.hasOwn(value.control, path))
410
449
  throw new Error("Backup lacks required control data.");
@@ -418,6 +457,8 @@ export async function restoreProjectInstallation(options) {
418
457
  await directory(join(source, "volumes"));
419
458
  const manifest = JSON.parse((await regular(join(source, "manifest.json"), 1024 * 1024)).toString());
420
459
  validateManifest(manifest);
460
+ if (manifest.multiAccountOrigin !== options.multiAccountOrigin)
461
+ throw new Error("Restore requires the explicitly configured Account origin and matching installation mode.");
421
462
  // Verify every archive before creating a destination or Docker resource.
422
463
  for (const volume of manifest.volumes)
423
464
  same(await fileDigest(join(source, volume.file)), volume.digest);
@@ -425,31 +466,67 @@ export async function restoreProjectInstallation(options) {
425
466
  same(digest(await controlBytes(join(source, "control"), path)), expected);
426
467
  await docker(["image", "inspect", options.helperImage]);
427
468
  const destination = await exclusiveDestination(source, resolve(options.destination));
469
+ const marker = join(destination, "restore-in-progress");
470
+ const directories = new Set([destination]);
471
+ const sync = (path) => {
472
+ const before = lstatSync(path);
473
+ if (before.isSymbolicLink() || (!before.isFile() && !before.isDirectory()))
474
+ throw new Error("Restore durability requires ordinary files and directories.");
475
+ const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
476
+ try {
477
+ const current = fstatSync(fd);
478
+ if (current.dev !== before.dev || current.ino !== before.ino)
479
+ throw new Error("Restore storage changed while opening.");
480
+ (options.io?.fsync ?? fsyncSync)(fd);
481
+ }
482
+ finally {
483
+ closeSync(fd);
484
+ }
485
+ };
486
+ const persistMarker = (rearm = false) => {
487
+ try {
488
+ writeFileSync(marker, "1\n", { mode: 0o600, flag: "wx" });
489
+ }
490
+ catch (error) {
491
+ if (!rearm || error.code !== "EEXIST")
492
+ throw error;
493
+ }
494
+ sync(marker);
495
+ sync(destination);
496
+ sync(dirname(destination));
497
+ };
428
498
  const created = [];
429
499
  let registry;
430
500
  try {
431
- await writeFile(join(destination, "restore-in-progress"), "1\n", {
432
- mode: 0o600,
433
- flag: "wx",
434
- flush: true,
435
- });
501
+ persistMarker();
436
502
  for (const [path, expected] of Object.entries(manifest.control)) {
437
503
  const bytes = await controlBytes(join(source, "control"), path);
438
504
  same(digest(bytes), expected);
439
505
  await mkdir(dirname(join(destination, path)), { recursive: true, mode: 0o700 });
506
+ for (let parent = dirname(join(destination, path)); parent !== destination; parent = dirname(parent))
507
+ directories.add(parent);
440
508
  await writeFile(join(destination, path), bytes, { mode: 0o600, flag: "wx", flush: true });
441
509
  }
442
- registry = new ProjectRegistry(destination);
510
+ registry = new ProjectRegistry(destination, Date.now, {
511
+ fsync: options.io?.fsync,
512
+ multiAccountOrigin: options.multiAccountOrigin,
513
+ });
443
514
  const rows = registry.all();
444
515
  if (registry.installationId !== manifest.installationId ||
445
516
  rows.some((p) => p.status !== "stopped") ||
446
517
  JSON.stringify(rows.map(({ id, ownerId, generation }) => ({ id, ownerId, generation }))) !==
447
518
  JSON.stringify(manifest.projects))
448
519
  throw new Error("Backup control and volume identities disagree.");
449
- const auth = new HubAuthStore({ dataDir: destination });
450
- if (!auth.isInitialized())
451
- throw new Error("Backup administrator is not initialized.");
452
- auth.revokeAllSessions();
520
+ if (!registry.multiAccount) {
521
+ const auth = new HubAuthStore({ dataDir: destination });
522
+ if (!auth.isInitialized())
523
+ throw new Error("Backup administrator is not initialized.");
524
+ auth.revokeAllSessions();
525
+ // HubAuthStore publishes by rename. Persist the replacement and its directory
526
+ // before a new namespace or removal of the restore barrier can become visible.
527
+ sync(join(destination, "hub/auth.json"));
528
+ sync(join(destination, "hub"));
529
+ }
453
530
  registry.renewInstallationForRestore();
454
531
  const installationId = registry.installationId;
455
532
  for (const volume of manifest.volumes) {
@@ -472,7 +549,12 @@ export async function restoreProjectInstallation(options) {
472
549
  for (const path of ["project-runtime-secrets", "administrator-setup.txt", "environment.json"])
473
550
  await rm(join(destination, path), { recursive: true, force: true });
474
551
  registry.all();
475
- await rm(join(destination, "restore-in-progress"));
552
+ // Copied file bytes were flushed individually; their new directory entries
553
+ // must also survive a crash before the installation is advertised as usable.
554
+ for (const path of [...directories].sort((a, b) => b.length - a.length))
555
+ sync(path);
556
+ await (options.io?.removeMarker ?? rm)(marker);
557
+ sync(destination);
476
558
  return {
477
559
  destination,
478
560
  installationId,
@@ -482,6 +564,15 @@ export async function restoreProjectInstallation(options) {
482
564
  };
483
565
  }
484
566
  catch (error) {
567
+ let barrierError;
568
+ try {
569
+ // In particular, unlink may have succeeded before its directory fsync
570
+ // failed. Re-establish and persist the barrier before any cleanup awaits.
571
+ persistMarker(true);
572
+ }
573
+ catch (failure) {
574
+ barrierError = failure;
575
+ }
485
576
  // Only delete volumes created by this restore and still carrying its exact labels.
486
577
  for (const { name, expected } of created.reverse()) {
487
578
  try {
@@ -492,6 +583,8 @@ export async function restoreProjectInstallation(options) {
492
583
  /* Preserve a busy/changed volume for operator inspection. */
493
584
  }
494
585
  }
586
+ if (barrierError)
587
+ throw new AggregateError([error, barrierError], `Restore failed and its durable barrier could not be confirmed; keep ${destination} offline.`, { cause: error });
495
588
  throw error; // Keep the marked offline directory as recovery evidence; never advertise success.
496
589
  }
497
590
  finally {
@@ -0,0 +1,23 @@
1
+ import { type CloudAccountBinding } from "./account-binding.js";
2
+ import type { CloudAccountAuthority, CloudManagementSession, ProjectControlAuthority } from "./control-authority.js";
3
+ export declare const CLOUD_MANAGEMENT_AUTHORITY_VERSION = 2;
4
+ export declare const CLOUD_MANAGEMENT_COOKIE = "cs_cloud_management";
5
+ export declare function cloudManagementSessionView(session: CloudManagementSession): {
6
+ id: string;
7
+ kind: "cloud-management";
8
+ audience: string;
9
+ installationId: string;
10
+ accountOrigin: string;
11
+ accountId: string;
12
+ managementEpoch: string;
13
+ expiresAt: number;
14
+ };
15
+ /** An explicitly bound Account controls one existing single-operator installation. */
16
+ export declare function createCloudAccountAuth(options: {
17
+ binding: CloudAccountBinding;
18
+ installationId: string;
19
+ publicOrigin: string;
20
+ authority: CloudAccountAuthority;
21
+ onRevoke(sessionId: string): void;
22
+ now?: () => number;
23
+ }): ProjectControlAuthority;
@@ -0,0 +1,287 @@
1
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
2
+ import { isCloudAccountOrigin } from "./account-binding.js";
3
+ import { ProjectRegistryError } from "./registry.js";
4
+ export const CLOUD_MANAGEMENT_AUTHORITY_VERSION = 2;
5
+ export const CLOUD_MANAGEMENT_COOKIE = "cs_cloud_management";
6
+ const TOKEN = /^[A-Za-z0-9_-]{43}$/;
7
+ const UUID = /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/;
8
+ const hash = (token) => createHash("sha256").update(token).digest("hex");
9
+ export function cloudManagementSessionView(session) {
10
+ const { ownerId: _ownerId, ...view } = session;
11
+ return view;
12
+ }
13
+ function principalValid(value) {
14
+ return (value &&
15
+ Object.keys(value).sort().join() ===
16
+ ["accountOrigin", "accountId", "sessionId", "rootSessionId", "authorityEpoch", "expiresAt"]
17
+ .sort()
18
+ .join() &&
19
+ isCloudAccountOrigin(value.accountOrigin) &&
20
+ UUID.test(value.accountId) &&
21
+ UUID.test(value.sessionId) &&
22
+ value.sessionId === value.rootSessionId &&
23
+ UUID.test(value.authorityEpoch) &&
24
+ Number.isSafeInteger(value.expiresAt));
25
+ }
26
+ /** An explicitly bound Account controls one existing single-operator installation. */
27
+ export function createCloudAccountAuth(options) {
28
+ const now = options.now ?? Date.now;
29
+ if (!isCloudAccountOrigin(options.publicOrigin) ||
30
+ options.authority.accountOrigin !== options.binding.accountOrigin ||
31
+ !UUID.test(options.installationId))
32
+ throw new Error("Cloud authority configuration does not match explicit ownership.");
33
+ const sessions = new Map();
34
+ const reading = new Set();
35
+ let closed = false;
36
+ let exchanging = 0;
37
+ let attempts = { until: now() + 60_000, count: 0 };
38
+ function revoke(entry) {
39
+ if (entry.revoked)
40
+ return;
41
+ entry.revoked = true;
42
+ options.onRevoke(entry.session.id);
43
+ }
44
+ function entryFor(session) {
45
+ if (closed || !("kind" in session))
46
+ return;
47
+ const entry = sessions.get(session.id);
48
+ if (!entry || entry.revoked || entry.session.expiresAt <= now()) {
49
+ if (entry)
50
+ revoke(entry);
51
+ return;
52
+ }
53
+ // Do not let a caller change owner, epoch or deadline using an existing id.
54
+ if (JSON.stringify(entry.session) !== JSON.stringify(session))
55
+ return;
56
+ return entry;
57
+ }
58
+ async function revalidate(session) {
59
+ const entry = entryFor(session);
60
+ if (!entry)
61
+ throw new ProjectRegistryError(401, "Cloud 管理登录已失效。");
62
+ try {
63
+ const current = await options.authority.validate({ ...entry.principal }, options.installationId);
64
+ if (!principalValid(current) ||
65
+ Object.keys(current).some((key) => current[key] !==
66
+ entry.principal[key]) ||
67
+ !entryFor(session))
68
+ throw new Error("Cloud Account authority changed.");
69
+ }
70
+ catch {
71
+ revoke(entry);
72
+ throw new ProjectRegistryError(401, "Cloud 管理授权已失效,请重新登录云账号。");
73
+ }
74
+ }
75
+ function requestToken(req) {
76
+ let authorizations = 0;
77
+ for (let i = 0; i < req.rawHeaders.length; i += 2)
78
+ if (req.rawHeaders[i].toLowerCase() === "authorization")
79
+ authorizations++;
80
+ if (authorizations > 1)
81
+ return;
82
+ const bearer = req.headers.authorization;
83
+ const cookie = req.headers.cookie
84
+ ?.split(";")
85
+ .map((part) => part.trim())
86
+ .filter((part) => part.startsWith(`${CLOUD_MANAGEMENT_COOKIE}=`));
87
+ if (bearer !== undefined && cookie?.length)
88
+ return;
89
+ if (bearer !== undefined)
90
+ return /^Bearer ([A-Za-z0-9_-]{43})$/.exec(bearer)?.[1];
91
+ if (cookie?.length === 1)
92
+ return cookie[0].slice(CLOUD_MANAGEMENT_COOKIE.length + 1);
93
+ }
94
+ function isOriginAllowed(req) {
95
+ let hosts = 0;
96
+ for (let i = 0; i < req.rawHeaders.length; i += 2)
97
+ if (req.rawHeaders[i].toLowerCase() === "host")
98
+ hosts++;
99
+ if (hosts !== 1 ||
100
+ req.headers.host !== new URL(options.publicOrigin).host ||
101
+ req.headers["sec-fetch-site"] === "cross-site")
102
+ return false;
103
+ return req.headers.origin === undefined || req.headers.origin === options.publicOrigin;
104
+ }
105
+ async function authenticate(req) {
106
+ if (!isOriginAllowed(req))
107
+ return null;
108
+ const token = requestToken(req);
109
+ if (!token || !TOKEN.test(token))
110
+ return null;
111
+ const entry = [...sessions.values()].find((row) => row.hash === hash(token));
112
+ if (!entry || !entryFor(entry.session))
113
+ return null;
114
+ await revalidate(entry.session);
115
+ return { ...entry.session };
116
+ }
117
+ function json(res, status, value) {
118
+ res.writeHead(status, {
119
+ "Content-Type": "application/json",
120
+ "Cache-Control": "no-store",
121
+ "Referrer-Policy": "no-referrer",
122
+ "X-Content-Type-Options": "nosniff",
123
+ });
124
+ res.end(JSON.stringify(value));
125
+ }
126
+ async function readBody(req) {
127
+ if (!/^application\/json(?:\s*;|$)/i.test(req.headers["content-type"] ?? ""))
128
+ throw new ProjectRegistryError(415, "请使用 JSON 提交。");
129
+ const chunks = [];
130
+ let size = 0;
131
+ const timer = setTimeout(() => req.destroy(), 5000);
132
+ timer.unref();
133
+ reading.add(req);
134
+ try {
135
+ for await (const chunk of req) {
136
+ size += chunk.length;
137
+ if (size > 4096)
138
+ throw new ProjectRegistryError(413, "请求过大。");
139
+ chunks.push(Buffer.from(chunk));
140
+ }
141
+ }
142
+ finally {
143
+ clearTimeout(timer);
144
+ reading.delete(req);
145
+ }
146
+ try {
147
+ const body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
148
+ if (body && typeof body === "object" && !Array.isArray(body))
149
+ return body;
150
+ }
151
+ catch {
152
+ /* fixed public error below */
153
+ }
154
+ throw new ProjectRegistryError(400, "请求格式无效。");
155
+ }
156
+ async function handle(req, res) {
157
+ const path = (req.url ?? "/").split("?", 1)[0];
158
+ if (!path.startsWith("/api/v1/cloud-management/") && !path.startsWith("/api/v1/auth/"))
159
+ return false;
160
+ if (!isOriginAllowed(req))
161
+ throw new ProjectRegistryError(403, "请求来源无效。");
162
+ if (closed)
163
+ throw new ProjectRegistryError(503, "Cloud 管理服务正在关闭。");
164
+ if (req.url !== path)
165
+ throw new ProjectRegistryError(400, "请求参数无效。");
166
+ if (path === "/api/v1/cloud-management/capabilities" && req.method === "GET") {
167
+ json(res, 200, {
168
+ version: CLOUD_MANAGEMENT_AUTHORITY_VERSION,
169
+ mode: "cloud-account",
170
+ cloudOrigin: options.publicOrigin,
171
+ installationId: options.installationId,
172
+ accountOrigin: options.binding.accountOrigin,
173
+ });
174
+ return true;
175
+ }
176
+ if (path === "/api/v1/cloud-management/session" && req.method === "POST") {
177
+ // Main sends an Account-issued target-bound single-use ticket, never its root credential.
178
+ if (req.headers.origin !== undefined ||
179
+ req.headers.cookie !== undefined ||
180
+ req.headers.authorization !== undefined ||
181
+ req.headers["sec-fetch-site"] !== undefined)
182
+ throw new ProjectRegistryError(403, "请从桌面 Cloud 管理器登录。");
183
+ if (now() >= attempts.until)
184
+ attempts = { until: now() + 60_000, count: 0 };
185
+ if (++attempts.count > 60 || exchanging >= 4)
186
+ throw new ProjectRegistryError(429, "Cloud 管理登录请求过多。");
187
+ exchanging++;
188
+ try {
189
+ const body = await readBody(req);
190
+ if (Object.keys(body).join() !== "ticket" ||
191
+ typeof body.ticket !== "string" ||
192
+ !TOKEN.test(body.ticket))
193
+ throw new ProjectRegistryError(400, "Cloud 管理票据格式无效。");
194
+ let principal;
195
+ try {
196
+ principal = await options.authority.redeem(body.ticket, options.installationId);
197
+ }
198
+ catch {
199
+ throw new ProjectRegistryError(401, "云账号验证失败。");
200
+ }
201
+ if (closed ||
202
+ !principalValid(principal) ||
203
+ principal.accountOrigin !== options.binding.accountOrigin ||
204
+ principal.accountId !== options.binding.accountId ||
205
+ principal.expiresAt <= now())
206
+ throw new ProjectRegistryError(403, "此账号没有 Cloud 管理权限。");
207
+ for (const [id, row] of sessions)
208
+ if (row.session.expiresAt + 600_000 <= now())
209
+ sessions.delete(id);
210
+ if (sessions.size >= 128)
211
+ throw new ProjectRegistryError(429, "Cloud 管理会话数量已达上限。");
212
+ const token = randomBytes(32).toString("base64url");
213
+ const session = {
214
+ id: randomUUID(),
215
+ kind: "cloud-management",
216
+ audience: options.publicOrigin,
217
+ installationId: options.installationId,
218
+ accountOrigin: principal.accountOrigin,
219
+ accountId: principal.accountId,
220
+ managementEpoch: options.binding.managementEpoch,
221
+ expiresAt: Math.min(now() + 600_000, principal.expiresAt),
222
+ ownerId: options.binding.ownerId,
223
+ };
224
+ sessions.set(session.id, {
225
+ hash: hash(token),
226
+ session,
227
+ principal: { ...principal },
228
+ revoked: false,
229
+ });
230
+ json(res, 201, { token, session: cloudManagementSessionView(session) });
231
+ }
232
+ finally {
233
+ exchanging--;
234
+ }
235
+ return true;
236
+ }
237
+ if (path === "/api/v1/auth/status" && req.method === "GET") {
238
+ const session = await authenticate(req);
239
+ json(res, 200, {
240
+ initialized: true,
241
+ mode: "cloud-account",
242
+ authenticated: !!session,
243
+ ...(session && "kind" in session ? { session: cloudManagementSessionView(session) } : {}),
244
+ });
245
+ return true;
246
+ }
247
+ if (["/api/v1/cloud-management/logout", "/api/v1/auth/logout"].includes(path) &&
248
+ req.method === "POST") {
249
+ const body = await readBody(req);
250
+ if (Object.keys(body).length)
251
+ throw new ProjectRegistryError(400, "退出不接受额外参数。");
252
+ const token = requestToken(req);
253
+ const entry = token &&
254
+ TOKEN.test(token) &&
255
+ [...sessions.values()].find((row) => row.hash === hash(token));
256
+ if (!entry)
257
+ throw new ProjectRegistryError(401, "Cloud 管理登录已失效。");
258
+ revoke(entry);
259
+ res.setHeader("Set-Cookie", `${CLOUD_MANAGEMENT_COOKIE}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0${options.publicOrigin.startsWith("https:") ? "; Secure" : ""}`);
260
+ json(res, 200, { revoked: true });
261
+ return true;
262
+ }
263
+ throw new ProjectRegistryError(403, "此 Cloud 只接受明确绑定的云账号管理。");
264
+ }
265
+ return {
266
+ cookieName: CLOUD_MANAGEMENT_COOKIE,
267
+ store: {
268
+ listSessions: () => [...sessions.values()]
269
+ .filter((row) => entryFor(row.session))
270
+ .map((row) => ({ ...row.session })),
271
+ },
272
+ handle,
273
+ authenticate,
274
+ isOriginAllowed,
275
+ isActive: (session) => !!entryFor(session),
276
+ revalidate,
277
+ close() {
278
+ closed = true;
279
+ for (const request of reading)
280
+ request.destroy();
281
+ reading.clear();
282
+ for (const row of sessions.values())
283
+ revoke(row);
284
+ sessions.clear();
285
+ },
286
+ };
287
+ }
@@ -0,0 +1,15 @@
1
+ import type { CloudAccountAuthority, ProjectControlAuthority } from "./control-authority.js";
2
+ /** Browser bootstrap is separate from the native, single-operator v2 API. */
3
+ export declare const CLOUD_BROWSER_AUTHORITY_VERSION = 1;
4
+ export declare const CLOUD_BROWSER_COOKIE = "cs_cloud_browser";
5
+ /** Every principal is independently scoped; a login never claims legacy projects. */
6
+ export declare function createCloudBrowserAuth(options: {
7
+ accountOrigin: string;
8
+ managementEpoch: string;
9
+ installationId: string;
10
+ publicOrigin: string;
11
+ authority: CloudAccountAuthority;
12
+ ownerForAccount(accountOrigin: string, accountId: string): string;
13
+ onRevoke(sessionId: string, ownerId: string): void;
14
+ now?: () => number;
15
+ }): ProjectControlAuthority;