@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.
Files changed (55) hide show
  1. package/README.md +29 -0
  2. package/package.json +28 -0
  3. package/src/app.test.ts +225 -0
  4. package/src/app.ts +382 -0
  5. package/src/auth.ts +21 -0
  6. package/src/context.ts +38 -0
  7. package/src/format.ts +9 -0
  8. package/src/git-http/advertise-refs.test.ts +459 -0
  9. package/src/git-http/advertise-refs.ts +226 -0
  10. package/src/git-http/pkt-line.test.ts +220 -0
  11. package/src/git-http/pkt-line.ts +235 -0
  12. package/src/git-http/receive-pack.test.ts +397 -0
  13. package/src/git-http/receive-pack.ts +261 -0
  14. package/src/git-http/side-band-64k.test.ts +181 -0
  15. package/src/git-http/side-band-64k.ts +134 -0
  16. package/src/git-http/upload-pack.test.ts +545 -0
  17. package/src/git-http/upload-pack.ts +396 -0
  18. package/src/index.ts +23 -0
  19. package/src/middleware/git-token-auth.test.ts +587 -0
  20. package/src/middleware/git-token-auth.ts +315 -0
  21. package/src/middleware/grant.ts +106 -0
  22. package/src/middleware/session.ts +13 -0
  23. package/src/middleware/tenant.test.ts +192 -0
  24. package/src/middleware/tenant.ts +101 -0
  25. package/src/openapi.ts +66 -0
  26. package/src/pagination.ts +117 -0
  27. package/src/routes/agent-data.ts +179 -0
  28. package/src/routes/agent-state-git.ts +562 -0
  29. package/src/routes/agents.test.ts +337 -0
  30. package/src/routes/agents.ts +704 -0
  31. package/src/routes/approvals.ts +130 -0
  32. package/src/routes/assets.test.ts +567 -0
  33. package/src/routes/assets.ts +592 -0
  34. package/src/routes/credentials.ts +435 -0
  35. package/src/routes/git-tokens.test.ts +709 -0
  36. package/src/routes/git-tokens.ts +771 -0
  37. package/src/routes/grants.ts +509 -0
  38. package/src/routes/instances.test.ts +1103 -0
  39. package/src/routes/instances.ts +1797 -0
  40. package/src/routes/me.ts +405 -0
  41. package/src/routes/oauth-clients.ts +349 -0
  42. package/src/routes/observability.ts +146 -0
  43. package/src/routes/offerings.ts +382 -0
  44. package/src/routes/principals.ts +515 -0
  45. package/src/routes/providers.ts +351 -0
  46. package/src/routes/roles.ts +452 -0
  47. package/src/routes/sidecars.ts +221 -0
  48. package/src/routes/tenant-federation.ts +225 -0
  49. package/src/routes/tenants.ts +369 -0
  50. package/src/routes/wallets.ts +370 -0
  51. package/src/session.ts +44 -0
  52. package/src/timeline-reconstruction.test.ts +786 -0
  53. package/src/timeline-reconstruction.ts +383 -0
  54. package/tsconfig.json +4 -0
  55. 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;