@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,315 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { and, eq } from "drizzle-orm";
|
|
4
|
+
import { createMiddleware } from "hono/factory";
|
|
5
|
+
import type { Context, Env, MiddlewareHandler } from "hono";
|
|
6
|
+
|
|
7
|
+
import type { DB } from "@intx/db";
|
|
8
|
+
import { parseGitTokenRow } from "@intx/db";
|
|
9
|
+
import { gitToken, principal, tenant } from "@intx/db/schema";
|
|
10
|
+
import { PAT_PREFIX, SVC_PREFIX } from "@intx/hub-common";
|
|
11
|
+
import { getLogger } from "@intx/log";
|
|
12
|
+
import type { RepoAction } from "@intx/hub-sessions";
|
|
13
|
+
|
|
14
|
+
import type { AppEnv, PrincipalRow, TenantRow } from "../context";
|
|
15
|
+
|
|
16
|
+
const log = getLogger(["hub", "git-token"]);
|
|
17
|
+
|
|
18
|
+
const WWW_AUTHENTICATE_HEADER = "WWW-Authenticate";
|
|
19
|
+
const WWW_AUTHENTICATE_VALUE = 'Basic realm="Interchange"';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Claims carried on a successfully authenticated git token. These
|
|
23
|
+
* mirror the typed columns on the `git_token` row but are the only
|
|
24
|
+
* shape downstream middleware should rely on; the row itself is not
|
|
25
|
+
* exposed.
|
|
26
|
+
*/
|
|
27
|
+
export type GitTokenClaims = {
|
|
28
|
+
resource: string;
|
|
29
|
+
refPattern: string;
|
|
30
|
+
actions: RepoAction[];
|
|
31
|
+
expiresAt: Date;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type GitTokenAuthEnv = Env & {
|
|
35
|
+
Variables: {
|
|
36
|
+
principal: PrincipalRow;
|
|
37
|
+
tenant: TenantRow;
|
|
38
|
+
"git-token-claims": GitTokenClaims;
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Hono environment for routes mounted under the bearer middleware.
|
|
44
|
+
* Combines the tenant-resolution variables with the bearer auth
|
|
45
|
+
* variables so the smart-HTTP route handlers can read
|
|
46
|
+
* `git-token-claims` straight from `c.get(...)` without an `as` cast.
|
|
47
|
+
*/
|
|
48
|
+
export type TenantGitTokenEnv = Env & {
|
|
49
|
+
Variables: AppEnv["Variables"] & {
|
|
50
|
+
tenant: TenantRow;
|
|
51
|
+
principal: PrincipalRow;
|
|
52
|
+
"git-token-claims": GitTokenClaims;
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export type CreateGitTokenAuthDeps = {
|
|
57
|
+
db: DB["db"];
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Bearer-token authentication for the smart-HTTP git endpoints.
|
|
62
|
+
* Parses `Authorization: Basic` (password is the secret; username is
|
|
63
|
+
* ignored but logged) or `Authorization: Bearer`, validates the
|
|
64
|
+
* `itx_pat_` / `itx_svc_` prefix shape, hashes the secret with
|
|
65
|
+
* SHA-256, looks up the matching `git_token` row, and resolves the
|
|
66
|
+
* principal and tenant before passing to the next middleware.
|
|
67
|
+
*
|
|
68
|
+
* On success, the middleware sets `principal`, `tenant`, and
|
|
69
|
+
* `git-token-claims` on the request context.
|
|
70
|
+
*/
|
|
71
|
+
export function createGitTokenAuth({
|
|
72
|
+
db,
|
|
73
|
+
}: CreateGitTokenAuthDeps): MiddlewareHandler<GitTokenAuthEnv> {
|
|
74
|
+
return createMiddleware<GitTokenAuthEnv>(async (c, next) => {
|
|
75
|
+
const authHeader = c.req.header("authorization");
|
|
76
|
+
const parsed = parseAuthorizationHeader(authHeader);
|
|
77
|
+
if (parsed === null) {
|
|
78
|
+
log.info("git-token auth: missing or unparseable Authorization header");
|
|
79
|
+
return unauthorized(c, "Authentication required");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const { secret, basicUsername } = parsed;
|
|
83
|
+
if (basicUsername !== null) {
|
|
84
|
+
log.info(
|
|
85
|
+
"git-token auth: Basic username received (ignored for gating) {username}",
|
|
86
|
+
{ username: basicUsername },
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!hasKnownPrefix(secret)) {
|
|
91
|
+
log.info("git-token auth: malformed token prefix");
|
|
92
|
+
return unauthorized(c, "Authentication required");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const tokenHash = sha256(secret);
|
|
96
|
+
|
|
97
|
+
const tokenRowRaw = await db.query.gitToken.findFirst({
|
|
98
|
+
where: eq(gitToken.tokenHashSha256, tokenHash),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
if (!tokenRowRaw) {
|
|
102
|
+
log.info("git-token auth: unknown token");
|
|
103
|
+
return unauthorized(c, "Authentication required");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const tokenRow = parseGitTokenRow(tokenRowRaw);
|
|
107
|
+
|
|
108
|
+
if (tokenRow.revokedAt !== null) {
|
|
109
|
+
log.info("git-token auth: token revoked {tokenId}", {
|
|
110
|
+
tokenId: tokenRow.id,
|
|
111
|
+
});
|
|
112
|
+
return forbidden(c, "token_revoked", "Token has been revoked");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const now = new Date();
|
|
116
|
+
if (tokenRow.expiresAt.getTime() <= now.getTime()) {
|
|
117
|
+
log.info("git-token auth: token expired {tokenId}", {
|
|
118
|
+
tokenId: tokenRow.id,
|
|
119
|
+
});
|
|
120
|
+
return forbidden(c, "token_expired", "Token has expired");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const urlTenantId = c.req.param("tenantId");
|
|
124
|
+
|
|
125
|
+
if (tokenRow.tenantId !== null) {
|
|
126
|
+
if (urlTenantId !== undefined && urlTenantId !== tokenRow.tenantId) {
|
|
127
|
+
log.info(
|
|
128
|
+
"git-token auth: tenant mismatch {tokenId} token={tokenTenantId} url={urlTenantId}",
|
|
129
|
+
{
|
|
130
|
+
tokenId: tokenRow.id,
|
|
131
|
+
tokenTenantId: tokenRow.tenantId,
|
|
132
|
+
urlTenantId,
|
|
133
|
+
},
|
|
134
|
+
);
|
|
135
|
+
return forbidden(
|
|
136
|
+
c,
|
|
137
|
+
"tenant_mismatch",
|
|
138
|
+
"Token is not valid for this tenant",
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const resolvedTenantId =
|
|
144
|
+
tokenRow.tenantId !== null ? tokenRow.tenantId : urlTenantId;
|
|
145
|
+
if (resolvedTenantId === undefined) {
|
|
146
|
+
log.info(
|
|
147
|
+
"git-token auth: tenant cannot be resolved (token not tenant-bound and URL has no :tenantId) {tokenId}",
|
|
148
|
+
{ tokenId: tokenRow.id },
|
|
149
|
+
);
|
|
150
|
+
return forbidden(
|
|
151
|
+
c,
|
|
152
|
+
"tenant_mismatch",
|
|
153
|
+
"Token is not valid for this request",
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const tenantRow = await db.query.tenant.findFirst({
|
|
158
|
+
where: eq(tenant.id, resolvedTenantId),
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
if (!tenantRow) {
|
|
162
|
+
log.info("git-token auth: tenant not found {tenantId}", {
|
|
163
|
+
tenantId: resolvedTenantId,
|
|
164
|
+
});
|
|
165
|
+
return forbidden(
|
|
166
|
+
c,
|
|
167
|
+
"tenant_mismatch",
|
|
168
|
+
"Token is not valid for this tenant",
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const principalRow = await resolvePrincipal(db, tokenRow, resolvedTenantId);
|
|
173
|
+
|
|
174
|
+
if (!principalRow) {
|
|
175
|
+
log.info(
|
|
176
|
+
"git-token auth: principal not found {tokenId} userId={userId} tenantId={tenantId}",
|
|
177
|
+
{
|
|
178
|
+
tokenId: tokenRow.id,
|
|
179
|
+
userId: tokenRow.userId,
|
|
180
|
+
tenantId: resolvedTenantId,
|
|
181
|
+
},
|
|
182
|
+
);
|
|
183
|
+
return forbidden(
|
|
184
|
+
c,
|
|
185
|
+
"principal_not_found",
|
|
186
|
+
"No principal is registered for this token in the target tenant",
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (principalRow.status !== "active") {
|
|
191
|
+
log.info(
|
|
192
|
+
"git-token auth: principal suspended {principalId} status={status}",
|
|
193
|
+
{
|
|
194
|
+
principalId: principalRow.id,
|
|
195
|
+
status: principalRow.status,
|
|
196
|
+
},
|
|
197
|
+
);
|
|
198
|
+
return forbidden(c, "principal_suspended", "Principal is not active");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
log.info(
|
|
202
|
+
"git-token auth: success {tokenId} principal={principalId} tenant={tenantId}",
|
|
203
|
+
{
|
|
204
|
+
tokenId: tokenRow.id,
|
|
205
|
+
principalId: principalRow.id,
|
|
206
|
+
tenantId: tenantRow.id,
|
|
207
|
+
},
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
c.set("principal", principalRow);
|
|
211
|
+
c.set("tenant", tenantRow);
|
|
212
|
+
c.set("git-token-claims", {
|
|
213
|
+
resource: tokenRow.resource,
|
|
214
|
+
refPattern: tokenRow.refPattern,
|
|
215
|
+
actions: tokenRow.actions,
|
|
216
|
+
expiresAt: tokenRow.expiresAt,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
await next();
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
type ParsedAuthorization = {
|
|
224
|
+
secret: string;
|
|
225
|
+
basicUsername: string | null;
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
function parseAuthorizationHeader(
|
|
229
|
+
header: string | undefined,
|
|
230
|
+
): ParsedAuthorization | null {
|
|
231
|
+
if (header === undefined) return null;
|
|
232
|
+
|
|
233
|
+
const trimmed = header.trim();
|
|
234
|
+
if (trimmed.length === 0) return null;
|
|
235
|
+
|
|
236
|
+
const spaceIdx = trimmed.indexOf(" ");
|
|
237
|
+
if (spaceIdx === -1) return null;
|
|
238
|
+
|
|
239
|
+
const scheme = trimmed.slice(0, spaceIdx).toLowerCase();
|
|
240
|
+
const rest = trimmed.slice(spaceIdx + 1).trim();
|
|
241
|
+
if (rest.length === 0) return null;
|
|
242
|
+
|
|
243
|
+
if (scheme === "bearer") {
|
|
244
|
+
return { secret: rest, basicUsername: null };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (scheme === "basic") {
|
|
248
|
+
const decoded = decodeBase64Utf8(rest);
|
|
249
|
+
if (decoded === null) return null;
|
|
250
|
+
const colonIdx = decoded.indexOf(":");
|
|
251
|
+
if (colonIdx === -1) return null;
|
|
252
|
+
const username = decoded.slice(0, colonIdx);
|
|
253
|
+
const password = decoded.slice(colonIdx + 1);
|
|
254
|
+
if (password.length === 0) return null;
|
|
255
|
+
return { secret: password, basicUsername: username };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function decodeBase64Utf8(input: string): string | null {
|
|
262
|
+
try {
|
|
263
|
+
return Buffer.from(input, "base64").toString("utf8");
|
|
264
|
+
} catch {
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function hasKnownPrefix(secret: string): boolean {
|
|
270
|
+
return secret.startsWith(PAT_PREFIX) || secret.startsWith(SVC_PREFIX);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function sha256(input: string): Uint8Array {
|
|
274
|
+
const hash = createHash("sha256");
|
|
275
|
+
hash.update(input, "utf8");
|
|
276
|
+
return new Uint8Array(hash.digest());
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function resolvePrincipal(
|
|
280
|
+
db: DB["db"],
|
|
281
|
+
tokenRow: ReturnType<typeof parseGitTokenRow>,
|
|
282
|
+
resolvedTenantId: string,
|
|
283
|
+
): Promise<PrincipalRow | undefined> {
|
|
284
|
+
if (tokenRow.principalId !== null) {
|
|
285
|
+
return await db.query.principal.findFirst({
|
|
286
|
+
where: eq(principal.id, tokenRow.principalId),
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return await db.query.principal.findFirst({
|
|
291
|
+
where: and(
|
|
292
|
+
eq(principal.tenantId, resolvedTenantId),
|
|
293
|
+
eq(principal.kind, "user"),
|
|
294
|
+
eq(principal.refId, tokenRow.userId),
|
|
295
|
+
),
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function unauthorized(c: Context, message: string): Response {
|
|
300
|
+
c.header(WWW_AUTHENTICATE_HEADER, WWW_AUTHENTICATE_VALUE);
|
|
301
|
+
return c.json({ error: { code: "unauthorized", message } }, 401);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function forbidden(
|
|
305
|
+
c: Context,
|
|
306
|
+
code:
|
|
307
|
+
| "token_revoked"
|
|
308
|
+
| "token_expired"
|
|
309
|
+
| "tenant_mismatch"
|
|
310
|
+
| "principal_not_found"
|
|
311
|
+
| "principal_suspended",
|
|
312
|
+
message: string,
|
|
313
|
+
): Response {
|
|
314
|
+
return c.json({ error: { code, message } }, 403);
|
|
315
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { createMiddleware } from "hono/factory";
|
|
2
|
+
import type { MiddlewareHandler } from "hono";
|
|
3
|
+
|
|
4
|
+
import { authorize } from "@intx/authz";
|
|
5
|
+
import { getLogger } from "@intx/log";
|
|
6
|
+
import type { ConditionRegistry, GrantStore } from "@intx/types/authz";
|
|
7
|
+
|
|
8
|
+
import type { TenantEnv } from "../context";
|
|
9
|
+
|
|
10
|
+
const log = getLogger(["hub", "middleware", "grant"]);
|
|
11
|
+
|
|
12
|
+
type ResourceFn = (c: {
|
|
13
|
+
param: (name: string) => string | undefined;
|
|
14
|
+
}) => string;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Closure-bound grant-check middleware factory returned by
|
|
18
|
+
* `createRequireGrant`. Returns a Hono middleware that authorizes the
|
|
19
|
+
* current principal against the given resource and action. The function
|
|
20
|
+
* form of `resource` is intended to be built with `idResource(...)`.
|
|
21
|
+
*/
|
|
22
|
+
export type RequireGrant = (
|
|
23
|
+
resource: string | ResourceFn,
|
|
24
|
+
action: string,
|
|
25
|
+
) => MiddlewareHandler<TenantEnv>;
|
|
26
|
+
|
|
27
|
+
export type CreateRequireGrantDeps = {
|
|
28
|
+
grantStore: GrantStore;
|
|
29
|
+
conditionRegistry: ConditionRegistry;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Builds a `requireGrant` middleware factory bound to the application's
|
|
34
|
+
* grant store and condition registry. Usage:
|
|
35
|
+
*
|
|
36
|
+
* const requireGrant = createRequireGrant({ grantStore, conditionRegistry });
|
|
37
|
+
* app.get("/", requireGrant("agent:*", "read"), handler);
|
|
38
|
+
*/
|
|
39
|
+
export function createRequireGrant({
|
|
40
|
+
grantStore,
|
|
41
|
+
conditionRegistry,
|
|
42
|
+
}: CreateRequireGrantDeps): RequireGrant {
|
|
43
|
+
return function requireGrant(resource, action) {
|
|
44
|
+
return createMiddleware<TenantEnv>(async (c, next) => {
|
|
45
|
+
const principal = c.get("principal");
|
|
46
|
+
const tenant = c.get("tenant");
|
|
47
|
+
|
|
48
|
+
const resolvedResource =
|
|
49
|
+
typeof resource === "function"
|
|
50
|
+
? resource({ param: (name) => c.req.param(name) })
|
|
51
|
+
: resource;
|
|
52
|
+
|
|
53
|
+
const result = await authorize(
|
|
54
|
+
grantStore,
|
|
55
|
+
principal.id,
|
|
56
|
+
tenant.id,
|
|
57
|
+
resolvedResource,
|
|
58
|
+
action,
|
|
59
|
+
conditionRegistry,
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
if (result.effect === "allow") {
|
|
63
|
+
await next();
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
log.info(
|
|
68
|
+
"Authorization denied for {principalId}: {resource} {action} -> {effect}",
|
|
69
|
+
{
|
|
70
|
+
principalId: principal.id,
|
|
71
|
+
resource: resolvedResource,
|
|
72
|
+
action,
|
|
73
|
+
effect: result.effect ?? "no_match",
|
|
74
|
+
resolvedBy: result.resolvedBy?.id ?? null,
|
|
75
|
+
},
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
return c.json(
|
|
79
|
+
{
|
|
80
|
+
error: {
|
|
81
|
+
code: "forbidden",
|
|
82
|
+
message: "You do not have permission to perform this action",
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
403,
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Helper that builds a resource string from a URL parameter.
|
|
93
|
+
*
|
|
94
|
+
* Usage:
|
|
95
|
+
* requireGrant(idResource("agent", "agentId"), "manage")
|
|
96
|
+
* // resolves to "agent:agt_abc123" from the URL
|
|
97
|
+
*/
|
|
98
|
+
export function idResource(
|
|
99
|
+
resourceType: string,
|
|
100
|
+
paramName: string,
|
|
101
|
+
): ResourceFn {
|
|
102
|
+
return (c) => {
|
|
103
|
+
const id = c.param(paramName);
|
|
104
|
+
return id ? `${resourceType}:${id}` : `${resourceType}:*`;
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Context, Next } from "hono";
|
|
2
|
+
|
|
3
|
+
import type { AppEnv } from "../context";
|
|
4
|
+
import type { GetSession } from "../session";
|
|
5
|
+
|
|
6
|
+
export function createSessionMiddleware(getSession: GetSession) {
|
|
7
|
+
return async function sessionMiddleware(c: Context<AppEnv>, next: Next) {
|
|
8
|
+
const result = await getSession(c.req.raw.headers);
|
|
9
|
+
c.set("user", result?.user ?? null);
|
|
10
|
+
c.set("session", result?.session ?? null);
|
|
11
|
+
await next();
|
|
12
|
+
};
|
|
13
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import type { MiddlewareHandler } from "hono";
|
|
4
|
+
|
|
5
|
+
import type { DB } from "@intx/db";
|
|
6
|
+
|
|
7
|
+
import type { PrincipalRow, TenantEnv, TenantRow } from "../context";
|
|
8
|
+
import { createResolveTenant } from "./tenant";
|
|
9
|
+
|
|
10
|
+
const NOW = new Date("2025-01-15T00:00:00Z");
|
|
11
|
+
|
|
12
|
+
function makeTenant(id: string): TenantRow {
|
|
13
|
+
return {
|
|
14
|
+
id,
|
|
15
|
+
name: `Tenant ${id}`,
|
|
16
|
+
slug: id,
|
|
17
|
+
domain: `${id}.example.com`,
|
|
18
|
+
parentId: null,
|
|
19
|
+
config: null,
|
|
20
|
+
createdAt: NOW,
|
|
21
|
+
updatedAt: NOW,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function makePrincipal(
|
|
26
|
+
id: string,
|
|
27
|
+
tenantId: string,
|
|
28
|
+
status: PrincipalRow["status"] = "active",
|
|
29
|
+
): PrincipalRow {
|
|
30
|
+
return {
|
|
31
|
+
id,
|
|
32
|
+
tenantId,
|
|
33
|
+
kind: "user",
|
|
34
|
+
refId: "user_alice",
|
|
35
|
+
status,
|
|
36
|
+
createdAt: NOW,
|
|
37
|
+
updatedAt: NOW,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function notImplemented(path: string) {
|
|
42
|
+
return () => {
|
|
43
|
+
throw new Error(`mock: ${path} not implemented`);
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type MockDBOpts = {
|
|
48
|
+
tenant?: TenantRow | undefined;
|
|
49
|
+
principal?: PrincipalRow | undefined;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
function createMockDB(opts: MockDBOpts): DB["db"] {
|
|
53
|
+
const mock = {
|
|
54
|
+
query: {
|
|
55
|
+
tenant: {
|
|
56
|
+
findFirst: async () => opts.tenant,
|
|
57
|
+
findMany: notImplemented("db.query.tenant.findMany"),
|
|
58
|
+
},
|
|
59
|
+
principal: {
|
|
60
|
+
findFirst: async () => opts.principal,
|
|
61
|
+
findMany: notImplemented("db.query.principal.findMany"),
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- drizzle PgDatabase type cannot be structurally satisfied in tests
|
|
66
|
+
return mock as unknown as DB["db"];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe("createResolveTenant", () => {
|
|
70
|
+
test("short-circuits when principal and tenant are already set on context", async () => {
|
|
71
|
+
const dbMock = {
|
|
72
|
+
query: {
|
|
73
|
+
tenant: { findFirst: notImplemented("tenant.findFirst") },
|
|
74
|
+
principal: { findFirst: notImplemented("principal.findFirst") },
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- drizzle PgDatabase type cannot be structurally satisfied in tests
|
|
78
|
+
const db = dbMock as unknown as DB["db"];
|
|
79
|
+
|
|
80
|
+
const tenantRow = makeTenant("ten_a");
|
|
81
|
+
const principalRow = makePrincipal("prin_1", "ten_a");
|
|
82
|
+
|
|
83
|
+
const app = new Hono<TenantEnv>();
|
|
84
|
+
const preset: MiddlewareHandler<TenantEnv> = async (c, next) => {
|
|
85
|
+
c.set("tenant", tenantRow);
|
|
86
|
+
c.set("principal", principalRow);
|
|
87
|
+
await next();
|
|
88
|
+
};
|
|
89
|
+
app.get(
|
|
90
|
+
"/tenants/:tenantId/probe",
|
|
91
|
+
preset,
|
|
92
|
+
createResolveTenant({ db }),
|
|
93
|
+
(c) => {
|
|
94
|
+
const t = c.get("tenant");
|
|
95
|
+
const p = c.get("principal");
|
|
96
|
+
return c.json({ tenantId: t.id, principalId: p.id });
|
|
97
|
+
},
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const res = await app.request("/tenants/ten_a/probe");
|
|
101
|
+
expect(res.status).toBe(200);
|
|
102
|
+
const body: unknown = await res.json();
|
|
103
|
+
expect(body).toEqual({ tenantId: "ten_a", principalId: "prin_1" });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("normal path runs when context is not pre-populated", async () => {
|
|
107
|
+
const tenantRow = makeTenant("ten_a");
|
|
108
|
+
const principalRow = makePrincipal("prin_alice", "ten_a");
|
|
109
|
+
const db = createMockDB({
|
|
110
|
+
tenant: tenantRow,
|
|
111
|
+
principal: principalRow,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const app = new Hono<TenantEnv>();
|
|
115
|
+
const setUser: MiddlewareHandler<TenantEnv> = async (c, next) => {
|
|
116
|
+
c.set("user", {
|
|
117
|
+
id: "user_alice",
|
|
118
|
+
createdAt: NOW,
|
|
119
|
+
updatedAt: NOW,
|
|
120
|
+
email: "alice@example.com",
|
|
121
|
+
emailVerified: true,
|
|
122
|
+
name: "Alice",
|
|
123
|
+
});
|
|
124
|
+
c.set("session", null);
|
|
125
|
+
await next();
|
|
126
|
+
};
|
|
127
|
+
app.get(
|
|
128
|
+
"/tenants/:tenantId/probe",
|
|
129
|
+
setUser,
|
|
130
|
+
createResolveTenant({ db }),
|
|
131
|
+
(c) => {
|
|
132
|
+
const t = c.get("tenant");
|
|
133
|
+
const p = c.get("principal");
|
|
134
|
+
return c.json({ tenantId: t.id, principalId: p.id });
|
|
135
|
+
},
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
const res = await app.request("/tenants/ten_a/probe");
|
|
139
|
+
expect(res.status).toBe(200);
|
|
140
|
+
const body: unknown = await res.json();
|
|
141
|
+
expect(body).toEqual({ tenantId: "ten_a", principalId: "prin_alice" });
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("normal path returns 401 when there is no user", async () => {
|
|
145
|
+
const db = createMockDB({});
|
|
146
|
+
const app = new Hono<TenantEnv>();
|
|
147
|
+
const setNoUser: MiddlewareHandler<TenantEnv> = async (c, next) => {
|
|
148
|
+
c.set("user", null);
|
|
149
|
+
c.set("session", null);
|
|
150
|
+
await next();
|
|
151
|
+
};
|
|
152
|
+
app.get(
|
|
153
|
+
"/tenants/:tenantId/probe",
|
|
154
|
+
setNoUser,
|
|
155
|
+
createResolveTenant({ db }),
|
|
156
|
+
(c) => c.text("ok"),
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const res = await app.request("/tenants/ten_a/probe");
|
|
160
|
+
expect(res.status).toBe(401);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("normal path returns 403 when the principal is suspended", async () => {
|
|
164
|
+
const db = createMockDB({
|
|
165
|
+
tenant: makeTenant("ten_a"),
|
|
166
|
+
principal: makePrincipal("prin_alice", "ten_a", "suspended"),
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const app = new Hono<TenantEnv>();
|
|
170
|
+
const setUser: MiddlewareHandler<TenantEnv> = async (c, next) => {
|
|
171
|
+
c.set("user", {
|
|
172
|
+
id: "user_alice",
|
|
173
|
+
createdAt: NOW,
|
|
174
|
+
updatedAt: NOW,
|
|
175
|
+
email: "alice@example.com",
|
|
176
|
+
emailVerified: true,
|
|
177
|
+
name: "Alice",
|
|
178
|
+
});
|
|
179
|
+
c.set("session", null);
|
|
180
|
+
await next();
|
|
181
|
+
};
|
|
182
|
+
app.get(
|
|
183
|
+
"/tenants/:tenantId/probe",
|
|
184
|
+
setUser,
|
|
185
|
+
createResolveTenant({ db }),
|
|
186
|
+
(c) => c.text("ok"),
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
const res = await app.request("/tenants/ten_a/probe");
|
|
190
|
+
expect(res.status).toBe(403);
|
|
191
|
+
});
|
|
192
|
+
});
|