@cjhyy/code-shell-server 0.9.35 → 0.9.37

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 (32) 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 +3 -0
  4. package/dist/index.serve.js +2 -0
  5. package/dist/mobile-remote/mobile-client-event-validator.js +10 -1
  6. package/dist/mobile-remote/room-history.d.ts +20 -0
  7. package/dist/mobile-remote/room-history.js +327 -0
  8. package/dist/mobile-remote/room-manager.d.ts +11 -0
  9. package/dist/mobile-remote/room-manager.js +107 -11
  10. package/dist/panels/management.js +12 -1
  11. package/dist/panels/runtime-services.js +32 -7
  12. package/dist/panels/types.d.ts +5 -3
  13. package/dist/project-runtime/account-adoption.d.ts +34 -0
  14. package/dist/project-runtime/account-adoption.js +280 -0
  15. package/dist/project-runtime/account-binding.d.ts +12 -0
  16. package/dist/project-runtime/account-binding.js +33 -0
  17. package/dist/project-runtime/backup.d.ts +7 -0
  18. package/dist/project-runtime/backup.js +56 -8
  19. package/dist/project-runtime/cloud-account-auth.d.ts +23 -0
  20. package/dist/project-runtime/cloud-account-auth.js +287 -0
  21. package/dist/project-runtime/control-authority.d.ts +46 -0
  22. package/dist/project-runtime/control-authority.js +15 -0
  23. package/dist/project-runtime/control-server.d.ts +3 -0
  24. package/dist/project-runtime/control-server.js +60 -24
  25. package/dist/project-runtime/lock.js +181 -12
  26. package/dist/project-runtime/manager.d.ts +12 -8
  27. package/dist/project-runtime/manager.js +56 -15
  28. package/dist/project-runtime/proxy.d.ts +3 -3
  29. package/dist/project-runtime/proxy.js +4 -1
  30. package/dist/project-runtime/registry.d.ts +14 -1
  31. package/dist/project-runtime/registry.js +91 -2
  32. package/package.json +7 -6
@@ -1,10 +1,12 @@
1
1
  import { remoteLinkHostConfiguration } from "../links/remote-configuration.js";
2
2
  import { describeEnvironment } from "../environment-identity.js";
3
3
  import { createServer } from "node:http";
4
- import { existsSync, readFileSync } from "node:fs";
4
+ import { lstatSync, readFileSync } from "node:fs";
5
5
  import { join } from "node:path";
6
6
  import { extname } from "node:path";
7
7
  import { createHubAuth } from "../hub/auth-http.js";
8
+ import { createCloudAccountAuth } from "./cloud-account-auth.js";
9
+ import { legacyProjectAuthority } from "./control-authority.js";
8
10
  import { resolveSafe } from "../mobile-remote/mobile-static.js";
9
11
  import { contentTypeFor } from "../static-files.js";
10
12
  import { createDockerProjectProvider } from "./docker-provider.js";
@@ -69,8 +71,16 @@ async function readBody(request) {
69
71
  /** A small account/project gateway. It never creates a worker, executes tools or mounts cwd. */
70
72
  export async function startProjectControlServer(options) {
71
73
  const remoteLink = remoteLinkHostConfiguration(options.remoteLink, options.publicOrigin);
72
- if (existsSync(join(options.dataDir, "restore-in-progress")))
74
+ // Any entry is a barrier, including dangling symlinks. Only ENOENT means absent;
75
+ // permission/I/O failures must abort before registry or Hub initialization.
76
+ try {
77
+ lstatSync(join(options.dataDir, "restore-in-progress"));
73
78
  throw new Error("Cloud installation restore is incomplete; do not start this directory.");
79
+ }
80
+ catch (error) {
81
+ if (error.code !== "ENOENT")
82
+ throw error;
83
+ }
74
84
  const registry = new ProjectRegistry(options.dataDir);
75
85
  let proxy;
76
86
  let manager;
@@ -78,26 +88,38 @@ export async function startProjectControlServer(options) {
78
88
  let server;
79
89
  let shuttingDown = false;
80
90
  try {
81
- const auth = await createHubAuth({
82
- dataDir: options.dataDir,
83
- publicOrigin: options.publicOrigin,
84
- onRevoke: (sessionId) => {
91
+ const onRevoke = (sessionId) => {
92
+ if (shuttingDown)
93
+ return;
94
+ void proxy?.revokeOwner(sessionId).catch(async () => {
85
95
  if (shuttingDown)
86
96
  return;
87
- void proxy?.revokeOwner(sessionId).catch(async () => {
88
- if (shuttingDown)
89
- return;
90
- // The revoked browser's transports are already closed. If its inner
91
- // session cannot be revoked, stop the single administrator's runtimes.
92
- try {
93
- await manager?.stopAll();
94
- }
95
- catch {
96
- console.error("[project-control] could not confirm project termination after session revocation.");
97
- }
98
- });
99
- },
100
- });
97
+ // The revoked browser's transports are already closed. If its inner
98
+ // session cannot be revoked, stop the single administrator's runtimes.
99
+ try {
100
+ await manager?.stopAll();
101
+ }
102
+ catch {
103
+ console.error("[project-control] could not confirm project termination after session revocation.");
104
+ }
105
+ });
106
+ };
107
+ const binding = registry.accountBinding;
108
+ if (!!binding !== !!options.accountAuthority || (binding && !options.publicOrigin))
109
+ throw new Error("Cloud account authority requires explicit matching ownership and public origin.");
110
+ const auth = binding
111
+ ? createCloudAccountAuth({
112
+ binding,
113
+ installationId: registry.installationId,
114
+ publicOrigin: options.publicOrigin,
115
+ authority: options.accountAuthority,
116
+ onRevoke,
117
+ })
118
+ : legacyProjectAuthority(await createHubAuth({
119
+ dataDir: options.dataDir,
120
+ publicOrigin: options.publicOrigin,
121
+ onRevoke,
122
+ }));
101
123
  let listeningOrigin = "";
102
124
  const publicOrigin = () => options.publicOrigin ?? listeningOrigin;
103
125
  provider =
@@ -113,9 +135,12 @@ export async function startProjectControlServer(options) {
113
135
  registry,
114
136
  provider,
115
137
  publicOrigin,
116
- isSessionActive: (session) => auth.store
117
- .listSessions()
118
- .some((item) => item.id === session.id && item.username === session.username),
138
+ isSessionActive: (session) => auth.isActive(session),
139
+ ...(binding
140
+ ? {
141
+ revalidateSession: (session) => auth.revalidate(session),
142
+ }
143
+ : {}),
119
144
  revokeProject: async (id) => {
120
145
  await proxy?.revokeProject(id);
121
146
  },
@@ -172,6 +197,7 @@ export async function startProjectControlServer(options) {
172
197
  throw new ProjectRegistryError(400, "项目请求参数无效。");
173
198
  if (pathname === "/api/v1/projects" && request.method === "GET") {
174
199
  const availability = await provider.availability();
200
+ await auth.revalidate(session);
175
201
  json(response, 200, {
176
202
  runtime: "docker",
177
203
  ...availability,
@@ -183,10 +209,11 @@ export async function startProjectControlServer(options) {
183
209
  const body = await readBody(request);
184
210
  if (Object.keys(body).some((key) => key !== "name"))
185
211
  throw new ProjectRegistryError(400, "仅支持设置项目名称。");
212
+ await auth.revalidate(session);
186
213
  json(response, 201, { project: projects.create(session, body.name) });
187
214
  return;
188
215
  }
189
- const match = /^\/api\/v1\/projects\/([a-f0-9-]{36})(?:\/(start|stop))?$/.exec(pathname);
216
+ const match = /^\/api\/v1\/projects\/([a-f0-9-]{36})(?:\/(start|stop|open))?$/.exec(pathname);
190
217
  if (match && request.method === "GET" && !match[2]) {
191
218
  json(response, 200, { project: projects.get(session, match[1]) });
192
219
  return;
@@ -195,6 +222,14 @@ export async function startProjectControlServer(options) {
195
222
  const body = await readBody(request);
196
223
  if (Object.keys(body).length)
197
224
  throw new ProjectRegistryError(400, "启动和停止不接受额外参数。");
225
+ if (match[2] === "open") {
226
+ await projects.resolveTarget(session, match[1]);
227
+ json(response, 200, {
228
+ project: projects.get(session, match[1]),
229
+ url: `${publicOrigin()}/?project=${match[1]}`,
230
+ });
231
+ return;
232
+ }
198
233
  const project = match[2] === "start"
199
234
  ? await projects.start(session, match[1])
200
235
  : await projects.stop(session, match[1]);
@@ -286,6 +321,7 @@ export async function startProjectControlServer(options) {
286
321
  // Revoke streams and inner sessions before stopping their servers.
287
322
  // If logout is unreachable, confirmed runtime termination is the
288
323
  // fallback; a real stop failure must still reject close().
324
+ auth.close?.();
289
325
  await transport.close().catch(() => { });
290
326
  await projects.close();
291
327
  }
@@ -1,43 +1,212 @@
1
- import { lstatSync } from "node:fs";
1
+ import * as fs from "node:fs";
2
+ import { randomUUID } from "node:crypto";
3
+ import { join, resolve } from "node:path";
2
4
  import { lockSync } from "@cjhyy/code-shell-core/internal";
3
5
  /** One control process per directory; heartbeat leases recover after a crash. */
4
6
  export function acquireProjectControllerLock(directory) {
5
- const original = lstatSync(directory);
7
+ directory = resolve(directory);
8
+ const original = fs.lstatSync(directory);
6
9
  if (!original.isDirectory() || original.isSymbolicLink())
7
10
  throw new Error("Unsafe project control directory.");
11
+ const lockPath = `${directory}.lock`;
12
+ let identity;
13
+ let candidate;
14
+ let owner;
15
+ let candidateOwner;
16
+ let mtime;
8
17
  let compromised = false;
9
18
  let released = false;
19
+ const sameDirectory = (actual, expected) => actual.isDirectory() &&
20
+ !actual.isSymbolicLink() &&
21
+ actual.dev === expected.dev &&
22
+ actual.ino === expected.ino;
23
+ const ownerFile = (actual) => actual.isFile() &&
24
+ !actual.isSymbolicLink() &&
25
+ actual.nlink === 1 &&
26
+ actual.size === 0 &&
27
+ (actual.mode & 0o077) === 0;
28
+ function readOwner() {
29
+ const entries = fs.readdirSync(lockPath);
30
+ if (!entries.length)
31
+ return; // A historical proper-lockfile owner had no marker.
32
+ if (entries.length !== 1 ||
33
+ !/^\.owner-[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(entries[0]))
34
+ throw lost();
35
+ const identity = fs.lstatSync(join(lockPath, entries[0]));
36
+ if (!ownerFile(identity))
37
+ throw lost();
38
+ return { name: entries[0], identity };
39
+ }
40
+ function assertOwner(expected) {
41
+ const current = readOwner();
42
+ if (!current ||
43
+ current.name !== expected.name ||
44
+ current.identity.dev !== expected.identity.dev ||
45
+ current.identity.ino !== expected.identity.ino)
46
+ throw lost();
47
+ }
48
+ const lost = () => {
49
+ compromised = true;
50
+ // ENOENT terminates proper-lockfile's renewal immediately, rather than
51
+ // repeatedly retrying an owner which has already permanently lost custody.
52
+ return Object.assign(new Error("Project controller lock is no longer held."), {
53
+ code: "ENOENT",
54
+ });
55
+ };
56
+ function assertRoot() {
57
+ if (compromised)
58
+ throw lost();
59
+ try {
60
+ if (!sameDirectory(fs.lstatSync(directory), original))
61
+ throw lost();
62
+ }
63
+ catch {
64
+ throw lost();
65
+ }
66
+ }
67
+ function assertOwned() {
68
+ assertRoot();
69
+ try {
70
+ const current = fs.lstatSync(lockPath);
71
+ if (!identity ||
72
+ !owner ||
73
+ !sameDirectory(current, identity) ||
74
+ (mtime !== undefined && current.mtimeMs !== mtime))
75
+ throw lost();
76
+ assertOwner(owner);
77
+ }
78
+ catch {
79
+ throw lost();
80
+ }
81
+ }
82
+ // lockSync's adapter invokes these synchronous functions for acquisition,
83
+ // every heartbeat and exit cleanup. Guarding only callback-style fs methods
84
+ // would leave its renewal and release paths unprotected.
85
+ const guardedFs = {
86
+ ...fs,
87
+ mkdirSync(path) {
88
+ if (path !== lockPath)
89
+ return fs.mkdirSync(path);
90
+ assertRoot();
91
+ fs.mkdirSync(path, { mode: 0o700 });
92
+ identity = fs.lstatSync(path);
93
+ const name = `.owner-${randomUUID()}`;
94
+ const marker = fs.openSync(join(path, name), fs.constants.O_CREAT |
95
+ fs.constants.O_EXCL |
96
+ fs.constants.O_WRONLY |
97
+ fs.constants.O_NOFOLLOW, 0o600);
98
+ try {
99
+ owner = { name, identity: fs.fstatSync(marker) };
100
+ if (!ownerFile(owner.identity))
101
+ throw lost();
102
+ }
103
+ finally {
104
+ fs.closeSync(marker);
105
+ }
106
+ mtime = fs.lstatSync(path).mtimeMs;
107
+ assertOwned();
108
+ },
109
+ statSync(path) {
110
+ if (path !== lockPath)
111
+ return fs.statSync(path);
112
+ if (identity)
113
+ assertOwned();
114
+ else
115
+ assertRoot();
116
+ const current = fs.lstatSync(path);
117
+ if (!current.isDirectory() || current.isSymbolicLink())
118
+ throw lost();
119
+ // An existing stale lock may be removed only if it remains the exact
120
+ // directory whose age proper-lockfile just inspected.
121
+ if (!identity) {
122
+ candidate = current;
123
+ candidateOwner = readOwner();
124
+ }
125
+ return current;
126
+ },
127
+ utimesSync(path, atime, nextMtime) {
128
+ if (path !== lockPath)
129
+ return fs.utimesSync(path, atime, nextMtime);
130
+ assertOwned();
131
+ const descriptor = fs.openSync(path, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
132
+ try {
133
+ if (!sameDirectory(fs.fstatSync(descriptor), identity))
134
+ throw lost();
135
+ // Renew the captured inode, never a replacement installed between the
136
+ // path check and the write. Recheck the public path before accepting it.
137
+ fs.futimesSync(descriptor, atime, nextMtime);
138
+ mtime = fs.fstatSync(descriptor).mtimeMs;
139
+ assertOwned();
140
+ }
141
+ catch {
142
+ throw lost();
143
+ }
144
+ finally {
145
+ fs.closeSync(descriptor);
146
+ }
147
+ },
148
+ rmdirSync(path) {
149
+ if (path !== lockPath)
150
+ return fs.rmdirSync(path);
151
+ if (identity) {
152
+ assertOwned();
153
+ fs.unlinkSync(join(path, owner.name));
154
+ }
155
+ else {
156
+ assertRoot();
157
+ if (!candidate || !sameDirectory(fs.lstatSync(path), candidate))
158
+ throw lost();
159
+ if (candidateOwner) {
160
+ assertOwner(candidateOwner);
161
+ fs.unlinkSync(join(path, candidateOwner.name));
162
+ }
163
+ else if (fs.readdirSync(path).length)
164
+ throw lost();
165
+ }
166
+ // Only our own random marker (or the exact observed stale candidate's)
167
+ // was removed. If the pathname changes immediately before this syscall,
168
+ // an established new owner still has its different marker: ENOTEMPTY
169
+ // preserves it, and stale acquisition must fail rather than claim success.
170
+ fs.rmdirSync(path);
171
+ },
172
+ };
10
173
  let unlock;
11
174
  try {
12
175
  unlock = lockSync(directory, {
176
+ realpath: false,
177
+ lockfilePath: lockPath,
13
178
  stale: 30000,
14
179
  update: 10000,
15
180
  retries: 0,
181
+ fs: guardedFs,
16
182
  onCompromised: () => {
17
183
  compromised = true;
18
184
  },
19
185
  });
186
+ assertOwned();
20
187
  }
21
- catch {
22
- throw new Error("Another project controller owns this data directory; stop it before starting another.");
188
+ catch (error) {
189
+ throw new Error(compromised
190
+ ? "Project controller lock is no longer held."
191
+ : "Another project controller owns this data directory; stop it before starting another.", { cause: error });
23
192
  }
24
193
  return {
25
194
  assertHeld() {
26
195
  if (released || compromised)
27
196
  throw new Error("Project controller lock is no longer held.");
28
- const current = lstatSync(directory);
29
- if (!current.isDirectory() ||
30
- current.isSymbolicLink() ||
31
- current.dev !== original.dev ||
32
- current.ino !== original.ino)
33
- throw new Error("Project control directory changed while the controller was running.");
197
+ assertOwned();
34
198
  },
35
199
  release() {
36
200
  if (released)
37
201
  return;
38
202
  released = true;
39
- if (!compromised)
40
- unlock();
203
+ try {
204
+ assertOwned();
205
+ }
206
+ catch {
207
+ return; // The lost owner may never delete another owner's replacement.
208
+ }
209
+ unlock();
41
210
  },
42
211
  };
43
212
  }
@@ -1,12 +1,13 @@
1
- import type { HubSession } from "../hub/auth-store.js";
1
+ import type { ProjectControlSession } from "./control-authority.js";
2
2
  import { ProjectRegistry, type ProjectView } from "./registry.js";
3
3
  import type { ProjectRuntimeConnection, ProjectRuntimeProvider } from "./types.js";
4
4
  export interface ProjectManagerOptions {
5
5
  registry: ProjectRegistry;
6
6
  provider: ProjectRuntimeProvider;
7
7
  publicOrigin(): string;
8
- isSessionActive(session: HubSession): boolean;
8
+ isSessionActive(session: ProjectControlSession): boolean;
9
9
  revokeProject(id: string): Promise<void>;
10
+ revalidateSession?(session: ProjectControlSession): Promise<void>;
10
11
  maxRunning?: number;
11
12
  /** Clock seam for Docker observations; authorization is never cached. */
12
13
  now?: () => number;
@@ -17,20 +18,23 @@ export declare class ProjectManager {
17
18
  private readonly starts;
18
19
  private readonly stops;
19
20
  private readonly targets;
21
+ private readonly stopRevisions;
20
22
  private readonly statusChecks;
21
23
  private closed;
22
24
  private closing;
23
25
  constructor(options: ProjectManagerOptions);
24
26
  private authorize;
27
+ private ownedProject;
28
+ private authorizeContinuous;
25
29
  reconcile(): Promise<void>;
26
- list(session: HubSession): ProjectView[];
27
- get(session: HubSession, id: string): ProjectView;
28
- create(session: HubSession, name: unknown): ProjectView;
29
- start(session: HubSession, id: string): Promise<ProjectView>;
30
- stop(session: HubSession, id: string): Promise<ProjectView>;
30
+ list(session: ProjectControlSession): ProjectView[];
31
+ get(session: ProjectControlSession, id: string): ProjectView;
32
+ create(session: ProjectControlSession, name: unknown): ProjectView;
33
+ start(session: ProjectControlSession, id: string): Promise<ProjectView>;
34
+ stop(session: ProjectControlSession, id: string): Promise<ProjectView>;
31
35
  private beginStop;
32
36
  private stopProject;
33
- resolveTarget(session: HubSession, id: string): Promise<ProjectRuntimeConnection>;
37
+ resolveTarget(session: ProjectControlSession, id: string): Promise<ProjectRuntimeConnection>;
34
38
  private observeRuntime;
35
39
  /** Stop the affected runtime without relying on its already-revoked outer session. */
36
40
  stopAfterRevocationFailure(id: string): Promise<void>;
@@ -5,6 +5,7 @@ export class ProjectManager {
5
5
  starts = new Map();
6
6
  stops = new Map();
7
7
  targets = new Map();
8
+ stopRevisions = new Map();
8
9
  statusChecks = new Map();
9
10
  closed = false;
10
11
  closing;
@@ -21,6 +22,16 @@ export class ProjectManager {
21
22
  if (!this.options.isSessionActive(session))
22
23
  throw new ProjectRegistryError(401, "登录已过期,请重新登录。");
23
24
  }
25
+ ownedProject(session, id) {
26
+ return "kind" in session
27
+ ? this.options.registry.getAccount(session.ownerId, id)
28
+ : this.options.registry.get(session.username, id);
29
+ }
30
+ async authorizeContinuous(session) {
31
+ this.authorize(session);
32
+ await this.options.revalidateSession?.(session);
33
+ this.authorize(session);
34
+ }
24
35
  async reconcile() {
25
36
  this.statusChecks.clear();
26
37
  // Never adopt a previous controller's approvals or device credentials after a crash.
@@ -41,30 +52,47 @@ export class ProjectManager {
41
52
  }
42
53
  list(session) {
43
54
  this.authorize(session);
44
- return this.options.registry.list(session.username);
55
+ return "kind" in session
56
+ ? this.options.registry.listAccount(session.ownerId)
57
+ : this.options.registry.list(session.username);
45
58
  }
46
59
  get(session, id) {
47
60
  this.authorize(session);
48
- return projectView(this.options.registry.get(session.username, id));
61
+ return projectView(this.ownedProject(session, id));
49
62
  }
50
63
  create(session, name) {
51
64
  this.authorize(session);
52
- return projectView(this.options.registry.create(session.username, name));
65
+ return projectView("kind" in session
66
+ ? this.options.registry.createAccount(session.ownerId, name)
67
+ : this.options.registry.create(session.username, name));
53
68
  }
54
69
  async start(session, id) {
55
70
  this.authorize(session);
56
- this.options.registry.get(session.username, id);
71
+ this.ownedProject(session, id);
72
+ const revision = this.stopRevisions.get(id) ?? 0;
73
+ const authorizeStart = () => {
74
+ this.authorize(session);
75
+ if ((this.stopRevisions.get(id) ?? 0) !== revision)
76
+ throw new ProjectRegistryError(409, "项目启动已取消。");
77
+ };
78
+ if (this.options.revalidateSession)
79
+ await this.authorizeContinuous(session);
80
+ authorizeStart();
57
81
  const stopping = this.stops.get(id);
58
82
  if (stopping)
59
83
  await stopping;
60
- this.authorize(session);
84
+ if (this.options.revalidateSession)
85
+ await this.authorizeContinuous(session);
86
+ authorizeStart();
61
87
  const existing = this.starts.get(id);
62
88
  if (existing) {
63
89
  const result = await existing;
64
- this.authorize(session);
90
+ if (this.options.revalidateSession)
91
+ await this.authorizeContinuous(session);
92
+ authorizeStart();
65
93
  return result;
66
94
  }
67
- const project = this.options.registry.get(session.username, id);
95
+ const project = this.ownedProject(session, id);
68
96
  if (project.status === "running") {
69
97
  await this.resolveTarget(session, id);
70
98
  return this.get(session, id);
@@ -84,14 +112,18 @@ export class ProjectManager {
84
112
  const operation = (async () => {
85
113
  try {
86
114
  await this.options.revokeProject(id);
87
- this.authorize(session);
88
- if (this.options.registry.get(session.username, id).status !== "starting")
115
+ if (this.options.revalidateSession)
116
+ await this.authorizeContinuous(session);
117
+ authorizeStart();
118
+ if (this.ownedProject(session, id).status !== "starting")
89
119
  throw new ProjectRegistryError(409, "项目启动已取消。");
90
120
  const target = await this.options.provider.ensure(record, {
91
121
  publicOrigin: this.options.publicOrigin(),
92
122
  });
93
- this.authorize(session);
94
- const current = this.options.registry.get(session.username, id);
123
+ if (this.options.revalidateSession)
124
+ await this.authorizeContinuous(session);
125
+ authorizeStart();
126
+ const current = this.ownedProject(session, id);
95
127
  if (current.status !== "starting" || current.generation !== record.generation)
96
128
  throw new ProjectRegistryError(409, "项目启动已取消。");
97
129
  if (target.generation !== record.generation)
@@ -109,7 +141,7 @@ export class ProjectManager {
109
141
  catch {
110
142
  stopped = false;
111
143
  }
112
- const current = this.options.registry.get(session.username, id);
144
+ const current = this.ownedProject(session, id);
113
145
  if (current.generation === record.generation && current.status === "starting") {
114
146
  this.options.registry.update(id, {
115
147
  status: stopped ? "stopped" : "error",
@@ -129,8 +161,13 @@ export class ProjectManager {
129
161
  }
130
162
  async stop(session, id) {
131
163
  this.authorize(session);
132
- this.options.registry.get(session.username, id);
164
+ this.ownedProject(session, id);
165
+ this.stopRevisions.set(id, (this.stopRevisions.get(id) ?? 0) + 1);
166
+ if (this.options.revalidateSession)
167
+ await this.authorizeContinuous(session);
133
168
  const result = await this.beginStop(id);
169
+ if (this.options.revalidateSession)
170
+ await this.authorizeContinuous(session);
134
171
  this.authorize(session);
135
172
  return result;
136
173
  }
@@ -168,15 +205,19 @@ export class ProjectManager {
168
205
  }
169
206
  }
170
207
  async resolveTarget(session, id) {
208
+ if (this.options.revalidateSession)
209
+ await this.authorizeContinuous(session);
171
210
  this.authorize(session);
172
- const record = this.options.registry.get(session.username, id);
211
+ const record = this.ownedProject(session, id);
173
212
  const target = this.targets.get(id);
174
213
  if (record.status !== "running" || !target || target.generation !== record.generation)
175
214
  throw new ProjectRegistryError(409, "项目尚未运行,请先启动项目。");
176
215
  // The provider checks labels and the loopback binding, not just a remembered port.
177
216
  const state = await this.observeRuntime(record, target);
217
+ if (this.options.revalidateSession)
218
+ await this.authorizeContinuous(session);
178
219
  this.authorize(session);
179
- const current = this.options.registry.get(session.username, id);
220
+ const current = this.ownedProject(session, id);
180
221
  if (current.status !== "running" || current.generation !== target.generation)
181
222
  throw new ProjectRegistryError(409, "项目已停止或重新启动,请重新进入。");
182
223
  if (state.state !== "running" || state.url !== target.url) {
@@ -1,12 +1,12 @@
1
1
  import { type IncomingMessage, type ServerResponse } from "node:http";
2
2
  import { type Duplex } from "node:stream";
3
3
  import { type HubAuth } from "../hub/auth-http.js";
4
- import type { HubSession } from "../hub/auth-store.js";
4
+ import type { ProjectControlAuthority, ProjectControlSession } from "./control-authority.js";
5
5
  import type { ProjectRuntimeConnection } from "./types.js";
6
6
  export type RuntimeTarget = ProjectRuntimeConnection;
7
7
  export interface ProjectRuntimeProxyOptions {
8
- auth: HubAuth;
9
- resolveTarget(projectId: string, session: HubSession): Promise<RuntimeTarget>;
8
+ auth: HubAuth | ProjectControlAuthority;
9
+ resolveTarget(projectId: string, session: ProjectControlSession): Promise<RuntimeTarget>;
10
10
  publicOrigin: () => string;
11
11
  /** Stop this runtime if its private logout cannot acknowledge revocation.
12
12
  * Dispatched after retirement settles; may call back into revokeProject. */
@@ -86,6 +86,9 @@ export function createProjectRuntimeProxy(options) {
86
86
  }
87
87
  async function validate(lease) {
88
88
  const session = assertLease(lease);
89
+ if ("revalidate" in options.auth)
90
+ await options.auth.revalidate(session);
91
+ assertLease(lease);
89
92
  const current = checkedTarget(await options.resolveTarget(lease.projectId, session));
90
93
  assertLease(lease);
91
94
  if (!sameTarget(current, lease.target)) {
@@ -212,7 +215,7 @@ export function createProjectRuntimeProxy(options) {
212
215
  // An opaque iframe has no cookie and Origin:null. Only its already issued
213
216
  // random capability takes this path; another signed-in owner cannot borrow it.
214
217
  if (req.headers.authorization !== undefined ||
215
- req.headers.cookie?.includes(`${HUB_SESSION_COOKIE}=`)) {
218
+ req.headers.cookie?.includes(`${"cookieName" in options.auth ? options.auth.cookieName : HUB_SESSION_COOKIE}=`)) {
216
219
  const session = await options.auth.authenticate(req);
217
220
  if (!session || session.id !== grant.lease.sessionId)
218
221
  throw new ProxyError(403, "Panel asset belongs to another session");
@@ -1,5 +1,6 @@
1
- import { renameSync } from "node:fs";
1
+ import { fsyncSync, renameSync } from "node:fs";
2
2
  import type { ProjectRuntimeRecord } from "./types.js";
3
+ import { type CloudAccountBinding } from "./account-binding.js";
3
4
  export type ProjectStatus = "stopped" | "starting" | "running" | "stopping" | "error";
4
5
  export interface StoredProject extends ProjectRuntimeRecord {
5
6
  name: string;
@@ -32,14 +33,20 @@ export declare class ProjectRegistry {
32
33
  private readonly controllerLock;
33
34
  private diskDigest;
34
35
  private markerEstablished;
36
+ private storageFailed;
35
37
  constructor(dataDir: string, now?: () => number, io?: {
36
38
  rename?: typeof renameSync;
39
+ fsync?: typeof fsyncSync;
40
+ offlineAccountAdoption?: boolean;
37
41
  });
38
42
  /** Release the single-controller lease on shutdown or startup failure. */
39
43
  close(): void;
40
44
  /** Offline restore only: new Docker namespace, same project credentials for restored data volumes. */
41
45
  renewInstallationForRestore(): void;
42
46
  get installationId(): string;
47
+ get accountBinding(): CloudAccountBinding | undefined;
48
+ /** Offline migration only; the caller must durably preserve source evidence first. */
49
+ bindAccount(binding: CloudAccountBinding): void;
43
50
  private validate;
44
51
  private assertStorage;
45
52
  private write;
@@ -47,6 +54,12 @@ export declare class ProjectRegistry {
47
54
  list(username: string): ProjectView[];
48
55
  get(username: string, id: string): StoredProject;
49
56
  create(username: string, name: unknown): StoredProject;
57
+ listAccount(ownerId: string): ProjectView[];
58
+ getAccount(ownerId: string, id: string): StoredProject;
59
+ createAccount(ownerId: string, name: unknown): StoredProject;
60
+ private requireLegacy;
61
+ private requireAccountOwner;
62
+ private createForOwner;
50
63
  update(id: string, patch: {
51
64
  status: ProjectStatus;
52
65
  generation?: number;