hivecode-mcp 0.14.1 → 0.14.3
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/HIVE_RULES.md +98 -98
- package/LICENSE +21 -21
- package/README.md +421 -421
- package/core.js +91 -91
- package/docs/ALWAYS_ON.md +310 -310
- package/docs/GITHUB_WORKFLOW.md +394 -394
- package/docs/HIVE_RULES.md +41 -41
- package/docs/MCP.md +157 -157
- package/docs/QUICKSTART.md +266 -266
- package/docs/SELF_HOSTING.md +256 -256
- package/docs/SLACK_SETUP.md +474 -474
- package/docs/TROUBLESHOOTING.md +106 -105
- package/hive-activity.js +336 -336
- package/hive-authors.js +46 -44
- package/hive-await.js +230 -230
- package/hive-branch.js +674 -607
- package/hive-busy.js +88 -88
- package/hive-coord.js +110 -110
- package/hive-gate.js +135 -135
- package/hive-github.js +3 -1
- package/hive-hook-install.js +179 -179
- package/hive-instructions.js +74 -74
- package/hive-land.js +709 -691
- package/hive-listen.js +81 -81
- package/hive-mailbox.js +520 -520
- package/hive-mcp-http.js +591 -591
- package/hive-mcp.js +92 -12
- package/hive-mux-proto.js +58 -58
- package/hive-mux.js +354 -351
- package/hive-notify.js +234 -233
- package/hive-obligations.js +247 -247
- package/hive-poll-proto.js +50 -50
- package/hive-pr-staleness.js +189 -189
- package/hive-pr.js +637 -512
- package/hive-privacy.js +327 -327
- package/hive-receipts.js +233 -233
- package/hive-relay-health.js +234 -234
- package/hive-repos.js +430 -430
- package/hive-room-key.js +210 -210
- package/hive-room-mode.js +1761 -1668
- package/hive-room-watcher.js +623 -561
- package/hive-secrets.js +89 -89
- package/hive-session.js +114 -114
- package/hive-setup.js +560 -560
- package/hive-slack-agent.js +408 -408
- package/hive-slack.js +2417 -2367
- package/hive-stdio.js +172 -172
- package/hive-stop-hook.js +293 -293
- package/hive-throttle.js +118 -118
- package/hive-time.js +71 -71
- package/hive-transport.js +617 -578
- package/hive-update.js +58 -58
- package/hive-version.js +110 -110
- package/hive-webhook.js +55 -54
- package/icr-merge.js +58 -58
- package/icr.js +856 -851
- package/lang-brace.js +305 -305
- package/lang-js.js +695 -651
- package/lang-json.js +211 -211
- package/lang-python.js +588 -588
- package/lang-ruby.js +230 -230
- package/lang-toml.js +136 -136
- package/lang-yaml.js +183 -183
- package/limits.js +413 -413
- package/package.json +11 -3
- package/reconcile.cjs +621 -621
- package/slack-app-manifest-agent.json +61 -61
- package/slack-app-manifest-inbound.json +36 -36
- package/slack-app-manifest.json +27 -27
- package/substrate.js +252 -252
- package/sync.js +434 -60
- package/token.js +201 -201
package/token.js
CHANGED
|
@@ -1,201 +1,201 @@
|
|
|
1
|
-
// Hivecode access tokens — a tiny, dependency-free JWT (JWS) implementation used
|
|
2
|
-
// by the relay (verify) and the hive-token CLI (mint). No external libs: just
|
|
3
|
-
// Node's crypto, so there's no supply-chain surface for the thing that guards
|
|
4
|
-
// access. Supports HS256 (shared secret — self-host) and RS256 (public/private
|
|
5
|
-
// key — a hosted control plane signs, relays verify with the public key).
|
|
6
|
-
//
|
|
7
|
-
// Token shape (claims):
|
|
8
|
-
// {
|
|
9
|
-
// iss, sub, // issuer, principal id
|
|
10
|
-
// name, kind, // display name + 'human' | 'ai'
|
|
11
|
-
// owner, // (agents) the human who may approve its tasks
|
|
12
|
-
// scopes: [ // what this principal may reach
|
|
13
|
-
// { room: "room-id" | "acme/*" | "*", role: "admin|maintainer|writer|reader|agent",
|
|
14
|
-
// paths: ["src/**", "!**/*.env"] } // path globs are enforced in a later phase
|
|
15
|
-
// ],
|
|
16
|
-
// iat, exp, // issued-at, expiry (unix seconds)
|
|
17
|
-
// jti // unique id, for revocation
|
|
18
|
-
// }
|
|
19
|
-
|
|
20
|
-
import crypto from 'crypto'
|
|
21
|
-
import ignore from 'ignore'
|
|
22
|
-
|
|
23
|
-
const b64url = (buf) => Buffer.from(buf).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
24
|
-
const b64urlToBuf = (s) => Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/'), 'base64')
|
|
25
|
-
const enc = (obj) => b64url(JSON.stringify(obj))
|
|
26
|
-
|
|
27
|
-
// --- mint ---
|
|
28
|
-
// signWith: { secret } -> HS256, or { privateKey } -> RS256.
|
|
29
|
-
export function sign(payload, signWith) {
|
|
30
|
-
const useRsa = !!signWith.privateKey
|
|
31
|
-
const header = { alg: useRsa ? 'RS256' : 'HS256', typ: 'JWT' }
|
|
32
|
-
const data = enc(header) + '.' + enc(payload)
|
|
33
|
-
let sig
|
|
34
|
-
if (useRsa) sig = crypto.sign('RSA-SHA256', Buffer.from(data), signWith.privateKey)
|
|
35
|
-
else sig = crypto.createHmac('sha256', signWith.secret).update(data).digest()
|
|
36
|
-
return data + '.' + b64url(sig)
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// --- verify ---
|
|
40
|
-
// verifyWith: { secret } and/or { publicKey }, plus optional { alg } to PIN the
|
|
41
|
-
// accepted algorithm (defense-in-depth against alg-confusion: a deployment that
|
|
42
|
-
// only issues RS256 can refuse every HS256 token outright). The token's
|
|
43
|
-
// header.alg selects which key is used. Returns { ok, payload } or { ok:false, error }.
|
|
44
|
-
export function verify(token, verifyWith = {}, now = Math.floor(Date.now() / 1000)) {
|
|
45
|
-
if (!token || typeof token !== 'string') return { ok: false, error: 'no token' }
|
|
46
|
-
const parts = token.split('.')
|
|
47
|
-
if (parts.length !== 3) return { ok: false, error: 'malformed token' }
|
|
48
|
-
const [h, p, s] = parts
|
|
49
|
-
if (!s) return { ok: false, error: 'unsigned token' } // "h.p." — empty signature segment
|
|
50
|
-
let header, payload
|
|
51
|
-
try { header = JSON.parse(b64urlToBuf(h).toString('utf8')); payload = JSON.parse(b64urlToBuf(p).toString('utf8')) }
|
|
52
|
-
catch { return { ok: false, error: 'undecodable token' } }
|
|
53
|
-
if (!payload || typeof payload !== 'object') return { ok: false, error: 'bad payload' }
|
|
54
|
-
// Optional algorithm pinning: reject anything the relay didn't opt into.
|
|
55
|
-
if (verifyWith.alg && header.alg !== verifyWith.alg) return { ok: false, error: `alg ${header.alg} not allowed (pinned to ${verifyWith.alg})` }
|
|
56
|
-
const data = h + '.' + p
|
|
57
|
-
const sig = b64urlToBuf(s)
|
|
58
|
-
|
|
59
|
-
if (header.alg === 'HS256') {
|
|
60
|
-
if (!verifyWith.secret) return { ok: false, error: 'HS256 token but relay has no secret configured' }
|
|
61
|
-
const expected = crypto.createHmac('sha256', verifyWith.secret).update(data).digest()
|
|
62
|
-
if (sig.length !== expected.length || !crypto.timingSafeEqual(sig, expected)) return { ok: false, error: 'bad signature' }
|
|
63
|
-
} else if (header.alg === 'RS256') {
|
|
64
|
-
if (!verifyWith.publicKey) return { ok: false, error: 'RS256 token but relay has no public key configured' }
|
|
65
|
-
let valid = false
|
|
66
|
-
try { valid = crypto.verify('RSA-SHA256', Buffer.from(data), verifyWith.publicKey, sig) } catch { valid = false }
|
|
67
|
-
if (!valid) return { ok: false, error: 'bad signature' }
|
|
68
|
-
} else {
|
|
69
|
-
return { ok: false, error: `unsupported alg: ${header.alg}` }
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// exp/nbf must be NUMBERS. A non-numeric exp would make `now >= exp` false and
|
|
73
|
-
// the token would never expire — fail closed and reject it instead.
|
|
74
|
-
if (payload.exp != null && (typeof payload.exp !== 'number' || now >= payload.exp)) return { ok: false, error: 'token expired' }
|
|
75
|
-
if (payload.nbf != null && (typeof payload.nbf !== 'number' || now < payload.nbf)) return { ok: false, error: 'token not yet valid' }
|
|
76
|
-
return { ok: true, payload }
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// --- room naming for the per-file subdoc model (Phase 2) ---
|
|
80
|
-
// A project is a PARENT room (manifest + coordination) plus one room per file,
|
|
81
|
-
// named `<baseRoom>␁<path>`. Encoding the path in the room name lets the relay
|
|
82
|
-
// authorize per-path (Phase 3) and lets a client connect only to the files it
|
|
83
|
-
// may load. FILE_SEP is a control char that never appears in room ids or paths.
|
|
84
|
-
export const FILE_SEP = ''
|
|
85
|
-
export const fileRoom = (baseRoom, relPath) => baseRoom + FILE_SEP + relPath
|
|
86
|
-
export const baseRoomOf = (room) => { const i = room.indexOf(FILE_SEP); return i < 0 ? room : room.slice(0, i) }
|
|
87
|
-
export const pathOf = (room) => { const i = room.indexOf(FILE_SEP); return i < 0 ? null : room.slice(i + 1) }
|
|
88
|
-
|
|
89
|
-
// Does a scope's room pattern authorize this room? Supports exact, "*" (all),
|
|
90
|
-
// and a trailing "*" prefix wildcard (e.g. "acme/*" matches "acme/api").
|
|
91
|
-
export function roomMatches(pattern, room) {
|
|
92
|
-
if (!pattern) return false
|
|
93
|
-
if (pattern === '*' || pattern === room) return true
|
|
94
|
-
if (pattern.endsWith('*')) return room.startsWith(pattern.slice(0, -1))
|
|
95
|
-
return false
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// Find the scope (and role) that authorizes `room`, or null. When several scopes
|
|
99
|
-
// match, the MOST SPECIFIC wins (exact room > longer prefix > "*"). This stops a
|
|
100
|
-
// broad "*" scope listed first from shadowing a tighter, intentionally-narrower
|
|
101
|
-
// scope for the same room (which would silently over-grant). Ties keep the first.
|
|
102
|
-
export function scopeForRoom(payload, room) {
|
|
103
|
-
const scopes = Array.isArray(payload && payload.scopes) ? payload.scopes : []
|
|
104
|
-
let best = null, bestScore = -1
|
|
105
|
-
for (const sc of scopes) {
|
|
106
|
-
if (!sc || !roomMatches(sc.room, room)) continue
|
|
107
|
-
const score = sc.room === room ? Infinity : sc.room === '*' ? 0 : sc.room.length // exact > longer prefix > "*"
|
|
108
|
-
if (score > bestScore) { bestScore = score; best = sc }
|
|
109
|
-
}
|
|
110
|
-
return best
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// Is a remote-supplied path safe to materialize on this client's disk? A room
|
|
114
|
-
// participant controls the manifest (and thus file paths); without this, a
|
|
115
|
-
// malicious entry like "../../etc/cron.d/x", "/etc/passwd", or "C:\\Windows\\..."
|
|
116
|
-
// would be written OUTSIDE the project root by every client that syncs it. We
|
|
117
|
-
// reject absolute paths, drive letters, UNC, any ".." segment, control chars
|
|
118
|
-
// (incl. the FILE_SEP separator and NULs), and absurd lengths. Enforced on the
|
|
119
|
-
// client (before writing) AND the relay (rejects the file-room) — defense in depth.
|
|
120
|
-
export function isSafeRelPath(relPath) {
|
|
121
|
-
if (typeof relPath !== 'string' || relPath.length === 0 || relPath.length > 1024) return false
|
|
122
|
-
if (/[\u0000-\u001f]/.test(relPath)) return false // control chars: NUL, FILE_SEP (U+0001), etc.
|
|
123
|
-
const p = relPath.replace(/\\/g, '/') // normalize Windows separators
|
|
124
|
-
if (p.startsWith('/')) return false // POSIX absolute
|
|
125
|
-
if (p.startsWith('//')) return false // UNC-ish
|
|
126
|
-
if (/^[a-zA-Z]:/.test(p)) return false // Windows drive letter (C:...)
|
|
127
|
-
if (p.split('/').some((seg) => seg === '..')) return false // traversal
|
|
128
|
-
return true
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// Phase 3: is a file path allowed by a scope's path globs? Gitignore-style globs
|
|
132
|
-
// (via the `ignore` lib): positive patterns grant subtrees, "!"-prefixed patterns
|
|
133
|
-
// deny. No globs (or none given) = the whole room is allowed (Phase-2 behavior).
|
|
134
|
-
// pathAllowed(["src/**", "!**/*.env"], "src/app.js") -> true
|
|
135
|
-
// pathAllowed(["src/**"], "secrets/k.txt") -> false
|
|
136
|
-
export function pathAllowed(globs, relPath) {
|
|
137
|
-
if (!Array.isArray(globs) || globs.length === 0) return true
|
|
138
|
-
// The `ignore` lib throws on a "./"-prefixed path; normalize it away. And guard
|
|
139
|
-
// every match call so a malformed input can NEVER throw into the relay's auth
|
|
140
|
-
// gate (a throw there must fail closed, not crash the process).
|
|
141
|
-
const p = String(relPath).replace(/^(\.\/)+/, '')
|
|
142
|
-
const pos = [], neg = []
|
|
143
|
-
for (const g of globs) { if (typeof g !== 'string' || !g) continue; if (g[0] === '!') neg.push(g.slice(1)); else pos.push(g) }
|
|
144
|
-
const matches = (pats) => { try { return pats.length > 0 && ignore().add(pats).ignores(p) } catch { return false } }
|
|
145
|
-
const inAllow = pos.length === 0 ? true : matches(pos)
|
|
146
|
-
return inAllow && !matches(neg)
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// Phase 5: within a scope, which VISIBLE paths are also WRITABLE. `writePaths` is
|
|
150
|
-
// an optional subset of `paths`: a file the scope can see is read-only unless it
|
|
151
|
-
// ALSO matches writePaths. This is what lets one agent read frontend/ for context
|
|
152
|
-
// but only edit backend/. Semantics, chosen to fail safe:
|
|
153
|
-
// - role 'reader' -> never writable (whole grant is view-only)
|
|
154
|
-
// - no writePaths key -> every visible path is writable (back-compat: a plain
|
|
155
|
-
// writer/agent edits everything it can see)
|
|
156
|
-
// - writePaths: [] -> nothing writable (explicit view-only everywhere)
|
|
157
|
-
// - writePaths: [globs] -> writable IFF the path matches the globs
|
|
158
|
-
// The relay enforces this by connecting an out-of-write-scope file-room as a
|
|
159
|
-
// reader (so makeReadOnly drops its writes); clients use it for read-only UX.
|
|
160
|
-
export function writeAllowed(scope, relPath) {
|
|
161
|
-
if (!scope) return false
|
|
162
|
-
if ((scope.role || 'writer') === 'reader') return false
|
|
163
|
-
const wp = scope.writePaths
|
|
164
|
-
if (wp == null) return true
|
|
165
|
-
if (!Array.isArray(wp) || wp.length === 0) return false
|
|
166
|
-
return pathAllowed(wp, relPath)
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// --- self-certifying secured rooms (no server-side key registry, no secret files) ---
|
|
170
|
-
// A secured room's id embeds a fingerprint of the OWNER's public key:
|
|
171
|
-
// hs_<fp>_<rand> fp = base64url(sha256(spki-DER)).slice(0, 22)
|
|
172
|
-
// Every token for the room carries the owner's public key (claim `pk`) and is
|
|
173
|
-
// signed by the matching private key (RS256). The relay trusts a token IFF the
|
|
174
|
-
// embedded key's fingerprint equals the one in the room id — so trust is anchored
|
|
175
|
-
// in the room id itself and the relay needs to store nothing. Only the owner (who
|
|
176
|
-
// holds the private key, kept in the editor's secure storage) can mint valid
|
|
177
|
-
// tokens. This is what lets the hosted, stateless, free-tier relay enforce
|
|
178
|
-
// per-folder access with no registration step and no secret handed to anyone.
|
|
179
|
-
export function keyFingerprint(publicKeyPem) {
|
|
180
|
-
try {
|
|
181
|
-
const der = crypto.createPublicKey(publicKeyPem).export({ type: 'spki', format: 'der' })
|
|
182
|
-
return crypto.createHash('sha256').update(der).digest('base64url').slice(0, 22)
|
|
183
|
-
} catch { return null }
|
|
184
|
-
}
|
|
185
|
-
export function roomFingerprint(room) {
|
|
186
|
-
const m = /^hs_([A-Za-z0-9_-]{22})_/.exec(room || '')
|
|
187
|
-
return m ? m[1] : null
|
|
188
|
-
}
|
|
189
|
-
export const isSecuredRoom = (room) => roomFingerprint(room) != null
|
|
190
|
-
export function makeSecuredRoomId(publicKeyPem, rand) {
|
|
191
|
-
const fp = keyFingerprint(publicKeyPem)
|
|
192
|
-
return fp ? `hs_${fp}_${rand}` : null
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
// Read a token's payload WITHOUT verifying — for a client to inspect its OWN grant
|
|
196
|
-
// (e.g. which paths it may open) for UX. NEVER use for access decisions; the relay
|
|
197
|
-
// is the enforcer (it verifies the signature).
|
|
198
|
-
export function decodeUnsafe(token) {
|
|
199
|
-
try { return JSON.parse(Buffer.from(String(token).split('.')[1].replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')) }
|
|
200
|
-
catch { return null }
|
|
201
|
-
}
|
|
1
|
+
// Hivecode access tokens — a tiny, dependency-free JWT (JWS) implementation used
|
|
2
|
+
// by the relay (verify) and the hive-token CLI (mint). No external libs: just
|
|
3
|
+
// Node's crypto, so there's no supply-chain surface for the thing that guards
|
|
4
|
+
// access. Supports HS256 (shared secret — self-host) and RS256 (public/private
|
|
5
|
+
// key — a hosted control plane signs, relays verify with the public key).
|
|
6
|
+
//
|
|
7
|
+
// Token shape (claims):
|
|
8
|
+
// {
|
|
9
|
+
// iss, sub, // issuer, principal id
|
|
10
|
+
// name, kind, // display name + 'human' | 'ai'
|
|
11
|
+
// owner, // (agents) the human who may approve its tasks
|
|
12
|
+
// scopes: [ // what this principal may reach
|
|
13
|
+
// { room: "room-id" | "acme/*" | "*", role: "admin|maintainer|writer|reader|agent",
|
|
14
|
+
// paths: ["src/**", "!**/*.env"] } // path globs are enforced in a later phase
|
|
15
|
+
// ],
|
|
16
|
+
// iat, exp, // issued-at, expiry (unix seconds)
|
|
17
|
+
// jti // unique id, for revocation
|
|
18
|
+
// }
|
|
19
|
+
|
|
20
|
+
import crypto from 'crypto'
|
|
21
|
+
import ignore from 'ignore'
|
|
22
|
+
|
|
23
|
+
const b64url = (buf) => Buffer.from(buf).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
24
|
+
const b64urlToBuf = (s) => Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/'), 'base64')
|
|
25
|
+
const enc = (obj) => b64url(JSON.stringify(obj))
|
|
26
|
+
|
|
27
|
+
// --- mint ---
|
|
28
|
+
// signWith: { secret } -> HS256, or { privateKey } -> RS256.
|
|
29
|
+
export function sign(payload, signWith) {
|
|
30
|
+
const useRsa = !!signWith.privateKey
|
|
31
|
+
const header = { alg: useRsa ? 'RS256' : 'HS256', typ: 'JWT' }
|
|
32
|
+
const data = enc(header) + '.' + enc(payload)
|
|
33
|
+
let sig
|
|
34
|
+
if (useRsa) sig = crypto.sign('RSA-SHA256', Buffer.from(data), signWith.privateKey)
|
|
35
|
+
else sig = crypto.createHmac('sha256', signWith.secret).update(data).digest()
|
|
36
|
+
return data + '.' + b64url(sig)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// --- verify ---
|
|
40
|
+
// verifyWith: { secret } and/or { publicKey }, plus optional { alg } to PIN the
|
|
41
|
+
// accepted algorithm (defense-in-depth against alg-confusion: a deployment that
|
|
42
|
+
// only issues RS256 can refuse every HS256 token outright). The token's
|
|
43
|
+
// header.alg selects which key is used. Returns { ok, payload } or { ok:false, error }.
|
|
44
|
+
export function verify(token, verifyWith = {}, now = Math.floor(Date.now() / 1000)) {
|
|
45
|
+
if (!token || typeof token !== 'string') return { ok: false, error: 'no token' }
|
|
46
|
+
const parts = token.split('.')
|
|
47
|
+
if (parts.length !== 3) return { ok: false, error: 'malformed token' }
|
|
48
|
+
const [h, p, s] = parts
|
|
49
|
+
if (!s) return { ok: false, error: 'unsigned token' } // "h.p." — empty signature segment
|
|
50
|
+
let header, payload
|
|
51
|
+
try { header = JSON.parse(b64urlToBuf(h).toString('utf8')); payload = JSON.parse(b64urlToBuf(p).toString('utf8')) }
|
|
52
|
+
catch { return { ok: false, error: 'undecodable token' } }
|
|
53
|
+
if (!payload || typeof payload !== 'object') return { ok: false, error: 'bad payload' }
|
|
54
|
+
// Optional algorithm pinning: reject anything the relay didn't opt into.
|
|
55
|
+
if (verifyWith.alg && header.alg !== verifyWith.alg) return { ok: false, error: `alg ${header.alg} not allowed (pinned to ${verifyWith.alg})` }
|
|
56
|
+
const data = h + '.' + p
|
|
57
|
+
const sig = b64urlToBuf(s)
|
|
58
|
+
|
|
59
|
+
if (header.alg === 'HS256') {
|
|
60
|
+
if (!verifyWith.secret) return { ok: false, error: 'HS256 token but relay has no secret configured' }
|
|
61
|
+
const expected = crypto.createHmac('sha256', verifyWith.secret).update(data).digest()
|
|
62
|
+
if (sig.length !== expected.length || !crypto.timingSafeEqual(sig, expected)) return { ok: false, error: 'bad signature' }
|
|
63
|
+
} else if (header.alg === 'RS256') {
|
|
64
|
+
if (!verifyWith.publicKey) return { ok: false, error: 'RS256 token but relay has no public key configured' }
|
|
65
|
+
let valid = false
|
|
66
|
+
try { valid = crypto.verify('RSA-SHA256', Buffer.from(data), verifyWith.publicKey, sig) } catch { valid = false }
|
|
67
|
+
if (!valid) return { ok: false, error: 'bad signature' }
|
|
68
|
+
} else {
|
|
69
|
+
return { ok: false, error: `unsupported alg: ${header.alg}` }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// exp/nbf must be NUMBERS. A non-numeric exp would make `now >= exp` false and
|
|
73
|
+
// the token would never expire — fail closed and reject it instead.
|
|
74
|
+
if (payload.exp != null && (typeof payload.exp !== 'number' || now >= payload.exp)) return { ok: false, error: 'token expired' }
|
|
75
|
+
if (payload.nbf != null && (typeof payload.nbf !== 'number' || now < payload.nbf)) return { ok: false, error: 'token not yet valid' }
|
|
76
|
+
return { ok: true, payload }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// --- room naming for the per-file subdoc model (Phase 2) ---
|
|
80
|
+
// A project is a PARENT room (manifest + coordination) plus one room per file,
|
|
81
|
+
// named `<baseRoom>␁<path>`. Encoding the path in the room name lets the relay
|
|
82
|
+
// authorize per-path (Phase 3) and lets a client connect only to the files it
|
|
83
|
+
// may load. FILE_SEP is a control char that never appears in room ids or paths.
|
|
84
|
+
export const FILE_SEP = ''
|
|
85
|
+
export const fileRoom = (baseRoom, relPath) => baseRoom + FILE_SEP + relPath
|
|
86
|
+
export const baseRoomOf = (room) => { const i = room.indexOf(FILE_SEP); return i < 0 ? room : room.slice(0, i) }
|
|
87
|
+
export const pathOf = (room) => { const i = room.indexOf(FILE_SEP); return i < 0 ? null : room.slice(i + 1) }
|
|
88
|
+
|
|
89
|
+
// Does a scope's room pattern authorize this room? Supports exact, "*" (all),
|
|
90
|
+
// and a trailing "*" prefix wildcard (e.g. "acme/*" matches "acme/api").
|
|
91
|
+
export function roomMatches(pattern, room) {
|
|
92
|
+
if (!pattern) return false
|
|
93
|
+
if (pattern === '*' || pattern === room) return true
|
|
94
|
+
if (pattern.endsWith('*')) return room.startsWith(pattern.slice(0, -1))
|
|
95
|
+
return false
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Find the scope (and role) that authorizes `room`, or null. When several scopes
|
|
99
|
+
// match, the MOST SPECIFIC wins (exact room > longer prefix > "*"). This stops a
|
|
100
|
+
// broad "*" scope listed first from shadowing a tighter, intentionally-narrower
|
|
101
|
+
// scope for the same room (which would silently over-grant). Ties keep the first.
|
|
102
|
+
export function scopeForRoom(payload, room) {
|
|
103
|
+
const scopes = Array.isArray(payload && payload.scopes) ? payload.scopes : []
|
|
104
|
+
let best = null, bestScore = -1
|
|
105
|
+
for (const sc of scopes) {
|
|
106
|
+
if (!sc || !roomMatches(sc.room, room)) continue
|
|
107
|
+
const score = sc.room === room ? Infinity : sc.room === '*' ? 0 : sc.room.length // exact > longer prefix > "*"
|
|
108
|
+
if (score > bestScore) { bestScore = score; best = sc }
|
|
109
|
+
}
|
|
110
|
+
return best
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Is a remote-supplied path safe to materialize on this client's disk? A room
|
|
114
|
+
// participant controls the manifest (and thus file paths); without this, a
|
|
115
|
+
// malicious entry like "../../etc/cron.d/x", "/etc/passwd", or "C:\\Windows\\..."
|
|
116
|
+
// would be written OUTSIDE the project root by every client that syncs it. We
|
|
117
|
+
// reject absolute paths, drive letters, UNC, any ".." segment, control chars
|
|
118
|
+
// (incl. the FILE_SEP separator and NULs), and absurd lengths. Enforced on the
|
|
119
|
+
// client (before writing) AND the relay (rejects the file-room) — defense in depth.
|
|
120
|
+
export function isSafeRelPath(relPath) {
|
|
121
|
+
if (typeof relPath !== 'string' || relPath.length === 0 || relPath.length > 1024) return false
|
|
122
|
+
if (/[\u0000-\u001f]/.test(relPath)) return false // control chars: NUL, FILE_SEP (U+0001), etc.
|
|
123
|
+
const p = relPath.replace(/\\/g, '/') // normalize Windows separators
|
|
124
|
+
if (p.startsWith('/')) return false // POSIX absolute
|
|
125
|
+
if (p.startsWith('//')) return false // UNC-ish
|
|
126
|
+
if (/^[a-zA-Z]:/.test(p)) return false // Windows drive letter (C:...)
|
|
127
|
+
if (p.split('/').some((seg) => seg === '..')) return false // traversal
|
|
128
|
+
return true
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Phase 3: is a file path allowed by a scope's path globs? Gitignore-style globs
|
|
132
|
+
// (via the `ignore` lib): positive patterns grant subtrees, "!"-prefixed patterns
|
|
133
|
+
// deny. No globs (or none given) = the whole room is allowed (Phase-2 behavior).
|
|
134
|
+
// pathAllowed(["src/**", "!**/*.env"], "src/app.js") -> true
|
|
135
|
+
// pathAllowed(["src/**"], "secrets/k.txt") -> false
|
|
136
|
+
export function pathAllowed(globs, relPath) {
|
|
137
|
+
if (!Array.isArray(globs) || globs.length === 0) return true
|
|
138
|
+
// The `ignore` lib throws on a "./"-prefixed path; normalize it away. And guard
|
|
139
|
+
// every match call so a malformed input can NEVER throw into the relay's auth
|
|
140
|
+
// gate (a throw there must fail closed, not crash the process).
|
|
141
|
+
const p = String(relPath).replace(/^(\.\/)+/, '')
|
|
142
|
+
const pos = [], neg = []
|
|
143
|
+
for (const g of globs) { if (typeof g !== 'string' || !g) continue; if (g[0] === '!') neg.push(g.slice(1)); else pos.push(g) }
|
|
144
|
+
const matches = (pats) => { try { return pats.length > 0 && ignore().add(pats).ignores(p) } catch { return false } }
|
|
145
|
+
const inAllow = pos.length === 0 ? true : matches(pos)
|
|
146
|
+
return inAllow && !matches(neg)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Phase 5: within a scope, which VISIBLE paths are also WRITABLE. `writePaths` is
|
|
150
|
+
// an optional subset of `paths`: a file the scope can see is read-only unless it
|
|
151
|
+
// ALSO matches writePaths. This is what lets one agent read frontend/ for context
|
|
152
|
+
// but only edit backend/. Semantics, chosen to fail safe:
|
|
153
|
+
// - role 'reader' -> never writable (whole grant is view-only)
|
|
154
|
+
// - no writePaths key -> every visible path is writable (back-compat: a plain
|
|
155
|
+
// writer/agent edits everything it can see)
|
|
156
|
+
// - writePaths: [] -> nothing writable (explicit view-only everywhere)
|
|
157
|
+
// - writePaths: [globs] -> writable IFF the path matches the globs
|
|
158
|
+
// The relay enforces this by connecting an out-of-write-scope file-room as a
|
|
159
|
+
// reader (so makeReadOnly drops its writes); clients use it for read-only UX.
|
|
160
|
+
export function writeAllowed(scope, relPath) {
|
|
161
|
+
if (!scope) return false
|
|
162
|
+
if ((scope.role || 'writer') === 'reader') return false
|
|
163
|
+
const wp = scope.writePaths
|
|
164
|
+
if (wp == null) return true
|
|
165
|
+
if (!Array.isArray(wp) || wp.length === 0) return false
|
|
166
|
+
return pathAllowed(wp, relPath)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// --- self-certifying secured rooms (no server-side key registry, no secret files) ---
|
|
170
|
+
// A secured room's id embeds a fingerprint of the OWNER's public key:
|
|
171
|
+
// hs_<fp>_<rand> fp = base64url(sha256(spki-DER)).slice(0, 22)
|
|
172
|
+
// Every token for the room carries the owner's public key (claim `pk`) and is
|
|
173
|
+
// signed by the matching private key (RS256). The relay trusts a token IFF the
|
|
174
|
+
// embedded key's fingerprint equals the one in the room id — so trust is anchored
|
|
175
|
+
// in the room id itself and the relay needs to store nothing. Only the owner (who
|
|
176
|
+
// holds the private key, kept in the editor's secure storage) can mint valid
|
|
177
|
+
// tokens. This is what lets the hosted, stateless, free-tier relay enforce
|
|
178
|
+
// per-folder access with no registration step and no secret handed to anyone.
|
|
179
|
+
export function keyFingerprint(publicKeyPem) {
|
|
180
|
+
try {
|
|
181
|
+
const der = crypto.createPublicKey(publicKeyPem).export({ type: 'spki', format: 'der' })
|
|
182
|
+
return crypto.createHash('sha256').update(der).digest('base64url').slice(0, 22)
|
|
183
|
+
} catch { return null }
|
|
184
|
+
}
|
|
185
|
+
export function roomFingerprint(room) {
|
|
186
|
+
const m = /^hs_([A-Za-z0-9_-]{22})_/.exec(room || '')
|
|
187
|
+
return m ? m[1] : null
|
|
188
|
+
}
|
|
189
|
+
export const isSecuredRoom = (room) => roomFingerprint(room) != null
|
|
190
|
+
export function makeSecuredRoomId(publicKeyPem, rand) {
|
|
191
|
+
const fp = keyFingerprint(publicKeyPem)
|
|
192
|
+
return fp ? `hs_${fp}_${rand}` : null
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Read a token's payload WITHOUT verifying — for a client to inspect its OWN grant
|
|
196
|
+
// (e.g. which paths it may open) for UX. NEVER use for access decisions; the relay
|
|
197
|
+
// is the enforcer (it verifies the signature).
|
|
198
|
+
export function decodeUnsafe(token) {
|
|
199
|
+
try { return JSON.parse(Buffer.from(String(token).split('.')[1].replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')) }
|
|
200
|
+
catch { return null }
|
|
201
|
+
}
|