@teammy/server 0.0.0-stage → 0.0.2
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/README.md +21 -2
- package/bin/teammy-server.js +8 -0
- package/dist/LICENSE +21 -0
- package/dist/main.js +1627 -0
- package/dist/web/assets/index-B6jzznIb.css +1 -0
- package/dist/web/assets/index-CDeIcfJr.js +91 -0
- package/dist/web/index.html +14 -0
- package/package.json +28 -4
package/dist/main.js
ADDED
|
@@ -0,0 +1,1627 @@
|
|
|
1
|
+
// apps/server/main.ts
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
4
|
+
|
|
5
|
+
// apps/server/static.ts
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import fastifyStatic from "@fastify/static";
|
|
8
|
+
function registerWeb(server, root) {
|
|
9
|
+
server.register(fastifyStatic, {
|
|
10
|
+
root: join(root, "assets"),
|
|
11
|
+
prefix: "/assets/",
|
|
12
|
+
immutable: true,
|
|
13
|
+
maxAge: "1y",
|
|
14
|
+
index: false,
|
|
15
|
+
dotfiles: "deny"
|
|
16
|
+
});
|
|
17
|
+
for (const path of ["/", "/files", "/members", "/settings"])
|
|
18
|
+
server.get(
|
|
19
|
+
path,
|
|
20
|
+
(_request, reply) => reply.header("cache-control", "no-cache").header(
|
|
21
|
+
"content-security-policy",
|
|
22
|
+
"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'"
|
|
23
|
+
).header("x-content-type-options", "nosniff").header("referrer-policy", "no-referrer").sendFile("index.html", root, { maxAge: 0, immutable: false })
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// apps/server/server.ts
|
|
28
|
+
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
29
|
+
import Fastify from "fastify";
|
|
30
|
+
|
|
31
|
+
// packages/protocol/index.ts
|
|
32
|
+
import { z } from "zod";
|
|
33
|
+
var PROTOCOL_VERSION = 1;
|
|
34
|
+
var HealthSchema = z.object({ status: z.literal("ok") });
|
|
35
|
+
var ServerInfoSchema = z.object({
|
|
36
|
+
name: z.literal("teammy"),
|
|
37
|
+
version: z.string().min(1),
|
|
38
|
+
protocolVersion: z.literal(PROTOCOL_VERSION),
|
|
39
|
+
capabilities: z.array(z.string())
|
|
40
|
+
});
|
|
41
|
+
var ErrorResponseSchema = z.object({
|
|
42
|
+
error: z.object({
|
|
43
|
+
code: z.string(),
|
|
44
|
+
message: z.string(),
|
|
45
|
+
requestId: z.string()
|
|
46
|
+
})
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// packages/protocol/identity.ts
|
|
50
|
+
import { z as z2 } from "zod";
|
|
51
|
+
var NameSchema = z2.string().regex(/^[a-z][a-z0-9_-]{0,63}$/);
|
|
52
|
+
var DeviceIdSchema = z2.uuid();
|
|
53
|
+
var BindRequestSchema = z2.object({ deviceId: DeviceIdSchema }).strict();
|
|
54
|
+
var BindingSchema = z2.object({
|
|
55
|
+
workspaceId: NameSchema,
|
|
56
|
+
memberId: NameSchema,
|
|
57
|
+
deviceId: DeviceIdSchema
|
|
58
|
+
});
|
|
59
|
+
var MembersSchema = z2.object({ members: z2.array(NameSchema) });
|
|
60
|
+
|
|
61
|
+
// apps/server/store.ts
|
|
62
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
63
|
+
import { join as join2 } from "node:path";
|
|
64
|
+
|
|
65
|
+
// packages/state/sqlite.ts
|
|
66
|
+
import { chmodSync, mkdirSync } from "node:fs";
|
|
67
|
+
import { dirname } from "node:path";
|
|
68
|
+
import { DatabaseSync } from "node:sqlite";
|
|
69
|
+
function transaction(db, action) {
|
|
70
|
+
db.exec("BEGIN IMMEDIATE");
|
|
71
|
+
try {
|
|
72
|
+
const result = action();
|
|
73
|
+
db.exec("COMMIT");
|
|
74
|
+
return result;
|
|
75
|
+
} catch (error) {
|
|
76
|
+
db.exec("ROLLBACK");
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function openDatabase(path, migrations2) {
|
|
81
|
+
mkdirSync(dirname(path), { recursive: true, mode: 448 });
|
|
82
|
+
const db = new DatabaseSync(path);
|
|
83
|
+
try {
|
|
84
|
+
chmodSync(path, 384);
|
|
85
|
+
db.exec(
|
|
86
|
+
"PRAGMA busy_timeout = 5000; PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL;"
|
|
87
|
+
);
|
|
88
|
+
transaction(db, () => {
|
|
89
|
+
const version = Number(db.prepare("PRAGMA user_version").get().user_version);
|
|
90
|
+
if (version > migrations2.length)
|
|
91
|
+
throw new Error("Database was created by a newer Teammy version.");
|
|
92
|
+
for (let index = version; index < migrations2.length; index++) {
|
|
93
|
+
db.exec(migrations2[index]);
|
|
94
|
+
db.exec(`PRAGMA user_version = ${index + 1}`);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
return db;
|
|
98
|
+
} catch (error) {
|
|
99
|
+
db.close();
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// apps/server/errors.ts
|
|
105
|
+
var HubError = class extends Error {
|
|
106
|
+
constructor(statusCode, code, message) {
|
|
107
|
+
super(message);
|
|
108
|
+
this.statusCode = statusCode;
|
|
109
|
+
this.code = code;
|
|
110
|
+
this.name = "HubError";
|
|
111
|
+
}
|
|
112
|
+
statusCode;
|
|
113
|
+
code;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// apps/server/session-schema.ts
|
|
117
|
+
var sessionMigration = String.raw`
|
|
118
|
+
CREATE TABLE sessions (
|
|
119
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
120
|
+
workspace_id TEXT NOT NULL, owner TEXT NOT NULL, session_key TEXT NOT NULL,
|
|
121
|
+
agent TEXT NOT NULL, native_id TEXT NOT NULL, cwd TEXT NOT NULL, title TEXT NOT NULL,
|
|
122
|
+
revision INTEGER NOT NULL, updated_at TEXT NOT NULL, message_count INTEGER NOT NULL,
|
|
123
|
+
hash TEXT NOT NULL, size INTEGER NOT NULL,
|
|
124
|
+
UNIQUE(workspace_id, owner, session_key)
|
|
125
|
+
);
|
|
126
|
+
CREATE INDEX session_listing ON sessions(workspace_id, id);
|
|
127
|
+
CREATE TABLE session_revisions (
|
|
128
|
+
workspace_id TEXT NOT NULL, owner TEXT NOT NULL, session_key TEXT NOT NULL,
|
|
129
|
+
revision INTEGER NOT NULL, info_json TEXT NOT NULL,
|
|
130
|
+
PRIMARY KEY(workspace_id, owner, session_key, revision)
|
|
131
|
+
);
|
|
132
|
+
CREATE TABLE session_operations (
|
|
133
|
+
workspace_id TEXT NOT NULL, owner TEXT NOT NULL, operation_id TEXT NOT NULL,
|
|
134
|
+
fingerprint TEXT NOT NULL, revision INTEGER NOT NULL,
|
|
135
|
+
PRIMARY KEY(workspace_id, owner, operation_id)
|
|
136
|
+
);
|
|
137
|
+
CREATE TABLE session_messages (
|
|
138
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
139
|
+
workspace_id TEXT NOT NULL, owner TEXT NOT NULL, session_key TEXT NOT NULL,
|
|
140
|
+
revision INTEGER NOT NULL, message_id TEXT NOT NULL, role TEXT NOT NULL, body TEXT NOT NULL, search_text TEXT NOT NULL
|
|
141
|
+
);
|
|
142
|
+
CREATE INDEX session_message_lookup ON session_messages(workspace_id, owner, session_key);
|
|
143
|
+
CREATE VIRTUAL TABLE session_fts USING fts5(body, tokenize='trigram');
|
|
144
|
+
`;
|
|
145
|
+
|
|
146
|
+
// apps/server/control-schema.ts
|
|
147
|
+
var controlMigration = String.raw`
|
|
148
|
+
CREATE TABLE device_status(workspace_id TEXT NOT NULL,member_id TEXT NOT NULL,device_id TEXT NOT NULL,seen_at TEXT NOT NULL,status_json TEXT NOT NULL,PRIMARY KEY(workspace_id,member_id));
|
|
149
|
+
`;
|
|
150
|
+
|
|
151
|
+
// apps/server/device-schema.ts
|
|
152
|
+
var deviceMigration = String.raw`
|
|
153
|
+
CREATE TABLE device_enrollments(workspace_id TEXT NOT NULL, member_id TEXT NOT NULL, operation_id TEXT NOT NULL, fingerprint TEXT NOT NULL, PRIMARY KEY(workspace_id,member_id,operation_id));
|
|
154
|
+
`;
|
|
155
|
+
|
|
156
|
+
// apps/server/project-schema.ts
|
|
157
|
+
var projectMigration = String.raw`
|
|
158
|
+
ALTER TABLE members ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1;
|
|
159
|
+
ALTER TABLE members ADD COLUMN role TEXT NOT NULL DEFAULT 'member';
|
|
160
|
+
ALTER TABLE members ADD COLUMN display_name TEXT NOT NULL DEFAULT '';
|
|
161
|
+
ALTER TABLE members ADD COLUMN member_revision INTEGER NOT NULL DEFAULT 1;
|
|
162
|
+
UPDATE members SET enabled=0 WHERE token_hash IS NULL;
|
|
163
|
+
CREATE TABLE project_settings(id INTEGER PRIMARY KEY CHECK(id=1), config_json TEXT NOT NULL, setup_operation TEXT NOT NULL, setup_fingerprint TEXT NOT NULL);
|
|
164
|
+
CREATE TABLE web_sessions(token_hash TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, member_id TEXT NOT NULL, expires_at INTEGER NOT NULL);
|
|
165
|
+
`;
|
|
166
|
+
|
|
167
|
+
// apps/server/file-schema.ts
|
|
168
|
+
var fileMigration = String.raw`
|
|
169
|
+
ALTER TABLE members ADD COLUMN file_revision INTEGER NOT NULL DEFAULT 0;
|
|
170
|
+
CREATE TABLE workspace_blobs (workspace_id TEXT NOT NULL, hash TEXT NOT NULL, size INTEGER NOT NULL, PRIMARY KEY(workspace_id, hash));
|
|
171
|
+
CREATE TABLE file_operations (
|
|
172
|
+
workspace_id TEXT NOT NULL, owner TEXT NOT NULL, operation_id TEXT NOT NULL,
|
|
173
|
+
fingerprint TEXT NOT NULL, revision INTEGER NOT NULL,
|
|
174
|
+
PRIMARY KEY(workspace_id, owner, operation_id)
|
|
175
|
+
);
|
|
176
|
+
CREATE TABLE file_revisions (
|
|
177
|
+
workspace_id TEXT NOT NULL, owner TEXT NOT NULL, revision INTEGER NOT NULL,
|
|
178
|
+
operation_id TEXT NOT NULL, created_at TEXT NOT NULL, changes_json TEXT NOT NULL,
|
|
179
|
+
PRIMARY KEY(workspace_id, owner, revision)
|
|
180
|
+
);
|
|
181
|
+
CREATE TABLE current_files (
|
|
182
|
+
workspace_id TEXT NOT NULL, owner TEXT NOT NULL, path TEXT NOT NULL, path_key TEXT NOT NULL,
|
|
183
|
+
hash TEXT NOT NULL, size INTEGER NOT NULL, revision INTEGER NOT NULL,
|
|
184
|
+
PRIMARY KEY(workspace_id, owner, path), UNIQUE(workspace_id, owner, path_key)
|
|
185
|
+
);
|
|
186
|
+
CREATE TABLE file_events (
|
|
187
|
+
workspace_id TEXT NOT NULL, owner TEXT NOT NULL, path TEXT NOT NULL, revision INTEGER NOT NULL,
|
|
188
|
+
hash TEXT, size INTEGER,
|
|
189
|
+
PRIMARY KEY(workspace_id, owner, path, revision)
|
|
190
|
+
);
|
|
191
|
+
`;
|
|
192
|
+
|
|
193
|
+
// apps/server/store.ts
|
|
194
|
+
function digest(token) {
|
|
195
|
+
return createHash("sha256").update(token).digest("hex");
|
|
196
|
+
}
|
|
197
|
+
var migrations = [
|
|
198
|
+
String.raw`
|
|
199
|
+
CREATE TABLE members (
|
|
200
|
+
workspace_id TEXT NOT NULL,
|
|
201
|
+
member_id TEXT NOT NULL,
|
|
202
|
+
token_hash TEXT UNIQUE,
|
|
203
|
+
device_id TEXT,
|
|
204
|
+
PRIMARY KEY (workspace_id, member_id)
|
|
205
|
+
);
|
|
206
|
+
`,
|
|
207
|
+
fileMigration,
|
|
208
|
+
sessionMigration,
|
|
209
|
+
projectMigration,
|
|
210
|
+
deviceMigration,
|
|
211
|
+
controlMigration
|
|
212
|
+
];
|
|
213
|
+
var HubStore = class {
|
|
214
|
+
constructor(directory) {
|
|
215
|
+
this.directory = directory;
|
|
216
|
+
this.db = openDatabase(join2(directory, "hub.sqlite3"), migrations);
|
|
217
|
+
}
|
|
218
|
+
directory;
|
|
219
|
+
db;
|
|
220
|
+
close() {
|
|
221
|
+
this.db.close();
|
|
222
|
+
}
|
|
223
|
+
issueMember(workspaceId, memberId, reset = false) {
|
|
224
|
+
NameSchema.parse(workspaceId);
|
|
225
|
+
NameSchema.parse(memberId);
|
|
226
|
+
const token = `tmy_${randomBytes(32).toString("hex")}`;
|
|
227
|
+
transaction(this.db, () => {
|
|
228
|
+
const exists = this.db.prepare("SELECT 1 FROM members WHERE workspace_id=? AND member_id=?").get(workspaceId, memberId);
|
|
229
|
+
if (exists && !reset)
|
|
230
|
+
throw new HubError(
|
|
231
|
+
409,
|
|
232
|
+
"MEMBER_EXISTS",
|
|
233
|
+
"Member exists; use member reset to replace its credential and device binding."
|
|
234
|
+
);
|
|
235
|
+
if (!exists && reset) throw new HubError(404, "MEMBER_NOT_FOUND", "Member does not exist.");
|
|
236
|
+
this.db.prepare(
|
|
237
|
+
`INSERT INTO members (workspace_id, member_id, token_hash) VALUES (?, ?, ?)
|
|
238
|
+
ON CONFLICT(workspace_id, member_id) DO UPDATE SET token_hash=excluded.token_hash, device_id=NULL, enabled=1, member_revision=member_revision+1`
|
|
239
|
+
).run(workspaceId, memberId, digest(token));
|
|
240
|
+
});
|
|
241
|
+
return token;
|
|
242
|
+
}
|
|
243
|
+
revokeMember(workspaceId, memberId) {
|
|
244
|
+
const result = this.db.prepare(
|
|
245
|
+
"UPDATE members SET token_hash=NULL, device_id=NULL, enabled=0, member_revision=member_revision+1 WHERE workspace_id=? AND member_id=?"
|
|
246
|
+
).run(NameSchema.parse(workspaceId), NameSchema.parse(memberId));
|
|
247
|
+
if (!result.changes) throw new HubError(404, "MEMBER_NOT_FOUND", "Member does not exist.");
|
|
248
|
+
this.db.prepare("DELETE FROM web_sessions WHERE workspace_id=? AND member_id=?").run(workspaceId, memberId);
|
|
249
|
+
}
|
|
250
|
+
identity(authorization) {
|
|
251
|
+
const match = authorization?.match(/^Bearer (tmy_[a-f0-9]{64})$/);
|
|
252
|
+
const row = match && this.db.prepare(
|
|
253
|
+
"SELECT workspace_id, member_id, device_id FROM members WHERE token_hash=? AND enabled=1"
|
|
254
|
+
).get(digest(match[1]));
|
|
255
|
+
if (!row) throw new HubError(401, "UNAUTHORIZED", "A valid member credential is required.");
|
|
256
|
+
return {
|
|
257
|
+
workspaceId: String(row.workspace_id),
|
|
258
|
+
memberId: String(row.member_id),
|
|
259
|
+
deviceId: row.device_id
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
bind(authorization, deviceId) {
|
|
263
|
+
return transaction(this.db, () => {
|
|
264
|
+
const identity = this.identity(authorization);
|
|
265
|
+
if (identity.deviceId && identity.deviceId !== deviceId)
|
|
266
|
+
throw new HubError(
|
|
267
|
+
409,
|
|
268
|
+
"DEVICE_BOUND",
|
|
269
|
+
"This member is bound to another device. An administrator must reset the binding."
|
|
270
|
+
);
|
|
271
|
+
this.db.prepare("UPDATE members SET device_id=? WHERE workspace_id=? AND member_id=?").run(deviceId, identity.workspaceId, identity.memberId);
|
|
272
|
+
return BindingSchema.parse({ ...identity, deviceId });
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
authorize(authorization, deviceId) {
|
|
276
|
+
const identity = this.identity(authorization);
|
|
277
|
+
if (!identity.deviceId || identity.deviceId !== deviceId)
|
|
278
|
+
throw new HubError(
|
|
279
|
+
403,
|
|
280
|
+
"DEVICE_MISMATCH",
|
|
281
|
+
"Connect this device before using workspace APIs."
|
|
282
|
+
);
|
|
283
|
+
return BindingSchema.parse(identity);
|
|
284
|
+
}
|
|
285
|
+
members(identity) {
|
|
286
|
+
return this.db.prepare("SELECT member_id FROM members WHERE workspace_id=? ORDER BY member_id").all(identity.workspaceId).map((row) => String(row.member_id));
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
// apps/server/server.ts
|
|
291
|
+
import { ZodError } from "zod";
|
|
292
|
+
|
|
293
|
+
// packages/protocol/devices.ts
|
|
294
|
+
import { z as z4 } from "zod";
|
|
295
|
+
|
|
296
|
+
// packages/protocol/files.ts
|
|
297
|
+
import { z as z3 } from "zod";
|
|
298
|
+
var MAX_BLOB_BYTES = 10 * 1024 * 1024;
|
|
299
|
+
var MAX_BATCH_FILES = 200;
|
|
300
|
+
var HashSchema = z3.string().regex(/^[a-f0-9]{64}$/);
|
|
301
|
+
var RevisionSchema = z3.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER);
|
|
302
|
+
var FilePathSchema = z3.string().min(1).max(1024).refine((path) => {
|
|
303
|
+
if (path !== path.normalize("NFC") || /[\\\x00-\x1f\x7f:<>"|?*]/.test(path)) return false;
|
|
304
|
+
return path.split("/").every(
|
|
305
|
+
(part) => part.length > 0 && part.length <= 255 && part !== "." && part !== ".." && !/[. ]$/.test(part) && !/^\.teammy(?:-|$)/i.test(part) && !/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part)
|
|
306
|
+
);
|
|
307
|
+
}, "Path must be a portable, normalized relative file path.");
|
|
308
|
+
var BlobRefSchema = z3.object({
|
|
309
|
+
sha256: HashSchema,
|
|
310
|
+
size: z3.number().int().min(0).max(MAX_BLOB_BYTES)
|
|
311
|
+
});
|
|
312
|
+
var FileChangeSchema = z3.discriminatedUnion("kind", [
|
|
313
|
+
BlobRefSchema.extend({ kind: z3.literal("upsert"), path: FilePathSchema }).strict(),
|
|
314
|
+
z3.object({ kind: z3.literal("delete"), path: FilePathSchema }).strict()
|
|
315
|
+
]);
|
|
316
|
+
var FileCommitSchema = z3.object({
|
|
317
|
+
operationId: z3.uuid(),
|
|
318
|
+
parentRevision: RevisionSchema,
|
|
319
|
+
changes: z3.array(FileChangeSchema).min(1).max(MAX_BATCH_FILES)
|
|
320
|
+
}).strict().refine(
|
|
321
|
+
(batch) => new Set(batch.changes.map((change) => change.path)).size === batch.changes.length,
|
|
322
|
+
"Duplicate paths in a batch."
|
|
323
|
+
);
|
|
324
|
+
var CommitResultSchema = z3.object({ operationId: z3.uuid(), revision: RevisionSchema });
|
|
325
|
+
var FileEntrySchema = BlobRefSchema.extend({
|
|
326
|
+
path: FilePathSchema,
|
|
327
|
+
revision: RevisionSchema
|
|
328
|
+
});
|
|
329
|
+
var ManifestSchema = z3.object({
|
|
330
|
+
revision: RevisionSchema,
|
|
331
|
+
files: z3.array(FileEntrySchema),
|
|
332
|
+
nextCursor: z3.string().nullable()
|
|
333
|
+
});
|
|
334
|
+
var FileRevisionSchema = z3.object({
|
|
335
|
+
revision: RevisionSchema,
|
|
336
|
+
operationId: z3.uuid(),
|
|
337
|
+
createdAt: z3.string(),
|
|
338
|
+
changes: z3.array(FileChangeSchema)
|
|
339
|
+
});
|
|
340
|
+
var ChangesSchema = z3.object({
|
|
341
|
+
headRevision: RevisionSchema,
|
|
342
|
+
revisions: z3.array(FileRevisionSchema)
|
|
343
|
+
});
|
|
344
|
+
var BlobCheckSchema = z3.object({ hashes: z3.array(HashSchema).max(MAX_BATCH_FILES) }).strict();
|
|
345
|
+
var MissingBlobsSchema = z3.object({ missing: z3.array(HashSchema) });
|
|
346
|
+
|
|
347
|
+
// packages/protocol/devices.ts
|
|
348
|
+
var CredentialSchema = z4.string().regex(/^tmy_[a-f0-9]{64}$/);
|
|
349
|
+
var EnrollmentSchema = z4.object({
|
|
350
|
+
operationId: z4.uuid(),
|
|
351
|
+
memberId: NameSchema,
|
|
352
|
+
deviceId: DeviceIdSchema,
|
|
353
|
+
credential: CredentialSchema
|
|
354
|
+
}).strict();
|
|
355
|
+
var EffectivePolicySchema = z4.object({
|
|
356
|
+
checkIntervalSeconds: z4.number().min(1).max(86400),
|
|
357
|
+
fileSettleSeconds: z4.number().min(0).max(86400)
|
|
358
|
+
}).strict();
|
|
359
|
+
var SyncSummarySchema = z4.object({
|
|
360
|
+
checkedAt: z4.string(),
|
|
361
|
+
published: z4.boolean(),
|
|
362
|
+
appliedFiles: z4.number().int().nonnegative(),
|
|
363
|
+
pendingFiles: z4.number().int().nonnegative(),
|
|
364
|
+
sessionSources: z4.number().int().nonnegative(),
|
|
365
|
+
errors: z4.array(z4.string().max(1e3)).max(20)
|
|
366
|
+
}).strict();
|
|
367
|
+
var DeviceStatusSchema = z4.object({
|
|
368
|
+
clientVersion: z4.string().max(64),
|
|
369
|
+
receivedRevision: RevisionSchema.nullable(),
|
|
370
|
+
appliedRevision: RevisionSchema.nullable(),
|
|
371
|
+
effectivePolicy: EffectivePolicySchema,
|
|
372
|
+
overrides: z4.array(z4.enum(["checkIntervalSeconds", "fileSettleSeconds"])).max(2),
|
|
373
|
+
configError: z4.string().max(2e3).nullable(),
|
|
374
|
+
reportError: z4.string().max(2e3).nullable(),
|
|
375
|
+
lastSync: SyncSummarySchema.nullable()
|
|
376
|
+
}).strict();
|
|
377
|
+
var WebDevicesSchema = z4.object({
|
|
378
|
+
devices: z4.array(
|
|
379
|
+
z4.object({
|
|
380
|
+
memberId: NameSchema,
|
|
381
|
+
bound: z4.boolean(),
|
|
382
|
+
lastSeenAt: z4.string().nullable(),
|
|
383
|
+
status: DeviceStatusSchema.nullable()
|
|
384
|
+
})
|
|
385
|
+
)
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
// apps/server/project.ts
|
|
389
|
+
import { createHash as createHash2, randomBytes as randomBytes2 } from "node:crypto";
|
|
390
|
+
|
|
391
|
+
// packages/protocol/project.ts
|
|
392
|
+
import { z as z5 } from "zod";
|
|
393
|
+
var ServerUrlSchema = z5.string().url().max(2048).refine((value) => {
|
|
394
|
+
const url = new URL(value);
|
|
395
|
+
return ["http:", "https:"].includes(url.protocol) && !url.username && !url.password && !url.search && !url.hash;
|
|
396
|
+
}, "Use an HTTP(S) server URL without credentials, query or fragment.");
|
|
397
|
+
var SyncPolicySchema = z5.object({
|
|
398
|
+
checkIntervalSeconds: z5.number().int().min(1).max(86400),
|
|
399
|
+
fileSettleSeconds: z5.number().int().min(0).max(86400)
|
|
400
|
+
}).strict();
|
|
401
|
+
var DEFAULT_POLICY = { checkIntervalSeconds: 60, fileSettleSeconds: 60 };
|
|
402
|
+
var ProjectConfigSchema = z5.object({
|
|
403
|
+
schemaVersion: z5.literal(1),
|
|
404
|
+
projectId: NameSchema,
|
|
405
|
+
projectName: z5.string().trim().min(1).max(120),
|
|
406
|
+
description: z5.string().max(2e3),
|
|
407
|
+
publicServerUrl: ServerUrlSchema,
|
|
408
|
+
configRevision: RevisionSchema,
|
|
409
|
+
scopeProfile: z5.literal("workspace-and-sessions-v1"),
|
|
410
|
+
sync: SyncPolicySchema
|
|
411
|
+
}).strict();
|
|
412
|
+
var SetupSchema = z5.object({
|
|
413
|
+
operationId: z5.uuid(),
|
|
414
|
+
projectName: z5.string().trim().min(1).max(120),
|
|
415
|
+
description: z5.string().max(2e3).default(""),
|
|
416
|
+
publicServerUrl: ServerUrlSchema,
|
|
417
|
+
adminId: NameSchema,
|
|
418
|
+
members: z5.array(NameSchema).max(100).default([]),
|
|
419
|
+
existingWorkspaceId: NameSchema.optional(),
|
|
420
|
+
sync: SyncPolicySchema.default(DEFAULT_POLICY)
|
|
421
|
+
}).strict();
|
|
422
|
+
var SetupStatusSchema = z5.object({
|
|
423
|
+
initialized: z5.boolean(),
|
|
424
|
+
existingWorkspaces: z5.array(NameSchema)
|
|
425
|
+
});
|
|
426
|
+
var ConfigUpdateSchema = ProjectConfigSchema.pick({
|
|
427
|
+
projectName: true,
|
|
428
|
+
description: true,
|
|
429
|
+
publicServerUrl: true,
|
|
430
|
+
sync: true
|
|
431
|
+
}).extend({ expectedRevision: RevisionSchema }).strict();
|
|
432
|
+
var MemberSchema = z5.object({
|
|
433
|
+
id: NameSchema,
|
|
434
|
+
displayName: z5.string().max(120),
|
|
435
|
+
role: z5.enum(["admin", "member"]),
|
|
436
|
+
enabled: z5.boolean(),
|
|
437
|
+
revision: RevisionSchema,
|
|
438
|
+
bound: z5.boolean(),
|
|
439
|
+
fileRevision: RevisionSchema,
|
|
440
|
+
lastPublishedAt: z5.string().nullable()
|
|
441
|
+
});
|
|
442
|
+
var WebSessionSchema = z5.object({
|
|
443
|
+
workspaceId: NameSchema,
|
|
444
|
+
memberId: NameSchema,
|
|
445
|
+
displayName: z5.string(),
|
|
446
|
+
role: z5.enum(["admin", "member"])
|
|
447
|
+
});
|
|
448
|
+
var MemberCreateSchema = z5.object({ id: NameSchema, displayName: z5.string().trim().max(120).default("") }).strict();
|
|
449
|
+
var MemberUpdateSchema = z5.object({
|
|
450
|
+
expectedRevision: RevisionSchema,
|
|
451
|
+
displayName: z5.string().trim().max(120).optional(),
|
|
452
|
+
enabled: z5.boolean().optional()
|
|
453
|
+
}).strict();
|
|
454
|
+
|
|
455
|
+
// apps/server/project.ts
|
|
456
|
+
var digest2 = (value) => createHash2("sha256").update(value).digest("hex");
|
|
457
|
+
var ProjectRepository = class {
|
|
458
|
+
constructor(hub) {
|
|
459
|
+
this.hub = hub;
|
|
460
|
+
}
|
|
461
|
+
hub;
|
|
462
|
+
status() {
|
|
463
|
+
return {
|
|
464
|
+
initialized: Boolean(this.hub.db.prepare("SELECT 1 FROM project_settings").get()),
|
|
465
|
+
existingWorkspaces: this.hub.db.prepare("SELECT DISTINCT workspace_id FROM members ORDER BY workspace_id").all().map((r) => String(r.workspace_id))
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
config(identity) {
|
|
469
|
+
const row = this.hub.db.prepare("SELECT config_json FROM project_settings WHERE id=1").get();
|
|
470
|
+
if (!row)
|
|
471
|
+
throw new HubError(409, "SETUP_REQUIRED", "Initialize the project in the browser first.");
|
|
472
|
+
const config = ProjectConfigSchema.parse(JSON.parse(String(row.config_json)));
|
|
473
|
+
if (identity && identity.workspaceId !== config.projectId)
|
|
474
|
+
throw new HubError(
|
|
475
|
+
404,
|
|
476
|
+
"PROJECT_NOT_FOUND",
|
|
477
|
+
"This workspace has no Web project configuration."
|
|
478
|
+
);
|
|
479
|
+
return config;
|
|
480
|
+
}
|
|
481
|
+
setup(input) {
|
|
482
|
+
const value = SetupSchema.parse(input);
|
|
483
|
+
const fingerprint = digest2(JSON.stringify(value));
|
|
484
|
+
return transaction(this.hub.db, () => {
|
|
485
|
+
const prior = this.hub.db.prepare("SELECT setup_operation,setup_fingerprint FROM project_settings").get();
|
|
486
|
+
if (prior) {
|
|
487
|
+
if (prior.setup_operation === value.operationId && prior.setup_fingerprint === fingerprint)
|
|
488
|
+
return this.config();
|
|
489
|
+
throw new HubError(
|
|
490
|
+
409,
|
|
491
|
+
"ALREADY_INITIALIZED",
|
|
492
|
+
"Project initialization has already completed."
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
const existing = this.status().existingWorkspaces;
|
|
496
|
+
if (existing.length && !value.existingWorkspaceId)
|
|
497
|
+
throw new HubError(
|
|
498
|
+
400,
|
|
499
|
+
"WORKSPACE_REQUIRED",
|
|
500
|
+
"Select an existing workspace to preserve its data."
|
|
501
|
+
);
|
|
502
|
+
if (value.existingWorkspaceId && !existing.includes(value.existingWorkspaceId))
|
|
503
|
+
throw new HubError(400, "WORKSPACE_NOT_FOUND", "Existing workspace was not found.");
|
|
504
|
+
const projectId = value.existingWorkspaceId ?? `proj-${randomBytes2(8).toString("hex")}`;
|
|
505
|
+
for (const id of /* @__PURE__ */ new Set([value.adminId, ...value.members])) {
|
|
506
|
+
this.hub.db.prepare("INSERT OR IGNORE INTO members(workspace_id,member_id) VALUES (?,?)").run(projectId, id);
|
|
507
|
+
}
|
|
508
|
+
this.hub.db.prepare(
|
|
509
|
+
"UPDATE members SET role='admin',enabled=1,member_revision=member_revision+1 WHERE workspace_id=? AND member_id=?"
|
|
510
|
+
).run(projectId, value.adminId);
|
|
511
|
+
const config = ProjectConfigSchema.parse({
|
|
512
|
+
schemaVersion: 1,
|
|
513
|
+
projectId,
|
|
514
|
+
projectName: value.projectName,
|
|
515
|
+
description: value.description,
|
|
516
|
+
publicServerUrl: value.publicServerUrl,
|
|
517
|
+
configRevision: 1,
|
|
518
|
+
scopeProfile: "workspace-and-sessions-v1",
|
|
519
|
+
sync: value.sync
|
|
520
|
+
});
|
|
521
|
+
this.hub.db.prepare("INSERT INTO project_settings VALUES (1,?,?,?)").run(JSON.stringify(config), value.operationId, fingerprint);
|
|
522
|
+
return config;
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
login(id) {
|
|
526
|
+
const { projectId } = this.config();
|
|
527
|
+
const row = this.hub.db.prepare("SELECT 1 FROM members WHERE workspace_id=? AND member_id=? AND enabled=1").get(projectId, id);
|
|
528
|
+
if (!row)
|
|
529
|
+
throw new HubError(401, "UNKNOWN_MEMBER", "Member ID does not exist or has been disabled.");
|
|
530
|
+
const token = randomBytes2(32).toString("hex");
|
|
531
|
+
this.hub.db.prepare("DELETE FROM web_sessions WHERE expires_at<=?").run(Date.now());
|
|
532
|
+
this.hub.db.prepare("INSERT INTO web_sessions VALUES (?,?,?,?)").run(digest2(token), projectId, id, Date.now() + 7 * 864e5);
|
|
533
|
+
return { token, session: this.session(token) };
|
|
534
|
+
}
|
|
535
|
+
session(token) {
|
|
536
|
+
const row = token && /^[a-f0-9]{64}$/.test(token) ? this.hub.db.prepare(
|
|
537
|
+
`SELECT s.workspace_id,s.member_id,m.display_name,m.role FROM web_sessions s JOIN members m USING(workspace_id,member_id) WHERE s.token_hash=? AND s.expires_at>? AND m.enabled=1`
|
|
538
|
+
).get(digest2(token), Date.now()) : void 0;
|
|
539
|
+
if (!row)
|
|
540
|
+
throw new HubError(401, "LOGIN_REQUIRED", "Please sign in with an existing member ID.");
|
|
541
|
+
return {
|
|
542
|
+
workspaceId: String(row.workspace_id),
|
|
543
|
+
memberId: String(row.member_id),
|
|
544
|
+
displayName: String(row.display_name || row.member_id),
|
|
545
|
+
role: row.role
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
logout(token) {
|
|
549
|
+
if (token)
|
|
550
|
+
this.hub.db.prepare("DELETE FROM web_sessions WHERE token_hash=?").run(digest2(token));
|
|
551
|
+
}
|
|
552
|
+
admin(identity) {
|
|
553
|
+
if (identity.role !== "admin")
|
|
554
|
+
throw new HubError(403, "ADMIN_REQUIRED", "This action requires an administrator.");
|
|
555
|
+
}
|
|
556
|
+
updateConfig(identity, input) {
|
|
557
|
+
this.admin(identity);
|
|
558
|
+
const value = ConfigUpdateSchema.parse(input);
|
|
559
|
+
return transaction(this.hub.db, () => {
|
|
560
|
+
const current = this.config(identity);
|
|
561
|
+
if (current.configRevision !== value.expectedRevision)
|
|
562
|
+
throw new HubError(409, "STALE_CONFIG", "Project settings changed; reload before saving.");
|
|
563
|
+
const { expectedRevision: _, ...fields } = value;
|
|
564
|
+
const next = { ...current, ...fields };
|
|
565
|
+
if (JSON.stringify(next) !== JSON.stringify(current)) next.configRevision++;
|
|
566
|
+
this.hub.db.prepare("UPDATE project_settings SET config_json=? WHERE id=1").run(JSON.stringify(next));
|
|
567
|
+
return next;
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
members(identity) {
|
|
571
|
+
return this.hub.db.prepare(
|
|
572
|
+
`SELECT m.*, (SELECT MAX(created_at) FROM file_revisions f WHERE f.workspace_id=m.workspace_id AND f.owner=m.member_id) last_published FROM members m WHERE workspace_id=? ORDER BY member_id`
|
|
573
|
+
).all(identity.workspaceId).map(
|
|
574
|
+
(row) => MemberSchema.parse({
|
|
575
|
+
id: row.member_id,
|
|
576
|
+
displayName: row.display_name || row.member_id,
|
|
577
|
+
role: row.role,
|
|
578
|
+
enabled: Boolean(row.enabled),
|
|
579
|
+
revision: row.member_revision,
|
|
580
|
+
bound: Boolean(row.device_id),
|
|
581
|
+
fileRevision: row.file_revision,
|
|
582
|
+
lastPublishedAt: row.last_published ?? null
|
|
583
|
+
})
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
createMember(identity, input) {
|
|
587
|
+
this.admin(identity);
|
|
588
|
+
const value = MemberCreateSchema.parse(input);
|
|
589
|
+
const result = this.hub.db.prepare("INSERT OR IGNORE INTO members(workspace_id,member_id,display_name) VALUES (?,?,?)").run(identity.workspaceId, value.id, value.displayName);
|
|
590
|
+
if (!result.changes) throw new HubError(409, "MEMBER_EXISTS", "Member ID already exists.");
|
|
591
|
+
return this.members(identity).find((m) => m.id === value.id);
|
|
592
|
+
}
|
|
593
|
+
updateMember(identity, id, input) {
|
|
594
|
+
const value = MemberUpdateSchema.parse(input);
|
|
595
|
+
if (identity.role !== "admin" && (id !== identity.memberId || value.enabled !== void 0))
|
|
596
|
+
throw new HubError(403, "FORBIDDEN", "You can only edit your own display name.");
|
|
597
|
+
return transaction(this.hub.db, () => {
|
|
598
|
+
const row = this.hub.db.prepare("SELECT * FROM members WHERE workspace_id=? AND member_id=?").get(identity.workspaceId, id);
|
|
599
|
+
if (!row) throw new HubError(404, "MEMBER_NOT_FOUND", "Member not found.");
|
|
600
|
+
if (row.member_revision !== value.expectedRevision)
|
|
601
|
+
throw new HubError(409, "STALE_MEMBER", "Member changed; reload before saving.");
|
|
602
|
+
if (value.enabled === false && row.role === "admin" && row.enabled) {
|
|
603
|
+
const count = this.hub.db.prepare(
|
|
604
|
+
"SELECT COUNT(*) n FROM members WHERE workspace_id=? AND role='admin' AND enabled=1"
|
|
605
|
+
).get(identity.workspaceId);
|
|
606
|
+
if (Number(count.n) <= 1)
|
|
607
|
+
throw new HubError(409, "LAST_ADMIN", "Keep at least one enabled administrator.");
|
|
608
|
+
}
|
|
609
|
+
this.hub.db.prepare(
|
|
610
|
+
"UPDATE members SET display_name=?,enabled=?,member_revision=member_revision+1 WHERE workspace_id=? AND member_id=?"
|
|
611
|
+
).run(
|
|
612
|
+
value.displayName ?? row.display_name,
|
|
613
|
+
value.enabled === void 0 ? row.enabled : Number(value.enabled),
|
|
614
|
+
identity.workspaceId,
|
|
615
|
+
id
|
|
616
|
+
);
|
|
617
|
+
if (value.enabled === false) this.clearAccess(identity.workspaceId, id);
|
|
618
|
+
return this.members(identity).find((m) => m.id === id);
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
clearAccess(workspace, id) {
|
|
622
|
+
this.hub.db.prepare(
|
|
623
|
+
"UPDATE members SET token_hash=NULL,device_id=NULL WHERE workspace_id=? AND member_id=?"
|
|
624
|
+
).run(workspace, id);
|
|
625
|
+
this.hub.db.prepare("DELETE FROM web_sessions WHERE workspace_id=? AND member_id=?").run(workspace, id);
|
|
626
|
+
}
|
|
627
|
+
resetDevice(identity, id) {
|
|
628
|
+
this.admin(identity);
|
|
629
|
+
const result = this.hub.db.prepare(
|
|
630
|
+
"UPDATE members SET token_hash=NULL,device_id=NULL,member_revision=member_revision+1 WHERE workspace_id=? AND member_id=?"
|
|
631
|
+
).run(identity.workspaceId, id);
|
|
632
|
+
if (!result.changes) throw new HubError(404, "MEMBER_NOT_FOUND", "Member not found.");
|
|
633
|
+
this.hub.db.prepare("DELETE FROM device_status WHERE workspace_id=? AND member_id=?").run(identity.workspaceId, id);
|
|
634
|
+
return { ok: true };
|
|
635
|
+
}
|
|
636
|
+
};
|
|
637
|
+
|
|
638
|
+
// apps/server/web-auth.ts
|
|
639
|
+
function cookieToken(request) {
|
|
640
|
+
return request.headers.cookie?.split(";").map((p) => p.trim()).find((p) => p.startsWith("teammy_session="))?.slice("teammy_session=".length);
|
|
641
|
+
}
|
|
642
|
+
function webMutation(request) {
|
|
643
|
+
if (request.headers["x-teammy-web"] !== "1")
|
|
644
|
+
throw new HubError(403, "WEB_REQUEST_REQUIRED", "Missing Web request header.");
|
|
645
|
+
const origin = request.headers.origin;
|
|
646
|
+
if (origin) {
|
|
647
|
+
let host;
|
|
648
|
+
try {
|
|
649
|
+
host = new URL(origin).host;
|
|
650
|
+
} catch {
|
|
651
|
+
throw new HubError(403, "ORIGIN_MISMATCH", "Invalid request origin.");
|
|
652
|
+
}
|
|
653
|
+
if (host !== request.headers.host)
|
|
654
|
+
throw new HubError(403, "ORIGIN_MISMATCH", "Request origin does not match this server.");
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
function authorizeRead(store, request) {
|
|
658
|
+
if (request.headers.authorization)
|
|
659
|
+
return store.authorize(
|
|
660
|
+
request.headers.authorization,
|
|
661
|
+
typeof request.headers["x-teammy-device"] === "string" ? request.headers["x-teammy-device"] : void 0
|
|
662
|
+
);
|
|
663
|
+
return new ProjectRepository(store).session(cookieToken(request));
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// apps/server/control-routes.ts
|
|
667
|
+
function registerControlRoutes(server, store) {
|
|
668
|
+
server.put("/api/v1/devices/self/status", async (request) => {
|
|
669
|
+
const device = request.headers["x-teammy-device"];
|
|
670
|
+
const identity = store.authorize(
|
|
671
|
+
request.headers.authorization,
|
|
672
|
+
typeof device === "string" ? device : void 0
|
|
673
|
+
);
|
|
674
|
+
new ProjectRepository(store).config(identity);
|
|
675
|
+
const status = DeviceStatusSchema.parse(request.body);
|
|
676
|
+
store.db.prepare(
|
|
677
|
+
"INSERT INTO device_status VALUES (?,?,?,?,?) ON CONFLICT(workspace_id,member_id) DO UPDATE SET device_id=excluded.device_id,seen_at=excluded.seen_at,status_json=excluded.status_json"
|
|
678
|
+
).run(
|
|
679
|
+
identity.workspaceId,
|
|
680
|
+
identity.memberId,
|
|
681
|
+
identity.deviceId,
|
|
682
|
+
(/* @__PURE__ */ new Date()).toISOString(),
|
|
683
|
+
JSON.stringify(status)
|
|
684
|
+
);
|
|
685
|
+
return { ok: true };
|
|
686
|
+
});
|
|
687
|
+
server.get("/api/v1/web/devices", async (request) => {
|
|
688
|
+
const identity = new ProjectRepository(store).session(cookieToken(request));
|
|
689
|
+
const devices = store.db.prepare(
|
|
690
|
+
"SELECT m.member_id,m.device_id,d.seen_at,d.status_json FROM members m LEFT JOIN device_status d ON d.workspace_id=m.workspace_id AND d.member_id=m.member_id AND d.device_id=m.device_id WHERE m.workspace_id=? ORDER BY m.member_id"
|
|
691
|
+
).all(identity.workspaceId).map((row) => ({
|
|
692
|
+
memberId: String(row.member_id),
|
|
693
|
+
bound: Boolean(row.device_id),
|
|
694
|
+
lastSeenAt: row.seen_at === null ? null : String(row.seen_at),
|
|
695
|
+
status: row.status_json ? DeviceStatusSchema.parse(JSON.parse(String(row.status_json))) : null
|
|
696
|
+
}));
|
|
697
|
+
return { devices };
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// apps/server/devices.ts
|
|
702
|
+
var DeviceRepository = class {
|
|
703
|
+
constructor(hub) {
|
|
704
|
+
this.hub = hub;
|
|
705
|
+
}
|
|
706
|
+
hub;
|
|
707
|
+
enroll(input) {
|
|
708
|
+
const value = EnrollmentSchema.parse(input);
|
|
709
|
+
return transaction(this.hub.db, () => {
|
|
710
|
+
const { projectId } = new ProjectRepository(this.hub).config();
|
|
711
|
+
const member = this.hub.db.prepare("SELECT * FROM members WHERE workspace_id=? AND member_id=? AND enabled=1").get(projectId, value.memberId);
|
|
712
|
+
if (!member)
|
|
713
|
+
throw new HubError(401, "UNKNOWN_MEMBER", "Member ID does not exist or has been disabled.");
|
|
714
|
+
const fingerprint = digest2(JSON.stringify(value));
|
|
715
|
+
const prior = this.hub.db.prepare(
|
|
716
|
+
"SELECT fingerprint FROM device_enrollments WHERE workspace_id=? AND member_id=? AND operation_id=?"
|
|
717
|
+
).get(projectId, value.memberId, value.operationId);
|
|
718
|
+
if (prior && prior.fingerprint !== fingerprint)
|
|
719
|
+
throw new HubError(
|
|
720
|
+
409,
|
|
721
|
+
"OPERATION_MISMATCH",
|
|
722
|
+
"Enrollment operation was reused with different content."
|
|
723
|
+
);
|
|
724
|
+
const tokenHash = digest2(value.credential);
|
|
725
|
+
if (prior) {
|
|
726
|
+
if (member.token_hash !== tokenHash || member.device_id !== value.deviceId)
|
|
727
|
+
throw new HubError(
|
|
728
|
+
409,
|
|
729
|
+
"ENROLLMENT_REVOKED",
|
|
730
|
+
"This enrollment was reset. Reconnect explicitly to create a new request."
|
|
731
|
+
);
|
|
732
|
+
} else {
|
|
733
|
+
if (member.device_id)
|
|
734
|
+
throw new HubError(
|
|
735
|
+
409,
|
|
736
|
+
"DEVICE_BOUND",
|
|
737
|
+
"This member already has a device. Reset it in the Web member settings before reconnecting."
|
|
738
|
+
);
|
|
739
|
+
this.hub.db.prepare(
|
|
740
|
+
"UPDATE members SET token_hash=?,device_id=?,member_revision=member_revision+1 WHERE workspace_id=? AND member_id=?"
|
|
741
|
+
).run(tokenHash, value.deviceId, projectId, value.memberId);
|
|
742
|
+
this.hub.db.prepare("INSERT INTO device_enrollments VALUES (?,?,?,?)").run(projectId, value.memberId, value.operationId, fingerprint);
|
|
743
|
+
}
|
|
744
|
+
return { workspaceId: projectId, memberId: value.memberId, deviceId: value.deviceId };
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
};
|
|
748
|
+
|
|
749
|
+
// apps/server/project-routes.ts
|
|
750
|
+
import { z as z6 } from "zod";
|
|
751
|
+
function registerProjectRoutes(server, store) {
|
|
752
|
+
const project = new ProjectRepository(store);
|
|
753
|
+
server.post(
|
|
754
|
+
"/api/v1/devices/enroll",
|
|
755
|
+
async (request) => new DeviceRepository(store).enroll(EnrollmentSchema.parse(request.body))
|
|
756
|
+
);
|
|
757
|
+
server.get("/api/v1/setup", async () => project.status());
|
|
758
|
+
server.post(
|
|
759
|
+
"/api/v1/setup",
|
|
760
|
+
{ onRequest: async (request) => webMutation(request) },
|
|
761
|
+
async (request) => project.setup(SetupSchema.parse(request.body))
|
|
762
|
+
);
|
|
763
|
+
server.post(
|
|
764
|
+
"/api/v1/web/session",
|
|
765
|
+
{ onRequest: async (request) => webMutation(request) },
|
|
766
|
+
async (request, reply) => {
|
|
767
|
+
const { id } = z6.object({ id: NameSchema }).strict().parse(request.body);
|
|
768
|
+
const { token, session } = project.login(id);
|
|
769
|
+
const secure = request.protocol === "https" || request.headers.origin?.startsWith("https://");
|
|
770
|
+
reply.header(
|
|
771
|
+
"set-cookie",
|
|
772
|
+
`teammy_session=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=604800${secure ? "; Secure" : ""}`
|
|
773
|
+
);
|
|
774
|
+
return session;
|
|
775
|
+
}
|
|
776
|
+
);
|
|
777
|
+
server.get("/api/v1/web/session", async (request) => project.session(cookieToken(request)));
|
|
778
|
+
server.delete("/api/v1/web/session", async (request, reply) => {
|
|
779
|
+
webMutation(request);
|
|
780
|
+
project.logout(cookieToken(request));
|
|
781
|
+
reply.header("set-cookie", "teammy_session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0");
|
|
782
|
+
return { ok: true };
|
|
783
|
+
});
|
|
784
|
+
server.get("/api/v1/project/config", async (request, reply) => {
|
|
785
|
+
const config = project.config(authorizeRead(store, request));
|
|
786
|
+
const etag = `"project-${config.configRevision}"`;
|
|
787
|
+
reply.header("cache-control", "private, no-cache").header("etag", etag);
|
|
788
|
+
if (request.headers["if-none-match"] === etag) return reply.code(304).send();
|
|
789
|
+
return config;
|
|
790
|
+
});
|
|
791
|
+
server.patch("/api/v1/project/config", async (request) => {
|
|
792
|
+
webMutation(request);
|
|
793
|
+
return project.updateConfig(
|
|
794
|
+
project.session(cookieToken(request)),
|
|
795
|
+
ConfigUpdateSchema.parse(request.body)
|
|
796
|
+
);
|
|
797
|
+
});
|
|
798
|
+
server.get("/api/v1/web/members", async (request) => ({
|
|
799
|
+
members: project.members(project.session(cookieToken(request)))
|
|
800
|
+
}));
|
|
801
|
+
server.post("/api/v1/web/members", async (request) => {
|
|
802
|
+
webMutation(request);
|
|
803
|
+
return project.createMember(
|
|
804
|
+
project.session(cookieToken(request)),
|
|
805
|
+
MemberCreateSchema.parse(request.body)
|
|
806
|
+
);
|
|
807
|
+
});
|
|
808
|
+
server.patch("/api/v1/web/members/:id", async (request) => {
|
|
809
|
+
webMutation(request);
|
|
810
|
+
const { id } = z6.object({ id: NameSchema }).parse(request.params);
|
|
811
|
+
return project.updateMember(
|
|
812
|
+
project.session(cookieToken(request)),
|
|
813
|
+
id,
|
|
814
|
+
MemberUpdateSchema.parse(request.body)
|
|
815
|
+
);
|
|
816
|
+
});
|
|
817
|
+
server.post("/api/v1/web/members/:id/device-reset", async (request) => {
|
|
818
|
+
webMutation(request);
|
|
819
|
+
const { id } = z6.object({ id: NameSchema }).parse(request.params);
|
|
820
|
+
return project.resetDevice(project.session(cookieToken(request)), id);
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// apps/server/session-routes.ts
|
|
825
|
+
import { z as z8 } from "zod";
|
|
826
|
+
|
|
827
|
+
// packages/protocol/sessions.ts
|
|
828
|
+
import { createHash as createHash3 } from "node:crypto";
|
|
829
|
+
import { z as z7 } from "zod";
|
|
830
|
+
var AgentSchema = z7.enum(["claude-code", "codex", "cursor", "pi", "dsh"]);
|
|
831
|
+
var SessionSnapshotSchema = z7.object({
|
|
832
|
+
formatVersion: z7.literal(1),
|
|
833
|
+
adapterVersion: z7.literal(1),
|
|
834
|
+
agent: AgentSchema,
|
|
835
|
+
nativeId: z7.string().min(1).max(512),
|
|
836
|
+
cwd: z7.string().min(1).max(4096),
|
|
837
|
+
messages: z7.array(
|
|
838
|
+
z7.object({
|
|
839
|
+
id: z7.string().min(1).max(1024),
|
|
840
|
+
role: z7.enum(["user", "assistant"]),
|
|
841
|
+
text: z7.string().min(1).max(2 * 1024 * 1024),
|
|
842
|
+
source: z7.string().min(1).max(1024)
|
|
843
|
+
}).strict()
|
|
844
|
+
).max(5e4)
|
|
845
|
+
}).strict().refine(
|
|
846
|
+
(s) => new Set(s.messages.map((m) => m.id)).size === s.messages.length,
|
|
847
|
+
"Duplicate message IDs."
|
|
848
|
+
);
|
|
849
|
+
function sessionKey(agent, nativeId) {
|
|
850
|
+
return createHash3("sha256").update(JSON.stringify([agent, nativeId])).digest("hex");
|
|
851
|
+
}
|
|
852
|
+
var SessionCommitSchema = z7.object({
|
|
853
|
+
operationId: z7.uuid(),
|
|
854
|
+
sessionKey: HashSchema,
|
|
855
|
+
parentRevision: RevisionSchema,
|
|
856
|
+
blob: BlobRefSchema
|
|
857
|
+
}).strict();
|
|
858
|
+
var SessionInfoSchema = z7.object({
|
|
859
|
+
owner: NameSchema,
|
|
860
|
+
sessionKey: HashSchema,
|
|
861
|
+
agent: AgentSchema,
|
|
862
|
+
nativeId: z7.string(),
|
|
863
|
+
cwd: z7.string(),
|
|
864
|
+
title: z7.string(),
|
|
865
|
+
revision: RevisionSchema,
|
|
866
|
+
updatedAt: z7.string(),
|
|
867
|
+
messageCount: z7.number().int().nonnegative(),
|
|
868
|
+
blob: BlobRefSchema
|
|
869
|
+
});
|
|
870
|
+
var SessionListSchema = z7.object({
|
|
871
|
+
sessions: z7.array(SessionInfoSchema),
|
|
872
|
+
nextCursor: RevisionSchema.nullable()
|
|
873
|
+
});
|
|
874
|
+
var SessionSearchSchema = z7.object({
|
|
875
|
+
matches: z7.array(
|
|
876
|
+
z7.object({
|
|
877
|
+
owner: NameSchema,
|
|
878
|
+
sessionKey: HashSchema,
|
|
879
|
+
revision: RevisionSchema,
|
|
880
|
+
messageId: z7.string(),
|
|
881
|
+
role: z7.enum(["user", "assistant"]),
|
|
882
|
+
snippet: z7.string()
|
|
883
|
+
})
|
|
884
|
+
),
|
|
885
|
+
nextCursor: RevisionSchema.nullable()
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
// apps/server/sessions.ts
|
|
889
|
+
import { createHash as createHash6 } from "node:crypto";
|
|
890
|
+
|
|
891
|
+
// apps/server/files.ts
|
|
892
|
+
import { createHash as createHash5 } from "node:crypto";
|
|
893
|
+
import { join as join4 } from "node:path";
|
|
894
|
+
|
|
895
|
+
// packages/storage/local-blob-store.ts
|
|
896
|
+
import { createHash as createHash4, randomUUID } from "node:crypto";
|
|
897
|
+
import { link, mkdir, open, readFile, unlink } from "node:fs/promises";
|
|
898
|
+
import { dirname as dirname2, join as join3, resolve } from "node:path";
|
|
899
|
+
function hash(content) {
|
|
900
|
+
return createHash4("sha256").update(content).digest("hex");
|
|
901
|
+
}
|
|
902
|
+
function hasCode(error, code) {
|
|
903
|
+
return error instanceof Error && "code" in error && error.code === code;
|
|
904
|
+
}
|
|
905
|
+
var BlobIntegrityError = class extends Error {
|
|
906
|
+
constructor(sha256) {
|
|
907
|
+
super(`Stored content failed integrity verification: ${sha256}`);
|
|
908
|
+
this.sha256 = sha256;
|
|
909
|
+
this.name = "BlobIntegrityError";
|
|
910
|
+
}
|
|
911
|
+
sha256;
|
|
912
|
+
};
|
|
913
|
+
var LocalBlobStore = class {
|
|
914
|
+
root;
|
|
915
|
+
constructor(root) {
|
|
916
|
+
this.root = resolve(root);
|
|
917
|
+
}
|
|
918
|
+
async put(content) {
|
|
919
|
+
const bytes = Buffer.from(content);
|
|
920
|
+
const sha256 = hash(bytes);
|
|
921
|
+
const target = this.pathFor(sha256);
|
|
922
|
+
await mkdir(dirname2(target), { recursive: true, mode: 448 });
|
|
923
|
+
const temporary = `${target}.${randomUUID()}.tmp`;
|
|
924
|
+
const file = await open(temporary, "wx", 384);
|
|
925
|
+
try {
|
|
926
|
+
try {
|
|
927
|
+
await file.writeFile(bytes);
|
|
928
|
+
await file.sync();
|
|
929
|
+
} finally {
|
|
930
|
+
await file.close();
|
|
931
|
+
}
|
|
932
|
+
try {
|
|
933
|
+
await link(temporary, target);
|
|
934
|
+
} catch (error) {
|
|
935
|
+
if (!hasCode(error, "EEXIST")) throw error;
|
|
936
|
+
await this.get(sha256);
|
|
937
|
+
}
|
|
938
|
+
if (process.platform !== "win32") {
|
|
939
|
+
let directory = dirname2(target);
|
|
940
|
+
for (; ; ) {
|
|
941
|
+
const handle = await open(directory, "r");
|
|
942
|
+
try {
|
|
943
|
+
await handle.sync();
|
|
944
|
+
} finally {
|
|
945
|
+
await handle.close();
|
|
946
|
+
}
|
|
947
|
+
if (directory === dirname2(this.root)) break;
|
|
948
|
+
directory = dirname2(directory);
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
} finally {
|
|
952
|
+
await unlink(temporary);
|
|
953
|
+
}
|
|
954
|
+
return { sha256, size: bytes.byteLength };
|
|
955
|
+
}
|
|
956
|
+
async get(sha256) {
|
|
957
|
+
const target = this.pathFor(sha256);
|
|
958
|
+
let content;
|
|
959
|
+
try {
|
|
960
|
+
content = await readFile(target);
|
|
961
|
+
} catch (error) {
|
|
962
|
+
if (hasCode(error, "ENOENT")) return void 0;
|
|
963
|
+
throw error;
|
|
964
|
+
}
|
|
965
|
+
if (hash(content) !== sha256) throw new BlobIntegrityError(sha256);
|
|
966
|
+
return content;
|
|
967
|
+
}
|
|
968
|
+
pathFor(sha256) {
|
|
969
|
+
if (!/^[a-f0-9]{64}$/.test(sha256)) throw new Error("Invalid SHA-256 content identifier.");
|
|
970
|
+
return join3(this.root, "sha256", sha256.slice(0, 2), sha256);
|
|
971
|
+
}
|
|
972
|
+
};
|
|
973
|
+
|
|
974
|
+
// apps/server/files.ts
|
|
975
|
+
var FileRepository = class {
|
|
976
|
+
constructor(hub) {
|
|
977
|
+
this.hub = hub;
|
|
978
|
+
this.blobs = new LocalBlobStore(join4(hub.directory, "blobs"));
|
|
979
|
+
}
|
|
980
|
+
hub;
|
|
981
|
+
blobs;
|
|
982
|
+
missing(identity, hashes) {
|
|
983
|
+
const query = this.hub.db.prepare(
|
|
984
|
+
"SELECT 1 FROM workspace_blobs WHERE workspace_id=? AND hash=?"
|
|
985
|
+
);
|
|
986
|
+
return hashes.filter((hash2) => !query.get(identity.workspaceId, hash2));
|
|
987
|
+
}
|
|
988
|
+
async upload(authorization, device, hash2, bytes) {
|
|
989
|
+
this.hub.authorize(authorization, device);
|
|
990
|
+
if (createHash5("sha256").update(bytes).digest("hex") !== HashSchema.parse(hash2))
|
|
991
|
+
throw new HubError(400, "HASH_MISMATCH", "Content does not match its SHA-256.");
|
|
992
|
+
const ref = await this.blobs.put(bytes);
|
|
993
|
+
transaction(this.hub.db, () => {
|
|
994
|
+
const identity = this.hub.authorize(authorization, device);
|
|
995
|
+
this.hub.db.prepare("INSERT OR IGNORE INTO workspace_blobs(workspace_id, hash, size) VALUES (?, ?, ?)").run(identity.workspaceId, ref.sha256, ref.size);
|
|
996
|
+
});
|
|
997
|
+
return ref;
|
|
998
|
+
}
|
|
999
|
+
async download(identity, hash2) {
|
|
1000
|
+
if (this.missing(identity, [hash2]).length)
|
|
1001
|
+
throw new HubError(404, "BLOB_NOT_FOUND", "Content is not available in this workspace.");
|
|
1002
|
+
const bytes = await this.blobs.get(hash2);
|
|
1003
|
+
if (!bytes) throw new HubError(500, "BLOB_MISSING", "Stored content is unavailable.");
|
|
1004
|
+
return bytes;
|
|
1005
|
+
}
|
|
1006
|
+
head(identity, owner) {
|
|
1007
|
+
const row = this.hub.db.prepare("SELECT file_revision FROM members WHERE workspace_id=? AND member_id=?").get(identity.workspaceId, owner);
|
|
1008
|
+
if (!row) throw new HubError(404, "MEMBER_NOT_FOUND", "Member is not part of this workspace.");
|
|
1009
|
+
return Number(row.file_revision);
|
|
1010
|
+
}
|
|
1011
|
+
commit(authorization, device, input) {
|
|
1012
|
+
const batch = FileCommitSchema.parse(input);
|
|
1013
|
+
const changes = [...batch.changes].sort(
|
|
1014
|
+
(a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0
|
|
1015
|
+
);
|
|
1016
|
+
const fingerprint = createHash5("sha256").update(JSON.stringify({ parentRevision: batch.parentRevision, changes })).digest("hex");
|
|
1017
|
+
return transaction(this.hub.db, () => {
|
|
1018
|
+
const identity = this.hub.authorize(authorization, device);
|
|
1019
|
+
const { workspaceId: workspace, memberId: owner } = identity;
|
|
1020
|
+
const prior = this.hub.db.prepare(
|
|
1021
|
+
"SELECT fingerprint, revision FROM file_operations WHERE workspace_id=? AND owner=? AND operation_id=?"
|
|
1022
|
+
).get(workspace, owner, batch.operationId);
|
|
1023
|
+
if (prior) {
|
|
1024
|
+
if (prior.fingerprint !== fingerprint)
|
|
1025
|
+
throw new HubError(
|
|
1026
|
+
409,
|
|
1027
|
+
"OPERATION_MISMATCH",
|
|
1028
|
+
"Operation ID was already used with different content."
|
|
1029
|
+
);
|
|
1030
|
+
return { operationId: batch.operationId, revision: Number(prior.revision) };
|
|
1031
|
+
}
|
|
1032
|
+
const head = this.head(identity, owner);
|
|
1033
|
+
if (head !== batch.parentRevision)
|
|
1034
|
+
throw new HubError(
|
|
1035
|
+
409,
|
|
1036
|
+
"STALE_REVISION",
|
|
1037
|
+
"Publication must be based on the latest confirmed directory revision."
|
|
1038
|
+
);
|
|
1039
|
+
const current = new Map(
|
|
1040
|
+
this.hub.db.prepare("SELECT path, hash, size FROM current_files WHERE workspace_id=? AND owner=?").all(workspace, owner).map((row) => [String(row.path), { hash: String(row.hash), size: Number(row.size) }])
|
|
1041
|
+
);
|
|
1042
|
+
const effective = [];
|
|
1043
|
+
for (const change of changes) {
|
|
1044
|
+
const previous = current.get(change.path);
|
|
1045
|
+
if (change.kind === "delete") {
|
|
1046
|
+
if (previous) {
|
|
1047
|
+
current.delete(change.path);
|
|
1048
|
+
effective.push(change);
|
|
1049
|
+
}
|
|
1050
|
+
} else {
|
|
1051
|
+
const blob = this.hub.db.prepare("SELECT size FROM workspace_blobs WHERE workspace_id=? AND hash=?").get(workspace, change.sha256);
|
|
1052
|
+
if (!blob || Number(blob.size) !== change.size)
|
|
1053
|
+
throw new HubError(
|
|
1054
|
+
409,
|
|
1055
|
+
"BLOB_REQUIRED",
|
|
1056
|
+
"Upload all referenced content before publishing."
|
|
1057
|
+
);
|
|
1058
|
+
current.set(change.path, { hash: change.sha256, size: change.size });
|
|
1059
|
+
if (!previous || previous.hash !== change.sha256) effective.push(change);
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
const keys = /* @__PURE__ */ new Set();
|
|
1063
|
+
for (const path of current.keys()) {
|
|
1064
|
+
const key = path.toLowerCase();
|
|
1065
|
+
if (keys.has(key))
|
|
1066
|
+
throw new HubError(
|
|
1067
|
+
409,
|
|
1068
|
+
"PATH_COLLISION",
|
|
1069
|
+
"Paths must be distinct on case-insensitive filesystems."
|
|
1070
|
+
);
|
|
1071
|
+
keys.add(key);
|
|
1072
|
+
}
|
|
1073
|
+
for (const key of keys) {
|
|
1074
|
+
const parts = key.split("/");
|
|
1075
|
+
for (let length = 1; length < parts.length; length++) {
|
|
1076
|
+
if (keys.has(parts.slice(0, length).join("/")))
|
|
1077
|
+
throw new HubError(409, "PATH_COLLISION", "A file cannot also be a parent directory.");
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
const revision = effective.length ? head + 1 : head;
|
|
1081
|
+
if (effective.length) {
|
|
1082
|
+
for (const change of effective)
|
|
1083
|
+
if (change.kind === "delete")
|
|
1084
|
+
this.hub.db.prepare("DELETE FROM current_files WHERE workspace_id=? AND owner=? AND path=?").run(workspace, owner, change.path);
|
|
1085
|
+
for (const change of effective) {
|
|
1086
|
+
if (change.kind === "upsert")
|
|
1087
|
+
this.hub.db.prepare(
|
|
1088
|
+
`INSERT INTO current_files VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1089
|
+
ON CONFLICT(workspace_id, owner, path) DO UPDATE SET hash=excluded.hash, size=excluded.size, revision=excluded.revision`
|
|
1090
|
+
).run(
|
|
1091
|
+
workspace,
|
|
1092
|
+
owner,
|
|
1093
|
+
change.path,
|
|
1094
|
+
change.path.toLowerCase(),
|
|
1095
|
+
change.sha256,
|
|
1096
|
+
change.size,
|
|
1097
|
+
revision
|
|
1098
|
+
);
|
|
1099
|
+
this.hub.db.prepare("INSERT INTO file_events VALUES (?, ?, ?, ?, ?, ?)").run(
|
|
1100
|
+
workspace,
|
|
1101
|
+
owner,
|
|
1102
|
+
change.path,
|
|
1103
|
+
revision,
|
|
1104
|
+
change.kind === "upsert" ? change.sha256 : null,
|
|
1105
|
+
change.kind === "upsert" ? change.size : null
|
|
1106
|
+
);
|
|
1107
|
+
}
|
|
1108
|
+
this.hub.db.prepare("INSERT INTO file_revisions VALUES (?, ?, ?, ?, ?, ?)").run(
|
|
1109
|
+
workspace,
|
|
1110
|
+
owner,
|
|
1111
|
+
revision,
|
|
1112
|
+
batch.operationId,
|
|
1113
|
+
(/* @__PURE__ */ new Date()).toISOString(),
|
|
1114
|
+
JSON.stringify(effective)
|
|
1115
|
+
);
|
|
1116
|
+
this.hub.db.prepare("UPDATE members SET file_revision=? WHERE workspace_id=? AND member_id=?").run(revision, workspace, owner);
|
|
1117
|
+
}
|
|
1118
|
+
this.hub.db.prepare("INSERT INTO file_operations VALUES (?, ?, ?, ?, ?)").run(workspace, owner, batch.operationId, fingerprint, revision);
|
|
1119
|
+
return { operationId: batch.operationId, revision };
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
manifest(identity, owner, revision, after = "", limit = 200) {
|
|
1123
|
+
const head = this.head(identity, owner);
|
|
1124
|
+
const target = revision ?? head;
|
|
1125
|
+
if (target > head)
|
|
1126
|
+
throw new HubError(400, "INVALID_REVISION", "Requested revision has not been published.");
|
|
1127
|
+
const rows = this.hub.db.prepare(
|
|
1128
|
+
`SELECT e.path, e.hash, e.size, e.revision FROM file_events e JOIN
|
|
1129
|
+
(SELECT path, MAX(revision) revision FROM file_events WHERE workspace_id=? AND owner=? AND revision<=? GROUP BY path) latest
|
|
1130
|
+
ON e.path=latest.path AND e.revision=latest.revision
|
|
1131
|
+
WHERE e.workspace_id=? AND e.owner=? AND e.hash IS NOT NULL AND e.path>? ORDER BY e.path LIMIT ?`
|
|
1132
|
+
).all(identity.workspaceId, owner, target, identity.workspaceId, owner, after, limit + 1);
|
|
1133
|
+
const files = rows.slice(0, limit).map(
|
|
1134
|
+
(row) => FileEntrySchema.parse({
|
|
1135
|
+
path: row.path,
|
|
1136
|
+
sha256: row.hash,
|
|
1137
|
+
size: row.size,
|
|
1138
|
+
revision: row.revision
|
|
1139
|
+
})
|
|
1140
|
+
);
|
|
1141
|
+
return { revision: target, files, nextCursor: rows.length > limit ? files.at(-1).path : null };
|
|
1142
|
+
}
|
|
1143
|
+
changes(identity, owner, after, limit = 100) {
|
|
1144
|
+
const headRevision = this.head(identity, owner);
|
|
1145
|
+
if (after > headRevision)
|
|
1146
|
+
throw new HubError(400, "INVALID_REVISION", "Local revision is ahead of the server.");
|
|
1147
|
+
const revisions = this.hub.db.prepare(
|
|
1148
|
+
"SELECT revision, operation_id, created_at, changes_json FROM file_revisions WHERE workspace_id=? AND owner=? AND revision>? AND revision<=? ORDER BY revision LIMIT ?"
|
|
1149
|
+
).all(identity.workspaceId, owner, after, headRevision, limit).map((row) => ({
|
|
1150
|
+
revision: Number(row.revision),
|
|
1151
|
+
operationId: String(row.operation_id),
|
|
1152
|
+
createdAt: String(row.created_at),
|
|
1153
|
+
changes: JSON.parse(String(row.changes_json))
|
|
1154
|
+
}));
|
|
1155
|
+
return { headRevision, revisions };
|
|
1156
|
+
}
|
|
1157
|
+
};
|
|
1158
|
+
|
|
1159
|
+
// apps/server/sessions.ts
|
|
1160
|
+
var SessionRepository = class {
|
|
1161
|
+
constructor(hub) {
|
|
1162
|
+
this.hub = hub;
|
|
1163
|
+
}
|
|
1164
|
+
hub;
|
|
1165
|
+
async commit(authorization, device, input) {
|
|
1166
|
+
const request = SessionCommitSchema.parse(input);
|
|
1167
|
+
const identity = this.hub.authorize(authorization, device);
|
|
1168
|
+
const bytes = await new FileRepository(this.hub).download(identity, request.blob.sha256);
|
|
1169
|
+
if (bytes.byteLength !== request.blob.size)
|
|
1170
|
+
throw new HubError(400, "SIZE_MISMATCH", "Snapshot size mismatch.");
|
|
1171
|
+
let snapshot;
|
|
1172
|
+
try {
|
|
1173
|
+
snapshot = SessionSnapshotSchema.parse(
|
|
1174
|
+
JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes))
|
|
1175
|
+
);
|
|
1176
|
+
} catch {
|
|
1177
|
+
throw new HubError(400, "INVALID_SNAPSHOT", "Invalid normalized session snapshot.");
|
|
1178
|
+
}
|
|
1179
|
+
if (sessionKey(snapshot.agent, snapshot.nativeId) !== request.sessionKey)
|
|
1180
|
+
throw new HubError(400, "SESSION_MISMATCH", "Snapshot identity mismatch.");
|
|
1181
|
+
const fingerprint = createHash6("sha256").update(JSON.stringify([request.sessionKey, request.parentRevision, request.blob])).digest("hex");
|
|
1182
|
+
return transaction(this.hub.db, () => {
|
|
1183
|
+
const { workspaceId: workspace, memberId: owner } = this.hub.authorize(authorization, device);
|
|
1184
|
+
const db = this.hub.db;
|
|
1185
|
+
const prior = db.prepare(
|
|
1186
|
+
"SELECT fingerprint, revision FROM session_operations WHERE workspace_id=? AND owner=? AND operation_id=?"
|
|
1187
|
+
).get(workspace, owner, request.operationId);
|
|
1188
|
+
if (prior) {
|
|
1189
|
+
if (prior.fingerprint !== fingerprint)
|
|
1190
|
+
throw new HubError(
|
|
1191
|
+
409,
|
|
1192
|
+
"OPERATION_MISMATCH",
|
|
1193
|
+
"Operation ID already used with different content."
|
|
1194
|
+
);
|
|
1195
|
+
return { operationId: request.operationId, revision: Number(prior.revision) };
|
|
1196
|
+
}
|
|
1197
|
+
const current = db.prepare(
|
|
1198
|
+
"SELECT revision, hash FROM sessions WHERE workspace_id=? AND owner=? AND session_key=?"
|
|
1199
|
+
).get(workspace, owner, request.sessionKey);
|
|
1200
|
+
const head = Number(current?.revision ?? 0);
|
|
1201
|
+
if (head !== request.parentRevision)
|
|
1202
|
+
throw new HubError(
|
|
1203
|
+
409,
|
|
1204
|
+
"STALE_REVISION",
|
|
1205
|
+
"Session changed on the server; inspect the current revision before retrying."
|
|
1206
|
+
);
|
|
1207
|
+
const revision = current?.hash === request.blob.sha256 ? head : head + 1;
|
|
1208
|
+
if (revision !== head) {
|
|
1209
|
+
const info = {
|
|
1210
|
+
owner,
|
|
1211
|
+
sessionKey: request.sessionKey,
|
|
1212
|
+
agent: snapshot.agent,
|
|
1213
|
+
nativeId: snapshot.nativeId,
|
|
1214
|
+
cwd: snapshot.cwd,
|
|
1215
|
+
title: (snapshot.messages.find((m) => m.role === "user")?.text ?? snapshot.nativeId).slice(0, 200),
|
|
1216
|
+
revision,
|
|
1217
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1218
|
+
messageCount: snapshot.messages.length,
|
|
1219
|
+
blob: request.blob
|
|
1220
|
+
};
|
|
1221
|
+
db.prepare(
|
|
1222
|
+
`INSERT INTO sessions(workspace_id, owner, session_key, agent, native_id, cwd, title, revision, updated_at, message_count, hash, size)
|
|
1223
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1224
|
+
ON CONFLICT(workspace_id, owner, session_key) DO UPDATE SET cwd=excluded.cwd, title=excluded.title, revision=excluded.revision, updated_at=excluded.updated_at, message_count=excluded.message_count, hash=excluded.hash, size=excluded.size`
|
|
1225
|
+
).run(
|
|
1226
|
+
workspace,
|
|
1227
|
+
owner,
|
|
1228
|
+
request.sessionKey,
|
|
1229
|
+
info.agent,
|
|
1230
|
+
info.nativeId,
|
|
1231
|
+
info.cwd,
|
|
1232
|
+
info.title,
|
|
1233
|
+
revision,
|
|
1234
|
+
info.updatedAt,
|
|
1235
|
+
info.messageCount,
|
|
1236
|
+
info.blob.sha256,
|
|
1237
|
+
info.blob.size
|
|
1238
|
+
);
|
|
1239
|
+
db.prepare("INSERT INTO session_revisions VALUES (?, ?, ?, ?, ?)").run(
|
|
1240
|
+
workspace,
|
|
1241
|
+
owner,
|
|
1242
|
+
request.sessionKey,
|
|
1243
|
+
revision,
|
|
1244
|
+
JSON.stringify(info)
|
|
1245
|
+
);
|
|
1246
|
+
db.prepare(
|
|
1247
|
+
"DELETE FROM session_fts WHERE rowid IN (SELECT id FROM session_messages WHERE workspace_id=? AND owner=? AND session_key=?)"
|
|
1248
|
+
).run(workspace, owner, request.sessionKey);
|
|
1249
|
+
db.prepare(
|
|
1250
|
+
"DELETE FROM session_messages WHERE workspace_id=? AND owner=? AND session_key=?"
|
|
1251
|
+
).run(workspace, owner, request.sessionKey);
|
|
1252
|
+
const insert = db.prepare(
|
|
1253
|
+
"INSERT INTO session_messages(workspace_id, owner, session_key, revision, message_id, role, body, search_text) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
|
|
1254
|
+
);
|
|
1255
|
+
const index = db.prepare("INSERT INTO session_fts(rowid, body) VALUES (?, ?)");
|
|
1256
|
+
for (const message of snapshot.messages) {
|
|
1257
|
+
const lower = message.text.toLowerCase();
|
|
1258
|
+
const result = insert.run(
|
|
1259
|
+
workspace,
|
|
1260
|
+
owner,
|
|
1261
|
+
request.sessionKey,
|
|
1262
|
+
revision,
|
|
1263
|
+
message.id,
|
|
1264
|
+
message.role,
|
|
1265
|
+
message.text,
|
|
1266
|
+
lower
|
|
1267
|
+
);
|
|
1268
|
+
index.run(result.lastInsertRowid, lower);
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
db.prepare("INSERT INTO session_operations VALUES (?, ?, ?, ?, ?)").run(
|
|
1272
|
+
workspace,
|
|
1273
|
+
owner,
|
|
1274
|
+
request.operationId,
|
|
1275
|
+
fingerprint,
|
|
1276
|
+
revision
|
|
1277
|
+
);
|
|
1278
|
+
return { operationId: request.operationId, revision };
|
|
1279
|
+
});
|
|
1280
|
+
}
|
|
1281
|
+
read(identity, owner, key, revision) {
|
|
1282
|
+
const row = revision === void 0 ? this.hub.db.prepare(
|
|
1283
|
+
`SELECT r.info_json FROM session_revisions r JOIN sessions s USING(workspace_id, owner, session_key, revision)
|
|
1284
|
+
WHERE r.workspace_id=? AND r.owner=? AND r.session_key=?`
|
|
1285
|
+
).get(identity.workspaceId, owner, key) : this.hub.db.prepare(
|
|
1286
|
+
"SELECT info_json FROM session_revisions WHERE workspace_id=? AND owner=? AND session_key=? AND revision=?"
|
|
1287
|
+
).get(identity.workspaceId, owner, key, revision);
|
|
1288
|
+
if (!row)
|
|
1289
|
+
throw new HubError(
|
|
1290
|
+
404,
|
|
1291
|
+
"SESSION_NOT_FOUND",
|
|
1292
|
+
"Session revision is not available in this workspace."
|
|
1293
|
+
);
|
|
1294
|
+
return SessionInfoSchema.parse(JSON.parse(String(row.info_json)));
|
|
1295
|
+
}
|
|
1296
|
+
list(identity, after, limit) {
|
|
1297
|
+
const rows = this.hub.db.prepare(
|
|
1298
|
+
`SELECT s.id, r.info_json FROM sessions s JOIN session_revisions r USING(workspace_id, owner, session_key, revision)
|
|
1299
|
+
WHERE s.workspace_id=? AND s.id>? ORDER BY s.id LIMIT ?`
|
|
1300
|
+
).all(identity.workspaceId, after, limit + 1);
|
|
1301
|
+
return {
|
|
1302
|
+
sessions: rows.slice(0, limit).map((row) => SessionInfoSchema.parse(JSON.parse(String(row.info_json)))),
|
|
1303
|
+
nextCursor: rows.length > limit ? Number(rows[limit - 1].id) : null
|
|
1304
|
+
};
|
|
1305
|
+
}
|
|
1306
|
+
search(identity, query, after, limit) {
|
|
1307
|
+
const lower = query.toLowerCase();
|
|
1308
|
+
const indexed = [...lower].length >= 3;
|
|
1309
|
+
const rows = this.hub.db.prepare(
|
|
1310
|
+
`SELECT * FROM session_messages WHERE workspace_id=? AND id>? AND instr(search_text, ?)>0
|
|
1311
|
+
${indexed ? "AND id IN (SELECT rowid FROM session_fts WHERE session_fts MATCH ?)" : ""} ORDER BY id LIMIT ?`
|
|
1312
|
+
).all(
|
|
1313
|
+
identity.workspaceId,
|
|
1314
|
+
after,
|
|
1315
|
+
lower,
|
|
1316
|
+
...indexed ? [`"${lower.replaceAll('"', '""')}"`] : [],
|
|
1317
|
+
limit + 1
|
|
1318
|
+
);
|
|
1319
|
+
return {
|
|
1320
|
+
matches: rows.slice(0, limit).map((row) => {
|
|
1321
|
+
const start = Math.max(0, String(row.search_text).indexOf(lower) - 100);
|
|
1322
|
+
return {
|
|
1323
|
+
owner: String(row.owner),
|
|
1324
|
+
sessionKey: String(row.session_key),
|
|
1325
|
+
revision: Number(row.revision),
|
|
1326
|
+
messageId: String(row.message_id),
|
|
1327
|
+
role: String(row.role),
|
|
1328
|
+
snippet: String(row.body).slice(start, start + 500)
|
|
1329
|
+
};
|
|
1330
|
+
}),
|
|
1331
|
+
nextCursor: rows.length > limit ? Number(rows[limit - 1].id) : null
|
|
1332
|
+
};
|
|
1333
|
+
}
|
|
1334
|
+
};
|
|
1335
|
+
|
|
1336
|
+
// apps/server/session-routes.ts
|
|
1337
|
+
function registerSessionRoutes(server, store) {
|
|
1338
|
+
const sessions = new SessionRepository(store);
|
|
1339
|
+
const device = (request) => typeof request.headers["x-teammy-device"] === "string" ? request.headers["x-teammy-device"] : void 0;
|
|
1340
|
+
const authorize = (request) => store.authorize(request.headers.authorization, device(request));
|
|
1341
|
+
const guard = {
|
|
1342
|
+
onRequest: async (request) => {
|
|
1343
|
+
authorize(request);
|
|
1344
|
+
}
|
|
1345
|
+
};
|
|
1346
|
+
const pagination = z8.object({
|
|
1347
|
+
after: z8.coerce.number().pipe(RevisionSchema).default(0),
|
|
1348
|
+
limit: z8.coerce.number().int().min(1).max(100).default(50)
|
|
1349
|
+
});
|
|
1350
|
+
server.post(
|
|
1351
|
+
"/api/v1/sessions/commits",
|
|
1352
|
+
guard,
|
|
1353
|
+
(request) => sessions.commit(
|
|
1354
|
+
request.headers.authorization,
|
|
1355
|
+
device(request),
|
|
1356
|
+
SessionCommitSchema.parse(request.body)
|
|
1357
|
+
)
|
|
1358
|
+
);
|
|
1359
|
+
server.get("/api/v1/sessions", guard, async (request) => {
|
|
1360
|
+
const q = pagination.parse(request.query);
|
|
1361
|
+
return sessions.list(authorize(request), q.after, q.limit);
|
|
1362
|
+
});
|
|
1363
|
+
server.get("/api/v1/sessions/search", guard, async (request) => {
|
|
1364
|
+
const q = pagination.extend({
|
|
1365
|
+
q: z8.string().min(1).max(500).refine((s) => !s.includes("\0"))
|
|
1366
|
+
}).parse(request.query);
|
|
1367
|
+
return sessions.search(authorize(request), q.q, q.after, q.limit);
|
|
1368
|
+
});
|
|
1369
|
+
server.get("/api/v1/sessions/:owner/:key", guard, async (request) => {
|
|
1370
|
+
const p = z8.object({ owner: NameSchema, key: HashSchema }).parse(request.params);
|
|
1371
|
+
const q = z8.object({ revision: z8.coerce.number().pipe(RevisionSchema).optional() }).parse(request.query);
|
|
1372
|
+
return sessions.read(authorize(request), p.owner, p.key, q.revision);
|
|
1373
|
+
});
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
// apps/server/file-routes.ts
|
|
1377
|
+
import { z as z9 } from "zod";
|
|
1378
|
+
function registerFileRoutes(server, store) {
|
|
1379
|
+
const files = new FileRepository(store);
|
|
1380
|
+
const device = (request) => {
|
|
1381
|
+
const value = request.headers["x-teammy-device"];
|
|
1382
|
+
return typeof value === "string" ? value : void 0;
|
|
1383
|
+
};
|
|
1384
|
+
const authorize = (request) => store.authorize(request.headers.authorization, device(request));
|
|
1385
|
+
const guard = {
|
|
1386
|
+
onRequest: async (request) => {
|
|
1387
|
+
authorize(request);
|
|
1388
|
+
}
|
|
1389
|
+
};
|
|
1390
|
+
server.addContentTypeParser(
|
|
1391
|
+
"application/octet-stream",
|
|
1392
|
+
{ parseAs: "buffer", bodyLimit: MAX_BLOB_BYTES },
|
|
1393
|
+
(_request, body, done) => done(null, body)
|
|
1394
|
+
);
|
|
1395
|
+
server.post("/api/v1/blobs/check", guard, async (request) => ({
|
|
1396
|
+
missing: files.missing(authorize(request), BlobCheckSchema.parse(request.body).hashes)
|
|
1397
|
+
}));
|
|
1398
|
+
server.put("/api/v1/blobs/:hash", { ...guard, bodyLimit: MAX_BLOB_BYTES }, async (request) => {
|
|
1399
|
+
const { hash: hash2 } = z9.object({ hash: HashSchema }).parse(request.params);
|
|
1400
|
+
if (!Buffer.isBuffer(request.body))
|
|
1401
|
+
throw Object.assign(new Error("Expected binary content"), { statusCode: 400 });
|
|
1402
|
+
return files.upload(request.headers.authorization, device(request), hash2, request.body);
|
|
1403
|
+
});
|
|
1404
|
+
server.get("/api/v1/blobs/:hash", async (request, reply) => {
|
|
1405
|
+
const { hash: hash2 } = z9.object({ hash: HashSchema }).parse(request.params);
|
|
1406
|
+
return reply.type("application/octet-stream").send(Buffer.from(await files.download(authorizeRead(store, request), hash2)));
|
|
1407
|
+
});
|
|
1408
|
+
server.post(
|
|
1409
|
+
"/api/v1/files/commits",
|
|
1410
|
+
guard,
|
|
1411
|
+
async (request) => files.commit(
|
|
1412
|
+
request.headers.authorization,
|
|
1413
|
+
device(request),
|
|
1414
|
+
FileCommitSchema.parse(request.body)
|
|
1415
|
+
)
|
|
1416
|
+
);
|
|
1417
|
+
server.get("/api/v1/files/:owner/manifest", async (request) => {
|
|
1418
|
+
const { owner } = z9.object({ owner: NameSchema }).parse(request.params);
|
|
1419
|
+
const query = z9.object({
|
|
1420
|
+
revision: z9.coerce.number().int().min(0).max(Number.MAX_SAFE_INTEGER).optional(),
|
|
1421
|
+
after: z9.string().max(1024).default(""),
|
|
1422
|
+
limit: z9.coerce.number().int().min(1).max(500).default(200)
|
|
1423
|
+
}).parse(request.query);
|
|
1424
|
+
return files.manifest(
|
|
1425
|
+
authorizeRead(store, request),
|
|
1426
|
+
owner,
|
|
1427
|
+
query.revision,
|
|
1428
|
+
query.after,
|
|
1429
|
+
query.limit
|
|
1430
|
+
);
|
|
1431
|
+
});
|
|
1432
|
+
server.get("/api/v1/files/:owner/changes", async (request) => {
|
|
1433
|
+
const { owner } = z9.object({ owner: NameSchema }).parse(request.params);
|
|
1434
|
+
const query = z9.object({
|
|
1435
|
+
after: z9.coerce.number().int().min(0).max(Number.MAX_SAFE_INTEGER).default(0),
|
|
1436
|
+
limit: z9.coerce.number().int().min(1).max(100).default(100)
|
|
1437
|
+
}).parse(request.query);
|
|
1438
|
+
return files.changes(authorizeRead(store, request), owner, query.after, query.limit);
|
|
1439
|
+
});
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
// apps/server/server.ts
|
|
1443
|
+
function buildServer(options) {
|
|
1444
|
+
const server = Fastify({
|
|
1445
|
+
logger: options.logger ? { redact: ["req.headers.authorization", "req.headers.cookie"] } : false,
|
|
1446
|
+
requestIdHeader: false,
|
|
1447
|
+
genReqId: () => randomUUID2(),
|
|
1448
|
+
bodyLimit: 1024 * 1024,
|
|
1449
|
+
requestTimeout: 3e4
|
|
1450
|
+
});
|
|
1451
|
+
const store = options.dataDir ? new HubStore(options.dataDir) : void 0;
|
|
1452
|
+
if (store) server.addHook("onClose", async () => store.close());
|
|
1453
|
+
server.addHook("onRequest", async (request, reply) => {
|
|
1454
|
+
reply.header("x-request-id", request.id);
|
|
1455
|
+
if (request.url.startsWith("/api/")) reply.header("cache-control", "no-store");
|
|
1456
|
+
});
|
|
1457
|
+
server.setNotFoundHandler((request, reply) => {
|
|
1458
|
+
const body = {
|
|
1459
|
+
error: { code: "NOT_FOUND", message: "Endpoint not found.", requestId: request.id }
|
|
1460
|
+
};
|
|
1461
|
+
return reply.code(404).send(body);
|
|
1462
|
+
});
|
|
1463
|
+
server.setErrorHandler((error, request, reply) => {
|
|
1464
|
+
const candidate = error.statusCode;
|
|
1465
|
+
const status = error instanceof ZodError ? 400 : candidate && candidate >= 400 && candidate < 500 ? candidate : 500;
|
|
1466
|
+
if (status === 500) request.log.error({ err: error }, "Request failed");
|
|
1467
|
+
const body = {
|
|
1468
|
+
error: {
|
|
1469
|
+
code: error instanceof HubError ? error.code : status === 500 ? "INTERNAL_ERROR" : "INVALID_REQUEST",
|
|
1470
|
+
message: error instanceof HubError ? error.message : status === 500 ? "Internal server error." : "Invalid request.",
|
|
1471
|
+
requestId: request.id
|
|
1472
|
+
}
|
|
1473
|
+
};
|
|
1474
|
+
return reply.code(status).send(body);
|
|
1475
|
+
});
|
|
1476
|
+
server.get("/healthz", async () => ({ status: "ok" }));
|
|
1477
|
+
server.get("/api/v1/server", async () => ({
|
|
1478
|
+
name: "teammy",
|
|
1479
|
+
version: options.version,
|
|
1480
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
1481
|
+
capabilities: [
|
|
1482
|
+
"health",
|
|
1483
|
+
"server-info",
|
|
1484
|
+
...store ? [
|
|
1485
|
+
"identity",
|
|
1486
|
+
"blobs",
|
|
1487
|
+
"file-revisions",
|
|
1488
|
+
"session-revisions",
|
|
1489
|
+
"session-search",
|
|
1490
|
+
"project-config",
|
|
1491
|
+
"web-identity",
|
|
1492
|
+
"device-status"
|
|
1493
|
+
] : []
|
|
1494
|
+
]
|
|
1495
|
+
}));
|
|
1496
|
+
if (store) {
|
|
1497
|
+
registerProjectRoutes(server, store);
|
|
1498
|
+
registerControlRoutes(server, store);
|
|
1499
|
+
registerFileRoutes(server, store);
|
|
1500
|
+
registerSessionRoutes(server, store);
|
|
1501
|
+
server.post(
|
|
1502
|
+
"/api/v1/bind",
|
|
1503
|
+
async (request) => store.bind(request.headers.authorization, BindRequestSchema.parse(request.body).deviceId)
|
|
1504
|
+
);
|
|
1505
|
+
server.get("/api/v1/members", async (request) => {
|
|
1506
|
+
const device = request.headers["x-teammy-device"];
|
|
1507
|
+
const identity = store.authorize(
|
|
1508
|
+
request.headers.authorization,
|
|
1509
|
+
typeof device === "string" ? device : void 0
|
|
1510
|
+
);
|
|
1511
|
+
return { members: store.members(identity) };
|
|
1512
|
+
});
|
|
1513
|
+
}
|
|
1514
|
+
if (options.webDir) registerWeb(server, options.webDir);
|
|
1515
|
+
return server;
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
// apps/server/main.ts
|
|
1519
|
+
import { homedir } from "node:os";
|
|
1520
|
+
import { join as join5, resolve as resolve2 } from "node:path";
|
|
1521
|
+
var help = `teammy-server \u2014 the Teammy collaboration hub
|
|
1522
|
+
|
|
1523
|
+
Usage: teammy-server [--host HOST] [--port PORT]
|
|
1524
|
+
teammy-server member <add|reset|revoke> --workspace ID --member ID [--data-dir DIR]
|
|
1525
|
+
|
|
1526
|
+
Options:
|
|
1527
|
+
--host HOST Bind address (default: 127.0.0.1)
|
|
1528
|
+
--port PORT Listen port, 0 for an available port (default: 4317)
|
|
1529
|
+
-h, --help Show this help
|
|
1530
|
+
-v, --version Show the installed server version
|
|
1531
|
+
--data-dir DIR State directory (default: ~/.teammy/server)
|
|
1532
|
+
|
|
1533
|
+
member add/reset prints a member token once to stdout. reset revokes the old token
|
|
1534
|
+
and releases the device binding; published content is preserved. revoke disables access.
|
|
1535
|
+
|
|
1536
|
+
Endpoints:
|
|
1537
|
+
GET /healthz Process health
|
|
1538
|
+
GET /api/v1/server Server version and implemented capabilities
|
|
1539
|
+
|
|
1540
|
+
Open the server URL to initialize the Web dashboard and preset member IDs.
|
|
1541
|
+
File synchronization and session collection/search are available; AI summaries are pending.
|
|
1542
|
+
`;
|
|
1543
|
+
async function runServerCli(args, version) {
|
|
1544
|
+
try {
|
|
1545
|
+
if (args[0] === "member") {
|
|
1546
|
+
const action = args[1];
|
|
1547
|
+
if (!action || !["add", "reset", "revoke"].includes(action))
|
|
1548
|
+
throw new Error("Use member add, reset, or revoke.");
|
|
1549
|
+
const { values: values2 } = parseArgs({
|
|
1550
|
+
args: args.slice(2),
|
|
1551
|
+
options: {
|
|
1552
|
+
workspace: { type: "string" },
|
|
1553
|
+
member: { type: "string" },
|
|
1554
|
+
"data-dir": { type: "string", default: join5(homedir(), ".teammy", "server") }
|
|
1555
|
+
}
|
|
1556
|
+
});
|
|
1557
|
+
if (!values2.workspace || !values2.member)
|
|
1558
|
+
throw new Error("--workspace and --member are required.");
|
|
1559
|
+
const store = new HubStore(resolve2(values2["data-dir"]));
|
|
1560
|
+
try {
|
|
1561
|
+
if (action === "revoke") store.revokeMember(values2.workspace, values2.member);
|
|
1562
|
+
else
|
|
1563
|
+
process.stdout.write(
|
|
1564
|
+
`${store.issueMember(values2.workspace, values2.member, action === "reset")}
|
|
1565
|
+
`
|
|
1566
|
+
);
|
|
1567
|
+
} finally {
|
|
1568
|
+
store.close();
|
|
1569
|
+
}
|
|
1570
|
+
return 0;
|
|
1571
|
+
}
|
|
1572
|
+
const { values } = parseArgs({
|
|
1573
|
+
args,
|
|
1574
|
+
options: {
|
|
1575
|
+
help: { type: "boolean", short: "h" },
|
|
1576
|
+
version: { type: "boolean", short: "v" },
|
|
1577
|
+
host: { type: "string", default: "127.0.0.1" },
|
|
1578
|
+
port: { type: "string", default: "4317" },
|
|
1579
|
+
"data-dir": { type: "string", default: join5(homedir(), ".teammy", "server") }
|
|
1580
|
+
}
|
|
1581
|
+
});
|
|
1582
|
+
if (values.help) {
|
|
1583
|
+
process.stdout.write(help);
|
|
1584
|
+
return 0;
|
|
1585
|
+
}
|
|
1586
|
+
if (values.version) {
|
|
1587
|
+
process.stdout.write(`${version}
|
|
1588
|
+
`);
|
|
1589
|
+
return 0;
|
|
1590
|
+
}
|
|
1591
|
+
const portText = values.port;
|
|
1592
|
+
if (!/^\d+$/.test(portText) || Number(portText) > 65535) {
|
|
1593
|
+
throw new Error("Port must be an integer between 0 and 65535.");
|
|
1594
|
+
}
|
|
1595
|
+
if (!values.host?.trim()) throw new Error("Host must not be empty.");
|
|
1596
|
+
const server = buildServer({
|
|
1597
|
+
version,
|
|
1598
|
+
logger: true,
|
|
1599
|
+
dataDir: resolve2(values["data-dir"]),
|
|
1600
|
+
webDir: fileURLToPath(new URL("./web/", import.meta.url))
|
|
1601
|
+
});
|
|
1602
|
+
try {
|
|
1603
|
+
await server.listen({ host: values.host, port: Number(portText) });
|
|
1604
|
+
} catch (error) {
|
|
1605
|
+
await server.close();
|
|
1606
|
+
throw error;
|
|
1607
|
+
}
|
|
1608
|
+
const shutdown = () => {
|
|
1609
|
+
process.off("SIGINT", shutdown);
|
|
1610
|
+
process.off("SIGTERM", shutdown);
|
|
1611
|
+
void server.close().catch((error) => {
|
|
1612
|
+
server.log.error({ err: error }, "Server shutdown failed");
|
|
1613
|
+
process.exitCode = 1;
|
|
1614
|
+
});
|
|
1615
|
+
};
|
|
1616
|
+
process.once("SIGINT", shutdown);
|
|
1617
|
+
process.once("SIGTERM", shutdown);
|
|
1618
|
+
return 0;
|
|
1619
|
+
} catch (error) {
|
|
1620
|
+
process.stderr.write(`${error instanceof Error ? error.message : "Unable to start server."}
|
|
1621
|
+
`);
|
|
1622
|
+
return 1;
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
export {
|
|
1626
|
+
runServerCli
|
|
1627
|
+
};
|