@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,130 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { describeRoute, resolver, validator } from "hono-openapi";
|
|
3
|
+
import {
|
|
4
|
+
ApprovalResponse,
|
|
5
|
+
ApproveAction,
|
|
6
|
+
RejectAction,
|
|
7
|
+
ErrorResponse,
|
|
8
|
+
} from "@intx/types";
|
|
9
|
+
|
|
10
|
+
import type { AppEnv } from "../context";
|
|
11
|
+
|
|
12
|
+
export function createApprovalRoutes(): Hono<AppEnv> {
|
|
13
|
+
const app = new Hono<AppEnv>();
|
|
14
|
+
|
|
15
|
+
app.get(
|
|
16
|
+
"/",
|
|
17
|
+
describeRoute({
|
|
18
|
+
tags: ["Approvals"],
|
|
19
|
+
summary: "List pending approvals in the tenant",
|
|
20
|
+
description:
|
|
21
|
+
"Returns pending approval requests for the authenticated user within this tenant.",
|
|
22
|
+
responses: {
|
|
23
|
+
200: {
|
|
24
|
+
description: "List of approvals",
|
|
25
|
+
content: {
|
|
26
|
+
"application/json": {
|
|
27
|
+
schema: resolver(ApprovalResponse.array()),
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
}),
|
|
33
|
+
(c) =>
|
|
34
|
+
c.json(
|
|
35
|
+
{ error: { code: "not_implemented", message: "Not implemented" } },
|
|
36
|
+
501,
|
|
37
|
+
),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
app.get(
|
|
41
|
+
"/:approvalId",
|
|
42
|
+
describeRoute({
|
|
43
|
+
tags: ["Approvals"],
|
|
44
|
+
summary: "Get approval details",
|
|
45
|
+
description:
|
|
46
|
+
"Returns the proposed action, context, originating agent, and session.",
|
|
47
|
+
responses: {
|
|
48
|
+
200: {
|
|
49
|
+
description: "Approval details",
|
|
50
|
+
content: {
|
|
51
|
+
"application/json": { schema: resolver(ApprovalResponse) },
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
404: {
|
|
55
|
+
description: "Approval not found",
|
|
56
|
+
content: {
|
|
57
|
+
"application/json": { schema: resolver(ErrorResponse) },
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
}),
|
|
62
|
+
(c) =>
|
|
63
|
+
c.json(
|
|
64
|
+
{ error: { code: "not_implemented", message: "Not implemented" } },
|
|
65
|
+
501,
|
|
66
|
+
),
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
app.post(
|
|
70
|
+
"/:approvalId/approve",
|
|
71
|
+
describeRoute({
|
|
72
|
+
tags: ["Approvals"],
|
|
73
|
+
summary: "Approve an action",
|
|
74
|
+
description:
|
|
75
|
+
"Approves the pending action. With scope 'once', the approval is one-time. With scope 'always', a persistent grant is created so the agent won't need to ask again.",
|
|
76
|
+
responses: {
|
|
77
|
+
200: {
|
|
78
|
+
description: "Action approved",
|
|
79
|
+
content: {
|
|
80
|
+
"application/json": { schema: resolver(ApprovalResponse) },
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
404: {
|
|
84
|
+
description: "Approval not found",
|
|
85
|
+
content: {
|
|
86
|
+
"application/json": { schema: resolver(ErrorResponse) },
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
}),
|
|
91
|
+
validator("json", ApproveAction),
|
|
92
|
+
(c) =>
|
|
93
|
+
c.json(
|
|
94
|
+
{ error: { code: "not_implemented", message: "Not implemented" } },
|
|
95
|
+
501,
|
|
96
|
+
),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
app.post(
|
|
100
|
+
"/:approvalId/reject",
|
|
101
|
+
describeRoute({
|
|
102
|
+
tags: ["Approvals"],
|
|
103
|
+
summary: "Reject an action",
|
|
104
|
+
description:
|
|
105
|
+
"Rejects the pending action. An optional message provides feedback to the agent.",
|
|
106
|
+
responses: {
|
|
107
|
+
200: {
|
|
108
|
+
description: "Action rejected",
|
|
109
|
+
content: {
|
|
110
|
+
"application/json": { schema: resolver(ApprovalResponse) },
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
404: {
|
|
114
|
+
description: "Approval not found",
|
|
115
|
+
content: {
|
|
116
|
+
"application/json": { schema: resolver(ErrorResponse) },
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
}),
|
|
121
|
+
validator("json", RejectAction),
|
|
122
|
+
(c) =>
|
|
123
|
+
c.json(
|
|
124
|
+
{ error: { code: "not_implemented", message: "Not implemented" } },
|
|
125
|
+
501,
|
|
126
|
+
),
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
return app;
|
|
130
|
+
}
|
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
import { describe, test, expect, afterAll, beforeAll } from "bun:test";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import git from "isomorphic-git";
|
|
6
|
+
import { type } from "arktype";
|
|
7
|
+
|
|
8
|
+
import { createInMemoryGrantStore } from "@intx/authz";
|
|
9
|
+
import {
|
|
10
|
+
generateKeyPair,
|
|
11
|
+
createSSHSignature,
|
|
12
|
+
verifySSHSignature,
|
|
13
|
+
} from "@intx/crypto-node";
|
|
14
|
+
import type { KeyPair } from "@intx/types/runtime";
|
|
15
|
+
import type { GrantRule } from "@intx/types/authz";
|
|
16
|
+
import type { DB } from "@intx/db";
|
|
17
|
+
import {
|
|
18
|
+
asset as assetTable,
|
|
19
|
+
agentAsset as agentAssetTable,
|
|
20
|
+
} from "@intx/db/schema";
|
|
21
|
+
import {
|
|
22
|
+
createAssetService,
|
|
23
|
+
createRepoStore,
|
|
24
|
+
createSidecarEmitter,
|
|
25
|
+
skillKindHandler,
|
|
26
|
+
skillAuthorize,
|
|
27
|
+
type AssetService,
|
|
28
|
+
type EventCollectorRegistry,
|
|
29
|
+
type RepoStore,
|
|
30
|
+
type SessionService,
|
|
31
|
+
type SidecarRouter,
|
|
32
|
+
} from "@intx/hub-sessions";
|
|
33
|
+
|
|
34
|
+
import { createApp } from "../app";
|
|
35
|
+
import type { GetSession } from "../session";
|
|
36
|
+
import { SANE_GITIGNORE } from "./assets";
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// IDs and base fixtures
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
const TENANT_ID = "tnt_test";
|
|
43
|
+
const PRINCIPAL_ID = "prn_test";
|
|
44
|
+
const USER_ID = "usr_test";
|
|
45
|
+
|
|
46
|
+
const testTenant = {
|
|
47
|
+
id: TENANT_ID,
|
|
48
|
+
name: "Test",
|
|
49
|
+
slug: "test",
|
|
50
|
+
domain: "test.example.com",
|
|
51
|
+
parentId: null,
|
|
52
|
+
config: null,
|
|
53
|
+
createdAt: new Date("2025-01-01"),
|
|
54
|
+
updatedAt: new Date("2025-01-01"),
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const testPrincipal = {
|
|
58
|
+
id: PRINCIPAL_ID,
|
|
59
|
+
tenantId: TENANT_ID,
|
|
60
|
+
kind: "user" as const,
|
|
61
|
+
refId: USER_ID,
|
|
62
|
+
status: "active" as const,
|
|
63
|
+
createdAt: new Date("2025-01-01"),
|
|
64
|
+
updatedAt: new Date("2025-01-01"),
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Filesystem fixtures
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
const tempDirs: string[] = [];
|
|
72
|
+
|
|
73
|
+
async function makeTempDir(prefix: string): Promise<string> {
|
|
74
|
+
const d = await fs.promises.mkdtemp(path.join(os.tmpdir(), prefix));
|
|
75
|
+
tempDirs.push(d);
|
|
76
|
+
return d;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let signingKey: KeyPair;
|
|
80
|
+
|
|
81
|
+
beforeAll(async () => {
|
|
82
|
+
signingKey = await generateKeyPair();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
afterAll(async () => {
|
|
86
|
+
for (const d of tempDirs.splice(0)) {
|
|
87
|
+
await fs.promises.rm(d, { recursive: true, force: true }).catch(() => {
|
|
88
|
+
/* best effort */
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// DB stub
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
type AssetRow = {
|
|
98
|
+
id: string;
|
|
99
|
+
tenantId: string;
|
|
100
|
+
kind: "agent-state" | "skill";
|
|
101
|
+
name: string;
|
|
102
|
+
displayName: string | null;
|
|
103
|
+
creatorPrincipalId: string | null;
|
|
104
|
+
createdAt: Date;
|
|
105
|
+
updatedAt: Date;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
type DBState = {
|
|
109
|
+
assets: AssetRow[];
|
|
110
|
+
/** When set, the next `findFirst(asset)` returns the row whose id
|
|
111
|
+
* matches this value. The asset-routes smart-HTTP handler looks up
|
|
112
|
+
* by `(tenantId, kind, name)`, but the stub only needs to honour
|
|
113
|
+
* the route's narrow query: we pre-seed which row to return. */
|
|
114
|
+
assetLookupHint: { tenantId: string; kind: string; name: string } | null;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
function makeMockDB(state: DBState): DB["db"] {
|
|
118
|
+
function findFirstAsset(): Promise<AssetRow | undefined> {
|
|
119
|
+
const hint = state.assetLookupHint;
|
|
120
|
+
if (hint === null) return Promise.resolve(undefined);
|
|
121
|
+
const match = state.assets.find(
|
|
122
|
+
(a) =>
|
|
123
|
+
a.tenantId === hint.tenantId &&
|
|
124
|
+
a.kind === hint.kind &&
|
|
125
|
+
a.name === hint.name,
|
|
126
|
+
);
|
|
127
|
+
return Promise.resolve(match);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function insertChain(table: unknown) {
|
|
131
|
+
return {
|
|
132
|
+
values(rows: AssetRow | AssetRow[]) {
|
|
133
|
+
const list = Array.isArray(rows) ? rows : [rows];
|
|
134
|
+
if (table === assetTable) {
|
|
135
|
+
const inserted: AssetRow[] = [];
|
|
136
|
+
for (const r of list) {
|
|
137
|
+
if (
|
|
138
|
+
state.assets.some(
|
|
139
|
+
(existing) =>
|
|
140
|
+
existing.tenantId === r.tenantId &&
|
|
141
|
+
existing.kind === r.kind &&
|
|
142
|
+
existing.name === r.name,
|
|
143
|
+
)
|
|
144
|
+
) {
|
|
145
|
+
const err = new Error(
|
|
146
|
+
`duplicate key value violates unique constraint`,
|
|
147
|
+
) as Error & { code?: string };
|
|
148
|
+
err.code = "23505";
|
|
149
|
+
return {
|
|
150
|
+
returning: () => Promise.reject(err),
|
|
151
|
+
then: (_resolve: (v: undefined) => unknown) =>
|
|
152
|
+
Promise.reject(err),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
state.assets.push(r);
|
|
156
|
+
inserted.push(r);
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
returning: () => Promise.resolve(inserted),
|
|
160
|
+
then: (resolve: (v: undefined) => unknown) => resolve(undefined),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
if (table === agentAssetTable) {
|
|
164
|
+
// Unused in these tests; the asset-routes layer never
|
|
165
|
+
// inserts agent_asset rows.
|
|
166
|
+
return {
|
|
167
|
+
returning: () => Promise.resolve(list),
|
|
168
|
+
then: (resolve: (v: undefined) => unknown) => resolve(undefined),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
returning: () => Promise.resolve(list),
|
|
173
|
+
then: (resolve: (v: undefined) => unknown) => resolve(undefined),
|
|
174
|
+
};
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const mock = {
|
|
180
|
+
query: {
|
|
181
|
+
tenant: {
|
|
182
|
+
findFirst: async () => testTenant,
|
|
183
|
+
findMany: async () => [testTenant],
|
|
184
|
+
},
|
|
185
|
+
principal: {
|
|
186
|
+
findFirst: async () => testPrincipal,
|
|
187
|
+
findMany: async () => [testPrincipal],
|
|
188
|
+
},
|
|
189
|
+
asset: {
|
|
190
|
+
findFirst: () => findFirstAsset(),
|
|
191
|
+
findMany: async () => state.assets,
|
|
192
|
+
},
|
|
193
|
+
gitToken: {
|
|
194
|
+
findFirst: async () => undefined,
|
|
195
|
+
findMany: async () => [],
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
transaction: async (fn: (tx: unknown) => Promise<unknown>) =>
|
|
199
|
+
fn({ insert: insertChain }),
|
|
200
|
+
insert: insertChain,
|
|
201
|
+
};
|
|
202
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- drizzle PgDatabase type cannot be structurally satisfied in tests
|
|
203
|
+
return mock as unknown as DB["db"];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
// Substrate (real RepoStore, real signer) and AssetService
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
async function createWiredSubstrate(): Promise<{
|
|
211
|
+
dataDir: string;
|
|
212
|
+
repoStore: RepoStore;
|
|
213
|
+
}> {
|
|
214
|
+
const dataDir = await makeTempDir("asset-routes-");
|
|
215
|
+
const signer = async (payload: string) =>
|
|
216
|
+
createSSHSignature(payload, signingKey.privateKey, signingKey.publicKey);
|
|
217
|
+
const repoStore = createRepoStore({
|
|
218
|
+
dataDir,
|
|
219
|
+
signingKey,
|
|
220
|
+
handlers: { skill: skillKindHandler },
|
|
221
|
+
authorize: skillAuthorize,
|
|
222
|
+
signingCallback: () => signer,
|
|
223
|
+
});
|
|
224
|
+
return { dataDir, repoStore };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
// Other mocks
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
function createMockGetSession(userId: string): GetSession {
|
|
232
|
+
const now = new Date("2025-01-01");
|
|
233
|
+
return async () => ({
|
|
234
|
+
user: {
|
|
235
|
+
id: userId,
|
|
236
|
+
email: "test@example.com",
|
|
237
|
+
emailVerified: true,
|
|
238
|
+
name: "Test User",
|
|
239
|
+
createdAt: now,
|
|
240
|
+
updatedAt: now,
|
|
241
|
+
},
|
|
242
|
+
session: {
|
|
243
|
+
id: "session_test",
|
|
244
|
+
userId,
|
|
245
|
+
token: "tok_test",
|
|
246
|
+
expiresAt: new Date("2999-01-01"),
|
|
247
|
+
createdAt: now,
|
|
248
|
+
updatedAt: now,
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function createMockSidecarRouter(): SidecarRouter {
|
|
254
|
+
function notImpl(name: string): never {
|
|
255
|
+
throw new Error(`mock: sidecarRouter.${name} not implemented`);
|
|
256
|
+
}
|
|
257
|
+
return {
|
|
258
|
+
handleOpen: () => notImpl("handleOpen"),
|
|
259
|
+
handleMessage: () => notImpl("handleMessage"),
|
|
260
|
+
handleClose: () => notImpl("handleClose"),
|
|
261
|
+
routeMail: () => notImpl("routeMail"),
|
|
262
|
+
sendAgentDeploy: () => notImpl("sendAgentDeploy"),
|
|
263
|
+
sendAgentUndeploy: () => notImpl("sendAgentUndeploy"),
|
|
264
|
+
sendSessionStart: () => notImpl("sendSessionStart"),
|
|
265
|
+
sendSessionAbort: () => notImpl("sendSessionAbort"),
|
|
266
|
+
sendGrantsUpdate: () => notImpl("sendGrantsUpdate"),
|
|
267
|
+
sendSourcesUpdate: () => notImpl("sendSourcesUpdate"),
|
|
268
|
+
sendPack: () => notImpl("sendPack"),
|
|
269
|
+
sendSyncRequest: () => notImpl("sendSyncRequest"),
|
|
270
|
+
subscribeAgent: () => notImpl("subscribeAgent"),
|
|
271
|
+
dispatchAgentEvent: () => undefined,
|
|
272
|
+
getConnectedSidecars: () => [],
|
|
273
|
+
getRoutableAddresses: () => [],
|
|
274
|
+
getConnectorState: () => null,
|
|
275
|
+
events: createSidecarEmitter(),
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function createMockSessionService(): SessionService {
|
|
280
|
+
return {
|
|
281
|
+
launchSession: () => {
|
|
282
|
+
throw new Error("mock: sessionService.launchSession not implemented");
|
|
283
|
+
},
|
|
284
|
+
sendUserMessage: () => {
|
|
285
|
+
throw new Error("mock: sessionService.sendUserMessage not implemented");
|
|
286
|
+
},
|
|
287
|
+
endSession: () => {
|
|
288
|
+
throw new Error("mock: sessionService.endSession not implemented");
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function notImplemented(name: string) {
|
|
294
|
+
return () => {
|
|
295
|
+
throw new Error(`mock: ${name} not implemented`);
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function createMockEventCollectors(): EventCollectorRegistry {
|
|
300
|
+
return {
|
|
301
|
+
create: notImplemented("eventCollectors.create"),
|
|
302
|
+
dispatch: notImplemented("eventCollectors.dispatch"),
|
|
303
|
+
abandon: notImplemented("eventCollectors.abandon"),
|
|
304
|
+
has: () => false,
|
|
305
|
+
getStatus: () => undefined,
|
|
306
|
+
getAccumulatedText: () => undefined,
|
|
307
|
+
getCurrentTurnId: () => undefined,
|
|
308
|
+
getLastTurnId: () => undefined,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ---------------------------------------------------------------------------
|
|
313
|
+
// App harness
|
|
314
|
+
// ---------------------------------------------------------------------------
|
|
315
|
+
|
|
316
|
+
function makeCreateGrant(overrides: Partial<GrantRule> = {}): GrantRule {
|
|
317
|
+
return {
|
|
318
|
+
id: "grant-create",
|
|
319
|
+
resource: "asset:*",
|
|
320
|
+
action: "create",
|
|
321
|
+
effect: "allow",
|
|
322
|
+
origin: "system",
|
|
323
|
+
conditions: null,
|
|
324
|
+
expiresAt: null,
|
|
325
|
+
roleId: null,
|
|
326
|
+
principalId: PRINCIPAL_ID,
|
|
327
|
+
...overrides,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
type Harness = {
|
|
332
|
+
app: ReturnType<typeof createApp>;
|
|
333
|
+
state: DBState;
|
|
334
|
+
repoStore: RepoStore;
|
|
335
|
+
assetService: AssetService;
|
|
336
|
+
dataDir: string;
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
async function setup(
|
|
340
|
+
grants: GrantRule[] = [makeCreateGrant()],
|
|
341
|
+
): Promise<Harness> {
|
|
342
|
+
const state: DBState = { assets: [], assetLookupHint: null };
|
|
343
|
+
const db = makeMockDB(state);
|
|
344
|
+
const { dataDir, repoStore } = await createWiredSubstrate();
|
|
345
|
+
const assetService = createAssetService({ db, repoStore });
|
|
346
|
+
const app = createApp({
|
|
347
|
+
getSession: createMockGetSession(USER_ID),
|
|
348
|
+
authHandler: () => new Response("", { status: 404 }),
|
|
349
|
+
db,
|
|
350
|
+
grantStore: createInMemoryGrantStore(grants),
|
|
351
|
+
sidecarRouter: createMockSidecarRouter(),
|
|
352
|
+
sessionService: createMockSessionService(),
|
|
353
|
+
eventCollectors: createMockEventCollectors(),
|
|
354
|
+
assetService,
|
|
355
|
+
repoStore,
|
|
356
|
+
});
|
|
357
|
+
return { app, state, repoStore, assetService, dataDir };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const createURL = `/api/tenants/${TENANT_ID}/assets`;
|
|
361
|
+
|
|
362
|
+
const AssetResponseShape = type({
|
|
363
|
+
id: "string",
|
|
364
|
+
tenantId: "string",
|
|
365
|
+
kind: "string",
|
|
366
|
+
name: "string",
|
|
367
|
+
"displayName?": "string | null",
|
|
368
|
+
"creatorPrincipalId?": "string | null",
|
|
369
|
+
"createdAt?": "string",
|
|
370
|
+
"updatedAt?": "string",
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
const ErrorResponseShape = type({
|
|
374
|
+
error: {
|
|
375
|
+
code: "string",
|
|
376
|
+
message: "string",
|
|
377
|
+
},
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
async function parseAssetResponse(res: Response) {
|
|
381
|
+
const raw: unknown = await res.json();
|
|
382
|
+
const parsed = AssetResponseShape(raw);
|
|
383
|
+
if (parsed instanceof type.errors) {
|
|
384
|
+
throw new Error(`asset response did not validate: ${parsed.summary}`);
|
|
385
|
+
}
|
|
386
|
+
return parsed;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async function parseErrorResponse(res: Response) {
|
|
390
|
+
const raw: unknown = await res.json();
|
|
391
|
+
const parsed = ErrorResponseShape(raw);
|
|
392
|
+
if (parsed instanceof type.errors) {
|
|
393
|
+
throw new Error(`error response did not validate: ${parsed.summary}`);
|
|
394
|
+
}
|
|
395
|
+
return parsed;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ---------------------------------------------------------------------------
|
|
399
|
+
// REST POST /assets
|
|
400
|
+
// ---------------------------------------------------------------------------
|
|
401
|
+
|
|
402
|
+
describe("POST /api/tenants/:tenantId/assets", () => {
|
|
403
|
+
test("creates a skill asset and returns the row", async () => {
|
|
404
|
+
const h = await setup();
|
|
405
|
+
const res = await h.app.request(createURL, {
|
|
406
|
+
method: "POST",
|
|
407
|
+
headers: { "content-type": "application/json" },
|
|
408
|
+
body: JSON.stringify({
|
|
409
|
+
kind: "skill",
|
|
410
|
+
name: "greet",
|
|
411
|
+
displayName: "Greeting skill",
|
|
412
|
+
}),
|
|
413
|
+
});
|
|
414
|
+
expect(res.status).toBe(201);
|
|
415
|
+
const body = await parseAssetResponse(res);
|
|
416
|
+
expect(body.kind).toBe("skill");
|
|
417
|
+
expect(body.name).toBe("greet");
|
|
418
|
+
expect(body.tenantId).toBe(TENANT_ID);
|
|
419
|
+
expect(body.id.startsWith("ast_")).toBe(true);
|
|
420
|
+
expect(h.state.assets).toHaveLength(1);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test("rejects an unknown kind with 400", async () => {
|
|
424
|
+
const h = await setup();
|
|
425
|
+
const res = await h.app.request(createURL, {
|
|
426
|
+
method: "POST",
|
|
427
|
+
headers: { "content-type": "application/json" },
|
|
428
|
+
body: JSON.stringify({ kind: "nonsense", name: "greet" }),
|
|
429
|
+
});
|
|
430
|
+
expect(res.status).toBe(400);
|
|
431
|
+
expect(h.state.assets).toHaveLength(0);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test("rejects a malformed name with 400 invalid_name", async () => {
|
|
435
|
+
const h = await setup();
|
|
436
|
+
const res = await h.app.request(createURL, {
|
|
437
|
+
method: "POST",
|
|
438
|
+
headers: { "content-type": "application/json" },
|
|
439
|
+
body: JSON.stringify({ kind: "skill", name: "Bad Name!" }),
|
|
440
|
+
});
|
|
441
|
+
expect(res.status).toBe(400);
|
|
442
|
+
const body = await parseErrorResponse(res);
|
|
443
|
+
expect(body.error.code).toBe("invalid_name");
|
|
444
|
+
expect(h.state.assets).toHaveLength(0);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test("rejects creation when the principal has no asset:* create grant", async () => {
|
|
448
|
+
const h = await setup([]);
|
|
449
|
+
const res = await h.app.request(createURL, {
|
|
450
|
+
method: "POST",
|
|
451
|
+
headers: { "content-type": "application/json" },
|
|
452
|
+
body: JSON.stringify({ kind: "skill", name: "greet" }),
|
|
453
|
+
});
|
|
454
|
+
expect(res.status).toBe(403);
|
|
455
|
+
expect(h.state.assets).toHaveLength(0);
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test("the genesis commit ships the asset-route gitignore body", async () => {
|
|
459
|
+
const h = await setup();
|
|
460
|
+
const res = await h.app.request(createURL, {
|
|
461
|
+
method: "POST",
|
|
462
|
+
headers: { "content-type": "application/json" },
|
|
463
|
+
body: JSON.stringify({ kind: "skill", name: "greet" }),
|
|
464
|
+
});
|
|
465
|
+
expect(res.status).toBe(201);
|
|
466
|
+
const body = await parseAssetResponse(res);
|
|
467
|
+
const dir = h.repoStore.getRepoDir({ kind: "skill", id: body.id });
|
|
468
|
+
const onDisk = await fs.promises.readFile(
|
|
469
|
+
path.join(dir, ".gitignore"),
|
|
470
|
+
"utf-8",
|
|
471
|
+
);
|
|
472
|
+
expect(onDisk).toBe(SANE_GITIGNORE);
|
|
473
|
+
expect(onDisk).toContain(".DS_Store");
|
|
474
|
+
expect(onDisk).toContain("keys/");
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
test("the genesis commit is signed and verifies against the hub public key", async () => {
|
|
478
|
+
const h = await setup();
|
|
479
|
+
const res = await h.app.request(createURL, {
|
|
480
|
+
method: "POST",
|
|
481
|
+
headers: { "content-type": "application/json" },
|
|
482
|
+
body: JSON.stringify({ kind: "skill", name: "greet" }),
|
|
483
|
+
});
|
|
484
|
+
expect(res.status).toBe(201);
|
|
485
|
+
const body = await parseAssetResponse(res);
|
|
486
|
+
const dir = h.repoStore.getRepoDir({ kind: "skill", id: body.id });
|
|
487
|
+
|
|
488
|
+
const [entry] = await git.log({ fs, dir, depth: 1 });
|
|
489
|
+
if (entry === undefined) throw new Error("no commit in log");
|
|
490
|
+
expect(entry.commit.author.name).toBe("interchange-hub");
|
|
491
|
+
const signature = entry.commit.gpgsig;
|
|
492
|
+
if (signature === undefined) throw new Error("commit was not signed");
|
|
493
|
+
|
|
494
|
+
const { object } = await git.readObject({
|
|
495
|
+
fs,
|
|
496
|
+
dir,
|
|
497
|
+
oid: entry.oid,
|
|
498
|
+
format: "content",
|
|
499
|
+
});
|
|
500
|
+
if (!(object instanceof Uint8Array)) {
|
|
501
|
+
throw new Error("expected raw commit content as Uint8Array");
|
|
502
|
+
}
|
|
503
|
+
const content = new TextDecoder().decode(object);
|
|
504
|
+
const gpgsigIdx = content.indexOf("\ngpgsig ");
|
|
505
|
+
let endIdx = gpgsigIdx + 1;
|
|
506
|
+
while (endIdx < content.length) {
|
|
507
|
+
const nlIdx = content.indexOf("\n", endIdx);
|
|
508
|
+
if (nlIdx === -1) break;
|
|
509
|
+
endIdx = nlIdx + 1;
|
|
510
|
+
if (endIdx < content.length && content[endIdx] !== " ") break;
|
|
511
|
+
}
|
|
512
|
+
const payload =
|
|
513
|
+
content.substring(0, gpgsigIdx) + "\n" + content.substring(endIdx);
|
|
514
|
+
expect(verifySSHSignature(payload, signature, signingKey.publicKey)).toBe(
|
|
515
|
+
true,
|
|
516
|
+
);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
test("HEAD points at refs/heads/main after init", async () => {
|
|
520
|
+
const h = await setup();
|
|
521
|
+
const res = await h.app.request(createURL, {
|
|
522
|
+
method: "POST",
|
|
523
|
+
headers: { "content-type": "application/json" },
|
|
524
|
+
body: JSON.stringify({ kind: "skill", name: "greet" }),
|
|
525
|
+
});
|
|
526
|
+
expect(res.status).toBe(201);
|
|
527
|
+
const body = await parseAssetResponse(res);
|
|
528
|
+
const dir = h.repoStore.getRepoDir({ kind: "skill", id: body.id });
|
|
529
|
+
const head = await fs.promises.readFile(
|
|
530
|
+
path.join(dir, ".git", "HEAD"),
|
|
531
|
+
"utf-8",
|
|
532
|
+
);
|
|
533
|
+
expect(head.trim()).toBe("ref: refs/heads/main");
|
|
534
|
+
});
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
// ---------------------------------------------------------------------------
|
|
538
|
+
// Smart-HTTP routes -- bearer enforcement
|
|
539
|
+
// ---------------------------------------------------------------------------
|
|
540
|
+
|
|
541
|
+
describe("smart-HTTP asset routes", () => {
|
|
542
|
+
test("info/refs without bearer credentials responds 401 with WWW-Authenticate", async () => {
|
|
543
|
+
const h = await setup();
|
|
544
|
+
const url =
|
|
545
|
+
`/api/tenants/${TENANT_ID}/assets/skill/missing.git/info/refs?` +
|
|
546
|
+
`service=git-upload-pack`;
|
|
547
|
+
const res = await h.app.request(url, { method: "GET" });
|
|
548
|
+
expect(res.status).toBe(401);
|
|
549
|
+
const challenge = res.headers.get("WWW-Authenticate");
|
|
550
|
+
expect(challenge).not.toBeNull();
|
|
551
|
+
expect(challenge ?? "").toMatch(/Basic/);
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
test("git-upload-pack without bearer credentials responds 401", async () => {
|
|
555
|
+
const h = await setup();
|
|
556
|
+
const url = `/api/tenants/${TENANT_ID}/assets/skill/missing.git/git-upload-pack`;
|
|
557
|
+
const res = await h.app.request(url, { method: "POST" });
|
|
558
|
+
expect(res.status).toBe(401);
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
test("git-receive-pack without bearer credentials responds 401", async () => {
|
|
562
|
+
const h = await setup();
|
|
563
|
+
const url = `/api/tenants/${TENANT_ID}/assets/skill/missing.git/git-receive-pack`;
|
|
564
|
+
const res = await h.app.request(url, { method: "POST" });
|
|
565
|
+
expect(res.status).toBe(401);
|
|
566
|
+
});
|
|
567
|
+
});
|