Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 78 additions & 25 deletions cnx-ios/PROTOCOL.md

Large diffs are not rendered by default.

14 changes: 9 additions & 5 deletions cnx-ios/backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@ Cloudflare Worker for the cnx-ios app. It implements the HTTP and signaling
parts of [`../PROTOCOL.md`](../PROTOCOL.md), which is the contract.

- `src/index.ts`: routes, Stack JWT auth, validation.
- `src/team-hub.ts`: `TeamHub` Durable Object, one per team (SQLite-backed,
hibernating WebSockets): signaling admission, Mac presence (no data relay: WireGuard without UDP uses TURN over TLS),
60 s membership cache. A hub is created with a `locationHint` from the
caller's geolocation (`locationHintFor` in `src/index.ts`), since a hub
never moves and free-plan requests are sometimes received in a far colo.
- `src/team-hub.ts`: `TeamHub` Durable Object, one per **user** (named
`user:<owner key>` from the verified token; SQLite-backed, hibernating
WebSockets): signaling admission, Mac presence of the user's own devices
(no data relay: WireGuard without UDP uses TURN over TLS), 60 s membership
cache. A hub binds its owner on first use and refuses calls for another.
Teammates never share a hub (PROTOCOL.md "Signaling WebSocket"). A hub is
created with a `locationHint` from the caller's geolocation
(`locationHintFor` in `src/index.ts`), since a hub never moves and
free-plan requests are sometimes received in a far colo.
`GET /v1/teams/:teamId/hub` reports the ingress colo and the hub's colo.
- `src/store.ts`, `src/schema.ts`, `drizzle/`: PlanetScale Postgres through
Hyperdrive (Drizzle). Tests use an in-memory store.
Expand Down
2 changes: 2 additions & 0 deletions cnx-ios/backend/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export interface Env {
readonly ICE_LIMIT?: RateLimit
readonly INVITE_LIMIT?: RateLimit
readonly NOTIFY_LIMIT?: RateLimit
/** Milliseconds before an isolate retries a failed team hub retire (default 10 s; tests set 0). */
readonly TEAM_HUB_RETIRE_RETRY_MS?: string
// Test only (ENVIRONMENT=test).
readonly STACK_TEST_JWKS?: string
/** JSON {userId: [teamId]} for primary-project users, {"<projectId>:<userId>": [teamId]} for the others; replaces Stack membership calls. */
Expand Down
198 changes: 129 additions & 69 deletions cnx-ios/backend/src/index.ts

Large diffs are not rendered by default.

35 changes: 27 additions & 8 deletions cnx-ios/backend/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@ export interface Store {
getDevice(deviceId: string): Promise<Device | undefined>
/** Insert or update; "not_owner" when the id already belongs to another user (nothing changes). */
upsertDevice(d: DeviceInput): Promise<{ device: Device; previousTeamId: string | null } | "not_owner">
listMacs(teamId: string): Promise<Array<Device>>
/** Every Mac one owner registered, in any team or personal space (`ownerUserId` is the owner key). */
listOwnerMacs(ownerUserId: string): Promise<Array<Device>>
/** Every device (Mac or phone) of one owner, with whether it has a push token. */
Expand All @@ -67,6 +66,11 @@ export interface Store {
getPrefs(userId: string, teamId: string): Promise<Prefs>
/** Only the given fields change; `undefined` keeps the stored value. */
putPrefs(userId: string, teamId: string, patch: Partial<Prefs>): Promise<Prefs>
/**
* Every team id the database knows (devices and team prefs), with the Stack project of its rows: null for the
* primary project (bare owner keys), else the project id (`<projectId>:<sub>` keys). Names the old team hubs.
*/
listTeamScopes(): Promise<Array<{ project: string | null; teamId: string; deviceIds: Array<string> }>>
close(): Promise<void>
}

Expand Down Expand Up @@ -121,10 +125,6 @@ export const pgStore = async (hyperdrive: Hyperdrive): Promise<Store> => {
if (!r) return "not_owner"
return { device: row(r), previousTeamId: prev && prev.teamId !== d.teamId ? prev.teamId : null }
},
async listMacs(teamId) {
const rs = await db.select().from(devices).where(and(eq(devices.teamId, teamId), eq(devices.kind, "mac"))).orderBy(devices.name)
return rs.map(row)
},
async listOwnerMacs(ownerUserId) {
const rs = await db.select().from(devices).where(and(eq(devices.ownerUserId, ownerUserId), eq(devices.kind, "mac"))).orderBy(devices.name)
return rs.map(row)
Expand Down Expand Up @@ -184,6 +184,15 @@ export const pgStore = async (hyperdrive: Hyperdrive): Promise<Store> => {
.returning()
return { color: r?.color ?? null, emoji: r?.emoji ?? null }
},
async listTeamScopes() {
const r = await client.query<{ project: string | null; team_id: string; device_ids: Array<string> | null }>(
`SELECT project, team_id, array_agg(device_id::text) FILTER (WHERE device_id IS NOT NULL) AS device_ids FROM (
SELECT NULLIF(split_part(owner_user_id, ':', 1), owner_user_id) AS project, team_id, device_id FROM devices
UNION ALL SELECT NULLIF(split_part(user_id, ':', 1), user_id) AS project, team_id, NULL FROM team_prefs
) t GROUP BY project, team_id`
)
return r.rows.map((x) => ({ project: x.project, teamId: x.team_id, deviceIds: x.device_ids ?? [] }))
},
close: () => client.end()
}
}
Expand All @@ -203,9 +212,6 @@ export const memoryStore = (): Store => ({
mem.devices.set(d.deviceId, device)
return { device: { ...device }, previousTeamId: prev && prev.teamId !== d.teamId ? prev.teamId : null }
},
async listMacs(teamId) {
return [...mem.devices.values()].filter((d) => d.teamId === teamId && d.kind === "mac").sort((a, b) => a.name.localeCompare(b.name))
},
async listOwnerMacs(owner) {
return [...mem.devices.values()].filter((d) => d.ownerUserId === owner && d.kind === "mac").sort((a, b) => a.name.localeCompare(b.name))
},
Expand Down Expand Up @@ -254,6 +260,19 @@ export const memoryStore = (): Store => ({
mem.prefs.set(k, next)
return { ...next }
},
async listTeamScopes() {
const scope = (owner: string) => (owner.includes(":") ? owner.slice(0, owner.indexOf(":")) : null)
const out = new Map<string, { project: string | null; teamId: string; deviceIds: Array<string> }>()
const add = (project: string | null, teamId: string, deviceId?: string) => {
const k = `${project}\u0000${teamId}`
const row = out.get(k) ?? { project, teamId, deviceIds: [] }
if (deviceId) row.deviceIds.push(deviceId)
out.set(k, row)
}
for (const d of mem.devices.values()) add(scope(d.ownerUserId), d.teamId, d.deviceId)
for (const k of mem.prefs.keys()) add(scope(k.split("\u0000")[0]!), k.split("\u0000")[1]!)
return [...out.values()]
},
close: async () => {}
})

Expand Down
Loading