@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.
Files changed (96) hide show
  1. package/LICENSE +7 -0
  2. package/README.md +39 -2
  3. package/examples/starter/.env.example +6 -0
  4. package/examples/starter/keja.config.ts +7 -0
  5. package/examples/starter/keja.versioning.ts +5 -0
  6. package/examples/starter/migrations/0001_init.sql +1 -0
  7. package/examples/starter/package.json +14 -0
  8. package/examples/starter/tsconfig.json +9 -0
  9. package/package.json +62 -4
  10. package/src/access/cloudflare.test.ts +131 -0
  11. package/src/access/cloudflare.ts +193 -0
  12. package/src/argus/call.test.ts +98 -0
  13. package/src/argus/call.ts +153 -0
  14. package/src/argus/heartbeat.ts +65 -0
  15. package/src/argus/register.test.ts +120 -0
  16. package/src/argus/register.ts +145 -0
  17. package/src/cli/banner.test.ts +98 -0
  18. package/src/cli/banner.ts +209 -0
  19. package/src/cli/commands/build.ts +12 -0
  20. package/src/cli/commands/dev.ts +12 -0
  21. package/src/cli/commands/generate.ts +12 -0
  22. package/src/cli/commands/init.ts +57 -0
  23. package/src/cli/commands/migrate.ts +12 -0
  24. package/src/cli/commands/seed.ts +12 -0
  25. package/src/cli/commands/start.ts +12 -0
  26. package/src/cli/dev-reload.test.ts +87 -0
  27. package/src/cli/dev-reload.ts +182 -0
  28. package/src/cli/generate-project.ts +39 -0
  29. package/src/cli/index.ts +29 -0
  30. package/src/cli/init-project.test.ts +312 -0
  31. package/src/cli/init-project.ts +416 -0
  32. package/src/cli/migrate-project.ts +31 -0
  33. package/src/cli/run-project.test.ts +50 -0
  34. package/src/cli/run-project.ts +93 -0
  35. package/src/cli/seed-project.ts +46 -0
  36. package/src/db/client.test.ts +49 -0
  37. package/src/db/client.ts +61 -0
  38. package/src/db/migrate.test.ts +141 -0
  39. package/src/db/migrate.ts +146 -0
  40. package/src/db/owned-sql.ts +202 -0
  41. package/src/db/postgres.ts +90 -0
  42. package/src/env/required-env.test.ts +64 -0
  43. package/src/env/required-env.ts +120 -0
  44. package/src/errors/framework-errors.ts +295 -0
  45. package/src/grant/grant.test.ts +130 -0
  46. package/src/grant/grant.ts +268 -0
  47. package/src/http/create-app.test.ts +340 -0
  48. package/src/http/create-app.ts +196 -0
  49. package/src/http/openapi.ts +111 -0
  50. package/src/process/slot.ts +18 -0
  51. package/src/project/config.test.ts +143 -0
  52. package/src/project/config.ts +450 -0
  53. package/src/project/load.test.ts +60 -0
  54. package/src/project/load.ts +158 -0
  55. package/src/project/reload-graph.test.ts +52 -0
  56. package/src/project/reload-graph.ts +175 -0
  57. package/src/repository/define.ts +123 -0
  58. package/src/repository/generate.test.ts +58 -0
  59. package/src/repository/generate.ts +117 -0
  60. package/src/repository/query.test.ts +99 -0
  61. package/src/repository/query.ts +287 -0
  62. package/src/repository/runtime.test.ts +154 -0
  63. package/src/repository/runtime.ts +434 -0
  64. package/src/repository/sql.test.ts +78 -0
  65. package/src/repository/sql.ts +282 -0
  66. package/src/route/define.test.ts +65 -0
  67. package/src/route/define.ts +307 -0
  68. package/src/route/middleware.test.ts +63 -0
  69. package/src/route/middleware.ts +144 -0
  70. package/src/seed/apply.test.ts +65 -0
  71. package/src/seed/apply.ts +11 -0
  72. package/src/seed/define.test.ts +51 -0
  73. package/src/seed/define.ts +55 -0
  74. package/src/seed/load.test.ts +54 -0
  75. package/src/seed/load.ts +67 -0
  76. package/src/seed/report.test.ts +16 -0
  77. package/src/seed/report.ts +15 -0
  78. package/src/self-test/checks.ts +88 -0
  79. package/src/self-test/run.test.ts +128 -0
  80. package/src/self-test/run.ts +105 -0
  81. package/src/server/identity.test.ts +57 -0
  82. package/src/server/identity.ts +116 -0
  83. package/src/server/index.test.ts +15 -0
  84. package/src/server/index.ts +171 -0
  85. package/src/server/swap.test.ts +89 -0
  86. package/src/server/swap.ts +29 -0
  87. package/src/typeid/id.test.ts +46 -0
  88. package/src/typeid/id.ts +136 -0
  89. package/src/typeid/owned.test.ts +73 -0
  90. package/src/typeid/owned.ts +193 -0
  91. package/src/vault/client.test.ts +128 -0
  92. package/src/vault/client.ts +282 -0
  93. package/src/versioning/define.test.ts +79 -0
  94. package/src/versioning/define.ts +173 -0
  95. package/src/versioning/select.test.ts +79 -0
  96. package/src/versioning/select.ts +62 -0
@@ -0,0 +1,120 @@
1
+ export type EnvSource = {
2
+ readonly [key: string]: string | undefined;
3
+ };
4
+
5
+ export type RequiredEnv = {
6
+ host: string;
7
+ httpPort: number;
8
+ httpServicePort: number;
9
+ };
10
+
11
+ type EnvRequirement<Key extends keyof RequiredEnv> = {
12
+ key: Key;
13
+ name: string;
14
+ validate(value: string | undefined): string | null;
15
+ parse(value: string): RequiredEnv[Key];
16
+ };
17
+
18
+ function portValidator(name: string) {
19
+ return (value: string | undefined): string | null => {
20
+ if (typeof value !== "string" || !/^\d+$/.test(value)) {
21
+ return `${name} must be an integer from 1 to 65535`;
22
+ }
23
+ const port = Number(value);
24
+ if (port < 1 || port > 65535) {
25
+ return `${name} must be an integer from 1 to 65535`;
26
+ }
27
+ return null;
28
+ };
29
+ }
30
+
31
+ function portParser(value: string) {
32
+ return Number(value);
33
+ }
34
+
35
+ export const requiredEnv: [
36
+ EnvRequirement<"host">,
37
+ EnvRequirement<"httpPort">,
38
+ EnvRequirement<"httpServicePort">,
39
+ ] = [
40
+ {
41
+ key: "host",
42
+ name: "HOST",
43
+ validate(value: string | undefined): string | null {
44
+ if (typeof value !== "string" || value.trim() === "") {
45
+ return "HOST is required";
46
+ }
47
+ return null;
48
+ },
49
+ parse(value: string): string {
50
+ return value;
51
+ },
52
+ },
53
+ {
54
+ key: "httpPort",
55
+ name: "HTTP_PORT",
56
+ validate: portValidator("HTTP_PORT"),
57
+ parse: portParser,
58
+ },
59
+ {
60
+ key: "httpServicePort",
61
+ name: "HTTP_SERVICE_PORT",
62
+ validate: portValidator("HTTP_SERVICE_PORT"),
63
+ parse: portParser,
64
+ },
65
+ ];
66
+
67
+ export function readRequiredEnv(env: EnvSource): RequiredEnv {
68
+ return {
69
+ host: requiredEnv[0].parse(env.HOST ?? ""),
70
+ httpPort: requiredEnv[1].parse(env.HTTP_PORT ?? ""),
71
+ httpServicePort: requiredEnv[2].parse(env.HTTP_SERVICE_PORT ?? ""),
72
+ };
73
+ }
74
+
75
+ export const openApiServerEnv = [
76
+ { key: "openapiServer", name: "OPENAPI_SERVER" },
77
+ { key: "openapiServiceServer", name: "OPENAPI_SERVICE_SERVER" },
78
+ ] as const;
79
+
80
+ export function validateOpenApiServer(
81
+ name: string,
82
+ value: string | undefined,
83
+ ): string | null {
84
+ if (typeof value !== "string" || value.trim() === "") {
85
+ return null;
86
+ }
87
+ try {
88
+ const url = new URL(value.trim());
89
+ if (url.protocol === "http:" || url.protocol === "https:") {
90
+ return null;
91
+ }
92
+ } catch {
93
+ return `${name} must be an absolute http or https URL`;
94
+ }
95
+ return `${name} must be an absolute http or https URL`;
96
+ }
97
+
98
+ export function readOpenApiServers(env: EnvSource): {
99
+ openapiServer: string | null;
100
+ openapiServiceServer: string | null;
101
+ } {
102
+ return {
103
+ openapiServer: readOpenApiServer("OPENAPI_SERVER", env.OPENAPI_SERVER),
104
+ openapiServiceServer: readOpenApiServer(
105
+ "OPENAPI_SERVICE_SERVER",
106
+ env.OPENAPI_SERVICE_SERVER,
107
+ ),
108
+ };
109
+ }
110
+
111
+ function readOpenApiServer(name: string, value: string | undefined) {
112
+ const error = validateOpenApiServer(name, value);
113
+ if (error) {
114
+ throw new Error(error);
115
+ }
116
+ if (typeof value !== "string" || value.trim() === "") {
117
+ return null;
118
+ }
119
+ return value.trim();
120
+ }
@@ -0,0 +1,295 @@
1
+ export const frameworkErrors = {
2
+ notFound: {
3
+ code: 1001,
4
+ name: "NotFound",
5
+ description: "No route matched this request.",
6
+ tip: "Check the method, path, and version token.",
7
+ },
8
+ unknownVersion: {
9
+ code: 1002,
10
+ name: "UnknownVersion",
11
+ description: "The version token does not match a published version.",
12
+ tip: "Use a version such as v1, a release date, latest, or current.",
13
+ },
14
+ invalidRequest: {
15
+ code: 1003,
16
+ name: "InvalidRequest",
17
+ description: "The request did not match the route schema.",
18
+ tip: "Compare the path parameters, query, and JSON body with the route definition.",
19
+ },
20
+ invalidResponse: {
21
+ code: 1004,
22
+ name: "InvalidResponse",
23
+ description:
24
+ "The handler result did not match the declared response schema.",
25
+ tip: "Return one of the named responses with the fields its schema requires.",
26
+ },
27
+ accessDenied: {
28
+ code: 1005,
29
+ name: "AccessDenied",
30
+ description: "Cloudflare Access did not accept this request.",
31
+ tip: "Send a valid Cf-Access-Jwt-Assertion header or CF_Authorization cookie.",
32
+ },
33
+ methodNotAllowed: {
34
+ code: 1006,
35
+ name: "MethodNotAllowed",
36
+ description: "The service server only accepts POST.",
37
+ tip: "Send the service request as POST.",
38
+ },
39
+ } as const;
40
+
41
+ export function frameworkError<Extra extends Record<string, unknown>>(
42
+ key: keyof typeof frameworkErrors,
43
+ extra?: Extra,
44
+ ) {
45
+ return { ...frameworkErrors[key], ...extra };
46
+ }
47
+
48
+ const projectScope = "Project";
49
+
50
+ export const projectErrors = {
51
+ unknownRouteVersion: {
52
+ scope: projectScope,
53
+ code: 2001,
54
+ name: "UnknownRouteVersion",
55
+ description: "A route uses a version this project does not define.",
56
+ tip: "Add the version to keja.versioning.ts, or change the route version.",
57
+ },
58
+ duplicateRoute: {
59
+ scope: projectScope,
60
+ code: 2002,
61
+ name: "DuplicateRoute",
62
+ description: "Two routes use the same method, path, and version.",
63
+ tip: "Keep one route for that method, path, and version.",
64
+ },
65
+ missingVersionFile: {
66
+ scope: projectScope,
67
+ code: 2003,
68
+ name: "MissingVersionFile",
69
+ description: "The project has no keja.versioning.ts file.",
70
+ tip: "Add keja.versioning.ts that default-exports defineVersions(...).",
71
+ },
72
+ invalidVersionFile: {
73
+ scope: projectScope,
74
+ code: 2004,
75
+ name: "InvalidVersionFile",
76
+ description: "keja.versioning.ts does not export the version catalog.",
77
+ tip: "Default-export the result of defineVersions(...).",
78
+ },
79
+ missingRouteDirectory: {
80
+ scope: projectScope,
81
+ code: 2005,
82
+ name: "MissingRouteDirectory",
83
+ description: "A route directory is missing.",
84
+ tip: "Create src/public and src/service.",
85
+ },
86
+ invalidRouteFile: {
87
+ scope: projectScope,
88
+ code: 2006,
89
+ name: "InvalidRouteFile",
90
+ description: "A route file does not export a route.",
91
+ tip: "Export defineRoute(...) by name, or as the default export.",
92
+ },
93
+ invalidMiddlewareFile: {
94
+ scope: projectScope,
95
+ code: 2007,
96
+ name: "InvalidMiddlewareFile",
97
+ description: "A server middleware file does not export middleware.",
98
+ tip: "Default-export defineMiddleware(...) or an array of them.",
99
+ },
100
+ invalidVersionId: {
101
+ scope: projectScope,
102
+ code: 2008,
103
+ name: "InvalidVersionId",
104
+ description: "A version id is not v followed by a whole number.",
105
+ tip: "Use a version such as v1, v2, or v10.",
106
+ },
107
+ invalidReleaseDate: {
108
+ scope: projectScope,
109
+ code: 2009,
110
+ name: "InvalidReleaseDate",
111
+ description: "A version has a release date Keja cannot read.",
112
+ tip: "Use YYYY-MM-DD, DD-MM-YYYY, or a unix timestamp.",
113
+ },
114
+ invalidStatusCode: {
115
+ scope: projectScope,
116
+ code: 2010,
117
+ name: "InvalidStatusCode",
118
+ description: "A response status code is outside the HTTP range.",
119
+ tip: "Use an integer from 100 to 599.",
120
+ },
121
+ invalidRouteMiddleware: {
122
+ scope: projectScope,
123
+ code: 2011,
124
+ name: "InvalidRouteMiddleware",
125
+ description: "A route lists middleware Keja did not create.",
126
+ tip: "Pass functions created with defineMiddleware(...).",
127
+ },
128
+ invalidRoute: {
129
+ scope: projectScope,
130
+ code: 2030,
131
+ name: "InvalidRoute",
132
+ description:
133
+ "A route definition has a method, path, or schema Keja cannot use.",
134
+ tip: "Use get, post, put, patch, or delete, a path that starts with /, and Zod schemas.",
135
+ },
136
+ invalidConfigFile: {
137
+ scope: projectScope,
138
+ code: 2012,
139
+ name: "InvalidConfigFile",
140
+ description: "keja.config.ts does not export a Keja config.",
141
+ tip: "Default-export the result of defineConfig(...).",
142
+ },
143
+ databaseUrl: {
144
+ scope: projectScope,
145
+ code: 2013,
146
+ name: "DatabaseUrl",
147
+ description:
148
+ "The database is enabled and DATABASE_URL is missing or not a postgres URL.",
149
+ tip: "Set DATABASE_URL to a postgres connection string, or set db.enabled to false.",
150
+ },
151
+ missingMigrations: {
152
+ scope: projectScope,
153
+ code: 2014,
154
+ name: "MissingMigrations",
155
+ description: "The migrations folder is missing.",
156
+ tip: "Create the folder and add files named 0001_name.sql.",
157
+ },
158
+ invalidMigrationFile: {
159
+ scope: projectScope,
160
+ code: 2015,
161
+ name: "InvalidMigrationFile",
162
+ description: "A migration file does not use the 0001_name.sql form.",
163
+ tip: "Rename the file to 0001_name.sql.",
164
+ },
165
+ duplicateMigration: {
166
+ scope: projectScope,
167
+ code: 2016,
168
+ name: "DuplicateMigration",
169
+ description: "Two migration files use the same number.",
170
+ tip: "Keep one file for each migration number.",
171
+ },
172
+ migrationChanged: {
173
+ scope: projectScope,
174
+ code: 2017,
175
+ name: "MigrationChanged",
176
+ description: "An applied migration file changed.",
177
+ tip: "Add a new migration instead of editing an applied file.",
178
+ },
179
+ migrationFailed: {
180
+ scope: projectScope,
181
+ code: 2018,
182
+ name: "MigrationFailed",
183
+ description: "Postgres rejected a migration.",
184
+ tip: "Fix the SQL in that migration file.",
185
+ },
186
+ ownedObjects: {
187
+ scope: projectScope,
188
+ code: 2021,
189
+ name: "OwnedObjects",
190
+ description: "A table does not match the OwnedObjects shape.",
191
+ tip: "Add _id, _object, _tenant, _archived, _createdAt, and _updatedAt with the Keja checks.",
192
+ },
193
+ serviceMethod: {
194
+ scope: projectScope,
195
+ code: 2020,
196
+ name: "ServiceMethod",
197
+ description: "A service route uses a method other than POST.",
198
+ tip: "Change the service route method to post.",
199
+ },
200
+ databaseInit: {
201
+ scope: projectScope,
202
+ code: 2022,
203
+ name: "DatabaseInit",
204
+ description: "The database is enabled and Postgres could not be reached.",
205
+ tip: "Check DATABASE_URL and that Postgres is running.",
206
+ },
207
+ argusInit: {
208
+ scope: projectScope,
209
+ code: 2023,
210
+ name: "ArgusInit",
211
+ description: "Argus is enabled and the first registration failed.",
212
+ tip: "Check the Argus URL, credentials, and catalog service names.",
213
+ },
214
+ vaultInit: {
215
+ scope: projectScope,
216
+ code: 2024,
217
+ name: "VaultInit",
218
+ description: "Vault is enabled and the token lookup failed.",
219
+ tip: "Check the Vault address, token, and namespace.",
220
+ },
221
+ dependencyNotMet: {
222
+ scope: projectScope,
223
+ code: 2025,
224
+ name: "DependencyNotMet",
225
+ description: "A feature was used while a feature it requires is disabled.",
226
+ tip: "Enable the required feature in keja.config.ts.",
227
+ },
228
+ invalidSeeder: {
229
+ scope: projectScope,
230
+ code: 2026,
231
+ name: "InvalidSeeder",
232
+ description: "A seeder does not describe a table and OwnedObjects rows.",
233
+ tip: "Pass a table name and objects with _id, _object, _tenant, _archived, _createdAt, and _updatedAt.",
234
+ },
235
+ invalidSeederFile: {
236
+ scope: projectScope,
237
+ code: 2027,
238
+ name: "InvalidSeederFile",
239
+ description: "A seed file does not export a seeder.",
240
+ tip: "Export defineSeeder(...) by name, or as the default export.",
241
+ },
242
+ invalidRepositorySql: {
243
+ scope: projectScope,
244
+ code: 2028,
245
+ name: "InvalidRepositorySql",
246
+ description: "A migration cannot be read as a repository table.",
247
+ tip: "Use CREATE TABLE or ALTER TABLE ... ADD COLUMN with a mapped column type and the OwnedObjects columns.",
248
+ },
249
+ invalidRepositoryFile: {
250
+ scope: projectScope,
251
+ code: 2029,
252
+ name: "InvalidRepositoryFile",
253
+ description: "keja.repositories.ts does not export repository flags.",
254
+ tip: "Default-export defineRepositories(...), and name tables that the migrations create.",
255
+ },
256
+ accessConfig: {
257
+ scope: projectScope,
258
+ code: 2019,
259
+ name: "AccessConfig",
260
+ description:
261
+ "Cloudflare Access is missing its team domain or audience tag.",
262
+ tip: "Set CF_ACCESS_TEAM_DOMAIN and CF_ACCESS_AUD, or pass team and audience to cloudflareAccess().",
263
+ },
264
+ } as const;
265
+
266
+ export type ProjectErrorEntry =
267
+ (typeof projectErrors)[keyof typeof projectErrors];
268
+
269
+ export class ProjectError extends Error {
270
+ readonly scope: string;
271
+ readonly code: number;
272
+ readonly errorName: string;
273
+ readonly description: string;
274
+ readonly tip: string;
275
+
276
+ constructor(entry: ProjectErrorEntry, detail: string) {
277
+ super(detail);
278
+ this.scope = entry.scope;
279
+ this.code = entry.code;
280
+ this.errorName = entry.name;
281
+ this.description = entry.description;
282
+ this.tip = entry.tip;
283
+ }
284
+ }
285
+
286
+ export function projectError(key: keyof typeof projectErrors, detail: string) {
287
+ return new ProjectError(projectErrors[key], detail);
288
+ }
289
+
290
+ export class DependencyNotMet extends ProjectError {
291
+ constructor(feature: string, requires: string) {
292
+ super(projectErrors.dependencyNotMet, `${feature} requires ${requires}`);
293
+ this.name = "DependencyNotMet";
294
+ }
295
+ }
@@ -0,0 +1,130 @@
1
+ import { decodeJwt, decodeProtectedHeader } from "jose";
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import { connectVault, setVault, VaultError } from "../vault/client.ts";
4
+ import { grant, verifyGrant } from "./grant.ts";
5
+
6
+ const settings = {
7
+ address: "https://vault.example",
8
+ token: "s.token",
9
+ namespace: "apps" as string | null,
10
+ mount: "secret",
11
+ transit_key: "keja-grants" as string | null,
12
+ transit_mount: "transit",
13
+ grant_max_exp: 900,
14
+ };
15
+
16
+ afterEach(() => {
17
+ setVault(null);
18
+ });
19
+
20
+ describe("grant", () => {
21
+ it("signs a subject, action, expiry, and context with the transit key", async () => {
22
+ const calls: { url: string; body: unknown }[] = [];
23
+ await connect(calls, { type: "ecdsa-p256", valid: true });
24
+ const now = 1_700_000_000_000;
25
+ const clock = Date.now;
26
+ Date.now = () => now;
27
+ try {
28
+ const token = await grant({
29
+ subject: "tenant_01h455vb4pex5vsknk084sn02q",
30
+ action: "posts.read",
31
+ exp: 60,
32
+ context: { postId: "posts_01h455vb4pex5vsknk084sn02q" },
33
+ });
34
+ expect(decodeProtectedHeader(token)).toEqual({
35
+ alg: "ES256",
36
+ typ: "JWT",
37
+ kid: "1",
38
+ });
39
+ expect(decodeJwt(token)).toMatchObject({
40
+ sub: "tenant_01h455vb4pex5vsknk084sn02q",
41
+ action: "posts.read",
42
+ iat: 1_700_000_000,
43
+ exp: 1_700_000_060,
44
+ context: { postId: "posts_01h455vb4pex5vsknk084sn02q" },
45
+ });
46
+ const sign = calls.find((call) => call.url.endsWith("/sign/keja-grants"));
47
+ expect(sign?.body).toMatchObject({
48
+ hash_algorithm: "sha2-256",
49
+ marshaling_algorithm: "jws",
50
+ });
51
+ await expect(
52
+ verifyGrant(token, { action: "posts.read" }),
53
+ ).resolves.toEqual({
54
+ subject: "tenant_01h455vb4pex5vsknk084sn02q",
55
+ action: "posts.read",
56
+ context: { postId: "posts_01h455vb4pex5vsknk084sn02q" },
57
+ });
58
+ Date.now = () => now + 120_000;
59
+ await expect(
60
+ verifyGrant(token, { action: "posts.read" }),
61
+ ).rejects.toThrow("grant is expired");
62
+ Date.now = () => now;
63
+ await expect(
64
+ verifyGrant(token, { action: "posts.write" }),
65
+ ).rejects.toThrow("grant action does not match");
66
+ } finally {
67
+ Date.now = clock;
68
+ }
69
+ });
70
+
71
+ it("rejects a lifetime past the configured maximum", async () => {
72
+ await connect([], { type: "rsa-2048", valid: true });
73
+ await expect(
74
+ grant({ subject: "tenant", action: "posts.read", exp: 901 }),
75
+ ).rejects.toThrow("grant exp must be at most 900 seconds");
76
+ });
77
+
78
+ it("rejects a signature Vault does not accept", async () => {
79
+ await connect([], { type: "rsa-2048", valid: false });
80
+ const now = Date.now;
81
+ Date.now = () => 1_700_000_000_000;
82
+ try {
83
+ const token = await grant({
84
+ subject: "tenant",
85
+ action: "posts.read",
86
+ exp: 60,
87
+ });
88
+ await expect(
89
+ verifyGrant(token, { action: "posts.read" }),
90
+ ).rejects.toThrow("grant signature was rejected");
91
+ } finally {
92
+ Date.now = now;
93
+ }
94
+ });
95
+
96
+ it("requires vault and a transit key", async () => {
97
+ await expect(
98
+ grant({ subject: "tenant", action: "posts.read", exp: 60 }),
99
+ ).rejects.toThrow("grant requires vault");
100
+ await connect([], { type: "rsa-2048", valid: true });
101
+ setVault({ ...settings, transit_key: null });
102
+ await expect(
103
+ grant({ subject: "tenant", action: "posts.read", exp: 60 }),
104
+ ).rejects.toBeInstanceOf(VaultError);
105
+ });
106
+ });
107
+
108
+ async function connect(
109
+ calls: { url: string; body: unknown }[],
110
+ result: { type: string; valid: boolean },
111
+ ) {
112
+ await connectVault(settings, async (url, init) => {
113
+ const body = init?.body ? JSON.parse(String(init.body)) : null;
114
+ calls.push({ url, body });
115
+ if (url.endsWith("/v1/auth/token/lookup-self")) {
116
+ return Response.json({ data: { id: "s.token" } });
117
+ }
118
+ if (url.endsWith("/keys/keja-grants")) {
119
+ return Response.json({ data: { type: result.type, latest_version: 1 } });
120
+ }
121
+ if (url.endsWith("/sign/keja-grants")) {
122
+ return Response.json({
123
+ data: {
124
+ signature: `vault:v1:${Buffer.from("signed").toString("base64")}`,
125
+ },
126
+ });
127
+ }
128
+ return Response.json({ data: { valid: result.valid } });
129
+ });
130
+ }