heyquilt 0.3.90 → 0.3.107
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/README.md +14 -11
- package/RELEASES.md +135 -2
- package/bin/quilt.js +45 -12
- package/package.json +3 -3
- package/src/account.js +28 -0
- package/src/agents/claude-code.js +11 -4
- package/src/agents/cursor-transcripts.js +11 -4
- package/src/ai-vm-signin.js +14 -2
- package/src/ai-vm.js +150 -8
- package/src/api/agent-auth.js +9 -3
- package/src/api/fly-machines.js +72 -15
- package/src/api/memory-store.js +142 -6
- package/src/api/relay-cluster.js +337 -43
- package/src/api/relay-machines.js +35 -3
- package/src/api/releases.js +87 -0
- package/src/api/routes/agents.js +111 -16
- package/src/api/routes/ai-vm.js +146 -19
- package/src/api/routes/orgs.js +3 -1
- package/src/api/routes/secrets.js +448 -0
- package/src/api/routes/workspaces.js +8 -1
- package/src/api/sandbox-keys.js +57 -0
- package/src/api/secrets-access.js +93 -0
- package/src/api/server.js +40 -9
- package/src/api/supabase-store.js +121 -11
- package/src/autowork.js +3 -15
- package/src/catchup.js +1 -1
- package/src/clash.js +10 -22
- package/src/control.js +18 -15
- package/src/fsutil.js +11 -1
- package/src/gitstate.js +29 -5
- package/src/hooks.js +1 -1
- package/src/mcp-proxy.js +7 -2
- package/src/mcp.js +22 -25
- package/src/merge3.js +94 -12
- package/src/pass-source.js +9 -2
- package/src/pathrules.js +7 -0
- package/src/relay-clash.js +101 -484
- package/src/relay-githttp.js +36 -7
- package/src/relay-mcp.js +19 -7
- package/src/relay-metrics.js +66 -1
- package/src/relay-outbound.js +154 -25
- package/src/relay-sync.js +133 -136
- package/src/relay-turn-sync.js +250 -51
- package/src/relay-upstream.js +49 -179
- package/src/releases.js +16 -9
- package/src/runner.js +3 -2
- package/src/secret-import.js +55 -0
- package/src/secret-names.js +26 -0
- package/src/secret-run.js +80 -0
- package/src/secrets-client.js +56 -0
- package/src/server.js +71 -44
- package/src/session.js +347 -262
- package/src/status.js +3 -5
- package/src/sync-words.js +1 -1
- package/src/turn-sync-plan.js +13 -10
- package/src/turn-sync.js +32 -11
- package/src/ui/agent-guide.js +4 -4
- package/src/ui/app.css +208 -23
- package/src/ui/app.js +4 -0
- package/src/ui/cloud-ai-model.js +3 -2
- package/src/ui/common.js +2 -0
- package/src/ui/fileview.js +28 -1
- package/src/ui/home.js +32 -33
- package/src/ui/loom.css +18 -2
- package/src/ui/loom.js +41 -8
- package/src/ui/rail-model.js +6 -9
- package/src/ui/releases.js +1 -1
- package/src/ui/sandbox-keys-model.js +53 -0
- package/src/ui/sandbox-keys.js +132 -0
- package/src/ui/secret-rules.js +255 -0
- package/src/ui/secrets-view.js +162 -0
- package/src/ui/secrets.js +283 -0
- package/src/ui/session.js +47 -149
- package/src/ui/tree.js +23 -18
- package/src/ui/workspaces.js +5 -0
- package/src/ui-server.js +140 -28
- package/src/update-check.js +1 -1
- package/src/upstream.js +75 -38
- package/src/vault/crypto.js +96 -0
- package/src/vault/kms.js +61 -0
- package/src/vault/main.js +33 -0
- package/src/vault/server.js +279 -0
- package/src/vault/sigv4.js +38 -0
- package/src/ui/merges.js +0 -158
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
// Secrets (docs/superpowers/specs/2026-10-18-secrets.md): names and settings for people, run
|
|
2
|
+
// tokens for `quilt run`, the app's inline "allow" for people-only secrets, and the vault's
|
|
3
|
+
// internal routes. No route here ever answers a value; the API can't open one either (the
|
|
4
|
+
// vault holds the keys). Sealed blobs pass through only to the vault, behind its shared secret.
|
|
5
|
+
import crypto from 'node:crypto'
|
|
6
|
+
import { HttpError, needId } from '../http.js'
|
|
7
|
+
import { newToken, hashToken } from '../tokens.js'
|
|
8
|
+
import { orgAccess } from '../org-access.js'
|
|
9
|
+
import { workspaceReach } from '../workspace-reach.js'
|
|
10
|
+
import { isAgentKey } from '../agent-auth.js'
|
|
11
|
+
import { sessionMember, roomLevels, workspaceLevels, resolveLevels, mayUse, LEVEL_WORDS } from '../secrets-access.js'
|
|
12
|
+
import { cleanSecretName, cleanUpstream, cleanFormat, formatLabel, formatId, envVarsFor, shortDigest, WHO, MAX_ENTRIES } from '../../ui/secret-rules.js'
|
|
13
|
+
|
|
14
|
+
export const TICKET_TTL_MS = 5 * 60 * 1000
|
|
15
|
+
export const RUN_TTL_MS = 60 * 60 * 1000
|
|
16
|
+
// How long a waiting request is held, and how often it looks again (other API machines answer too).
|
|
17
|
+
export const WAIT_MS = 25 * 1000
|
|
18
|
+
const LOOK_EVERY_MS = 1000
|
|
19
|
+
// One use logged per run and secret per minute: a busy process makes many requests.
|
|
20
|
+
const USE_LOG_MS = 60 * 1000
|
|
21
|
+
const ROOM = /^[A-Za-z0-9_-]{1,64}$/
|
|
22
|
+
const SCOPE = '(org|workspace|session)'
|
|
23
|
+
const NAME = '([A-Z_][A-Z0-9_]{0,63})'
|
|
24
|
+
const DIGEST = /^[0-9a-f]{64}$/
|
|
25
|
+
const MAX_LABEL = 200
|
|
26
|
+
|
|
27
|
+
export function secretRoutes (ctx) {
|
|
28
|
+
const { store, person, agentAuth, bearer, now, log = () => {}, vaultSecret = '', vaultUrl = 'https://vault.heyquilt.com', allowLocalUpstreams = false, waitMs = WAIT_MS } = ctx
|
|
29
|
+
const { caller: reachCaller, grantsIn, reach } = workspaceReach(ctx)
|
|
30
|
+
// Where people and processes reach the vault (a function in tests, whose vault starts later).
|
|
31
|
+
const vaultAt = () => String(typeof vaultUrl === 'function' ? vaultUrl() : vaultUrl).replace(/\/+$/, '')
|
|
32
|
+
|
|
33
|
+
// Small per-key limits: a person can't mint tickets or runs in a loop.
|
|
34
|
+
const hits = new Map()
|
|
35
|
+
const limit = (key, max, message) => {
|
|
36
|
+
const t = now()
|
|
37
|
+
const recent = (hits.get(key) || []).filter((x) => t - x < 60_000)
|
|
38
|
+
if (recent.length >= max) throw new HttpError(429, message)
|
|
39
|
+
recent.push(t)
|
|
40
|
+
hits.set(key, recent)
|
|
41
|
+
if (hits.size > 10_000) hits.delete(hits.keys().next().value)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Woken when a run's asks are answered, or a person gets a new ask (this machine only; the
|
|
45
|
+
// waits also look at the store every second, for answers that came in on another machine).
|
|
46
|
+
const waiters = new Map() // key -> Set(fn)
|
|
47
|
+
const wake = (key) => { for (const fn of waiters.get(key) || []) fn(); waiters.delete(key) }
|
|
48
|
+
async function waitUntil (key, done, ms) {
|
|
49
|
+
const end = now() + ms
|
|
50
|
+
while (true) {
|
|
51
|
+
const r = await done()
|
|
52
|
+
if (r || now() >= end) return r
|
|
53
|
+
await new Promise((resolve) => {
|
|
54
|
+
const fn = () => { clearTimeout(timer); resolve() }
|
|
55
|
+
const timer = setTimeout(() => { waiters.get(key)?.delete(fn); resolve() }, Math.min(LOOK_EVERY_MS, Math.max(0, end - now())))
|
|
56
|
+
if (!waiters.has(key)) waiters.set(key, new Set())
|
|
57
|
+
waiters.get(key).add(fn)
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const nameOf = async (account) => {
|
|
63
|
+
const [kind, id] = String(account || '').split(':')
|
|
64
|
+
if (kind === 'agent') return (await store.agentById(id).catch(() => null))?.name || 'An agent'
|
|
65
|
+
if (kind === 'person') return (await store.profile(id))?.name || 'Someone'
|
|
66
|
+
return ''
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The person or agent calling: { account, kind, userId, agent }. Hosted agents come with a pass. */
|
|
70
|
+
async function whoCalls (req, { pass = false } = {}) {
|
|
71
|
+
// An agent's access key (qa_) or app key (qk_, a sandbox's QUILT_AGENT_KEY).
|
|
72
|
+
if (!pass && isAgentKey(bearer(req))) {
|
|
73
|
+
const { agent } = await agentAuth.agentFromRequest(req)
|
|
74
|
+
return { account: `agent:${agent.id}`, kind: 'agent', userId: null, agent }
|
|
75
|
+
}
|
|
76
|
+
if (pass) {
|
|
77
|
+
const c = await reachCaller(req)
|
|
78
|
+
return c.agent ? { account: c.account, kind: 'agent', userId: null, agent: c.agent, hosted: /^QuiltPass\s/i.test(String(req.headers.authorization || '')) } : { account: c.account, kind: 'person', userId: c.userId, agent: null }
|
|
79
|
+
}
|
|
80
|
+
const p = await person(req)
|
|
81
|
+
return { account: `person:${p.userId}`, kind: 'person', userId: p.userId, agent: null }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* One scope as a person reaches it: { type, id, name, levels, canManage, me }. Teams by slug
|
|
86
|
+
* (members see, the owner and Org settings: Update manage); workspaces by id (anyone who
|
|
87
|
+
* reaches it sees, admins manage); sessions by room (members see, the owner manages).
|
|
88
|
+
*/
|
|
89
|
+
async function scopeOf (req, type, rawId) {
|
|
90
|
+
const me = await whoCalls(req)
|
|
91
|
+
if (me.kind !== 'person') throw new HttpError(403, 'agents use secrets through quilt run; people manage them')
|
|
92
|
+
if (type === 'org') {
|
|
93
|
+
const a = await orgAccess(store, me.userId, rawId)
|
|
94
|
+
return { type, id: a.org.id, name: a.org.name, slug: a.org.slug, levels: [{ type, id: a.org.id, name: a.org.name, slug: a.org.slug }], canManage: a.can('org', 'u'), me }
|
|
95
|
+
}
|
|
96
|
+
if (type === 'workspace') {
|
|
97
|
+
const r = await reach(req, needId(rawId, 'workspace'))
|
|
98
|
+
return { type, id: r.ws.id, name: r.ws.name, levels: await workspaceLevels(store, r.ws), canManage: !!r.access.admin, me, ws: r.ws }
|
|
99
|
+
}
|
|
100
|
+
const room = String(rawId)
|
|
101
|
+
if (!ROOM.test(room)) throw new HttpError(404, 'no such session')
|
|
102
|
+
const found = await roomLevels(store, room)
|
|
103
|
+
if (!found || !(await sessionMember(store, room, me.account))) throw new HttpError(404, 'no such session')
|
|
104
|
+
return { type, id: room, name: found.session.name || '', levels: found.levels, canManage: found.session.ownerAccount === me.account, me, session: found.session }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const needManage = (s) => { if (!s.canManage) throw new HttpError(403, s.type === 'org' ? 'Only the team\'s owner, or a role with Org settings: Update, manages team secrets.' : s.type === 'workspace' ? 'Only the workspace\'s admins manage its secrets.' : 'Only the session owner manages its secrets.') }
|
|
108
|
+
|
|
109
|
+
const rowView = (s, level, extra = {}) => ({
|
|
110
|
+
id: s.id, name: s.name, level: level.type, levelName: level.name || '', digest: shortDigest(s.digest), upstream: s.upstream,
|
|
111
|
+
format: s.format, formatId: formatId(s.format), formatLabel: formatLabel(s.format), who: s.who, usedAt: s.lastUsedAt ?? null, updatedAt: s.updatedAt, ...extra
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
async function settingsView (settings) {
|
|
115
|
+
return {
|
|
116
|
+
inheritOff: !!settings.inheritOff,
|
|
117
|
+
inheritOffBy: settings.inheritOff && settings.inheritOffBy ? await nameOf(settings.inheritOffBy) : '',
|
|
118
|
+
inheritOffAt: settings.inheritOff ? settings.inheritOffAt ?? null : null,
|
|
119
|
+
agentsOn: !!settings.agentsOn,
|
|
120
|
+
agents: settings.agents === 'all' || !Array.isArray(settings.agents) ? 'all' : settings.agents
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Agents one may pick for "Agents can use secrets": the scope's agents and the caller's own. */
|
|
125
|
+
async function agentChoices (s) {
|
|
126
|
+
const ids = new Set()
|
|
127
|
+
for (const a of await store.listPersonalAgents(s.me.userId)) ids.add(a.id)
|
|
128
|
+
if (s.type === 'org') for (const a of await store.listOrgAgents(s.id)) ids.add(a.id)
|
|
129
|
+
if (s.type === 'workspace') {
|
|
130
|
+
for (const m of await store.listWorkspaceMembers(s.id)) if (m.account.startsWith('agent:')) ids.add(m.account.slice(6))
|
|
131
|
+
if (s.ws.orgId) for (const a of await store.listOrgAgents(s.ws.orgId)) ids.add(a.id)
|
|
132
|
+
}
|
|
133
|
+
if (s.type === 'session') {
|
|
134
|
+
for (const g of await store.listGrants(s.id)) if (g.account.startsWith('agent:')) ids.add(g.account.slice(6))
|
|
135
|
+
for (const v of await store.visitsInRooms([s.id])) if (v.account.startsWith('agent:')) ids.add(v.account.slice(6))
|
|
136
|
+
}
|
|
137
|
+
const out = []
|
|
138
|
+
for (const id of ids) { const a = await store.agentById(id).catch(() => null); if (a && !a.revokedAt) out.push({ id: a.id, name: a.name }) }
|
|
139
|
+
return out.sort((a, b) => a.name.localeCompare(b.name))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function scopeView (s) {
|
|
143
|
+
const { levels, effective } = await resolveLevels(store, s.levels)
|
|
144
|
+
const [own, ...parents] = levels
|
|
145
|
+
const inherited = []
|
|
146
|
+
for (const l of parents) {
|
|
147
|
+
for (const sec of l.secrets) {
|
|
148
|
+
const winner = effective.get(sec.name)
|
|
149
|
+
const overridden = !l.off && winner && winner.secret.id !== sec.id
|
|
150
|
+
inherited.push(rowView(sec, l, { off: l.off, overridden: !!overridden }))
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
scope: { type: s.type, id: s.id, name: s.name, ...(s.slug ? { slug: s.slug } : {}) },
|
|
155
|
+
// The address of each level's own page: a team by its slug.
|
|
156
|
+
levels: s.levels.map((l) => ({ type: l.type, id: l.type === 'org' ? l.slug : l.id, name: l.name })),
|
|
157
|
+
canManage: s.canManage,
|
|
158
|
+
settings: await settingsView(own.settings),
|
|
159
|
+
secrets: own.secrets.map((sec) => rowView(sec, own)),
|
|
160
|
+
inherited,
|
|
161
|
+
offCount: inherited.filter((r) => r.off).length,
|
|
162
|
+
agentChoices: s.canManage ? await agentChoices(s) : [],
|
|
163
|
+
vault: vaultAt()
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function cleanEntries (entries) {
|
|
168
|
+
if (!Array.isArray(entries) || !entries.length) throw new HttpError(400, 'Say which secrets to save.')
|
|
169
|
+
if (entries.length > MAX_ENTRIES) throw new HttpError(400, `At most ${MAX_ENTRIES} secrets at once.`)
|
|
170
|
+
const seen = new Set()
|
|
171
|
+
return entries.map((e) => {
|
|
172
|
+
try {
|
|
173
|
+
const name = cleanSecretName(e && e.name)
|
|
174
|
+
if (seen.has(name)) throw new Error(`${name} is there twice.`)
|
|
175
|
+
seen.add(name)
|
|
176
|
+
const who = WHO.includes(e.who) ? e.who : 'agents'
|
|
177
|
+
return { name, upstream: cleanUpstream(e.upstream, { allowLocal: allowLocalUpstreams }), format: cleanFormat(e.format ?? 'bearer'), who }
|
|
178
|
+
} catch (err) { throw new HttpError(400, `${e && e.name ? `${String(e.name).slice(0, 64)}: ` : ''}${err.message}`) }
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ---------------------------------------------------------------- runs (`quilt run`) --
|
|
183
|
+
|
|
184
|
+
/** What each name the session reaches comes to for this caller (and run): [{ name, level, …, state, why }]. */
|
|
185
|
+
async function namesFor (room, who, run = null) {
|
|
186
|
+
const found = await roomLevels(store, room)
|
|
187
|
+
if (!found) return null
|
|
188
|
+
const { effective } = await resolveLevels(store, found.levels)
|
|
189
|
+
const asks = run ? new Map((await store.listSecretRunAsks(run.id)).map((a) => [a.secretId, a])) : null
|
|
190
|
+
const out = []
|
|
191
|
+
for (const [name, { secret, level }] of [...effective].sort(([a], [b]) => a.localeCompare(b))) {
|
|
192
|
+
const verdict = mayUse({ kind: who.kind, agentId: who.agent?.id || null, secret, level, ask: asks ? asks.get(secret.id) || null : undefined })
|
|
193
|
+
out.push({ name, secretId: secret.id, level: level.type, levelName: level.name || '', upstream: secret.upstream, who: secret.who, state: verdict.state, ...(verdict.why ? { why: verdict.why } : {}), env: envVarsFor(name, secret.upstream) })
|
|
194
|
+
}
|
|
195
|
+
return out
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const publicNames = (list) => list.map(({ secretId, ...rest }) => rest)
|
|
199
|
+
|
|
200
|
+
async function runOf (id, me) {
|
|
201
|
+
const run = await store.secretRunById(needId(id, 'run'))
|
|
202
|
+
if (!run || run.account !== me.account) throw new HttpError(404, 'no such run')
|
|
203
|
+
return run
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const runLive = (run) => run && !run.revokedAt && run.expiresAt > now()
|
|
207
|
+
|
|
208
|
+
// ---------------------------------------------------------------- the vault's routes --
|
|
209
|
+
|
|
210
|
+
function needVault (req) {
|
|
211
|
+
const got = Buffer.from(String(req.headers['x-quilt-vault-secret'] || ''))
|
|
212
|
+
const want = Buffer.from(String(vaultSecret))
|
|
213
|
+
if (!vaultSecret || got.length !== want.length || !crypto.timingSafeEqual(got, want)) throw new HttpError(401, 'vault only')
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const usesLogged = new Map() // `${run}\n${secret}` -> at
|
|
217
|
+
|
|
218
|
+
const routes = [
|
|
219
|
+
['GET', new RegExp(`^/v1/secrets/${SCOPE}/([^/]+)$`), async (req, body, [type, id]) => scopeView(await scopeOf(req, type, id))],
|
|
220
|
+
|
|
221
|
+
['PUT', new RegExp(`^/v1/secrets/${SCOPE}/([^/]+)/settings$`), async (req, body, [type, id]) => {
|
|
222
|
+
const s = await scopeOf(req, type, id)
|
|
223
|
+
needManage(s)
|
|
224
|
+
const patch = {}
|
|
225
|
+
if (body.inheritOff !== undefined) {
|
|
226
|
+
if (s.levels.length < 2) throw new HttpError(400, s.type === 'org' ? 'A team has nothing above it to switch off.' : 'Nothing above this to switch off: it is not in a workspace or team.')
|
|
227
|
+
patch.inheritOff = body.inheritOff === true
|
|
228
|
+
patch.inheritOffBy = patch.inheritOff ? s.me.account : null
|
|
229
|
+
patch.inheritOffAt = patch.inheritOff ? now() : null
|
|
230
|
+
}
|
|
231
|
+
if (body.agentsOn !== undefined) patch.agentsOn = body.agentsOn === true
|
|
232
|
+
if (body.agents !== undefined) {
|
|
233
|
+
if (body.agents === 'all') patch.agents = 'all'
|
|
234
|
+
else if (Array.isArray(body.agents) && body.agents.length <= 100) patch.agents = [...new Set(body.agents.map((a) => needId(a, 'agent')))]
|
|
235
|
+
else throw new HttpError(400, 'agents is "all" or a list of agent ids')
|
|
236
|
+
}
|
|
237
|
+
await store.putSecretSettings(s.type, s.id, patch)
|
|
238
|
+
return scopeView(s)
|
|
239
|
+
}],
|
|
240
|
+
|
|
241
|
+
// A write ticket: what this person may save here, once, for five minutes. The value goes
|
|
242
|
+
// from their app or browser straight to the vault (POST <vault>/v1/write), never through here.
|
|
243
|
+
['POST', new RegExp(`^/v1/secrets/${SCOPE}/([^/]+)/tickets$`), async (req, body, [type, id]) => {
|
|
244
|
+
const s = await scopeOf(req, type, id)
|
|
245
|
+
needManage(s)
|
|
246
|
+
limit(`ticket:${s.me.account}`, 30, 'too many saves; wait a minute')
|
|
247
|
+
const entries = cleanEntries(body.entries)
|
|
248
|
+
const ticket = newToken('qst_')
|
|
249
|
+
const expiresAt = now() + TICKET_TTL_MS
|
|
250
|
+
await store.createSecretTicket({ tokenHash: hashToken(ticket), scopeType: s.type, scopeId: s.id, entries, createdBy: s.me.account, expiresAt })
|
|
251
|
+
return { ticket, vault: vaultAt(), expiresAt, names: entries.map((e) => e.name) }
|
|
252
|
+
}],
|
|
253
|
+
|
|
254
|
+
['GET', new RegExp(`^/v1/secrets/${SCOPE}/([^/]+)/uses$`), async (req, body, [type, id]) => {
|
|
255
|
+
const s = await scopeOf(req, type, id)
|
|
256
|
+
needManage(s)
|
|
257
|
+
const secrets = await store.listSecrets(s.type, s.id)
|
|
258
|
+
const byId = new Map(secrets.map((x) => [x.id, x.name]))
|
|
259
|
+
const uses = await store.listSecretUses(secrets.map((x) => x.id), { limit: 100 })
|
|
260
|
+
const names = new Map()
|
|
261
|
+
const out = []
|
|
262
|
+
for (const u of uses) {
|
|
263
|
+
if (!names.has(u.account)) names.set(u.account, await nameOf(u.account))
|
|
264
|
+
out.push({ name: byId.get(u.secretId) || '', account: u.account, accountName: names.get(u.account), room: u.room, upstream: u.upstream, at: u.at })
|
|
265
|
+
}
|
|
266
|
+
return { uses: out }
|
|
267
|
+
}],
|
|
268
|
+
|
|
269
|
+
// Who can use it: agents, or people only. The host and format change only with a new value.
|
|
270
|
+
['PATCH', new RegExp(`^/v1/secrets/${SCOPE}/([^/]+)/${NAME}$`), async (req, body, [type, id, name]) => {
|
|
271
|
+
const s = await scopeOf(req, type, id)
|
|
272
|
+
needManage(s)
|
|
273
|
+
if (body.upstream !== undefined || body.format !== undefined) throw new HttpError(400, 'Where a secret goes and how changes only with its value: use Replace.')
|
|
274
|
+
if (!WHO.includes(body.who)) throw new HttpError(400, 'who is agents or people')
|
|
275
|
+
const sec = await store.secretByName(s.type, s.id, name)
|
|
276
|
+
if (!sec) throw new HttpError(404, 'no such secret')
|
|
277
|
+
await store.updateSecret(sec.id, { who: body.who, updatedBy: s.me.account })
|
|
278
|
+
return scopeView(s)
|
|
279
|
+
}],
|
|
280
|
+
|
|
281
|
+
['DELETE', new RegExp(`^/v1/secrets/${SCOPE}/([^/]+)/${NAME}$`), async (req, body, [type, id, name]) => {
|
|
282
|
+
const s = await scopeOf(req, type, id)
|
|
283
|
+
needManage(s)
|
|
284
|
+
const sec = await store.secretByName(s.type, s.id, name)
|
|
285
|
+
if (!sec || !(await store.deleteSecret(sec.id))) throw new HttpError(404, 'no such secret')
|
|
286
|
+
return scopeView(s)
|
|
287
|
+
}],
|
|
288
|
+
|
|
289
|
+
// The names a session reaches, for its members (people, agents with a key, hosted agents
|
|
290
|
+
// with a pass): where each goes, who can use it, whether the caller can, and the variables
|
|
291
|
+
// `quilt run` sets. Never a value.
|
|
292
|
+
['GET', /^\/v1\/sessions\/([^/]+)\/secret-names$/, async (req, body, [room]) => {
|
|
293
|
+
const me = await whoCalls(req, { pass: /^QuiltPass\s/i.test(String(req.headers.authorization || '')) })
|
|
294
|
+
if (!ROOM.test(room) || !(await sessionMember(store, room, me.account, { agent: me.agent }))) throw new HttpError(404, 'no such session')
|
|
295
|
+
const names = await namesFor(room, me)
|
|
296
|
+
if (!names) throw new HttpError(404, 'no such session')
|
|
297
|
+
return { room, kind: me.kind, hosted: !!me.hosted, names: publicNames(names) }
|
|
298
|
+
}],
|
|
299
|
+
|
|
300
|
+
// A run token for `quilt run`: one member, one session, one hour. A person's people-only
|
|
301
|
+
// secrets wait for them to allow each in the app; an agent never gets them.
|
|
302
|
+
['POST', /^\/v1\/secret-runs$/, async (req, body) => {
|
|
303
|
+
const me = await whoCalls(req)
|
|
304
|
+
if (me.hosted) throw new HttpError(403, 'Hosted agents run no commands, so they use no secrets.')
|
|
305
|
+
const room = String(body.room || '')
|
|
306
|
+
if (!ROOM.test(room) || !(await sessionMember(store, room, me.account, { agent: me.agent }))) throw new HttpError(404, 'no such session')
|
|
307
|
+
limit(`run:${me.account}`, 120, 'too many runs; wait a minute')
|
|
308
|
+
const names = await namesFor(room, me)
|
|
309
|
+
if (!names) throw new HttpError(404, 'no such session')
|
|
310
|
+
const label = String(body.label || '').replace(/[\u0000-\u001f\u007f]/g, ' ').slice(0, MAX_LABEL) // eslint-disable-line no-control-regex
|
|
311
|
+
const token = newToken('qrun_')
|
|
312
|
+
const run = await store.createSecretRun({ tokenHash: hashToken(token), room, account: me.account, kind: me.kind, label, expiresAt: now() + RUN_TTL_MS })
|
|
313
|
+
const asked = names.filter((n) => n.state === 'ask')
|
|
314
|
+
if (asked.length) {
|
|
315
|
+
await store.putSecretRunAsks(run.id, asked.map((n) => ({ secretId: n.secretId, name: n.name })))
|
|
316
|
+
wake(`asks:${me.account}`)
|
|
317
|
+
}
|
|
318
|
+
return { run: { id: run.id, room, expiresAt: run.expiresAt }, token, vault: vaultAt(), secrets: publicNames(await namesFor(room, me, run)) }
|
|
319
|
+
}],
|
|
320
|
+
|
|
321
|
+
// Holds until no secret of the run is waiting for the person (or 25 seconds): its secrets' states.
|
|
322
|
+
['POST', /^\/v1\/secret-runs\/([^/]+)\/wait$/, async (req, body, [id]) => {
|
|
323
|
+
const me = await whoCalls(req)
|
|
324
|
+
const run = await runOf(id, me)
|
|
325
|
+
const waiting = async () => (await store.listSecretRunAsks(run.id)).some((a) => a.state === 'asked')
|
|
326
|
+
await waitUntil(`run:${run.id}`, async () => !(await waiting()), Math.min(Number(body.waitMs) || waitMs, waitMs))
|
|
327
|
+
return { run: { id: run.id, room: run.room, expiresAt: run.expiresAt, live: runLive(run) }, secrets: publicNames(await namesFor(run.room, me, run) || []) }
|
|
328
|
+
}],
|
|
329
|
+
|
|
330
|
+
// Ends a run at once: by its member (quilt run does when its command ends) or the session's owner.
|
|
331
|
+
['DELETE', /^\/v1\/secret-runs\/([^/]+)$/, async (req, body, [id]) => {
|
|
332
|
+
const me = await whoCalls(req)
|
|
333
|
+
const run = await store.secretRunById(needId(id, 'run'))
|
|
334
|
+
const owner = run && (await store.sessionByRoom(run.room))?.ownerAccount === me.account
|
|
335
|
+
if (!run || (run.account !== me.account && !owner)) throw new HttpError(404, 'no such run')
|
|
336
|
+
await store.revokeSecretRun(run.id, now())
|
|
337
|
+
await store.answerSecretRunAsks(run.id, 'denied', now())
|
|
338
|
+
wake(`run:${run.id}`)
|
|
339
|
+
return { ok: true }
|
|
340
|
+
}],
|
|
341
|
+
|
|
342
|
+
// The app's inline confirm: runs of this person's waiting for them to allow people-only
|
|
343
|
+
// secrets. ?wait=1 holds until there is one (or 25 seconds).
|
|
344
|
+
['GET', /^\/v1\/me\/secret-asks$/, async (req) => {
|
|
345
|
+
const me = await whoCalls(req)
|
|
346
|
+
if (me.kind !== 'person') throw new HttpError(403, 'only people allow secrets')
|
|
347
|
+
const q = new URL(req.url, 'http://x').searchParams
|
|
348
|
+
const list = () => store.pendingSecretAsks(me.account, now())
|
|
349
|
+
let pending = await list()
|
|
350
|
+
if (!pending.length && q.get('wait')) pending = (await waitUntil(`asks:${me.account}`, async () => { const p = await list(); return p.length ? p : null }, waitMs)) || []
|
|
351
|
+
const asks = []
|
|
352
|
+
for (const { run, asks: a } of pending) {
|
|
353
|
+
const s = await store.sessionByRoom(run.room)
|
|
354
|
+
asks.push({ runId: run.id, room: run.room, sessionName: s?.name || '', label: run.label, names: a.map((x) => x.name), askedAt: Math.min(...a.map((x) => x.askedAt)) })
|
|
355
|
+
}
|
|
356
|
+
return { asks }
|
|
357
|
+
}],
|
|
358
|
+
|
|
359
|
+
['POST', /^\/v1\/me\/secret-asks\/([^/]+)$/, async (req, body, [id]) => {
|
|
360
|
+
const me = await whoCalls(req)
|
|
361
|
+
if (me.kind !== 'person') throw new HttpError(403, 'only people allow secrets')
|
|
362
|
+
const run = await runOf(id, me)
|
|
363
|
+
if (!runLive(run)) throw new HttpError(410, 'that run has ended')
|
|
364
|
+
const n = await store.answerSecretRunAsks(run.id, body.allow === true ? 'allowed' : 'denied', now())
|
|
365
|
+
wake(`run:${run.id}`)
|
|
366
|
+
return { ok: true, answered: n }
|
|
367
|
+
}],
|
|
368
|
+
|
|
369
|
+
// ------------------------------------------------ internal: the vault, by shared secret --
|
|
370
|
+
|
|
371
|
+
// A write ticket, once: what may be saved, where, and the scope's current data key.
|
|
372
|
+
['POST', /^\/v1\/vault\/tickets\/redeem$/, async (req, body) => {
|
|
373
|
+
needVault(req)
|
|
374
|
+
const t = await store.redeemSecretTicket(hashToken(String(body.ticket || '')), now())
|
|
375
|
+
if (!t) throw new HttpError(401, 'this save link has expired or was used; try again')
|
|
376
|
+
const dk = await store.secretDataKey(t.scopeType, t.scopeId)
|
|
377
|
+
return { ticketId: t.id, scope: { type: t.scopeType, id: t.scopeId }, entries: t.entries, by: t.createdBy, dataKey: dk && { version: dk.version, sealedKey: dk.sealedKey, provider: dk.provider } }
|
|
378
|
+
}],
|
|
379
|
+
|
|
380
|
+
['POST', /^\/v1\/vault\/data-keys$/, async (req, body) => {
|
|
381
|
+
needVault(req)
|
|
382
|
+
const { scopeType, scopeId, version, sealedKey, provider } = body
|
|
383
|
+
if (!['org', 'workspace', 'session'].includes(scopeType) || !scopeId || !Number.isInteger(version) || version < 1 || typeof sealedKey !== 'string' || !['env', 'aws-kms'].includes(provider)) throw new HttpError(400, 'bad data key')
|
|
384
|
+
const dk = await store.putSecretDataKey({ scopeType, scopeId: String(scopeId), version, sealedKey, provider })
|
|
385
|
+
return { dataKey: { version: dk.version, sealedKey: dk.sealedKey, provider: dk.provider } }
|
|
386
|
+
}],
|
|
387
|
+
|
|
388
|
+
// Sealed values for a redeemed ticket, stored with the ticket's names, hosts and formats.
|
|
389
|
+
['POST', /^\/v1\/vault\/secrets$/, async (req, body) => {
|
|
390
|
+
needVault(req)
|
|
391
|
+
const t = await store.secretTicketById(needId(body.ticketId, 'ticket'))
|
|
392
|
+
if (!t || !t.redeemedAt || now() - t.redeemedAt > TICKET_TTL_MS) throw new HttpError(401, 'this save link has expired; try again')
|
|
393
|
+
const items = Array.isArray(body.items) ? body.items : []
|
|
394
|
+
const entries = new Map(t.entries.map((e) => [e.name, e]))
|
|
395
|
+
if (!items.length || items.some((i) => !entries.has(i?.name) || typeof i.sealedValue !== 'string' || !DIGEST.test(String(i.digest)) || !Number.isInteger(i.keyVersion))) throw new HttpError(400, 'those are not the names this save was for')
|
|
396
|
+
if (!(await store.markSecretTicketStored(t.id, now()))) throw new HttpError(409, 'this save was already stored')
|
|
397
|
+
const saved = []
|
|
398
|
+
for (const i of items) {
|
|
399
|
+
const e = entries.get(i.name)
|
|
400
|
+
const row = await store.putSecret({ scopeType: t.scopeType, scopeId: t.scopeId, name: e.name, sealedValue: i.sealedValue, keyVersion: i.keyVersion, digest: i.digest, upstream: e.upstream, format: e.format, who: e.who, by: t.createdBy })
|
|
401
|
+
saved.push({ name: row.name, id: row.id })
|
|
402
|
+
}
|
|
403
|
+
log(`secrets: ${saved.length} saved in ${t.scopeType} ${t.scopeId} by ${t.createdBy}`)
|
|
404
|
+
return { saved }
|
|
405
|
+
}],
|
|
406
|
+
|
|
407
|
+
// One gateway request: may this run use this name now? The answer carries the sealed value
|
|
408
|
+
// and its sealed data key, for the vault to open; every use is logged (once a minute).
|
|
409
|
+
['POST', /^\/v1\/vault\/resolve$/, async (req, body) => {
|
|
410
|
+
needVault(req)
|
|
411
|
+
const run = await store.secretRunByToken(hashToken(String(body.token || '')))
|
|
412
|
+
if (!runLive(run)) throw new HttpError(401, 'this run has ended (or was revoked): start the command again with quilt run')
|
|
413
|
+
const name = String(body.name || '')
|
|
414
|
+
const [kind, id] = run.account.split(':')
|
|
415
|
+
const agent = kind === 'agent' ? await store.agentById(id).catch(() => null) : null
|
|
416
|
+
if (kind === 'agent' && (!agent || agent.revokedAt)) throw new HttpError(401, 'this agent was revoked')
|
|
417
|
+
if (!(await sessionMember(store, run.room, run.account, { agent }))) throw new HttpError(403, 'not in this session any more')
|
|
418
|
+
const found = await roomLevels(store, run.room)
|
|
419
|
+
const { effective } = await resolveLevels(store, found ? found.levels : [])
|
|
420
|
+
const hit = effective.get(name)
|
|
421
|
+
if (!hit) throw new HttpError(404, `${name.slice(0, 64)} is not a secret in this session (or it is switched off here)`)
|
|
422
|
+
const ask = run.kind === 'person' && hit.secret.who === 'people' ? (await store.listSecretRunAsks(run.id)).find((a) => a.secretId === hit.secret.id) || null : undefined
|
|
423
|
+
const verdict = mayUse({ kind: run.kind, agentId: agent?.id || null, secret: hit.secret, level: hit.level, ask })
|
|
424
|
+
if (!verdict.ok) throw new HttpError(403, `${name}: ${verdict.why}`)
|
|
425
|
+
const sealed = await store.secretSealed(hit.secret.id)
|
|
426
|
+
const dk = await store.secretDataKeyVersion(hit.secret.scopeType, hit.secret.scopeId, sealed.keyVersion)
|
|
427
|
+
const key = `${run.id}\n${hit.secret.id}`
|
|
428
|
+
if (!usesLogged.has(key) || now() - usesLogged.get(key) >= USE_LOG_MS) {
|
|
429
|
+
usesLogged.set(key, now())
|
|
430
|
+
if (usesLogged.size > 50_000) usesLogged.delete(usesLogged.keys().next().value)
|
|
431
|
+
await store.recordSecretUse({ secretId: hit.secret.id, runId: run.id, room: run.room, account: run.account, upstream: hit.secret.upstream, at: now() }).catch((err) => log(`secret use log: ${err.message}`))
|
|
432
|
+
}
|
|
433
|
+
return {
|
|
434
|
+
run: { id: run.id },
|
|
435
|
+
secret: { id: hit.secret.id, name, scopeType: hit.secret.scopeType, scopeId: hit.secret.scopeId, upstream: hit.secret.upstream, format: hit.secret.format, sealedValue: sealed.sealedValue, keyVersion: sealed.keyVersion },
|
|
436
|
+
dataKey: { version: dk.version, sealedKey: dk.sealedKey, provider: dk.provider }
|
|
437
|
+
}
|
|
438
|
+
}]
|
|
439
|
+
]
|
|
440
|
+
// Off (a plain 503) until the vault is set up: QUILT_VAULT_SECRET on this API.
|
|
441
|
+
const on = (fn) => (...args) => {
|
|
442
|
+
if (!vaultSecret) throw new HttpError(503, 'Secrets are not switched on on this server yet.')
|
|
443
|
+
return fn(...args)
|
|
444
|
+
}
|
|
445
|
+
return routes.map(([method, pattern, fn, ...rest]) => [method, pattern, on(fn), ...rest])
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
export { LEVEL_WORDS }
|
|
@@ -15,7 +15,7 @@ const ACCOUNT = /^(person|agent):[A-Za-z0-9_-]{1,64}$/
|
|
|
15
15
|
const OPEN_MS = 10 * 60 * 1000
|
|
16
16
|
|
|
17
17
|
export function workspaceRoutes (ctx) {
|
|
18
|
-
const { store, now, files: fileStore, workspaces = false, log = () => {} } = ctx
|
|
18
|
+
const { store, now, files: fileStore, workspaces = false, log = () => {}, policyChanged = null } = ctx
|
|
19
19
|
const { caller, grantsIn, reach } = workspaceReach(ctx)
|
|
20
20
|
/** A handler that answers 404 while the flag is off (a route's own 404, not a missing route). */
|
|
21
21
|
const gated = (fn) => (...args) => {
|
|
@@ -163,7 +163,11 @@ export function workspaceRoutes (ctx) {
|
|
|
163
163
|
for (const v of await store.listWorkspaceFileVersions(f.id)) if (v.objectKey) keys.push(v.objectKey)
|
|
164
164
|
}
|
|
165
165
|
if (fileStore) await removeObjects(fileStore, keys, log)
|
|
166
|
+
const had = (await store.listWorkspaceSessions(r.ws.id)).length
|
|
167
|
+
// Its secrets go with it (sealed blobs nobody could reach again); never in the way of the delete.
|
|
168
|
+
await store.deleteScopeSecrets('workspace', r.ws.id).catch((err) => log(`workspace secrets: ${err.message}`))
|
|
166
169
|
await store.deleteWorkspace(r.ws.id)
|
|
170
|
+
if (had && policyChanged) policyChanged() // its sessions leave it, and its git rule with them
|
|
167
171
|
return { ok: true }
|
|
168
172
|
}],
|
|
169
173
|
|
|
@@ -204,6 +208,8 @@ export function workspaceRoutes (ctx) {
|
|
|
204
208
|
const existing = await store.sessionByRoom(room)
|
|
205
209
|
if (existing && existing.ownerAccount && existing.ownerAccount !== r.me.account) throw new HttpError(403, 'only the session owner can move it')
|
|
206
210
|
const session = await store.setSessionWorkspace(room, r.ws.id, { linkedBy: r.me.account, at: now() })
|
|
211
|
+
// The relay asked for the session's git rule when it made the room, before this link: it asks again now.
|
|
212
|
+
if (policyChanged) policyChanged(room)
|
|
207
213
|
// Its people come along when the owner manages the workspace; an editor's move leaves
|
|
208
214
|
// the workspace's people as they are, and says so (`peopleNeedAdmin`).
|
|
209
215
|
const owner = existing && existing.ownerAccount === r.me.account
|
|
@@ -218,6 +224,7 @@ export function workspaceRoutes (ctx) {
|
|
|
218
224
|
if (!s || s.workspaceId !== r.ws.id) throw new HttpError(404, 'that session is not in this workspace')
|
|
219
225
|
if (s.ownerAccount !== r.me.account && !r.access.admin) throw new HttpError(403, 'only the session owner or a workspace admin can do that')
|
|
220
226
|
await store.setSessionWorkspace(room, null, { linkedBy: null, at: now() })
|
|
227
|
+
if (policyChanged) policyChanged(room) // the workspace's rule no longer covers it
|
|
221
228
|
return { ok: true }
|
|
222
229
|
}]
|
|
223
230
|
]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Sandbox keys (QUILT_AGENT_KEY, ui/agent-env-key.js) as their person sees them on heyquilt.com
|
|
2
|
+
// and in the app: each one's use over the last seven days, whether it is in use, a short hint
|
|
3
|
+
// to tell it apart, and the session it works in. The session's invite link is kept so Rotate
|
|
4
|
+
// can make the same key again, sealed: a copy of the database alone never reads as the link.
|
|
5
|
+
import crypto from 'node:crypto'
|
|
6
|
+
|
|
7
|
+
/** Used this recently: In use. Otherwise Idle. */
|
|
8
|
+
export const IN_USE_MS = 60 * 60 * 1000
|
|
9
|
+
export const USE_DAYS = 7
|
|
10
|
+
const DAY_MS = 24 * 60 * 60 * 1000
|
|
11
|
+
|
|
12
|
+
/** The UTC date of a moment, YYYY-MM-DD: requests are counted per key per day. */
|
|
13
|
+
export const dayOf = (ms) => new Date(ms).toISOString().slice(0, 10)
|
|
14
|
+
|
|
15
|
+
/** The last `n` days, oldest first, ending with today's. */
|
|
16
|
+
export const lastDays = (at, n = USE_DAYS) => Array.from({ length: n }, (_, i) => dayOf(at - (n - 1 - i) * DAY_MS))
|
|
17
|
+
|
|
18
|
+
/** A key's use rows ({ day, count }) as one count per day of the last seven, oldest first. */
|
|
19
|
+
export function usesByDay (rows, at) {
|
|
20
|
+
const by = new Map(rows.map((r) => [r.day, r.count]))
|
|
21
|
+
return lastDays(at).map((d) => by.get(d) || 0)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 'revoked', 'in-use' (used in the last hour) or 'idle'. */
|
|
25
|
+
export function keyState (row, at) {
|
|
26
|
+
if (row.revokedAt) return 'revoked'
|
|
27
|
+
return row.lastUsedAt && at - row.lastUsedAt < IN_USE_MS ? 'in-use' : 'idle'
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The last four characters of a key: shown to tell keys apart, never enough to use one. */
|
|
31
|
+
export const hintOf = (key) => String(key).slice(-4)
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Seals and opens session links with AES-256-GCM under a key derived from `secret` (the API's
|
|
35
|
+
* pass key). Without one, a random key for this process: links then open until it restarts.
|
|
36
|
+
*/
|
|
37
|
+
export function linkSealer (secret) {
|
|
38
|
+
const key = Buffer.from(crypto.hkdfSync('sha256', secret ? String(secret) : crypto.randomBytes(32), 'quilt sandbox keys', 'session link', 32))
|
|
39
|
+
const b64 = (b) => b.toString('base64url')
|
|
40
|
+
return {
|
|
41
|
+
seal (text) {
|
|
42
|
+
const iv = crypto.randomBytes(12)
|
|
43
|
+
const c = crypto.createCipheriv('aes-256-gcm', key, iv)
|
|
44
|
+
const body = Buffer.concat([c.update(String(text), 'utf8'), c.final()])
|
|
45
|
+
return `v1.${b64(iv)}.${b64(c.getAuthTag())}.${b64(body)}`
|
|
46
|
+
},
|
|
47
|
+
open (sealed) {
|
|
48
|
+
const [v, iv, tag, body] = String(sealed || '').split('.')
|
|
49
|
+
if (v !== 'v1' || !iv || !tag || body === undefined) return null
|
|
50
|
+
try {
|
|
51
|
+
const d = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(iv, 'base64url'))
|
|
52
|
+
d.setAuthTag(Buffer.from(tag, 'base64url'))
|
|
53
|
+
return Buffer.concat([d.update(Buffer.from(body, 'base64url')), d.final()]).toString('utf8')
|
|
54
|
+
} catch { return null }
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Secrets (docs/superpowers/specs/2026-10-18-secrets.md): which secrets a session reaches, and
|
|
2
|
+
// who may use which. Pure apart from store reads. Never touches a sealed value: the routes hand
|
|
3
|
+
// that to the vault alone (routes/secrets.js, /v1/vault/resolve).
|
|
4
|
+
import { roomAccessFrom } from './access.js'
|
|
5
|
+
import { cloudAiOf } from './routes/ai-vm.js'
|
|
6
|
+
|
|
7
|
+
export const LEVEL_WORDS = { org: 'Team', workspace: 'Workspace', session: 'Session' }
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Whether `account` is one of the session's members: its owner, someone with a grant or let in
|
|
11
|
+
* by the session's workspace, or someone who came in by the invite link and wasn't removed
|
|
12
|
+
* since. A person's cloud AI stands where its person does.
|
|
13
|
+
*/
|
|
14
|
+
export async function sessionMember (store, room, account, { agent = null } = {}) {
|
|
15
|
+
if ((await roomAccessFrom(store, room, account)).access) return true
|
|
16
|
+
const visits = (await store.visitsInRooms([room])).filter((v) => v.account === account)
|
|
17
|
+
if (visits.length && visits.at(-1).endReason !== 'removed') return true
|
|
18
|
+
if (agent) {
|
|
19
|
+
const vm = await cloudAiOf(store, agent)
|
|
20
|
+
if (vm) return sessionMember(store, room, `person:${vm.userId}`)
|
|
21
|
+
}
|
|
22
|
+
return false
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A session's levels, nearest first: [{ type, id, name }] for the session, its workspace and
|
|
27
|
+
* the workspace's org (Team). null when the API doesn't know the session yet.
|
|
28
|
+
*/
|
|
29
|
+
export async function roomLevels (store, room) {
|
|
30
|
+
const session = await store.sessionByRoom(room)
|
|
31
|
+
if (!session) return null
|
|
32
|
+
const levels = [{ type: 'session', id: room, name: session.name || '' }]
|
|
33
|
+
const ws = session.workspaceId ? await store.workspaceById(session.workspaceId) : null
|
|
34
|
+
if (ws) levels.push({ type: 'workspace', id: ws.id, name: ws.name })
|
|
35
|
+
if (ws && ws.orgId) levels.push(await orgLevel(store, ws.orgId))
|
|
36
|
+
return { session, levels }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A workspace's levels: itself and its org. */
|
|
40
|
+
export async function workspaceLevels (store, ws) {
|
|
41
|
+
const levels = [{ type: 'workspace', id: ws.id, name: ws.name }]
|
|
42
|
+
if (ws.orgId) levels.push(await orgLevel(store, ws.orgId))
|
|
43
|
+
return levels
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function orgLevel (store, orgId) {
|
|
47
|
+
const org = await store.orgById(orgId)
|
|
48
|
+
return { type: 'org', id: orgId, name: org?.name || '', slug: org?.slug || '' }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Every level's secrets and settings, and what each name comes to here: { levels (with
|
|
53
|
+
* `settings`, `secrets` and `off`), effective: Map name -> { secret, level } }. A level is off
|
|
54
|
+
* when a nearer level switched inherited secrets off (a session for its workspace and team, a
|
|
55
|
+
* workspace for its team). The nearest level that is on wins a name.
|
|
56
|
+
*/
|
|
57
|
+
export async function resolveLevels (store, levels) {
|
|
58
|
+
const out = []
|
|
59
|
+
let off = false
|
|
60
|
+
for (const [i, l] of levels.entries()) {
|
|
61
|
+
const settings = await store.secretSettings(l.type, l.id)
|
|
62
|
+
const secrets = await store.listSecrets(l.type, l.id)
|
|
63
|
+
out.push({ ...l, settings, secrets, off: i > 0 && off })
|
|
64
|
+
if (settings.inheritOff) off = true
|
|
65
|
+
}
|
|
66
|
+
const effective = new Map()
|
|
67
|
+
for (const l of out) {
|
|
68
|
+
if (l.off) continue
|
|
69
|
+
for (const s of l.secrets) if (!effective.has(s.name)) effective.set(s.name, { secret: s, level: l })
|
|
70
|
+
}
|
|
71
|
+
return { levels: out, effective }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Whether a run (or a caller, with no run) may use a secret: { ok } or { ok: false, why, state }.
|
|
76
|
+
* Agents: only secrets for agents, in a scope with agents on that chose them (or all). People:
|
|
77
|
+
* every secret; a people-only one once they allowed it for this run (`ask`: its ask row).
|
|
78
|
+
*/
|
|
79
|
+
export function mayUse ({ kind, agentId = null, secret, level, ask = undefined }) {
|
|
80
|
+
if (kind === 'agent') {
|
|
81
|
+
if (secret.who === 'people') return { ok: false, state: 'refused', why: 'people only: agents never get it' }
|
|
82
|
+
const s = level.settings
|
|
83
|
+
if (!s.agentsOn) return { ok: false, state: 'refused', why: `agents can't use ${LEVEL_WORDS[level.type].toLowerCase()} secrets here (switched off)` }
|
|
84
|
+
if (s.agents !== 'all' && !(Array.isArray(s.agents) && s.agents.includes(agentId))) return { ok: false, state: 'refused', why: 'this agent was not chosen to use secrets here' }
|
|
85
|
+
return { ok: true, state: 'ready' }
|
|
86
|
+
}
|
|
87
|
+
if (secret.who !== 'people') return { ok: true, state: 'ready' }
|
|
88
|
+
if (ask === undefined) return { ok: false, state: 'ask', why: 'people only: each run asks the person to allow it in the Quilt app' }
|
|
89
|
+
if (!ask) return { ok: false, state: 'refused', why: 'people only, and this run was not asked about it (it was added since): run the command again' }
|
|
90
|
+
if (ask.state === 'allowed') return { ok: true, state: 'ready' }
|
|
91
|
+
if (ask.state === 'denied') return { ok: false, state: 'refused', why: 'people only: not allowed for this run' }
|
|
92
|
+
return { ok: false, state: 'asked', why: 'people only: waiting for the person to allow it in the Quilt app' }
|
|
93
|
+
}
|