@chatroomcp/chatroom 0.1.3

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 (159) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +50 -0
  3. package/assets/chatroom.svg +15 -0
  4. package/chatroom.config.example.json +24 -0
  5. package/dist/app/application.d.ts +18 -0
  6. package/dist/app/application.js +63 -0
  7. package/dist/app/event-bus.d.ts +23 -0
  8. package/dist/app/event-bus.js +12 -0
  9. package/dist/app/external-access-registry.d.ts +18 -0
  10. package/dist/app/external-access-registry.js +48 -0
  11. package/dist/app/lifecycle.d.ts +12 -0
  12. package/dist/app/lifecycle.js +41 -0
  13. package/dist/auth/auth-service.d.ts +88 -0
  14. package/dist/auth/auth-service.js +222 -0
  15. package/dist/auth/ingress-policy.d.ts +21 -0
  16. package/dist/auth/ingress-policy.js +57 -0
  17. package/dist/auth/passkey-service.d.ts +39 -0
  18. package/dist/auth/passkey-service.js +145 -0
  19. package/dist/cli/index.d.ts +2 -0
  20. package/dist/cli/index.js +10 -0
  21. package/dist/cli/run-cli.d.ts +1 -0
  22. package/dist/cli/run-cli.js +42 -0
  23. package/dist/config/load-config.d.ts +7 -0
  24. package/dist/config/load-config.js +182 -0
  25. package/dist/config/platform-paths.d.ts +6 -0
  26. package/dist/config/platform-paths.js +34 -0
  27. package/dist/config/types.d.ts +25 -0
  28. package/dist/config/types.js +1 -0
  29. package/dist/core/auth/passkey-repository.d.ts +18 -0
  30. package/dist/core/auth/passkey-repository.js +1 -0
  31. package/dist/core/auth/repository.d.ts +59 -0
  32. package/dist/core/auth/repository.js +7 -0
  33. package/dist/core/auth/session-repository.d.ts +6 -0
  34. package/dist/core/auth/session-repository.js +1 -0
  35. package/dist/core/errors/chatroom-error.d.ts +23 -0
  36. package/dist/core/errors/chatroom-error.js +46 -0
  37. package/dist/core/operations/bounded-value.d.ts +6 -0
  38. package/dist/core/operations/bounded-value.js +41 -0
  39. package/dist/core/operations/redactor.d.ts +4 -0
  40. package/dist/core/operations/redactor.js +65 -0
  41. package/dist/core/operations/repository.d.ts +19 -0
  42. package/dist/core/operations/repository.js +1 -0
  43. package/dist/core/operations/types.d.ts +31 -0
  44. package/dist/core/operations/types.js +9 -0
  45. package/dist/core/runtime/child-environment.d.ts +1 -0
  46. package/dist/core/runtime/child-environment.js +39 -0
  47. package/dist/core/runtime/command-runner.d.ts +15 -0
  48. package/dist/core/runtime/command-runner.js +39 -0
  49. package/dist/core/runtime/identity.d.ts +2 -0
  50. package/dist/core/runtime/identity.js +9 -0
  51. package/dist/infrastructure/database/app-database.d.ts +6 -0
  52. package/dist/infrastructure/database/app-database.js +104 -0
  53. package/dist/infrastructure/database/oauth-repository.d.ts +20 -0
  54. package/dist/infrastructure/database/oauth-repository.js +140 -0
  55. package/dist/infrastructure/database/operation-repository.d.ts +16 -0
  56. package/dist/infrastructure/database/operation-repository.js +129 -0
  57. package/dist/infrastructure/database/passkey-repository.d.ts +11 -0
  58. package/dist/infrastructure/database/passkey-repository.js +57 -0
  59. package/dist/infrastructure/database/web-session-repository.d.ts +10 -0
  60. package/dist/infrastructure/database/web-session-repository.js +26 -0
  61. package/dist/infrastructure/http/http-server.d.ts +26 -0
  62. package/dist/infrastructure/http/http-server.js +166 -0
  63. package/dist/mcp/server/create-mcp-server.d.ts +3 -0
  64. package/dist/mcp/server/create-mcp-server.js +16 -0
  65. package/dist/mcp/server/tool-support.d.ts +105 -0
  66. package/dist/mcp/server/tool-support.js +124 -0
  67. package/dist/operations/operation-log.d.ts +23 -0
  68. package/dist/operations/operation-log.js +103 -0
  69. package/dist/plugins/cloud/api-client.d.ts +56 -0
  70. package/dist/plugins/cloud/api-client.js +122 -0
  71. package/dist/plugins/cloud/controller.d.ts +48 -0
  72. package/dist/plugins/cloud/controller.js +282 -0
  73. package/dist/plugins/cloud/device-proof.d.ts +9 -0
  74. package/dist/plugins/cloud/device-proof.js +17 -0
  75. package/dist/plugins/cloud/plugin.d.ts +4 -0
  76. package/dist/plugins/cloud/plugin.js +17 -0
  77. package/dist/plugins/cloud/state-store.d.ts +7 -0
  78. package/dist/plugins/cloud/state-store.js +74 -0
  79. package/dist/plugins/cloud/tunnel-client.d.ts +36 -0
  80. package/dist/plugins/cloud/tunnel-client.js +293 -0
  81. package/dist/plugins/cloud/types.d.ts +70 -0
  82. package/dist/plugins/cloud/types.js +32 -0
  83. package/dist/plugins/plugin-manager.d.ts +11 -0
  84. package/dist/plugins/plugin-manager.js +24 -0
  85. package/dist/plugins/process/backend.d.ts +19 -0
  86. package/dist/plugins/process/backend.js +1 -0
  87. package/dist/plugins/process/head-tail-buffer.d.ts +18 -0
  88. package/dist/plugins/process/head-tail-buffer.js +49 -0
  89. package/dist/plugins/process/infrastructure/pipe-process-backend.d.ts +8 -0
  90. package/dist/plugins/process/infrastructure/pipe-process-backend.js +61 -0
  91. package/dist/plugins/process/infrastructure/pty-process-backend.d.ts +8 -0
  92. package/dist/plugins/process/infrastructure/pty-process-backend.js +50 -0
  93. package/dist/plugins/process/mcp.d.ts +4 -0
  94. package/dist/plugins/process/mcp.js +69 -0
  95. package/dist/plugins/process/plugin.d.ts +4 -0
  96. package/dist/plugins/process/plugin.js +28 -0
  97. package/dist/plugins/process/process-supervisor.d.ts +36 -0
  98. package/dist/plugins/process/process-supervisor.js +232 -0
  99. package/dist/plugins/process/types.d.ts +34 -0
  100. package/dist/plugins/process/types.js +1 -0
  101. package/dist/plugins/types.d.ts +32 -0
  102. package/dist/plugins/types.js +17 -0
  103. package/dist/plugins/web/api-types.d.ts +10 -0
  104. package/dist/plugins/web/api-types.js +1 -0
  105. package/dist/plugins/web/http/api-router.d.ts +13 -0
  106. package/dist/plugins/web/http/api-router.js +146 -0
  107. package/dist/plugins/web/http/cloud-api-router.d.ts +4 -0
  108. package/dist/plugins/web/http/cloud-api-router.js +41 -0
  109. package/dist/plugins/web/http/process-api-router.d.ts +3 -0
  110. package/dist/plugins/web/http/process-api-router.js +16 -0
  111. package/dist/plugins/web/http/workspace-api-router.d.ts +3 -0
  112. package/dist/plugins/web/http/workspace-api-router.js +82 -0
  113. package/dist/plugins/web/plugin.d.ts +8 -0
  114. package/dist/plugins/web/plugin.js +17 -0
  115. package/dist/plugins/web/runtime.d.ts +36 -0
  116. package/dist/plugins/web/runtime.js +66 -0
  117. package/dist/plugins/workspace/domain/filesystem.d.ts +25 -0
  118. package/dist/plugins/workspace/domain/filesystem.js +1 -0
  119. package/dist/plugins/workspace/domain/git.d.ts +7 -0
  120. package/dist/plugins/workspace/domain/git.js +1 -0
  121. package/dist/plugins/workspace/domain/repository.d.ts +16 -0
  122. package/dist/plugins/workspace/domain/repository.js +1 -0
  123. package/dist/plugins/workspace/domain/review.d.ts +28 -0
  124. package/dist/plugins/workspace/domain/review.js +1 -0
  125. package/dist/plugins/workspace/domain/workspace.d.ts +23 -0
  126. package/dist/plugins/workspace/domain/workspace.js +1 -0
  127. package/dist/plugins/workspace/git/git-command-runner.d.ts +6 -0
  128. package/dist/plugins/workspace/git/git-command-runner.js +15 -0
  129. package/dist/plugins/workspace/git/git-service.d.ts +14 -0
  130. package/dist/plugins/workspace/git/git-service.js +107 -0
  131. package/dist/plugins/workspace/git/git-snapshot-service.d.ts +33 -0
  132. package/dist/plugins/workspace/git/git-snapshot-service.js +352 -0
  133. package/dist/plugins/workspace/infrastructure/workspace-fs.d.ts +38 -0
  134. package/dist/plugins/workspace/infrastructure/workspace-fs.js +340 -0
  135. package/dist/plugins/workspace/infrastructure/workspace-repository.d.ts +12 -0
  136. package/dist/plugins/workspace/infrastructure/workspace-repository.js +51 -0
  137. package/dist/plugins/workspace/mcp.d.ts +4 -0
  138. package/dist/plugins/workspace/mcp.js +168 -0
  139. package/dist/plugins/workspace/plugin.d.ts +10 -0
  140. package/dist/plugins/workspace/plugin.js +37 -0
  141. package/dist/plugins/workspace/workspace-helpers.d.ts +9 -0
  142. package/dist/plugins/workspace/workspace-helpers.js +67 -0
  143. package/dist/plugins/workspace/workspace-service.d.ts +46 -0
  144. package/dist/plugins/workspace/workspace-service.js +339 -0
  145. package/dist/plugins/workspace/worktree-review-service.d.ts +16 -0
  146. package/dist/plugins/workspace/worktree-review-service.js +51 -0
  147. package/dist/presentation/http/http-utils.d.ts +7 -0
  148. package/dist/presentation/http/http-utils.js +47 -0
  149. package/dist/presentation/http/oauth-router.d.ts +5 -0
  150. package/dist/presentation/http/oauth-router.js +163 -0
  151. package/dist/web/assets/chatroom-B5p-5n3y.svg +15 -0
  152. package/dist/web/assets/index-BF_mWl1D.js +26 -0
  153. package/dist/web/assets/index-JPZut9tc.css +1 -0
  154. package/dist/web/assets/materialdesignicons-webfont-B7mPwVP_.ttf +0 -0
  155. package/dist/web/assets/materialdesignicons-webfont-CSr8KVlo.eot +0 -0
  156. package/dist/web/assets/materialdesignicons-webfont-Dp5v-WZN.woff2 +0 -0
  157. package/dist/web/assets/materialdesignicons-webfont-PXm3-2wK.woff +0 -0
  158. package/dist/web/index.html +19 -0
  159. package/package.json +77 -0
@@ -0,0 +1,122 @@
1
+ import { z } from "zod";
2
+ import { CLOUD_ENTITLEMENT_SCHEMA, CLOUD_LEASE_SCHEMA, } from "./types.js";
3
+ import { signDeviceProof } from "./device-proof.js";
4
+ const customerSchema = z.object({ id: z.string().min(1) }).strict();
5
+ const recoveryCredentialSchema = z
6
+ .object({
7
+ recoveryKey: z.string().regex(/^crr\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/),
8
+ })
9
+ .strict();
10
+ const sessionSchema = z
11
+ .object({
12
+ purchaseToken: z.string().min(24),
13
+ managementUrl: z.string().url(),
14
+ expiresAt: z.string().datetime(),
15
+ })
16
+ .strict();
17
+ const statusSchema = z
18
+ .object({
19
+ managementSessionActive: z.boolean(),
20
+ entitlements: z.array(CLOUD_ENTITLEMENT_SCHEMA),
21
+ customer: customerSchema.nullable(),
22
+ publicPrefix: z.string().min(1).nullable(),
23
+ })
24
+ .strict();
25
+ const restoreSchema = z
26
+ .object({
27
+ entitlements: z.array(CLOUD_ENTITLEMENT_SCHEMA),
28
+ customer: customerSchema,
29
+ publicPrefix: z.string().min(1).nullable(),
30
+ recoveryKey: z.string().regex(/^crr\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/),
31
+ })
32
+ .strict();
33
+ const prefixSchema = z
34
+ .object({
35
+ publicPrefix: z.string().min(1),
36
+ mcpUrl: z.string().url(),
37
+ webUrl: z.string().url(),
38
+ })
39
+ .strict();
40
+ export class CloudApiClient {
41
+ baseUrl;
42
+ constructor(baseUrl) {
43
+ this.baseUrl = baseUrl;
44
+ }
45
+ async createManagementSession(identity) {
46
+ const payload = { devicePublicKey: identity.devicePublicKey };
47
+ return sessionSchema.parse(await this.devicePost("/v1/device/session", "session", identity, payload));
48
+ }
49
+ async status(identity, purchaseToken) {
50
+ const payload = {
51
+ devicePublicKey: identity.devicePublicKey,
52
+ purchaseToken,
53
+ };
54
+ return statusSchema.parse(await this.devicePost("/v1/device/status", "status", identity, payload));
55
+ }
56
+ async restore(identity, recoveryKey) {
57
+ const payload = { devicePublicKey: identity.devicePublicKey, recoveryKey };
58
+ return restoreSchema.parse(await this.devicePost("/v1/device/restore", "restore", identity, payload));
59
+ }
60
+ async replaceRecoveryKey(identity) {
61
+ const payload = { devicePublicKey: identity.devicePublicKey };
62
+ return recoveryCredentialSchema.parse(await this.devicePost("/v1/device/recovery-key", "recovery-key", identity, payload));
63
+ }
64
+ async setPrefix(identity, prefix) {
65
+ const payload = { devicePublicKey: identity.devicePublicKey, prefix };
66
+ return prefixSchema.parse(await this.devicePost("/v1/device/prefix", "prefix", identity, payload));
67
+ }
68
+ async lease(identity, requestedServices) {
69
+ const payload = {
70
+ devicePublicKey: identity.devicePublicKey,
71
+ requestedServices,
72
+ };
73
+ return CLOUD_LEASE_SCHEMA.parse(await this.devicePost("/v1/device/lease", "lease", identity, payload));
74
+ }
75
+ async devicePost(path, operation, identity, payload) {
76
+ const proof = signDeviceProof({
77
+ operation,
78
+ installationId: identity.installationId,
79
+ devicePrivateKey: identity.devicePrivateKey,
80
+ payload,
81
+ });
82
+ const response = await fetch(new URL(path, `${this.baseUrl}/`), {
83
+ method: "POST",
84
+ headers: {
85
+ "content-type": "application/json",
86
+ accept: "application/json",
87
+ },
88
+ body: JSON.stringify({
89
+ installationId: identity.installationId,
90
+ ...payload,
91
+ ...proof,
92
+ }),
93
+ });
94
+ const body = (await response.json().catch(() => null));
95
+ if (!response.ok) {
96
+ const message = typeof body === "object" &&
97
+ body &&
98
+ "message" in body &&
99
+ typeof body.message === "string"
100
+ ? body.message
101
+ : `ChatRoom Cloud request failed (${response.status})`;
102
+ const issues = typeof body === "object" &&
103
+ body &&
104
+ "issues" in body &&
105
+ Array.isArray(body.issues)
106
+ ? body.issues.flatMap((issue) => {
107
+ if (!issue || typeof issue !== "object")
108
+ return [];
109
+ const path = "path" in issue && Array.isArray(issue.path)
110
+ ? issue.path.join(".")
111
+ : "";
112
+ const detail = "message" in issue && typeof issue.message === "string"
113
+ ? issue.message
114
+ : "";
115
+ return detail ? [`${path || "request"}: ${detail}`] : [];
116
+ })
117
+ : [];
118
+ throw new Error(issues.length ? `${message}: ${issues.join("; ")}` : message);
119
+ }
120
+ return body;
121
+ }
122
+ }
@@ -0,0 +1,48 @@
1
+ import type { ChatRoomConfig } from "../../config/types.js";
2
+ import { type CloudServiceId, type CloudStatus } from "./types.js";
3
+ interface ExternalAccessSink {
4
+ setCloud(state: {
5
+ mcpBaseUrl: string | null;
6
+ webBaseUrl: string | null;
7
+ }): void;
8
+ clearCloud(): void;
9
+ }
10
+ export declare class CloudController {
11
+ private readonly config;
12
+ private readonly externalAccess?;
13
+ private readonly store;
14
+ private readonly api;
15
+ private state;
16
+ private tunnel;
17
+ private connection;
18
+ private lastError;
19
+ private managementTimer;
20
+ private renewalTimer;
21
+ private stopped;
22
+ private constructor();
23
+ static create(config: ChatRoomConfig, externalAccess?: ExternalAccessSink): Promise<CloudController>;
24
+ status(): CloudStatus;
25
+ start(): Promise<void>;
26
+ stop(): Promise<void>;
27
+ managementUrl(): Promise<{
28
+ url: string;
29
+ expiresAt: string;
30
+ }>;
31
+ syncStatus(): Promise<CloudStatus>;
32
+ restore(recoveryKey: string): Promise<{
33
+ status: CloudStatus;
34
+ recoveryKey: string;
35
+ }>;
36
+ replaceRecoveryKey(): Promise<string>;
37
+ setPrefix(prefix: string): Promise<CloudStatus>;
38
+ setService(service: CloudServiceId, enabled: boolean): Promise<CloudStatus>;
39
+ private refreshLeaseIfNeeded;
40
+ private connectTunnel;
41
+ private publishExternalAccess;
42
+ private scheduleManagementSync;
43
+ private scheduleRenewal;
44
+ private validManagementSession;
45
+ private identity;
46
+ private setError;
47
+ }
48
+ export {};
@@ -0,0 +1,282 @@
1
+ import { CloudApiClient } from "./api-client.js";
2
+ import { CloudStateStore } from "./state-store.js";
3
+ import { CloudTunnelClient } from "./tunnel-client.js";
4
+ import { CLOUD_SERVICES, } from "./types.js";
5
+ export class CloudController {
6
+ config;
7
+ externalAccess;
8
+ store;
9
+ api;
10
+ state;
11
+ tunnel = null;
12
+ connection = "inactive";
13
+ lastError = null;
14
+ managementTimer = null;
15
+ renewalTimer = null;
16
+ stopped = true;
17
+ constructor(config, cloudApi, externalAccess) {
18
+ this.config = config;
19
+ this.externalAccess = externalAccess;
20
+ this.store = new CloudStateStore(config.dataDir);
21
+ this.api = new CloudApiClient(cloudApi);
22
+ }
23
+ static async create(config, externalAccess) {
24
+ const apiBaseUrl = (process.env.CHATROOM_CLOUD_API ?? "https://chatroomcp.com").replace(/\/$/, "");
25
+ const controller = new CloudController(config, apiBaseUrl, externalAccess);
26
+ controller.state = await controller.store.loadOrCreate();
27
+ controller.publishExternalAccess();
28
+ return controller;
29
+ }
30
+ status() {
31
+ return {
32
+ installationId: this.state.installationId,
33
+ customerId: this.state.customerId,
34
+ publicPrefix: this.state.publicPrefix,
35
+ desiredServices: { ...this.state.desiredServices },
36
+ entitlements: [...this.state.entitlements],
37
+ managementSessionActive: Boolean(this.state.managementSession &&
38
+ Date.parse(this.state.managementSession.expiresAt) > Date.now()),
39
+ connection: this.connection,
40
+ mcpUrl: this.state.lease?.mcpBaseUrl
41
+ ? `${this.state.lease.mcpBaseUrl}/mcp`
42
+ : null,
43
+ webUrl: this.state.lease?.webBaseUrl ?? null,
44
+ lastError: this.lastError,
45
+ };
46
+ }
47
+ async start() {
48
+ if (!this.stopped)
49
+ return;
50
+ this.stopped = false;
51
+ await this.syncStatus().catch((error) => this.setError(error));
52
+ this.scheduleManagementSync();
53
+ }
54
+ async stop() {
55
+ this.stopped = true;
56
+ if (this.managementTimer)
57
+ clearTimeout(this.managementTimer);
58
+ if (this.renewalTimer)
59
+ clearTimeout(this.renewalTimer);
60
+ this.managementTimer = null;
61
+ this.renewalTimer = null;
62
+ this.tunnel?.stop();
63
+ this.tunnel = null;
64
+ this.externalAccess?.clearCloud();
65
+ if (this.state.lease)
66
+ this.connection = "disconnected";
67
+ }
68
+ async managementUrl() {
69
+ const current = this.state.managementSession;
70
+ if (current && Date.parse(current.expiresAt) > Date.now()) {
71
+ return { url: current.managementUrl, expiresAt: current.expiresAt };
72
+ }
73
+ const session = await this.api.createManagementSession(this.identity());
74
+ this.state = {
75
+ ...this.state,
76
+ managementSession: {
77
+ purchaseToken: session.purchaseToken,
78
+ managementUrl: session.managementUrl,
79
+ expiresAt: session.expiresAt,
80
+ },
81
+ };
82
+ await this.store.save(this.state);
83
+ this.scheduleManagementSync(0);
84
+ return { url: session.managementUrl, expiresAt: session.expiresAt };
85
+ }
86
+ async syncStatus() {
87
+ const managementSession = this.validManagementSession();
88
+ const previousEntitlements = this.state.entitlements;
89
+ const previousPrefix = this.state.publicPrefix;
90
+ const result = await this.api.status(this.identity(), managementSession?.purchaseToken ?? null);
91
+ const nextCustomerId = result.customer?.id ?? null;
92
+ this.state = {
93
+ ...this.state,
94
+ customerId: nextCustomerId,
95
+ publicPrefix: result.publicPrefix,
96
+ desiredServices: enableNewlyPurchasedServices(this.state.desiredServices, previousEntitlements.map((entry) => entry.service), result.entitlements.map((entry) => entry.service)),
97
+ entitlements: result.entitlements,
98
+ managementSession: result.managementSessionActive
99
+ ? managementSession
100
+ : null,
101
+ };
102
+ await this.store.save(this.state);
103
+ if (!this.stopped) {
104
+ await this.refreshLeaseIfNeeded(previousPrefix !== null && previousPrefix !== result.publicPrefix);
105
+ this.connectTunnel();
106
+ this.scheduleRenewal();
107
+ }
108
+ this.lastError = null;
109
+ return this.status();
110
+ }
111
+ async restore(recoveryKey) {
112
+ const previousEntitlements = this.state.entitlements;
113
+ const result = await this.api.restore(this.identity(), recoveryKey);
114
+ this.state = {
115
+ ...this.state,
116
+ customerId: result.customer.id,
117
+ publicPrefix: result.publicPrefix,
118
+ desiredServices: enableNewlyPurchasedServices(this.state.desiredServices, previousEntitlements.map((entry) => entry.service), result.entitlements.map((entry) => entry.service)),
119
+ entitlements: result.entitlements,
120
+ lease: null,
121
+ };
122
+ await this.store.save(this.state);
123
+ this.tunnel?.stop();
124
+ this.tunnel = null;
125
+ if (!this.stopped) {
126
+ await this.refreshLeaseIfNeeded(true);
127
+ this.connectTunnel();
128
+ this.scheduleRenewal();
129
+ }
130
+ this.lastError = null;
131
+ return { status: this.status(), recoveryKey: result.recoveryKey };
132
+ }
133
+ async replaceRecoveryKey() {
134
+ const result = await this.api.replaceRecoveryKey(this.identity());
135
+ return result.recoveryKey;
136
+ }
137
+ async setPrefix(prefix) {
138
+ const result = await this.api.setPrefix(this.identity(), prefix);
139
+ this.state = { ...this.state, publicPrefix: result.publicPrefix };
140
+ await this.store.save(this.state);
141
+ if (!this.stopped && this.state.entitlements.length > 0) {
142
+ await this.refreshLeaseIfNeeded(true);
143
+ this.connectTunnel();
144
+ this.scheduleRenewal();
145
+ }
146
+ this.lastError = null;
147
+ return this.status();
148
+ }
149
+ async setService(service, enabled) {
150
+ if (!CLOUD_SERVICES.includes(service))
151
+ throw new Error(`Unknown Cloud service: ${service}`);
152
+ this.state = {
153
+ ...this.state,
154
+ desiredServices: { ...this.state.desiredServices, [service]: enabled },
155
+ };
156
+ await this.store.save(this.state);
157
+ if (!this.stopped) {
158
+ await this.refreshLeaseIfNeeded(true);
159
+ this.connectTunnel();
160
+ this.scheduleRenewal();
161
+ }
162
+ this.lastError = null;
163
+ return this.status();
164
+ }
165
+ async refreshLeaseIfNeeded(force) {
166
+ const entitled = new Set(this.state.entitlements.map((entry) => entry.service));
167
+ const requested = CLOUD_SERVICES.filter((service) => this.state.desiredServices[service] && entitled.has(service));
168
+ if (requested.length === 0) {
169
+ if (this.state.lease) {
170
+ this.state = { ...this.state, lease: null };
171
+ await this.store.save(this.state);
172
+ }
173
+ this.tunnel?.drainAndStop();
174
+ this.tunnel = null;
175
+ this.connection = "inactive";
176
+ this.publishExternalAccess();
177
+ return;
178
+ }
179
+ const current = this.state.lease;
180
+ const sameServices = current &&
181
+ requested.length === current.services.length &&
182
+ requested.every((service) => current.services.includes(service));
183
+ const fresh = current && Date.parse(current.expiresAt) - Date.now() > 15 * 60 * 1000;
184
+ if (!force && sameServices && fresh) {
185
+ this.publishExternalAccess();
186
+ return;
187
+ }
188
+ const lease = await this.api.lease(this.identity(), requested);
189
+ this.state = { ...this.state, lease };
190
+ await this.store.save(this.state);
191
+ this.publishExternalAccess();
192
+ this.tunnel?.updateLease(lease);
193
+ }
194
+ connectTunnel() {
195
+ if (this.stopped ||
196
+ this.tunnel ||
197
+ !this.state.lease ||
198
+ this.state.lease.services.length === 0)
199
+ return;
200
+ this.connection = "connecting";
201
+ this.tunnel = new CloudTunnelClient(this.state.lease, { devicePrivateKey: this.state.devicePrivateKey }, this.config.server, {
202
+ onConnected: () => {
203
+ this.lastError = null;
204
+ this.connection = "connected";
205
+ },
206
+ onDisconnected: () => {
207
+ if (!this.stopped && this.state.lease)
208
+ this.connection = "disconnected";
209
+ },
210
+ onError: (error) => this.setError(error),
211
+ });
212
+ this.tunnel.start();
213
+ }
214
+ publishExternalAccess() {
215
+ const lease = this.state.lease;
216
+ const activeLease = lease && Date.parse(lease.expiresAt) > Date.now() ? lease : null;
217
+ this.externalAccess?.setCloud({
218
+ mcpBaseUrl: activeLease?.services.includes("remote_mcp")
219
+ ? activeLease.mcpBaseUrl
220
+ : null,
221
+ webBaseUrl: activeLease?.services.includes("remote_web")
222
+ ? activeLease.webBaseUrl
223
+ : null,
224
+ });
225
+ }
226
+ scheduleManagementSync(delayMs = 3_000) {
227
+ if (this.managementTimer)
228
+ clearTimeout(this.managementTimer);
229
+ this.managementTimer = null;
230
+ if (this.stopped || !this.validManagementSession())
231
+ return;
232
+ this.managementTimer = setTimeout(() => {
233
+ this.managementTimer = null;
234
+ void this.syncStatus()
235
+ .catch((error) => this.setError(error))
236
+ .finally(() => this.scheduleManagementSync());
237
+ }, delayMs);
238
+ this.managementTimer.unref();
239
+ }
240
+ scheduleRenewal() {
241
+ if (this.renewalTimer)
242
+ clearTimeout(this.renewalTimer);
243
+ this.renewalTimer = null;
244
+ const lease = this.state.lease;
245
+ if (this.stopped || !lease)
246
+ return;
247
+ const remaining = Date.parse(lease.expiresAt) - Date.now();
248
+ const delay = Math.max(60_000, remaining - (10 + Math.random() * 10) * 60_000);
249
+ this.renewalTimer = setTimeout(() => {
250
+ this.renewalTimer = null;
251
+ void this.refreshLeaseIfNeeded(true)
252
+ .then(() => this.connectTunnel())
253
+ .catch((error) => this.setError(error))
254
+ .finally(() => this.scheduleRenewal());
255
+ }, delay);
256
+ this.renewalTimer.unref();
257
+ }
258
+ validManagementSession() {
259
+ const value = this.state.managementSession;
260
+ return value && Date.parse(value.expiresAt) > Date.now() ? value : null;
261
+ }
262
+ identity() {
263
+ return {
264
+ installationId: this.state.installationId,
265
+ devicePublicKey: this.state.devicePublicKey,
266
+ devicePrivateKey: this.state.devicePrivateKey,
267
+ };
268
+ }
269
+ setError(error) {
270
+ this.lastError = error instanceof Error ? error.message : String(error);
271
+ this.connection = "error";
272
+ }
273
+ }
274
+ function enableNewlyPurchasedServices(desiredServices, previousEntitlements, currentEntitlements) {
275
+ const previous = new Set(previousEntitlements);
276
+ const current = new Set(currentEntitlements);
277
+ const next = { ...desiredServices };
278
+ for (const service of CLOUD_SERVICES)
279
+ if (!previous.has(service) && current.has(service))
280
+ next[service] = true;
281
+ return next;
282
+ }
@@ -0,0 +1,9 @@
1
+ export declare function signDeviceProof(input: {
2
+ operation: string;
3
+ installationId: string;
4
+ devicePrivateKey: string;
5
+ payload: unknown;
6
+ }): {
7
+ timestamp: string;
8
+ signature: string;
9
+ };
@@ -0,0 +1,17 @@
1
+ import { createHash, createPrivateKey, sign } from "node:crypto";
2
+ export function signDeviceProof(input) {
3
+ const timestamp = new Date().toISOString();
4
+ const payloadHash = createHash("sha256")
5
+ .update(JSON.stringify(input.payload))
6
+ .digest("base64url");
7
+ const message = Buffer.from(`chatroom-cloud-device-v1\n${input.operation}\n${input.installationId}\n${timestamp}\n${payloadHash}`);
8
+ const key = createPrivateKey({
9
+ key: Buffer.from(input.devicePrivateKey, "base64url"),
10
+ format: "der",
11
+ type: "pkcs8",
12
+ });
13
+ return {
14
+ timestamp,
15
+ signature: sign(null, message, key).toString("base64url"),
16
+ };
17
+ }
@@ -0,0 +1,4 @@
1
+ import type { InternalPlugin } from "../types.js";
2
+ import { CloudController } from "./controller.js";
3
+ export declare const CloudService: import("../types.js").ServiceToken<CloudController>;
4
+ export declare function createCloudPlugin(): InternalPlugin;
@@ -0,0 +1,17 @@
1
+ import { createServiceToken } from "../types.js";
2
+ import { CloudController } from "./controller.js";
3
+ export const CloudService = createServiceToken("cloud");
4
+ export function createCloudPlugin() {
5
+ let controller = null;
6
+ return {
7
+ id: "cloud",
8
+ async activate(context) {
9
+ controller = await CloudController.create(context.config, context.externalAccess);
10
+ context.services.provide(CloudService, controller);
11
+ },
12
+ async deactivate() {
13
+ await controller?.stop();
14
+ controller = null;
15
+ },
16
+ };
17
+ }
@@ -0,0 +1,7 @@
1
+ import { type CloudPersistedState } from "./types.js";
2
+ export declare class CloudStateStore {
3
+ readonly filePath: string;
4
+ constructor(dataDir: string);
5
+ loadOrCreate(): Promise<CloudPersistedState>;
6
+ save(state: CloudPersistedState): Promise<void>;
7
+ }
@@ -0,0 +1,74 @@
1
+ import { generateKeyPairSync, randomUUID } from "node:crypto";
2
+ import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { z } from "zod";
5
+ import { CLOUD_DESIRED_SERVICES_SCHEMA, CLOUD_ENTITLEMENT_SCHEMA, CLOUD_LEASE_SCHEMA, } from "./types.js";
6
+ const stateSchema = z
7
+ .object({
8
+ schemaVersion: z.literal(1),
9
+ installationId: z.string().uuid(),
10
+ devicePublicKey: z.string().min(20),
11
+ devicePrivateKey: z.string().min(20),
12
+ customerId: z.string().min(1).nullable(),
13
+ publicPrefix: z.string().min(1).nullable(),
14
+ desiredServices: CLOUD_DESIRED_SERVICES_SCHEMA,
15
+ entitlements: z.array(CLOUD_ENTITLEMENT_SCHEMA),
16
+ managementSession: z
17
+ .object({
18
+ purchaseToken: z.string().min(24),
19
+ managementUrl: z.string().url(),
20
+ expiresAt: z.string().datetime(),
21
+ })
22
+ .strict()
23
+ .nullable(),
24
+ lease: CLOUD_LEASE_SCHEMA.nullable(),
25
+ })
26
+ .strict();
27
+ export class CloudStateStore {
28
+ filePath;
29
+ constructor(dataDir) {
30
+ this.filePath = path.join(dataDir, "cloud.json");
31
+ }
32
+ async loadOrCreate() {
33
+ try {
34
+ return stateSchema.parse(JSON.parse(await readFile(this.filePath, "utf8")));
35
+ }
36
+ catch (error) {
37
+ if (error.code !== "ENOENT")
38
+ throw error;
39
+ }
40
+ const state = createInitialState();
41
+ await this.save(state);
42
+ return state;
43
+ }
44
+ async save(state) {
45
+ const validated = stateSchema.parse(state);
46
+ await mkdir(path.dirname(this.filePath), { recursive: true, mode: 0o700 });
47
+ const temporary = `${this.filePath}.${process.pid}.${randomUUID()}.tmp`;
48
+ await writeFile(temporary, `${JSON.stringify(validated, null, 2)}\n`, {
49
+ mode: 0o600,
50
+ });
51
+ await chmod(temporary, 0o600).catch(() => undefined);
52
+ await rename(temporary, this.filePath);
53
+ await chmod(this.filePath, 0o600).catch(() => undefined);
54
+ }
55
+ }
56
+ function createInitialState() {
57
+ const { publicKey, privateKey } = generateKeyPairSync("ed25519");
58
+ return {
59
+ schemaVersion: 1,
60
+ installationId: randomUUID(),
61
+ devicePublicKey: publicKey
62
+ .export({ format: "der", type: "spki" })
63
+ .toString("base64url"),
64
+ devicePrivateKey: privateKey
65
+ .export({ format: "der", type: "pkcs8" })
66
+ .toString("base64url"),
67
+ customerId: null,
68
+ publicPrefix: null,
69
+ desiredServices: { remote_mcp: true, remote_web: true },
70
+ entitlements: [],
71
+ managementSession: null,
72
+ lease: null,
73
+ };
74
+ }
@@ -0,0 +1,36 @@
1
+ import { type CloudLeaseState } from "./types.js";
2
+ export declare class CloudTunnelClient {
3
+ private lease;
4
+ private readonly identity;
5
+ private readonly local;
6
+ private readonly callbacks;
7
+ private socket;
8
+ private reconnectTimer;
9
+ private stopped;
10
+ private attempts;
11
+ private closeWhenIdle;
12
+ private acceptingServices;
13
+ private readonly streams;
14
+ constructor(lease: CloudLeaseState, identity: {
15
+ devicePrivateKey: string;
16
+ }, local: {
17
+ host: string;
18
+ port: number;
19
+ }, callbacks: {
20
+ onConnected(): void;
21
+ onDisconnected(): void;
22
+ onError(error: Error): void;
23
+ });
24
+ start(): void;
25
+ stop(): void;
26
+ drainAndStop(): void;
27
+ updateLease(lease: CloudLeaseState): void;
28
+ private connect;
29
+ private handleControl;
30
+ private openStream;
31
+ private finishStream;
32
+ private handleBinary;
33
+ private send;
34
+ private sendBinary;
35
+ private scheduleReconnect;
36
+ }