@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 +66 -0
- package/bin/orgii-collab-hub.js +87 -0
- package/package.json +39 -0
- package/templates/worker/migrations/0001_initial.sql +64 -0
- package/templates/worker/package.json +18 -0
- package/templates/worker/src/index.ts +655 -0
- package/templates/worker/src/route.ts +105 -0
- package/templates/worker/tsconfig.json +15 -0
- package/templates/worker/wrangler.jsonc +27 -0
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
|
+
}
|