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.
- package/RELEASES.md +19 -0
- package/bin/quilt.js +23 -2
- package/package.json +2 -1
- package/src/api/memory-store.js +5 -1
- package/src/api/routes/seam-connect.js +103 -0
- package/src/api/routes/session-migrate.js +36 -0
- package/src/api/server.js +37 -9
- package/src/api/supabase-store.js +10 -0
- package/src/connection.js +4 -1
- package/src/control.js +12 -0
- package/src/history.js +15 -1
- package/src/mcp.js +12 -1
- package/src/quilts/accounts.js +136 -0
- package/src/quilts/affected.js +132 -0
- package/src/quilts/checks.js +308 -0
- package/src/quilts/cli.js +97 -10
- package/src/quilts/client.js +9 -0
- package/src/quilts/describe.js +31 -1
- package/src/quilts/events.js +13 -2
- package/src/quilts/export-remote.js +156 -0
- package/src/quilts/import-follow.js +236 -0
- package/src/quilts/import-git.js +6 -3
- package/src/quilts/import.js +67 -5
- package/src/quilts/index.js +28 -6
- package/src/quilts/keys.js +186 -0
- package/src/quilts/merge.js +16 -1
- package/src/quilts/oauth.js +202 -0
- package/src/quilts/reviews.js +18 -5
- package/src/quilts/routes.js +61 -1
- package/src/quilts/runner.js +95 -0
- package/src/quilts/server.js +144 -0
- package/src/quilts/service.js +41 -1
- package/src/quilts/setup.js +9 -2
- package/src/quilts/store-memory.js +164 -3
- package/src/quilts/store-rpc.js +12 -1
- package/src/quilts/tools.js +39 -11
- package/src/quilts/window.js +2 -0
- package/src/relay-mcp.js +33 -3
- package/src/relay-prune.js +4 -1
- package/src/server.js +317 -14
- package/src/session-follow.js +81 -0
- package/src/session-layout3.js +60 -0
- package/src/session-migrate.js +119 -0
- package/src/session-quilt.js +145 -0
- package/src/session-seam.js +71 -0
- package/src/session.js +74 -2
- package/src/source-live/live-block.js +4 -3
- 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
|
+
}
|
package/src/quilts/merge.js
CHANGED
|
@@ -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',
|
|
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
|
+
}
|
package/src/quilts/reviews.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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)}`,
|
package/src/quilts/routes.js
CHANGED
|
@@ -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
|
-
['
|
|
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')) }) })],
|