@orgii/collab-hub 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,66 @@
1
+ # ORGII Collaboration Hub
2
+
3
+ Self-deployable Cloudflare relay for ORGII collaboration orgs. It stores org metadata, members, invites, lightweight session metadata, and group chat messages, then relays live JSON updates through Durable Object WebSockets.
4
+
5
+ ## Who needs this?
6
+
7
+ Only one admin per ORGII collaboration org needs to deploy a hub. Teammates who join an existing org do not need npm, Cloudflare, Wrangler, or repo access; they only need the invite link generated by ORGII.
8
+
9
+ ## Create a hub project
10
+
11
+ ```bash
12
+ npx @orgii/collab-hub init my-orgii-hub
13
+ cd my-orgii-hub
14
+ npm install
15
+ ```
16
+
17
+ ## Deploy to Cloudflare
18
+
19
+ Log in to Cloudflare:
20
+
21
+ ```bash
22
+ npx wrangler login
23
+ ```
24
+
25
+ Create the D1 database:
26
+
27
+ ```bash
28
+ npm run db:create
29
+ ```
30
+
31
+ Cloudflare prints a `database_id`. Paste that value into `wrangler.jsonc` under `d1_databases[0].database_id`.
32
+
33
+ Apply migrations:
34
+
35
+ ```bash
36
+ npm run db:migrate
37
+ ```
38
+
39
+ Deploy the Worker:
40
+
41
+ ```bash
42
+ npm run deploy
43
+ ```
44
+
45
+ Wrangler prints the Worker URL, for example:
46
+
47
+ ```text
48
+ https://my-orgii-hub.example.workers.dev
49
+ ```
50
+
51
+ Paste that URL into ORGII under `Add Org` → `Create Org` → `Cloudflare hub URL`.
52
+
53
+ ## Invite teammates
54
+
55
+ After creating the org in ORGII, copy the generated invite link and send it to teammates. The invite link includes the hub URL and invite code, so teammates do not need to configure Cloudflare.
56
+
57
+ ## Local development
58
+
59
+ ```bash
60
+ npm run dev
61
+ npm run typecheck
62
+ ```
63
+
64
+ ## Source repo workflow
65
+
66
+ Developers building ORGII from source can also work directly from the `cloudflare/collab-hub` folder in the ORGII repository. The npm package exists so bundled-app users can deploy the hub without cloning the app repo.
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
7
+ const TEMPLATE_DIR = path.join(PACKAGE_ROOT, "templates", "worker");
8
+
9
+ function printHelp() {
10
+ console.log(`ORGII Collaboration Hub
11
+
12
+ Usage:
13
+ npx @orgii/collab-hub init <directory>
14
+
15
+ Commands:
16
+ init <directory> Create a deployable Cloudflare Worker hub project.
17
+
18
+ After init:
19
+ cd <directory>
20
+ npm install
21
+ npx wrangler login
22
+ npm run db:create
23
+ npm run db:migrate
24
+ npm run deploy
25
+ `);
26
+ }
27
+
28
+ function normalizeProjectName(targetDir) {
29
+ return path.basename(path.resolve(targetDir)).replace(/[^a-zA-Z0-9_-]/g, "-");
30
+ }
31
+
32
+ function updateTemplatePackageJson(targetDir) {
33
+ const packageJsonPath = path.join(targetDir, "package.json");
34
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
35
+ packageJson.name = normalizeProjectName(targetDir);
36
+ fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
37
+ }
38
+
39
+ function formatCdTarget(targetArg, targetDir) {
40
+ return path.isAbsolute(targetArg) ? targetDir : targetArg;
41
+ }
42
+
43
+ function initProject(targetArg) {
44
+ if (!targetArg) {
45
+ throw new Error("Missing target directory. Usage: npx @orgii/collab-hub init <directory>");
46
+ }
47
+
48
+ const targetDir = path.resolve(process.cwd(), targetArg);
49
+ if (fs.existsSync(targetDir) && fs.readdirSync(targetDir).length > 0) {
50
+ throw new Error(`Target directory is not empty: ${targetDir}`);
51
+ }
52
+
53
+ fs.mkdirSync(targetDir, { recursive: true });
54
+ fs.cpSync(TEMPLATE_DIR, targetDir, { recursive: true });
55
+ updateTemplatePackageJson(targetDir);
56
+
57
+ console.log(`Created ORGII Collaboration Hub project at ${targetDir}`);
58
+ console.log(`
59
+ Next steps:
60
+ cd ${formatCdTarget(targetArg, targetDir)}
61
+ npm install
62
+ npx wrangler login
63
+ npm run db:create
64
+
65
+ Then paste the returned database_id into wrangler.jsonc and run:
66
+ npm run db:migrate
67
+ npm run deploy
68
+
69
+ After deploy, paste the Worker URL into ORGII > Add Org > Create Org.`);
70
+ }
71
+
72
+ try {
73
+ const [command, targetDir] = process.argv.slice(2);
74
+ if (!command || command === "--help" || command === "-h") {
75
+ printHelp();
76
+ process.exit(0);
77
+ }
78
+
79
+ if (command !== "init") {
80
+ throw new Error(`Unknown command: ${command}`);
81
+ }
82
+
83
+ initProject(targetDir);
84
+ } catch (error) {
85
+ console.error(error instanceof Error ? error.message : String(error));
86
+ process.exit(1);
87
+ }
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@orgii/collab-hub",
3
+ "version": "0.1.0",
4
+ "description": "Self-deployable Cloudflare Collaboration Hub for ORGII team presence, invites, session metadata, and group chat.",
5
+ "private": false,
6
+ "type": "module",
7
+ "bin": {
8
+ "orgii-collab-hub": "./bin/orgii-collab-hub.js"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "templates",
13
+ "README.md"
14
+ ],
15
+ "scripts": {
16
+ "dev": "wrangler dev",
17
+ "deploy": "wrangler deploy",
18
+ "typecheck": "tsc --noEmit",
19
+ "typecheck:template": "cd templates/worker && npm install && npm run typecheck",
20
+ "pack:dry": "npm pack --dry-run"
21
+ },
22
+ "keywords": [
23
+ "orgii",
24
+ "collaboration",
25
+ "cloudflare",
26
+ "workers",
27
+ "durable-objects",
28
+ "d1"
29
+ ],
30
+ "license": "UNLICENSED",
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "devDependencies": {
35
+ "@cloudflare/workers-types": "^4.20260613.1",
36
+ "typescript": "^5.3.3",
37
+ "wrangler": "^4.100.0"
38
+ }
39
+ }
@@ -0,0 +1,64 @@
1
+ CREATE TABLE IF NOT EXISTS orgs (
2
+ id TEXT PRIMARY KEY,
3
+ name TEXT NOT NULL,
4
+ admin_member_id TEXT NOT NULL,
5
+ created_at TEXT NOT NULL
6
+ );
7
+
8
+ CREATE TABLE IF NOT EXISTS members (
9
+ id TEXT PRIMARY KEY,
10
+ org_id TEXT NOT NULL,
11
+ display_name TEXT NOT NULL,
12
+ avatar_initials TEXT NOT NULL,
13
+ avatar_variant TEXT NOT NULL,
14
+ role TEXT NOT NULL CHECK (role IN ('admin', 'member')),
15
+ identity_kind TEXT NOT NULL CHECK (identity_kind IN ('human', 'agent')),
16
+ access_token_hash TEXT NOT NULL,
17
+ joined_at TEXT NOT NULL,
18
+ removed_at TEXT,
19
+ FOREIGN KEY (org_id) REFERENCES orgs(id)
20
+ );
21
+
22
+ CREATE INDEX IF NOT EXISTS idx_members_org_id ON members(org_id);
23
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_members_org_token ON members(org_id, access_token_hash);
24
+
25
+ CREATE TABLE IF NOT EXISTS invites (
26
+ id TEXT PRIMARY KEY,
27
+ org_id TEXT NOT NULL,
28
+ token_hash TEXT NOT NULL UNIQUE,
29
+ inviter_member_id TEXT NOT NULL,
30
+ expires_at TEXT,
31
+ usage_limit INTEGER NOT NULL DEFAULT 1,
32
+ usage_count INTEGER NOT NULL DEFAULT 0,
33
+ created_at TEXT NOT NULL,
34
+ revoked_at TEXT,
35
+ FOREIGN KEY (org_id) REFERENCES orgs(id),
36
+ FOREIGN KEY (inviter_member_id) REFERENCES members(id)
37
+ );
38
+
39
+ CREATE INDEX IF NOT EXISTS idx_invites_org_id ON invites(org_id);
40
+
41
+ CREATE TABLE IF NOT EXISTS events (
42
+ id TEXT PRIMARY KEY,
43
+ org_id TEXT NOT NULL,
44
+ member_id TEXT NOT NULL,
45
+ event_type TEXT NOT NULL,
46
+ payload_json TEXT NOT NULL,
47
+ created_at TEXT NOT NULL,
48
+ FOREIGN KEY (org_id) REFERENCES orgs(id),
49
+ FOREIGN KEY (member_id) REFERENCES members(id)
50
+ );
51
+
52
+ CREATE INDEX IF NOT EXISTS idx_events_org_created ON events(org_id, created_at);
53
+
54
+ CREATE TABLE IF NOT EXISTS chat_messages (
55
+ id TEXT PRIMARY KEY,
56
+ org_id TEXT NOT NULL,
57
+ author_member_id TEXT NOT NULL,
58
+ body TEXT NOT NULL,
59
+ created_at TEXT NOT NULL,
60
+ FOREIGN KEY (org_id) REFERENCES orgs(id),
61
+ FOREIGN KEY (author_member_id) REFERENCES members(id)
62
+ );
63
+
64
+ CREATE INDEX IF NOT EXISTS idx_chat_messages_org_created ON chat_messages(org_id, created_at);
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "orgii-collab-hub-worker",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "wrangler dev",
8
+ "deploy": "wrangler deploy",
9
+ "typecheck": "tsc --noEmit",
10
+ "db:create": "wrangler d1 create orgii-collab-hub",
11
+ "db:migrate": "wrangler d1 migrations apply orgii-collab-hub"
12
+ },
13
+ "devDependencies": {
14
+ "@cloudflare/workers-types": "^4.20260613.1",
15
+ "typescript": "^5.3.3",
16
+ "wrangler": "^4.100.0"
17
+ }
18
+ }
@@ -0,0 +1,655 @@
1
+ import { jsonResponse, matchCollabHubRoute } from "./route";
2
+
3
+ const COLLAB_ROLE = {
4
+ ADMIN: "admin",
5
+ MEMBER: "member",
6
+ } as const;
7
+
8
+ const COLLAB_IDENTITY_KIND = {
9
+ HUMAN: "human",
10
+ AGENT: "agent",
11
+ } as const;
12
+
13
+ const COLLAB_MESSAGE_TYPE = {
14
+ CHAT_MESSAGE: "chat.message",
15
+ } as const;
16
+
17
+ const COLLAB_PROTOCOL_VERSION = 1;
18
+ const MAX_CHAT_BODY_LENGTH = 4000;
19
+ const DEFAULT_CHAT_HISTORY_LIMIT = 100;
20
+ const MAX_CHAT_HISTORY_LIMIT = 200;
21
+
22
+ type CollabRole = (typeof COLLAB_ROLE)[keyof typeof COLLAB_ROLE];
23
+ type CollabIdentityKind =
24
+ (typeof COLLAB_IDENTITY_KIND)[keyof typeof COLLAB_IDENTITY_KIND];
25
+
26
+ interface Env {
27
+ DB: D1Database;
28
+ ORG_ROOMS: DurableObjectNamespace;
29
+ }
30
+
31
+ interface CreateOrgRequest {
32
+ name?: string;
33
+ displayName?: string;
34
+ identityKind?: CollabIdentityKind;
35
+ }
36
+
37
+ interface AcceptInviteRequest {
38
+ displayName?: string;
39
+ identityKind?: CollabIdentityKind;
40
+ }
41
+
42
+ interface CreateInviteRequest {
43
+ expiresAt?: string;
44
+ usageLimit?: number;
45
+ }
46
+
47
+ interface PostChatMessageRequest {
48
+ body?: string;
49
+ }
50
+
51
+ interface StoredMember {
52
+ id: string;
53
+ org_id: string;
54
+ display_name: string;
55
+ avatar_initials: string;
56
+ avatar_variant: string;
57
+ role: CollabRole;
58
+ identity_kind: CollabIdentityKind;
59
+ joined_at: string;
60
+ removed_at: string | null;
61
+ }
62
+
63
+ interface StoredOrg {
64
+ id: string;
65
+ name: string;
66
+ admin_member_id: string;
67
+ created_at: string;
68
+ }
69
+
70
+ interface StoredInvite {
71
+ id: string;
72
+ org_id: string;
73
+ token_hash: string;
74
+ inviter_member_id: string;
75
+ expires_at: string | null;
76
+ usage_limit: number;
77
+ usage_count: number;
78
+ revoked_at: string | null;
79
+ }
80
+
81
+ interface StoredChatMessage {
82
+ id: string;
83
+ org_id: string;
84
+ author_member_id: string;
85
+ author_display_name: string;
86
+ author_identity_kind: CollabIdentityKind;
87
+ body: string;
88
+ created_at: string;
89
+ }
90
+
91
+ interface AuthContext {
92
+ orgId: string;
93
+ memberId: string;
94
+ role: CollabRole;
95
+ }
96
+
97
+ function createId(prefix: string): string {
98
+ return `${prefix}_${crypto.randomUUID().replaceAll("-", "")}`;
99
+ }
100
+
101
+ function createSecret(): string {
102
+ const bytes = new Uint8Array(24);
103
+ crypto.getRandomValues(bytes);
104
+ return btoa(String.fromCharCode(...bytes))
105
+ .replaceAll("+", "-")
106
+ .replaceAll("/", "_")
107
+ .replaceAll("=", "");
108
+ }
109
+
110
+ async function sha256(value: string): Promise<string> {
111
+ const digest = await crypto.subtle.digest(
112
+ "SHA-256",
113
+ new TextEncoder().encode(value)
114
+ );
115
+ return [...new Uint8Array(digest)]
116
+ .map((byte) => byte.toString(16).padStart(2, "0"))
117
+ .join("");
118
+ }
119
+
120
+ function createAvatar(displayName: string): {
121
+ initials: string;
122
+ variant: string;
123
+ } {
124
+ const words = displayName.trim().split(/\s+/).filter(Boolean);
125
+ const initials = `${words[0]?.[0] ?? "U"}${words[1]?.[0] ?? ""}`
126
+ .toLocaleUpperCase()
127
+ .slice(0, 2);
128
+ const seed = [...displayName].reduce(
129
+ (sum, character) => sum + character.charCodeAt(0),
130
+ 0
131
+ );
132
+ return { initials, variant: seed % 2 === 0 ? "v" : "h" };
133
+ }
134
+
135
+ function isIdentityKind(value: unknown): value is CollabIdentityKind {
136
+ return (
137
+ value === COLLAB_IDENTITY_KIND.HUMAN || value === COLLAB_IDENTITY_KIND.AGENT
138
+ );
139
+ }
140
+
141
+ async function readJson<T>(request: Request): Promise<T> {
142
+ return (await request.json()) as T;
143
+ }
144
+
145
+ function publicMember(member: StoredMember) {
146
+ return {
147
+ id: member.id,
148
+ orgId: member.org_id,
149
+ displayName: member.display_name,
150
+ avatar: {
151
+ initials: member.avatar_initials,
152
+ variant: member.avatar_variant,
153
+ },
154
+ role: member.role,
155
+ identityKind: member.identity_kind,
156
+ joinedAt: member.joined_at,
157
+ removedAt: member.removed_at ?? undefined,
158
+ };
159
+ }
160
+
161
+ function publicOrg(org: StoredOrg) {
162
+ return {
163
+ id: org.id,
164
+ name: org.name,
165
+ adminMemberId: org.admin_member_id,
166
+ createdAt: org.created_at,
167
+ };
168
+ }
169
+
170
+ function publicChatMessage(message: StoredChatMessage) {
171
+ return {
172
+ id: message.id,
173
+ orgId: message.org_id,
174
+ authorMemberId: message.author_member_id,
175
+ authorDisplayName: message.author_display_name,
176
+ authorIdentityKind: message.author_identity_kind,
177
+ body: message.body,
178
+ createdAt: message.created_at,
179
+ };
180
+ }
181
+
182
+ function readBearerToken(request: Request): string | null {
183
+ const authHeader = request.headers.get("authorization");
184
+ if (authHeader?.startsWith("Bearer ")) {
185
+ return authHeader.slice("Bearer ".length);
186
+ }
187
+ const url = new URL(request.url);
188
+ return url.searchParams.get("access_token");
189
+ }
190
+
191
+ async function getAuthContext(
192
+ request: Request,
193
+ env: Env,
194
+ orgId: string
195
+ ): Promise<AuthContext | null> {
196
+ const accessToken = readBearerToken(request);
197
+ if (!accessToken) return null;
198
+ const tokenHash = await sha256(accessToken);
199
+ const member = await env.DB.prepare(
200
+ "SELECT id, role FROM members WHERE org_id = ? AND access_token_hash = ? AND removed_at IS NULL"
201
+ )
202
+ .bind(orgId, tokenHash)
203
+ .first<{ id: string; role: CollabRole }>();
204
+ if (!member) return null;
205
+ return { orgId, memberId: member.id, role: member.role };
206
+ }
207
+
208
+ async function requireAdmin(
209
+ request: Request,
210
+ env: Env,
211
+ orgId: string
212
+ ): Promise<AuthContext | Response> {
213
+ const context = await getAuthContext(request, env, orgId);
214
+ if (!context) return jsonResponse({ error: "Unauthorized" }, { status: 401 });
215
+ if (context.role !== COLLAB_ROLE.ADMIN) {
216
+ return jsonResponse({ error: "Admin role required" }, { status: 403 });
217
+ }
218
+ return context;
219
+ }
220
+
221
+ async function handleCreateOrg(request: Request, env: Env): Promise<Response> {
222
+ const body = await readJson<CreateOrgRequest>(request);
223
+ const name = body.name?.trim();
224
+ const displayName = body.displayName?.trim();
225
+ const identityKind = isIdentityKind(body.identityKind)
226
+ ? body.identityKind
227
+ : COLLAB_IDENTITY_KIND.HUMAN;
228
+
229
+ if (!name)
230
+ return jsonResponse({ error: "Org name is required" }, { status: 400 });
231
+ if (!displayName) {
232
+ return jsonResponse({ error: "Display name is required" }, { status: 400 });
233
+ }
234
+
235
+ const now = new Date().toISOString();
236
+ const orgId = createId("org");
237
+ const memberId = createId("mem");
238
+ const accessToken = createSecret();
239
+ const tokenHash = await sha256(accessToken);
240
+ const avatar = createAvatar(displayName);
241
+
242
+ await env.DB.batch([
243
+ env.DB.prepare(
244
+ "INSERT INTO orgs (id, name, admin_member_id, created_at) VALUES (?, ?, ?, ?)"
245
+ ).bind(orgId, name, memberId, now),
246
+ env.DB.prepare(
247
+ "INSERT INTO members (id, org_id, display_name, avatar_initials, avatar_variant, role, identity_kind, access_token_hash, joined_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
248
+ ).bind(
249
+ memberId,
250
+ orgId,
251
+ displayName,
252
+ avatar.initials,
253
+ avatar.variant,
254
+ COLLAB_ROLE.ADMIN,
255
+ identityKind,
256
+ tokenHash,
257
+ now
258
+ ),
259
+ ]);
260
+
261
+ return jsonResponse({
262
+ org: {
263
+ id: orgId,
264
+ name,
265
+ adminMemberId: memberId,
266
+ createdAt: now,
267
+ },
268
+ member: {
269
+ id: memberId,
270
+ orgId,
271
+ displayName,
272
+ avatar,
273
+ role: COLLAB_ROLE.ADMIN,
274
+ identityKind,
275
+ accessToken,
276
+ joinedAt: now,
277
+ },
278
+ });
279
+ }
280
+
281
+ async function handleCreateInvite(
282
+ request: Request,
283
+ env: Env,
284
+ orgId: string
285
+ ): Promise<Response> {
286
+ const auth = await requireAdmin(request, env, orgId);
287
+ if (auth instanceof Response) return auth;
288
+ const body: CreateInviteRequest = await readJson<CreateInviteRequest>(
289
+ request
290
+ ).catch(() => ({}));
291
+ const inviteCode = createSecret();
292
+ const tokenHash = await sha256(inviteCode);
293
+ const inviteId = createId("inv");
294
+ const now = new Date().toISOString();
295
+ const usageLimit = Math.max(1, Math.min(body.usageLimit ?? 1, 100));
296
+
297
+ await env.DB.prepare(
298
+ "INSERT INTO invites (id, org_id, token_hash, inviter_member_id, expires_at, usage_limit, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
299
+ )
300
+ .bind(
301
+ inviteId,
302
+ orgId,
303
+ tokenHash,
304
+ auth.memberId,
305
+ body.expiresAt ?? null,
306
+ usageLimit,
307
+ now
308
+ )
309
+ .run();
310
+
311
+ return jsonResponse({
312
+ invite: {
313
+ id: inviteId,
314
+ orgId,
315
+ inviteCode,
316
+ expiresAt: body.expiresAt,
317
+ createdAt: now,
318
+ },
319
+ });
320
+ }
321
+
322
+ async function handleAcceptInvite(
323
+ request: Request,
324
+ env: Env,
325
+ inviteCode: string
326
+ ): Promise<Response> {
327
+ const body = await readJson<AcceptInviteRequest>(request);
328
+ const displayName = body.displayName?.trim();
329
+ const identityKind = isIdentityKind(body.identityKind)
330
+ ? body.identityKind
331
+ : COLLAB_IDENTITY_KIND.HUMAN;
332
+ if (!displayName) {
333
+ return jsonResponse({ error: "Display name is required" }, { status: 400 });
334
+ }
335
+
336
+ const tokenHash = await sha256(inviteCode);
337
+ const invite = await env.DB.prepare(
338
+ "SELECT id, org_id, token_hash, inviter_member_id, expires_at, usage_limit, usage_count, revoked_at FROM invites WHERE token_hash = ?"
339
+ )
340
+ .bind(tokenHash)
341
+ .first<StoredInvite>();
342
+
343
+ if (!invite || invite.revoked_at) {
344
+ return jsonResponse({ error: "Invite not found" }, { status: 404 });
345
+ }
346
+ if (invite.expires_at && Date.parse(invite.expires_at) < Date.now()) {
347
+ return jsonResponse({ error: "Invite expired" }, { status: 410 });
348
+ }
349
+ if (invite.usage_count >= invite.usage_limit) {
350
+ return jsonResponse({ error: "Invite already used" }, { status: 409 });
351
+ }
352
+
353
+ const org = await env.DB.prepare(
354
+ "SELECT id, name, admin_member_id, created_at FROM orgs WHERE id = ?"
355
+ )
356
+ .bind(invite.org_id)
357
+ .first<StoredOrg>();
358
+ if (!org) return jsonResponse({ error: "Org not found" }, { status: 404 });
359
+
360
+ const now = new Date().toISOString();
361
+ const memberId = createId("mem");
362
+ const accessToken = createSecret();
363
+ const tokenHashForMember = await sha256(accessToken);
364
+ const avatar = createAvatar(displayName);
365
+
366
+ await env.DB.batch([
367
+ env.DB.prepare(
368
+ "INSERT INTO members (id, org_id, display_name, avatar_initials, avatar_variant, role, identity_kind, access_token_hash, joined_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
369
+ ).bind(
370
+ memberId,
371
+ org.id,
372
+ displayName,
373
+ avatar.initials,
374
+ avatar.variant,
375
+ COLLAB_ROLE.MEMBER,
376
+ identityKind,
377
+ tokenHashForMember,
378
+ now
379
+ ),
380
+ env.DB.prepare(
381
+ "UPDATE invites SET usage_count = usage_count + 1 WHERE id = ?"
382
+ ).bind(invite.id),
383
+ ]);
384
+
385
+ return jsonResponse({
386
+ org: publicOrg(org),
387
+ member: {
388
+ id: memberId,
389
+ orgId: org.id,
390
+ displayName,
391
+ avatar,
392
+ role: COLLAB_ROLE.MEMBER,
393
+ identityKind,
394
+ accessToken,
395
+ joinedAt: now,
396
+ },
397
+ });
398
+ }
399
+
400
+ async function handleRemoveMember(
401
+ request: Request,
402
+ env: Env,
403
+ orgId: string,
404
+ memberId: string
405
+ ): Promise<Response> {
406
+ const auth = await requireAdmin(request, env, orgId);
407
+ if (auth instanceof Response) return auth;
408
+ if (auth.memberId === memberId) {
409
+ return jsonResponse({ error: "Admin cannot remove self" }, { status: 400 });
410
+ }
411
+ await env.DB.prepare(
412
+ "UPDATE members SET removed_at = ? WHERE org_id = ? AND id = ? AND removed_at IS NULL"
413
+ )
414
+ .bind(new Date().toISOString(), orgId, memberId)
415
+ .run();
416
+ return jsonResponse({ ok: true });
417
+ }
418
+
419
+ async function handleBootstrap(
420
+ request: Request,
421
+ env: Env,
422
+ orgId: string
423
+ ): Promise<Response> {
424
+ const auth = await getAuthContext(request, env, orgId);
425
+ if (!auth) return jsonResponse({ error: "Unauthorized" }, { status: 401 });
426
+ const org = await env.DB.prepare(
427
+ "SELECT id, name, admin_member_id, created_at FROM orgs WHERE id = ?"
428
+ )
429
+ .bind(orgId)
430
+ .first<StoredOrg>();
431
+ if (!org) return jsonResponse({ error: "Org not found" }, { status: 404 });
432
+ const members = await env.DB.prepare(
433
+ "SELECT id, org_id, display_name, avatar_initials, avatar_variant, role, identity_kind, joined_at, removed_at FROM members WHERE org_id = ?"
434
+ )
435
+ .bind(orgId)
436
+ .all<StoredMember>();
437
+ return jsonResponse({
438
+ org: publicOrg(org),
439
+ members: members.results.map(publicMember),
440
+ });
441
+ }
442
+
443
+ async function handleListChatMessages(
444
+ request: Request,
445
+ env: Env,
446
+ orgId: string
447
+ ): Promise<Response> {
448
+ const auth = await getAuthContext(request, env, orgId);
449
+ if (!auth) return jsonResponse({ error: "Unauthorized" }, { status: 401 });
450
+
451
+ const url = new URL(request.url);
452
+ const limitParam = Number(url.searchParams.get("limit"));
453
+ const limit = Number.isFinite(limitParam)
454
+ ? Math.max(1, Math.min(limitParam, MAX_CHAT_HISTORY_LIMIT))
455
+ : DEFAULT_CHAT_HISTORY_LIMIT;
456
+
457
+ const messages = await env.DB.prepare(
458
+ `SELECT chat_messages.id,
459
+ chat_messages.org_id,
460
+ chat_messages.author_member_id,
461
+ members.display_name AS author_display_name,
462
+ members.identity_kind AS author_identity_kind,
463
+ chat_messages.body,
464
+ chat_messages.created_at
465
+ FROM chat_messages
466
+ JOIN members ON members.id = chat_messages.author_member_id
467
+ WHERE chat_messages.org_id = ?
468
+ ORDER BY chat_messages.created_at DESC
469
+ LIMIT ?`
470
+ )
471
+ .bind(orgId, limit)
472
+ .all<StoredChatMessage>();
473
+
474
+ return jsonResponse({
475
+ messages: messages.results.map(publicChatMessage).reverse(),
476
+ });
477
+ }
478
+
479
+ async function insertChatMessage({
480
+ env,
481
+ orgId,
482
+ authorMemberId,
483
+ body,
484
+ }: {
485
+ env: Env;
486
+ orgId: string;
487
+ authorMemberId: string;
488
+ body: string;
489
+ }) {
490
+ const member = await env.DB.prepare(
491
+ "SELECT id, org_id, display_name, avatar_initials, avatar_variant, role, identity_kind, joined_at, removed_at FROM members WHERE org_id = ? AND id = ? AND removed_at IS NULL"
492
+ )
493
+ .bind(orgId, authorMemberId)
494
+ .first<StoredMember>();
495
+ if (!member) return null;
496
+
497
+ const id = createId("msg");
498
+ const createdAt = new Date().toISOString();
499
+ await env.DB.prepare(
500
+ "INSERT INTO chat_messages (id, org_id, author_member_id, body, created_at) VALUES (?, ?, ?, ?, ?)"
501
+ )
502
+ .bind(id, orgId, authorMemberId, body, createdAt)
503
+ .run();
504
+
505
+ return {
506
+ id,
507
+ org_id: orgId,
508
+ author_member_id: authorMemberId,
509
+ author_display_name: member.display_name,
510
+ author_identity_kind: member.identity_kind,
511
+ body,
512
+ created_at: createdAt,
513
+ } satisfies StoredChatMessage;
514
+ }
515
+
516
+ async function handlePostChatMessage(
517
+ request: Request,
518
+ env: Env,
519
+ orgId: string
520
+ ): Promise<Response> {
521
+ const auth = await getAuthContext(request, env, orgId);
522
+ if (!auth) return jsonResponse({ error: "Unauthorized" }, { status: 401 });
523
+ const body = await readJson<PostChatMessageRequest>(request);
524
+ const messageBody = body.body?.trim();
525
+ if (!messageBody) {
526
+ return jsonResponse({ error: "Message body is required" }, { status: 400 });
527
+ }
528
+ if (messageBody.length > MAX_CHAT_BODY_LENGTH) {
529
+ return jsonResponse({ error: "Message body is too long" }, { status: 413 });
530
+ }
531
+
532
+ const message = await insertChatMessage({
533
+ env,
534
+ orgId,
535
+ authorMemberId: auth.memberId,
536
+ body: messageBody,
537
+ });
538
+ if (!message) return jsonResponse({ error: "Member not found" }, { status: 404 });
539
+
540
+ const publicMessage = publicChatMessage(message);
541
+ await routeToOrgRoom(
542
+ new Request(`https://collab.internal/orgs/${encodeURIComponent(orgId)}/ws`, {
543
+ method: "POST",
544
+ body: JSON.stringify({
545
+ protocolVersion: COLLAB_PROTOCOL_VERSION,
546
+ id: createId("evt"),
547
+ type: COLLAB_MESSAGE_TYPE.CHAT_MESSAGE,
548
+ orgId,
549
+ senderMemberId: auth.memberId,
550
+ sentAt: publicMessage.createdAt,
551
+ payload: { message: publicMessage },
552
+ }),
553
+ }),
554
+ env,
555
+ orgId
556
+ );
557
+
558
+ return jsonResponse({ message: publicMessage });
559
+ }
560
+
561
+ function routeToOrgRoom(
562
+ request: Request,
563
+ env: Env,
564
+ orgId: string
565
+ ): Response | Promise<Response> {
566
+ const id = env.ORG_ROOMS.idFromName(orgId);
567
+ return env.ORG_ROOMS.get(id).fetch(request);
568
+ }
569
+
570
+ export class CollabOrgRoom implements DurableObject {
571
+ private sessions = new Set<WebSocket>();
572
+
573
+ constructor(
574
+ private state: DurableObjectState,
575
+ private env: Env
576
+ ) {}
577
+
578
+ async fetch(request: Request): Promise<Response> {
579
+ const upgradeHeader = request.headers.get("upgrade");
580
+ if (request.method === "POST" && upgradeHeader !== "websocket") {
581
+ const message = await request.text();
582
+ this.broadcast(message);
583
+ return jsonResponse({ ok: true });
584
+ }
585
+
586
+ if (upgradeHeader !== "websocket") {
587
+ return jsonResponse(
588
+ { error: "WebSocket upgrade required" },
589
+ { status: 426 }
590
+ );
591
+ }
592
+
593
+ const pair = new WebSocketPair();
594
+ const [client, server] = Object.values(pair);
595
+ this.sessions.add(server);
596
+ server.accept();
597
+ server.addEventListener("message", (event) => {
598
+ this.broadcast(event.data, server);
599
+ });
600
+ server.addEventListener("close", () => this.sessions.delete(server));
601
+ server.addEventListener("error", () => this.sessions.delete(server));
602
+
603
+ return new Response(null, { status: 101, webSocket: client });
604
+ }
605
+
606
+ private broadcast(message: string | ArrayBuffer, except?: WebSocket): void {
607
+ for (const socket of this.sessions) {
608
+ if (socket !== except && socket.readyState === WebSocket.OPEN) {
609
+ socket.send(message);
610
+ }
611
+ }
612
+ }
613
+ }
614
+
615
+ export default {
616
+ async fetch(request: Request, env: Env): Promise<Response> {
617
+ const route = matchCollabHubRoute(request);
618
+ try {
619
+ switch (route.kind) {
620
+ case "createOrg":
621
+ return await handleCreateOrg(request, env);
622
+ case "createInvite":
623
+ return await handleCreateInvite(request, env, route.orgId);
624
+ case "acceptInvite":
625
+ return await handleAcceptInvite(request, env, route.inviteCode);
626
+ case "removeMember":
627
+ return await handleRemoveMember(
628
+ request,
629
+ env,
630
+ route.orgId,
631
+ route.memberId
632
+ );
633
+ case "listChatMessages":
634
+ return await handleListChatMessages(request, env, route.orgId);
635
+ case "postChatMessage":
636
+ return await handlePostChatMessage(request, env, route.orgId);
637
+ case "bootstrap":
638
+ return await handleBootstrap(request, env, route.orgId);
639
+ case "webSocket": {
640
+ const auth = await getAuthContext(request, env, route.orgId);
641
+ if (!auth)
642
+ return jsonResponse({ error: "Unauthorized" }, { status: 401 });
643
+ return routeToOrgRoom(request, env, route.orgId);
644
+ }
645
+ case "notFound":
646
+ return jsonResponse({ error: "Not found" }, { status: 404 });
647
+ }
648
+ } catch (error) {
649
+ return jsonResponse(
650
+ { error: error instanceof Error ? error.message : "Internal error" },
651
+ { status: 500 }
652
+ );
653
+ }
654
+ },
655
+ };
@@ -0,0 +1,105 @@
1
+ export const COLLAB_HUB_ROUTE = {
2
+ ORGS: "orgs",
3
+ INVITES: "invites",
4
+ MEMBERS: "members",
5
+ CHAT: "chat",
6
+ BOOTSTRAP: "bootstrap",
7
+ WS: "ws",
8
+ REMOVE: "remove",
9
+ } as const;
10
+
11
+ export type CollabHubRouteMatch =
12
+ | { kind: "createOrg" }
13
+ | { kind: "createInvite"; orgId: string }
14
+ | { kind: "acceptInvite"; inviteCode: string }
15
+ | { kind: "removeMember"; orgId: string; memberId: string }
16
+ | { kind: "listChatMessages"; orgId: string }
17
+ | { kind: "postChatMessage"; orgId: string }
18
+ | { kind: "bootstrap"; orgId: string }
19
+ | { kind: "webSocket"; orgId: string }
20
+ | { kind: "notFound" };
21
+
22
+ export function matchCollabHubRoute(request: Request): CollabHubRouteMatch {
23
+ const url = new URL(request.url);
24
+ const parts = url.pathname.split("/").filter(Boolean).map(decodeURIComponent);
25
+
26
+ if (request.method === "POST" && parts.length === 1 && parts[0] === "orgs") {
27
+ return { kind: "createOrg" };
28
+ }
29
+
30
+ if (
31
+ request.method === "POST" &&
32
+ parts.length === 3 &&
33
+ parts[0] === COLLAB_HUB_ROUTE.ORGS &&
34
+ parts[2] === COLLAB_HUB_ROUTE.INVITES
35
+ ) {
36
+ return { kind: "createInvite", orgId: parts[1] };
37
+ }
38
+
39
+ if (
40
+ request.method === "POST" &&
41
+ parts.length === 3 &&
42
+ parts[0] === COLLAB_HUB_ROUTE.INVITES &&
43
+ parts[2] === "accept"
44
+ ) {
45
+ return { kind: "acceptInvite", inviteCode: parts[1] };
46
+ }
47
+
48
+ if (
49
+ request.method === "POST" &&
50
+ parts.length === 5 &&
51
+ parts[0] === COLLAB_HUB_ROUTE.ORGS &&
52
+ parts[2] === COLLAB_HUB_ROUTE.MEMBERS &&
53
+ parts[4] === COLLAB_HUB_ROUTE.REMOVE
54
+ ) {
55
+ return { kind: "removeMember", orgId: parts[1], memberId: parts[3] };
56
+ }
57
+
58
+ if (
59
+ request.method === "GET" &&
60
+ parts.length === 3 &&
61
+ parts[0] === COLLAB_HUB_ROUTE.ORGS &&
62
+ parts[2] === COLLAB_HUB_ROUTE.CHAT
63
+ ) {
64
+ return { kind: "listChatMessages", orgId: parts[1] };
65
+ }
66
+
67
+ if (
68
+ request.method === "POST" &&
69
+ parts.length === 3 &&
70
+ parts[0] === COLLAB_HUB_ROUTE.ORGS &&
71
+ parts[2] === COLLAB_HUB_ROUTE.CHAT
72
+ ) {
73
+ return { kind: "postChatMessage", orgId: parts[1] };
74
+ }
75
+
76
+ if (
77
+ request.method === "GET" &&
78
+ parts.length === 3 &&
79
+ parts[0] === COLLAB_HUB_ROUTE.ORGS &&
80
+ parts[2] === COLLAB_HUB_ROUTE.BOOTSTRAP
81
+ ) {
82
+ return { kind: "bootstrap", orgId: parts[1] };
83
+ }
84
+
85
+ if (
86
+ request.method === "GET" &&
87
+ parts.length === 3 &&
88
+ parts[0] === COLLAB_HUB_ROUTE.ORGS &&
89
+ parts[2] === COLLAB_HUB_ROUTE.WS
90
+ ) {
91
+ return { kind: "webSocket", orgId: parts[1] };
92
+ }
93
+
94
+ return { kind: "notFound" };
95
+ }
96
+
97
+ export function jsonResponse(value: unknown, init?: ResponseInit): Response {
98
+ return new Response(JSON.stringify(value), {
99
+ ...init,
100
+ headers: {
101
+ "content-type": "application/json; charset=utf-8",
102
+ ...init?.headers,
103
+ },
104
+ });
105
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "lib": ["ES2022", "WebWorker"],
7
+ "types": ["@cloudflare/workers-types"],
8
+ "strict": true,
9
+ "noImplicitAny": true,
10
+ "noFallthroughCasesInSwitch": true,
11
+ "skipLibCheck": true,
12
+ "isolatedModules": true
13
+ },
14
+ "include": ["src/**/*.ts"]
15
+ }
@@ -0,0 +1,27 @@
1
+ {
2
+ "$schema": "node_modules/wrangler/config-schema.json",
3
+ "name": "orgii-collab-hub",
4
+ "main": "src/index.ts",
5
+ "compatibility_date": "2026-06-15",
6
+ "durable_objects": {
7
+ "bindings": [
8
+ {
9
+ "name": "ORG_ROOMS",
10
+ "class_name": "CollabOrgRoom"
11
+ }
12
+ ]
13
+ },
14
+ "migrations": [
15
+ {
16
+ "tag": "v1",
17
+ "new_sqlite_classes": ["CollabOrgRoom"]
18
+ }
19
+ ],
20
+ "d1_databases": [
21
+ {
22
+ "binding": "DB",
23
+ "database_name": "orgii-collab-hub",
24
+ "database_id": "replace-with-cloudflare-d1-database-id"
25
+ }
26
+ ]
27
+ }