@veducio/keja 0.2.3 → 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.
@@ -63,6 +63,39 @@ export type VaultConfig =
63
63
  | { enabled: false }
64
64
  | ({ enabled: true } & VaultSettings);
65
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
+
66
99
  export type KejaConfigInput = {
67
100
  name?: string;
68
101
  versioning?: string;
@@ -71,6 +104,8 @@ export type KejaConfigInput = {
71
104
  argus?: ArgusConfigInput;
72
105
  db?: DatabaseConfigInput;
73
106
  vault?: VaultConfigInput;
107
+ grants?: GrantsConfigInput;
108
+ batch?: BatchConfigInput;
74
109
  };
75
110
 
76
111
  export type KejaConfig = {
@@ -81,6 +116,8 @@ export type KejaConfig = {
81
116
  argus: ArgusConfig;
82
117
  database: DatabaseConfig;
83
118
  vault: VaultConfig;
119
+ grants: GrantsConfig;
120
+ batch: BatchConfig;
84
121
  };
85
122
 
86
123
  export function defineConfig(input: KejaConfigInput = {}) {
@@ -104,6 +141,8 @@ export function defineConfig(input: KejaConfigInput = {}) {
104
141
  argus: configArgus(input.argus),
105
142
  database: configDatabase(input.db),
106
143
  vault: configVault(input.vault),
144
+ grants: configGrants(input.grants),
145
+ batch: configBatch(input.batch),
107
146
  };
108
147
  return Object.assign(config, { [configBrand]: true as const });
109
148
  }
@@ -120,7 +159,9 @@ export function isConfig(value: unknown): value is KejaConfig {
120
159
  hasName(value) &&
121
160
  hasArgus(value) &&
122
161
  hasDatabase(value) &&
123
- hasVault(value)
162
+ hasVault(value) &&
163
+ hasGrants(value) &&
164
+ hasBatch(value)
124
165
  );
125
166
  }
126
167
 
@@ -136,6 +177,8 @@ export type LoadedConfig = ProjectPaths & {
136
177
  argus: ArgusConfig;
137
178
  database: DatabaseConfig;
138
179
  vault: VaultConfig;
180
+ grants: GrantsConfig;
181
+ batch: BatchConfig;
139
182
  };
140
183
 
141
184
  export function resolveProjectPaths(root: string, config: KejaConfig) {
@@ -192,6 +235,8 @@ function loadedConfig(
192
235
  argus: config.argus,
193
236
  database: resolvedDatabase(root, config.database),
194
237
  vault: config.vault,
238
+ grants: config.grants,
239
+ batch: config.batch,
195
240
  };
196
241
  }
197
242
 
@@ -292,6 +337,87 @@ function configVault(value: VaultConfigInput | undefined): VaultConfig {
292
337
  };
293
338
  }
294
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",
401
+ ),
402
+ };
403
+ }
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
+
295
421
  function isEnabledObject(value: unknown): value is { enabled: boolean } {
296
422
  return (
297
423
  typeof value === "object" &&
@@ -325,14 +451,51 @@ function absoluteHttpUrl(value: unknown, field: string) {
325
451
  }
326
452
 
327
453
  function vaultMount(value: string) {
328
- const mount = value.trim().replace(/^\/+|\/+$/g, "");
329
- if (!/^[A-Za-z0-9_-]+$/.test(mount)) {
454
+ return vaultName(value, "vault.mount");
455
+ }
456
+
457
+ function vaultName(value: string, field: string) {
458
+ const name = value.trim().replace(/^\/+|\/+$/g, "");
459
+ if (!/^[A-Za-z0-9_-]+$/.test(name)) {
460
+ throw projectError(
461
+ "invalidConfigFile",
462
+ `keja.config.ts ${field} must be a name`,
463
+ );
464
+ }
465
+ return name;
466
+ }
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
+
483
+ function grantMaxExp(value: number | undefined) {
484
+ if (value === undefined) {
485
+ return 900;
486
+ }
487
+ if (
488
+ typeof value !== "number" ||
489
+ !Number.isInteger(value) ||
490
+ value < 1 ||
491
+ value > 86400
492
+ ) {
330
493
  throw projectError(
331
494
  "invalidConfigFile",
332
- `keja.config.ts vault.mount must be a mount name`,
495
+ "keja.config.ts grants.max_exp must be a whole number of seconds from 1 to 86400",
333
496
  );
334
497
  }
335
- return mount;
498
+ return value;
336
499
  }
337
500
 
338
501
  function configEnv(name: string) {
@@ -398,6 +561,27 @@ function hasVault(value: object) {
398
561
  );
399
562
  }
400
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
+
401
585
  function hasDatabase(value: object) {
402
586
  const database = (value as KejaConfig).database;
403
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";
@@ -24,6 +25,8 @@ export {
24
25
  DependencyNotMet,
25
26
  frameworkErrors,
26
27
  } from "../errors/framework-errors.ts";
28
+ export type { Grant } from "../grant/grant.ts";
29
+ export { grant, verifyGrant } from "../grant/grant.ts";
27
30
  export { defineConfig } from "../project/config.ts";
28
31
  export { defineRepositories } from "../repository/define.ts";
29
32
  export { repository } from "../repository/runtime.ts";
@@ -73,6 +76,21 @@ export async function listen(
73
76
  } else {
74
77
  setVault(null);
75
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
+ }
76
94
  const mounted = mountProject(root, project, env, openApiServers);
77
95
  const identity = mounted.identity;
78
96
  if (project.argus.enabled) {
@@ -155,6 +173,7 @@ function mountProject(
155
173
  middleware: project.publicMiddleware,
156
174
  server: openApiServers.openapiServer,
157
175
  identity,
176
+ batch: project.batch.enabled,
158
177
  }),
159
178
  serviceApp: createHttpApp({
160
179
  title: "Service",
@@ -164,6 +183,7 @@ function mountProject(
164
183
  server: openApiServers.openapiServiceServer,
165
184
  identity,
166
185
  postOnly: true,
186
+ batch: project.batch.enabled,
167
187
  }),
168
188
  };
169
189
  }
@@ -62,6 +62,7 @@ export function createVault(options: VaultClientOptions = {}) {
62
62
  options.mount ?? envValue("VAULT_KV_MOUNT") ?? "secret",
63
63
  );
64
64
  const fetchImpl = options.fetch ?? fetch;
65
+ const headers = () => vaultHeaders(token, namespace);
65
66
  return {
66
67
  async secret<
67
68
  Data extends Record<string, unknown> = Record<string, unknown>,
@@ -70,7 +71,7 @@ export function createVault(options: VaultClientOptions = {}) {
70
71
  const response = await fetchImpl(
71
72
  `${address}/v1/${mount}/data/${secretPath}`,
72
73
  {
73
- headers: vaultHeaders(token, namespace),
74
+ headers: headers(),
74
75
  },
75
76
  );
76
77
  if (!response.ok) {
@@ -84,9 +85,76 @@ export function createVault(options: VaultClientOptions = {}) {
84
85
  }
85
86
  return body.data.data;
86
87
  },
88
+ transitKey(mountName: string, key: string) {
89
+ const transit = secretMount(mountName);
90
+ const name = secretMount(key);
91
+ return vaultSend<{ type?: unknown; latest_version?: unknown }>(
92
+ fetchImpl,
93
+ `${address}/v1/${transit}/keys/${name}`,
94
+ { headers: headers() },
95
+ `${transit}/keys/${name}`,
96
+ );
97
+ },
98
+ transitSign(mountName: string, key: string, body: TransitSignBody) {
99
+ const transit = secretMount(mountName);
100
+ const name = secretMount(key);
101
+ return vaultSend<{ signature?: unknown }>(
102
+ fetchImpl,
103
+ `${address}/v1/${transit}/sign/${name}`,
104
+ {
105
+ method: "POST",
106
+ headers: jsonHeaders(headers()),
107
+ body: JSON.stringify(body),
108
+ },
109
+ `${transit}/sign/${name}`,
110
+ );
111
+ },
112
+ transitVerify(mountName: string, key: string, body: TransitVerifyBody) {
113
+ const transit = secretMount(mountName);
114
+ const name = secretMount(key);
115
+ return vaultSend<{ valid?: unknown }>(
116
+ fetchImpl,
117
+ `${address}/v1/${transit}/verify/${name}`,
118
+ {
119
+ method: "POST",
120
+ headers: jsonHeaders(headers()),
121
+ body: JSON.stringify(body),
122
+ },
123
+ `${transit}/verify/${name}`,
124
+ );
125
+ },
87
126
  };
88
127
  }
89
128
 
129
+ export function openVault(feature: string) {
130
+ const settings = requireVaultSettings(feature);
131
+ return createVault({
132
+ address: settings.address,
133
+ token: settings.token,
134
+ namespace: settings.namespace ?? undefined,
135
+ mount: settings.mount,
136
+ fetch: state.fetch,
137
+ });
138
+ }
139
+
140
+ export function requireVaultSettings(feature: string) {
141
+ if (!state.active) {
142
+ throw new DependencyNotMet(feature, "vault");
143
+ }
144
+ return state.active;
145
+ }
146
+
147
+ type TransitSignBody = {
148
+ input: string;
149
+ hash_algorithm?: string;
150
+ marshaling_algorithm?: string;
151
+ signature_algorithm?: string;
152
+ };
153
+
154
+ type TransitVerifyBody = TransitSignBody & {
155
+ signature: string;
156
+ };
157
+
90
158
  export function vaultSecret<
91
159
  Data extends Record<string, unknown> = Record<string, unknown>,
92
160
  >(path: string, options?: VaultClientOptions) {
@@ -141,6 +209,29 @@ function normalizePath(path: string) {
141
209
  return parts.map((part) => encodeURIComponent(part)).join("/");
142
210
  }
143
211
 
212
+ async function vaultSend<Data>(
213
+ fetchImpl: VaultClientOptions["fetch"],
214
+ url: string,
215
+ init: RequestInit,
216
+ path: string,
217
+ ) {
218
+ const fetch = fetchImpl ?? globalThis.fetch;
219
+ const response = await fetch(url, init);
220
+ if (!response.ok) {
221
+ throw await vaultFailure(response, path);
222
+ }
223
+ const body = (await response.json()) as { data?: Data };
224
+ if (!body.data || typeof body.data !== "object") {
225
+ throw new VaultError(`Vault ${path} has no data`);
226
+ }
227
+ return body.data;
228
+ }
229
+
230
+ function jsonHeaders(headers: Headers) {
231
+ headers.set("Content-Type", "application/json");
232
+ return headers;
233
+ }
234
+
144
235
  function vaultHeaders(token: string, namespace: string | null) {
145
236
  const headers = new Headers({ "X-Vault-Token": token });
146
237
  if (namespace) {