@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,592 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Asset REST endpoint and smart-HTTP route group.
|
|
3
|
+
*
|
|
4
|
+
* Two distinct surfaces live in this file. The REST half — `POST /` —
|
|
5
|
+
* is gated by the standard session + `requireGrant("asset:*", "create")`
|
|
6
|
+
* pipeline and provisions the asset row plus the genesis-signed repo
|
|
7
|
+
* via `assetService.createAsset`. The smart-HTTP half — every path
|
|
8
|
+
* under `/:kind/:nameDotGit/...` — is gated by the bearer middleware
|
|
9
|
+
* the app layer mounts ahead of it (`itx_pat_*` / `itx_svc_*` tokens)
|
|
10
|
+
* and serves the four standard endpoints (`info/refs` for upload-pack
|
|
11
|
+
* and receive-pack, then the two POST endpoints themselves).
|
|
12
|
+
*
|
|
13
|
+
* The smart-HTTP handler resolves URL `:kind/:name` to a concrete
|
|
14
|
+
* `RepoId` by looking up the asset row `(tenantId, kind, name)`; on
|
|
15
|
+
* miss the request is rejected with `404 not_found`. The handler
|
|
16
|
+
* then resolves the authz verdict against `asset:<asset.id>` and the
|
|
17
|
+
* grant verb derived from the `RepoAction`, and constructs the
|
|
18
|
+
* `UserPrincipal` with the verdict pre-resolved so the substrate's
|
|
19
|
+
* authorize gate only sanity-checks rather than re-querying.
|
|
20
|
+
*
|
|
21
|
+
* Bearer-claim `expiresAt` is a `Date` on the wire; the substrate's
|
|
22
|
+
* `UserPrincipal.tokenClaims.expiresAt` is a `number`. The Date →
|
|
23
|
+
* number conversion happens exactly once, at the route handler
|
|
24
|
+
* boundary.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { and, eq } from "drizzle-orm";
|
|
28
|
+
import { Hono, type Context } from "hono";
|
|
29
|
+
import { describeRoute, resolver, validator } from "hono-openapi";
|
|
30
|
+
import { type } from "arktype";
|
|
31
|
+
|
|
32
|
+
import { authorize } from "@intx/authz";
|
|
33
|
+
import { asset as assetTable } from "@intx/db/schema";
|
|
34
|
+
import type { DB } from "@intx/db";
|
|
35
|
+
import { repoActionToGrantVerb } from "@intx/hub-common";
|
|
36
|
+
import { getLogger } from "@intx/log";
|
|
37
|
+
import {
|
|
38
|
+
AssetServiceError,
|
|
39
|
+
type AssetService,
|
|
40
|
+
type RefEntry,
|
|
41
|
+
type RepoId,
|
|
42
|
+
type RepoStore,
|
|
43
|
+
type UserPrincipal,
|
|
44
|
+
} from "@intx/hub-sessions";
|
|
45
|
+
import type { RepoAction, RepoKind } from "@intx/types/sidecar";
|
|
46
|
+
import type { ConditionRegistry, GrantStore } from "@intx/types/authz";
|
|
47
|
+
import { ErrorResponse } from "@intx/types";
|
|
48
|
+
|
|
49
|
+
import type { TenantEnv } from "../context";
|
|
50
|
+
import { ts } from "../format";
|
|
51
|
+
import type {
|
|
52
|
+
GitTokenClaims,
|
|
53
|
+
TenantGitTokenEnv,
|
|
54
|
+
} from "../middleware/git-token-auth";
|
|
55
|
+
import type { RequireGrant } from "../middleware/grant";
|
|
56
|
+
import {
|
|
57
|
+
advertiseReceivePack,
|
|
58
|
+
advertiseUploadPack,
|
|
59
|
+
type RefSource,
|
|
60
|
+
} from "../git-http/advertise-refs";
|
|
61
|
+
import {
|
|
62
|
+
handleUploadPack,
|
|
63
|
+
type UploadPackRepoStore,
|
|
64
|
+
} from "../git-http/upload-pack";
|
|
65
|
+
import { handleReceivePack } from "../git-http/receive-pack";
|
|
66
|
+
|
|
67
|
+
const log = getLogger(["hub", "assets"]);
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Genesis `.gitignore` body shipped with every asset repo. Captures
|
|
71
|
+
* the OS- and editor-cruft families that show up in skill-asset
|
|
72
|
+
* workspaces in practice, plus the `keys/` directory the hub uses to
|
|
73
|
+
* stage materialised credentials at session-start time. The list is
|
|
74
|
+
* a deliberate literal here; new entries are policy decisions
|
|
75
|
+
* reviewed at this file rather than fanned out through configuration.
|
|
76
|
+
*/
|
|
77
|
+
export const SANE_GITIGNORE = [
|
|
78
|
+
".DS_Store",
|
|
79
|
+
"Thumbs.db",
|
|
80
|
+
"desktop.ini",
|
|
81
|
+
".idea/",
|
|
82
|
+
".vscode/",
|
|
83
|
+
"*.swp",
|
|
84
|
+
"*.swo",
|
|
85
|
+
"node_modules/",
|
|
86
|
+
"dist/",
|
|
87
|
+
"build/",
|
|
88
|
+
"target/",
|
|
89
|
+
"*.log",
|
|
90
|
+
"keys/",
|
|
91
|
+
"",
|
|
92
|
+
].join("\n");
|
|
93
|
+
|
|
94
|
+
// REST contract -----------------------------------------------------
|
|
95
|
+
|
|
96
|
+
const KIND_VALUES = ["agent-state", "skill"] as const;
|
|
97
|
+
|
|
98
|
+
const CreateAsset = type({
|
|
99
|
+
kind: type.enumerated(...KIND_VALUES),
|
|
100
|
+
name: "string",
|
|
101
|
+
"displayName?": "string",
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const AssetResponseSchema = type({
|
|
105
|
+
id: "string",
|
|
106
|
+
tenantId: "string",
|
|
107
|
+
kind: type.enumerated(...KIND_VALUES),
|
|
108
|
+
name: "string",
|
|
109
|
+
displayName: "string | null",
|
|
110
|
+
creatorPrincipalId: "string | null",
|
|
111
|
+
createdAt: "string",
|
|
112
|
+
updatedAt: "string",
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// URL parsing for the smart-HTTP routes -----------------------------
|
|
116
|
+
|
|
117
|
+
// Asset names admitted by the service are lowercase-kebab. The
|
|
118
|
+
// smart-HTTP URL strips the `.git` suffix from the trailing path
|
|
119
|
+
// segment before the lookup; the kind segment is validated against
|
|
120
|
+
// the same enum the REST endpoint accepts.
|
|
121
|
+
const ASSET_NAME_URL_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
122
|
+
|
|
123
|
+
function parseKind(raw: string): RepoKind | null {
|
|
124
|
+
if (raw === "agent-state" || raw === "skill") return raw;
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function stripGitSuffix(raw: string): string | null {
|
|
129
|
+
if (!raw.endsWith(".git")) return null;
|
|
130
|
+
return raw.slice(0, -".git".length);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Pre-resolved authz + UserPrincipal construction ------------------
|
|
134
|
+
|
|
135
|
+
type AssetLookup = {
|
|
136
|
+
id: string;
|
|
137
|
+
tenantId: string;
|
|
138
|
+
kind: RepoKind;
|
|
139
|
+
name: string;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
function dateToNumber(d: Date): number {
|
|
143
|
+
return d.getTime();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function resolveAuthzVerdict(args: {
|
|
147
|
+
grantStore: GrantStore;
|
|
148
|
+
conditionRegistry: ConditionRegistry;
|
|
149
|
+
principalId: string;
|
|
150
|
+
tenantId: string;
|
|
151
|
+
assetId: string;
|
|
152
|
+
action: RepoAction;
|
|
153
|
+
}): Promise<UserPrincipal["authz"]> {
|
|
154
|
+
const resource = `asset:${args.assetId}`;
|
|
155
|
+
const grantVerb = repoActionToGrantVerb(args.action);
|
|
156
|
+
const verdict = await authorize(
|
|
157
|
+
args.grantStore,
|
|
158
|
+
args.principalId,
|
|
159
|
+
args.tenantId,
|
|
160
|
+
resource,
|
|
161
|
+
grantVerb,
|
|
162
|
+
args.conditionRegistry,
|
|
163
|
+
);
|
|
164
|
+
return {
|
|
165
|
+
effect: verdict.effect === "allow" ? "allow" : "deny",
|
|
166
|
+
resource,
|
|
167
|
+
grantVerb,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function buildUserPrincipal(args: {
|
|
172
|
+
principalId: string;
|
|
173
|
+
tenantId: string;
|
|
174
|
+
authz: UserPrincipal["authz"];
|
|
175
|
+
claims: GitTokenClaims;
|
|
176
|
+
}): UserPrincipal {
|
|
177
|
+
return {
|
|
178
|
+
kind: "user",
|
|
179
|
+
principalId: args.principalId,
|
|
180
|
+
tenantId: args.tenantId,
|
|
181
|
+
authz: args.authz,
|
|
182
|
+
tokenClaims: {
|
|
183
|
+
refPattern: args.claims.refPattern,
|
|
184
|
+
actions: args.claims.actions,
|
|
185
|
+
expiresAt: dateToNumber(args.claims.expiresAt),
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Substrate adapters: bridge the substrate's RepoStore to the narrow
|
|
191
|
+
// per-handler contracts that advertise-refs and upload-pack expose.
|
|
192
|
+
|
|
193
|
+
function makeRefSource(
|
|
194
|
+
repoStore: RepoStore,
|
|
195
|
+
principal: UserPrincipal,
|
|
196
|
+
): RefSource {
|
|
197
|
+
return {
|
|
198
|
+
async listRefs(_p, repoId): Promise<RefEntry[]> {
|
|
199
|
+
return repoStore.listRefs(principal, repoId);
|
|
200
|
+
},
|
|
201
|
+
async resolveHead(_p, repoId) {
|
|
202
|
+
return repoStore.resolveHead(principal, repoId);
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function makeUploadPackStore(
|
|
208
|
+
repoStore: RepoStore,
|
|
209
|
+
principal: UserPrincipal,
|
|
210
|
+
): UploadPackRepoStore {
|
|
211
|
+
return {
|
|
212
|
+
async listRefs(_p, repoId): Promise<RefEntry[]> {
|
|
213
|
+
return repoStore.listRefs(principal, repoId);
|
|
214
|
+
},
|
|
215
|
+
async getRepoDir(_p, repoId): Promise<string> {
|
|
216
|
+
return repoStore.getRepoDir(repoId);
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Routes ------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
export type CreateAssetRoutesDeps = {
|
|
224
|
+
db: DB["db"];
|
|
225
|
+
assetService: AssetService;
|
|
226
|
+
repoStore: RepoStore;
|
|
227
|
+
grantStore: GrantStore;
|
|
228
|
+
conditionRegistry: ConditionRegistry;
|
|
229
|
+
requireGrant: RequireGrant;
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
export function createAssetRoutes({
|
|
233
|
+
db,
|
|
234
|
+
assetService,
|
|
235
|
+
repoStore,
|
|
236
|
+
grantStore,
|
|
237
|
+
conditionRegistry,
|
|
238
|
+
requireGrant,
|
|
239
|
+
}: CreateAssetRoutesDeps): Hono<TenantEnv> {
|
|
240
|
+
const app = new Hono<TenantEnv>();
|
|
241
|
+
|
|
242
|
+
app.post(
|
|
243
|
+
"/",
|
|
244
|
+
requireGrant("asset:*", "create"),
|
|
245
|
+
describeRoute({
|
|
246
|
+
tags: ["Assets"],
|
|
247
|
+
summary: "Create an asset",
|
|
248
|
+
description:
|
|
249
|
+
"Inserts an asset row and initializes the backing git repository with a hub-signed genesis commit and the asset-route .gitignore body.",
|
|
250
|
+
responses: {
|
|
251
|
+
201: {
|
|
252
|
+
description: "Asset created",
|
|
253
|
+
content: {
|
|
254
|
+
"application/json": { schema: resolver(AssetResponseSchema) },
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
400: {
|
|
258
|
+
description: "Validation error",
|
|
259
|
+
content: {
|
|
260
|
+
"application/json": { schema: resolver(ErrorResponse) },
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
409: {
|
|
264
|
+
description: "Asset already exists",
|
|
265
|
+
content: {
|
|
266
|
+
"application/json": { schema: resolver(ErrorResponse) },
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
}),
|
|
271
|
+
validator("json", CreateAsset),
|
|
272
|
+
async (c) => {
|
|
273
|
+
const tenantCtx = c.get("tenant");
|
|
274
|
+
const principalCtx = c.get("principal");
|
|
275
|
+
const body = c.req.valid("json");
|
|
276
|
+
|
|
277
|
+
try {
|
|
278
|
+
const asset = await assetService.createAsset({
|
|
279
|
+
tenantId: tenantCtx.id,
|
|
280
|
+
kind: body.kind,
|
|
281
|
+
name: body.name,
|
|
282
|
+
...(body.displayName === undefined
|
|
283
|
+
? {}
|
|
284
|
+
: { displayName: body.displayName }),
|
|
285
|
+
creatorPrincipalId: principalCtx.id,
|
|
286
|
+
initOpts: { gitignore: SANE_GITIGNORE },
|
|
287
|
+
});
|
|
288
|
+
log.info(
|
|
289
|
+
"create succeeded {tenantId} kind={kind} name={name} id={id}",
|
|
290
|
+
{
|
|
291
|
+
tenantId: tenantCtx.id,
|
|
292
|
+
kind: asset.kind,
|
|
293
|
+
name: asset.name,
|
|
294
|
+
id: asset.id,
|
|
295
|
+
},
|
|
296
|
+
);
|
|
297
|
+
return c.json(
|
|
298
|
+
{
|
|
299
|
+
id: asset.id,
|
|
300
|
+
tenantId: asset.tenantId,
|
|
301
|
+
kind: asset.kind,
|
|
302
|
+
name: asset.name,
|
|
303
|
+
displayName: asset.displayName,
|
|
304
|
+
creatorPrincipalId: asset.creatorPrincipalId,
|
|
305
|
+
createdAt: ts(asset.createdAt),
|
|
306
|
+
updatedAt: ts(asset.updatedAt),
|
|
307
|
+
},
|
|
308
|
+
201,
|
|
309
|
+
);
|
|
310
|
+
} catch (err) {
|
|
311
|
+
if (err instanceof AssetServiceError) {
|
|
312
|
+
let status: 400 | 409;
|
|
313
|
+
if (err.reason === "duplicate_asset") {
|
|
314
|
+
status = 409;
|
|
315
|
+
} else {
|
|
316
|
+
status = 400;
|
|
317
|
+
}
|
|
318
|
+
log.info("create rejected {tenantId} code={code}", {
|
|
319
|
+
tenantId: tenantCtx.id,
|
|
320
|
+
code: err.reason,
|
|
321
|
+
});
|
|
322
|
+
return c.json(
|
|
323
|
+
{ error: { code: err.reason, message: err.message } },
|
|
324
|
+
status,
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
throw err;
|
|
328
|
+
}
|
|
329
|
+
},
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
// ----- Smart-HTTP route group -------------------------------------
|
|
333
|
+
//
|
|
334
|
+
// The bearer middleware is mounted by the app layer ahead of this
|
|
335
|
+
// route group (so the `principal`, `tenant`, and `git-token-claims`
|
|
336
|
+
// context variables are populated before any handler runs). The
|
|
337
|
+
// handlers here resolve the asset row from `:kind/:nameDotGit`,
|
|
338
|
+
// build the pre-resolved authz verdict, construct a UserPrincipal,
|
|
339
|
+
// and dispatch to the wire handlers.
|
|
340
|
+
|
|
341
|
+
async function resolveAssetFromUrl(
|
|
342
|
+
c: { req: { param: (n: string) => string | undefined } },
|
|
343
|
+
tenantId: string,
|
|
344
|
+
): Promise<
|
|
345
|
+
| { ok: true; asset: AssetLookup; kind: RepoKind }
|
|
346
|
+
| { ok: false; status: 400 | 404; code: string; message: string }
|
|
347
|
+
> {
|
|
348
|
+
const kindRaw = c.req.param("kind");
|
|
349
|
+
const nameRaw = c.req.param("nameDotGit");
|
|
350
|
+
if (kindRaw === undefined || nameRaw === undefined) {
|
|
351
|
+
return {
|
|
352
|
+
ok: false,
|
|
353
|
+
status: 400,
|
|
354
|
+
code: "bad_request",
|
|
355
|
+
message: "Missing :kind or :name in URL",
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
const kind = parseKind(kindRaw);
|
|
359
|
+
if (kind === null) {
|
|
360
|
+
return {
|
|
361
|
+
ok: false,
|
|
362
|
+
status: 404,
|
|
363
|
+
code: "not_found",
|
|
364
|
+
message: `unknown asset kind: ${kindRaw}`,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
const name = stripGitSuffix(nameRaw);
|
|
368
|
+
if (name === null) {
|
|
369
|
+
return {
|
|
370
|
+
ok: false,
|
|
371
|
+
status: 400,
|
|
372
|
+
code: "bad_request",
|
|
373
|
+
message: `URL :name must end in .git, got ${nameRaw}`,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
if (!ASSET_NAME_URL_PATTERN.test(name)) {
|
|
377
|
+
return {
|
|
378
|
+
ok: false,
|
|
379
|
+
status: 400,
|
|
380
|
+
code: "bad_request",
|
|
381
|
+
message: `malformed asset name: ${name}`,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
const row = await db.query.asset.findFirst({
|
|
385
|
+
where: and(
|
|
386
|
+
eq(assetTable.tenantId, tenantId),
|
|
387
|
+
eq(assetTable.kind, kind),
|
|
388
|
+
eq(assetTable.name, name),
|
|
389
|
+
),
|
|
390
|
+
});
|
|
391
|
+
if (row === undefined) {
|
|
392
|
+
return {
|
|
393
|
+
ok: false,
|
|
394
|
+
status: 404,
|
|
395
|
+
code: "not_found",
|
|
396
|
+
message: `no asset ${kind}/${name}`,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
let narrowedKind: RepoKind;
|
|
400
|
+
if (row.kind === "agent-state") narrowedKind = "agent-state";
|
|
401
|
+
else if (row.kind === "skill") narrowedKind = "skill";
|
|
402
|
+
else {
|
|
403
|
+
return {
|
|
404
|
+
ok: false,
|
|
405
|
+
status: 404,
|
|
406
|
+
code: "not_found",
|
|
407
|
+
message: `asset row ${row.id} carries unsupported kind ${row.kind}`,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
return {
|
|
411
|
+
ok: true,
|
|
412
|
+
asset: {
|
|
413
|
+
id: row.id,
|
|
414
|
+
tenantId: row.tenantId,
|
|
415
|
+
kind: narrowedKind,
|
|
416
|
+
name: row.name,
|
|
417
|
+
},
|
|
418
|
+
kind: narrowedKind,
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Capture: tenant resolution is normally handled by the tenant
|
|
423
|
+
// middleware (session-based), but bearer requests skip the user
|
|
424
|
+
// session pipeline. The bearer middleware itself sets
|
|
425
|
+
// `principal`/`tenant` on the context, so the handlers here read
|
|
426
|
+
// straight from `c.get(...)` rather than re-querying the DB.
|
|
427
|
+
|
|
428
|
+
type SmartHttpResolved = {
|
|
429
|
+
principal: UserPrincipal;
|
|
430
|
+
repoId: RepoId;
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
async function resolveSmartHttp(
|
|
434
|
+
c: Context<TenantGitTokenEnv>,
|
|
435
|
+
action: RepoAction,
|
|
436
|
+
): Promise<
|
|
437
|
+
| { ok: true; resolved: SmartHttpResolved }
|
|
438
|
+
| {
|
|
439
|
+
ok: false;
|
|
440
|
+
status: 400 | 403 | 404;
|
|
441
|
+
code: string;
|
|
442
|
+
message: string;
|
|
443
|
+
}
|
|
444
|
+
> {
|
|
445
|
+
const tenantRow = c.get("tenant");
|
|
446
|
+
const principalRow = c.get("principal");
|
|
447
|
+
const claims: GitTokenClaims = c.get("git-token-claims");
|
|
448
|
+
// The typed env makes this unreachable today, but if the route
|
|
449
|
+
// module is ever mounted without the bearer middleware ahead of
|
|
450
|
+
// it, surface a misconfiguration rather than a downstream
|
|
451
|
+
// TypeError. A 401 would imply the client was unauthenticated;
|
|
452
|
+
// a missing claims object means the server is misconfigured.
|
|
453
|
+
if (claims === undefined) {
|
|
454
|
+
throw new Error(
|
|
455
|
+
"smart-HTTP route handler invoked without bearer middleware; check the mount order in app.ts",
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
if (!claims.actions.includes(action)) {
|
|
459
|
+
return {
|
|
460
|
+
ok: false,
|
|
461
|
+
status: 403,
|
|
462
|
+
code: "forbidden",
|
|
463
|
+
message: `token claims do not include action ${action}`,
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
const tenantId = tenantRow.id;
|
|
467
|
+
const resolvedAsset = await resolveAssetFromUrl(c, tenantId);
|
|
468
|
+
if (!resolvedAsset.ok) return resolvedAsset;
|
|
469
|
+
const authz = await resolveAuthzVerdict({
|
|
470
|
+
grantStore,
|
|
471
|
+
conditionRegistry,
|
|
472
|
+
principalId: principalRow.id,
|
|
473
|
+
tenantId,
|
|
474
|
+
assetId: resolvedAsset.asset.id,
|
|
475
|
+
action,
|
|
476
|
+
});
|
|
477
|
+
if (authz.effect !== "allow") {
|
|
478
|
+
log.info("smart-HTTP authz denied {tenantId} asset={assetId}", {
|
|
479
|
+
tenantId,
|
|
480
|
+
assetId: resolvedAsset.asset.id,
|
|
481
|
+
});
|
|
482
|
+
return {
|
|
483
|
+
ok: false,
|
|
484
|
+
status: 403,
|
|
485
|
+
code: "forbidden",
|
|
486
|
+
message: "authz denied",
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
const principal = buildUserPrincipal({
|
|
490
|
+
principalId: principalRow.id,
|
|
491
|
+
tenantId,
|
|
492
|
+
authz,
|
|
493
|
+
claims,
|
|
494
|
+
});
|
|
495
|
+
const repoId: RepoId = {
|
|
496
|
+
kind: resolvedAsset.kind,
|
|
497
|
+
id: resolvedAsset.asset.id,
|
|
498
|
+
};
|
|
499
|
+
return { ok: true, resolved: { principal, repoId } };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// The smart-HTTP sub-app is typed against `TenantGitTokenEnv` so the
|
|
503
|
+
// bearer middleware's `git-token-claims` variable narrows naturally
|
|
504
|
+
// at the handler site. The bearer middleware is mounted in `app.ts`
|
|
505
|
+
// ahead of this route surface, so the variable is statically present.
|
|
506
|
+
const smartHttp = new Hono<TenantGitTokenEnv>();
|
|
507
|
+
|
|
508
|
+
smartHttp.get("/:kind/:nameDotGit/info/refs", async (c) => {
|
|
509
|
+
const service = c.req.query("service");
|
|
510
|
+
if (service !== "git-upload-pack" && service !== "git-receive-pack") {
|
|
511
|
+
return c.json(
|
|
512
|
+
{
|
|
513
|
+
error: {
|
|
514
|
+
code: "bad_request",
|
|
515
|
+
message:
|
|
516
|
+
"info/refs requires service=git-upload-pack or git-receive-pack",
|
|
517
|
+
},
|
|
518
|
+
},
|
|
519
|
+
400,
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
// info/refs maps to the `resolveRef` RepoAction for the bearer
|
|
523
|
+
// claims gate; the substrate's createPack handler runs later and
|
|
524
|
+
// its own gate covers the upload itself.
|
|
525
|
+
const r = await resolveSmartHttp(c, "resolveRef");
|
|
526
|
+
if (!r.ok) {
|
|
527
|
+
return c.json({ error: { code: r.code, message: r.message } }, r.status);
|
|
528
|
+
}
|
|
529
|
+
const refSource = makeRefSource(repoStore, r.resolved.principal);
|
|
530
|
+
const stream =
|
|
531
|
+
service === "git-upload-pack"
|
|
532
|
+
? await advertiseUploadPack(
|
|
533
|
+
refSource,
|
|
534
|
+
r.resolved.principal,
|
|
535
|
+
r.resolved.repoId,
|
|
536
|
+
)
|
|
537
|
+
: await advertiseReceivePack(
|
|
538
|
+
refSource,
|
|
539
|
+
r.resolved.principal,
|
|
540
|
+
r.resolved.repoId,
|
|
541
|
+
);
|
|
542
|
+
return new Response(stream, {
|
|
543
|
+
status: 200,
|
|
544
|
+
headers: {
|
|
545
|
+
"content-type": `application/x-${service}-advertisement`,
|
|
546
|
+
"cache-control": "no-cache",
|
|
547
|
+
},
|
|
548
|
+
});
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
smartHttp.post("/:kind/:nameDotGit/git-upload-pack", async (c) => {
|
|
552
|
+
const r = await resolveSmartHttp(c, "createPack");
|
|
553
|
+
if (!r.ok) {
|
|
554
|
+
return c.json({ error: { code: r.code, message: r.message } }, r.status);
|
|
555
|
+
}
|
|
556
|
+
return handleUploadPack(
|
|
557
|
+
makeUploadPackStore(repoStore, r.resolved.principal),
|
|
558
|
+
r.resolved.principal,
|
|
559
|
+
r.resolved.repoId,
|
|
560
|
+
c.req.raw,
|
|
561
|
+
);
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
smartHttp.post("/:kind/:nameDotGit/git-receive-pack", async (c) => {
|
|
565
|
+
const r = await resolveSmartHttp(c, "receivePack");
|
|
566
|
+
if (!r.ok) {
|
|
567
|
+
return c.json({ error: { code: r.code, message: r.message } }, r.status);
|
|
568
|
+
}
|
|
569
|
+
return handleReceivePack(
|
|
570
|
+
repoStore,
|
|
571
|
+
r.resolved.principal,
|
|
572
|
+
r.resolved.repoId,
|
|
573
|
+
c.req.raw,
|
|
574
|
+
);
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
app.route("/", smartHttp);
|
|
578
|
+
|
|
579
|
+
return app;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Paths under the asset routes Hono app that the smart-HTTP wire
|
|
584
|
+
* vocabulary touches. The hub-api app excludes these from the OpenAPI
|
|
585
|
+
* document so the generated spec does not advertise binary wire
|
|
586
|
+
* endpoints.
|
|
587
|
+
*/
|
|
588
|
+
export const ASSET_OPENAPI_EXCLUDE_GLOBS = [
|
|
589
|
+
"/api/tenants/*/assets/*/*.git/info/refs",
|
|
590
|
+
"/api/tenants/*/assets/*/*.git/git-upload-pack",
|
|
591
|
+
"/api/tenants/*/assets/*/*.git/git-receive-pack",
|
|
592
|
+
] as const;
|