@intx/hub-api 0.1.2
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 +29 -0
- package/package.json +28 -0
- package/src/app.test.ts +225 -0
- package/src/app.ts +382 -0
- package/src/auth.ts +21 -0
- package/src/context.ts +38 -0
- package/src/format.ts +9 -0
- package/src/git-http/advertise-refs.test.ts +459 -0
- package/src/git-http/advertise-refs.ts +226 -0
- package/src/git-http/pkt-line.test.ts +220 -0
- package/src/git-http/pkt-line.ts +235 -0
- package/src/git-http/receive-pack.test.ts +397 -0
- package/src/git-http/receive-pack.ts +261 -0
- package/src/git-http/side-band-64k.test.ts +181 -0
- package/src/git-http/side-band-64k.ts +134 -0
- package/src/git-http/upload-pack.test.ts +545 -0
- package/src/git-http/upload-pack.ts +396 -0
- package/src/index.ts +23 -0
- package/src/middleware/git-token-auth.test.ts +587 -0
- package/src/middleware/git-token-auth.ts +315 -0
- package/src/middleware/grant.ts +106 -0
- package/src/middleware/session.ts +13 -0
- package/src/middleware/tenant.test.ts +192 -0
- package/src/middleware/tenant.ts +101 -0
- package/src/openapi.ts +66 -0
- package/src/pagination.ts +117 -0
- package/src/routes/agent-data.ts +179 -0
- package/src/routes/agent-state-git.ts +562 -0
- package/src/routes/agents.test.ts +337 -0
- package/src/routes/agents.ts +704 -0
- package/src/routes/approvals.ts +130 -0
- package/src/routes/assets.test.ts +567 -0
- package/src/routes/assets.ts +592 -0
- package/src/routes/credentials.ts +435 -0
- package/src/routes/git-tokens.test.ts +709 -0
- package/src/routes/git-tokens.ts +771 -0
- package/src/routes/grants.ts +509 -0
- package/src/routes/instances.test.ts +1103 -0
- package/src/routes/instances.ts +1797 -0
- package/src/routes/me.ts +405 -0
- package/src/routes/oauth-clients.ts +349 -0
- package/src/routes/observability.ts +146 -0
- package/src/routes/offerings.ts +382 -0
- package/src/routes/principals.ts +515 -0
- package/src/routes/providers.ts +351 -0
- package/src/routes/roles.ts +452 -0
- package/src/routes/sidecars.ts +221 -0
- package/src/routes/tenant-federation.ts +225 -0
- package/src/routes/tenants.ts +369 -0
- package/src/routes/wallets.ts +370 -0
- package/src/session.ts +44 -0
- package/src/timeline-reconstruction.test.ts +786 -0
- package/src/timeline-reconstruction.ts +383 -0
- package/tsconfig.json +4 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent-state smart-HTTP route group.
|
|
3
|
+
*
|
|
4
|
+
* Two URL grammars exposed under two Hono sub-apps:
|
|
5
|
+
*
|
|
6
|
+
* /api/tenants/:tenantId/agents/instances/:insId/state.git/...
|
|
7
|
+
* -> RepoId { kind: "agent-state", id: insId }
|
|
8
|
+
* (per-instance runtime state, written by the sidecar's first
|
|
9
|
+
* state pack)
|
|
10
|
+
*
|
|
11
|
+
* /api/tenants/:tenantId/agents/definitions/:agtId/state.git/...
|
|
12
|
+
* -> RepoId { kind: "agent-state", id: agtId }
|
|
13
|
+
* (per-definition repo with hub-written deploy artifacts; the
|
|
14
|
+
* `deploy/` prefix is populated by writeDeployTree at instance
|
|
15
|
+
* launch)
|
|
16
|
+
*
|
|
17
|
+
* Both grammars are READ-ONLY over HTTP. Upload-pack
|
|
18
|
+
* (`info/refs?service=git-upload-pack` and `POST /git-upload-pack`)
|
|
19
|
+
* runs behind the same bearer middleware the asset routes use, with
|
|
20
|
+
* a pre-resolved authz verdict on the constructed UserPrincipal.
|
|
21
|
+
* Receive-pack
|
|
22
|
+
* (`info/refs?service=git-receive-pack` and `POST /git-receive-pack`)
|
|
23
|
+
* is denied at the edge with pkt-line-framed responses so a
|
|
24
|
+
* `git push -v` parses the protocol-level rejection even when no
|
|
25
|
+
* Authorization header is present. The receive-pack denial
|
|
26
|
+
* middleware is mounted BEFORE bearer middleware in the app layer;
|
|
27
|
+
* the substrate's `handleReceivePack` is NOT imported here — agent
|
|
28
|
+
* state never accepts writes over HTTP.
|
|
29
|
+
*
|
|
30
|
+
* Both resolvers verify the instance / definition row belongs to
|
|
31
|
+
* `:tenantId` and 404 otherwise. The per-instance repo is
|
|
32
|
+
* lazily-materialised: on a never-pushed instance, `listRefs`
|
|
33
|
+
* returns the empty list and the advertise layer emits the
|
|
34
|
+
* `capabilities^{}` empty-repo record so a stock `git clone`
|
|
35
|
+
* succeeds against an empty tree rather than 404ing.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { and, eq } from "drizzle-orm";
|
|
39
|
+
import { Hono, type Context } from "hono";
|
|
40
|
+
import { createMiddleware } from "hono/factory";
|
|
41
|
+
import type { MiddlewareHandler } from "hono";
|
|
42
|
+
|
|
43
|
+
import { authorize } from "@intx/authz";
|
|
44
|
+
import { agent as agentTable, agentInstance } from "@intx/db/schema";
|
|
45
|
+
import type { DB } from "@intx/db";
|
|
46
|
+
import { repoActionToGrantVerb } from "@intx/hub-common";
|
|
47
|
+
import { getLogger } from "@intx/log";
|
|
48
|
+
import type {
|
|
49
|
+
RefEntry,
|
|
50
|
+
RepoId,
|
|
51
|
+
RepoStore,
|
|
52
|
+
UserPrincipal,
|
|
53
|
+
} from "@intx/hub-sessions";
|
|
54
|
+
import type { RepoAction } from "@intx/types/sidecar";
|
|
55
|
+
import type { ConditionRegistry, GrantStore } from "@intx/types/authz";
|
|
56
|
+
|
|
57
|
+
import type {
|
|
58
|
+
GitTokenClaims,
|
|
59
|
+
TenantGitTokenEnv,
|
|
60
|
+
} from "../middleware/git-token-auth";
|
|
61
|
+
import {
|
|
62
|
+
advertiseUploadPack,
|
|
63
|
+
type RefSource,
|
|
64
|
+
} from "../git-http/advertise-refs";
|
|
65
|
+
import {
|
|
66
|
+
handleUploadPack,
|
|
67
|
+
type UploadPackRepoStore,
|
|
68
|
+
} from "../git-http/upload-pack";
|
|
69
|
+
import { writePktLine, writeFlush } from "../git-http/pkt-line";
|
|
70
|
+
|
|
71
|
+
const log = getLogger(["hub", "agent-state-git"]);
|
|
72
|
+
|
|
73
|
+
// ----- Receive-pack denial: pkt-line responses -----------------------
|
|
74
|
+
//
|
|
75
|
+
// The advertise denial body is locked to:
|
|
76
|
+
//
|
|
77
|
+
// # service=git-receive-pack\n0000ERR agent-state is read-only over HTTP\n
|
|
78
|
+
//
|
|
79
|
+
// The leading `# service=` line and the `0000` flush packet mirror the
|
|
80
|
+
// shape stock git emits for a successful advertise; the trailing
|
|
81
|
+
// `ERR ...` substring is what `git push -v` surfaces as the visible
|
|
82
|
+
// rejection reason. The body is emitted verbatim (not pkt-line
|
|
83
|
+
// framed beyond the literal `0000` flush in the middle).
|
|
84
|
+
const RECEIVE_PACK_ADVERTISE_DENY_BODY =
|
|
85
|
+
"# service=git-receive-pack\n0000ERR agent-state is read-only over HTTP\n";
|
|
86
|
+
|
|
87
|
+
// The POST denial reason that surfaces in the `unpack` and per-ref
|
|
88
|
+
// `ng` pkt-lines. Short, stable, support-recognisable.
|
|
89
|
+
const RECEIVE_PACK_POST_DENY_REASON = "agent-state-readonly";
|
|
90
|
+
|
|
91
|
+
const RECEIVE_PACK_RESULT_CONTENT_TYPE =
|
|
92
|
+
"application/x-git-receive-pack-result";
|
|
93
|
+
const RECEIVE_PACK_ADVERTISEMENT_CONTENT_TYPE =
|
|
94
|
+
"application/x-git-receive-pack-advertisement";
|
|
95
|
+
|
|
96
|
+
function parseHex4(buf: Uint8Array, off: number): number {
|
|
97
|
+
let v = 0;
|
|
98
|
+
for (let i = 0; i < 4; i++) {
|
|
99
|
+
const c = buf[off + i];
|
|
100
|
+
if (c === undefined) {
|
|
101
|
+
throw new Error("truncated pkt-line: short header");
|
|
102
|
+
}
|
|
103
|
+
let d: number;
|
|
104
|
+
if (c >= 0x30 && c <= 0x39) {
|
|
105
|
+
d = c - 0x30;
|
|
106
|
+
} else if (c >= 0x61 && c <= 0x66) {
|
|
107
|
+
d = c - 0x61 + 10;
|
|
108
|
+
} else if (c >= 0x41 && c <= 0x46) {
|
|
109
|
+
d = c - 0x41 + 10;
|
|
110
|
+
} else {
|
|
111
|
+
throw new Error("malformed pkt-line length");
|
|
112
|
+
}
|
|
113
|
+
v = (v << 4) | d;
|
|
114
|
+
}
|
|
115
|
+
return v;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Minimal parse of the receive-pack request body to extract the list
|
|
120
|
+
* of ref names. The full request grammar includes old/new shas and
|
|
121
|
+
* optional capability tail; we only care about the ref name for the
|
|
122
|
+
* `ng <ref> <reason>` lines. Capabilities and packfile bytes that
|
|
123
|
+
* follow the flush packet are ignored.
|
|
124
|
+
*/
|
|
125
|
+
function extractReceivePackRefs(body: Uint8Array): string[] {
|
|
126
|
+
const decoder = new TextDecoder();
|
|
127
|
+
const refs: string[] = [];
|
|
128
|
+
let off = 0;
|
|
129
|
+
while (off + 4 <= body.length) {
|
|
130
|
+
const length = parseHex4(body, off);
|
|
131
|
+
off += 4;
|
|
132
|
+
if (length === 0) {
|
|
133
|
+
// flush: end of commands
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
if (length < 4) {
|
|
137
|
+
throw new Error(`reserved pkt-line length: ${length}`);
|
|
138
|
+
}
|
|
139
|
+
const bodyLen = length - 4;
|
|
140
|
+
if (off + bodyLen > body.length) {
|
|
141
|
+
throw new Error("truncated receive-pack pkt-line body");
|
|
142
|
+
}
|
|
143
|
+
const line = decoder.decode(body.subarray(off, off + bodyLen));
|
|
144
|
+
off += bodyLen;
|
|
145
|
+
// strip optional capability tail after \0 and trailing \n
|
|
146
|
+
const nulIdx = line.indexOf("\0");
|
|
147
|
+
const head = nulIdx === -1 ? line : line.substring(0, nulIdx);
|
|
148
|
+
const trimmed = head.endsWith("\n") ? head.slice(0, -1) : head;
|
|
149
|
+
const parts = trimmed.split(" ");
|
|
150
|
+
if (parts.length < 3) continue;
|
|
151
|
+
const ref = parts.slice(2).join(" ");
|
|
152
|
+
if (ref.length > 0) refs.push(ref);
|
|
153
|
+
}
|
|
154
|
+
return refs;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function writeReceivePackDenyReport(
|
|
158
|
+
writer: WritableStreamDefaultWriter<Uint8Array>,
|
|
159
|
+
refs: readonly string[],
|
|
160
|
+
): Promise<void> {
|
|
161
|
+
await writePktLine(writer, `unpack ${RECEIVE_PACK_POST_DENY_REASON}\n`);
|
|
162
|
+
for (const ref of refs) {
|
|
163
|
+
await writePktLine(writer, `ng ${ref} ${RECEIVE_PACK_POST_DENY_REASON}\n`);
|
|
164
|
+
}
|
|
165
|
+
await writeFlush(writer);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function buildReceivePackPostDenyStream(
|
|
169
|
+
refs: readonly string[],
|
|
170
|
+
): ReadableStream<Uint8Array> {
|
|
171
|
+
return new ReadableStream<Uint8Array>({
|
|
172
|
+
async start(controller) {
|
|
173
|
+
const sink = new WritableStream<Uint8Array>({
|
|
174
|
+
write(chunk) {
|
|
175
|
+
controller.enqueue(chunk);
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
const writer = sink.getWriter();
|
|
179
|
+
try {
|
|
180
|
+
await writeReceivePackDenyReport(writer, refs);
|
|
181
|
+
await writer.close();
|
|
182
|
+
controller.close();
|
|
183
|
+
} catch (cause) {
|
|
184
|
+
await writer.abort(cause).catch(() => undefined);
|
|
185
|
+
controller.error(cause);
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ----- Receive-pack deny middleware ---------------------------------
|
|
192
|
+
//
|
|
193
|
+
// Mounted ahead of the bearer middleware. Intercepts:
|
|
194
|
+
// - GET .../info/refs?service=git-receive-pack -> 403 with locked body
|
|
195
|
+
// - POST .../git-receive-pack -> 200 pkt-line denial
|
|
196
|
+
// Other requests (upload-pack info/refs and POST) call next() so the
|
|
197
|
+
// subsequent bearer + upload-pack handlers run normally.
|
|
198
|
+
|
|
199
|
+
function urlPathEndsWith(path: string, suffix: string): boolean {
|
|
200
|
+
return path === suffix || path.endsWith(suffix);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Hono middleware that denies any receive-pack request reaching the
|
|
205
|
+
* agent-state route surface, regardless of bearer-token presence.
|
|
206
|
+
* Mount BEFORE `createGitTokenAuth` so unauthenticated `git push -v`
|
|
207
|
+
* sees the locked pkt-line ERR rather than a 401.
|
|
208
|
+
*/
|
|
209
|
+
export function createAgentStateReceivePackDeny(): MiddlewareHandler {
|
|
210
|
+
return createMiddleware(async (c, next) => {
|
|
211
|
+
const method = c.req.method.toUpperCase();
|
|
212
|
+
const path = c.req.path;
|
|
213
|
+
|
|
214
|
+
if (method === "GET" && urlPathEndsWith(path, "/info/refs")) {
|
|
215
|
+
const service = c.req.query("service");
|
|
216
|
+
if (service === "git-receive-pack") {
|
|
217
|
+
log.info("receive-pack advertise denied {path}", { path });
|
|
218
|
+
return new Response(RECEIVE_PACK_ADVERTISE_DENY_BODY, {
|
|
219
|
+
status: 403,
|
|
220
|
+
headers: {
|
|
221
|
+
"content-type": RECEIVE_PACK_ADVERTISEMENT_CONTENT_TYPE,
|
|
222
|
+
"cache-control": "no-cache",
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
// upload-pack advertise: fall through.
|
|
227
|
+
await next();
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (method === "POST" && urlPathEndsWith(path, "/git-receive-pack")) {
|
|
232
|
+
log.info("receive-pack POST denied {path}", { path });
|
|
233
|
+
let refs: string[] = [];
|
|
234
|
+
try {
|
|
235
|
+
const body = new Uint8Array(await c.req.raw.arrayBuffer());
|
|
236
|
+
refs = extractReceivePackRefs(body);
|
|
237
|
+
} catch (err) {
|
|
238
|
+
// Malformed body still gets a deny report with no per-ref lines.
|
|
239
|
+
log.info("receive-pack POST: body parse failed {err}", {
|
|
240
|
+
err: err instanceof Error ? err.message : String(err),
|
|
241
|
+
});
|
|
242
|
+
refs = [];
|
|
243
|
+
}
|
|
244
|
+
const stream = buildReceivePackPostDenyStream(refs);
|
|
245
|
+
return new Response(stream, {
|
|
246
|
+
status: 200,
|
|
247
|
+
headers: {
|
|
248
|
+
"content-type": RECEIVE_PACK_RESULT_CONTENT_TYPE,
|
|
249
|
+
"cache-control": "no-cache",
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
await next();
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ----- Pre-resolved authz + UserPrincipal construction --------------
|
|
259
|
+
|
|
260
|
+
function dateToNumber(d: Date): number {
|
|
261
|
+
return d.getTime();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function resolveAuthzVerdict(args: {
|
|
265
|
+
grantStore: GrantStore;
|
|
266
|
+
conditionRegistry: ConditionRegistry;
|
|
267
|
+
principalId: string;
|
|
268
|
+
tenantId: string;
|
|
269
|
+
agentStateId: string;
|
|
270
|
+
action: RepoAction;
|
|
271
|
+
}): Promise<UserPrincipal["authz"]> {
|
|
272
|
+
const resource = `agent-state:${args.agentStateId}`;
|
|
273
|
+
const grantVerb = repoActionToGrantVerb(args.action);
|
|
274
|
+
const verdict = await authorize(
|
|
275
|
+
args.grantStore,
|
|
276
|
+
args.principalId,
|
|
277
|
+
args.tenantId,
|
|
278
|
+
resource,
|
|
279
|
+
grantVerb,
|
|
280
|
+
args.conditionRegistry,
|
|
281
|
+
);
|
|
282
|
+
return {
|
|
283
|
+
effect: verdict.effect === "allow" ? "allow" : "deny",
|
|
284
|
+
resource,
|
|
285
|
+
grantVerb,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function buildUserPrincipal(args: {
|
|
290
|
+
principalId: string;
|
|
291
|
+
tenantId: string;
|
|
292
|
+
authz: UserPrincipal["authz"];
|
|
293
|
+
claims: GitTokenClaims;
|
|
294
|
+
}): UserPrincipal {
|
|
295
|
+
return {
|
|
296
|
+
kind: "user",
|
|
297
|
+
principalId: args.principalId,
|
|
298
|
+
tenantId: args.tenantId,
|
|
299
|
+
authz: args.authz,
|
|
300
|
+
tokenClaims: {
|
|
301
|
+
refPattern: args.claims.refPattern,
|
|
302
|
+
actions: args.claims.actions,
|
|
303
|
+
expiresAt: dateToNumber(args.claims.expiresAt),
|
|
304
|
+
},
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ----- Substrate adapters -------------------------------------------
|
|
309
|
+
|
|
310
|
+
function makeRefSource(
|
|
311
|
+
repoStore: RepoStore,
|
|
312
|
+
principal: UserPrincipal,
|
|
313
|
+
): RefSource {
|
|
314
|
+
return {
|
|
315
|
+
async listRefs(_p, repoId): Promise<RefEntry[]> {
|
|
316
|
+
return repoStore.listRefs(principal, repoId);
|
|
317
|
+
},
|
|
318
|
+
async resolveHead(_p, repoId) {
|
|
319
|
+
return repoStore.resolveHead(principal, repoId);
|
|
320
|
+
},
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function makeUploadPackStore(
|
|
325
|
+
repoStore: RepoStore,
|
|
326
|
+
principal: UserPrincipal,
|
|
327
|
+
): UploadPackRepoStore {
|
|
328
|
+
return {
|
|
329
|
+
async listRefs(_p, repoId): Promise<RefEntry[]> {
|
|
330
|
+
return repoStore.listRefs(principal, repoId);
|
|
331
|
+
},
|
|
332
|
+
async getRepoDir(_p, repoId): Promise<string> {
|
|
333
|
+
return repoStore.getRepoDir(repoId);
|
|
334
|
+
},
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ----- Resolver shape -----------------------------------------------
|
|
339
|
+
|
|
340
|
+
type AgentStateRouteMode = "instance" | "definition";
|
|
341
|
+
|
|
342
|
+
type SmartHttpResolved = {
|
|
343
|
+
principal: UserPrincipal;
|
|
344
|
+
repoId: RepoId;
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
type ResolveResult =
|
|
348
|
+
| { ok: true; resolved: SmartHttpResolved }
|
|
349
|
+
| {
|
|
350
|
+
ok: false;
|
|
351
|
+
status: 400 | 403 | 404;
|
|
352
|
+
code: string;
|
|
353
|
+
message: string;
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
async function resolveAgentStateId(
|
|
357
|
+
db: DB["db"],
|
|
358
|
+
mode: AgentStateRouteMode,
|
|
359
|
+
tenantId: string,
|
|
360
|
+
paramId: string,
|
|
361
|
+
): Promise<{ ok: true; id: string } | { ok: false; reason: string }> {
|
|
362
|
+
if (mode === "instance") {
|
|
363
|
+
const row = await db.query.agentInstance.findFirst({
|
|
364
|
+
where: and(
|
|
365
|
+
eq(agentInstance.id, paramId),
|
|
366
|
+
eq(agentInstance.tenantId, tenantId),
|
|
367
|
+
),
|
|
368
|
+
});
|
|
369
|
+
if (row === undefined) {
|
|
370
|
+
return { ok: false, reason: `no instance ${paramId} in tenant` };
|
|
371
|
+
}
|
|
372
|
+
return { ok: true, id: row.id };
|
|
373
|
+
}
|
|
374
|
+
const row = await db.query.agent.findFirst({
|
|
375
|
+
where: and(eq(agentTable.id, paramId), eq(agentTable.tenantId, tenantId)),
|
|
376
|
+
});
|
|
377
|
+
if (row === undefined) {
|
|
378
|
+
return { ok: false, reason: `no agent definition ${paramId} in tenant` };
|
|
379
|
+
}
|
|
380
|
+
return { ok: true, id: row.id };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
type ResolveSmartHttpDeps = {
|
|
384
|
+
db: DB["db"];
|
|
385
|
+
grantStore: GrantStore;
|
|
386
|
+
conditionRegistry: ConditionRegistry;
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
async function resolveSmartHttp(
|
|
390
|
+
deps: ResolveSmartHttpDeps,
|
|
391
|
+
c: Context<TenantGitTokenEnv>,
|
|
392
|
+
mode: AgentStateRouteMode,
|
|
393
|
+
action: RepoAction,
|
|
394
|
+
): Promise<ResolveResult> {
|
|
395
|
+
const tenantRow = c.get("tenant");
|
|
396
|
+
const principalRow = c.get("principal");
|
|
397
|
+
const claims: GitTokenClaims = c.get("git-token-claims");
|
|
398
|
+
// The typed env makes this unreachable today, but if the route
|
|
399
|
+
// module is ever mounted without the bearer middleware ahead of
|
|
400
|
+
// it, surface a misconfiguration rather than a downstream
|
|
401
|
+
// TypeError. A 401 would imply the client was unauthenticated;
|
|
402
|
+
// a missing claims object means the server is misconfigured.
|
|
403
|
+
if (claims === undefined) {
|
|
404
|
+
throw new Error(
|
|
405
|
+
"smart-HTTP route handler invoked without bearer middleware; check the mount order in app.ts",
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
if (!claims.actions.includes(action)) {
|
|
409
|
+
return {
|
|
410
|
+
ok: false,
|
|
411
|
+
status: 403,
|
|
412
|
+
code: "forbidden",
|
|
413
|
+
message: `token claims do not include action ${action}`,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
const paramName = mode === "instance" ? "instanceId" : "agentId";
|
|
417
|
+
const paramId = c.req.param(paramName);
|
|
418
|
+
if (paramId === undefined) {
|
|
419
|
+
return {
|
|
420
|
+
ok: false,
|
|
421
|
+
status: 400,
|
|
422
|
+
code: "bad_request",
|
|
423
|
+
message: `missing :${paramName} in URL`,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
const tenantId = tenantRow.id;
|
|
427
|
+
const resolved = await resolveAgentStateId(deps.db, mode, tenantId, paramId);
|
|
428
|
+
if (!resolved.ok) {
|
|
429
|
+
return {
|
|
430
|
+
ok: false,
|
|
431
|
+
status: 404,
|
|
432
|
+
code: "not_found",
|
|
433
|
+
message: resolved.reason,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
const authz = await resolveAuthzVerdict({
|
|
437
|
+
grantStore: deps.grantStore,
|
|
438
|
+
conditionRegistry: deps.conditionRegistry,
|
|
439
|
+
principalId: principalRow.id,
|
|
440
|
+
tenantId,
|
|
441
|
+
agentStateId: resolved.id,
|
|
442
|
+
action,
|
|
443
|
+
});
|
|
444
|
+
if (authz.effect !== "allow") {
|
|
445
|
+
log.info(
|
|
446
|
+
"agent-state authz denied {tenantId} {mode}={id} principal={principalId}",
|
|
447
|
+
{
|
|
448
|
+
tenantId,
|
|
449
|
+
mode,
|
|
450
|
+
id: resolved.id,
|
|
451
|
+
principalId: principalRow.id,
|
|
452
|
+
},
|
|
453
|
+
);
|
|
454
|
+
return {
|
|
455
|
+
ok: false,
|
|
456
|
+
status: 403,
|
|
457
|
+
code: "forbidden",
|
|
458
|
+
message: "authz denied",
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
const principal = buildUserPrincipal({
|
|
462
|
+
principalId: principalRow.id,
|
|
463
|
+
tenantId,
|
|
464
|
+
authz,
|
|
465
|
+
claims,
|
|
466
|
+
});
|
|
467
|
+
const repoId: RepoId = { kind: "agent-state", id: resolved.id };
|
|
468
|
+
return { ok: true, resolved: { principal, repoId } };
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// ----- Upload-pack route factory ------------------------------------
|
|
472
|
+
|
|
473
|
+
export type CreateAgentStateGitRoutesDeps = {
|
|
474
|
+
db: DB["db"];
|
|
475
|
+
repoStore: RepoStore;
|
|
476
|
+
grantStore: GrantStore;
|
|
477
|
+
conditionRegistry: ConditionRegistry;
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
function createAgentStateGitRoutes(
|
|
481
|
+
deps: CreateAgentStateGitRoutesDeps,
|
|
482
|
+
mode: AgentStateRouteMode,
|
|
483
|
+
): Hono<TenantGitTokenEnv> {
|
|
484
|
+
const app = new Hono<TenantGitTokenEnv>();
|
|
485
|
+
const paramSeg = mode === "instance" ? ":instanceId" : ":agentId";
|
|
486
|
+
|
|
487
|
+
app.get(`/${paramSeg}/state.git/info/refs`, async (c) => {
|
|
488
|
+
const service = c.req.query("service");
|
|
489
|
+
if (service !== "git-upload-pack") {
|
|
490
|
+
// The receive-pack case is handled by the deny middleware above;
|
|
491
|
+
// anything else is a bad request.
|
|
492
|
+
return c.json(
|
|
493
|
+
{
|
|
494
|
+
error: {
|
|
495
|
+
code: "bad_request",
|
|
496
|
+
message: "info/refs requires service=git-upload-pack",
|
|
497
|
+
},
|
|
498
|
+
},
|
|
499
|
+
400,
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
const r = await resolveSmartHttp(deps, c, mode, "resolveRef");
|
|
503
|
+
if (!r.ok) {
|
|
504
|
+
return c.json({ error: { code: r.code, message: r.message } }, r.status);
|
|
505
|
+
}
|
|
506
|
+
const refSource = makeRefSource(deps.repoStore, r.resolved.principal);
|
|
507
|
+
const stream = await advertiseUploadPack(
|
|
508
|
+
refSource,
|
|
509
|
+
r.resolved.principal,
|
|
510
|
+
r.resolved.repoId,
|
|
511
|
+
);
|
|
512
|
+
return new Response(stream, {
|
|
513
|
+
status: 200,
|
|
514
|
+
headers: {
|
|
515
|
+
"content-type": "application/x-git-upload-pack-advertisement",
|
|
516
|
+
"cache-control": "no-cache",
|
|
517
|
+
},
|
|
518
|
+
});
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
app.post(`/${paramSeg}/state.git/git-upload-pack`, async (c) => {
|
|
522
|
+
const r = await resolveSmartHttp(deps, c, mode, "createPack");
|
|
523
|
+
if (!r.ok) {
|
|
524
|
+
return c.json({ error: { code: r.code, message: r.message } }, r.status);
|
|
525
|
+
}
|
|
526
|
+
return handleUploadPack(
|
|
527
|
+
makeUploadPackStore(deps.repoStore, r.resolved.principal),
|
|
528
|
+
r.resolved.principal,
|
|
529
|
+
r.resolved.repoId,
|
|
530
|
+
c.req.raw,
|
|
531
|
+
);
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
return app;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
export function createAgentStateInstanceGitRoutes(
|
|
538
|
+
deps: CreateAgentStateGitRoutesDeps,
|
|
539
|
+
): Hono<TenantGitTokenEnv> {
|
|
540
|
+
return createAgentStateGitRoutes(deps, "instance");
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
export function createAgentStateDefinitionGitRoutes(
|
|
544
|
+
deps: CreateAgentStateGitRoutesDeps,
|
|
545
|
+
): Hono<TenantGitTokenEnv> {
|
|
546
|
+
return createAgentStateGitRoutes(deps, "definition");
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Smart-HTTP paths excluded from the OpenAPI document. The agent-state
|
|
551
|
+
* routes serve binary git wire vocabulary; advertising them in the
|
|
552
|
+
* generated spec would invite client codegen to treat them as JSON
|
|
553
|
+
* endpoints.
|
|
554
|
+
*/
|
|
555
|
+
export const AGENT_STATE_OPENAPI_EXCLUDE_GLOBS = [
|
|
556
|
+
"/api/tenants/*/agents/instances/*/state.git/info/refs",
|
|
557
|
+
"/api/tenants/*/agents/instances/*/state.git/git-upload-pack",
|
|
558
|
+
"/api/tenants/*/agents/instances/*/state.git/git-receive-pack",
|
|
559
|
+
"/api/tenants/*/agents/definitions/*/state.git/info/refs",
|
|
560
|
+
"/api/tenants/*/agents/definitions/*/state.git/git-upload-pack",
|
|
561
|
+
"/api/tenants/*/agents/definitions/*/state.git/git-receive-pack",
|
|
562
|
+
] as const;
|