@veducio/keja 0.3.0 → 0.4.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/package.json +1 -1
- package/src/grant/grant.test.ts +116 -57
- package/src/grant/grant.ts +47 -26
- package/src/grant/settings.ts +67 -0
- package/src/http/batch.ts +307 -0
- package/src/http/create-app.test.ts +141 -0
- package/src/http/create-app.ts +25 -0
- package/src/http/openapi.ts +94 -0
- package/src/project/config.test.ts +39 -3
- package/src/project/config.ts +163 -16
- package/src/project/load.ts +6 -0
- package/src/route/define.ts +14 -0
- package/src/server/index.ts +19 -1
- package/src/vault/client.test.ts +0 -6
|
@@ -100,12 +100,46 @@ describe("defineConfig", () => {
|
|
|
100
100
|
token: "s.token",
|
|
101
101
|
namespace: "apps",
|
|
102
102
|
mount: "kv",
|
|
103
|
-
transit_key: null,
|
|
104
|
-
transit_mount: "transit",
|
|
105
|
-
grant_max_exp: 900,
|
|
106
103
|
});
|
|
107
104
|
expect(defineConfig().vault).toEqual({ enabled: false });
|
|
108
105
|
});
|
|
106
|
+
|
|
107
|
+
it("reads grants and batch settings from config", () => {
|
|
108
|
+
expect(
|
|
109
|
+
defineConfig({
|
|
110
|
+
grants: {
|
|
111
|
+
enabled: true,
|
|
112
|
+
mode: "transit",
|
|
113
|
+
transit_key: "keja-grants",
|
|
114
|
+
},
|
|
115
|
+
}).grants,
|
|
116
|
+
).toEqual({
|
|
117
|
+
enabled: true,
|
|
118
|
+
mode: "transit",
|
|
119
|
+
max_exp: 900,
|
|
120
|
+
transit_key: "keja-grants",
|
|
121
|
+
transit_mount: "transit",
|
|
122
|
+
});
|
|
123
|
+
expect(
|
|
124
|
+
defineConfig({
|
|
125
|
+
grants: {
|
|
126
|
+
enabled: true,
|
|
127
|
+
mode: "secret",
|
|
128
|
+
secret: "test-secret",
|
|
129
|
+
},
|
|
130
|
+
}).grants,
|
|
131
|
+
).toEqual({
|
|
132
|
+
enabled: true,
|
|
133
|
+
mode: "secret",
|
|
134
|
+
max_exp: 900,
|
|
135
|
+
secret: "test-secret",
|
|
136
|
+
secret_vault: null,
|
|
137
|
+
secret_field: "secret",
|
|
138
|
+
});
|
|
139
|
+
expect(defineConfig({ batch: false }).batch).toEqual({ enabled: false });
|
|
140
|
+
expect(defineConfig().grants).toEqual({ enabled: false });
|
|
141
|
+
expect(defineConfig().batch).toEqual({ enabled: true });
|
|
142
|
+
});
|
|
109
143
|
});
|
|
110
144
|
|
|
111
145
|
describe("loadConfig", () => {
|
|
@@ -117,6 +151,8 @@ describe("loadConfig", () => {
|
|
|
117
151
|
argus: { enabled: false },
|
|
118
152
|
database: { enabled: false },
|
|
119
153
|
vault: { enabled: false },
|
|
154
|
+
grants: { enabled: false },
|
|
155
|
+
batch: { enabled: true },
|
|
120
156
|
versioning: path.resolve(root, "keja.versioning.ts"),
|
|
121
157
|
public: path.resolve(root, "src/public"),
|
|
122
158
|
service: path.resolve(root, "src/service"),
|
package/src/project/config.ts
CHANGED
|
@@ -49,9 +49,6 @@ export type VaultSettings = {
|
|
|
49
49
|
token: string;
|
|
50
50
|
namespace: string | null;
|
|
51
51
|
mount: string;
|
|
52
|
-
transit_key: string | null;
|
|
53
|
-
transit_mount: string;
|
|
54
|
-
grant_max_exp: number;
|
|
55
52
|
};
|
|
56
53
|
|
|
57
54
|
export type VaultConfigInput = {
|
|
@@ -60,15 +57,45 @@ export type VaultConfigInput = {
|
|
|
60
57
|
token?: string;
|
|
61
58
|
namespace?: string;
|
|
62
59
|
mount?: string;
|
|
63
|
-
transit_key?: string;
|
|
64
|
-
transit_mount?: string;
|
|
65
|
-
grant_max_exp?: number;
|
|
66
60
|
};
|
|
67
61
|
|
|
68
62
|
export type VaultConfig =
|
|
69
63
|
| { enabled: false }
|
|
70
64
|
| ({ enabled: true } & VaultSettings);
|
|
71
65
|
|
|
66
|
+
export type GrantsConfigInput = {
|
|
67
|
+
enabled: boolean;
|
|
68
|
+
mode?: "transit" | "secret";
|
|
69
|
+
max_exp?: number;
|
|
70
|
+
transit_key?: string;
|
|
71
|
+
transit_mount?: string;
|
|
72
|
+
secret?: string;
|
|
73
|
+
secret_vault?: string;
|
|
74
|
+
secret_field?: string;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export type GrantsConfig =
|
|
78
|
+
| { enabled: false }
|
|
79
|
+
| {
|
|
80
|
+
enabled: true;
|
|
81
|
+
mode: "transit";
|
|
82
|
+
max_exp: number;
|
|
83
|
+
transit_key: string;
|
|
84
|
+
transit_mount: string;
|
|
85
|
+
}
|
|
86
|
+
| {
|
|
87
|
+
enabled: true;
|
|
88
|
+
mode: "secret";
|
|
89
|
+
max_exp: number;
|
|
90
|
+
secret: string | null;
|
|
91
|
+
secret_vault: string | null;
|
|
92
|
+
secret_field: string;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export type BatchConfigInput = boolean | { enabled: boolean };
|
|
96
|
+
|
|
97
|
+
export type BatchConfig = { enabled: boolean };
|
|
98
|
+
|
|
72
99
|
export type KejaConfigInput = {
|
|
73
100
|
name?: string;
|
|
74
101
|
versioning?: string;
|
|
@@ -77,6 +104,8 @@ export type KejaConfigInput = {
|
|
|
77
104
|
argus?: ArgusConfigInput;
|
|
78
105
|
db?: DatabaseConfigInput;
|
|
79
106
|
vault?: VaultConfigInput;
|
|
107
|
+
grants?: GrantsConfigInput;
|
|
108
|
+
batch?: BatchConfigInput;
|
|
80
109
|
};
|
|
81
110
|
|
|
82
111
|
export type KejaConfig = {
|
|
@@ -87,6 +116,8 @@ export type KejaConfig = {
|
|
|
87
116
|
argus: ArgusConfig;
|
|
88
117
|
database: DatabaseConfig;
|
|
89
118
|
vault: VaultConfig;
|
|
119
|
+
grants: GrantsConfig;
|
|
120
|
+
batch: BatchConfig;
|
|
90
121
|
};
|
|
91
122
|
|
|
92
123
|
export function defineConfig(input: KejaConfigInput = {}) {
|
|
@@ -110,6 +141,8 @@ export function defineConfig(input: KejaConfigInput = {}) {
|
|
|
110
141
|
argus: configArgus(input.argus),
|
|
111
142
|
database: configDatabase(input.db),
|
|
112
143
|
vault: configVault(input.vault),
|
|
144
|
+
grants: configGrants(input.grants),
|
|
145
|
+
batch: configBatch(input.batch),
|
|
113
146
|
};
|
|
114
147
|
return Object.assign(config, { [configBrand]: true as const });
|
|
115
148
|
}
|
|
@@ -126,7 +159,9 @@ export function isConfig(value: unknown): value is KejaConfig {
|
|
|
126
159
|
hasName(value) &&
|
|
127
160
|
hasArgus(value) &&
|
|
128
161
|
hasDatabase(value) &&
|
|
129
|
-
hasVault(value)
|
|
162
|
+
hasVault(value) &&
|
|
163
|
+
hasGrants(value) &&
|
|
164
|
+
hasBatch(value)
|
|
130
165
|
);
|
|
131
166
|
}
|
|
132
167
|
|
|
@@ -142,6 +177,8 @@ export type LoadedConfig = ProjectPaths & {
|
|
|
142
177
|
argus: ArgusConfig;
|
|
143
178
|
database: DatabaseConfig;
|
|
144
179
|
vault: VaultConfig;
|
|
180
|
+
grants: GrantsConfig;
|
|
181
|
+
batch: BatchConfig;
|
|
145
182
|
};
|
|
146
183
|
|
|
147
184
|
export function resolveProjectPaths(root: string, config: KejaConfig) {
|
|
@@ -198,6 +235,8 @@ function loadedConfig(
|
|
|
198
235
|
argus: config.argus,
|
|
199
236
|
database: resolvedDatabase(root, config.database),
|
|
200
237
|
vault: config.vault,
|
|
238
|
+
grants: config.grants,
|
|
239
|
+
batch: config.batch,
|
|
201
240
|
};
|
|
202
241
|
}
|
|
203
242
|
|
|
@@ -295,18 +334,90 @@ function configVault(value: VaultConfigInput | undefined): VaultConfig {
|
|
|
295
334
|
),
|
|
296
335
|
namespace: optionalText(namespace, "vault.namespace", "namespace"),
|
|
297
336
|
mount: vaultMount(value.mount ?? configEnv("VAULT_KV_MOUNT") ?? "secret"),
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function configGrants(value: GrantsConfigInput | undefined): GrantsConfig {
|
|
341
|
+
if (value === undefined) {
|
|
342
|
+
return { enabled: false };
|
|
343
|
+
}
|
|
344
|
+
if (!isEnabledObject(value)) {
|
|
345
|
+
throw projectError(
|
|
346
|
+
"invalidConfigFile",
|
|
347
|
+
"keja.config.ts grants must be an object with enabled",
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
if (!value.enabled) {
|
|
351
|
+
return { enabled: false };
|
|
352
|
+
}
|
|
353
|
+
const mode = value.mode ?? "transit";
|
|
354
|
+
if (mode !== "transit" && mode !== "secret") {
|
|
355
|
+
throw projectError(
|
|
356
|
+
"invalidConfigFile",
|
|
357
|
+
"keja.config.ts grants.mode must be transit or secret",
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
const max_exp = grantMaxExp(value.max_exp);
|
|
361
|
+
if (mode === "transit") {
|
|
362
|
+
return {
|
|
363
|
+
enabled: true,
|
|
364
|
+
mode: "transit",
|
|
365
|
+
max_exp,
|
|
366
|
+
transit_key: vaultName(
|
|
367
|
+
requiredText(value.transit_key, "grants.transit_key", "key name"),
|
|
368
|
+
"grants.transit_key",
|
|
369
|
+
),
|
|
370
|
+
transit_mount: vaultName(
|
|
371
|
+
value.transit_mount ?? "transit",
|
|
372
|
+
"grants.transit_mount",
|
|
373
|
+
),
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
const secret =
|
|
377
|
+
value.secret === undefined
|
|
378
|
+
? (configEnv("GRANT_SECRET") ?? null)
|
|
379
|
+
: requiredText(value.secret, "grants.secret", "secret");
|
|
380
|
+
const secret_vault =
|
|
381
|
+
value.secret_vault === undefined
|
|
382
|
+
? null
|
|
383
|
+
: secretVaultPath(
|
|
384
|
+
requiredText(value.secret_vault, "grants.secret_vault", "path"),
|
|
385
|
+
);
|
|
386
|
+
if (secret === null && secret_vault === null) {
|
|
387
|
+
throw projectError(
|
|
388
|
+
"invalidConfigFile",
|
|
389
|
+
"keja.config.ts grants.secret, GRANT_SECRET, or grants.secret_vault is required",
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
return {
|
|
393
|
+
enabled: true,
|
|
394
|
+
mode: "secret",
|
|
395
|
+
max_exp,
|
|
396
|
+
secret,
|
|
397
|
+
secret_vault,
|
|
398
|
+
secret_field: vaultName(
|
|
399
|
+
value.secret_field ?? "secret",
|
|
400
|
+
"grants.secret_field",
|
|
305
401
|
),
|
|
306
|
-
grant_max_exp: grantMaxExp(value.grant_max_exp),
|
|
307
402
|
};
|
|
308
403
|
}
|
|
309
404
|
|
|
405
|
+
function configBatch(value: BatchConfigInput | undefined): BatchConfig {
|
|
406
|
+
if (value === undefined) {
|
|
407
|
+
return { enabled: true };
|
|
408
|
+
}
|
|
409
|
+
if (typeof value === "boolean") {
|
|
410
|
+
return { enabled: value };
|
|
411
|
+
}
|
|
412
|
+
if (!isEnabledObject(value)) {
|
|
413
|
+
throw projectError(
|
|
414
|
+
"invalidConfigFile",
|
|
415
|
+
"keja.config.ts batch must be a boolean or an object with enabled",
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
return { enabled: value.enabled };
|
|
419
|
+
}
|
|
420
|
+
|
|
310
421
|
function isEnabledObject(value: unknown): value is { enabled: boolean } {
|
|
311
422
|
return (
|
|
312
423
|
typeof value === "object" &&
|
|
@@ -354,6 +465,21 @@ function vaultName(value: string, field: string) {
|
|
|
354
465
|
return name;
|
|
355
466
|
}
|
|
356
467
|
|
|
468
|
+
function secretVaultPath(value: string) {
|
|
469
|
+
const cleaned = value.trim().replace(/^\/+|\/+$/g, "");
|
|
470
|
+
const parts = cleaned.split("/");
|
|
471
|
+
if (
|
|
472
|
+
!cleaned ||
|
|
473
|
+
parts.some((part) => part.length === 0 || part === "." || part === "..")
|
|
474
|
+
) {
|
|
475
|
+
throw projectError(
|
|
476
|
+
"invalidConfigFile",
|
|
477
|
+
"keja.config.ts grants.secret_vault must be a Vault secret path",
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
return cleaned;
|
|
481
|
+
}
|
|
482
|
+
|
|
357
483
|
function grantMaxExp(value: number | undefined) {
|
|
358
484
|
if (value === undefined) {
|
|
359
485
|
return 900;
|
|
@@ -366,7 +492,7 @@ function grantMaxExp(value: number | undefined) {
|
|
|
366
492
|
) {
|
|
367
493
|
throw projectError(
|
|
368
494
|
"invalidConfigFile",
|
|
369
|
-
"keja.config.ts
|
|
495
|
+
"keja.config.ts grants.max_exp must be a whole number of seconds from 1 to 86400",
|
|
370
496
|
);
|
|
371
497
|
}
|
|
372
498
|
return value;
|
|
@@ -435,6 +561,27 @@ function hasVault(value: object) {
|
|
|
435
561
|
);
|
|
436
562
|
}
|
|
437
563
|
|
|
564
|
+
function hasGrants(value: object) {
|
|
565
|
+
const grants = (value as KejaConfig).grants;
|
|
566
|
+
return (
|
|
567
|
+
typeof grants === "object" &&
|
|
568
|
+
grants !== null &&
|
|
569
|
+
(grants.enabled === false ||
|
|
570
|
+
(grants.enabled === true &&
|
|
571
|
+
(grants.mode === "transit" || grants.mode === "secret") &&
|
|
572
|
+
typeof grants.max_exp === "number"))
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function hasBatch(value: object) {
|
|
577
|
+
const batch = (value as KejaConfig).batch;
|
|
578
|
+
return (
|
|
579
|
+
typeof batch === "object" &&
|
|
580
|
+
batch !== null &&
|
|
581
|
+
typeof batch.enabled === "boolean"
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
|
|
438
585
|
function hasDatabase(value: object) {
|
|
439
586
|
const database = (value as KejaConfig).database;
|
|
440
587
|
return (
|
package/src/project/load.ts
CHANGED
|
@@ -11,7 +11,9 @@ import {
|
|
|
11
11
|
import { isVersions, type VersionDefinition } from "../versioning/define.ts";
|
|
12
12
|
import {
|
|
13
13
|
type ArgusConfig,
|
|
14
|
+
type BatchConfig,
|
|
14
15
|
type DatabaseConfig,
|
|
16
|
+
type GrantsConfig,
|
|
15
17
|
loadConfig,
|
|
16
18
|
type ProjectPaths,
|
|
17
19
|
type VaultConfig,
|
|
@@ -23,6 +25,8 @@ export type LoadedProject = {
|
|
|
23
25
|
name: string | null;
|
|
24
26
|
argus: ArgusConfig;
|
|
25
27
|
vault: VaultConfig;
|
|
28
|
+
grants: GrantsConfig;
|
|
29
|
+
batch: BatchConfig;
|
|
26
30
|
database: DatabaseConfig;
|
|
27
31
|
versions: VersionDefinition[];
|
|
28
32
|
publicRoutes: RouteDefinition[];
|
|
@@ -58,6 +62,8 @@ export async function loadProject(
|
|
|
58
62
|
name: loaded.name,
|
|
59
63
|
argus: loaded.argus,
|
|
60
64
|
vault: loaded.vault,
|
|
65
|
+
grants: loaded.grants,
|
|
66
|
+
batch: loaded.batch,
|
|
61
67
|
database: loaded.database,
|
|
62
68
|
versions: versioning.default.versions,
|
|
63
69
|
publicRoutes: await loadRoutes(publicDirectory, generation),
|
package/src/route/define.ts
CHANGED
|
@@ -78,6 +78,7 @@ export type RouteDefinition = {
|
|
|
78
78
|
method: HttpMethod;
|
|
79
79
|
path: string;
|
|
80
80
|
version: string;
|
|
81
|
+
name: string | null;
|
|
81
82
|
request: RequestSchemas;
|
|
82
83
|
responses: RouteResponse[];
|
|
83
84
|
middleware: readonly MiddlewareDefinition<object>[];
|
|
@@ -124,6 +125,7 @@ export function defineRoute<
|
|
|
124
125
|
>(definition: {
|
|
125
126
|
method: HttpMethod;
|
|
126
127
|
path: string;
|
|
128
|
+
name?: string;
|
|
127
129
|
version: VersionId<Version> extends never
|
|
128
130
|
? "Version must use the form v1, v2, v3"
|
|
129
131
|
: Version;
|
|
@@ -150,6 +152,17 @@ export function defineRoute<
|
|
|
150
152
|
`Path ${String(definition.path)} must start with /`,
|
|
151
153
|
);
|
|
152
154
|
}
|
|
155
|
+
const name =
|
|
156
|
+
definition.name === undefined
|
|
157
|
+
? null
|
|
158
|
+
: typeof definition.name === "string" && definition.name.trim() !== ""
|
|
159
|
+
? definition.name.trim()
|
|
160
|
+
: (() => {
|
|
161
|
+
throw projectError(
|
|
162
|
+
"invalidRoute",
|
|
163
|
+
`${definition.method.toUpperCase()} ${definition.path} name must be a non-empty string`,
|
|
164
|
+
);
|
|
165
|
+
})();
|
|
153
166
|
const request = definition.request as RequestSchemas | undefined;
|
|
154
167
|
if (request) {
|
|
155
168
|
for (const key of ["params", "query", "body"] as const) {
|
|
@@ -229,6 +242,7 @@ export function defineRoute<
|
|
|
229
242
|
method: definition.method,
|
|
230
243
|
path: definition.path,
|
|
231
244
|
version: definition.version,
|
|
245
|
+
name,
|
|
232
246
|
request: definition.request ?? {},
|
|
233
247
|
responses,
|
|
234
248
|
middleware,
|
package/src/server/index.ts
CHANGED
|
@@ -4,7 +4,8 @@ import { startArgusHeartbeat } from "../argus/heartbeat.ts";
|
|
|
4
4
|
import { argusRegistrations, publishArgus } from "../argus/register.ts";
|
|
5
5
|
import { connectDatabase } from "../db/postgres.ts";
|
|
6
6
|
import { readOpenApiServers, readRequiredEnv } from "../env/required-env.ts";
|
|
7
|
-
import { projectError } from "../errors/framework-errors.ts";
|
|
7
|
+
import { DependencyNotMet, projectError } from "../errors/framework-errors.ts";
|
|
8
|
+
import { connectGrants, setGrants } from "../grant/settings.ts";
|
|
8
9
|
import { createHttpApp } from "../http/create-app.ts";
|
|
9
10
|
import { type LoadedProject, loadProject } from "../project/load.ts";
|
|
10
11
|
import { formatSelfTest, runSelfTest } from "../self-test/run.ts";
|
|
@@ -75,6 +76,21 @@ export async function listen(
|
|
|
75
76
|
} else {
|
|
76
77
|
setVault(null);
|
|
77
78
|
}
|
|
79
|
+
if (project.grants.enabled) {
|
|
80
|
+
if (project.grants.mode === "transit" && !project.vault.enabled) {
|
|
81
|
+
throw new DependencyNotMet("grant transit", "vault");
|
|
82
|
+
}
|
|
83
|
+
if (
|
|
84
|
+
project.grants.mode === "secret" &&
|
|
85
|
+
project.grants.secret_vault &&
|
|
86
|
+
!project.vault.enabled
|
|
87
|
+
) {
|
|
88
|
+
throw new DependencyNotMet("grant secret_vault", "vault");
|
|
89
|
+
}
|
|
90
|
+
await connectGrants(project.grants);
|
|
91
|
+
} else {
|
|
92
|
+
setGrants(null);
|
|
93
|
+
}
|
|
78
94
|
const mounted = mountProject(root, project, env, openApiServers);
|
|
79
95
|
const identity = mounted.identity;
|
|
80
96
|
if (project.argus.enabled) {
|
|
@@ -157,6 +173,7 @@ function mountProject(
|
|
|
157
173
|
middleware: project.publicMiddleware,
|
|
158
174
|
server: openApiServers.openapiServer,
|
|
159
175
|
identity,
|
|
176
|
+
batch: project.batch.enabled,
|
|
160
177
|
}),
|
|
161
178
|
serviceApp: createHttpApp({
|
|
162
179
|
title: "Service",
|
|
@@ -166,6 +183,7 @@ function mountProject(
|
|
|
166
183
|
server: openApiServers.openapiServiceServer,
|
|
167
184
|
identity,
|
|
168
185
|
postOnly: true,
|
|
186
|
+
batch: project.batch.enabled,
|
|
169
187
|
}),
|
|
170
188
|
};
|
|
171
189
|
}
|
package/src/vault/client.test.ts
CHANGED
|
@@ -85,9 +85,6 @@ describe("createVault", () => {
|
|
|
85
85
|
token: "s.token",
|
|
86
86
|
namespace: null,
|
|
87
87
|
mount: "secret",
|
|
88
|
-
transit_key: null,
|
|
89
|
-
transit_mount: "transit",
|
|
90
|
-
grant_max_exp: 900,
|
|
91
88
|
},
|
|
92
89
|
async (url) => {
|
|
93
90
|
urls.push(url);
|
|
@@ -111,9 +108,6 @@ describe("createVault", () => {
|
|
|
111
108
|
token: "s.token",
|
|
112
109
|
namespace: null,
|
|
113
110
|
mount: "secret",
|
|
114
|
-
transit_key: null,
|
|
115
|
-
transit_mount: "transit",
|
|
116
|
-
grant_max_exp: 900,
|
|
117
111
|
},
|
|
118
112
|
async () =>
|
|
119
113
|
Response.json({ errors: ["permission denied"] }, { status: 403 }),
|