@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
package/README.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# @intx/hub-api
|
|
2
|
+
|
|
3
|
+
Hono application factory for the hub. Owns the public HTTP API
|
|
4
|
+
surface: routes, middleware (session, tenant resolution, grant
|
|
5
|
+
enforcement), authentication via better-auth, and the OpenAPI
|
|
6
|
+
spec served from the running app.
|
|
7
|
+
|
|
8
|
+
Consumed by `apps/hub` as the HTTP entry point. The factory takes
|
|
9
|
+
the database client, session service, sidecar router, repo store,
|
|
10
|
+
asset service, and event collector registry from `@intx/db` and
|
|
11
|
+
`@intx/hub-sessions` and returns a configured Hono app.
|
|
12
|
+
|
|
13
|
+
`createApp` returns a configured Hono app. Its `CreateAppOpts`
|
|
14
|
+
parameter wires together the dependencies the API needs: a
|
|
15
|
+
better-auth `authHandler` and matching `getSession`, the database
|
|
16
|
+
client (`db`), the `SidecarRouter` and `SessionService` from
|
|
17
|
+
`@intx/hub-sessions`, and an `EventCollectorRegistry`. The
|
|
18
|
+
`assetService` and `repoStore` fields must be supplied but accept
|
|
19
|
+
`null` for deployments that don't host the git surface; the
|
|
20
|
+
`grantStore` and `sidecarWsHandler` fields are fully optional. See
|
|
21
|
+
`CreateAppOpts` in `src/app.ts` for the exact field list. The
|
|
22
|
+
companion `createAuth` factory builds the better-auth instance
|
|
23
|
+
that supplies `authHandler` and `getSession`.
|
|
24
|
+
|
|
25
|
+
`createRequireGrant` is the grant-enforcement middleware factory
|
|
26
|
+
exposed for callers that mount additional routes against the same
|
|
27
|
+
authorization stack; it composes with the tenant and session
|
|
28
|
+
middleware so every route sees a resolved principal, tenant, and
|
|
29
|
+
grant decision.
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@intx/hub-api",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"license": "LGPL-2.1-only",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./src/index.ts",
|
|
9
|
+
"default": "./src/index.ts"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@hono/standard-validator": "^0.2.2",
|
|
14
|
+
"@intx/authz": "0.0.0",
|
|
15
|
+
"@intx/crypto-node": "0.0.0",
|
|
16
|
+
"@intx/db": "0.0.0",
|
|
17
|
+
"@intx/hub-common": "0.0.0",
|
|
18
|
+
"@intx/hub-sessions": "0.0.0",
|
|
19
|
+
"@intx/log": "0.0.0",
|
|
20
|
+
"@intx/mime": "0.0.0",
|
|
21
|
+
"@intx/storage-isogit": "0.0.0",
|
|
22
|
+
"@intx/types": "0.0.0",
|
|
23
|
+
"arktype": "^2.1.29",
|
|
24
|
+
"better-auth": "^1.4.18",
|
|
25
|
+
"hono": "^4.11.9",
|
|
26
|
+
"hono-openapi": "^1.2.0"
|
|
27
|
+
}
|
|
28
|
+
}
|
package/src/app.test.ts
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { type } from "arktype";
|
|
3
|
+
import { Hono } from "hono";
|
|
4
|
+
import type { DB } from "@intx/db";
|
|
5
|
+
import { createApp, createHubContextMiddleware, mountHubRoutes } from "./app";
|
|
6
|
+
import type { AppEnv } from "./context";
|
|
7
|
+
import {
|
|
8
|
+
createEventCollectorRegistry,
|
|
9
|
+
createSidecarRouter,
|
|
10
|
+
type SessionService,
|
|
11
|
+
} from "@intx/hub-sessions";
|
|
12
|
+
import type { GetSession } from "./session";
|
|
13
|
+
|
|
14
|
+
const OpenAPISpec = type({
|
|
15
|
+
info: { title: "string", version: "string" },
|
|
16
|
+
paths: "Record<string, Record<string, unknown>>",
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- drizzle PgDatabase type cannot be structurally satisfied in tests
|
|
20
|
+
const mockDb = {} as unknown as DB["db"];
|
|
21
|
+
const sidecarRouter = createSidecarRouter({});
|
|
22
|
+
const sessionService: SessionService = {
|
|
23
|
+
launchSession(_params) {
|
|
24
|
+
throw new Error("mock: sessionService.launchSession not implemented");
|
|
25
|
+
},
|
|
26
|
+
sendUserMessage(_params) {
|
|
27
|
+
throw new Error("mock: sessionService.sendUserMessage not implemented");
|
|
28
|
+
},
|
|
29
|
+
endSession(_addr, _reason) {
|
|
30
|
+
throw new Error("mock: sessionService.endSession not implemented");
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
const eventCollectors = createEventCollectorRegistry({ db: mockDb });
|
|
34
|
+
|
|
35
|
+
const app = createApp({
|
|
36
|
+
getSession: async () => null,
|
|
37
|
+
authHandler: () => new Response("", { status: 404 }),
|
|
38
|
+
db: mockDb,
|
|
39
|
+
sidecarRouter,
|
|
40
|
+
sessionService,
|
|
41
|
+
eventCollectors,
|
|
42
|
+
assetService: null,
|
|
43
|
+
repoStore: null,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("app", () => {
|
|
47
|
+
test("GET /status returns ok", async () => {
|
|
48
|
+
const res = await app.request("/status");
|
|
49
|
+
expect(res.status).toBe(200);
|
|
50
|
+
const body = await res.json();
|
|
51
|
+
expect(body).toEqual({ status: "ok" });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("GET /openapi.json returns a valid spec with expected tags", async () => {
|
|
55
|
+
const res = await app.request("/openapi.json");
|
|
56
|
+
expect(res.status).toBe(200);
|
|
57
|
+
|
|
58
|
+
const spec = OpenAPISpec.assert(await res.json());
|
|
59
|
+
|
|
60
|
+
expect(spec.info.title).toBe("Interchange Hub");
|
|
61
|
+
expect(spec.info.version).toBe("0.0.0");
|
|
62
|
+
|
|
63
|
+
const paths = Object.keys(spec.paths);
|
|
64
|
+
expect(paths.length).toBeGreaterThan(50);
|
|
65
|
+
|
|
66
|
+
const tags = new Set<string>();
|
|
67
|
+
for (const methods of Object.values(spec.paths)) {
|
|
68
|
+
for (const op of Object.values(methods)) {
|
|
69
|
+
if (typeof op !== "object" || op === null) continue;
|
|
70
|
+
if (!("tags" in op)) continue;
|
|
71
|
+
const { tags: opTags } = op;
|
|
72
|
+
if (!Array.isArray(opTags)) continue;
|
|
73
|
+
for (const tag of opTags) {
|
|
74
|
+
if (typeof tag === "string") tags.add(tag);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const expectedTags = [
|
|
80
|
+
"User",
|
|
81
|
+
"Tenants",
|
|
82
|
+
"Principals",
|
|
83
|
+
"Roles",
|
|
84
|
+
"Grants",
|
|
85
|
+
"Agents",
|
|
86
|
+
"Instances",
|
|
87
|
+
"Approvals",
|
|
88
|
+
"Wallets",
|
|
89
|
+
"Credentials",
|
|
90
|
+
"Discovery",
|
|
91
|
+
"Observability",
|
|
92
|
+
"Agent Data",
|
|
93
|
+
"Sidecars",
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
for (const tag of expectedTags) {
|
|
97
|
+
expect(tags.has(tag)).toBe(true);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("federation routes do not double the /federation path segment", async () => {
|
|
102
|
+
const res = await app.request("/openapi.json");
|
|
103
|
+
const spec = OpenAPISpec.assert(await res.json());
|
|
104
|
+
const federationPaths = Object.keys(spec.paths).filter((p) =>
|
|
105
|
+
p.includes("federation"),
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
expect(federationPaths.length).toBeGreaterThan(0);
|
|
109
|
+
for (const p of federationPaths) {
|
|
110
|
+
expect(p).not.toContain("federation/federation");
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("POST with invalid body returns 400", async () => {
|
|
115
|
+
const res = await app.request("/api/tenants", {
|
|
116
|
+
method: "POST",
|
|
117
|
+
headers: { "Content-Type": "application/json" },
|
|
118
|
+
body: JSON.stringify({}),
|
|
119
|
+
});
|
|
120
|
+
expect(res.status).toBe(400);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("opting out of asset+repo-store hides the git-tokens mint surface", async () => {
|
|
124
|
+
// Token minting is only useful when at least one smart-HTTP route
|
|
125
|
+
// consumes the tokens. With `assetService: null` and
|
|
126
|
+
// `repoStore: null` there is no consumer, so the OpenAPI spec
|
|
127
|
+
// must not advertise the `Git Tokens` tag and the mint paths
|
|
128
|
+
// must not appear. The HTTP-request shape would test the same
|
|
129
|
+
// invariant but is noisier — the auth middleware fronts the
|
|
130
|
+
// `/api/me/*` tree and short-circuits with 401 before the router
|
|
131
|
+
// matches, masking whether the route exists.
|
|
132
|
+
const res = await app.request("/openapi.json");
|
|
133
|
+
const spec = OpenAPISpec.assert(await res.json());
|
|
134
|
+
|
|
135
|
+
const tags = new Set<string>();
|
|
136
|
+
for (const methods of Object.values(spec.paths)) {
|
|
137
|
+
for (const op of Object.values(methods)) {
|
|
138
|
+
if (typeof op !== "object" || op === null) continue;
|
|
139
|
+
if (!("tags" in op)) continue;
|
|
140
|
+
const { tags: opTags } = op;
|
|
141
|
+
if (!Array.isArray(opTags)) continue;
|
|
142
|
+
for (const tag of opTags) {
|
|
143
|
+
if (typeof tag === "string") tags.add(tag);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
expect(tags.has("Git Tokens")).toBe(false);
|
|
148
|
+
|
|
149
|
+
const paths = Object.keys(spec.paths);
|
|
150
|
+
const gitTokenPaths = paths.filter((p) => p.includes("git-tokens"));
|
|
151
|
+
expect(gitTokenPaths).toEqual([]);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("mountHubRoutes composition", () => {
|
|
156
|
+
const stubUser = {
|
|
157
|
+
id: "usr_compose",
|
|
158
|
+
email: "compose@example.com",
|
|
159
|
+
emailVerified: true,
|
|
160
|
+
name: "Compose Test",
|
|
161
|
+
image: null,
|
|
162
|
+
createdAt: new Date("2025-01-01"),
|
|
163
|
+
updatedAt: new Date("2025-01-01"),
|
|
164
|
+
};
|
|
165
|
+
const stubSession = {
|
|
166
|
+
id: "ses_compose",
|
|
167
|
+
userId: stubUser.id,
|
|
168
|
+
token: "tok_compose",
|
|
169
|
+
expiresAt: new Date("2999-01-01"),
|
|
170
|
+
createdAt: stubUser.createdAt,
|
|
171
|
+
updatedAt: stubUser.updatedAt,
|
|
172
|
+
};
|
|
173
|
+
const getSession: GetSession = async () => ({
|
|
174
|
+
user: stubUser,
|
|
175
|
+
session: stubSession,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("third-party Hono app shares the hub's request context", async () => {
|
|
179
|
+
const thirdParty = new Hono<AppEnv>();
|
|
180
|
+
thirdParty.use(createHubContextMiddleware({ getSession }));
|
|
181
|
+
|
|
182
|
+
// A sibling route owned by the third party reads from the same
|
|
183
|
+
// request context the hub routes use.
|
|
184
|
+
thirdParty.get("/sibling/whoami", (c) => {
|
|
185
|
+
const user = c.get("user");
|
|
186
|
+
return c.json({ userId: user?.id ?? null });
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
mountHubRoutes(thirdParty, {
|
|
190
|
+
db: mockDb,
|
|
191
|
+
sidecarRouter,
|
|
192
|
+
sessionService,
|
|
193
|
+
eventCollectors,
|
|
194
|
+
assetService: null,
|
|
195
|
+
repoStore: null,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const siblingRes = await thirdParty.request("/sibling/whoami");
|
|
199
|
+
expect(siblingRes.status).toBe(200);
|
|
200
|
+
expect(await siblingRes.json()).toEqual({ userId: stubUser.id });
|
|
201
|
+
|
|
202
|
+
// Hub routes mounted on the same app respond as expected.
|
|
203
|
+
const statusRes = await thirdParty.request("/status");
|
|
204
|
+
expect(statusRes.status).toBe(200);
|
|
205
|
+
expect(await statusRes.json()).toEqual({ status: "ok" });
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("mountHubRoutes does not mount an auth handler at /api/auth/*", async () => {
|
|
209
|
+
const thirdParty = new Hono<AppEnv>();
|
|
210
|
+
thirdParty.use(createHubContextMiddleware({ getSession }));
|
|
211
|
+
mountHubRoutes(thirdParty, {
|
|
212
|
+
db: mockDb,
|
|
213
|
+
sidecarRouter,
|
|
214
|
+
sessionService,
|
|
215
|
+
eventCollectors,
|
|
216
|
+
assetService: null,
|
|
217
|
+
repoStore: null,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// Without an auth handler mounted by the caller, /api/auth/* is
|
|
221
|
+
// free for the third party to wire as they choose.
|
|
222
|
+
const res = await thirdParty.request("/api/auth/anything");
|
|
223
|
+
expect(res.status).toBe(404);
|
|
224
|
+
});
|
|
225
|
+
});
|
package/src/app.ts
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import type { Handler, MiddlewareHandler } from "hono";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { openAPIRouteHandler } from "hono-openapi";
|
|
4
|
+
|
|
5
|
+
import { honoLogger, type HonoContext } from "@intx/log/hono";
|
|
6
|
+
import { timeWindowEvaluator } from "@intx/authz";
|
|
7
|
+
import { type DB, createGrantStore } from "@intx/db";
|
|
8
|
+
import type { ConditionRegistry, GrantStore } from "@intx/types/authz";
|
|
9
|
+
|
|
10
|
+
import type { AppEnv } from "./context";
|
|
11
|
+
import { createSessionMiddleware } from "./middleware/session";
|
|
12
|
+
import { createRequireGrant, type RequireGrant } from "./middleware/grant";
|
|
13
|
+
import { createResolveTenant, requireAuth } from "./middleware/tenant";
|
|
14
|
+
import type { GetSession } from "./session";
|
|
15
|
+
import type {
|
|
16
|
+
AssetService,
|
|
17
|
+
EventCollectorRegistry,
|
|
18
|
+
RepoStore,
|
|
19
|
+
SessionService,
|
|
20
|
+
SidecarRouter,
|
|
21
|
+
} from "@intx/hub-sessions";
|
|
22
|
+
|
|
23
|
+
import { createMeRoutes } from "./routes/me";
|
|
24
|
+
import { createTenantRoutes } from "./routes/tenants";
|
|
25
|
+
import { createTenantFederationRoutes } from "./routes/tenant-federation";
|
|
26
|
+
import { createPrincipalRoutes, createInviteRoutes } from "./routes/principals";
|
|
27
|
+
import { createRoleRoutes, createRoleAssignRoutes } from "./routes/roles";
|
|
28
|
+
import { createGrantRoutes, createEvaluateRoutes } from "./routes/grants";
|
|
29
|
+
import { createAgentRoutes } from "./routes/agents";
|
|
30
|
+
import { createInstanceRoutes } from "./routes/instances";
|
|
31
|
+
import { createApprovalRoutes } from "./routes/approvals";
|
|
32
|
+
import { createWalletRoutes } from "./routes/wallets";
|
|
33
|
+
import { createProviderRoutes } from "./routes/providers";
|
|
34
|
+
import { createOAuthClientRoutes } from "./routes/oauth-clients";
|
|
35
|
+
import { createCredentialRoutes } from "./routes/credentials";
|
|
36
|
+
import { createOfferingRoutes, createModelRoutes } from "./routes/offerings";
|
|
37
|
+
import { createObservabilityRoutes } from "./routes/observability";
|
|
38
|
+
import { createAgentDataRoutes } from "./routes/agent-data";
|
|
39
|
+
import { createSidecarRoutes } from "./routes/sidecars";
|
|
40
|
+
import {
|
|
41
|
+
createMeGitTokenRoutes,
|
|
42
|
+
createTenantGitTokenRoutes,
|
|
43
|
+
} from "./routes/git-tokens";
|
|
44
|
+
import {
|
|
45
|
+
ASSET_OPENAPI_EXCLUDE_GLOBS,
|
|
46
|
+
createAssetRoutes,
|
|
47
|
+
} from "./routes/assets";
|
|
48
|
+
import {
|
|
49
|
+
AGENT_STATE_OPENAPI_EXCLUDE_GLOBS,
|
|
50
|
+
createAgentStateDefinitionGitRoutes,
|
|
51
|
+
createAgentStateInstanceGitRoutes,
|
|
52
|
+
createAgentStateReceivePackDeny,
|
|
53
|
+
} from "./routes/agent-state-git";
|
|
54
|
+
import { createGitTokenAuth } from "./middleware/git-token-auth";
|
|
55
|
+
|
|
56
|
+
export type CreateHubContextMiddlewareDeps = {
|
|
57
|
+
getSession: GetSession;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Builds the per-request context middleware that resolves the
|
|
62
|
+
* authenticated user and session from the incoming request and
|
|
63
|
+
* exposes them via the Hono variable bag.
|
|
64
|
+
*/
|
|
65
|
+
export function createHubContextMiddleware({
|
|
66
|
+
getSession,
|
|
67
|
+
}: CreateHubContextMiddlewareDeps): MiddlewareHandler<AppEnv> {
|
|
68
|
+
return createSessionMiddleware(getSession);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type MountHubRoutesDeps = {
|
|
72
|
+
db: DB["db"];
|
|
73
|
+
sidecarRouter: SidecarRouter;
|
|
74
|
+
sessionService: SessionService;
|
|
75
|
+
eventCollectors: EventCollectorRegistry;
|
|
76
|
+
grantStore?: GrantStore;
|
|
77
|
+
conditionRegistry?: ConditionRegistry;
|
|
78
|
+
sidecarWsHandler?: Handler<AppEnv>;
|
|
79
|
+
/**
|
|
80
|
+
* The asset REST endpoint and smart-HTTP route group mount under
|
|
81
|
+
* `/api/tenants/:tenantId/assets` when both are supplied. Tests
|
|
82
|
+
* that have no reason to exercise the asset surface MUST pass
|
|
83
|
+
* `null` for both to opt out explicitly; passing only one is a
|
|
84
|
+
* wiring bug and throws at construction.
|
|
85
|
+
*/
|
|
86
|
+
assetService: AssetService | null;
|
|
87
|
+
repoStore: RepoStore | null;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Mounts every hub route group, middleware, and supporting endpoint
|
|
92
|
+
* onto the provided Hono application. The caller is responsible for
|
|
93
|
+
* having mounted the request logger and the context middleware first,
|
|
94
|
+
* and for wiring their own auth handler at the path of their choice.
|
|
95
|
+
*
|
|
96
|
+
* `grantStore` and `conditionRegistry` default to the hub's standard
|
|
97
|
+
* choices (a database-backed grant store and the time-window condition
|
|
98
|
+
* evaluator) when not supplied.
|
|
99
|
+
*/
|
|
100
|
+
export function mountHubRoutes(
|
|
101
|
+
app: Hono<AppEnv>,
|
|
102
|
+
opts: MountHubRoutesDeps,
|
|
103
|
+
): void {
|
|
104
|
+
const {
|
|
105
|
+
db,
|
|
106
|
+
sidecarRouter,
|
|
107
|
+
sessionService,
|
|
108
|
+
eventCollectors,
|
|
109
|
+
sidecarWsHandler,
|
|
110
|
+
assetService,
|
|
111
|
+
repoStore,
|
|
112
|
+
} = opts;
|
|
113
|
+
if ((assetService === null) !== (repoStore === null)) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
"mountHubRoutes: assetService and repoStore must be provided together or both omitted",
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
const grantStore = opts.grantStore ?? createGrantStore(db);
|
|
119
|
+
const conditionRegistry: ConditionRegistry = opts.conditionRegistry ?? {
|
|
120
|
+
time_window: timeWindowEvaluator,
|
|
121
|
+
};
|
|
122
|
+
const requireGrant: RequireGrant = createRequireGrant({
|
|
123
|
+
grantStore,
|
|
124
|
+
conditionRegistry,
|
|
125
|
+
});
|
|
126
|
+
const resolveTenant = createResolveTenant({ db });
|
|
127
|
+
|
|
128
|
+
app.get("/status", (c) => c.json({ status: "ok" }));
|
|
129
|
+
|
|
130
|
+
// User-scoped (cross-tenant) -- requires auth but not tenant membership
|
|
131
|
+
app.use("/api/me/*", requireAuth);
|
|
132
|
+
app.route("/api/me", createMeRoutes({ db }));
|
|
133
|
+
|
|
134
|
+
// The git-tokens mint surface mounts under the same gate as the
|
|
135
|
+
// smart-HTTP route groups: tokens are only useful when at least one
|
|
136
|
+
// smart-HTTP route consumes them. Both deps null = no smart-HTTP
|
|
137
|
+
// anywhere = no token-mint endpoints.
|
|
138
|
+
if (repoStore !== null) {
|
|
139
|
+
app.route("/api/me/git-tokens", createMeGitTokenRoutes({ db }));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Smart-HTTP asset routes use bearer authentication instead of
|
|
143
|
+
// session+tenant resolution. The bearer middleware mounts ahead of
|
|
144
|
+
// resolveTenant so it populates `principal` + `tenant` first; the
|
|
145
|
+
// tenant resolver short-circuits when both are already set, which
|
|
146
|
+
// lets bearer-only requests bypass the session-required path.
|
|
147
|
+
//
|
|
148
|
+
// The gate is `repoStore !== null` rather than the two-dep check;
|
|
149
|
+
// the XOR throw above already guarantees the deps move as a unit,
|
|
150
|
+
// so checking either one is equivalent. Keeping a single shape
|
|
151
|
+
// across every gate site makes the contract obvious to a reader.
|
|
152
|
+
if (repoStore !== null) {
|
|
153
|
+
app.use(
|
|
154
|
+
"/api/tenants/:tenantId/assets/:kind/:nameDotGit/*",
|
|
155
|
+
createGitTokenAuth({ db }),
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Agent-state smart-HTTP read routes also use bearer auth. The
|
|
160
|
+
// receive-pack denial middleware mounts BEFORE bearer auth so an
|
|
161
|
+
// unauthenticated `git push -v` parses the pkt-line ERR record
|
|
162
|
+
// rather than a generic 401. The bearer middleware then gates the
|
|
163
|
+
// upload-pack half (advertise + POST) on a valid token.
|
|
164
|
+
if (repoStore !== null) {
|
|
165
|
+
app.use(
|
|
166
|
+
"/api/tenants/:tenantId/agents/instances/:instanceId/state.git/*",
|
|
167
|
+
createAgentStateReceivePackDeny(),
|
|
168
|
+
);
|
|
169
|
+
app.use(
|
|
170
|
+
"/api/tenants/:tenantId/agents/definitions/:agentId/state.git/*",
|
|
171
|
+
createAgentStateReceivePackDeny(),
|
|
172
|
+
);
|
|
173
|
+
app.use(
|
|
174
|
+
"/api/tenants/:tenantId/agents/instances/:instanceId/state.git/*",
|
|
175
|
+
createGitTokenAuth({ db }),
|
|
176
|
+
);
|
|
177
|
+
app.use(
|
|
178
|
+
"/api/tenants/:tenantId/agents/definitions/:agentId/state.git/*",
|
|
179
|
+
createGitTokenAuth({ db }),
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Tenant-scoped middleware -- require auth + tenant membership for any
|
|
184
|
+
// path under /api/tenants/:tenantId/*. Must be registered before routes
|
|
185
|
+
// so Hono includes it in the middleware chain.
|
|
186
|
+
app.use("/api/tenants/:tenantId/*", resolveTenant);
|
|
187
|
+
|
|
188
|
+
// Global tenant routes (create needs auth, detail/update handle auth inline)
|
|
189
|
+
app.route("/api/tenants", createTenantRoutes({ db }));
|
|
190
|
+
app.route("/api/models", createModelRoutes());
|
|
191
|
+
|
|
192
|
+
// Tenant-scoped routes
|
|
193
|
+
app.route(
|
|
194
|
+
"/api/tenants/:tenantId/principals",
|
|
195
|
+
createPrincipalRoutes({ db, requireGrant }),
|
|
196
|
+
);
|
|
197
|
+
app.route(
|
|
198
|
+
"/api/tenants/:tenantId/members/invite",
|
|
199
|
+
createInviteRoutes({ db, requireGrant }),
|
|
200
|
+
);
|
|
201
|
+
app.route(
|
|
202
|
+
"/api/tenants/:tenantId/roles",
|
|
203
|
+
createRoleRoutes({ db, requireGrant }),
|
|
204
|
+
);
|
|
205
|
+
app.route(
|
|
206
|
+
"/api/tenants/:tenantId/principals/:principalId/roles",
|
|
207
|
+
createRoleAssignRoutes({ db, requireGrant }),
|
|
208
|
+
);
|
|
209
|
+
app.route(
|
|
210
|
+
"/api/tenants/:tenantId/grants",
|
|
211
|
+
createGrantRoutes({ db, requireGrant }),
|
|
212
|
+
);
|
|
213
|
+
app.route(
|
|
214
|
+
"/api/tenants/:tenantId/principals/:principalId/evaluate",
|
|
215
|
+
createEvaluateRoutes({ db, grantStore, conditionRegistry }),
|
|
216
|
+
);
|
|
217
|
+
app.route(
|
|
218
|
+
"/api/tenants/:tenantId/agents/definitions",
|
|
219
|
+
createAgentRoutes({ db, requireGrant }),
|
|
220
|
+
);
|
|
221
|
+
app.route(
|
|
222
|
+
"/api/tenants/:tenantId/agents/instances",
|
|
223
|
+
createInstanceRoutes({
|
|
224
|
+
db,
|
|
225
|
+
sessionService,
|
|
226
|
+
sidecarRouter,
|
|
227
|
+
eventCollectors,
|
|
228
|
+
grantStore,
|
|
229
|
+
conditionRegistry,
|
|
230
|
+
requireGrant,
|
|
231
|
+
}),
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
app.route("/api/tenants/:tenantId/approvals", createApprovalRoutes());
|
|
235
|
+
app.route(
|
|
236
|
+
"/api/tenants/:tenantId/wallets",
|
|
237
|
+
createWalletRoutes({ db, requireGrant }),
|
|
238
|
+
);
|
|
239
|
+
app.route(
|
|
240
|
+
"/api/tenants/:tenantId/providers",
|
|
241
|
+
createProviderRoutes({ db, requireGrant }),
|
|
242
|
+
);
|
|
243
|
+
app.route(
|
|
244
|
+
"/api/tenants/:tenantId/oauth-clients",
|
|
245
|
+
createOAuthClientRoutes({ db, requireGrant }),
|
|
246
|
+
);
|
|
247
|
+
app.route(
|
|
248
|
+
"/api/tenants/:tenantId/credentials",
|
|
249
|
+
createCredentialRoutes({ db, sidecarRouter, requireGrant }),
|
|
250
|
+
);
|
|
251
|
+
app.route(
|
|
252
|
+
"/api/tenants/:tenantId/offerings",
|
|
253
|
+
createOfferingRoutes({ db, requireGrant }),
|
|
254
|
+
);
|
|
255
|
+
if (repoStore !== null) {
|
|
256
|
+
app.route(
|
|
257
|
+
"/api/tenants/:tenantId/git-tokens",
|
|
258
|
+
createTenantGitTokenRoutes({ db, requireGrant }),
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
app.route("/api/tenants/:tenantId", createObservabilityRoutes());
|
|
262
|
+
app.route(
|
|
263
|
+
"/api/tenants/:tenantId/federation",
|
|
264
|
+
createTenantFederationRoutes({ db }),
|
|
265
|
+
);
|
|
266
|
+
app.route("/api/tenants/:tenantId/agents/:agentId", createAgentDataRoutes());
|
|
267
|
+
|
|
268
|
+
if (assetService !== null && repoStore !== null) {
|
|
269
|
+
app.route(
|
|
270
|
+
"/api/tenants/:tenantId/assets",
|
|
271
|
+
createAssetRoutes({
|
|
272
|
+
db,
|
|
273
|
+
assetService,
|
|
274
|
+
repoStore,
|
|
275
|
+
grantStore,
|
|
276
|
+
conditionRegistry,
|
|
277
|
+
requireGrant,
|
|
278
|
+
}),
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (repoStore !== null) {
|
|
283
|
+
app.route(
|
|
284
|
+
"/api/tenants/:tenantId/agents/instances",
|
|
285
|
+
createAgentStateInstanceGitRoutes({
|
|
286
|
+
db,
|
|
287
|
+
repoStore,
|
|
288
|
+
grantStore,
|
|
289
|
+
conditionRegistry,
|
|
290
|
+
}),
|
|
291
|
+
);
|
|
292
|
+
app.route(
|
|
293
|
+
"/api/tenants/:tenantId/agents/definitions",
|
|
294
|
+
createAgentStateDefinitionGitRoutes({
|
|
295
|
+
db,
|
|
296
|
+
repoStore,
|
|
297
|
+
grantStore,
|
|
298
|
+
conditionRegistry,
|
|
299
|
+
}),
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
app.route(
|
|
304
|
+
"/api/sidecars",
|
|
305
|
+
createSidecarRoutes(
|
|
306
|
+
sidecarWsHandler ? { db, wsHandler: sidecarWsHandler } : { db },
|
|
307
|
+
),
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export type CreateAppOpts = {
|
|
312
|
+
getSession: GetSession;
|
|
313
|
+
authHandler: Handler<AppEnv>;
|
|
314
|
+
db: DB["db"];
|
|
315
|
+
sidecarRouter: SidecarRouter;
|
|
316
|
+
sessionService: SessionService;
|
|
317
|
+
eventCollectors: EventCollectorRegistry;
|
|
318
|
+
grantStore?: GrantStore;
|
|
319
|
+
sidecarWsHandler?: Handler<AppEnv>;
|
|
320
|
+
assetService: AssetService | null;
|
|
321
|
+
repoStore: RepoStore | null;
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
export function createApp({
|
|
325
|
+
getSession,
|
|
326
|
+
authHandler,
|
|
327
|
+
db,
|
|
328
|
+
sidecarRouter,
|
|
329
|
+
sessionService,
|
|
330
|
+
eventCollectors,
|
|
331
|
+
grantStore,
|
|
332
|
+
sidecarWsHandler,
|
|
333
|
+
assetService,
|
|
334
|
+
repoStore,
|
|
335
|
+
}: CreateAppOpts) {
|
|
336
|
+
const app = new Hono<AppEnv>();
|
|
337
|
+
|
|
338
|
+
app.use(
|
|
339
|
+
honoLogger({
|
|
340
|
+
category: ["hub", "requests"],
|
|
341
|
+
skip: (c: HonoContext) => c.req.path === "/status",
|
|
342
|
+
}),
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
app.use(createHubContextMiddleware({ getSession }));
|
|
346
|
+
|
|
347
|
+
app.all("/api/auth/*", authHandler);
|
|
348
|
+
|
|
349
|
+
mountHubRoutes(app, {
|
|
350
|
+
db,
|
|
351
|
+
sidecarRouter,
|
|
352
|
+
sessionService,
|
|
353
|
+
eventCollectors,
|
|
354
|
+
assetService,
|
|
355
|
+
repoStore,
|
|
356
|
+
...(grantStore ? { grantStore } : {}),
|
|
357
|
+
...(sidecarWsHandler ? { sidecarWsHandler } : {}),
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
app.get(
|
|
361
|
+
"/openapi.json",
|
|
362
|
+
openAPIRouteHandler(app, {
|
|
363
|
+
documentation: {
|
|
364
|
+
info: {
|
|
365
|
+
title: "Interchange Hub",
|
|
366
|
+
version: "0.0.0",
|
|
367
|
+
},
|
|
368
|
+
},
|
|
369
|
+
exclude: [
|
|
370
|
+
"/openapi.json",
|
|
371
|
+
"/status",
|
|
372
|
+
"/api/auth/**",
|
|
373
|
+
...ASSET_OPENAPI_EXCLUDE_GLOBS,
|
|
374
|
+
...AGENT_STATE_OPENAPI_EXCLUDE_GLOBS,
|
|
375
|
+
],
|
|
376
|
+
}),
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
return app;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export type App = ReturnType<typeof createApp>;
|
package/src/auth.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { DB } from "@intx/db";
|
|
2
|
+
import { betterAuth } from "better-auth";
|
|
3
|
+
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
|
4
|
+
|
|
5
|
+
export function createAuth(db: DB["db"]) {
|
|
6
|
+
return betterAuth({
|
|
7
|
+
baseURL: process.env["BETTER_AUTH_BASE_URL"] ?? "http://localhost:3000",
|
|
8
|
+
database: drizzleAdapter(db, { provider: "pg" }),
|
|
9
|
+
emailAndPassword: {
|
|
10
|
+
enabled: true,
|
|
11
|
+
},
|
|
12
|
+
socialProviders: {
|
|
13
|
+
google: {
|
|
14
|
+
clientId: process.env["GOOGLE_CLIENT_ID"] ?? "",
|
|
15
|
+
clientSecret: process.env["GOOGLE_CLIENT_SECRET"] ?? "",
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type Auth = ReturnType<typeof createAuth>;
|