@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.
@@ -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"),
@@ -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
- transit_key:
299
- value.transit_key === undefined
300
- ? null
301
- : vaultName(value.transit_key, "vault.transit_key"),
302
- transit_mount: vaultName(
303
- value.transit_mount ?? "transit",
304
- "vault.transit_mount",
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 vault.grant_max_exp must be a whole number of seconds from 1 to 86400",
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 (
@@ -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),
@@ -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,
@@ -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
  }
@@ -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 }),