heyquilt 0.3.121 → 0.3.122

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.
Files changed (48) hide show
  1. package/RELEASES.md +19 -0
  2. package/bin/quilt.js +23 -2
  3. package/package.json +2 -1
  4. package/src/api/memory-store.js +5 -1
  5. package/src/api/routes/seam-connect.js +103 -0
  6. package/src/api/routes/session-migrate.js +36 -0
  7. package/src/api/server.js +37 -9
  8. package/src/api/supabase-store.js +10 -0
  9. package/src/connection.js +4 -1
  10. package/src/control.js +12 -0
  11. package/src/history.js +15 -1
  12. package/src/mcp.js +12 -1
  13. package/src/quilts/accounts.js +136 -0
  14. package/src/quilts/affected.js +132 -0
  15. package/src/quilts/checks.js +308 -0
  16. package/src/quilts/cli.js +97 -10
  17. package/src/quilts/client.js +9 -0
  18. package/src/quilts/describe.js +31 -1
  19. package/src/quilts/events.js +13 -2
  20. package/src/quilts/export-remote.js +156 -0
  21. package/src/quilts/import-follow.js +236 -0
  22. package/src/quilts/import-git.js +6 -3
  23. package/src/quilts/import.js +67 -5
  24. package/src/quilts/index.js +28 -6
  25. package/src/quilts/keys.js +186 -0
  26. package/src/quilts/merge.js +16 -1
  27. package/src/quilts/oauth.js +202 -0
  28. package/src/quilts/reviews.js +18 -5
  29. package/src/quilts/routes.js +61 -1
  30. package/src/quilts/runner.js +95 -0
  31. package/src/quilts/server.js +144 -0
  32. package/src/quilts/service.js +41 -1
  33. package/src/quilts/setup.js +9 -2
  34. package/src/quilts/store-memory.js +164 -3
  35. package/src/quilts/store-rpc.js +12 -1
  36. package/src/quilts/tools.js +39 -11
  37. package/src/quilts/window.js +2 -0
  38. package/src/relay-mcp.js +33 -3
  39. package/src/relay-prune.js +4 -1
  40. package/src/server.js +317 -14
  41. package/src/session-follow.js +81 -0
  42. package/src/session-layout3.js +60 -0
  43. package/src/session-migrate.js +119 -0
  44. package/src/session-quilt.js +145 -0
  45. package/src/session-seam.js +71 -0
  46. package/src/session.js +74 -2
  47. package/src/source-live/live-block.js +4 -3
  48. package/src/source-live/live-square.js +1 -1
@@ -0,0 +1,186 @@
1
+ // Seam's own keys for its own accounts (accounts.js):
2
+ // - a computer linked through Seam's device flow (`seam login`): it shows a code, the person
3
+ // approves it on seam.heyquilt.com/device, and the computer gets a key (ssk_…) that lasts
4
+ // 30 days and is renewed with the computer's own Ed25519 key, no browser (`resume`);
5
+ // - agents an account makes (`seam agent new <name>`), each with its own key (sak_…), which the
6
+ // owner refreshes (a new key, the old one revoked) or revokes with the agent.
7
+ // Only a key's sha256 is kept. An agent is the member agent:<id>, on behalf of its owner.
8
+ import crypto from 'node:crypto'
9
+ import { QuiltError } from './errors.js'
10
+ import { uuidv7 } from './ids.js'
11
+
12
+ export const DEVICE_KEY_PREFIX = 'ssk_'
13
+ export const AGENT_KEY_PREFIX = 'sak_'
14
+ export const DEVICE_LINK_CONTEXT = 'seam-device-link-v1'
15
+ export const DEVICE_RESUME_CONTEXT = 'seam-device-resume-v1'
16
+ export const DEVICE_KEY_DAYS = 30
17
+ export const LINK_SECONDS = 600
18
+ export const MAX_AGENTS = 100
19
+ const KEY = /^s[sa]k_[A-Za-z0-9_-]{43}$/
20
+ const USER_CODE_ABC = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
21
+
22
+ export const isSeamKey = (s) => KEY.test(String(s || ''))
23
+ const sha = (s) => crypto.createHash('sha256').update(String(s)).digest('hex')
24
+ const newSecret = (prefix) => prefix + crypto.randomBytes(32).toString('base64url')
25
+ const userCode = () => { const b = crypto.randomBytes(8); let s = ''; for (let i = 0; i < 8; i++) s += USER_CODE_ABC[b[i] % USER_CODE_ABC.length]; return `${s.slice(0, 4)}-${s.slice(4)}` }
26
+
27
+ function publicKeyOf (publicKey) {
28
+ try {
29
+ const k = crypto.createPublicKey({ key: Buffer.from(String(publicKey), 'base64url'), format: 'der', type: 'spki' })
30
+ if (k.asymmetricKeyType !== 'ed25519') throw new Error('not ed25519')
31
+ return k
32
+ } catch { throw new QuiltError(400, 'bad_request', 'publicKey: this computer\'s Ed25519 public key (SPKI DER, base64url)') }
33
+ }
34
+ const signedBy = (publicKey, text, signature) => {
35
+ try { return crypto.verify(null, Buffer.from(text), publicKeyOf(publicKey), Buffer.from(String(signature || ''), 'base64url')) } catch { return false }
36
+ }
37
+
38
+ export function keyOps (svc, { siteUrl = 'https://seam.heyquilt.com' } = {}) {
39
+ const { store, now, fromStore } = svc.internals
40
+ const site = String(siteUrl).replace(/\/+$/, '')
41
+ const profile = (a) => ({ id: a.id, name: a.name, email: a.email })
42
+ const agentView = (a) => ({ id: a.id, name: a.name, member_id: `agent:${a.id}`, created_at: a.created_at, ...(a.revoked_at ? { revoked_at: a.revoked_at } : {}) })
43
+ const accountOf = (me) => {
44
+ if (!me.account || me.kind !== 'person') throw new QuiltError(403, 'needs_account', 'sign in with your Seam account for this')
45
+ return me.account
46
+ }
47
+ const wrap = async (fn) => { try { return await fn() } catch (err) { throw fromStore(err) } }
48
+
49
+ async function deviceKeyFor (account, publicKey, name) {
50
+ const secret = newSecret(DEVICE_KEY_PREFIX)
51
+ const at = now()
52
+ await wrap(() => store.addKey({ id: uuidv7(at), hash: sha(secret), account_id: account.id, kind: 'device', public_key: publicKey, name, created_at: at, expires_at: at + DEVICE_KEY_DAYS * 86400_000, replace: true }))
53
+ return { token: secret, expiresIn: DEVICE_KEY_DAYS * 86400, profile: profile(account) }
54
+ }
55
+
56
+ /** POST /v1/device/start { publicKey, deviceName, platform? }: a code to approve on the site. */
57
+ async function deviceStart ({ publicKey, deviceName, platform } = {}) {
58
+ publicKeyOf(publicKey)
59
+ const name = String(deviceName || 'a computer').trim().slice(0, 200) || 'a computer'
60
+ const code = crypto.randomBytes(32).toString('base64url')
61
+ const at = now()
62
+ let link
63
+ for (let i = 0; !link; i++) {
64
+ try { link = await store.addDeviceLink({ id: uuidv7(at), code_hash: sha(code), user_code: userCode(), public_key: String(publicKey), name, platform: platform ? String(platform).slice(0, 40) : null, created_at: at, expires_at: at + LINK_SECONDS * 1000 }) } catch (err) { if (err.code !== 'exists' || i > 3) throw fromStore(err) }
65
+ }
66
+ return { deviceCode: code, userCode: link.user_code, verificationUrl: `${site}/device?code=${link.user_code}`, expiresIn: LINK_SECONDS, interval: 3 }
67
+ }
68
+
69
+ async function liveLink (userCodeIn) {
70
+ const l = await store.deviceLink({ user_code: String(userCodeIn || '').trim().toUpperCase() })
71
+ if (!l || l.expires_at < now()) throw new QuiltError(404, 'not_found', 'no such sign-in code, or it expired: run seam login again')
72
+ return l
73
+ }
74
+
75
+ /** GET /v1/device/:code: what the approval page shows (the signed-in person's own account only). */
76
+ async function deviceLinkInfo (me, code) {
77
+ accountOf(me)
78
+ const l = await liveLink(code)
79
+ return { user_code: l.user_code, name: l.name, platform: l.platform, state: l.state, expires_at: l.expires_at }
80
+ }
81
+
82
+ /** POST /v1/device/approve { user_code, approve? }: the signed-in person approves (or declines) a computer. */
83
+ async function deviceApprove (me, { user_code: code, approve = true } = {}) {
84
+ const account = accountOf(me)
85
+ const l = await liveLink(code)
86
+ if (l.state !== 'pending') throw new QuiltError(409, 'moved', 'this code was already used')
87
+ const out = await wrap(() => store.setDeviceLink({ id: l.id, from: 'pending', state: approve === false ? 'declined' : 'approved', account_id: account }))
88
+ return { user_code: out.user_code, name: out.name, state: out.state }
89
+ }
90
+
91
+ /** POST /v1/device/poll { deviceCode, signature }: pending, or the computer's key once approved. */
92
+ async function devicePoll ({ deviceCode, signature } = {}) {
93
+ const l = await store.deviceLink({ code_hash: sha(deviceCode) })
94
+ if (!l || !signedBy(l.public_key, `${DEVICE_LINK_CONTEXT}\0${deviceCode}`, signature)) throw new QuiltError(404, 'not_found', 'no such sign-in link')
95
+ if (l.state === 'taken' || l.expires_at < now()) throw new QuiltError(410, 'expired', 'this sign-in link expired: run seam login again')
96
+ if (l.state === 'declined') throw new QuiltError(403, 'declined', 'sign-in was declined in the browser')
97
+ if (l.state === 'pending') return { status: 'pending' }
98
+ await wrap(() => store.setDeviceLink({ id: l.id, from: 'approved', state: 'taken' }))
99
+ const account = await store.getAccount(l.account_id)
100
+ return { status: 'approved', ...(await deviceKeyFor(account, l.public_key, l.name)) }
101
+ }
102
+
103
+ /** POST /v1/device/resume { publicKey, at, signature }: a linked computer signs back in with its key. */
104
+ async function deviceResume ({ publicKey, at, signature } = {}) {
105
+ publicKeyOf(publicKey)
106
+ if (!Number.isFinite(Number(at)) || Math.abs(now() - Number(at)) > 5 * 60_000) throw new QuiltError(400, 'bad_request', 'at: the time now, in ms (check this computer\'s clock)')
107
+ const k = await store.deviceKey(String(publicKey))
108
+ if (!k || !signedBy(publicKey, `${DEVICE_RESUME_CONTEXT}\0${at}`, signature)) throw new QuiltError(401, 'not_linked', 'this computer is not signed in: run seam login')
109
+ const account = await store.getAccount(k.account_id)
110
+ return deviceKeyFor(account, k.public_key, k.name)
111
+ }
112
+
113
+ /** POST /v1/me/signout: ends the key the caller signed in with. */
114
+ async function signout (me) {
115
+ if (me.key) await wrap(() => store.revokeKey({ id: me.key, at: now() }))
116
+ return { ok: true }
117
+ }
118
+
119
+ /** The caller a Seam key stands for, or null when it is unknown, revoked or expired. */
120
+ async function keyCaller (secret) {
121
+ if (!isSeamKey(secret)) return null
122
+ const k = await store.keyByHash(sha(secret))
123
+ if (!k || k.revoked_at || (k.expires_at && k.expires_at <= now())) return null
124
+ const account = await store.getAccount(k.account_id)
125
+ if (!account) return null
126
+ if (k.kind === 'device') return { member_id: account.member_id, kind: 'person', name: account.name, account: account.id, key: k.id }
127
+ const a = await store.getAgent(k.agent_id)
128
+ if (!a || a.revoked_at) return null
129
+ return { member_id: `agent:${a.id}`, kind: 'agent', name: a.name, tool: 'seam', on_behalf_of: account.member_id, key: k.id }
130
+ }
131
+
132
+ async function agentKeyFor (account, agent) {
133
+ const secret = newSecret(AGENT_KEY_PREFIX)
134
+ const at = now()
135
+ await wrap(() => store.addKey({ id: uuidv7(at), hash: sha(secret), account_id: account, kind: 'agent', agent_id: agent.id, name: agent.name, created_at: at, expires_at: null, replace: true }))
136
+ return { ...agentView(agent), key: secret }
137
+ }
138
+
139
+ async function ownAgent (account, which) {
140
+ const w = String(which || '')
141
+ const all = await store.agentsOf(account)
142
+ const a = all.find((x) => x.id === w) || (() => {
143
+ const named = all.filter((x) => x.name === w && !x.revoked_at)
144
+ if (named.length > 1) throw new QuiltError(400, 'bad_request', `${named.length} agents are named ${w}; use its id`)
145
+ return named[0]
146
+ })()
147
+ if (!a) throw new QuiltError(404, 'not_found', `no agent ${w}`)
148
+ return a
149
+ }
150
+
151
+ /** POST /v1/agents { name, claim? }: a new agent and its key (shown in this answer only); `claim` re-links an old agent (agent:<id>) listed for this account's email, keeping its member id and its patches. */
152
+ async function createAgent (me, { name, claim } = {}) {
153
+ const account = accountOf(me)
154
+ if (claim != null && !/^agent:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(claim))) throw new QuiltError(400, 'bad_request', 'claim: the old agent\'s member id, agent:<id>')
155
+ let n = String(name || '').trim()
156
+ if (!n && claim) n = (await store.agentClaims((await store.getAccount(account)).email)).claims.find((c) => c.member_id === claim)?.name || 'agent'
157
+ if (!n || n.length > 100) throw new QuiltError(400, 'bad_request', 'name: the agent\'s name, up to 100 characters')
158
+ const at = now()
159
+ const a = await wrap(() => store.createAgent({ id: uuidv7(at), account_id: account, name: n, created_at: at, max: MAX_AGENTS, ...(claim ? { claim: String(claim).toLowerCase() } : {}) }))
160
+ return agentKeyFor(account, a)
161
+ }
162
+
163
+ /** GET /v1/agents: the account's agents, revoked ones too, and `relink`: old agents it may re-link. */
164
+ async function agents (me) {
165
+ const account = accountOf(me)
166
+ const a = await store.getAccount(account)
167
+ return { agents: (await store.agentsOf(account)).map(agentView), relink: (await store.agentClaims(a.email)).claims }
168
+ }
169
+
170
+ /** POST /v1/agents/:id/key: a new key for the agent; its old key stops working. */
171
+ async function refreshAgentKey (me, which) {
172
+ const account = accountOf(me)
173
+ const a = await ownAgent(account, which)
174
+ if (a.revoked_at) throw new QuiltError(409, 'revoked', `${a.name} was revoked; make a new agent`)
175
+ return agentKeyFor(account, a)
176
+ }
177
+
178
+ /** DELETE /v1/agents/:id: revokes the agent and its key. Its patches stay its own. */
179
+ async function revokeAgent (me, which) {
180
+ const account = accountOf(me)
181
+ const a = await ownAgent(account, which)
182
+ return agentView(await wrap(() => store.revokeAgent({ id: a.id, account_id: account, at: now() })))
183
+ }
184
+
185
+ return { deviceStart, deviceLinkInfo, deviceApprove, devicePoll, deviceResume, signout, keyCaller, createAgent, agents, refreshAgentKey, revokeAgent }
186
+ }
@@ -23,9 +23,17 @@ const MAX_SIDES = 8
23
23
  // across machines. A non-holder peeks at the table lease with ttl 0 (takes it only if free, and
24
24
  // then it is already expired). A crashed holder's lease lapses after LEASE_TTL_MS.
25
25
  const LEASE_TTL_MS = 10_000
26
+ const RENEW_MS = LEASE_TTL_MS / 4
26
27
  const leases = new Map() // quilt + line → { held: boolean, queue: [resolve] }
27
28
 
28
29
  function leaseOf (key) { let l = leases.get(key); if (!l) { l = { held: false, queue: [], idle: [] }; leases.set(key, l) } return l }
30
+ /** Why a line is frozen: an import that follows git names itself (14), else the plain refusal. */
31
+ export function frozenWhy (q, line) {
32
+ const imp = q.settings?.import
33
+ if (imp && imp.follow && imp.state !== 'done' && (line === 'main' || line.startsWith('release/'))) return `${line} is frozen while import ${imp.id} follows git: merges happen in git until cut-over (seam import cutover ${imp.id})`
34
+ return `${line} is frozen`
35
+ }
36
+
29
37
  function takeLease (key) {
30
38
  const l = leaseOf(key)
31
39
  if (!l.held) { l.held = true; return Promise.resolve() }
@@ -203,7 +211,7 @@ export function mergeOps (svc) {
203
211
  const run = async () => {
204
212
  const ln = await store.getLine(q.id, line)
205
213
  if (!ln) throw new QuiltError(404, 'not_found', `no line ${line}`)
206
- if (ln.frozen) throw new QuiltError(423, 'line_frozen', `${line} is frozen`)
214
+ if (ln.frozen) throw new QuiltError(423, 'line_frozen', frozenWhy(q, line))
207
215
  const parent = await openParent(sq)
208
216
  if (parent) throw new QuiltError(409, 'parent_open', `this square is stacked on ${squareShort(parent.id)}, which has not merged: merge it first, or fold this square into it`, { parent: squareShort(parent.id) })
209
217
  if (sq.state !== 'open') throw new QuiltError(409, sq.state === 'merging' ? 'square_merging' : 'square_closed', `the square is ${sq.state}`)
@@ -226,6 +234,12 @@ export function mergeOps (svc) {
226
234
 
227
235
  if (svc.internals.mergeWindow) await svc.internals.mergeWindow(q, line) // 429 merge_window above the line's window (07)
228
236
  try { await store.beginMerge({ square_id: sq.id, expect_head: sq.head, actor: me.member_id, at: now() }) } catch (err) { throw fromStore(err) }
237
+ // A merge queued behind others on a busy line can run past LEASE_TTL_MS: it renews its
238
+ // square while it runs, so the sweeper ends only merges whose server is gone.
239
+ const renew = setInterval(() => {
240
+ store.setSquare({ square_id: sq.id, op: 'merge_renew', expect: { state: 'merging' }, patch: {}, actor: me.member_id, at: now() }).catch(() => {})
241
+ }, RENEW_MS)
242
+ renew.unref?.()
229
243
  let retries = 0
230
244
  const leaseKey = `${q.id}\u0000${line}`
231
245
  const holder = `merge:${sq.id}:${uuidv7()}`
@@ -278,6 +292,7 @@ export function mergeOps (svc) {
278
292
  await store.endMerge({ square_id: sq.id, why: err.code || 'error', actor: me.member_id, at: now() }).catch(() => {})
279
293
  throw fromStore(err)
280
294
  } finally {
295
+ clearInterval(renew)
281
296
  if (release) await release()
282
297
  }
283
298
  }
@@ -0,0 +1,202 @@
1
+ // Seam as an OAuth 2 provider (D4): any app (Quilt is one) asks a person for access to their
2
+ // quilts with the authorization code flow and PKCE (S256 only; RFC 6749, RFC 7636). Clients are
3
+ // public (no secret): an account registers one with its redirect addresses (`seam oauth client
4
+ // new`). The person approves on seam.heyquilt.com/oauth/authorize; the app trades the code at
5
+ // POST /oauth/token for an access token (sok_…, an hour) and a refresh token (sor_…, 90 days,
6
+ // a new one on each use; a used one presented again ends the whole grant).
7
+ //
8
+ // Scopes: `quilts:read` / `quilts:write` (every quilt the person is in), `quilt:<quilt>:read` /
9
+ // `quilt:<quilt>:write` (one quilt; given by id or slug, kept as its id). Write includes read.
10
+ // A token reaches only /v1/me, the quilts list and the quilts its scopes name, never more than
11
+ // the person themself may do.
12
+ import crypto from 'node:crypto'
13
+ import { QuiltError } from './errors.js'
14
+ import { uuidv7 } from './ids.js'
15
+
16
+ export const ACCESS_PREFIX = 'sok_'
17
+ export const REFRESH_PREFIX = 'sor_'
18
+ export const ACCESS_SECONDS = 3600
19
+ export const REFRESH_DAYS = 90
20
+ export const CODE_SECONDS = 60
21
+ const ACCESS = /^sok_[A-Za-z0-9_-]{43}$/
22
+ const REFRESH = /^sor_[A-Za-z0-9_-]{43}$/
23
+ const CLIENT = /^sc_[A-Za-z0-9_-]{22}$/
24
+ const SCOPE = /^(quilts:(read|write)|quilt:([^:\s]{1,200}):(read|write))$/
25
+
26
+ export const isOAuthToken = (s) => ACCESS.test(String(s || ''))
27
+ const sha = (s) => crypto.createHash('sha256').update(String(s)).digest('hex')
28
+ const secret = (prefix) => prefix + crypto.randomBytes(32).toString('base64url')
29
+ const s256 = (verifier) => crypto.createHash('sha256').update(String(verifier)).digest('base64url')
30
+
31
+ /** An OAuth refusal: `error` is RFC 6749's code, as the token endpoint answers it. */
32
+ const oauthError = (status, error, description) => new QuiltError(status, error, description, { error, error_description: description })
33
+
34
+ /** A redirect address a client may register: https, or http on this computer (an app's loopback). */
35
+ export function goodRedirect (uri) {
36
+ let u
37
+ try { u = new URL(String(uri)) } catch { return false }
38
+ if (u.hash || u.username || u.password) return false
39
+ if (u.protocol === 'https:') return true
40
+ return u.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(u.hostname)
41
+ }
42
+
43
+ /** Whether a token's scopes let it do `need` ('read' or 'write') on the quilt with this id. */
44
+ export function scopeAllows (scopes, quiltId, need) {
45
+ for (const s of scopes || []) {
46
+ const m = SCOPE.exec(s)
47
+ if (!m) continue
48
+ const level = m[2] || m[4]
49
+ if (need === 'write' && level !== 'write') continue
50
+ if (m[1].startsWith('quilts:') || m[3] === quiltId) return true
51
+ }
52
+ return false
53
+ }
54
+
55
+ export function oauthOps (svc) {
56
+ const { store, now, fromStore, quiltFor } = svc.internals
57
+ const wrap = async (fn) => { try { return await fn() } catch (err) { throw fromStore(err) } }
58
+ const accountOf = (me) => {
59
+ if (!me.account || me.kind !== 'person' || me.scopes) throw new QuiltError(403, 'needs_account', 'sign in with your Seam account for this')
60
+ return me.account
61
+ }
62
+ const clientView = (c) => ({ client_id: c.id, name: c.name, redirect_uris: c.redirect_uris, created_at: c.created_at, ...(c.revoked_at ? { revoked_at: c.revoked_at } : {}) })
63
+
64
+ /** POST /v1/oauth/clients { name, redirect_uris }: a public client (PKCE, no secret) the account owns. */
65
+ async function createClient (me, { name, redirect_uris: uris } = {}) {
66
+ const account = accountOf(me)
67
+ const n = String(name || '').trim()
68
+ if (!n || n.length > 100) throw new QuiltError(400, 'bad_request', 'name: the app\'s name, as people see it when they approve it (up to 100 characters)')
69
+ const list = (Array.isArray(uris) ? uris : [uris]).filter(Boolean).map(String)
70
+ if (!list.length || list.length > 10 || !list.every(goodRedirect)) throw new QuiltError(400, 'bad_request', 'redirect_uris: 1 to 10 addresses, https (or http on 127.0.0.1 / localhost), without a #fragment')
71
+ const at = now()
72
+ const c = await wrap(() => store.addOAuthClient({ id: 'sc_' + crypto.randomBytes(16).toString('base64url'), account_id: account, name: n, redirect_uris: [...new Set(list)], created_at: at }))
73
+ return clientView(c)
74
+ }
75
+
76
+ /** GET /v1/oauth/clients: the account's clients. */
77
+ async function clients (me) {
78
+ return { clients: (await store.oauthClientsOf(accountOf(me))).map(clientView) }
79
+ }
80
+
81
+ /** DELETE /v1/oauth/clients/:id: the client stops working, with every token it was given. */
82
+ async function revokeClient (me, id) {
83
+ return clientView(await wrap(() => store.revokeOAuthClient({ id: String(id), account_id: accountOf(me), at: now() })))
84
+ }
85
+
86
+ async function liveClient (id) {
87
+ const c = CLIENT.test(String(id || '')) ? await store.oauthClient(String(id)) : null
88
+ if (!c || c.revoked_at) throw oauthError(400, 'invalid_client', 'no such app (client_id), or it was revoked')
89
+ return c
90
+ }
91
+
92
+ /** The scopes asked for, each checked against what the person may do, quilts kept by id. */
93
+ async function grantable (me, scope) {
94
+ const asked = [...new Set(String(scope || '').split(/\s+/).filter(Boolean))]
95
+ if (!asked.length || asked.length > 20) throw oauthError(400, 'invalid_scope', 'scope: up to 20 of quilts:read, quilts:write, quilt:<quilt>:read, quilt:<quilt>:write')
96
+ const out = []
97
+ const quilts = []
98
+ for (const s of asked) {
99
+ const m = SCOPE.exec(s)
100
+ if (!m) throw oauthError(400, 'invalid_scope', `${s} is not a Seam scope (quilts:read, quilts:write, quilt:<quilt>:read, quilt:<quilt>:write)`)
101
+ if (m[1].startsWith('quilts:')) { out.push(s); continue }
102
+ let q
103
+ try { q = await quiltFor(me, m[3], m[4]) } catch { throw oauthError(400, 'invalid_scope', `you don't have ${m[4]} on ${m[3]}`) }
104
+ out.push(`quilt:${q.id}:${m[4]}`)
105
+ quilts.push({ id: q.id, slug: q.slug, name: q.name || q.slug, level: m[4] })
106
+ }
107
+ return { scopes: [...new Set(out)], quilts }
108
+ }
109
+
110
+ function checkRequest (c, { redirect_uri: redirectUri, response_type: type = 'code', code_challenge: challenge, code_challenge_method: method } = {}) {
111
+ if (!c.redirect_uris.includes(String(redirectUri || ''))) throw oauthError(400, 'invalid_request', 'redirect_uri is not one this app registered')
112
+ if (type !== 'code') throw oauthError(400, 'unsupported_response_type', 'response_type must be code')
113
+ if (method !== 'S256' || !/^[A-Za-z0-9_-]{43}$/.test(String(challenge || ''))) throw oauthError(400, 'invalid_request', 'PKCE is required: code_challenge (base64url SHA-256 of the verifier) and code_challenge_method=S256')
114
+ }
115
+
116
+ /** GET /v1/oauth/authorize?…: what the approval page shows (the app, the scopes), once the request checks out. */
117
+ async function authorizeInfo (me, params = {}) {
118
+ accountOf(me)
119
+ const c = await liveClient(params.client_id)
120
+ checkRequest(c, params)
121
+ const { scopes, quilts } = await grantable(me, params.scope)
122
+ return { client: { client_id: c.id, name: c.name }, redirect_uri: params.redirect_uri, scopes, quilts }
123
+ }
124
+
125
+ /** POST /v1/oauth/authorize { client_id, redirect_uri, scope, state, code_challenge, code_challenge_method, approve }: where to send the browser. */
126
+ async function authorize (me, params = {}) {
127
+ const account = accountOf(me)
128
+ const c = await liveClient(params.client_id)
129
+ checkRequest(c, params)
130
+ const back = new URL(params.redirect_uri)
131
+ if (params.state != null) back.searchParams.set('state', String(params.state))
132
+ if (params.approve === false || params.approve === '0') {
133
+ back.searchParams.set('error', 'access_denied')
134
+ return { redirect: back.toString() }
135
+ }
136
+ const { scopes } = await grantable(me, params.scope)
137
+ const code = crypto.randomBytes(32).toString('base64url')
138
+ const at = now()
139
+ await wrap(() => store.addOAuthCode({ id: uuidv7(at), code_hash: sha(code), client_id: c.id, account_id: account, redirect_uri: String(params.redirect_uri), challenge: params.code_challenge, scopes, created_at: at, expires_at: at + CODE_SECONDS * 1000 }))
140
+ back.searchParams.set('code', code)
141
+ return { redirect: back.toString() }
142
+ }
143
+
144
+ async function issue ({ account_id: account, client_id: client, scopes, grant_id: grant }, at) {
145
+ const access = secret(ACCESS_PREFIX)
146
+ const refresh = secret(REFRESH_PREFIX)
147
+ const c = await store.oauthClient(client)
148
+ const base = { account_id: account, client_id: client, grant_id: grant, scopes, name: c?.name || 'an app', created_at: at }
149
+ await wrap(() => store.addKey({ ...base, id: uuidv7(at), hash: sha(refresh), kind: 'oauth_refresh', expires_at: at + REFRESH_DAYS * 86400_000, replace: true }))
150
+ await wrap(() => store.addKey({ ...base, id: uuidv7(at), hash: sha(access), kind: 'oauth', expires_at: at + ACCESS_SECONDS * 1000 }))
151
+ return { access_token: access, token_type: 'Bearer', expires_in: ACCESS_SECONDS, refresh_token: refresh, scope: scopes.join(' ') }
152
+ }
153
+
154
+ /** POST /oauth/token (form or JSON): grant_type authorization_code (with code_verifier) or refresh_token. */
155
+ async function token (body = {}) {
156
+ const at = now()
157
+ const c = await liveClient(body.client_id)
158
+ if (body.grant_type === 'authorization_code') {
159
+ const row = await wrap(() => store.takeOAuthCode({ code_hash: sha(body.code || ''), at }))
160
+ if (!row || row.client_id !== c.id) throw oauthError(400, 'invalid_grant', 'this code is unknown')
161
+ if (row.was_used) {
162
+ await wrap(() => store.revokeGrant({ grant_id: row.id, at }))
163
+ throw oauthError(400, 'invalid_grant', 'this code was used already; the tokens it gave are revoked')
164
+ }
165
+ if (row.expires_at <= at) throw oauthError(400, 'invalid_grant', 'this code expired; sign in again')
166
+ if (row.redirect_uri !== String(body.redirect_uri || '')) throw oauthError(400, 'invalid_grant', 'redirect_uri differs from the one the code was given for')
167
+ if (!body.code_verifier || s256(body.code_verifier) !== row.challenge) throw oauthError(400, 'invalid_grant', 'code_verifier does not match the code_challenge')
168
+ return issue({ account_id: row.account_id, client_id: c.id, scopes: row.scopes, grant_id: row.id }, at)
169
+ }
170
+ if (body.grant_type === 'refresh_token') {
171
+ const k = REFRESH.test(String(body.refresh_token || '')) ? await store.keyByHash(sha(body.refresh_token)) : null
172
+ if (!k || k.kind !== 'oauth_refresh' || k.client_id !== c.id) throw oauthError(400, 'invalid_grant', 'this refresh token is unknown')
173
+ if (k.revoked_at) {
174
+ // A used refresh token again: someone else may hold it, so the grant ends for both.
175
+ await wrap(() => store.revokeGrant({ grant_id: k.grant_id, at }))
176
+ throw oauthError(400, 'invalid_grant', 'this refresh token was used already; sign in again')
177
+ }
178
+ if (k.expires_at <= at) throw oauthError(400, 'invalid_grant', 'this refresh token expired; sign in again')
179
+ return issue(k, at)
180
+ }
181
+ throw oauthError(400, 'unsupported_grant_type', 'grant_type: authorization_code or refresh_token')
182
+ }
183
+
184
+ /** POST /oauth/revoke { token }: ends the grant the token belongs to (RFC 7009: always 200). */
185
+ async function revokeToken ({ token: t } = {}) {
186
+ const k = ACCESS.test(String(t || '')) || REFRESH.test(String(t || '')) ? await store.keyByHash(sha(t)) : null
187
+ if (k && k.grant_id) await wrap(() => store.revokeGrant({ grant_id: k.grant_id, at: now() }))
188
+ return {}
189
+ }
190
+
191
+ /** The person an access token stands for, with its scopes; null when unknown, revoked or expired. */
192
+ async function oauthCaller (t) {
193
+ if (!isOAuthToken(t)) return null
194
+ const k = await store.keyByHash(sha(t))
195
+ if (!k || k.kind !== 'oauth' || k.revoked_at || k.expires_at <= now()) return null
196
+ const [account, c] = await Promise.all([store.getAccount(k.account_id), store.oauthClient(k.client_id)])
197
+ if (!account || !c || c.revoked_at) return null
198
+ return { member_id: account.member_id, kind: 'person', name: account.name, tool: c.name, account: account.id, key: k.id, scopes: k.scopes, client: c.id }
199
+ }
200
+
201
+ return { createOAuthClient: createClient, oauthClients: clients, revokeOAuthClient: revokeClient, oauthAuthorizeInfo: authorizeInfo, oauthAuthorize: authorize, oauthToken: token, oauthRevoke: revokeToken, oauthCaller }
202
+ }
@@ -14,6 +14,7 @@
14
14
  // green there). Seam does not run the checks; whoever runs them reports with a key.
15
15
  import { QuiltError, squareShort } from './service.js'
16
16
  import { uuidv7 } from './ids.js'
17
+ import { cleanRunners, cleanTiers, cleanGlobs } from './checks.js'
17
18
 
18
19
  export const KINDS = ['comment', 'approve', 'request_changes', 'dismiss']
19
20
  export const MAX_BODY = 20000
@@ -65,7 +66,8 @@ export function reviewOps (svc) {
65
66
 
66
67
  async function ruleOf (q, line) {
67
68
  const r = (await store.lineRules(q)).find((x) => x.line === line)
68
- return { approvals: r ? r.approvals : 0, checks: r?.checks || [] }
69
+ const tg = r?.targeting || {}
70
+ return { approvals: r ? r.approvals : 0, checks: r?.checks || [], runners: r?.runners || {}, ...(tg.tiers && Object.keys(tg.tiers).length ? { tiers: tg.tiers } : {}), ...(tg.tests?.length ? { tests: tg.tests } : {}), ...(tg.ci_config?.length ? { ci_config: tg.ci_config } : {}), since_seq: r?.since_seq ?? null }
69
71
  }
70
72
  const need = async (q, line) => (await ruleOf(q, line)).approvals
71
73
 
@@ -103,8 +105,10 @@ export function reviewOps (svc) {
103
105
  if (!sq.head) throw new QuiltError(400, 'bad_request', 'the square has no commits to check yet')
104
106
  const head = body.head || sq.head
105
107
  if (head !== sq.head) throw new QuiltError(409, 'head_moved', 'the square moved on since that commit: check its latest commit', { head: sq.head })
106
- try { await store.setSquareCheck({ quilt_id: q.id, square_id: sq.id, name, status: body.status, head, by: me.member_id, at: now() }) } catch (err) { throw fromStore(err) }
107
108
  const rule = await ruleOf(q.id, sq.line)
109
+ const noted = svc.internals.noteVerdict ? await svc.internals.noteVerdict(me, q, { name, tier: 'pre_merge', commit: head, square: sq.id, line: sq.line, verdict: body.status, run_key: body.run_key, url: body.url }) : {}
110
+ if (noted.advisory) return { square: squareShort(sq.id), check: name, status: body.status, head, advisory: true, runners: rule.runners[name], checks: await checksOf(q.id, sq, rule.checks) }
111
+ try { await store.setSquareCheck({ quilt_id: q.id, square_id: sq.id, name, status: body.status, head, by: me.member_id, at: now() }) } catch (err) { throw fromStore(err) }
108
112
  return { square: squareShort(sq.id), check: name, status: body.status, head, checks: await checksOf(q.id, sq, rule.checks) }
109
113
  }
110
114
 
@@ -158,8 +162,13 @@ export function reviewOps (svc) {
158
162
  const bad = checks.find((c) => !CHECK_NAME.test(c))
159
163
  if (bad !== undefined) throw new QuiltError(400, 'bad_request', `${bad || '(empty)'} is not a check name: letters, digits, dots, dashes, underscores, slashes or colons`)
160
164
  }
161
- try { await store.setLineRule({ quilt_id: q.id, line, approvals: n, checks, actor: me.member_id, at: now() }) } catch (err) { throw fromStore(err) }
162
- return { line, approvals: n, checks }
165
+ const runners = body.runners === undefined ? was.runners : cleanRunners(body.runners, (c) => CHECK_NAME.test(c))
166
+ const tiers = body.tiers === undefined ? (was.tiers || {}) : cleanTiers(body.tiers, (c) => CHECK_NAME.test(c))
167
+ const tests = body.tests === undefined ? (was.tests || []) : cleanGlobs(body.tests, 'tests')
168
+ const ciConfig = body.ci_config === undefined ? (was.ci_config || []) : cleanGlobs(body.ci_config, 'ci_config')
169
+ const targeting = { ...(Object.keys(tiers).length ? { tiers } : {}), ...(tests.length ? { tests } : {}), ...(ciConfig.length ? { ci_config: ciConfig } : {}) }
170
+ try { await store.setLineRule({ quilt_id: q.id, line, approvals: n, checks, runners, targeting, actor: me.member_id, at: now() }) } catch (err) { throw fromStore(err) }
171
+ return { line, approvals: n, checks, runners, ...targeting }
163
172
  }
164
173
 
165
174
  /** GET /lines/:name/policy */
@@ -167,7 +176,9 @@ export function reviewOps (svc) {
167
176
  const q = await quiltFor(me, ref, 'read')
168
177
  const line = String(lineName)
169
178
  if (!await store.getLine(q.id, line)) throw new QuiltError(404, 'not_found', `no line ${line}`)
170
- return { line, ...await ruleOf(q.id, line) }
179
+ const { since_seq: seq, ...rule } = await ruleOf(q.id, line)
180
+ void seq
181
+ return { line, ...rule }
171
182
  }
172
183
 
173
184
  /** Refuses a merge of `sq` into `line` that the line's rule does not allow yet (428 policy). */
@@ -178,6 +189,8 @@ export function reviewOps (svc) {
178
189
  const s = n ? standing(sq, await store.reviews(q, sq.id), n, await notAllowed(q, sq)) : { ready: true, have: 0, changes_requested_by: [] }
179
190
  const notGreen = rule.checks.length ? (await checksOf(q, sq, rule.checks)).filter((c) => c.required && c.status !== 'green') : []
180
191
  if (notGreen.length) {
192
+ // Asking to merge asks the runners of a required check that has nothing on the head yet.
193
+ if (svc.internals.askForChecks) await svc.internals.askForChecks(q, sq, notGreen.filter((x) => x.status !== 'red').map((x) => x.name), sq.holder)
181
194
  const c = notGreen[0]
182
195
  const why = { missing: 'has not reported', stale: 'has not reported on the latest commit', red: 'is red' }[c.status]
183
196
  throw new QuiltError(428, 'policy', `${line} needs check ${c.name} green on ${squareShort(sq.id)}'s latest commit and it ${why}: run it and report with seam check ${c.name} green --square ${squareShort(sq.id)}`,
@@ -4,6 +4,7 @@
4
4
  // reaches the same operations as the seam CLI and seam_* tools.
5
5
  import { QuiltError } from './service.js'
6
6
  import { classify, tokenKey } from './limits.js'
7
+ import { scopeAllows } from './oauth.js'
7
8
 
8
9
  const Q = '/v1/quilts/([^/]+)'
9
10
  const MAX_JSON = 96 * 1024 * 1024
@@ -27,12 +28,31 @@ export function quiltRoutes ({ quilts, identify }) {
27
28
  return identify(req)
28
29
  }
29
30
 
31
+ // An app's OAuth token (oauth.js) reaches /v1/me, the quilts list and the quilts its scopes name.
32
+ const READ_POSTS = /\/(have|blobs)$/
33
+ async function withinScope (m, req) {
34
+ if (!m.scopes) return
35
+ const p = new URL(req.url, 'http://x').pathname
36
+ if (req.method === 'GET' && (p === '/v1/me' || p === '/v1/quilts')) return
37
+ const q = new RegExp(`^${Q}(?:/|$)`).exec(p)
38
+ const need = req.method === 'GET' || READ_POSTS.test(p) ? 'read' : 'write'
39
+ if (q) {
40
+ let ref
41
+ try { ref = decodeURIComponent(q[1]) } catch { ref = q[1] }
42
+ const quilt = await quilts.internals.quiltFor(m, ref, 'read')
43
+ if (scopeAllows(m.scopes, quilt.id, need)) return
44
+ }
45
+ throw new QuiltError(403, 'insufficient_scope', `this app's access does not include ${need} here; ask for a wider scope`)
46
+ }
47
+ const inScope = (m, list) => m.scopes && !m.scopes.some((s) => s.startsWith('quilts:')) ? list.filter((q) => scopeAllows(m.scopes, q.id, 'read')) : list
48
+
30
49
  // Every answer is the server's record; a refusal carries its wire code (12's WriteResult).
31
50
  // Per-token rate limits (04, limits.js), when the quilts were made with them.
32
51
  const limiter = quilts.rateLimiter || null
33
52
  const run = (fn) => async (req, body, params) => {
34
53
  try {
35
54
  const m = await me(req)
55
+ await withinScope(m, req)
36
56
  if (limiter) limiter.take(tokenKey(req, m), classify(req.method, req.url, body), m.kind === 'person')
37
57
  return await fn(m, body, params, req)
38
58
  } catch (err) {
@@ -42,9 +62,43 @@ export function quiltRoutes ({ quilts, identify }) {
42
62
  }
43
63
  }
44
64
  const big = { maxBody: MAX_JSON }
65
+ // Seam's device flow, before a computer has a sign-in: no caller, a refusal as an answer.
66
+ const open = (fn) => async (req, body) => {
67
+ try { return await fn(body) } catch (err) {
68
+ if (err instanceof QuiltError) return [err.status, { ok: false, error: err.message, code: err.code, ...err.extra }]
69
+ throw err
70
+ }
71
+ }
45
72
 
46
73
  return [
47
- ['GET', /^\/v1\/quilts$/, run(async (m) => ({ quilts: await quilts.quilts(m) }))],
74
+ ['POST', /^\/v1\/imports$/, run(async (m, b) => [202, await quilts.startImport(m, b)])],
75
+ ['GET', /^\/v1\/imports\/([^/]+)$/, run((m, b, [id]) => quilts.importStatus(m, decodeURIComponent(id)))],
76
+ ['POST', /^\/v1\/imports\/([^/]+)\/cutover$/, run((m, b, [id]) => quilts.cutover(m, decodeURIComponent(id), b || {}))],
77
+ // A poke carries the import's secret (Bearer sip_…) or a member's key.
78
+ ['POST', /^\/v1\/imports\/([^/]+)\/poke$/, async (req, body, [id]) => {
79
+ const h = String(req.headers?.authorization || '')
80
+ if (!h.startsWith('Bearer sip_')) return run(async (m) => [202, await quilts.pokeImport(m, decodeURIComponent(id))])(req, body, [id])
81
+ try { return [202, await quilts.pokeImport(null, decodeURIComponent(id), { secret: h.slice(7).trim() })] } catch (err) { if (err instanceof QuiltError) return [err.status, { ok: false, error: err.message, code: err.code }]; throw err }
82
+ }],
83
+ ['GET', /^\/v1\/me$/, run((m) => quilts.whoami(m))],
84
+ ['POST', /^\/v1\/me\/signout$/, run((m) => quilts.signout(m))],
85
+ ['POST', /^\/v1\/device\/start$/, open((b) => quilts.deviceStart(b))],
86
+ ['POST', /^\/v1\/device\/poll$/, open((b) => quilts.devicePoll(b))],
87
+ ['POST', /^\/v1\/device\/resume$/, open((b) => quilts.deviceResume(b))],
88
+ ['POST', /^\/v1\/device\/approve$/, run((m, b) => quilts.deviceApprove(m, b))],
89
+ ['GET', /^\/v1\/device\/([A-Za-z0-9-]+)$/, run((m, b, [code]) => quilts.deviceLinkInfo(m, code))],
90
+ ['GET', /^\/v1\/agents$/, run((m) => quilts.agents(m))],
91
+ ['POST', /^\/v1\/agents$/, run(async (m, b) => [201, await quilts.createAgent(m, b)])],
92
+ ['POST', /^\/v1\/agents\/([^/]+)\/key$/, run((m, b, [id]) => quilts.refreshAgentKey(m, decodeURIComponent(id)))],
93
+ ['DELETE', /^\/v1\/agents\/([^/]+)$/, run((m, b, [id]) => quilts.revokeAgent(m, decodeURIComponent(id)))],
94
+ ['GET', /^\/v1\/oauth\/clients$/, run((m) => quilts.oauthClients(m))],
95
+ ['POST', /^\/v1\/oauth\/clients$/, run(async (m, b) => [201, await quilts.createOAuthClient(m, b)])],
96
+ ['DELETE', /^\/v1\/oauth\/clients\/([^/]+)$/, run((m, b, [id]) => quilts.revokeOAuthClient(m, decodeURIComponent(id)))],
97
+ ['GET', /^\/v1\/oauth\/authorize$/, run((m, b, p, req) => quilts.oauthAuthorizeInfo(m, Object.fromEntries(query(req))))],
98
+ ['POST', /^\/v1\/oauth\/authorize$/, run((m, b) => quilts.oauthAuthorize(m, b))],
99
+ ['POST', /^\/oauth\/token$/, open((b) => quilts.oauthToken(b))],
100
+ ['POST', /^\/oauth\/revoke$/, open((b) => quilts.oauthRevoke(b))],
101
+ ['GET', /^\/v1\/quilts$/, run(async (m) => ({ quilts: inScope(m, await quilts.quilts(m)) }))],
48
102
  ['POST', /^\/v1\/quilts$/, run(async (m, b) => [201, await quilts.createQuilt(m, b)])],
49
103
  ['GET', new RegExp(`^${Q}$`), run((m, b, [q]) => quilts.quiltInfo(m, q))],
50
104
  ['POST', new RegExp(`^${Q}/members$`), run((m, b, [q]) => quilts.addMember(m, q, b))],
@@ -65,12 +119,15 @@ export function quiltRoutes ({ quilts, identify }) {
65
119
  ['GET', new RegExp(`^${Q}/squares/([^/]+)/reviews$`), run((m, b, [q, s]) => quilts.reviews(m, q, s))],
66
120
  ['POST', new RegExp(`^${Q}/squares/([^/]+)/reviews$`), run(async (m, b, [q, s]) => [201, await quilts.review(m, q, s, b)])],
67
121
  ['POST', new RegExp(`^${Q}/squares/([^/]+)/checks$`), run((m, b, [q, s]) => quilts.reportSquareCheck(m, q, s, b))],
122
+ ['POST', new RegExp(`^${Q}/squares/([^/]+)/checks/([^/]+)/rerun$`), run(async (m, b, [q, s, c]) => [201, await quilts.rerunCheck(m, q, { ...b, check: decodeURIComponent(c), square: s })])],
123
+ ['POST', new RegExp(`^${Q}/lines/([^/]+)/checks/([^/]+)/rerun$`), run(async (m, b, [q, line, c]) => [201, await quilts.rerunCheck(m, q, { ...b, check: decodeURIComponent(c), line: decodeURIComponent(line) })])],
68
124
  ['GET', new RegExp(`^${Q}/lines/([^/]+)/policy$`), run((m, b, [q, line]) => quilts.linePolicy(m, q, decodeURIComponent(line)))],
69
125
  ['PUT', new RegExp(`^${Q}/lines/([^/]+)/policy$`), run((m, b, [q, line]) => quilts.setLinePolicy(m, q, decodeURIComponent(line), b))],
70
126
  ['GET', new RegExp(`^${Q}/squares/([^/]+)/live$`), run((m, b, [q, s]) => quilts.live(m, q, s))],
71
127
  ['PUT', new RegExp(`^${Q}/squares/([^/]+)/live$`), run((m, b, [q, s]) => quilts.setLive(m, q, s, b)), big],
72
128
  ['POST', new RegExp(`^${Q}/squares/([^/]+)/handoff$`), run((m, b, [q, s]) => quilts.handoff(m, q, s, b))],
73
129
  ['POST', new RegExp(`^${Q}/squares/([^/]+)/abandon$`), run((m, b, [q, s]) => quilts.abandon(m, q, s, b))],
130
+ ['POST', new RegExp(`^${Q}/squares/([^/]+)/merged$`), run((m, b, [q, s]) => quilts.squareMerged(m, q, s, b))],
74
131
  ['POST', new RegExp(`^${Q}/squares/([^/]+)/reopen$`), run((m, b, [q, s]) => quilts.reopen(m, q, s, b))],
75
132
  ['POST', new RegExp(`^${Q}/squares/([^/]+)/merge$`), run(async (m, b, [q, s]) => { const r = await quilts.mergeSquare(m, q, s, b); return r.noop ? r : [201, r] })],
76
133
  ['POST', new RegExp(`^${Q}/squares/([^/]+)/fold$`), run((m, b, [q, s]) => quilts.fold(m, q, s, b))],
@@ -82,6 +139,8 @@ export function quiltRoutes ({ quilts, identify }) {
82
139
  ['POST', new RegExp(`^${Q}/lines$`), run(async (m, b, [q]) => [201, await quilts.newLine(m, q, b)])],
83
140
  ['POST', new RegExp(`^${Q}/lines/([^/]+)/freeze$`), run((m, b, [q, line]) => quilts.freezeLine(m, q, line))],
84
141
  ['POST', new RegExp(`^${Q}/lines/([^/]+)/unfreeze$`), run((m, b, [q, line]) => quilts.unfreezeLine(m, q, line))],
142
+ ['PUT', new RegExp(`^${Q}/export$`), run((m, b, [q]) => quilts.setExport(m, q, b))],
143
+ ['GET', new RegExp(`^${Q}/export$`), run((m, b, [q]) => quilts.exportStatus(m, q))],
85
144
  ['GET', new RegExp(`^${Q}/tags$`), run((m, b, [q]) => quilts.tags(m, q))],
86
145
  ['POST', new RegExp(`^${Q}/tags$`), run(async (m, b, [q]) => [201, await quilts.createTag(m, q, b)])],
87
146
  ['GET', new RegExp(`^${Q}/tags/(.+)$`), run((m, b, [q, name]) => quilts.getTag(m, q, decodeURIComponent(name)))],
@@ -98,6 +157,7 @@ export function quiltRoutes ({ quilts, identify }) {
98
157
  ['POST', new RegExp(`^${Q}/patches/(\\d+)/revert$`), run(async (m, b, [q, n]) => [201, await quilts.revert(m, q, { ...b, rev: `${b.line || 'main'}:${n}` })])],
99
158
  ['POST', new RegExp(`^${Q}/revert$`), run(async (m, b, [q]) => [201, await quilts.revert(m, q, b)])],
100
159
 
160
+ ['GET', new RegExp(`^${Q}/checks$`), run((m, b, [q], req) => { const s = query(req); return quilts.checkRuns(m, q, { state: s.get('state') || undefined, name: s.get('name') || undefined, tier: s.get('tier') || undefined, line: s.get('line') || undefined, square: s.get('square') || undefined, limit: s.get('limit') || 100 }) })],
101
161
  ['GET', new RegExp(`^${Q}/events$`), run((m, b, [q], req) => { const s = query(req); return quilts.events(m, q, { after: s.get('after') || 0, kinds: list(s.get('kinds')), limit: s.get('limit') || 100 }) })],
102
162
  ['POST', new RegExp(`^${Q}/webhook$`), run((m, b, [q]) => quilts.subscribe(m, q, b))],
103
163
  ['GET', new RegExp(`^${Q}/show/([^/]+)$`), run((m, b, [q, rev], req) => { const s = query(req); return quilts.show(m, q, rev, { format: s.get('format') || 'unified', paths: list(s.get('paths')) }) })],