heyquilt 0.0.1 → 0.3.90
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/LICENSE +85 -0
- package/README.md +630 -2
- package/RELEASES.md +733 -0
- package/assets/logo.svg +1 -0
- package/bin/quilt.js +816 -0
- package/desktop/main.js +315 -0
- package/package.json +163 -7
- package/src/account.js +563 -0
- package/src/admit-policy.js +32 -0
- package/src/agent-join.js +370 -0
- package/src/agent-task-workflow.js +176 -0
- package/src/agents/actions.js +85 -0
- package/src/agents/claude-code.js +180 -0
- package/src/agents/common.js +21 -0
- package/src/agents/cursor-transcripts.js +170 -0
- package/src/agents/cursor.js +404 -0
- package/src/agents/index.js +69 -0
- package/src/ai-vm-signin.js +98 -0
- package/src/ai-vm.js +380 -0
- package/src/api/access.js +75 -0
- package/src/api/activity.js +181 -0
- package/src/api/agent-auth.js +130 -0
- package/src/api/agent-placement.js +157 -0
- package/src/api/audit.js +83 -0
- package/src/api/auth.js +17 -0
- package/src/api/domains.js +30 -0
- package/src/api/file-paths.js +40 -0
- package/src/api/file-store.js +99 -0
- package/src/api/fly-machines.js +76 -0
- package/src/api/github-app.js +172 -0
- package/src/api/http.js +34 -0
- package/src/api/invite-email.js +53 -0
- package/src/api/issues.js +110 -0
- package/src/api/join-text.js +78 -0
- package/src/api/mailer.js +47 -0
- package/src/api/memory-store.js +1083 -0
- package/src/api/org-access.js +54 -0
- package/src/api/permissions.js +71 -0
- package/src/api/relay-cluster.js +251 -0
- package/src/api/relay-machines.js +60 -0
- package/src/api/routes/access-types.js +54 -0
- package/src/api/routes/agent-invites.js +115 -0
- package/src/api/routes/agents.js +157 -0
- package/src/api/routes/ai-vm.js +369 -0
- package/src/api/routes/github.js +380 -0
- package/src/api/routes/grants.js +70 -0
- package/src/api/routes/invites.js +188 -0
- package/src/api/routes/issues.js +47 -0
- package/src/api/routes/join.js +167 -0
- package/src/api/routes/members.js +110 -0
- package/src/api/routes/orgs.js +177 -0
- package/src/api/routes/relay-cluster.js +90 -0
- package/src/api/routes/relay.js +76 -0
- package/src/api/routes/session-invites.js +119 -0
- package/src/api/routes/sessions.js +92 -0
- package/src/api/routes/teams.js +132 -0
- package/src/api/routes/workspace-agents.js +332 -0
- package/src/api/routes/workspace-files.js +277 -0
- package/src/api/routes/workspace-invites.js +196 -0
- package/src/api/routes/workspaces.js +225 -0
- package/src/api/server.js +623 -0
- package/src/api/slugs.js +20 -0
- package/src/api/supabase-store.js +765 -0
- package/src/api/team-access.js +30 -0
- package/src/api/tokens.js +31 -0
- package/src/api/workspace-access.js +62 -0
- package/src/api/workspace-reach.js +60 -0
- package/src/autowork-tools.js +166 -0
- package/src/autowork.js +387 -0
- package/src/blobstore.js +99 -0
- package/src/boxes.js +37 -0
- package/src/branchdocs.js +235 -0
- package/src/branches.js +194 -0
- package/src/catchup.js +111 -0
- package/src/chat-archive.js +85 -0
- package/src/chat-links.js +923 -0
- package/src/clash.js +140 -0
- package/src/cloud-ai.js +50 -0
- package/src/commit-request.js +59 -0
- package/src/commit.js +213 -0
- package/src/connection.js +569 -0
- package/src/control.js +245 -0
- package/src/conversation.js +125 -0
- package/src/doctor.js +156 -0
- package/src/duties.js +303 -0
- package/src/editors.js +228 -0
- package/src/fsutil.js +87 -0
- package/src/git-hook.js +48 -0
- package/src/git.js +147 -0
- package/src/github-commit.js +320 -0
- package/src/gitignore.js +49 -0
- package/src/gitstate.js +900 -0
- package/src/history.js +307 -0
- package/src/hooks.js +371 -0
- package/src/hosted-agents.js +24 -0
- package/src/hosted-client.js +112 -0
- package/src/identity.js +99 -0
- package/src/inbox.js +237 -0
- package/src/integrations.js +221 -0
- package/src/key-cli.js +115 -0
- package/src/largefiles.js +91 -0
- package/src/legacy.js +25 -0
- package/src/mcp-proxy.js +40 -0
- package/src/mcp-room.js +32 -0
- package/src/mcp.js +1261 -0
- package/src/merge3.js +158 -0
- package/src/merges.js +148 -0
- package/src/pass-source.js +176 -0
- package/src/passes.js +64 -0
- package/src/pathrules.js +127 -0
- package/src/persona.js +101 -0
- package/src/presence.js +360 -0
- package/src/private-file.js +41 -0
- package/src/procs.js +70 -0
- package/src/project-rules.js +178 -0
- package/src/protocol.js +92 -0
- package/src/quiet-warnings.js +9 -0
- package/src/relay-ai-vm.js +82 -0
- package/src/relay-ask.js +141 -0
- package/src/relay-ci.js +111 -0
- package/src/relay-clash.js +597 -0
- package/src/relay-cluster.js +454 -0
- package/src/relay-commit.js +887 -0
- package/src/relay-filekey.js +46 -0
- package/src/relay-git.js +274 -0
- package/src/relay-githttp.js +137 -0
- package/src/relay-github.js +109 -0
- package/src/relay-lease.js +137 -0
- package/src/relay-lfs.js +37 -0
- package/src/relay-mcp.js +1679 -0
- package/src/relay-metrics.js +75 -0
- package/src/relay-outbound.js +1144 -0
- package/src/relay-sync.js +719 -0
- package/src/relay-turn-sync.js +307 -0
- package/src/relay-upstream.js +934 -0
- package/src/relay-webhooks.js +111 -0
- package/src/releases.js +131 -0
- package/src/report.js +129 -0
- package/src/runner.js +306 -0
- package/src/same-edit.js +18 -0
- package/src/server.js +4187 -0
- package/src/session-access.js +170 -0
- package/src/session-name.js +15 -0
- package/src/session.js +7361 -0
- package/src/settings.js +46 -0
- package/src/setup.js +184 -0
- package/src/shell-path.js +46 -0
- package/src/status.js +199 -0
- package/src/summarize.js +109 -0
- package/src/sync-words.js +42 -0
- package/src/task-comments.js +92 -0
- package/src/tasks.js +670 -0
- package/src/textdiff.js +20 -0
- package/src/turn-sync-plan.js +142 -0
- package/src/turn-sync.js +191 -0
- package/src/ui/access-form.js +37 -0
- package/src/ui/agent-env-key.js +76 -0
- package/src/ui/agent-guide.js +127 -0
- package/src/ui/agent-kinds.js +175 -0
- package/src/ui/agent-place.js +265 -0
- package/src/ui/app.css +2309 -0
- package/src/ui/app.js +542 -0
- package/src/ui/board.js +465 -0
- package/src/ui/boxes.js +76 -0
- package/src/ui/branches.js +111 -0
- package/src/ui/catchup.js +77 -0
- package/src/ui/changes.js +159 -0
- package/src/ui/chat.js +266 -0
- package/src/ui/cloud-ai-model.js +107 -0
- package/src/ui/cloud-ai.js +221 -0
- package/src/ui/common.js +431 -0
- package/src/ui/csv.js +21 -0
- package/src/ui/diffview.js +74 -0
- package/src/ui/feed-convs.js +39 -0
- package/src/ui/feed.js +174 -0
- package/src/ui/files.js +579 -0
- package/src/ui/fileview.js +123 -0
- package/src/ui/git-rule.js +60 -0
- package/src/ui/home.js +1323 -0
- package/src/ui/index.html +16 -0
- package/src/ui/invite.js +97 -0
- package/src/ui/invites.js +79 -0
- package/src/ui/loom-model.js +516 -0
- package/src/ui/loom.css +349 -0
- package/src/ui/loom.js +753 -0
- package/src/ui/mark.js +104 -0
- package/src/ui/merges.js +158 -0
- package/src/ui/rail-model.js +78 -0
- package/src/ui/rail.js +173 -0
- package/src/ui/releases.js +182 -0
- package/src/ui/schedule.js +156 -0
- package/src/ui/session.js +3394 -0
- package/src/ui/signin.js +75 -0
- package/src/ui/sounds.js +110 -0
- package/src/ui/tool-logo.js +86 -0
- package/src/ui/tree.js +276 -0
- package/src/ui/workspaces.js +542 -0
- package/src/ui-server.js +1272 -0
- package/src/update-check.js +67 -0
- package/src/upstream.js +155 -0
- package/src/webhooks.js +235 -0
- package/src/workspace-cli.js +62 -0
- package/src/workspace-tools.js +340 -0
- package/index.js +0 -2
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Agents' keys. An access key (qa_, 1 hour) signs an agent in; a refresh key
|
|
2
|
+
// (qr_, 30 days, single use) swaps for a new pair. Pairs minted by refreshing
|
|
3
|
+
// share a family, and presenting a spent refresh key revokes the whole family:
|
|
4
|
+
// someone copied it, and we can't tell which holder is the real agent. Every
|
|
5
|
+
// agent also gets a resume key (qs_) when it joins, which never expires: it swaps
|
|
6
|
+
// for a pair in a new family once the refresh key stopped working, so a revoke
|
|
7
|
+
// never locks an agent out (only a person revoking the agent does).
|
|
8
|
+
// An app key (qk_) is for apps that can only hold one pasted key (Pipedream, Zapier,
|
|
9
|
+
// a script): it signs the agent in like an access key but never runs out. Its owner
|
|
10
|
+
// makes and revokes it on heyquilt.com.
|
|
11
|
+
import crypto from 'node:crypto'
|
|
12
|
+
import { newToken, hashToken } from './tokens.js'
|
|
13
|
+
import { HttpError } from './http.js'
|
|
14
|
+
|
|
15
|
+
export const ACCESS_TTL_MS = 60 * 60 * 1000
|
|
16
|
+
export const REFRESH_TTL_MS = 30 * 24 * 60 * 60 * 1000
|
|
17
|
+
// last_used_at is for people reading the dashboard; once a minute is plenty.
|
|
18
|
+
const TOUCH_EVERY_MS = 60 * 1000
|
|
19
|
+
export const REUSED = "This key was already used, so this agent's keys were revoked. Get new ones with your resume key: POST /v1/agents/resume with {\"resumeKey\": \"<resumeKey>\"}."
|
|
20
|
+
const REVOKED = "This agent's keys were revoked. Get new ones with your resume key: POST /v1/agents/resume with {\"resumeKey\": \"<resumeKey>\"}."
|
|
21
|
+
|
|
22
|
+
/** 'active' while some key can still refresh; 'reused' once a family was revoked; otherwise 'expired'. */
|
|
23
|
+
export function keyStatus (rows, at) {
|
|
24
|
+
if (rows.some((k) => !k.revokedAt && k.refreshExpiresAt > at)) return 'active'
|
|
25
|
+
return rows.some((k) => k.revokedAt) ? 'reused' : 'expired'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Whether a bearer key is an agent's: an access key or an app key. */
|
|
29
|
+
export const isAgentKey = (key) => key.startsWith('qa_') || key.startsWith('qk_')
|
|
30
|
+
|
|
31
|
+
export function makeAgentAuth ({ store, now, bearer }) {
|
|
32
|
+
/** A fresh pair for an agent, in a new family unless one is given. The keys are shown once. */
|
|
33
|
+
async function mintKeys (agentId, familyId = crypto.randomUUID()) {
|
|
34
|
+
const accessKey = newToken('qa_'); const refreshKey = newToken('qr_')
|
|
35
|
+
const at = now()
|
|
36
|
+
const accessExpiresAt = at + ACCESS_TTL_MS; const refreshExpiresAt = at + REFRESH_TTL_MS
|
|
37
|
+
await store.createAgentKeys({ agentId, familyId, accessHash: hashToken(accessKey), refreshHash: hashToken(refreshKey), accessExpiresAt, refreshExpiresAt })
|
|
38
|
+
return { agentId, accessKey, accessExpiresAt, refreshKey, refreshExpiresAt }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Swaps a refresh key for a new pair in the same family, once. */
|
|
42
|
+
async function refresh (refreshKey) {
|
|
43
|
+
const key = typeof refreshKey === 'string' && refreshKey.startsWith('qr_') ? refreshKey : ''
|
|
44
|
+
const row = key && await store.agentKeyByRefresh(hashToken(key))
|
|
45
|
+
if (!row) throw new HttpError(401, "This key isn't valid. Invite the agent again.")
|
|
46
|
+
if (row.revokedAt) throw new HttpError(401, REVOKED)
|
|
47
|
+
if (row.refreshedAt) { await store.revokeFamily(row.familyId); throw new HttpError(401, REUSED) }
|
|
48
|
+
if (row.refreshExpiresAt <= now()) throw new HttpError(401, 'This key has expired. Invite the agent again.')
|
|
49
|
+
const agent = await store.agentById(row.agentId)
|
|
50
|
+
if (!agent || agent.revokedAt) throw new HttpError(401, 'This agent was revoked.')
|
|
51
|
+
// Two refreshes racing with one key: only one spends it, and the other is a reuse.
|
|
52
|
+
if (!await store.claimRefresh(row.id)) { await store.revokeFamily(row.familyId); throw new HttpError(401, REUSED) }
|
|
53
|
+
let pair
|
|
54
|
+
try {
|
|
55
|
+
pair = await mintKeys(agent.id, row.familyId)
|
|
56
|
+
} catch (err) {
|
|
57
|
+
// Minting failed after the claim went through: release it rather than
|
|
58
|
+
// leave the key stuck "spent" with no pair to show for it.
|
|
59
|
+
await store.releaseRefresh(row.id)
|
|
60
|
+
throw err
|
|
61
|
+
}
|
|
62
|
+
// A reuse caught while this pair was being made (the family revoked
|
|
63
|
+
// meanwhile) must not leave this new pair working.
|
|
64
|
+
if ((await store.agentKeyByRefresh(hashToken(key)))?.revokedAt) { await store.revokeFamily(row.familyId); throw new HttpError(401, REUSED) }
|
|
65
|
+
return pair
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A new resume key for an agent that is joining: the key, and the hash to store. */
|
|
69
|
+
function newResumeKey () {
|
|
70
|
+
const resumeKey = newToken('qs_')
|
|
71
|
+
return { resumeKey, resumeHash: hashToken(resumeKey) }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* New keys for an agent that proved who it is (its resume key, or a signature with the
|
|
76
|
+
* key it joined with: routes/agents.js), after its refresh key stopped working: a reply
|
|
77
|
+
* lost while offline, or a copy of the keys used in two places, leaves it holding a spent
|
|
78
|
+
* key, which revokes its keys. Every key it had is revoked and it starts a new family.
|
|
79
|
+
* Never for an agent a person revoked.
|
|
80
|
+
*/
|
|
81
|
+
async function resume (agent) {
|
|
82
|
+
if (agent.revokedAt) throw new HttpError(401, 'This agent was revoked. Invite it again.')
|
|
83
|
+
const families = new Set((await store.listAgentKeys(agent.id)).filter((k) => !k.revokedAt).map((k) => k.familyId))
|
|
84
|
+
for (const f of families) await store.revokeFamily(f)
|
|
85
|
+
return mintKeys(agent.id)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** resume() for the agent whose resume key this is. */
|
|
89
|
+
async function resumeByKey (resumeKey) {
|
|
90
|
+
const key = typeof resumeKey === 'string' && resumeKey.startsWith('qs_') ? resumeKey : ''
|
|
91
|
+
const agent = key && await store.agentByResume(hashToken(key))
|
|
92
|
+
if (!agent) throw new HttpError(401, "This resume key isn't valid. Invite the agent again.")
|
|
93
|
+
return resume(agent)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A new app key (qk_) for an agent: shown once, only its hash is stored. */
|
|
97
|
+
async function mintAppKey (agentId, name) {
|
|
98
|
+
const key = newToken('qk_')
|
|
99
|
+
const row = await store.createAgentAppKey({ agentId, name, keyHash: hashToken(key) })
|
|
100
|
+
return { id: row.id, name: row.name, createdAt: row.createdAt, key }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The agent behind an app key, or a 401. App keys don't run out: they work until revoked. */
|
|
104
|
+
async function agentFromAppKey (key) {
|
|
105
|
+
const row = await store.agentAppKeyByHash(hashToken(key))
|
|
106
|
+
if (!row) throw new HttpError(401, "this key isn't valid; make a new one on heyquilt.com (Agents)")
|
|
107
|
+
if (row.revokedAt) throw new HttpError(401, 'this key was revoked; make a new one on heyquilt.com (Agents)')
|
|
108
|
+
const agent = await store.agentById(row.agentId)
|
|
109
|
+
if (!agent || agent.revokedAt) throw new HttpError(401, 'this agent was revoked')
|
|
110
|
+
if (!row.lastUsedAt || now() - row.lastUsedAt >= TOUCH_EVERY_MS) await store.touchAgentAppKey(row.id)
|
|
111
|
+
if (!agent.lastUsedAt || now() - agent.lastUsedAt >= TOUCH_EVERY_MS) await store.touchAgent(agent.id)
|
|
112
|
+
return { agent, keyRow: null, appKey: row }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The agent behind a request's `qa_` (access) or `qk_` (app) bearer key, or a 401. */
|
|
116
|
+
async function agentFromRequest (req) {
|
|
117
|
+
const key = bearer(req)
|
|
118
|
+
if (key.startsWith('qk_')) return agentFromAppKey(key)
|
|
119
|
+
const keyRow = key.startsWith('qa_') ? await store.agentKeyByAccess(hashToken(key)) : null
|
|
120
|
+
if (!keyRow) throw new HttpError(401, 'sign the agent in first')
|
|
121
|
+
if (keyRow.revokedAt) throw new HttpError(401, "this agent's keys were revoked; get new ones with your resume key (POST /v1/agents/resume)")
|
|
122
|
+
if (keyRow.accessExpiresAt <= now()) throw new HttpError(401, 'this access key has expired; refresh it')
|
|
123
|
+
const agent = await store.agentById(keyRow.agentId)
|
|
124
|
+
if (!agent || agent.revokedAt) throw new HttpError(401, 'this agent was revoked')
|
|
125
|
+
if (!agent.lastUsedAt || now() - agent.lastUsedAt >= TOUCH_EVERY_MS) await store.touchAgent(agent.id)
|
|
126
|
+
return { agent, keyRow }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return { mintKeys, newResumeKey, refresh, resume, resumeByKey, agentFromRequest, mintAppKey }
|
|
130
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// Where an agent works without being added by hand (its placement), and who joins a
|
|
2
|
+
// session inside a workspace. A placement never crosses owners: a personal agent reaches
|
|
3
|
+
// only its owner's personal workspaces, an org agent only its org's. A workspace can
|
|
4
|
+
// keep a placed agent out or change whether it joins sessions (its override), and a
|
|
5
|
+
// session owner can keep any agent out of one session (an exclusion).
|
|
6
|
+
import { HttpError, UUID } from './http.js'
|
|
7
|
+
import { ACCESS, stillInOrg } from './workspace-access.js'
|
|
8
|
+
import { cleanFolders } from '../session-access.js'
|
|
9
|
+
|
|
10
|
+
export const REACH = ['all', 'workspaces', 'manual']
|
|
11
|
+
export const SESSIONS = ['all', 'invited']
|
|
12
|
+
const MAX_WORKSPACES = 100
|
|
13
|
+
|
|
14
|
+
/** A placement as a route receives it, checked: { reach, workspaceIds, sessions, access, scopes }. HttpError 400 otherwise. */
|
|
15
|
+
export function cleanPlacement (body) {
|
|
16
|
+
const b = body && typeof body === 'object' && !Array.isArray(body) ? body : {}
|
|
17
|
+
if (!REACH.includes(b.reach)) throw new HttpError(400, 'Reach is all, workspaces or manual.')
|
|
18
|
+
if (!SESSIONS.includes(b.sessions)) throw new HttpError(400, 'Sessions is all or invited.')
|
|
19
|
+
if (!ACCESS.includes(b.access)) throw new HttpError(400, 'Access is edit or view.')
|
|
20
|
+
let workspaceIds = []
|
|
21
|
+
if (b.reach === 'workspaces') {
|
|
22
|
+
const raw = b.workspaceIds ?? []
|
|
23
|
+
if (!Array.isArray(raw) || raw.some((id) => typeof id !== 'string' || !UUID.test(id))) throw new HttpError(400, 'workspaceIds must be a list of workspace ids.')
|
|
24
|
+
workspaceIds = [...new Set(raw.map((id) => id.toLowerCase()))]
|
|
25
|
+
if (workspaceIds.length > MAX_WORKSPACES) throw new HttpError(400, `A placement can list at most ${MAX_WORKSPACES} workspaces.`)
|
|
26
|
+
}
|
|
27
|
+
let scopes
|
|
28
|
+
try { scopes = cleanFolders(b.scopes ?? []) } catch (e) { throw new HttpError(400, e.message) }
|
|
29
|
+
return { reach: b.reach, workspaceIds, sessions: b.sessions, access: b.access, scopes }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A live agent (not revoked) from the same owner as `ws`, or null: for a personal workspace
|
|
34
|
+
* one of its owner's own agents, for an org's one of the org's agents still in it.
|
|
35
|
+
*/
|
|
36
|
+
export async function sameOwnerAgent (store, ws, agentId) {
|
|
37
|
+
if (!UUID.test(String(agentId || ''))) return null
|
|
38
|
+
const agent = await store.agentById(agentId)
|
|
39
|
+
if (!agent || agent.revokedAt) return null
|
|
40
|
+
if (ws.orgId) {
|
|
41
|
+
if (agent.orgId !== ws.orgId) return null
|
|
42
|
+
// Removing an agent from its org revokes it; this keeps a half-removed one out too.
|
|
43
|
+
if (!(await store.memberByAgent(ws.orgId, agentId))) return null
|
|
44
|
+
} else if (!ws.ownerUserId || agent.ownerUserId !== ws.ownerUserId) return null
|
|
45
|
+
return agent
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* `agentId`'s placement when it covers `ws`, before the workspace's override (so an admin
|
|
50
|
+
* can still find an agent they kept out, to let it back in); null otherwise.
|
|
51
|
+
*/
|
|
52
|
+
export async function placementIn (store, ws, agentId) {
|
|
53
|
+
if (!ws || !(await sameOwnerAgent(store, ws, agentId))) return null
|
|
54
|
+
const placement = await store.agentPlacement(agentId)
|
|
55
|
+
if (!placement || placement.reach === 'manual') return null
|
|
56
|
+
if (placement.reach === 'workspaces' && !(placement.workspaceIds || []).includes(ws.id)) return null
|
|
57
|
+
return placement
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* How `agentId`'s placement reaches `ws`: { access, scopes, via: 'placed' | 'global', sessions }
|
|
62
|
+
* or null. Ids in a placement that aren't `ws` are ignored, so a deleted workspace's id left
|
|
63
|
+
* in the list never matters.
|
|
64
|
+
*/
|
|
65
|
+
export async function agentReach (store, ws, agentId) {
|
|
66
|
+
const placement = await placementIn(store, ws, agentId)
|
|
67
|
+
if (!placement) return null
|
|
68
|
+
const override = await store.workspaceAgentOverride(ws.id, agentId)
|
|
69
|
+
if (override?.excluded) return null
|
|
70
|
+
return { access: placement.access, scopes: [...(placement.scopes || [])], via: placement.reach === 'all' ? 'global' : 'placed', sessions: override?.sessions ?? placement.sessions }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Whether `agentId` joins `session` (in `ws`) when it starts: { joins, via } (via null when nothing decided yes). */
|
|
74
|
+
async function decide (store, session, ws, agentId) {
|
|
75
|
+
if (!UUID.test(String(agentId || ''))) return { joins: false, via: null }
|
|
76
|
+
if (await store.sessionAgentExcluded(session.room, agentId)) return { joins: false, via: null }
|
|
77
|
+
return inviteFor(store, ws, agentId)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** What the workspace says about inviting `agentId` to its sessions, before any keep-out: { joins, via }. */
|
|
81
|
+
async function inviteFor (store, ws, agentId) {
|
|
82
|
+
const no = { joins: false, via: null }
|
|
83
|
+
if (!UUID.test(String(agentId || ''))) return no
|
|
84
|
+
// A revoked agent never joins, whatever rows it left behind.
|
|
85
|
+
const agent = await store.agentById(agentId)
|
|
86
|
+
if (!agent || agent.revokedAt) return no
|
|
87
|
+
const account = `agent:${agentId}`
|
|
88
|
+
const member = await store.workspaceMember(ws.id, account)
|
|
89
|
+
// A member row decides, but it makes an agent join every session only when the agent is
|
|
90
|
+
// the workspace owner's own: anyone can add someone else's agent, never summon it.
|
|
91
|
+
if (member && await stillInOrg(store, ws, account, null)) return { joins: member.sessions === 'all' && !!(await sameOwnerAgent(store, ws, agentId)), via: 'member' }
|
|
92
|
+
const r = await agentReach(store, ws, agentId)
|
|
93
|
+
if (r) return { joins: r.sessions === 'all', via: r.via }
|
|
94
|
+
return no
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The room's session and its workspace, or null when it isn't in one. As in roomAccess, a
|
|
99
|
+
* session counts as in a workspace only when its owner (as the relay reports it) put it there.
|
|
100
|
+
*/
|
|
101
|
+
async function sessionAndWorkspace (store, room) {
|
|
102
|
+
const session = await store.sessionByRoom(room)
|
|
103
|
+
if (!session || !session.workspaceId) return null
|
|
104
|
+
if (!session.ownerAccount || session.ownerAccount !== session.workspaceLinkedBy) return null
|
|
105
|
+
const ws = await store.workspaceById(session.workspaceId)
|
|
106
|
+
return ws ? { session, ws } : null
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Whether an agent joins the session in `room` by itself: kept out of this session, no;
|
|
111
|
+
* a member row in the session's workspace decides next (yes only for the workspace owner's
|
|
112
|
+
* own agent); then its placement (with the workspace's override); otherwise no.
|
|
113
|
+
*/
|
|
114
|
+
export async function agentJoinsSession (store, room, agentId) {
|
|
115
|
+
const sw = await sessionAndWorkspace(store, room)
|
|
116
|
+
if (!sw) return false
|
|
117
|
+
return (await decide(store, sw.session, sw.ws, agentId)).joins
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Every agent that joins the session in `room` by itself, with what decided it: [{ agentId, via }]. */
|
|
121
|
+
export async function agentsJoiningSession (store, room) {
|
|
122
|
+
const sw = await sessionAndWorkspace(store, room)
|
|
123
|
+
if (!sw) return []
|
|
124
|
+
const { session, ws } = sw
|
|
125
|
+
const members = (await store.listWorkspaceMembers(ws.id)).filter((m) => m.account.startsWith('agent:')).map((m) => m.account.slice(6))
|
|
126
|
+
const own = ws.orgId ? await store.listOrgAgents(ws.orgId) : await store.listPersonalAgents(ws.ownerUserId)
|
|
127
|
+
const out = []
|
|
128
|
+
for (const agentId of new Set([...members, ...own.map((a) => a.id)])) {
|
|
129
|
+
const d = await decide(store, session, ws, agentId)
|
|
130
|
+
if (d.joins) out.push({ agentId, via: d.via })
|
|
131
|
+
}
|
|
132
|
+
return out
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* A session's agents as its owner manages them (the session's People): every agent its
|
|
137
|
+
* workspace invites to it, by name, with why (via 'member' | 'global' | 'placed'), who manages
|
|
138
|
+
* that ('workspace', 'owner' or 'org') and whether its owner said Don't invite (excluded: the
|
|
139
|
+
* keep-out). Sorted by name. A session outside a workspace invites nobody.
|
|
140
|
+
*/
|
|
141
|
+
export async function sessionAgents (store, room) {
|
|
142
|
+
const sw = await sessionAndWorkspace(store, room)
|
|
143
|
+
if (!sw) return []
|
|
144
|
+
const { ws } = sw
|
|
145
|
+
const excluded = new Set((await store.listSessionAgentExclusions(room)).map((e) => e.agentId))
|
|
146
|
+
const ids = new Set()
|
|
147
|
+
for (const m of await store.listWorkspaceMembers(ws.id)) if (m.account.startsWith('agent:')) ids.add(m.account.slice(6))
|
|
148
|
+
for (const a of ws.orgId ? await store.listOrgAgents(ws.orgId) : await store.listPersonalAgents(ws.ownerUserId)) ids.add(a.id)
|
|
149
|
+
const out = []
|
|
150
|
+
for (const agentId of ids) {
|
|
151
|
+
const d = await inviteFor(store, ws, agentId)
|
|
152
|
+
if (!d.joins) continue
|
|
153
|
+
const agent = await store.agentById(agentId)
|
|
154
|
+
out.push({ agentId, name: agent?.name || '', via: d.via, managedBy: d.via === 'member' ? 'workspace' : ws.orgId ? 'org' : 'owner', excluded: excluded.has(agentId) })
|
|
155
|
+
}
|
|
156
|
+
return out.sort((a, b) => a.name.localeCompare(b.name))
|
|
157
|
+
}
|
package/src/api/audit.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// A session's audit trail, for its owner: every visit (who, a person or an agent, through
|
|
2
|
+
// the app or as a hosted agent, with which tool), when it started and ended and why, and
|
|
3
|
+
// what it did in between. Pure: routes/sessions.js hands it rows from the store.
|
|
4
|
+
// The relay reports what it saw (see src/presence.js); this adds what only the accounts
|
|
5
|
+
// API knows: an agent whose keys were revoked had its visit end because of that.
|
|
6
|
+
|
|
7
|
+
// A hosted agent counts as gone after this long without a call (the relay's HOSTED_ONLINE_MS).
|
|
8
|
+
export const HOSTED_QUIET_MS = 30 * 60 * 1000
|
|
9
|
+
export const MAX_ACTIONS = 5000
|
|
10
|
+
|
|
11
|
+
export const REASON_TEXT = {
|
|
12
|
+
left: 'Left',
|
|
13
|
+
disconnected: 'Connection dropped',
|
|
14
|
+
removed: 'Removed by the owner',
|
|
15
|
+
pass_expired: 'Sign-in ran out',
|
|
16
|
+
session_ended: 'Session ended',
|
|
17
|
+
relay_restart: 'Relay restarted',
|
|
18
|
+
idle: 'Done working (went quiet)',
|
|
19
|
+
replaced: 'Reconnected',
|
|
20
|
+
needs_update: 'Sent away to update',
|
|
21
|
+
revoked: 'Access revoked'
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Why a visit ended, with what the relay couldn't know: an agent whose keys were revoked
|
|
26
|
+
* while it was in, or before it would have come back, ended because of that.
|
|
27
|
+
*/
|
|
28
|
+
export function endReasonOf (v, revokedAt) {
|
|
29
|
+
if (v.endedAt == null) return null
|
|
30
|
+
const r = v.endReason || null
|
|
31
|
+
if (revokedAt == null || revokedAt < v.startedAt) return r
|
|
32
|
+
if (r === 'idle') return revokedAt <= v.endedAt + HOSTED_QUIET_MS ? 'revoked' : r
|
|
33
|
+
if (r === 'pass_expired' || r === 'disconnected' || r == null) return revokedAt <= v.endedAt ? 'revoked' : r
|
|
34
|
+
return r
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The trail: visits newest first, each with its actions oldest first.
|
|
39
|
+
* `revoked` maps an agent account ('agent:<id>') to when its keys were revoked.
|
|
40
|
+
*/
|
|
41
|
+
export function auditTrail ({ visits, actions, revoked = new Map() }) {
|
|
42
|
+
const byVisit = new Map()
|
|
43
|
+
for (const a of actions) {
|
|
44
|
+
if (!byVisit.has(a.visitStartId)) byVisit.set(a.visitStartId, [])
|
|
45
|
+
byVisit.get(a.visitStartId).push({ at: a.at, action: a.action, target: a.target || '' })
|
|
46
|
+
}
|
|
47
|
+
return [...visits]
|
|
48
|
+
.sort((a, b) => b.startedAt - a.startedAt)
|
|
49
|
+
.map((v) => ({
|
|
50
|
+
account: v.account,
|
|
51
|
+
name: v.accountName,
|
|
52
|
+
kind: v.kind,
|
|
53
|
+
via: v.via || null,
|
|
54
|
+
tool: v.tool || null,
|
|
55
|
+
startedAt: v.startedAt,
|
|
56
|
+
endedAt: v.endedAt ?? null,
|
|
57
|
+
endReason: endReasonOf(v, revoked.get(v.account) ?? null),
|
|
58
|
+
actions: byVisit.get(v.eventStartId) || [] // already oldest first, in arrival order
|
|
59
|
+
}))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const iso = (t) => (t == null ? '' : new Date(t).toISOString())
|
|
63
|
+
const cell = (x) => {
|
|
64
|
+
const s = String(x ?? '')
|
|
65
|
+
// A cell a spreadsheet would run as a formula is quoted as text.
|
|
66
|
+
const safe = /^[=+\-@\t\r]/.test(s) ? `'${s}` : s
|
|
67
|
+
return /[",\n\r]/.test(safe) ? `"${safe.replace(/"/g, '""')}"` : safe
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The trail as CSV, one row per event (joined, an action, left), oldest first, times in UTC. */
|
|
71
|
+
export function auditCsv (trail) {
|
|
72
|
+
const rows = []
|
|
73
|
+
for (const v of trail) {
|
|
74
|
+
const who = [v.name, v.account, v.kind]
|
|
75
|
+
const how = [v.via || '', v.tool || '']
|
|
76
|
+
rows.push([v.startedAt, ...who, 'joined', '', ...how])
|
|
77
|
+
for (const a of v.actions) rows.push([a.at, ...who, a.action, a.target, ...how])
|
|
78
|
+
if (v.endedAt != null) rows.push([v.endedAt, ...who, 'left', v.endReason || '', ...how])
|
|
79
|
+
}
|
|
80
|
+
rows.sort((a, b) => a[0] - b[0])
|
|
81
|
+
const head = ['time_utc', 'member', 'account', 'kind', 'event', 'detail', 'via', 'tool']
|
|
82
|
+
return [head, ...rows.map(([t, ...rest]) => [iso(t), ...rest])].map((r) => r.map(cell).join(',')).join('\r\n') + '\r\n'
|
|
83
|
+
}
|
package/src/api/auth.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Website users sign in with Supabase; the API trusts a request from them when its
|
|
2
|
+
// bearer token is a JWT signed by the project's keys (fetched from its JWKS).
|
|
3
|
+
import { createRemoteJWKSet, jwtVerify } from 'jose'
|
|
4
|
+
|
|
5
|
+
export function createUserVerifier ({ supabaseUrl, jwks }) {
|
|
6
|
+
const base = String(supabaseUrl).replace(/\/+$/, '')
|
|
7
|
+
const keys = jwks || createRemoteJWKSet(new URL(`${base}/auth/v1/.well-known/jwks.json`))
|
|
8
|
+
return async (token) => {
|
|
9
|
+
if (!token) return null
|
|
10
|
+
try {
|
|
11
|
+
const { payload } = await jwtVerify(token, keys, { issuer: `${base}/auth/v1`, audience: 'authenticated' })
|
|
12
|
+
return payload.sub ? { userId: payload.sub, email: payload.email || '' } : null
|
|
13
|
+
} catch {
|
|
14
|
+
return null
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Public mail providers: anyone can get an address there, so an org can never
|
|
2
|
+
// claim one of these domains for join requests.
|
|
3
|
+
const PUBLIC = new Set([
|
|
4
|
+
'gmail.com', 'googlemail.com', 'outlook.com', 'hotmail.com', 'live.com', 'msn.com', 'icloud.com', 'me.com', 'mac.com',
|
|
5
|
+
'aol.com', 'proton.me', 'protonmail.com', 'pm.me', 'mail.com', 'zoho.com', 'zohomail.com', 'fastmail.com', 'fastmail.fm',
|
|
6
|
+
'hey.com', 'qq.com', '163.com', '126.com', 'yeah.net', 'sina.com', 'naver.com', 'daum.net', 'mail.ru', 'inbox.ru', 'list.ru',
|
|
7
|
+
'bk.ru', 'rambler.ru', 'web.de', 't-online.de', 'tutanota.com', 'tuta.io', 'hushmail.com', 'rediffmail.com', 'ymail.com',
|
|
8
|
+
'rocketmail.com', 'outlook.co.uk', 'hotmail.co.uk', 'live.co.uk', 'duck.com', 'mailbox.org', 'posteo.de'
|
|
9
|
+
])
|
|
10
|
+
// Providers with a domain per country: yahoo.co.uk, gmx.de, yandex.ru, ...
|
|
11
|
+
const FAMILIES = /^(yahoo|gmx|yandex)\.[a-z]{2,}(\.[a-z]{2,})?$/
|
|
12
|
+
const DOMAIN = /^(?=.{4,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/
|
|
13
|
+
|
|
14
|
+
export function isDomain (d) {
|
|
15
|
+
return DOMAIN.test(String(d || ''))
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The lowercase domain of an email address, or '' if it doesn't have a real one. */
|
|
19
|
+
export function emailDomain (email) {
|
|
20
|
+
const s = String(email || '').trim().toLowerCase()
|
|
21
|
+
const at = s.lastIndexOf('@')
|
|
22
|
+
if (at < 1) return ''
|
|
23
|
+
const d = s.slice(at + 1).replace(/\.$/, '')
|
|
24
|
+
return isDomain(d) ? d : ''
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function isPublicDomain (domain) {
|
|
28
|
+
const d = String(domain || '').trim().toLowerCase().replace(/\.$/, '')
|
|
29
|
+
return PUBLIC.has(d) || FAMILIES.test(d)
|
|
30
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Rules for a workspace file's path and name. Pure.
|
|
2
|
+
import { HttpError, stripInvisible } from './http.js'
|
|
3
|
+
|
|
4
|
+
const MAX_PATH = 500
|
|
5
|
+
const MIMES = {
|
|
6
|
+
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml',
|
|
7
|
+
mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime', mp3: 'audio/mpeg', wav: 'audio/wav', m4a: 'audio/mp4',
|
|
8
|
+
pdf: 'application/pdf', csv: 'text/csv', txt: 'text/plain', md: 'text/markdown', json: 'application/json',
|
|
9
|
+
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', xls: 'application/vnd.ms-excel',
|
|
10
|
+
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', zip: 'application/zip'
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** 'cuts/teaser.mp4': trimmed, single slashes, no '.' or '..' segments, no backslashes or control characters. */
|
|
14
|
+
export function cleanFilePath (value) {
|
|
15
|
+
const str = String(value ?? '')
|
|
16
|
+
// Checked on the raw string: stripInvisible would otherwise quietly erase the
|
|
17
|
+
// very control characters this is meant to catch.
|
|
18
|
+
if (/[\\\u0000-\u001f\u007f]/.test(str)) throw new HttpError(400, 'That path has characters a file name cannot have.')
|
|
19
|
+
const raw = stripInvisible(str).join('').trim()
|
|
20
|
+
const parts = raw.split('/').map((p) => p.trim()).filter((p) => p !== '')
|
|
21
|
+
if (!parts.length) throw new HttpError(400, 'Give the file a path.')
|
|
22
|
+
if (parts.some((p) => p === '.' || p === '..')) throw new HttpError(400, 'A path cannot contain . or .. parts.')
|
|
23
|
+
const out = parts.join('/')
|
|
24
|
+
if (out.length > MAX_PATH) throw new HttpError(400, `Keep the path under ${MAX_PATH} characters.`)
|
|
25
|
+
return out
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const parentOf = (p) => (p.includes('/') ? p.slice(0, p.lastIndexOf('/')) : '')
|
|
29
|
+
export const nameOf = (p) => p.slice(p.lastIndexOf('/') + 1)
|
|
30
|
+
export const mimeOf = (name) => MIMES[String(name).toLowerCase().split('.').pop()] || 'application/octet-stream'
|
|
31
|
+
export const isTextual = (mime) => /^text\//.test(mime) || mime === 'application/json'
|
|
32
|
+
|
|
33
|
+
/** A Content-Disposition header for any file name. Header values must be Latin-1 (Node
|
|
34
|
+
* throws on anything else), so `filename` is an ASCII fallback (anything outside printable
|
|
35
|
+
* ASCII, a quote or a backslash becomes `_`) and `filename*` carries the real name (RFC 6266). */
|
|
36
|
+
export function contentDisposition (kind, name) {
|
|
37
|
+
const ascii = [...String(name)].map((c) => (/^[\x20-\x7e]$/.test(c) && c !== '"' && c !== '\\' ? c : '_')).join('')
|
|
38
|
+
const utf8 = encodeURIComponent(String(name)).replace(/['()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)
|
|
39
|
+
return `${kind}; filename="${ascii}"; filename*=UTF-8''${utf8}`
|
|
40
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Where workspace files' bytes live. The API only hands out short-lived links: apps upload
|
|
2
|
+
// and download straight to storage (Supabase) or, without storage settings, to the API's
|
|
3
|
+
// own disk through signed links the API serves itself.
|
|
4
|
+
import fs from 'node:fs'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import crypto from 'node:crypto'
|
|
7
|
+
import { createClient } from '@supabase/supabase-js'
|
|
8
|
+
|
|
9
|
+
export const LINK_MS = 10 * 60 * 1000
|
|
10
|
+
const KEY = /^[0-9a-f-]{36}\/[0-9a-f-]{36}\/\d+$/
|
|
11
|
+
|
|
12
|
+
const checkKey = (key) => { if (!KEY.test(String(key))) throw new Error('not a storage key'); return key }
|
|
13
|
+
|
|
14
|
+
/** Files on the API's disk, reached through HMAC-signed links the API serves (tests, --memory, self-hosting). */
|
|
15
|
+
export class DiskStore {
|
|
16
|
+
constructor (dir, { now = Date.now, signing = crypto.randomBytes(32) } = {}) {
|
|
17
|
+
this.dir = dir
|
|
18
|
+
this.now = now
|
|
19
|
+
this.signing = signing
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
file (key) { return path.join(this.dir, ...checkKey(key).split('/')) }
|
|
23
|
+
|
|
24
|
+
// `name` and `type` are only ever non-empty on a GET link; folding them into the
|
|
25
|
+
// signature means a link holder can't change the download's filename or content type
|
|
26
|
+
// (e.g. to have the bytes served back as `text/html` on the API's own origin).
|
|
27
|
+
sign (key, method, exp, size, name = '', type = '') {
|
|
28
|
+
return crypto.createHmac('sha256', this.signing).update(`${method} ${key} ${exp} ${size ?? ''} ${name} ${type}`).digest('hex')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
verify (key, method, exp, sig, size, name = '', type = '') {
|
|
32
|
+
if (!KEY.test(String(key)) || !(Number(exp) > this.now())) return false
|
|
33
|
+
const want = Buffer.from(this.sign(key, method, exp, size, name, type), 'hex')
|
|
34
|
+
const got = Buffer.from(String(sig || ''), 'hex')
|
|
35
|
+
return got.length === want.length && crypto.timingSafeEqual(got, want)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async uploadTarget (key, size) {
|
|
39
|
+
checkKey(key)
|
|
40
|
+
const exp = this.now() + LINK_MS
|
|
41
|
+
return { method: 'PUT', url: `/v1/file-data/${key}?m=PUT&exp=${exp}&sig=${this.sign(key, 'PUT', exp, size)}&n=${size}`, headers: {} }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async downloadTarget (key, { name = '', type = '' } = {}) {
|
|
45
|
+
checkKey(key)
|
|
46
|
+
const exp = this.now() + LINK_MS
|
|
47
|
+
const sig = this.sign(key, 'GET', exp, undefined, name, type)
|
|
48
|
+
const q = new URLSearchParams({ m: 'GET', exp: String(exp), sig, name, type })
|
|
49
|
+
return { url: `/v1/file-data/${key}?${q}` }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async exists (key) {
|
|
53
|
+
try { return { size: fs.statSync(this.file(key)).size } } catch { return null }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async remove (keys) { for (const k of keys) fs.rmSync(this.file(k), { force: true }) }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Files in a private Supabase Storage bucket in the Quilt Files project. */
|
|
60
|
+
export class SupabaseStore {
|
|
61
|
+
constructor ({ url, key, bucket, client }) {
|
|
62
|
+
const c = client || createClient(url, key, { auth: { persistSession: false, autoRefreshToken: false } })
|
|
63
|
+
this.bucket = c.storage.from(bucket)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async uploadTarget (key, size) {
|
|
67
|
+
const { data, error } = await this.bucket.createSignedUploadUrl(checkKey(key), { upsert: false })
|
|
68
|
+
if (error) throw new Error(`storage: ${error.message}`)
|
|
69
|
+
return { method: 'PUT', url: data.signedUrl, headers: {} }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async downloadTarget (key, { name = '', type = '' } = {}) {
|
|
73
|
+
const { data, error } = await this.bucket.createSignedUrl(checkKey(key), LINK_MS / 1000, name ? { download: name } : {})
|
|
74
|
+
if (error) throw new Error(`storage: ${error.message}`)
|
|
75
|
+
return { url: data.signedUrl }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async exists (key) {
|
|
79
|
+
checkKey(key)
|
|
80
|
+
const prefix = key.slice(0, key.lastIndexOf('/'))
|
|
81
|
+
const leaf = key.slice(key.lastIndexOf('/') + 1)
|
|
82
|
+
const { data, error } = await this.bucket.list(prefix, { limit: 100, search: leaf })
|
|
83
|
+
if (error) throw new Error(`storage: ${error.message}`)
|
|
84
|
+
const hit = (data || []).find((f) => f.name === leaf)
|
|
85
|
+
return hit ? { size: Number(hit.metadata?.size || 0) } : null
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async remove (keys) {
|
|
89
|
+
if (!keys.length) return
|
|
90
|
+
const { error } = await this.bucket.remove(keys.map(checkKey))
|
|
91
|
+
if (error) throw new Error(`storage: ${error.message}`)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function makeFileStore (cfg, dir) {
|
|
96
|
+
if (!cfg.storageUrl !== !cfg.storageKey) throw new Error('Workspace file storage is half set up: set both QUILT_STORAGE_URL and QUILT_STORAGE_KEY to use Supabase Storage, or neither to keep files on this server\'s disk.')
|
|
97
|
+
if (cfg.storageUrl && cfg.storageKey) return new SupabaseStore({ url: cfg.storageUrl, key: cfg.storageKey, bucket: cfg.storageBucket || 'workspace-files' })
|
|
98
|
+
return new DiskStore(dir)
|
|
99
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// The Fly Machines API, for people's cloud AI machines (routes/ai-vm.js): one machine and one
|
|
2
|
+
// volume per person, in the app AI_VM_APP (never the relay's). Plain fetch, so tests point it
|
|
3
|
+
// at a fake server. See https://fly.io/docs/machines/api/.
|
|
4
|
+
|
|
5
|
+
export const FLY_API = 'https://api.machines.dev/v1'
|
|
6
|
+
// Small enough to suspend (Fly suspends machines of 2 GB or less) and to cost little.
|
|
7
|
+
export const GUEST = { cpu_kind: 'shared', cpus: 2, memory_mb: 2048 }
|
|
8
|
+
export const VOLUME_GB = 5
|
|
9
|
+
const ID = /^[A-Za-z0-9_-]{1,64}$/
|
|
10
|
+
|
|
11
|
+
const needId = (id, what) => {
|
|
12
|
+
if (!ID.test(String(id || ''))) throw new Error(`bad ${what} id`)
|
|
13
|
+
return id
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A client for one Fly app. `token` is FLY_API_TOKEN; `image` the AI machine image
|
|
18
|
+
* (registry.fly.io/<app>:<tag>); `base` and `fetch` are for tests.
|
|
19
|
+
*/
|
|
20
|
+
export function flyMachines ({ token, app, image, region = 'iad', base = FLY_API, fetch: fetchImpl = globalThis.fetch }) {
|
|
21
|
+
const root = `${String(base).replace(/\/+$/, '')}/apps/${encodeURIComponent(app)}`
|
|
22
|
+
|
|
23
|
+
async function call (method, path, body, { gone = false } = {}) {
|
|
24
|
+
let res
|
|
25
|
+
try {
|
|
26
|
+
res = await fetchImpl(root + path, {
|
|
27
|
+
method,
|
|
28
|
+
headers: { authorization: `Bearer ${token}`, ...(body ? { 'content-type': 'application/json' } : {}) },
|
|
29
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
30
|
+
signal: AbortSignal.timeout(60_000)
|
|
31
|
+
})
|
|
32
|
+
} catch (err) {
|
|
33
|
+
throw Object.assign(new Error(`Fly did not answer (${err.cause?.code || err.message})`), { status: 502 })
|
|
34
|
+
}
|
|
35
|
+
const data = await res.json().catch(() => null)
|
|
36
|
+
// Destroying what is already gone is done.
|
|
37
|
+
if (gone && res.status === 404) return null
|
|
38
|
+
if (!res.ok) throw Object.assign(new Error(`Fly: ${(data && (data.error || data.message)) || `answered ${res.status}`}`), { status: res.status })
|
|
39
|
+
return data
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
app,
|
|
44
|
+
image,
|
|
45
|
+
region,
|
|
46
|
+
/** A new volume for one person's machine; encrypted, in this app's region. */
|
|
47
|
+
createVolume: async ({ name, sizeGb = VOLUME_GB }) => call('POST', '/volumes', { name, region, size_gb: sizeGb, encrypted: true }),
|
|
48
|
+
/** A machine running the AI image with the volume as /data. It restarts on a crash, never on its own after a stop. */
|
|
49
|
+
createMachine: async ({ name, volumeId, env = {} }) => call('POST', '/machines', {
|
|
50
|
+
name,
|
|
51
|
+
region,
|
|
52
|
+
config: {
|
|
53
|
+
image,
|
|
54
|
+
env,
|
|
55
|
+
guest: GUEST,
|
|
56
|
+
mounts: [{ volume: needId(volumeId, 'volume'), path: '/data' }],
|
|
57
|
+
restart: { policy: 'on-failure', max_retries: 5 },
|
|
58
|
+
auto_destroy: false,
|
|
59
|
+
metadata: { quilt: 'ai-vm' }
|
|
60
|
+
}
|
|
61
|
+
}),
|
|
62
|
+
getMachine: async (id) => call('GET', `/machines/${needId(id, 'machine')}`),
|
|
63
|
+
/** Starts a stopped machine, or resumes a suspended one where it was. */
|
|
64
|
+
start: async (id) => call('POST', `/machines/${needId(id, 'machine')}/start`),
|
|
65
|
+
suspend: async (id) => call('POST', `/machines/${needId(id, 'machine')}/suspend`),
|
|
66
|
+
stop: async (id) => call('POST', `/machines/${needId(id, 'machine')}/stop`),
|
|
67
|
+
destroyMachine: async (id) => call('DELETE', `/machines/${needId(id, 'machine')}?force=true`, null, { gone: true }),
|
|
68
|
+
destroyVolume: async (id) => call('DELETE', `/volumes/${needId(id, 'volume')}`, null, { gone: true })
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The client from the API's environment, or null when cloud AI machines aren't set up here. */
|
|
73
|
+
export function flyFromEnv (env = process.env) {
|
|
74
|
+
if (!env.FLY_API_TOKEN || !env.AI_VM_APP || !env.AI_VM_IMAGE) return null
|
|
75
|
+
return flyMachines({ token: env.FLY_API_TOKEN, app: env.AI_VM_APP, image: env.AI_VM_IMAGE, region: env.AI_VM_REGION || 'iad' })
|
|
76
|
+
}
|