@veducio/keja 0.0.0-stage → 0.2.3
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/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 +140 -0
- package/src/project/config.ts +413 -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 +169 -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 +122 -0
- package/src/vault/client.ts +191 -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
package/LICENSE
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Copyright © 2026 Veducio
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
4
|
+
|
|
5
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,40 @@
|
|
|
1
|
-
#
|
|
1
|
+
# keja
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Hono server framework for Bun.
|
|
4
|
+
|
|
5
|
+
Copy `.env.example` to `.env` and set `HOST`, `HTTP_PORT`, and `HTTP_SERVICE_PORT` before starting. `OPENAPI_SERVER` and `OPENAPI_SERVICE_SERVER` are optional absolute http or https URLs written into each OpenAPI document. The names and validators live in `src/env/required-env.ts`.
|
|
6
|
+
|
|
7
|
+
A project keeps `keja.versioning.ts` at its root. Public routes live in `src/public` and service routes live in `src/service`, one `defineRoute` module per file. `bun run dev` and `bun run start` load that project from the current directory and listen on both ports. Each server exposes `/_health`, `/_openapi.json`, and `/docs`.
|
|
8
|
+
|
|
9
|
+
## Commands
|
|
10
|
+
|
|
11
|
+
- `examples/example-app` is its own package. From that directory, `bun install` then `bun dev` starts it. The app defines `v1` and `v2`, serves users and posts on the public port, and creates jobs on the service port.
|
|
12
|
+
- `bun run dev` runs the self-test, then starts the development server with Bun watch. Run it from a Keja project. In this repository it fails until `keja.versioning.ts` exists in the current directory.
|
|
13
|
+
- `bun run start` runs the self-test, then starts the server once.
|
|
14
|
+
- `bun run self-test` checks that the process is Bun, checks the machine, then checks required environment variables.
|
|
15
|
+
- `keja dev` and `keja start` are the same commands through the CLI. `keja build` and `keja init` are reserved for a later specification.
|
|
16
|
+
- `bun run typecheck` typechecks the project with TypeScript.
|
|
17
|
+
- `bun run test` runs the Vitest suite once.
|
|
18
|
+
- `bun run test:watch` runs Vitest in watch mode.
|
|
19
|
+
- `bun run lint` lints the project with Biome.
|
|
20
|
+
- `bun run format` formats the project with Biome.
|
|
21
|
+
- `bun run check` runs the Biome formatter and linter without writing.
|
|
22
|
+
|
|
23
|
+
## Git hooks
|
|
24
|
+
|
|
25
|
+
Husky installs the hooks in `.husky` on `bun install`.
|
|
26
|
+
|
|
27
|
+
- Commits and pushes directly to `production` are rejected.
|
|
28
|
+
- Commit messages must use Conventional Commits, such as `feat: add a route` or `fix(api): handle a missing host`.
|
|
29
|
+
- Staged files are checked with Biome before a commit is created.
|
|
30
|
+
|
|
31
|
+
## Versioning
|
|
32
|
+
|
|
33
|
+
A push to `production` or `main` runs the test suite, then chooses a version bump from the commit messages in that push:
|
|
34
|
+
|
|
35
|
+
- `type!:` or a `BREAKING CHANGE:` footer bumps the major version.
|
|
36
|
+
- `feat:` bumps the minor version.
|
|
37
|
+
- `fix:` bumps the patch version.
|
|
38
|
+
- Any other message does not bump the version.
|
|
39
|
+
|
|
40
|
+
When several commits are pushed together, the highest bump wins. The workflow commits `chore: release <version>` and pushes that commit plus the git tag.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
select 1;
|
package/package.json
CHANGED
|
@@ -1,6 +1,64 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@veducio/keja",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.2.3",
|
|
4
|
+
"packageManager": "bun@1.4.4",
|
|
5
|
+
"description": "Veducio Keja - RESTFUL Web Server, Based on Lightweight Hono Framework, Written in TypeScript, Powered by Bun",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/veducio/keja.git"
|
|
10
|
+
},
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"registry": "https://registry.npmjs.org",
|
|
13
|
+
"access": "public"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"src",
|
|
17
|
+
"examples/starter",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"type": "module",
|
|
22
|
+
"bin": {
|
|
23
|
+
"keja": "./src/cli/index.ts"
|
|
24
|
+
},
|
|
25
|
+
"main": "src/server/index.ts",
|
|
26
|
+
"exports": {
|
|
27
|
+
".": "./src/server/index.ts",
|
|
28
|
+
"./cli": "./src/cli/index.ts"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"dev": "bun src/cli/index.ts dev",
|
|
32
|
+
"start": "bun src/cli/index.ts start",
|
|
33
|
+
"self-test": "bun src/self-test/run.ts",
|
|
34
|
+
"typecheck": "tsc --noEmit && tsc --noEmit -p examples/example-app && tsc --noEmit -p examples/starter",
|
|
35
|
+
"test": "vitest run",
|
|
36
|
+
"test:watch": "vitest",
|
|
37
|
+
"lint": "biome lint .",
|
|
38
|
+
"format": "biome format --write .",
|
|
39
|
+
"check": "biome check .",
|
|
40
|
+
"docs": "cd docs && mint dev",
|
|
41
|
+
"prepare": "husky"
|
|
42
|
+
},
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=22"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@biomejs/biome": "2.6.6",
|
|
48
|
+
"@types/bun": "^1.4.4",
|
|
49
|
+
"husky": "^9.1.7",
|
|
50
|
+
"mint": "^4.3.6",
|
|
51
|
+
"typescript": "^7.0.2",
|
|
52
|
+
"vitest": "^5.1.0"
|
|
53
|
+
},
|
|
54
|
+
"dependencies": {
|
|
55
|
+
"@hono/zod-openapi": "^1.6.3",
|
|
56
|
+
"@scalar/hono-api-reference": "^0.12.18",
|
|
57
|
+
"chalk": "^6.0.1",
|
|
58
|
+
"citty": "^0.2.2",
|
|
59
|
+
"hono": "^4.13.17",
|
|
60
|
+
"jose": "^6.2.16",
|
|
61
|
+
"typeid-js": "^1.2.0",
|
|
62
|
+
"zod": "^4.6.7"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { generateKeyPair, SignJWT } from "jose";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { frameworkErrors } from "../errors/framework-errors.ts";
|
|
4
|
+
import {
|
|
5
|
+
accessToken,
|
|
6
|
+
cloudflareAccess,
|
|
7
|
+
verifyAccessJwt,
|
|
8
|
+
} from "./cloudflare.ts";
|
|
9
|
+
|
|
10
|
+
const issuer = "https://veducio.cloudflareaccess.com";
|
|
11
|
+
const audience = "app-aud";
|
|
12
|
+
|
|
13
|
+
describe("accessToken", () => {
|
|
14
|
+
it("reads the assertion header before the cookie", () => {
|
|
15
|
+
const request = new Request("https://app.test", {
|
|
16
|
+
headers: {
|
|
17
|
+
"Cf-Access-Jwt-Assertion": "header-token",
|
|
18
|
+
cookie: "CF_Authorization=cookie-token",
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
expect(accessToken(request)).toBe("header-token");
|
|
22
|
+
expect(
|
|
23
|
+
accessToken(
|
|
24
|
+
new Request("https://app.test", {
|
|
25
|
+
headers: { cookie: "theme=light; CF_Authorization=cookie-token" },
|
|
26
|
+
}),
|
|
27
|
+
),
|
|
28
|
+
).toBe("cookie-token");
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("verifyAccessJwt", () => {
|
|
33
|
+
it("accepts a token signed for the team audience", async () => {
|
|
34
|
+
const { publicKey, privateKey } = await generateKeyPair("ES256");
|
|
35
|
+
const token = await new SignJWT({
|
|
36
|
+
email: "ada@veducio.com",
|
|
37
|
+
country: "PL",
|
|
38
|
+
})
|
|
39
|
+
.setProtectedHeader({ alg: "ES256" })
|
|
40
|
+
.setIssuer(issuer)
|
|
41
|
+
.setAudience(audience)
|
|
42
|
+
.setSubject("ada")
|
|
43
|
+
.setIssuedAt()
|
|
44
|
+
.setExpirationTime("2m")
|
|
45
|
+
.sign(privateKey);
|
|
46
|
+
|
|
47
|
+
await expect(
|
|
48
|
+
verifyAccessJwt(token, {
|
|
49
|
+
team: "veducio.cloudflareaccess.com",
|
|
50
|
+
audience,
|
|
51
|
+
getKey: async () => publicKey,
|
|
52
|
+
}),
|
|
53
|
+
).resolves.toMatchObject({
|
|
54
|
+
sub: "ada",
|
|
55
|
+
iss: issuer,
|
|
56
|
+
aud: [audience],
|
|
57
|
+
email: "ada@veducio.com",
|
|
58
|
+
country: "PL",
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("rejects a token for a different audience", async () => {
|
|
63
|
+
const { publicKey, privateKey } = await generateKeyPair("ES256");
|
|
64
|
+
const token = await new SignJWT({})
|
|
65
|
+
.setProtectedHeader({ alg: "ES256" })
|
|
66
|
+
.setIssuer(issuer)
|
|
67
|
+
.setAudience("other-app")
|
|
68
|
+
.setSubject("ada")
|
|
69
|
+
.setIssuedAt()
|
|
70
|
+
.setExpirationTime("2m")
|
|
71
|
+
.sign(privateKey);
|
|
72
|
+
|
|
73
|
+
await expect(
|
|
74
|
+
verifyAccessJwt(token, {
|
|
75
|
+
team: issuer,
|
|
76
|
+
audience,
|
|
77
|
+
getKey: async () => publicKey,
|
|
78
|
+
}),
|
|
79
|
+
).rejects.toThrow("Cloudflare Access token was rejected");
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("cloudflareAccess", () => {
|
|
84
|
+
it("passes the identity to the route and denies a missing token", async () => {
|
|
85
|
+
const { publicKey, privateKey } = await generateKeyPair("ES256");
|
|
86
|
+
const token = await new SignJWT({ email: "ada@veducio.com" })
|
|
87
|
+
.setProtectedHeader({ alg: "ES256" })
|
|
88
|
+
.setIssuer(issuer)
|
|
89
|
+
.setAudience(audience)
|
|
90
|
+
.setSubject("ada")
|
|
91
|
+
.setIssuedAt()
|
|
92
|
+
.setExpirationTime("2m")
|
|
93
|
+
.sign(privateKey);
|
|
94
|
+
const middleware = cloudflareAccess({
|
|
95
|
+
team: issuer,
|
|
96
|
+
audience,
|
|
97
|
+
getKey: async () => publicKey,
|
|
98
|
+
});
|
|
99
|
+
const accepted = await middleware.handle({
|
|
100
|
+
request: new Request("https://app.test/v1/jobs", {
|
|
101
|
+
headers: { "Cf-Access-Jwt-Assertion": token },
|
|
102
|
+
}),
|
|
103
|
+
version: "v1",
|
|
104
|
+
params: {},
|
|
105
|
+
next: async (fields) => Response.json(fields ?? null),
|
|
106
|
+
});
|
|
107
|
+
expect(accepted.status).toBe(200);
|
|
108
|
+
await expect(accepted.json()).resolves.toMatchObject({
|
|
109
|
+
access: { email: "ada@veducio.com", sub: "ada" },
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const denied = await middleware.handle({
|
|
113
|
+
request: new Request("https://app.test/v1/jobs"),
|
|
114
|
+
version: "v1",
|
|
115
|
+
params: {},
|
|
116
|
+
next: async () => Response.json({ ok: true }),
|
|
117
|
+
});
|
|
118
|
+
expect(denied.status).toBe(401);
|
|
119
|
+
await expect(denied.json()).resolves.toMatchObject(
|
|
120
|
+
frameworkErrors.accessDenied,
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("requires a team domain and audience tag", () => {
|
|
125
|
+
delete process.env.CF_ACCESS_TEAM_DOMAIN;
|
|
126
|
+
delete process.env.CF_ACCESS_AUD;
|
|
127
|
+
expect(() => cloudflareAccess()).toThrow(
|
|
128
|
+
"Missing CF_ACCESS_TEAM_DOMAIN and CF_ACCESS_AUD",
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createRemoteJWKSet,
|
|
3
|
+
type JWTPayload,
|
|
4
|
+
type JWTVerifyGetKey,
|
|
5
|
+
jwtVerify,
|
|
6
|
+
} from "jose";
|
|
7
|
+
import { frameworkError, projectError } from "../errors/framework-errors.ts";
|
|
8
|
+
import { defineMiddleware } from "../route/middleware.ts";
|
|
9
|
+
|
|
10
|
+
const assertionHeader = "Cf-Access-Jwt-Assertion";
|
|
11
|
+
const authorizationCookie = "CF_Authorization";
|
|
12
|
+
|
|
13
|
+
export type AccessIdentity = {
|
|
14
|
+
sub: string;
|
|
15
|
+
iss: string;
|
|
16
|
+
aud: string[];
|
|
17
|
+
exp: number;
|
|
18
|
+
iat: number;
|
|
19
|
+
email?: string;
|
|
20
|
+
country?: string;
|
|
21
|
+
type?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type CloudflareAccessOptions = {
|
|
25
|
+
team?: string;
|
|
26
|
+
audience?: string | string[];
|
|
27
|
+
getKey?: JWTVerifyGetKey;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const jwksByIssuer = new Map<string, JWTVerifyGetKey>();
|
|
31
|
+
|
|
32
|
+
export function cloudflareAccess(options: CloudflareAccessOptions = {}) {
|
|
33
|
+
const settings = accessSettings(options);
|
|
34
|
+
return defineMiddleware(async ({ request, next }) => {
|
|
35
|
+
const token = accessToken(request);
|
|
36
|
+
if (!token) {
|
|
37
|
+
return denied();
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
const access = await verifyAccessJwt(token, settings);
|
|
41
|
+
return next({ access });
|
|
42
|
+
} catch {
|
|
43
|
+
return denied();
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function verifyAccessJwt(
|
|
49
|
+
token: string,
|
|
50
|
+
options: {
|
|
51
|
+
team: string;
|
|
52
|
+
audience: string | string[];
|
|
53
|
+
getKey?: JWTVerifyGetKey;
|
|
54
|
+
},
|
|
55
|
+
) {
|
|
56
|
+
const issuer = teamIssuer(options.team);
|
|
57
|
+
const audience = audienceList(options.audience);
|
|
58
|
+
try {
|
|
59
|
+
const verified = await jwtVerify(token, options.getKey ?? jwks(issuer), {
|
|
60
|
+
issuer,
|
|
61
|
+
audience,
|
|
62
|
+
});
|
|
63
|
+
return accessIdentity(verified.payload);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (error instanceof Error && error.message === rejected) {
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
throw new Error(rejected);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function accessToken(request: Request) {
|
|
73
|
+
const header = request.headers.get(assertionHeader)?.trim();
|
|
74
|
+
if (header) {
|
|
75
|
+
return header;
|
|
76
|
+
}
|
|
77
|
+
return cookie(request, authorizationCookie);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function accessSettings(options: CloudflareAccessOptions) {
|
|
81
|
+
const team = options.team?.trim() || envValue("CF_ACCESS_TEAM_DOMAIN");
|
|
82
|
+
const audience = options.audience ?? envValue("CF_ACCESS_AUD");
|
|
83
|
+
const missing = [
|
|
84
|
+
team ? null : "CF_ACCESS_TEAM_DOMAIN",
|
|
85
|
+
audience ? null : "CF_ACCESS_AUD",
|
|
86
|
+
].filter((item) => item !== null);
|
|
87
|
+
if (missing.length > 0 || !team || !audience) {
|
|
88
|
+
throw projectError("accessConfig", `Missing ${missing.join(" and ")}`);
|
|
89
|
+
}
|
|
90
|
+
return { team, audience, getKey: options.getKey };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function accessIdentity(payload: JWTPayload): AccessIdentity {
|
|
94
|
+
const aud = payload.aud;
|
|
95
|
+
const audiences =
|
|
96
|
+
typeof aud === "string"
|
|
97
|
+
? [aud]
|
|
98
|
+
: Array.isArray(aud)
|
|
99
|
+
? aud.filter((item): item is string => typeof item === "string")
|
|
100
|
+
: [];
|
|
101
|
+
if (
|
|
102
|
+
typeof payload.sub !== "string" ||
|
|
103
|
+
typeof payload.iss !== "string" ||
|
|
104
|
+
typeof payload.exp !== "number" ||
|
|
105
|
+
typeof payload.iat !== "number" ||
|
|
106
|
+
audiences.length === 0
|
|
107
|
+
) {
|
|
108
|
+
throw new Error(rejected);
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
sub: payload.sub,
|
|
112
|
+
iss: payload.iss,
|
|
113
|
+
aud: audiences,
|
|
114
|
+
exp: payload.exp,
|
|
115
|
+
iat: payload.iat,
|
|
116
|
+
...(typeof payload.email === "string" ? { email: payload.email } : {}),
|
|
117
|
+
...(typeof payload.country === "string"
|
|
118
|
+
? { country: payload.country }
|
|
119
|
+
: {}),
|
|
120
|
+
...(typeof payload.type === "string" ? { type: payload.type } : {}),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function teamIssuer(team: string) {
|
|
125
|
+
const trimmed = team.trim().replace(/\/+$/, "");
|
|
126
|
+
if (trimmed.startsWith("https://")) {
|
|
127
|
+
const url = new URL(trimmed);
|
|
128
|
+
if (url.origin !== trimmed) {
|
|
129
|
+
throw projectError(
|
|
130
|
+
"accessConfig",
|
|
131
|
+
"Cloudflare Access team must be a hostname or https origin",
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
return url.origin;
|
|
135
|
+
}
|
|
136
|
+
if (!/^[A-Za-z0-9.-]+$/.test(trimmed)) {
|
|
137
|
+
throw projectError(
|
|
138
|
+
"accessConfig",
|
|
139
|
+
"Cloudflare Access team must be a hostname or https origin",
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
return `https://${trimmed}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function audienceList(audience: string | string[]) {
|
|
146
|
+
const values = (Array.isArray(audience) ? audience : audience.split(","))
|
|
147
|
+
.map((item) => item.trim())
|
|
148
|
+
.filter((item) => item.length > 0);
|
|
149
|
+
if (values.length === 0) {
|
|
150
|
+
throw projectError("accessConfig", "Missing CF_ACCESS_AUD");
|
|
151
|
+
}
|
|
152
|
+
return values;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function jwks(issuer: string) {
|
|
156
|
+
const existing = jwksByIssuer.get(issuer);
|
|
157
|
+
if (existing) {
|
|
158
|
+
return existing;
|
|
159
|
+
}
|
|
160
|
+
const created = createRemoteJWKSet(new URL(`${issuer}/cdn-cgi/access/certs`));
|
|
161
|
+
jwksByIssuer.set(issuer, created);
|
|
162
|
+
return created;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function cookie(request: Request, name: string) {
|
|
166
|
+
const header = request.headers.get("cookie");
|
|
167
|
+
if (!header) {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
for (const part of header.split(";")) {
|
|
171
|
+
const [key, ...rest] = part.trim().split("=");
|
|
172
|
+
if (key === name) {
|
|
173
|
+
const value = rest.join("=").trim();
|
|
174
|
+
return value ? decodeURIComponent(value) : null;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function envValue(name: string) {
|
|
181
|
+
const runtime = globalThis as {
|
|
182
|
+
Bun?: { env?: Record<string, string | undefined> };
|
|
183
|
+
};
|
|
184
|
+
const value = runtime.Bun?.env?.[name] ?? process.env[name];
|
|
185
|
+
const trimmed = value?.trim();
|
|
186
|
+
return trimmed ? trimmed : null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function denied() {
|
|
190
|
+
return Response.json(frameworkError("accessDenied"), { status: 401 });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const rejected = "Cloudflare Access token was rejected";
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import type { ArgusSettings } from "../project/config.ts";
|
|
3
|
+
import { callService, ServiceCallError, setArgus } from "./call.ts";
|
|
4
|
+
|
|
5
|
+
const argus: ArgusSettings = {
|
|
6
|
+
url: "https://argus.example",
|
|
7
|
+
env: "staging",
|
|
8
|
+
username: "argus",
|
|
9
|
+
password: "secret",
|
|
10
|
+
public: null,
|
|
11
|
+
service: null,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
describe("callService", () => {
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
setArgus(null);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("discovers a healthy instance and posts the body", async () => {
|
|
20
|
+
setArgus(argus);
|
|
21
|
+
const calls: {
|
|
22
|
+
url: string;
|
|
23
|
+
method?: string;
|
|
24
|
+
authorization: string | null;
|
|
25
|
+
body?: string;
|
|
26
|
+
}[] = [];
|
|
27
|
+
const result = await callService(
|
|
28
|
+
"billing-service",
|
|
29
|
+
"v1/invoices",
|
|
30
|
+
{ amount: 10 },
|
|
31
|
+
{
|
|
32
|
+
random: () => 0.9,
|
|
33
|
+
fetchImpl: async (url, init) => {
|
|
34
|
+
calls.push({
|
|
35
|
+
url,
|
|
36
|
+
method: init?.method,
|
|
37
|
+
authorization: init?.headers?.get("authorization") ?? null,
|
|
38
|
+
body: init?.body,
|
|
39
|
+
});
|
|
40
|
+
if (url.includes("/v1/services/")) {
|
|
41
|
+
return Response.json({
|
|
42
|
+
instances: [
|
|
43
|
+
{
|
|
44
|
+
base_url: "http://10.0.0.4:3001",
|
|
45
|
+
weight: 1,
|
|
46
|
+
status: "healthy",
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
base_url: "http://10.0.0.5:3001",
|
|
50
|
+
weight: 3,
|
|
51
|
+
status: "healthy",
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
base_url: "http://10.0.0.9:3001",
|
|
55
|
+
weight: 100,
|
|
56
|
+
status: "unhealthy",
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
return Response.json({ id: "inv_1" });
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
);
|
|
65
|
+
expect(result).toEqual({ id: "inv_1" });
|
|
66
|
+
expect(calls).toEqual([
|
|
67
|
+
{
|
|
68
|
+
url: "https://argus.example/v1/services/billing-service?env=staging",
|
|
69
|
+
method: undefined,
|
|
70
|
+
authorization: `Basic ${Buffer.from("argus:secret").toString("base64")}`,
|
|
71
|
+
body: undefined,
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
url: "http://10.0.0.5:3001/v1/invoices",
|
|
75
|
+
method: "POST",
|
|
76
|
+
authorization: null,
|
|
77
|
+
body: JSON.stringify({ amount: 10 }),
|
|
78
|
+
},
|
|
79
|
+
]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("requires an active Argus config and a registered instance", async () => {
|
|
83
|
+
await expect(callService("billing", "/v1/invoices", {})).rejects.toThrow(
|
|
84
|
+
"service call requires argus",
|
|
85
|
+
);
|
|
86
|
+
setArgus(argus);
|
|
87
|
+
await expect(
|
|
88
|
+
callService(
|
|
89
|
+
"billing",
|
|
90
|
+
"/v1/invoices",
|
|
91
|
+
{},
|
|
92
|
+
{
|
|
93
|
+
fetchImpl: async () => Response.json({ instances: [] }),
|
|
94
|
+
},
|
|
95
|
+
),
|
|
96
|
+
).rejects.toBeInstanceOf(ServiceCallError);
|
|
97
|
+
});
|
|
98
|
+
});
|