@veducio/keja 0.0.0-stage → 0.3.0
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/LICENSE +7 -0
- package/README.md +39 -2
- package/examples/starter/.env.example +6 -0
- package/examples/starter/keja.config.ts +7 -0
- package/examples/starter/keja.versioning.ts +5 -0
- package/examples/starter/migrations/0001_init.sql +1 -0
- package/examples/starter/package.json +14 -0
- package/examples/starter/tsconfig.json +9 -0
- package/package.json +62 -4
- package/src/access/cloudflare.test.ts +131 -0
- package/src/access/cloudflare.ts +193 -0
- package/src/argus/call.test.ts +98 -0
- package/src/argus/call.ts +153 -0
- package/src/argus/heartbeat.ts +65 -0
- package/src/argus/register.test.ts +120 -0
- package/src/argus/register.ts +145 -0
- package/src/cli/banner.test.ts +98 -0
- package/src/cli/banner.ts +209 -0
- package/src/cli/commands/build.ts +12 -0
- package/src/cli/commands/dev.ts +12 -0
- package/src/cli/commands/generate.ts +12 -0
- package/src/cli/commands/init.ts +57 -0
- package/src/cli/commands/migrate.ts +12 -0
- package/src/cli/commands/seed.ts +12 -0
- package/src/cli/commands/start.ts +12 -0
- package/src/cli/dev-reload.test.ts +87 -0
- package/src/cli/dev-reload.ts +182 -0
- package/src/cli/generate-project.ts +39 -0
- package/src/cli/index.ts +29 -0
- package/src/cli/init-project.test.ts +312 -0
- package/src/cli/init-project.ts +416 -0
- package/src/cli/migrate-project.ts +31 -0
- package/src/cli/run-project.test.ts +50 -0
- package/src/cli/run-project.ts +93 -0
- package/src/cli/seed-project.ts +46 -0
- package/src/db/client.test.ts +49 -0
- package/src/db/client.ts +61 -0
- package/src/db/migrate.test.ts +141 -0
- package/src/db/migrate.ts +146 -0
- package/src/db/owned-sql.ts +202 -0
- package/src/db/postgres.ts +90 -0
- package/src/env/required-env.test.ts +64 -0
- package/src/env/required-env.ts +120 -0
- package/src/errors/framework-errors.ts +295 -0
- package/src/grant/grant.test.ts +130 -0
- package/src/grant/grant.ts +268 -0
- package/src/http/create-app.test.ts +340 -0
- package/src/http/create-app.ts +196 -0
- package/src/http/openapi.ts +111 -0
- package/src/process/slot.ts +18 -0
- package/src/project/config.test.ts +143 -0
- package/src/project/config.ts +450 -0
- package/src/project/load.test.ts +60 -0
- package/src/project/load.ts +158 -0
- package/src/project/reload-graph.test.ts +52 -0
- package/src/project/reload-graph.ts +175 -0
- package/src/repository/define.ts +123 -0
- package/src/repository/generate.test.ts +58 -0
- package/src/repository/generate.ts +117 -0
- package/src/repository/query.test.ts +99 -0
- package/src/repository/query.ts +287 -0
- package/src/repository/runtime.test.ts +154 -0
- package/src/repository/runtime.ts +434 -0
- package/src/repository/sql.test.ts +78 -0
- package/src/repository/sql.ts +282 -0
- package/src/route/define.test.ts +65 -0
- package/src/route/define.ts +307 -0
- package/src/route/middleware.test.ts +63 -0
- package/src/route/middleware.ts +144 -0
- package/src/seed/apply.test.ts +65 -0
- package/src/seed/apply.ts +11 -0
- package/src/seed/define.test.ts +51 -0
- package/src/seed/define.ts +55 -0
- package/src/seed/load.test.ts +54 -0
- package/src/seed/load.ts +67 -0
- package/src/seed/report.test.ts +16 -0
- package/src/seed/report.ts +15 -0
- package/src/self-test/checks.ts +88 -0
- package/src/self-test/run.test.ts +128 -0
- package/src/self-test/run.ts +105 -0
- package/src/server/identity.test.ts +57 -0
- package/src/server/identity.ts +116 -0
- package/src/server/index.test.ts +15 -0
- package/src/server/index.ts +171 -0
- package/src/server/swap.test.ts +89 -0
- package/src/server/swap.ts +29 -0
- package/src/typeid/id.test.ts +46 -0
- package/src/typeid/id.ts +136 -0
- package/src/typeid/owned.test.ts +73 -0
- package/src/typeid/owned.ts +193 -0
- package/src/vault/client.test.ts +128 -0
- package/src/vault/client.ts +282 -0
- package/src/versioning/define.test.ts +79 -0
- package/src/versioning/define.ts +173 -0
- package/src/versioning/select.test.ts +79 -0
- package/src/versioning/select.ts +62 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { decodeJwt, decodeProtectedHeader, type JWTPayload } from "jose";
|
|
2
|
+
import {
|
|
3
|
+
openVault,
|
|
4
|
+
requireVaultSettings,
|
|
5
|
+
VaultError,
|
|
6
|
+
} from "../vault/client.ts";
|
|
7
|
+
|
|
8
|
+
export type Grant = {
|
|
9
|
+
subject: string;
|
|
10
|
+
action: string;
|
|
11
|
+
context: Record<string, unknown>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type GrantInput = {
|
|
15
|
+
subject: string;
|
|
16
|
+
action: string;
|
|
17
|
+
exp: number;
|
|
18
|
+
context?: Record<string, unknown>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type KeySpec = {
|
|
22
|
+
alg: string;
|
|
23
|
+
hash?: string;
|
|
24
|
+
marshaling?: string;
|
|
25
|
+
signature?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const keySpecs: Record<string, KeySpec> = {
|
|
29
|
+
"rsa-2048": { alg: "RS256", hash: "sha2-256", signature: "pkcs1v15" },
|
|
30
|
+
"rsa-3072": { alg: "RS256", hash: "sha2-256", signature: "pkcs1v15" },
|
|
31
|
+
"rsa-4096": { alg: "RS256", hash: "sha2-256", signature: "pkcs1v15" },
|
|
32
|
+
"ecdsa-p256": { alg: "ES256", hash: "sha2-256", marshaling: "jws" },
|
|
33
|
+
"ecdsa-p384": { alg: "ES384", hash: "sha2-384", marshaling: "jws" },
|
|
34
|
+
"ecdsa-p521": { alg: "ES512", hash: "sha2-512", marshaling: "jws" },
|
|
35
|
+
ed25519: { alg: "EdDSA" },
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export async function grant(input: GrantInput) {
|
|
39
|
+
const settings = grantSettings("grant");
|
|
40
|
+
const subject = requiredText(input.subject, "subject");
|
|
41
|
+
const action = requiredText(input.action, "action");
|
|
42
|
+
const lifetime = lifetimeSeconds(input.exp, settings.grant_max_exp);
|
|
43
|
+
const context = contextObject(input.context);
|
|
44
|
+
const vault = openVault("grant");
|
|
45
|
+
const key = await readKey(vault, settings);
|
|
46
|
+
const now = Math.floor(Date.now() / 1000);
|
|
47
|
+
const body = base64url(
|
|
48
|
+
JSON.stringify({
|
|
49
|
+
sub: subject,
|
|
50
|
+
action,
|
|
51
|
+
iat: now,
|
|
52
|
+
exp: now + lifetime,
|
|
53
|
+
context,
|
|
54
|
+
}),
|
|
55
|
+
);
|
|
56
|
+
const header = base64url(
|
|
57
|
+
JSON.stringify({ alg: key.alg, typ: "JWT", kid: String(key.version) }),
|
|
58
|
+
);
|
|
59
|
+
const signingInput = `${header}.${body}`;
|
|
60
|
+
const signed = await vault.transitSign(
|
|
61
|
+
settings.transit_mount,
|
|
62
|
+
settings.transit_key,
|
|
63
|
+
signBody(key, base64(signingInput)),
|
|
64
|
+
);
|
|
65
|
+
const signature = vaultSignature(signed.signature);
|
|
66
|
+
if (signature.version !== String(key.version)) {
|
|
67
|
+
throw new VaultError("Vault signed the grant with a different key version");
|
|
68
|
+
}
|
|
69
|
+
return `${signingInput}.${signature.segment}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function verifyGrant(token: string, options: { action: string }) {
|
|
73
|
+
const settings = grantSettings("verifyGrant");
|
|
74
|
+
const action = requiredText(options.action, "action");
|
|
75
|
+
const parts = token.split(".");
|
|
76
|
+
if (parts.length !== 3 || parts.some((part) => part.length === 0)) {
|
|
77
|
+
throw new VaultError("grant token is unreadable");
|
|
78
|
+
}
|
|
79
|
+
const header = decodeProtectedHeader(token);
|
|
80
|
+
const payload = decodeJwt(token);
|
|
81
|
+
const vault = openVault("verifyGrant");
|
|
82
|
+
const key = await readKey(vault, settings);
|
|
83
|
+
if (
|
|
84
|
+
header.alg !== key.alg ||
|
|
85
|
+
typeof header.kid !== "string" ||
|
|
86
|
+
!/^\d+$/.test(header.kid)
|
|
87
|
+
) {
|
|
88
|
+
throw new VaultError("grant token is unreadable");
|
|
89
|
+
}
|
|
90
|
+
const verified = await vault.transitVerify(
|
|
91
|
+
settings.transit_mount,
|
|
92
|
+
settings.transit_key,
|
|
93
|
+
{
|
|
94
|
+
...signBody(key, base64(`${parts[0]}.${parts[1]}`)),
|
|
95
|
+
signature: `vault:v${header.kid}:${Buffer.from(parts[2], "base64url").toString("base64")}`,
|
|
96
|
+
},
|
|
97
|
+
);
|
|
98
|
+
if (verified.valid !== true) {
|
|
99
|
+
throw new VaultError("grant signature was rejected");
|
|
100
|
+
}
|
|
101
|
+
return claims(payload, action);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function grantSettings(feature: "grant" | "verifyGrant") {
|
|
105
|
+
const settings = requireVaultSettings(feature);
|
|
106
|
+
if (!settings.transit_key) {
|
|
107
|
+
throw new VaultError("vault transit key is required");
|
|
108
|
+
}
|
|
109
|
+
return settings as typeof settings & { transit_key: string };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function readKey(
|
|
113
|
+
vault: ReturnType<typeof openVault>,
|
|
114
|
+
settings: { transit_mount: string; transit_key: string },
|
|
115
|
+
) {
|
|
116
|
+
const data = await vault.transitKey(
|
|
117
|
+
settings.transit_mount,
|
|
118
|
+
settings.transit_key,
|
|
119
|
+
);
|
|
120
|
+
if (typeof data.type !== "string" || !(data.type in keySpecs)) {
|
|
121
|
+
throw new VaultError("Vault transit key type cannot sign a grant");
|
|
122
|
+
}
|
|
123
|
+
const version = data.latest_version;
|
|
124
|
+
if (
|
|
125
|
+
typeof version !== "number" ||
|
|
126
|
+
!Number.isInteger(version) ||
|
|
127
|
+
version < 1
|
|
128
|
+
) {
|
|
129
|
+
throw new VaultError("Vault transit key has no version");
|
|
130
|
+
}
|
|
131
|
+
return { ...keySpecs[data.type], version };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function signBody(
|
|
135
|
+
key: KeySpec,
|
|
136
|
+
input: string,
|
|
137
|
+
): {
|
|
138
|
+
input: string;
|
|
139
|
+
hash_algorithm?: string;
|
|
140
|
+
marshaling_algorithm?: string;
|
|
141
|
+
signature_algorithm?: string;
|
|
142
|
+
} {
|
|
143
|
+
return {
|
|
144
|
+
input,
|
|
145
|
+
...(key.hash ? { hash_algorithm: key.hash } : {}),
|
|
146
|
+
...(key.marshaling ? { marshaling_algorithm: key.marshaling } : {}),
|
|
147
|
+
...(key.signature ? { signature_algorithm: key.signature } : {}),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function vaultSignature(value: unknown) {
|
|
152
|
+
if (typeof value !== "string") {
|
|
153
|
+
throw new VaultError("Vault returned an unreadable signature");
|
|
154
|
+
}
|
|
155
|
+
const match = /^vault:v(\d+):([A-Za-z0-9+/=_-]+)$/.exec(value);
|
|
156
|
+
if (!match) {
|
|
157
|
+
throw new VaultError("Vault returned an unreadable signature");
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
version: match[1],
|
|
161
|
+
segment: Buffer.from(match[2], "base64").toString("base64url"),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function claims(payload: JWTPayload, action: string): Grant {
|
|
166
|
+
const now = Math.floor(Date.now() / 1000);
|
|
167
|
+
if (
|
|
168
|
+
typeof payload.exp !== "number" ||
|
|
169
|
+
!Number.isInteger(payload.exp) ||
|
|
170
|
+
payload.exp <= now
|
|
171
|
+
) {
|
|
172
|
+
throw new VaultError("grant is expired");
|
|
173
|
+
}
|
|
174
|
+
if (
|
|
175
|
+
typeof payload.iat !== "number" ||
|
|
176
|
+
!Number.isInteger(payload.iat) ||
|
|
177
|
+
payload.iat > now ||
|
|
178
|
+
payload.iat >= payload.exp
|
|
179
|
+
) {
|
|
180
|
+
throw new VaultError("grant token is unreadable");
|
|
181
|
+
}
|
|
182
|
+
if (typeof payload.sub !== "string" || payload.sub.trim() === "") {
|
|
183
|
+
throw new VaultError("grant token is unreadable");
|
|
184
|
+
}
|
|
185
|
+
if (payload.action !== action) {
|
|
186
|
+
throw new VaultError("grant action does not match");
|
|
187
|
+
}
|
|
188
|
+
if (!isContext(payload.context)) {
|
|
189
|
+
throw new VaultError("grant context must be a plain object");
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
subject: payload.sub,
|
|
193
|
+
action,
|
|
194
|
+
context: payload.context,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function requiredText(value: unknown, field: string) {
|
|
199
|
+
if (
|
|
200
|
+
typeof value !== "string" ||
|
|
201
|
+
value.trim() === "" ||
|
|
202
|
+
value.trim() !== value
|
|
203
|
+
) {
|
|
204
|
+
throw new VaultError(`grant ${field} must be a non-empty string`);
|
|
205
|
+
}
|
|
206
|
+
return value;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function lifetimeSeconds(value: unknown, max: number) {
|
|
210
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
|
|
211
|
+
throw new VaultError("grant exp must be a whole number of seconds");
|
|
212
|
+
}
|
|
213
|
+
if (value > max) {
|
|
214
|
+
throw new VaultError(`grant exp must be at most ${max} seconds`);
|
|
215
|
+
}
|
|
216
|
+
return value;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function contextObject(value: unknown) {
|
|
220
|
+
if (value === undefined) {
|
|
221
|
+
return {};
|
|
222
|
+
}
|
|
223
|
+
if (!isContext(value)) {
|
|
224
|
+
throw new VaultError("grant context must be a plain object");
|
|
225
|
+
}
|
|
226
|
+
return value;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function isContext(value: unknown): value is Record<string, unknown> {
|
|
230
|
+
if (!isPlain(value)) {
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
return Object.values(value).every((item) => isJson(item));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function isJson(value: unknown): boolean {
|
|
237
|
+
if (
|
|
238
|
+
value === null ||
|
|
239
|
+
typeof value === "string" ||
|
|
240
|
+
typeof value === "boolean" ||
|
|
241
|
+
(typeof value === "number" && Number.isFinite(value))
|
|
242
|
+
) {
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
if (Array.isArray(value)) {
|
|
246
|
+
return value.every((item) => isJson(item));
|
|
247
|
+
}
|
|
248
|
+
if (!isPlain(value)) {
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
return Object.values(value).every((item) => isJson(item));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function isPlain(value: unknown): value is Record<string, unknown> {
|
|
255
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
const proto = Object.getPrototypeOf(value);
|
|
259
|
+
return proto === Object.prototype || proto === null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function base64url(value: string) {
|
|
263
|
+
return Buffer.from(value).toString("base64url");
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function base64(value: string) {
|
|
267
|
+
return Buffer.from(value).toString("base64");
|
|
268
|
+
}
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { frameworkErrors } from "../errors/framework-errors.ts";
|
|
4
|
+
import { defineRoute, response } from "../route/define.ts";
|
|
5
|
+
import { defineMiddleware } from "../route/middleware.ts";
|
|
6
|
+
import { defineVersions } from "../versioning/define.ts";
|
|
7
|
+
import { createHttpApp } from "./create-app.ts";
|
|
8
|
+
|
|
9
|
+
const versions = defineVersions({
|
|
10
|
+
v1: { releasedAt: "2020-01-15" },
|
|
11
|
+
v2: { releasedAt: "2024-06-01" },
|
|
12
|
+
}).versions;
|
|
13
|
+
|
|
14
|
+
const userResponse = z.object({
|
|
15
|
+
id: z.string(),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const publicRoutes = [
|
|
19
|
+
defineRoute({
|
|
20
|
+
method: "get",
|
|
21
|
+
path: "/users/:id",
|
|
22
|
+
version: "v1",
|
|
23
|
+
request: {
|
|
24
|
+
params: z.object({ id: z.string().min(1) }),
|
|
25
|
+
},
|
|
26
|
+
responses: {
|
|
27
|
+
User: response({ code: 200, json: userResponse }),
|
|
28
|
+
},
|
|
29
|
+
handler: ({ params, respond }) => respond.User({ id: params.id }),
|
|
30
|
+
}),
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const serviceRoutes = [
|
|
34
|
+
defineRoute({
|
|
35
|
+
method: "post",
|
|
36
|
+
path: "/jobs",
|
|
37
|
+
version: "v2",
|
|
38
|
+
request: {
|
|
39
|
+
body: z.object({ name: z.string() }),
|
|
40
|
+
},
|
|
41
|
+
responses: {
|
|
42
|
+
Created: response({ code: 201, json: z.object({ name: z.string() }) }),
|
|
43
|
+
},
|
|
44
|
+
handler: ({ body, respond }) => respond.Created(body),
|
|
45
|
+
}),
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
describe("createHttpApp", () => {
|
|
49
|
+
const publicApp = createHttpApp({
|
|
50
|
+
title: "Public",
|
|
51
|
+
versions,
|
|
52
|
+
routes: publicRoutes,
|
|
53
|
+
server: "http://127.0.0.1:3500",
|
|
54
|
+
});
|
|
55
|
+
const serviceApp = createHttpApp({
|
|
56
|
+
title: "Service",
|
|
57
|
+
versions,
|
|
58
|
+
routes: serviceRoutes,
|
|
59
|
+
server: "https://service.example",
|
|
60
|
+
postOnly: true,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("serves health, OpenAPI, and Scalar docs on both servers", async () => {
|
|
64
|
+
for (const app of [publicApp, serviceApp]) {
|
|
65
|
+
const health = await app.request("/_health");
|
|
66
|
+
expect(health.status).toBe(200);
|
|
67
|
+
await expect(health.json()).resolves.toEqual({ status: "ok" });
|
|
68
|
+
|
|
69
|
+
const docs = await app.request("/docs");
|
|
70
|
+
expect(docs.status).toBe(200);
|
|
71
|
+
expect(docs.headers.get("content-type")).toContain("text/html");
|
|
72
|
+
const html = await docs.text();
|
|
73
|
+
expect(html).toContain('"hideClientButton": true');
|
|
74
|
+
expect(html).toContain('"showDeveloperTools": "never"');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const identified = createHttpApp({
|
|
78
|
+
title: "Public",
|
|
79
|
+
versions,
|
|
80
|
+
routes: publicRoutes,
|
|
81
|
+
identity: {
|
|
82
|
+
name: "billing",
|
|
83
|
+
public: ["http://192.168.1.10:3000"],
|
|
84
|
+
service: ["http://192.168.1.10:3001"],
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
const identity = await identified.request("/_identity");
|
|
88
|
+
expect(identity.status).toBe(200);
|
|
89
|
+
await expect(identity.json()).resolves.toEqual({
|
|
90
|
+
name: "billing",
|
|
91
|
+
public: ["http://192.168.1.10:3000"],
|
|
92
|
+
service: ["http://192.168.1.10:3001"],
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const spec = await publicApp.request("/_openapi.json");
|
|
96
|
+
const document = (await spec.json()) as {
|
|
97
|
+
openapi: string;
|
|
98
|
+
paths: Record<string, unknown>;
|
|
99
|
+
servers?: { url: string }[];
|
|
100
|
+
};
|
|
101
|
+
expect(document.openapi).toMatch(/^3\./);
|
|
102
|
+
expect(document.paths["/v1/users/{id}"]).toBeDefined();
|
|
103
|
+
expect(document.servers).toEqual([{ url: "http://127.0.0.1:3500" }]);
|
|
104
|
+
|
|
105
|
+
const serviceSpec = await serviceApp.request("/_openapi.json");
|
|
106
|
+
const serviceDocument = (await serviceSpec.json()) as {
|
|
107
|
+
servers?: { url: string }[];
|
|
108
|
+
};
|
|
109
|
+
expect(serviceDocument.servers).toEqual([
|
|
110
|
+
{ url: "https://service.example" },
|
|
111
|
+
]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("runs the newest available handler and validates the request", async () => {
|
|
115
|
+
const found = await publicApp.request("/v2/users/ada");
|
|
116
|
+
expect(found.status).toBe(200);
|
|
117
|
+
await expect(found.json()).resolves.toEqual({ id: "ada" });
|
|
118
|
+
|
|
119
|
+
const dated = await publicApp.request("/2020-09-20/users/ada");
|
|
120
|
+
expect(dated.status).toBe(200);
|
|
121
|
+
|
|
122
|
+
const invalid = await publicApp.request("/latest/users/");
|
|
123
|
+
expect(invalid.status).toBe(404);
|
|
124
|
+
await expect(invalid.json()).resolves.toEqual(frameworkErrors.notFound);
|
|
125
|
+
|
|
126
|
+
const missing = await publicApp.request("/2019-01-01/users/ada");
|
|
127
|
+
expect(missing.status).toBe(404);
|
|
128
|
+
await expect(missing.json()).resolves.toEqual(
|
|
129
|
+
frameworkErrors.unknownVersion,
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const service = await serviceApp.request("/v1/jobs", {
|
|
133
|
+
method: "POST",
|
|
134
|
+
headers: { "content-type": "application/json" },
|
|
135
|
+
body: JSON.stringify({ name: "sync" }),
|
|
136
|
+
});
|
|
137
|
+
expect(service.status).toBe(404);
|
|
138
|
+
await expect(service.json()).resolves.toEqual(frameworkErrors.notFound);
|
|
139
|
+
|
|
140
|
+
const getJob = await serviceApp.request("/v2/jobs");
|
|
141
|
+
expect(getJob.status).toBe(405);
|
|
142
|
+
await expect(getJob.json()).resolves.toEqual(
|
|
143
|
+
frameworkErrors.methodNotAllowed,
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
const created = await serviceApp.request("/current/jobs", {
|
|
147
|
+
method: "POST",
|
|
148
|
+
headers: { "content-type": "application/json" },
|
|
149
|
+
body: JSON.stringify({ name: "sync" }),
|
|
150
|
+
});
|
|
151
|
+
expect(created.status).toBe(201);
|
|
152
|
+
|
|
153
|
+
const rejected = await serviceApp.request("/v2/jobs", {
|
|
154
|
+
method: "POST",
|
|
155
|
+
headers: { "content-type": "application/json" },
|
|
156
|
+
body: JSON.stringify({}),
|
|
157
|
+
});
|
|
158
|
+
expect(rejected.status).toBe(400);
|
|
159
|
+
await expect(rejected.json()).resolves.toMatchObject(
|
|
160
|
+
frameworkErrors.invalidRequest,
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("rejects a service route that is not POST", () => {
|
|
165
|
+
expect(() =>
|
|
166
|
+
createHttpApp({
|
|
167
|
+
title: "Service",
|
|
168
|
+
versions,
|
|
169
|
+
routes: publicRoutes,
|
|
170
|
+
postOnly: true,
|
|
171
|
+
}),
|
|
172
|
+
).toThrow("GET /users/:id must use POST");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("fills constant response fields that are not handler arguments", async () => {
|
|
176
|
+
const app = createHttpApp({
|
|
177
|
+
title: "Public",
|
|
178
|
+
versions,
|
|
179
|
+
routes: [
|
|
180
|
+
defineRoute({
|
|
181
|
+
method: "get",
|
|
182
|
+
path: "/missing/:id",
|
|
183
|
+
version: "v1",
|
|
184
|
+
request: { params: z.object({ id: z.string() }) },
|
|
185
|
+
responses: {
|
|
186
|
+
IdDoesNotExist: response({
|
|
187
|
+
code: 404,
|
|
188
|
+
json: z.object({
|
|
189
|
+
id: z.string(),
|
|
190
|
+
error: z.literal("ID_DOES_NOT_EXIST"),
|
|
191
|
+
}),
|
|
192
|
+
const: { error: "ID_DOES_NOT_EXIST" },
|
|
193
|
+
}),
|
|
194
|
+
Gone: response({
|
|
195
|
+
code: 410,
|
|
196
|
+
json: z.object({ error: z.literal("GONE") }),
|
|
197
|
+
const: { error: "GONE" },
|
|
198
|
+
}),
|
|
199
|
+
},
|
|
200
|
+
handler: ({ params, respond }) =>
|
|
201
|
+
params.id === "gone"
|
|
202
|
+
? respond.Gone()
|
|
203
|
+
: respond.IdDoesNotExist(params),
|
|
204
|
+
}),
|
|
205
|
+
],
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const missing = await app.request("/v1/missing/ada");
|
|
209
|
+
expect(missing.status).toBe(404);
|
|
210
|
+
await expect(missing.json()).resolves.toEqual({
|
|
211
|
+
id: "ada",
|
|
212
|
+
error: "ID_DOES_NOT_EXIST",
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const gone = await app.request("/v1/missing/gone");
|
|
216
|
+
expect(gone.status).toBe(410);
|
|
217
|
+
await expect(gone.json()).resolves.toEqual({ error: "GONE" });
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("runs code around next and passes values into the handler", async () => {
|
|
221
|
+
const events: string[] = [];
|
|
222
|
+
const server = defineMiddleware(async ({ next }) => {
|
|
223
|
+
events.push("server-before");
|
|
224
|
+
const response = await next();
|
|
225
|
+
events.push("server-after");
|
|
226
|
+
response.headers.set("x-server", "yes");
|
|
227
|
+
return response;
|
|
228
|
+
});
|
|
229
|
+
const route = defineMiddleware(async ({ version, params, next }) => {
|
|
230
|
+
events.push("route-before");
|
|
231
|
+
const response = await next({ seen: `${version}:${params.id}` });
|
|
232
|
+
events.push("route-after");
|
|
233
|
+
response.headers.set("x-route", "yes");
|
|
234
|
+
return response;
|
|
235
|
+
});
|
|
236
|
+
const app = createHttpApp({
|
|
237
|
+
title: "Public",
|
|
238
|
+
versions,
|
|
239
|
+
middleware: [server],
|
|
240
|
+
routes: [
|
|
241
|
+
defineRoute({
|
|
242
|
+
method: "get",
|
|
243
|
+
path: "/items/:id",
|
|
244
|
+
version: "v1",
|
|
245
|
+
request: { params: z.object({ id: z.string() }) },
|
|
246
|
+
middleware: [route],
|
|
247
|
+
responses: {
|
|
248
|
+
Ok: response({
|
|
249
|
+
code: 200,
|
|
250
|
+
json: z.object({ seen: z.string() }),
|
|
251
|
+
}),
|
|
252
|
+
},
|
|
253
|
+
handler: ({ seen, respond }) => {
|
|
254
|
+
events.push("handler");
|
|
255
|
+
return respond.Ok({ seen });
|
|
256
|
+
},
|
|
257
|
+
}),
|
|
258
|
+
],
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const health = await app.request("/_health");
|
|
262
|
+
expect(health.status).toBe(200);
|
|
263
|
+
expect(events).toEqual([]);
|
|
264
|
+
|
|
265
|
+
const result = await app.request("/v2/items/ada");
|
|
266
|
+
expect(result.status).toBe(200);
|
|
267
|
+
expect(result.headers.get("x-server")).toBe("yes");
|
|
268
|
+
expect(result.headers.get("x-route")).toBe("yes");
|
|
269
|
+
await expect(result.json()).resolves.toEqual({ seen: "v2:ada" });
|
|
270
|
+
expect(events).toEqual([
|
|
271
|
+
"server-before",
|
|
272
|
+
"route-before",
|
|
273
|
+
"handler",
|
|
274
|
+
"route-after",
|
|
275
|
+
"server-after",
|
|
276
|
+
]);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("stops the chain when middleware returns its own response", async () => {
|
|
280
|
+
let handled = false;
|
|
281
|
+
const app = createHttpApp({
|
|
282
|
+
title: "Public",
|
|
283
|
+
versions,
|
|
284
|
+
routes: [
|
|
285
|
+
defineRoute({
|
|
286
|
+
method: "get",
|
|
287
|
+
path: "/items/:id",
|
|
288
|
+
version: "v1",
|
|
289
|
+
middleware: [
|
|
290
|
+
defineMiddleware(async () =>
|
|
291
|
+
Response.json({ message: "Unauthorized" }, { status: 401 }),
|
|
292
|
+
),
|
|
293
|
+
],
|
|
294
|
+
responses: {
|
|
295
|
+
Ok: response({ code: 200, json: z.object({}) }),
|
|
296
|
+
},
|
|
297
|
+
handler: ({ respond }) => {
|
|
298
|
+
handled = true;
|
|
299
|
+
return respond.Ok({});
|
|
300
|
+
},
|
|
301
|
+
}),
|
|
302
|
+
],
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
const result = await app.request("/v1/items/ada");
|
|
306
|
+
expect(result.status).toBe(401);
|
|
307
|
+
await expect(result.json()).resolves.toEqual({
|
|
308
|
+
message: "Unauthorized",
|
|
309
|
+
});
|
|
310
|
+
expect(handled).toBe(false);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("returns the framework error when a response misses its schema", async () => {
|
|
314
|
+
const app = createHttpApp({
|
|
315
|
+
title: "Public",
|
|
316
|
+
versions,
|
|
317
|
+
routes: [
|
|
318
|
+
defineRoute({
|
|
319
|
+
method: "get",
|
|
320
|
+
path: "/items/:id",
|
|
321
|
+
version: "v1",
|
|
322
|
+
request: { params: z.object({ id: z.string() }) },
|
|
323
|
+
responses: {
|
|
324
|
+
Ok: response({
|
|
325
|
+
code: 200,
|
|
326
|
+
json: z.object({ id: z.string() }),
|
|
327
|
+
}),
|
|
328
|
+
},
|
|
329
|
+
handler: ({ respond }) => respond.Ok({ id: 1 } as never),
|
|
330
|
+
}),
|
|
331
|
+
],
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
const result = await app.request("/v1/items/ada");
|
|
335
|
+
expect(result.status).toBe(500);
|
|
336
|
+
await expect(result.json()).resolves.toMatchObject(
|
|
337
|
+
frameworkErrors.invalidResponse,
|
|
338
|
+
);
|
|
339
|
+
});
|
|
340
|
+
});
|