heyquilt 0.3.121 → 0.3.138
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/RELEASES.md +292 -17
- package/bin/quilt.js +72 -5
- package/desktop/main.js +16 -2
- package/package.json +3 -1
- package/src/agent-task-workflow.js +2 -2
- package/src/agents/common.js +13 -0
- package/src/ai-vm.js +9 -1
- package/src/api/activity.js +5 -0
- package/src/api/memory-store.js +21 -10
- package/src/api/notify.js +134 -0
- package/src/api/routes/browsers.js +29 -0
- package/src/api/routes/github.js +0 -13
- package/src/api/routes/relay.js +26 -3
- package/src/api/routes/seam-connect.js +103 -0
- package/src/api/routes/session-migrate.js +39 -0
- package/src/api/routes/sessions.js +73 -1
- package/src/api/server.js +46 -12
- package/src/api/supabase-store.js +53 -4
- package/src/autowork.js +182 -16
- package/src/connection.js +39 -9
- package/src/control.js +139 -13
- package/src/force-date.js +27 -0
- package/src/fsutil.js +13 -3
- package/src/gitstate.js +38 -2
- package/src/history.js +25 -1
- package/src/mcp.js +93 -7
- package/src/merge3.js +11 -3
- package/src/now-summary.js +113 -0
- package/src/pathrules.js +12 -0
- package/src/protocol.js +10 -2
- package/src/quilt-default.js +84 -0
- package/src/quilt-history.js +106 -0
- package/src/quilt-remote.js +78 -0
- package/src/relay-commit.js +5 -0
- package/src/relay-lease.js +6 -2
- package/src/relay-mcp.js +172 -8
- package/src/relay-outbound.js +1 -1
- package/src/relay-prune.js +4 -1
- package/src/release-words.js +2 -2
- package/src/runner.js +20 -7
- package/src/seam-access.js +97 -0
- package/src/seam-claims.js +53 -0
- package/src/seam-url.js +15 -0
- package/src/server.js +1767 -33
- package/src/session-follow.js +515 -0
- package/src/session-layout3.js +466 -0
- package/src/session-migrate.js +123 -0
- package/src/session-quilt.js +145 -0
- package/src/session-seam.js +284 -0
- package/src/session.js +899 -46
- package/src/source-live/bring-in.js +283 -0
- package/src/source-live/live-block.js +507 -35
- package/src/source-live/live-square.js +17 -6
- package/src/status.js +5 -0
- package/src/ui/agent-guide.js +1 -1
- package/src/ui/app.css +85 -20
- package/src/ui/app.js +4 -1
- package/src/ui/chat.js +9 -3
- package/src/ui/cloud-ai-model.js +13 -0
- package/src/ui/cloud-ai.js +10 -2
- package/src/ui/common.js +6 -2
- package/src/ui/fileview.js +20 -7
- package/src/ui/force-words.js +19 -0
- package/src/ui/git-rule.js +9 -0
- package/src/ui/home.js +0 -147
- package/src/ui/loom-model.js +15 -1
- package/src/ui/quiltview.js +213 -0
- package/src/ui/seam-model.js +251 -0
- package/src/ui/session.js +413 -31
- package/src/ui/voice-call.js +83 -14
- package/src/ui/voice-model.js +39 -0
- package/src/ui/voice.js +167 -17
- package/src/ui-server.js +80 -28
- package/src/update-check.js +25 -1
- package/src/voice.js +13 -6
- package/src/git.js +0 -147
- package/src/quilts/AGENTS.md +0 -1
- package/src/quilts/CLAUDE.md +0 -1
- package/src/quilts/archiver.js +0 -175
- package/src/quilts/autorevert.js +0 -49
- package/src/quilts/bisect-run.js +0 -31
- package/src/quilts/blame.js +0 -103
- package/src/quilts/block.js +0 -347
- package/src/quilts/bucket.js +0 -25
- package/src/quilts/cbor.js +0 -92
- package/src/quilts/claims.js +0 -144
- package/src/quilts/cli.js +0 -449
- package/src/quilts/client.js +0 -128
- package/src/quilts/conflict-format.js +0 -190
- package/src/quilts/describe.js +0 -266
- package/src/quilts/dogfood.js +0 -82
- package/src/quilts/errors.js +0 -8
- package/src/quilts/events.js +0 -160
- package/src/quilts/export-git.js +0 -130
- package/src/quilts/export.js +0 -81
- package/src/quilts/fastmerge.js +0 -41
- package/src/quilts/hash.js +0 -44
- package/src/quilts/history.js +0 -266
- package/src/quilts/ids.js +0 -12
- package/src/quilts/import-git.js +0 -291
- package/src/quilts/import.js +0 -134
- package/src/quilts/index.js +0 -72
- package/src/quilts/limits.js +0 -67
- package/src/quilts/lines.js +0 -49
- package/src/quilts/live.js +0 -50
- package/src/quilts/merge-smart.js +0 -114
- package/src/quilts/merge.js +0 -486
- package/src/quilts/objects.js +0 -144
- package/src/quilts/pack.js +0 -118
- package/src/quilts/records.js +0 -211
- package/src/quilts/resolutions.js +0 -69
- package/src/quilts/reviews.js +0 -195
- package/src/quilts/routes.js +0 -126
- package/src/quilts/service.js +0 -923
- package/src/quilts/setup.js +0 -34
- package/src/quilts/signing.js +0 -149
- package/src/quilts/squares.js +0 -239
- package/src/quilts/store-memory.js +0 -683
- package/src/quilts/store-rpc.js +0 -79
- package/src/quilts/store-supabase.js +0 -13
- package/src/quilts/tags.js +0 -64
- package/src/quilts/textdiff.js +0 -96
- package/src/quilts/tokens.js +0 -66
- package/src/quilts/tools.js +0 -375
- package/src/quilts/tree.js +0 -351
- package/src/quilts/webhook.js +0 -116
- package/src/quilts/window.js +0 -119
- package/src/quilts/xdiff.js +0 -435
- package/src/ui/branches.js +0 -111
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// "Connect Seam": a Quilt person links their own Seam account to Quilt, the way any app connects
|
|
2
|
+
// to Seam (Seam's OAuth 2, authorization code with PKCE). Quilt never shares accounts with Seam:
|
|
3
|
+
// it keeps the person's Seam token and calls Seam's HTTP API with it. Off unless Quilt is
|
|
4
|
+
// registered as a Seam app (seamClientId, env QUILT_SEAM_CLIENT_ID).
|
|
5
|
+
//
|
|
6
|
+
// The PKCE verifier is never stored: `state` carries who asked and until when, signed with
|
|
7
|
+
// Quilt's secret, and the verifier is an HMAC of the state, so any API machine can finish it.
|
|
8
|
+
import crypto from 'node:crypto'
|
|
9
|
+
import { HttpError, Raw } from '../http.js'
|
|
10
|
+
|
|
11
|
+
export const CONNECT_MS = 10 * 60_000
|
|
12
|
+
const REFRESH_EARLY_MS = 60_000
|
|
13
|
+
|
|
14
|
+
const b64 = (b) => Buffer.from(b).toString('base64url')
|
|
15
|
+
const mac = (secret, text) => crypto.createHmac('sha256', secret).update(text).digest('base64url')
|
|
16
|
+
const same = (a, b) => a.length === b.length && crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b))
|
|
17
|
+
const page = (status, title, text) => new Raw(status, `<!doctype html><meta charset="utf-8"><title>${title}</title><body style="font:16px system-ui;margin:4em auto;max-width:32em"><h1>${title}</h1><p>${text}</p></body>`, 'text/html; charset=utf-8')
|
|
18
|
+
|
|
19
|
+
export function seamConnectRoutes ({ store, person, now = Date.now, apiUrl, seamUrl, seamSiteUrl = 'https://seam.heyquilt.com', clientId, secret, fetch = globalThis.fetch, log = () => {} }) {
|
|
20
|
+
if (!clientId || !secret || !seamUrl || !store.seamLink) return []
|
|
21
|
+
const seam = String(seamUrl).replace(/\/+$/, '')
|
|
22
|
+
const site = String(seamSiteUrl).replace(/\/+$/, '')
|
|
23
|
+
const redirectUri = `${String(apiUrl).replace(/\/+$/, '')}/v1/seam/connect/callback`
|
|
24
|
+
const verifierOf = (state) => mac(secret, `seam-pkce\0${state}`)
|
|
25
|
+
const userOf = (p) => p.userId || p.id
|
|
26
|
+
|
|
27
|
+
function makeState (userId) {
|
|
28
|
+
const body = b64(JSON.stringify({ u: userId, e: now() + CONNECT_MS, n: crypto.randomBytes(12).toString('base64url') }))
|
|
29
|
+
return `${body}.${mac(secret, `seam-state\0${body}`)}`
|
|
30
|
+
}
|
|
31
|
+
function readState (state) {
|
|
32
|
+
const [body, sig] = String(state || '').split('.')
|
|
33
|
+
if (!body || !sig || !same(sig, mac(secret, `seam-state\0${body}`))) return null
|
|
34
|
+
try {
|
|
35
|
+
const s = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'))
|
|
36
|
+
return s && s.e > now() ? s : null
|
|
37
|
+
} catch { return null }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function tokenCall (form) {
|
|
41
|
+
let res
|
|
42
|
+
try { res = await fetch(`${seam}/oauth/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' }, body: new URLSearchParams({ client_id: clientId, ...form }).toString(), signal: AbortSignal.timeout(10_000) }) } catch { throw new HttpError(503, 'Seam could not be reached; try again') }
|
|
43
|
+
const data = await res.json().catch(() => null)
|
|
44
|
+
if (!res.ok || !data?.access_token) return { error: data?.error || `seam_${res.status}`, detail: data?.error_description || '' }
|
|
45
|
+
return { access: data.access_token, refresh: data.refresh_token, scope: data.scope || '', expiresAt: now() + Number(data.expires_in || 3600) * 1000 }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The person's Seam access token, refreshed when it is about to end; null when not connected. */
|
|
49
|
+
async function freshToken (userId) {
|
|
50
|
+
const link = await store.seamLink(userId)
|
|
51
|
+
if (!link) return null
|
|
52
|
+
if (link.expiresAt - REFRESH_EARLY_MS > now()) return link
|
|
53
|
+
const t = await tokenCall({ grant_type: 'refresh_token', refresh_token: link.refresh })
|
|
54
|
+
if (t.error) {
|
|
55
|
+
// Seam ended the grant (revoked on seam.heyquilt.com, or the token was reused): connect again.
|
|
56
|
+
if (t.error === 'invalid_grant' || t.error === 'invalid_client') { await store.deleteSeamLink(userId); return null }
|
|
57
|
+
throw new HttpError(503, 'Seam could not refresh your connection; try again')
|
|
58
|
+
}
|
|
59
|
+
return store.putSeamLink(userId, { ...t, connectedAt: link.connectedAt })
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return [
|
|
63
|
+
// { scope? } → { url }: the page on Seam's site where the person allows Quilt.
|
|
64
|
+
['POST', /^\/v1\/seam\/connect$/, async (req, body) => {
|
|
65
|
+
const p = await person(req)
|
|
66
|
+
const scope = String(body?.scope || 'quilts:write').trim()
|
|
67
|
+
const state = makeState(userOf(p))
|
|
68
|
+
const challenge = crypto.createHash('sha256').update(verifierOf(state)).digest('base64url')
|
|
69
|
+
const q = new URLSearchParams({ client_id: clientId, redirect_uri: redirectUri, response_type: 'code', scope, state, code_challenge: challenge, code_challenge_method: 'S256' })
|
|
70
|
+
return { url: `${site}/oauth/authorize?${q}` }
|
|
71
|
+
}],
|
|
72
|
+
// Seam sends the browser back here with a code (or error=access_denied).
|
|
73
|
+
['GET', /^\/v1\/seam\/connect\/callback$/, async (req) => {
|
|
74
|
+
const q = new URL(req.url, 'http://x').searchParams
|
|
75
|
+
const s = readState(q.get('state'))
|
|
76
|
+
if (!s) return page(400, 'Seam was not connected', 'This link expired or was not made by Quilt. Start again from Quilt.')
|
|
77
|
+
if (q.get('error')) return page(200, 'Seam was not connected', 'You chose not to connect Seam. You can connect it from Quilt any time.')
|
|
78
|
+
const t = await tokenCall({ grant_type: 'authorization_code', code: q.get('code') || '', redirect_uri: redirectUri, code_verifier: verifierOf(q.get('state')) })
|
|
79
|
+
if (t.error) { log(`seam connect: ${t.error} ${t.detail}`); return page(400, 'Seam was not connected', 'Seam did not accept this sign-in. Start again from Quilt.') }
|
|
80
|
+
await store.putSeamLink(s.u, { ...t, connectedAt: now() })
|
|
81
|
+
return page(200, 'Seam is connected', 'Quilt can now use Seam as you. You can close this tab.')
|
|
82
|
+
}],
|
|
83
|
+
['GET', /^\/v1\/seam\/connection$/, async (req) => {
|
|
84
|
+
const link = await store.seamLink(userOf(await person(req)))
|
|
85
|
+
return link ? { connected: true, scope: link.scope, connectedAt: link.connectedAt } : { connected: false }
|
|
86
|
+
}],
|
|
87
|
+
// The person's own Seam token, for their app to call Seam's API with.
|
|
88
|
+
['GET', /^\/v1\/seam\/token$/, async (req) => {
|
|
89
|
+
const link = await freshToken(userOf(await person(req)))
|
|
90
|
+
if (!link) throw new HttpError(404, 'Seam is not connected: connect it first')
|
|
91
|
+
return { access_token: link.access, token_type: 'Bearer', expires_at: link.expiresAt, scope: link.scope, seam_url: seam }
|
|
92
|
+
}],
|
|
93
|
+
['DELETE', /^\/v1\/seam\/connection$/, async (req) => {
|
|
94
|
+
const userId = userOf(await person(req))
|
|
95
|
+
const link = await store.seamLink(userId)
|
|
96
|
+
if (link) {
|
|
97
|
+
await fetch(`${seam}/oauth/revoke`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ token: link.refresh }).toString(), signal: AbortSignal.timeout(10_000) }).catch(() => {})
|
|
98
|
+
await store.deleteSeamLink(userId)
|
|
99
|
+
}
|
|
100
|
+
return { connected: false }
|
|
101
|
+
}]
|
|
102
|
+
]
|
|
103
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Making a session a quilt (Seam's plans/quilt-source-control/16, "How a session becomes a quilt"):
|
|
2
|
+
// POST /v1/sessions/:room/migrate { dry_run, token, step }. The session's owner, signed in, asks here
|
|
3
|
+
// (step 'make' is "Make this a quilt": made and moved into its squares in one go; 'switch' moves the session's files into its squares, layout 3; 'unswitch' goes back;
|
|
4
|
+
// 'shadow' links a session with a git remote to the quilt its import fills, 16 step 1);
|
|
5
|
+
// the relay machine holding the room does the work (POST /quilt/migrate, with the relay secret),
|
|
6
|
+
// so this is the same operation as the MCP tool and `quilt session migrate`. Seam is called as the
|
|
7
|
+
// owner: with `token` (their Seam sign-in) when given, else the sign-in they used here. The relay
|
|
8
|
+
// refuses while Seam is off for it (QUILT_SOURCE_URL), so nothing changes until that flag is set.
|
|
9
|
+
import { HttpError } from '../http.js'
|
|
10
|
+
|
|
11
|
+
const ROOM = /^[A-Za-z0-9_-]{1,64}$/
|
|
12
|
+
const NO_SESSION = 'no such session'
|
|
13
|
+
const MIGRATE_TIMEOUT_MS = 5 * 60 * 1000 // a large session's patch 1 and squares take a while
|
|
14
|
+
|
|
15
|
+
export function sessionMigrateRoutes ({ store, person, bearer, relaySecret, cluster }) {
|
|
16
|
+
return [
|
|
17
|
+
['POST', /^\/v1\/sessions\/([^/]+)\/migrate$/, async (req, body, [room]) => {
|
|
18
|
+
const account = `person:${(await person(req)).userId}`
|
|
19
|
+
if (!ROOM.test(room)) throw new HttpError(404, NO_SESSION)
|
|
20
|
+
const session = await store.sessionByRoom(room).catch(() => null)
|
|
21
|
+
if (!session) throw new HttpError(404, NO_SESSION)
|
|
22
|
+
if (session.ownerAccount !== account) throw new HttpError(403, 'Only the session owner can make it a quilt.')
|
|
23
|
+
if (!relaySecret) throw new HttpError(503, 'the relay is not set up on this server')
|
|
24
|
+
const dry = body && body.dry_run === true
|
|
25
|
+
const token = body && typeof body.token === 'string' && body.token ? body.token : bearer(req)
|
|
26
|
+
const where = await store.relayRoom(room).catch(() => null)
|
|
27
|
+
const step = body && (body.step === 'make' || body.step === 'switch' || body.step === 'unswitch' || body.step === 'shadow') ? body.step : null
|
|
28
|
+
// step 'shadow' (16 step 1): `quilt` names the quilt an import of the session's git remote fills; null unlinks.
|
|
29
|
+
const quilt = step === 'shadow' && typeof body.quilt === 'string' && body.quilt ? body.quilt.slice(0, 200) : null
|
|
30
|
+
const ask = { room, account, ...(dry ? { dry_run: true } : { token }), ...(step && !dry ? { step } : {}), ...(step === 'shadow' && !dry ? { quilt } : {}), ...(step === 'unswitch' && !dry && Array.isArray(body.carried) ? { carried: body.carried.filter((x) => typeof x === 'string').slice(0, 200) } : {}) }
|
|
31
|
+
try {
|
|
32
|
+
return await cluster.callRelay((where && where.machineId) || '', 'POST', '/quilt/migrate', ask, { timeoutMs: MIGRATE_TIMEOUT_MS })
|
|
33
|
+
} catch (err) {
|
|
34
|
+
const said = String(err.message).replace(/^relay .*? answered \d+: /, '')
|
|
35
|
+
throw new HttpError(err.status && err.status < 500 ? err.status : 502, said)
|
|
36
|
+
}
|
|
37
|
+
}]
|
|
38
|
+
]
|
|
39
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// who were there at the same time as you.
|
|
4
4
|
import { HttpError, Raw } from '../http.js'
|
|
5
5
|
import { auditTrail, auditCsv, MAX_ACTIONS } from '../audit.js'
|
|
6
|
-
import { summarize, collaborators, myVisits, isTimeZone, weekStart, monthStart, MAX_SESSIONS } from '../activity.js'
|
|
6
|
+
import { summarize, collaborators, myVisits, isTimeZone, weekStart, monthStart, MAX_SESSIONS, DAY_MS } from '../activity.js'
|
|
7
7
|
import { cleanSessionName, BAD_SESSION_NAME } from '../../session-name.js'
|
|
8
8
|
|
|
9
9
|
const ROOM = /^[A-Za-z0-9_-]{1,64}$/
|
|
@@ -90,3 +90,75 @@ export function sessionRoutes ({ store, now, person }) {
|
|
|
90
90
|
['GET', /^\/v1\/me\/collaborators$/, async (req) => ({ collaborators: await collaboratorsOf(store, await me(req), now()) })]
|
|
91
91
|
]
|
|
92
92
|
}
|
|
93
|
+
|
|
94
|
+
export const NOW_CACHE_MS = 10 * 1000
|
|
95
|
+
export const NOW_TIMEOUT_MS = 5 * 1000
|
|
96
|
+
const NOW_ROOMS = 40
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Asks the relay machines holding an account's recent sessions for its summary of now, and merges their
|
|
100
|
+
* answers: (account, t) → { sessions, at, live, partial? }. The dashboard's route and the email notices
|
|
101
|
+
* (notify.js) both use it.
|
|
102
|
+
*/
|
|
103
|
+
export function nowAsker ({ store, cluster, relayOn = false, log = () => {} }) {
|
|
104
|
+
return async (account, t) => {
|
|
105
|
+
if (!relayOn) return { sessions: [], at: t, live: false }
|
|
106
|
+
// The sessions that can be live: the latest ones, and every one active in the last day.
|
|
107
|
+
const sessions = await store.accountSessions(account, { since: t - DAY_MS, limit: 20 })
|
|
108
|
+
const rooms = sessions.map((s) => s.room).slice(0, NOW_ROOMS)
|
|
109
|
+
const byMachine = new Map()
|
|
110
|
+
await Promise.all(rooms.map(async (room) => {
|
|
111
|
+
const placed = await store.relayRoom(room).catch(() => null)
|
|
112
|
+
const id = (placed && placed.machineId) || ''
|
|
113
|
+
if (!byMachine.has(id)) byMachine.set(id, [])
|
|
114
|
+
byMachine.get(id).push(room)
|
|
115
|
+
}))
|
|
116
|
+
let partial = false
|
|
117
|
+
const answers = await Promise.all([...byMachine].map(([id, list]) =>
|
|
118
|
+
cluster.callRelay(id, 'POST', '/now', { account, rooms: list }, { timeoutMs: NOW_TIMEOUT_MS })
|
|
119
|
+
.catch((err) => { partial = true; log(`now from relay ${id || ''}: ${err.message}`); return null })))
|
|
120
|
+
return { sessions: answers.flatMap((a) => (a && Array.isArray(a.sessions) ? a.sessions : [])), at: t, live: true, ...(partial ? { partial: true } : {}) }
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The website dashboard's summary of now (plans/dashboard ticket 9): for each of my sessions a relay
|
|
126
|
+
* holds, who is in it, what each AI is doing, the board in brief, held files and what needs me
|
|
127
|
+
* (src/now-summary.js on the relay). The relay machines holding my recent sessions are asked, at most
|
|
128
|
+
* once every NOW_CACHE_MS per person (the page asks every 15 to 30 seconds), and their answers merged.
|
|
129
|
+
* `relayOn` false (no relay or no relay secret): nothing to ask, so nothing is live.
|
|
130
|
+
*/
|
|
131
|
+
export function nowRoutes ({ store, now, person, cluster, relayOn = false, log = () => {} }) {
|
|
132
|
+
const cache = new Map() // account -> { at, out }
|
|
133
|
+
const ask = nowAsker({ store, cluster, relayOn, log })
|
|
134
|
+
return [
|
|
135
|
+
['GET', /^\/v1\/me\/now$/, async (req) => {
|
|
136
|
+
const account = `person:${(await person(req)).userId}`
|
|
137
|
+
const t = now()
|
|
138
|
+
const hit = cache.get(account)
|
|
139
|
+
if (hit && t - hit.at < NOW_CACHE_MS) return hit.out
|
|
140
|
+
const out = await ask(account, t)
|
|
141
|
+
cache.set(account, { at: t, out })
|
|
142
|
+
if (cache.size > 5000) for (const [k, v] of cache) if (t - v.at >= NOW_CACHE_MS) cache.delete(k)
|
|
143
|
+
return out
|
|
144
|
+
}],
|
|
145
|
+
|
|
146
|
+
// One session I was in, in more detail (its page's Activity tab): { session } or { session: null } when
|
|
147
|
+
// nobody is in it now. 404 for a session I was never in, whether or not it exists.
|
|
148
|
+
['GET', /^\/v1\/me\/now\/([^/]+)$/, async (req, body, [room]) => {
|
|
149
|
+
const account = `person:${(await person(req)).userId}`
|
|
150
|
+
if (!ROOM.test(room)) throw new HttpError(404, NO_SESSION)
|
|
151
|
+
const visits = await store.visitsInRooms([room])
|
|
152
|
+
if (!visits.some((v) => v.account === account)) throw new HttpError(404, NO_SESSION)
|
|
153
|
+
if (!relayOn) return { session: null, live: false }
|
|
154
|
+
const placed = await store.relayRoom(room).catch(() => null)
|
|
155
|
+
try {
|
|
156
|
+
const a = await cluster.callRelay((placed && placed.machineId) || '', 'POST', '/now', { account, rooms: [room], detail: true }, { timeoutMs: NOW_TIMEOUT_MS })
|
|
157
|
+
return { session: (a && Array.isArray(a.sessions) && a.sessions[0]) || null, live: true }
|
|
158
|
+
} catch (err) {
|
|
159
|
+
log(`now for ${room}: ${err.message}`)
|
|
160
|
+
return { session: null, live: true, partial: true }
|
|
161
|
+
}
|
|
162
|
+
}]
|
|
163
|
+
]
|
|
164
|
+
}
|
package/src/api/server.js
CHANGED
|
@@ -28,7 +28,10 @@ import crypto from 'node:crypto'
|
|
|
28
28
|
import { githubRoutes } from './routes/github.js'
|
|
29
29
|
import { githubApp, ensureWebhookSecret, redeliverFailed } from './github-app.js'
|
|
30
30
|
import { releaseSource } from './releases.js'
|
|
31
|
-
import { sessionRoutes } from './routes/sessions.js'
|
|
31
|
+
import { sessionRoutes, nowRoutes, nowAsker, overviewOf } from './routes/sessions.js'
|
|
32
|
+
import { notifier, NOTIFY_EVERY_MS } from './notify.js'
|
|
33
|
+
import { browserRoutes } from './routes/browsers.js'
|
|
34
|
+
import { sessionMigrateRoutes } from './routes/session-migrate.js'
|
|
32
35
|
import { accessTypeRoutes } from './routes/access-types.js'
|
|
33
36
|
import { grantRoutes } from './routes/grants.js'
|
|
34
37
|
import { sessionInviteRoutes } from './routes/session-invites.js'
|
|
@@ -37,6 +40,7 @@ import { workspaceFileRoutes } from './routes/workspace-files.js'
|
|
|
37
40
|
import { workspaceAgentRoutes } from './routes/workspace-agents.js'
|
|
38
41
|
import { workspaceInviteRoutes } from './routes/workspace-invites.js'
|
|
39
42
|
import { HOSTED_RELAY } from '../settings.js'
|
|
43
|
+
import { seamConnectRoutes } from './routes/seam-connect.js'
|
|
40
44
|
import { roomAccessFrom } from './access.js'
|
|
41
45
|
import { joiningRoom, sessionArg } from '../mcp-room.js'
|
|
42
46
|
import { issueRoutes } from './routes/issues.js'
|
|
@@ -47,7 +51,6 @@ import { secretRoutes } from './routes/secrets.js'
|
|
|
47
51
|
import { voiceRoutes } from './routes/voice.js'
|
|
48
52
|
import { mailRoutes } from './routes/mail.js'
|
|
49
53
|
import { agentWakeRoutes } from './routes/agent-wake.js'
|
|
50
|
-
import { quiltRoutes } from '../quilts/routes.js'
|
|
51
54
|
import { workspaceReach } from './workspace-reach.js'
|
|
52
55
|
import { leader, LEADER_ONLY } from './leader.js'
|
|
53
56
|
import { autoscale } from './autoscale.js'
|
|
@@ -73,7 +76,7 @@ const MCP_TIMEOUT_MS = 30 * 1000
|
|
|
73
76
|
// a change to its grant reaches it as soon as it reaches a connected app.
|
|
74
77
|
const PASS_REUSE_MARGIN_MS = 5 * 60 * 1000
|
|
75
78
|
|
|
76
|
-
export function startApi ({ port = 0, host = '127.0.0.1', store, verifyUser, siteUrl, apiUrl = 'https://api.heyquilt.com', mailer = { send: async () => { throw new Error('no mailer configured') } }, now = Date.now, log = () => {}, startLimit = 10, inviteLimit = 10, inviteSendLimit = 20, tokenLimit = 30, joinLimit = 20, trustProxy = false, maxStartKeys = 10_000, passKey = '', passLimit = 60, relayUrl = HOSTED_RELAY, mcpLimit = 600, reportKey = '', reportLimit = 10, slowMs = 2000, pruneEveryMs = 60 * 60 * 1000, keepEventsMs = 30 * 24 * 60 * 60 * 1000, keepIssuesMs = 90 * 24 * 60 * 60 * 1000, pruneStartMs = 10_000, relaySecret = '', workspaces = false, fileStore = null, maxFileBytes = 500 * 1024 * 1024, workspaceQuotaBytes = 5 * 1024 * 1024 * 1024, maxWorkspaceFiles = 2000, webhookFetch = globalThis.fetch, webhookLookup, allowLocalWebhooks = false, github = null, githubFetch = globalThis.fetch, fly = null, aiVmWaitMs = AI_VM_WAIT_MS, aiVmSpareSuspendMs, relayFetch = null, relayFly = null, heavy = null, relayTargets = null, clusterTiming = {}, vaultSecret = '', vaultUrl = 'https://vault.heyquilt.com', allowLocalUpstreams = false, secretWaitMs, voice = {}, voiceFetch = globalThis.fetch, mail = {}, mailFetch = globalThis.fetch, machineId = '', leaderTiming = {}, autoscaleTiming = {}, exit = () => process.exit(0), apiFly = null, maxMachines,
|
|
79
|
+
export function startApi ({ port = 0, host = '127.0.0.1', store, verifyUser, siteUrl, apiUrl = 'https://api.heyquilt.com', mailer = { send: async () => { throw new Error('no mailer configured') } }, now = Date.now, log = () => {}, startLimit = 10, inviteLimit = 10, inviteSendLimit = 20, tokenLimit = 30, joinLimit = 20, trustProxy = false, maxStartKeys = 10_000, passKey = '', passLimit = 60, relayUrl = HOSTED_RELAY, mcpLimit = 600, reportKey = '', reportLimit = 10, slowMs = 2000, pruneEveryMs = 60 * 60 * 1000, keepEventsMs = 30 * 24 * 60 * 60 * 1000, keepIssuesMs = 90 * 24 * 60 * 60 * 1000, pruneStartMs = 10_000, relaySecret = '', workspaces = false, quiltSourceDefault = false, fileStore = null, maxFileBytes = 500 * 1024 * 1024, workspaceQuotaBytes = 5 * 1024 * 1024 * 1024, maxWorkspaceFiles = 2000, webhookFetch = globalThis.fetch, webhookLookup, allowLocalWebhooks = false, github = null, githubFetch = globalThis.fetch, fly = null, aiVmWaitMs = AI_VM_WAIT_MS, aiVmSpareSuspendMs, relayFetch = null, relayFly = null, heavy = null, relayTargets = null, clusterTiming = {}, vaultSecret = '', vaultUrl = 'https://vault.heyquilt.com', allowLocalUpstreams = false, secretWaitMs, voice = {}, voiceFetch = globalThis.fetch, mail = {}, mailFetch = globalThis.fetch, machineId = '', leaderTiming = {}, autoscaleTiming = {}, exit = () => process.exit(0), apiFly = null, maxMachines, seamUrl = 'https://api.seam.heyquilt.com', seamFetch = globalThis.fetch, seamClientId = '', seamSiteUrl = 'https://seam.heyquilt.com', seamConnectSecret = '', notifyEveryMs = NOTIFY_EVERY_MS }) {
|
|
77
80
|
// PASS_SIGNING_KEY. A bad one should stop the API at start, not fail every pass later.
|
|
78
81
|
if (passKey) passPublicKey(passKey)
|
|
79
82
|
const site = String(siteUrl || '').replace(/\/+$/, '')
|
|
@@ -222,7 +225,8 @@ export function startApi ({ port = 0, host = '127.0.0.1', store, verifyUser, sit
|
|
|
222
225
|
['GET', /^\/healthz$/, async () => ({ ok: true })],
|
|
223
226
|
|
|
224
227
|
// Which optional parts this API has on, for the app and the MCPs to decide what to show. Public.
|
|
225
|
-
|
|
228
|
+
// quilt_source_default (QUILT_SOURCE_DEFAULT, Seam's plans/quilt-source-control/16 flags order 8): apps make the sessions they start quilts from the start.
|
|
229
|
+
['GET', /^\/v1\/features$/, async () => ({ workspaces: !!workspaces, secrets: !!vaultSecret, quilt_source_default: !!quiltSourceDefault })],
|
|
226
230
|
|
|
227
231
|
['POST', /^\/v1\/device\/start$/, async (req, body) => {
|
|
228
232
|
limitStarts(req)
|
|
@@ -388,13 +392,16 @@ export function startApi ({ port = 0, host = '127.0.0.1', store, verifyUser, sit
|
|
|
388
392
|
// A person's AI on its own cloud machine (routes/ai-vm.js): off (503) without Fly set up. Only the leader runs it.
|
|
389
393
|
const aiVm = aiVmRoutes({ ...ctx, relayUrl, fly, waitMs: aiVmWaitMs, spareSuspendMs: aiVmSpareSuspendMs, leads: lead.leads })
|
|
390
394
|
routes.push(...aiVm.routes)
|
|
395
|
+
// Email notices people turned on in the website's Settings › Notifications (notify.js): the leader sends them.
|
|
396
|
+
const notices = notifier({ store, mailer, ask: nowAsker({ store, cluster, relayOn: !!(relay && relaySecret), log }), overview: (account, t) => overviewOf(store, account, 'UTC', t), now, log, site, leads: lead.leads, everyMs: notifyEveryMs })
|
|
397
|
+
if (notifyEveryMs > 0) notices.start()
|
|
391
398
|
// A team's git rule or who is on a team changed: the relay asks its sessions' rules again now (relay /github/policy).
|
|
392
399
|
// With a room (a session put in or taken out of a workspace), only that session asks.
|
|
393
400
|
const policyChanged = (room = '') => {
|
|
394
401
|
if (!relay || !relaySecret) return
|
|
395
402
|
cluster.tellRelays('/github/policy', room ? { room } : {}).catch((err) => log(`git rules to relay: ${err.message}`))
|
|
396
403
|
}
|
|
397
|
-
routes.push(...orgRoutes(ctx), ...memberRoutes(ctx), ...teamRoutes({ ...ctx, policyChanged }), ...inviteRoutes(ctx), ...agentRoutes({ ...ctx, passKey }), ...agentInviteRoutes({ ...ctx, workspaces }), ...joinRoutes(ctx), ...relayRoutes(ctx), ...githubRoutes({ ...ctx, github, githubFetch, relayHttp: relay, relayFetch: githubFetch, tellRelays: (path, body) => cluster.tellRelays(path, body), hookSecret, workspaces }), ...relayClusterRoutes({ ...ctx, cluster }), ...sessionRoutes(ctx), ...accessTypeRoutes(ctx), ...grantRoutes({ ...ctx, workspaces }), ...sessionInviteRoutes({ ...ctx, workspaces }), ...issueRoutes(ctx))
|
|
404
|
+
routes.push(...orgRoutes(ctx), ...memberRoutes(ctx), ...teamRoutes({ ...ctx, policyChanged }), ...inviteRoutes(ctx), ...agentRoutes({ ...ctx, passKey }), ...agentInviteRoutes({ ...ctx, workspaces }), ...joinRoutes(ctx), ...relayRoutes(ctx), ...githubRoutes({ ...ctx, github, githubFetch, relayHttp: relay, relayFetch: githubFetch, tellRelays: (path, body) => cluster.tellRelays(path, body), hookSecret, workspaces }), ...relayClusterRoutes({ ...ctx, cluster }), ...sessionRoutes(ctx), ...nowRoutes({ ...ctx, cluster, relayOn: !!(relay && relaySecret) }), ...browserRoutes(ctx), ...sessionMigrateRoutes({ ...ctx, cluster }), ...accessTypeRoutes(ctx), ...grantRoutes({ ...ctx, workspaces }), ...sessionInviteRoutes({ ...ctx, workspaces }), ...issueRoutes(ctx))
|
|
398
405
|
// Always routed: with the flag off each answers a plain 404 of its own, so the app's
|
|
399
406
|
// check at every launch isn't filed as a missing route.
|
|
400
407
|
// Only these take a hosted agent's pass (workspaceReach), so they alone get the pass key.
|
|
@@ -416,16 +423,19 @@ export function startApi ({ port = 0, host = '127.0.0.1', store, verifyUser, sit
|
|
|
416
423
|
routes.push(...mailRoutes({ ...ctx, passKey, mail, mailFetch, makeLimiter, tellRoom }))
|
|
417
424
|
// How Quilt wakes each agent (routes/agent-wake.js): step 2 of joining.
|
|
418
425
|
routes.push(...agentWakeRoutes({ ...ctx, passKey, mail, mailFetch, makeLimiter }))
|
|
419
|
-
//
|
|
420
|
-
//
|
|
421
|
-
|
|
422
|
-
const
|
|
423
|
-
|
|
426
|
+
// Seam (src/quilts/, Seam's plans/quilt-source-control/) is its own service at seamUrl. This API is its
|
|
427
|
+
// identity service for Quilt accounts: GET /v1/seam/me says who is calling, as a quilt member
|
|
428
|
+
// { member_id, kind, name, tool?, on_behalf_of? }, for a person's sign-in, an agent key or a pass.
|
|
429
|
+
const seamCaller = workspaceReach({ ...ctx, passKey })
|
|
430
|
+
const identifySeamMember = async (req) => {
|
|
431
|
+
const c = await seamCaller.caller(req)
|
|
424
432
|
if (c.agent) return { member_id: c.account, kind: 'agent', name: c.agent.name || 'agent', ...(c.agent.provider ? { tool: String(c.agent.provider).toLowerCase() } : {}), ...(c.agent.ownerUserId ? { on_behalf_of: `person:${c.agent.ownerUserId}` } : {}) }
|
|
425
433
|
const p = c.userId && store.profile ? await store.profile(c.userId).catch(() => null) : null
|
|
426
434
|
return { member_id: c.account, kind: 'person', name: (p && p.name) || 'person' }
|
|
427
435
|
}
|
|
428
|
-
routes.push(
|
|
436
|
+
routes.push(['GET', /^\/v1\/seam\/me$/, (req) => identifySeamMember(req)])
|
|
437
|
+
// "Connect Seam": a person links their own Seam account (Seam's OAuth); off without seamClientId.
|
|
438
|
+
routes.push(...seamConnectRoutes({ store, person, now, apiUrl: api, seamUrl, seamSiteUrl, clientId: seamClientId, secret: seamConnectSecret || passKey, fetch: seamFetch, log }))
|
|
429
439
|
|
|
430
440
|
async function openLink (code) {
|
|
431
441
|
const userCode = normalizeUserCode(code)
|
|
@@ -488,6 +498,7 @@ export function startApi ({ port = 0, host = '127.0.0.1', store, verifyUser, sit
|
|
|
488
498
|
try {
|
|
489
499
|
if (pathname.startsWith('/v1/file-data/') && files instanceof DiskStore) return await serveFileData(req, res, send, pathname.slice('/v1/file-data/'.length))
|
|
490
500
|
if (pathname === '/mcp') return await proxyMcp(req, res, send)
|
|
501
|
+
if (SEAM_PATH.test(pathname)) return await forwardSeam(req, res)
|
|
491
502
|
if (req.method === 'GET' && (pathname === '/v1/releases/latest' || pathname.startsWith('/v1/download/'))) return await serveRelease(req, res, send, pathname)
|
|
492
503
|
const url = new URL(req.url, 'http://x')
|
|
493
504
|
const route = routes.find(([m, re]) => m === req.method && re.test(url.pathname))
|
|
@@ -514,6 +525,25 @@ export function startApi ({ port = 0, host = '127.0.0.1', store, verifyUser, sit
|
|
|
514
525
|
}
|
|
515
526
|
})
|
|
516
527
|
|
|
528
|
+
// Seam's routes moved to Seam's own server (Seam's docs/seam-service.md): old seam commands and sites
|
|
529
|
+
// still call them here, so they go on to seamUrl as sent, and Seam's answer comes straight back.
|
|
530
|
+
async function forwardSeam (req, res) {
|
|
531
|
+
if (!seamUrl) throw new HttpError(404, 'not found')
|
|
532
|
+
const headers = {}
|
|
533
|
+
for (const h of ['authorization', 'content-type', 'accept']) if (req.headers[h]) headers[h] = req.headers[h]
|
|
534
|
+
const hasBody = !['GET', 'HEAD', 'DELETE'].includes(req.method)
|
|
535
|
+
let up
|
|
536
|
+
try {
|
|
537
|
+
up = await seamFetch(seamUrl.replace(/\/+$/, '') + req.url, { method: req.method, headers, ...(hasBody ? { body: await readRaw(req, SEAM_MAX_BODY), duplex: 'half' } : {}), redirect: 'manual' })
|
|
538
|
+
} catch (err) {
|
|
539
|
+
if (err instanceof HttpError) throw err
|
|
540
|
+
throw new HttpError(502, 'Seam did not answer; try again')
|
|
541
|
+
}
|
|
542
|
+
const body = Buffer.from(await up.arrayBuffer())
|
|
543
|
+
res.writeHead(up.status, { 'content-type': up.headers.get('content-type') || 'application/json', 'cache-control': 'no-store', ...cors(req) })
|
|
544
|
+
res.end(body)
|
|
545
|
+
}
|
|
546
|
+
|
|
517
547
|
// Downloads and the update check (releases.js): public, from the private repository's latest
|
|
518
548
|
// release, read as Quilt's GitHub App. A download is a redirect to GitHub's own short-lived link.
|
|
519
549
|
const releases = releaseSource({ github, fetch: githubFetch, now, apiUrl: api })
|
|
@@ -674,13 +704,17 @@ export function startApi ({ port = 0, host = '127.0.0.1', store, verifyUser, sit
|
|
|
674
704
|
|
|
675
705
|
return new Promise((resolve) => server.listen(port, host, () => {
|
|
676
706
|
const p = server.address().port
|
|
677
|
-
resolve({ port: p, url: `http://${host}:${p}`, cluster, lead, scaler, grow, fillSpares: aiVm.fillSpares, close: () => { clearInterval(prune); clearTimeout(firstPrune); aiVm.close(); lead.close(); scaler.close(); grow.close(); return new Promise((r) => server.close(r)) }, startKeys: () => limitStarts.size(), sweepFiles: (at) => wsFiles.sweep(at), flushWebhooks, githubReady })
|
|
707
|
+
resolve({ port: p, url: `http://${host}:${p}`, cluster, lead, scaler, grow, fillSpares: aiVm.fillSpares, notices, close: () => { clearInterval(prune); clearTimeout(firstPrune); aiVm.close(); notices.stop(); lead.close(); scaler.close(); grow.close(); return new Promise((r) => server.close(r)) }, startKeys: () => limitStarts.size(), sweepFiles: (at) => wsFiles.sweep(at), flushWebhooks, githubReady })
|
|
678
708
|
}))
|
|
679
709
|
}
|
|
680
710
|
|
|
681
711
|
// Which room a hosted agent's MCP request is about: shared with the relay (src/mcp-room.js).
|
|
682
712
|
export { joiningRoom, sessionArg } from '../mcp-room.js'
|
|
683
713
|
|
|
714
|
+
// Seam's routes, answered by Seam's own server now (forwardSeam). Its biggest body is a pack.
|
|
715
|
+
const SEAM_PATH = /^\/v1\/(quilts|imports|search|signing-keys)(\/|$)/
|
|
716
|
+
const SEAM_MAX_BODY = 96 * 1024 * 1024
|
|
717
|
+
|
|
684
718
|
function decodePart (s) {
|
|
685
719
|
try { return decodeURIComponent(s) } catch { throw new HttpError(400, 'bad path') }
|
|
686
720
|
}
|
|
@@ -69,6 +69,14 @@ export const rowFrom = (row) => row && Object.fromEntries(Object.entries(toCamel
|
|
|
69
69
|
export function createSupabaseStore ({ url, serviceKey, client }) {
|
|
70
70
|
const db = client || createClient(url, serviceKey, { auth: { persistSession: false, autoRefreshToken: false } })
|
|
71
71
|
const one = async (q) => { const { data, error } = await q; if (error) throw error; return data }
|
|
72
|
+
// A lease call (relay_leases), or the git_sync_leases one while the relay_leases migration is not applied (PGRST202: no such function).
|
|
73
|
+
const leaseRpc = async (what, key, args, oldArgs) => {
|
|
74
|
+
const { data, error } = await db.rpc(`${what}_relay_lease`, args)
|
|
75
|
+
if (!error) return data
|
|
76
|
+
const at = String(key).indexOf(' ')
|
|
77
|
+
if (error.code !== 'PGRST202' || at < 1) throw error
|
|
78
|
+
return one(db.rpc(`${what}_git_sync_lease`, oldArgs(key.slice(0, at), key.slice(at + 1))))
|
|
79
|
+
}
|
|
72
80
|
const memberOf = async (orgId, userId) => rowFrom(await one(db.from('org_members').select(MEMBER).eq('org_id', orgId).eq('user_id', userId).maybeSingle()))
|
|
73
81
|
// Every row of a query, a page at a time. `build` makes a fresh query (with a stable order) per page.
|
|
74
82
|
const pages = async (build) => {
|
|
@@ -343,10 +351,11 @@ export function createSupabaseStore ({ url, serviceKey, client }) {
|
|
|
343
351
|
await one(db.from('org_github').upsert(row, { onConflict: 'org_id' }))
|
|
344
352
|
},
|
|
345
353
|
// The API's own settings (app_settings): a value it keeps across starts, such as the webhook secret it set on GitHub.
|
|
346
|
-
//
|
|
347
|
-
|
|
348
|
-
async
|
|
349
|
-
async
|
|
354
|
+
// Relay leases (relay_leases, through its locking functions; 16 R2). `key`: the relay's name for what it holds.
|
|
355
|
+
// Before the migration that makes relay_leases is applied, a git branch's key ("<repo> <branch>") goes to the old functions.
|
|
356
|
+
async takeLease ({ key, room, holder = '', ttlMs }) { return await leaseRpc('take', key, { p_key: key, p_room: room, p_holder: holder, p_ttl_ms: ttlMs }, (repo, branch) => ({ p_repo: repo, p_branch: branch, p_room: room, p_holder: holder, p_ttl_ms: ttlMs })) },
|
|
357
|
+
async heartbeatLease ({ key, room, generation, ttlMs }) { return await leaseRpc('heartbeat', key, { p_key: key, p_room: room, p_generation: generation, p_ttl_ms: ttlMs }, (repo, branch) => ({ p_repo: repo, p_branch: branch, p_room: room, p_generation: generation, p_ttl_ms: ttlMs })) },
|
|
358
|
+
async releaseLease ({ key, room, generation }) { return await leaseRpc('release', key, { p_key: key, p_room: room, p_generation: generation }, (repo, branch) => ({ p_repo: repo, p_branch: branch, p_room: room, p_generation: generation })) },
|
|
350
359
|
async getSetting (key) { const r = await one(db.from('app_settings').select('value').eq('key', key).maybeSingle()); return r ? r.value : null },
|
|
351
360
|
async setSetting (key, value) { await one(db.from('app_settings').upsert({ key, value: String(value), updated_at: new Date().toISOString() }, { onConflict: 'key' })) },
|
|
352
361
|
async setSettingIfAbsent (key, value) {
|
|
@@ -417,6 +426,36 @@ export function createSupabaseStore ({ url, serviceKey, client }) {
|
|
|
417
426
|
return u ? { email: u.email || '', confirmed: !!u.email_confirmed_at } : null
|
|
418
427
|
},
|
|
419
428
|
|
|
429
|
+
// Email notices (notify.js): kept on each account, so no table is needed. user_metadata.notify is what
|
|
430
|
+
// the person turned on (the website writes it); user_metadata.notify_sent what was sent already.
|
|
431
|
+
async notifyUsers () {
|
|
432
|
+
const out = []
|
|
433
|
+
for (let page = 1; page < 1000; page++) {
|
|
434
|
+
const { data, error } = await db.auth.admin.listUsers({ page, perPage: 1000 })
|
|
435
|
+
if (error) throw error
|
|
436
|
+
const list = data?.users || []
|
|
437
|
+
for (const u of list) {
|
|
438
|
+
const m = u.user_metadata || {}
|
|
439
|
+
if (u.email && u.email_confirmed_at && m.notify && typeof m.notify === 'object') out.push({ userId: u.id, email: u.email, notify: m.notify, sent: m.notify_sent || {} })
|
|
440
|
+
}
|
|
441
|
+
if (list.length < 1000) break
|
|
442
|
+
}
|
|
443
|
+
return out
|
|
444
|
+
},
|
|
445
|
+
async setNotifySent (userId, sent) {
|
|
446
|
+
const { data, error } = await db.auth.admin.getUserById(userId)
|
|
447
|
+
if (error) throw error
|
|
448
|
+
const meta = data?.user?.user_metadata || {}
|
|
449
|
+
const { error: e2 } = await db.auth.admin.updateUserById(userId, { user_metadata: { ...meta, notify_sent: sent } })
|
|
450
|
+
if (e2) throw e2
|
|
451
|
+
},
|
|
452
|
+
|
|
453
|
+
// A person's browser sign-ins (20261026235000_my_browser_sessions.sql), for signing out one at a time.
|
|
454
|
+
async authSessions (userId) {
|
|
455
|
+
return (await one(db.rpc('auth_sessions_of', { p_user: userId }))).map((r) => ({ id: r.id, createdAt: ms(r.created_at), lastActiveAt: ms(r.updated_at || r.created_at), userAgent: r.user_agent || '', ip: r.ip || '' }))
|
|
456
|
+
},
|
|
457
|
+
async endAuthSession (userId, id) { return !!(await one(db.rpc('end_auth_session', { p_user: userId, p_id: id }))) },
|
|
458
|
+
|
|
420
459
|
// Session activity (see 20261002000000_session_activity.sql). Event times are epoch ms.
|
|
421
460
|
async ingestPresence (events, receivedAt) {
|
|
422
461
|
return await one(db.rpc('ingest_presence', { p_events: events, p_received_at: ts(receivedAt) }))
|
|
@@ -885,6 +924,16 @@ export function createSupabaseStore ({ url, serviceKey, client }) {
|
|
|
885
924
|
await one(db.from('secret_data_keys').upsert({ scope_type: scopeType, scope_id: scopeId, version, sealed_key: sealedKey, provider }, { onConflict: 'scope_type,scope_id,version', ignoreDuplicates: true }))
|
|
886
925
|
return rowFrom(await one(db.from('secret_data_keys').select(SECRET_DATA_KEY).eq('scope_type', scopeType).eq('scope_id', scopeId).eq('version', version).single()))
|
|
887
926
|
},
|
|
927
|
+
// "Connect Seam" (routes/seam-connect.js): the person's Seam tokens, one row per Quilt user.
|
|
928
|
+
async seamLink (userId) {
|
|
929
|
+
const r = await one(db.from('seam_links').select('user_id,access_token,refresh_token,scope,expires_at,connected_at').eq('user_id', userId).maybeSingle())
|
|
930
|
+
return r ? { userId: r.user_id, access: r.access_token, refresh: r.refresh_token, scope: r.scope, expiresAt: Date.parse(r.expires_at), connectedAt: Date.parse(r.connected_at) } : null
|
|
931
|
+
},
|
|
932
|
+
async putSeamLink (userId, link) {
|
|
933
|
+
await one(db.from('seam_links').upsert({ user_id: userId, access_token: link.access, refresh_token: link.refresh, scope: link.scope || '', expires_at: ts(link.expiresAt), connected_at: ts(link.connectedAt ?? Date.now()), updated_at: new Date().toISOString() }, { onConflict: 'user_id' }))
|
|
934
|
+
return { userId, access: link.access, refresh: link.refresh, scope: link.scope || '', expiresAt: link.expiresAt, connectedAt: link.connectedAt ?? Date.now() }
|
|
935
|
+
},
|
|
936
|
+
async deleteSeamLink (userId) { await one(db.from('seam_links').delete().eq('user_id', userId)) },
|
|
888
937
|
async secretSettings (scopeType, scopeId) {
|
|
889
938
|
const r = rowFrom(await one(db.from('secret_scope_settings').select(SECRET_SETTINGS).eq('scope_type', scopeType).eq('scope_id', scopeId).maybeSingle()))
|
|
890
939
|
return r || { scopeType, scopeId, inheritOff: false, inheritOffBy: null, inheritOffAt: null, agentsOn: false, agents: 'all', updatedAt: null }
|